Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66cfc7344e | ||
|
|
a4933b5614 | ||
|
|
e70e27296b | ||
|
|
361ef9236e | ||
|
|
450bb42c46 | ||
|
|
77152b3119 | ||
|
|
e9464f9e68 | ||
|
|
c8c9638e5a | ||
|
|
bd0ca0cded | ||
|
|
96781dbb0f | ||
|
|
19132d15b8 | ||
|
|
6befc6e564 | ||
|
|
441e5fc054 | ||
|
|
43633f2f28 | ||
|
|
3a9f01b9eb | ||
|
|
5e83da9ca1 | ||
|
|
aec4162c22 | ||
|
|
44542fdd6b | ||
|
|
5ad6e99327 | ||
|
|
30018d64a2 | ||
|
|
1c0c1586cb | ||
|
|
524033411e | ||
|
|
3b40158d9a | ||
|
|
4d9115d51e | ||
|
|
146a500f9f | ||
|
|
7d7415b235 | ||
|
|
7aea820cfa | ||
|
|
69b4f155fc | ||
|
|
8f492e4c6c | ||
|
|
8533eba07d | ||
|
|
edf0ab8175 | ||
|
|
45da70cf6a | ||
|
|
2e816656e5 | ||
|
|
6826ce0465 | ||
|
|
c4b5c737f3 | ||
|
|
89a1c0b534 | ||
|
|
74507559b8 | ||
|
|
3ce84f78f1 | ||
|
|
5ba1eeaf4c | ||
|
|
c14d20c2e0 | ||
|
|
f155a13929 | ||
|
|
485caaf2ec | ||
|
|
b44dd28ac0 | ||
|
|
1b0e31027e | ||
|
|
1efb067274 | ||
|
|
b2533b94ce | ||
|
|
441385fc95 | ||
|
|
5f1939cdeb | ||
|
|
9d694ab006 | ||
|
|
48e97477ed | ||
|
|
33dd5fe8cc | ||
|
|
aed2f2dd47 | ||
|
|
eb84f165f4 | ||
|
|
572644f7f9 | ||
|
|
ddbd9dfdc8 | ||
|
|
09578c9a7d | ||
|
|
e5ddfd322d | ||
|
|
87d6544b72 |
@@ -8,7 +8,6 @@ on:
|
||||
|
||||
env:
|
||||
CURRENT_BRANCH: ${{ github.ref_name }}
|
||||
SOURCE_BRANCH: ${{ vars.SYNC_SOURCE_BRANCH_NAME }} # The sync branch such as "sync/ce"
|
||||
TARGET_BRANCH: ${{ vars.SYNC_TARGET_BRANCH_NAME }} # The target branch that you would like to merge changes like develop
|
||||
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} # Personal access token required to modify contents and workflows
|
||||
REVIEWER: ${{ vars.SYNC_PR_REVIEWER }}
|
||||
@@ -16,22 +15,7 @@ env:
|
||||
ACCOUNT_USER_EMAIL: ${{ vars.ACCOUNT_USER_EMAIL }}
|
||||
|
||||
jobs:
|
||||
Check_Branch:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
BRANCH_MATCH: ${{ steps.check-branch.outputs.MATCH }}
|
||||
steps:
|
||||
- name: Check if current branch matches the secret
|
||||
id: check-branch
|
||||
run: |
|
||||
if [ "$CURRENT_BRANCH" = "$SOURCE_BRANCH" ]; then
|
||||
echo "MATCH=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "MATCH=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
Create_PR:
|
||||
if: ${{ needs.Check_Branch.outputs.BRANCH_MATCH == 'true' }}
|
||||
needs: [Check_Branch]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
@@ -59,11 +43,11 @@ jobs:
|
||||
- name: Create PR to Target Branch
|
||||
run: |
|
||||
# get all pull requests and check if there is already a PR
|
||||
PR_EXISTS=$(gh pr list --base $TARGET_BRANCH --head $SOURCE_BRANCH --state open --json number | jq '.[] | .number')
|
||||
PR_EXISTS=$(gh pr list --base $TARGET_BRANCH --head $CURRENT_BRANCH --state open --json number | jq '.[] | .number')
|
||||
if [ -n "$PR_EXISTS" ]; then
|
||||
echo "Pull Request already exists: $PR_EXISTS"
|
||||
else
|
||||
echo "Creating new pull request"
|
||||
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $SOURCE_BRANCH --title "sync: community changes" --body "")
|
||||
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $CURRENT_BRANCH --title "sync: community changes" --body "")
|
||||
echo "Pull Request created: $PR_URL"
|
||||
fi
|
||||
|
||||
@@ -35,8 +35,9 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
run: |
|
||||
RUN_ID="${{ github.run_id }}"
|
||||
TARGET_REPO="${{ vars.SYNC_TARGET_REPO }}"
|
||||
TARGET_BRANCH="${{ vars.SYNC_TARGET_BRANCH_NAME }}"
|
||||
TARGET_BRANCH="sync/${RUN_ID}"
|
||||
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
|
||||
|
||||
git checkout $SOURCE_BRANCH
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
import { FC, useEffect, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { HelpSection, SidebarMenu, SidebarDropdown } from "@/components/admin-sidebar";
|
||||
import { useTheme } from "@/hooks/store";
|
||||
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
|
||||
// plane helpers
|
||||
import { useOutsideClickDetector } from "@plane/helpers";
|
||||
// components
|
||||
import { HelpSection, SidebarMenu, SidebarDropdown } from "@/components/admin-sidebar";
|
||||
// hooks
|
||||
import { useTheme } from "@/hooks/store";
|
||||
|
||||
export interface IInstanceSidebar {}
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
const useOutsideClickDetector = (ref: React.RefObject<HTMLElement>, callback: () => void) => {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClick);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export default useOutsideClickDetector;
|
||||
Vendored
+1
-1
@@ -2,4 +2,4 @@
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
|
||||
+4
-3
@@ -12,9 +12,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.7.19",
|
||||
"@plane/constants": "*",
|
||||
"@plane/helpers": "*",
|
||||
"@plane/types": "*",
|
||||
"@plane/ui": "*",
|
||||
"@plane/constants": "*",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/lodash": "^4.17.0",
|
||||
"autoprefixer": "10.4.14",
|
||||
@@ -24,7 +25,7 @@
|
||||
"lucide-react": "^0.356.0",
|
||||
"mobx": "^6.12.0",
|
||||
"mobx-react": "^9.1.1",
|
||||
"next": "^14.2.3",
|
||||
"next": "^14.2.12",
|
||||
"next-themes": "^0.2.1",
|
||||
"postcss": "^8.4.38",
|
||||
"react": "^18.3.1",
|
||||
@@ -45,6 +46,6 @@
|
||||
"eslint-config-custom": "*",
|
||||
"tailwind-config-custom": "*",
|
||||
"tsconfig": "*",
|
||||
"typescript": "^5.4.2"
|
||||
"typescript": "5.4.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,7 +302,7 @@ class ProjectAPIEndpoint(BaseAPIView):
|
||||
serializer.save()
|
||||
if serializer.data["inbox_view"]:
|
||||
Inbox.objects.get_or_create(
|
||||
name=f"{project.name} Inbox",
|
||||
defaults={"name": f"{project.name} Inbox"},
|
||||
project=project,
|
||||
is_default=True,
|
||||
)
|
||||
|
||||
@@ -176,6 +176,7 @@ class UserAdminLiteSerializer(BaseSerializer):
|
||||
"is_bot",
|
||||
"display_name",
|
||||
"email",
|
||||
"last_login_medium",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
|
||||
@@ -167,9 +167,10 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def list(self, request, slug, project_id):
|
||||
inbox_id = Inbox.objects.get(
|
||||
inbox_id = Inbox.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
).first()
|
||||
project = Project.objects.get(pk=project_id)
|
||||
filters = issue_filters(request.GET, "GET", "issue__")
|
||||
inbox_issue = (
|
||||
InboxIssue.objects.filter(
|
||||
@@ -199,13 +200,16 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
if inbox_status:
|
||||
inbox_issue = inbox_issue.filter(status__in=inbox_status)
|
||||
|
||||
if ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
member=request.user,
|
||||
role=5,
|
||||
is_active=True,
|
||||
).exists():
|
||||
if (
|
||||
ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
member=request.user,
|
||||
role=5,
|
||||
is_active=True,
|
||||
).exists()
|
||||
and not project.guest_view_all_features
|
||||
):
|
||||
inbox_issue = inbox_issue.filter(created_by=request.user)
|
||||
return self.paginate(
|
||||
request=request,
|
||||
@@ -517,14 +521,16 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
allowed_roles=[
|
||||
ROLE.ADMIN,
|
||||
ROLE.MEMBER,
|
||||
ROLE.GUEST,
|
||||
],
|
||||
creator=True,
|
||||
model=Issue,
|
||||
)
|
||||
def retrieve(self, request, slug, project_id, pk):
|
||||
inbox_id = Inbox.objects.get(
|
||||
inbox_id = Inbox.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
).first()
|
||||
project = Project.objects.get(pk=project_id)
|
||||
inbox_issue = (
|
||||
InboxIssue.objects.select_related("issue")
|
||||
.prefetch_related(
|
||||
@@ -551,6 +557,21 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
)
|
||||
.get(inbox_id=inbox_id.id, issue_id=pk, project_id=project_id)
|
||||
)
|
||||
if (
|
||||
ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
member=request.user,
|
||||
role=5,
|
||||
is_active=True,
|
||||
).exists()
|
||||
and not project.guest_view_all_features
|
||||
and not inbox_issue.created_by == request.user
|
||||
):
|
||||
return Response(
|
||||
{"error": "You are not allowed to view this issue"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
issue = InboxIssueDetailSerializer(inbox_issue).data
|
||||
return Response(
|
||||
issue,
|
||||
|
||||
@@ -21,6 +21,8 @@ from plane.db.models import (
|
||||
IssueComment,
|
||||
ProjectMember,
|
||||
CommentReaction,
|
||||
Project,
|
||||
Issue,
|
||||
)
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
|
||||
@@ -67,9 +69,27 @@ class IssueCommentViewSet(BaseViewSet):
|
||||
[
|
||||
ROLE.ADMIN,
|
||||
ROLE.MEMBER,
|
||||
ROLE.GUEST,
|
||||
]
|
||||
)
|
||||
def create(self, request, slug, project_id, issue_id):
|
||||
project = Project.objects.get(pk=project_id)
|
||||
issue = Issue.objects.get(pk=issue_id)
|
||||
if (
|
||||
ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
member=request.user,
|
||||
role=5,
|
||||
is_active=True,
|
||||
).exists()
|
||||
and not project.guest_view_all_features
|
||||
and not issue.created_by == request.user
|
||||
):
|
||||
return Response(
|
||||
{"error": "You are not allowed to comment on the issue"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
serializer = IssueCommentSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(
|
||||
@@ -94,7 +114,7 @@ class IssueCommentViewSet(BaseViewSet):
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER],
|
||||
allowed_roles=[ROLE.ADMIN],
|
||||
creator=True,
|
||||
model=IssueComment,
|
||||
)
|
||||
@@ -182,6 +202,7 @@ class CommentReactionViewSet(BaseViewSet):
|
||||
[
|
||||
ROLE.ADMIN,
|
||||
ROLE.MEMBER,
|
||||
ROLE.GUEST,
|
||||
]
|
||||
)
|
||||
def create(self, request, slug, project_id, comment_id):
|
||||
@@ -210,6 +231,7 @@ class CommentReactionViewSet(BaseViewSet):
|
||||
[
|
||||
ROLE.ADMIN,
|
||||
ROLE.MEMBER,
|
||||
ROLE.GUEST,
|
||||
]
|
||||
)
|
||||
def destroy(self, request, slug, project_id, comment_id, reaction_code):
|
||||
|
||||
@@ -12,7 +12,7 @@ from rest_framework import status
|
||||
# Module imports
|
||||
from .. import BaseViewSet
|
||||
from plane.app.serializers import IssueReactionSerializer
|
||||
from plane.app.permissions import ProjectLitePermission
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.db.models import IssueReaction
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
|
||||
@@ -20,9 +20,6 @@ from plane.bgtasks.issue_activities_task import issue_activity
|
||||
class IssueReactionViewSet(BaseViewSet):
|
||||
serializer_class = IssueReactionSerializer
|
||||
model = IssueReaction
|
||||
permission_classes = [
|
||||
ProjectLitePermission,
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
@@ -40,6 +37,7 @@ class IssueReactionViewSet(BaseViewSet):
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def create(self, request, slug, project_id, issue_id):
|
||||
serializer = IssueReactionSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
@@ -62,6 +60,7 @@ class IssueReactionViewSet(BaseViewSet):
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def destroy(self, request, slug, project_id, issue_id, reaction_code):
|
||||
issue_reaction = IssueReaction.objects.get(
|
||||
workspace__slug=slug,
|
||||
|
||||
@@ -173,7 +173,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
role=10,
|
||||
role=15,
|
||||
).exists():
|
||||
projects = projects.filter(
|
||||
Q(
|
||||
@@ -439,7 +439,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
serializer.save()
|
||||
if serializer.data["inbox_view"]:
|
||||
Inbox.objects.get_or_create(
|
||||
name=f"{project.name} Inbox",
|
||||
defaults={"name": f"{project.name} Inbox"},
|
||||
project=project,
|
||||
is_default=True,
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ from rest_framework.permissions import AllowAny
|
||||
from .base import BaseViewSet, BaseAPIView
|
||||
from plane.app.serializers import ProjectMemberInviteSerializer
|
||||
|
||||
from plane.app.permissions import ProjectBasePermission
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
|
||||
from plane.db.models import (
|
||||
ProjectMember,
|
||||
@@ -35,10 +35,6 @@ class ProjectInvitationsViewset(BaseViewSet):
|
||||
|
||||
search_fields = []
|
||||
|
||||
permission_classes = [
|
||||
ProjectBasePermission,
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
@@ -49,6 +45,7 @@ class ProjectInvitationsViewset(BaseViewSet):
|
||||
.select_related("workspace", "workspace__owner")
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN])
|
||||
def create(self, request, slug, project_id):
|
||||
emails = request.data.get("emails", [])
|
||||
|
||||
@@ -59,24 +56,21 @@ class ProjectInvitationsViewset(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
requesting_user = ProjectMember.objects.get(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
member_id=request.user.id,
|
||||
)
|
||||
for email in emails:
|
||||
workspace_role = WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member__email=email.get("email"),
|
||||
is_active=True,
|
||||
).role
|
||||
|
||||
# Check if any invited user has an higher role
|
||||
if len(
|
||||
[
|
||||
email
|
||||
for email in emails
|
||||
if int(email.get("role", 5)) > requesting_user.role
|
||||
]
|
||||
):
|
||||
return Response(
|
||||
{"error": "You cannot invite a user with higher role"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if workspace_role in [5, 20] and workspace_role != email.get(
|
||||
"role", 5
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot invite a user with different role than workspace role"
|
||||
},
|
||||
)
|
||||
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
@@ -170,7 +164,7 @@ class UserProjectInvitationsViewset(BaseViewSet):
|
||||
ProjectMember(
|
||||
project_id=project_id,
|
||||
member=request.user,
|
||||
role=15 if workspace_role >= 15 else 5,
|
||||
role=workspace_role,
|
||||
workspace=workspace,
|
||||
created_by=request.user,
|
||||
)
|
||||
|
||||
@@ -414,6 +414,7 @@ class UserProjectRolesEndpoint(BaseAPIView):
|
||||
project_members = ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member_id=request.user.id,
|
||||
is_active=True,
|
||||
).values("project_id", "role")
|
||||
|
||||
project_members = {
|
||||
|
||||
@@ -14,7 +14,7 @@ from plane.app.permissions import (
|
||||
WorkSpaceAdminPermission,
|
||||
WorkspaceEntityPermission,
|
||||
allow_permission,
|
||||
ROLE
|
||||
ROLE,
|
||||
)
|
||||
|
||||
# Module imports
|
||||
@@ -44,7 +44,6 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
serializer_class = WorkspaceMemberAdminSerializer
|
||||
model = WorkspaceMember
|
||||
|
||||
|
||||
search_fields = [
|
||||
"member__display_name",
|
||||
"member__first_name",
|
||||
@@ -96,9 +95,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
user=False,
|
||||
multiple=True,
|
||||
)
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN], level="WORKSPACE"
|
||||
)
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
||||
def partial_update(self, request, slug, pk):
|
||||
workspace_member = WorkspaceMember.objects.get(
|
||||
pk=pk,
|
||||
@@ -112,25 +109,10 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get the requested user role
|
||||
requested_workspace_member = WorkspaceMember.objects.get(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
)
|
||||
# Check if role is being updated
|
||||
# One cannot update role higher than his own role
|
||||
if (
|
||||
"role" in request.data
|
||||
and int(request.data.get("role", workspace_member.role))
|
||||
> requested_workspace_member.role
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot update a role that is higher than your own role"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if workspace_member.role > int(request.data.get("role")):
|
||||
_ = ProjectMember.objects.filter(
|
||||
workspace__slug=slug, member_id=workspace_member.member_id
|
||||
).update(role=int(request.data.get("role")))
|
||||
|
||||
serializer = WorkSpaceMemberSerializer(
|
||||
workspace_member, data=request.data, partial=True
|
||||
@@ -151,9 +133,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
@invalidate_cache(
|
||||
path="/api/users/me/workspaces/", user=False, multiple=True
|
||||
)
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN], level="WORKSPACE"
|
||||
)
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
||||
def destroy(self, request, slug, pk):
|
||||
# Check the user role who is deleting the user
|
||||
workspace_member = WorkspaceMember.objects.get(
|
||||
|
||||
@@ -265,7 +265,7 @@ if AMQP_URL:
|
||||
CELERY_BROKER_URL = AMQP_URL
|
||||
else:
|
||||
CELERY_BROKER_URL = f"amqp://{RABBITMQ_USER}:{RABBITMQ_PASSWORD}@{RABBITMQ_HOST}:{RABBITMQ_PORT}/{RABBITMQ_VHOST}"
|
||||
|
||||
|
||||
CELERY_TIMEZONE = TIME_ZONE
|
||||
CELERY_TASK_SERIALIZER = "json"
|
||||
CELERY_RESULT_SERIALIZER = "json"
|
||||
@@ -336,14 +336,14 @@ SESSION_COOKIE_SECURE = secure_origins
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_ENGINE = "plane.db.models.session"
|
||||
SESSION_COOKIE_AGE = os.environ.get("SESSION_COOKIE_AGE", 604800)
|
||||
SESSION_COOKIE_NAME = "plane-session-id"
|
||||
SESSION_COOKIE_NAME = os.environ.get("SESSION_COOKIE_NAME", "session-id")
|
||||
SESSION_COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", None)
|
||||
SESSION_SAVE_EVERY_REQUEST = (
|
||||
os.environ.get("SESSION_SAVE_EVERY_REQUEST", "0") == "1"
|
||||
)
|
||||
|
||||
# Admin Cookie
|
||||
ADMIN_SESSION_COOKIE_NAME = "plane-admin-session-id"
|
||||
ADMIN_SESSION_COOKIE_NAME = "admin-session-id"
|
||||
ADMIN_SESSION_COOKIE_AGE = os.environ.get("ADMIN_SESSION_COOKIE_AGE", 3600)
|
||||
|
||||
# CSRF cookies
|
||||
|
||||
+3
-1
@@ -3,11 +3,13 @@ RUN apk add --no-cache libc6-compat
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
COPY . .
|
||||
RUN yarn global add turbo
|
||||
RUN yarn install
|
||||
EXPOSE 3003
|
||||
|
||||
ENV TURBO_TELEMETRY_DISABLED 1
|
||||
|
||||
VOLUME [ "/app/node_modules", "/app/live/node_modules"]
|
||||
|
||||
CMD ["yarn","dev", "--filter=live"]
|
||||
|
||||
@@ -28,6 +28,8 @@ RUN yarn install
|
||||
COPY --from=builder /app/out/full/ .
|
||||
COPY turbo.json turbo.json
|
||||
|
||||
ENV TURBO_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN yarn turbo build --filter=live
|
||||
|
||||
FROM base AS runner
|
||||
@@ -36,4 +38,6 @@ WORKDIR /app
|
||||
COPY --from=installer /app .
|
||||
# COPY --from=installer /app/live/node_modules ./node_modules
|
||||
|
||||
ENV TURBO_TELEMETRY_DISABLED 1
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
+1
-1
@@ -54,6 +54,6 @@
|
||||
"nodemon": "^3.1.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsup": "^7.2.0",
|
||||
"typescript": "^5.4.5"
|
||||
"typescript": "5.4.5"
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -14,7 +14,8 @@
|
||||
"packages/tsconfig",
|
||||
"packages/ui",
|
||||
"packages/types",
|
||||
"packages/constants"
|
||||
"packages/constants",
|
||||
"packages/helpers"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
"tailwind-config-custom": "*",
|
||||
"tsconfig": "*",
|
||||
"tsup": "^7.2.0",
|
||||
"typescript": "4.9.5"
|
||||
"typescript": "5.4.5"
|
||||
},
|
||||
"keywords": [
|
||||
"editor",
|
||||
|
||||
@@ -4,20 +4,17 @@ import { SlashCommand } from "@/extensions";
|
||||
// plane editor types
|
||||
import { TIssueEmbedConfig } from "@/plane-editor/types";
|
||||
// types
|
||||
import { TExtensions, TFileHandler, TUserDetails } from "@/types";
|
||||
import { TExtensions, TUserDetails } from "@/types";
|
||||
|
||||
type Props = {
|
||||
disabledExtensions?: TExtensions[];
|
||||
fileHandler: TFileHandler;
|
||||
issueEmbedConfig: TIssueEmbedConfig | undefined;
|
||||
provider: HocuspocusProvider;
|
||||
userDetails: TUserDetails;
|
||||
};
|
||||
|
||||
export const DocumentEditorAdditionalExtensions = (props: Props) => {
|
||||
const { fileHandler } = props;
|
||||
|
||||
const extensions: Extensions = [SlashCommand(fileHandler.upload)];
|
||||
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
|
||||
const extensions: Extensions = [SlashCommand()];
|
||||
|
||||
return extensions;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { FC, ReactNode } from "react";
|
||||
import { Editor, EditorContent } from "@tiptap/react";
|
||||
// extensions
|
||||
import { ImageResizer } from "@/extensions/image";
|
||||
|
||||
interface EditorContentProps {
|
||||
children?: ReactNode;
|
||||
@@ -16,7 +14,6 @@ export const EditorContentWrapper: FC<EditorContentProps> = (props) => {
|
||||
return (
|
||||
<div tabIndex={tabIndex} onFocus={() => editor?.chain().focus(undefined, { scrollIntoView: false }).run()}>
|
||||
<EditorContent editor={editor} />
|
||||
{editor?.isActive("image") && editor?.isEditable && <ImageResizer editor={editor} id={id} />}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,10 +8,10 @@ import { SideMenuExtension, SlashCommand } from "@/extensions";
|
||||
import { EditorRefApi, IRichTextEditor } from "@/types";
|
||||
|
||||
const RichTextEditor = (props: IRichTextEditor) => {
|
||||
const { dragDropEnabled, fileHandler } = props;
|
||||
const { dragDropEnabled } = props;
|
||||
|
||||
const getExtensions = useCallback(() => {
|
||||
const extensions = [SlashCommand(fileHandler.upload)];
|
||||
const extensions = [SlashCommand()];
|
||||
|
||||
extensions.push(
|
||||
SideMenuExtension({
|
||||
@@ -21,7 +21,7 @@ const RichTextEditor = (props: IRichTextEditor) => {
|
||||
);
|
||||
|
||||
return extensions;
|
||||
}, [dragDropEnabled, fileHandler.upload]);
|
||||
}, [dragDropEnabled]);
|
||||
|
||||
return (
|
||||
<EditorWrapper {...props} extensions={getExtensions()}>
|
||||
|
||||
@@ -101,7 +101,8 @@ export const BlockMenu = (props: BlockMenuProps) => {
|
||||
icon: Copy,
|
||||
key: "duplicate",
|
||||
label: "Duplicate",
|
||||
isDisabled: editor.state.selection.content().content.firstChild?.type.name === "image",
|
||||
isDisabled:
|
||||
editor.state.selection.content().content.firstChild?.type.name === "image" || editor.isActive("imageComponent"),
|
||||
onClick: (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
} from "lucide-react";
|
||||
// helpers
|
||||
import {
|
||||
insertImageCommand,
|
||||
insertTableCommand,
|
||||
setText,
|
||||
toggleBlockquote,
|
||||
@@ -43,7 +42,7 @@ import {
|
||||
toggleUnderline,
|
||||
} from "@/helpers/editor-commands";
|
||||
// types
|
||||
import { TEditorCommands, UploadImage } from "@/types";
|
||||
import { TEditorCommands } from "@/types";
|
||||
|
||||
export interface EditorMenuItem {
|
||||
key: TEditorCommands;
|
||||
@@ -189,16 +188,17 @@ export const TableItem = (editor: Editor): EditorMenuItem => ({
|
||||
icon: TableIcon,
|
||||
});
|
||||
|
||||
export const ImageItem = (editor: Editor, uploadFile: UploadImage) =>
|
||||
export const ImageItem = (editor: Editor) =>
|
||||
({
|
||||
key: "image",
|
||||
name: "Image",
|
||||
isActive: () => editor?.isActive("image"),
|
||||
command: (savedSelection: Selection | null) => insertImageCommand(editor, uploadFile, savedSelection),
|
||||
command: (savedSelection: Selection | null) =>
|
||||
editor?.commands.setImageUpload({ event: "insert", pos: savedSelection?.from }),
|
||||
icon: ImageIcon,
|
||||
}) as const;
|
||||
|
||||
export function getEditorMenuItems(editor: Editor | null, uploadFile: UploadImage) {
|
||||
export function getEditorMenuItems(editor: Editor | null) {
|
||||
if (!editor) {
|
||||
return [];
|
||||
}
|
||||
@@ -220,6 +220,6 @@ export function getEditorMenuItems(editor: Editor | null, uploadFile: UploadImag
|
||||
NumberedListItem(editor),
|
||||
QuoteItem(editor),
|
||||
TableItem(editor),
|
||||
ImageItem(editor, uploadFile),
|
||||
ImageItem(editor),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CustomCodeInlineExtension } from "./code-inline";
|
||||
import { CustomLinkExtension } from "./custom-link";
|
||||
import { CustomHorizontalRule } from "./horizontal-rule";
|
||||
import { ImageExtensionWithoutProps } from "./image";
|
||||
import { CustomImageComponentWithoutProps } from "./image/image-component-without-props";
|
||||
import { IssueWidgetWithoutProps } from "./issue-embed/issue-embed-without-props";
|
||||
import { CustomMentionWithoutProps } from "./mentions/mentions-without-props";
|
||||
import { CustomQuoteExtension } from "./quote";
|
||||
@@ -61,6 +62,7 @@ export const CoreEditorExtensionsWithoutProps = [
|
||||
class: "rounded-md",
|
||||
},
|
||||
}),
|
||||
CustomImageComponentWithoutProps(),
|
||||
TiptapUnderline,
|
||||
TextStyle,
|
||||
TaskList.configure({
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react";
|
||||
import { NodeSelection } from "@tiptap/pm/state";
|
||||
// extensions
|
||||
import { CustomImageNodeViewProps } from "@/extensions/custom-image";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
|
||||
const MIN_SIZE = 100;
|
||||
|
||||
export const CustomImageBlock: React.FC<CustomImageNodeViewProps> = (props) => {
|
||||
const { node, updateAttributes, selected, getPos, editor } = props;
|
||||
const { src, width, height } = node.attrs;
|
||||
|
||||
const [size, setSize] = useState({ width: width || "35%", height: height || "auto" });
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [initialResizeComplete, setInitialResizeComplete] = useState(false);
|
||||
const isShimmerVisible = isLoading || !initialResizeComplete;
|
||||
const [editorContainer, setEditorContainer] = useState<HTMLElement | null>(null);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const containerRect = useRef<DOMRect | null>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const isResizing = useRef(false);
|
||||
const aspectRatioRef = useRef<number | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (imageRef.current) {
|
||||
const img = imageRef.current;
|
||||
img.onload = () => {
|
||||
const closestEditorContainer = img.closest(".editor-container");
|
||||
if (!closestEditorContainer) {
|
||||
console.error("Editor container not found");
|
||||
return;
|
||||
}
|
||||
|
||||
setEditorContainer(closestEditorContainer as HTMLElement);
|
||||
|
||||
if (width === "35%") {
|
||||
const editorWidth = closestEditorContainer.clientWidth;
|
||||
const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE);
|
||||
const aspectRatio = img.naturalWidth / img.naturalHeight;
|
||||
const initialHeight = initialWidth / aspectRatio;
|
||||
|
||||
const newSize = {
|
||||
width: `${Math.round(initialWidth)}px`,
|
||||
height: `${Math.round(initialHeight)}px`,
|
||||
};
|
||||
|
||||
setSize(newSize);
|
||||
updateAttributes(newSize);
|
||||
}
|
||||
setInitialResizeComplete(true);
|
||||
setIsLoading(false);
|
||||
};
|
||||
}
|
||||
}, [width, height, updateAttributes]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setSize({ width, height });
|
||||
}, [width, height]);
|
||||
|
||||
const handleResizeStart = useCallback(
|
||||
(e: React.MouseEvent | React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isResizing.current = true;
|
||||
if (containerRef.current && editorContainer) {
|
||||
aspectRatioRef.current = Number(size.width.replace("px", "")) / Number(size.height.replace("px", ""));
|
||||
containerRect.current = containerRef.current.getBoundingClientRect();
|
||||
}
|
||||
},
|
||||
[size, editorContainer]
|
||||
);
|
||||
|
||||
const handleResize = useCallback(
|
||||
(e: MouseEvent | TouchEvent) => {
|
||||
if (!isResizing.current || !containerRef.current || !containerRect.current) return;
|
||||
|
||||
if (size) {
|
||||
aspectRatioRef.current = Number(size.width.replace("px", "")) / Number(size.height.replace("px", ""));
|
||||
}
|
||||
|
||||
if (!aspectRatioRef.current) return;
|
||||
|
||||
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
|
||||
|
||||
const newWidth = Math.max(clientX - containerRect.current.left, MIN_SIZE);
|
||||
const newHeight = newWidth / aspectRatioRef.current;
|
||||
|
||||
setSize({ width: `${newWidth}px`, height: `${newHeight}px` });
|
||||
},
|
||||
[size]
|
||||
);
|
||||
|
||||
const handleResizeEnd = useCallback(() => {
|
||||
if (isResizing.current) {
|
||||
isResizing.current = false;
|
||||
updateAttributes(size);
|
||||
}
|
||||
}, [size, updateAttributes]);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const pos = getPos();
|
||||
const nodeSelection = NodeSelection.create(editor.state.doc, pos);
|
||||
editor.view.dispatch(editor.state.tr.setSelection(nodeSelection));
|
||||
},
|
||||
[editor, getPos]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorContainer) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => handleResize(e);
|
||||
const handleMouseUp = () => handleResizeEnd();
|
||||
const handleMouseLeave = () => handleResizeEnd();
|
||||
|
||||
editorContainer.addEventListener("mousemove", handleMouseMove);
|
||||
editorContainer.addEventListener("mouseup", handleMouseUp);
|
||||
editorContainer.addEventListener("mouseleave", handleMouseLeave);
|
||||
|
||||
return () => {
|
||||
editorContainer.removeEventListener("mousemove", handleMouseMove);
|
||||
editorContainer.removeEventListener("mouseup", handleMouseUp);
|
||||
editorContainer.removeEventListener("mouseleave", handleMouseLeave);
|
||||
};
|
||||
}, [handleResize, handleResizeEnd, editorContainer]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="group/image-component relative inline-block max-w-full"
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
}}
|
||||
>
|
||||
{isShimmerVisible && (
|
||||
<div className="animate-pulse bg-custom-background-80 rounded-md" style={{ width, height }} />
|
||||
)}
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={src}
|
||||
width={size.width}
|
||||
height={size.height}
|
||||
className={cn("block rounded-md", {
|
||||
hidden: isShimmerVisible,
|
||||
"read-only-image": !editor.isEditable,
|
||||
})}
|
||||
style={{
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
}}
|
||||
/>
|
||||
{editor.isEditable && selected && <div className="absolute inset-0 size-full bg-custom-primary-500/30" />}
|
||||
{editor.isEditable && (
|
||||
<>
|
||||
<div className="opacity-0 group-hover/image-component:opacity-100 absolute inset-0 border-2 border-custom-primary-100 pointer-events-none rounded-md transition-opacity duration-100 ease-in-out" />
|
||||
<div
|
||||
className="opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto absolute bottom-0 right-0 translate-y-1/2 translate-x-1/2 size-4 rounded-full bg-custom-primary-100 border-2 border-white cursor-nwse-resize transition-opacity duration-100 ease-in-out"
|
||||
onMouseDown={handleResizeStart}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Node as ProsemirrorNode } from "@tiptap/pm/model";
|
||||
import { Editor, NodeViewWrapper } from "@tiptap/react";
|
||||
// extensions
|
||||
import {
|
||||
CustomImageBlock,
|
||||
CustomImageUploader,
|
||||
UploadEntity,
|
||||
UploadImageExtensionStorage,
|
||||
} from "@/extensions/custom-image";
|
||||
|
||||
export type CustomImageNodeViewProps = {
|
||||
getPos: () => number;
|
||||
editor: Editor;
|
||||
node: ProsemirrorNode & {
|
||||
attrs: {
|
||||
src: string;
|
||||
width: string;
|
||||
height: string;
|
||||
};
|
||||
};
|
||||
updateAttributes: (attrs: Record<string, any>) => void;
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
export const CustomImageNode = (props: CustomImageNodeViewProps) => {
|
||||
const { getPos, editor, node, updateAttributes, selected } = props;
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const hasTriggeredFilePickerRef = useRef(false);
|
||||
const [isUploaded, setIsUploaded] = useState(!!node.attrs.src);
|
||||
|
||||
const id = node.attrs.id as string;
|
||||
const editorStorage = editor.storage.imageComponent as UploadImageExtensionStorage | undefined;
|
||||
|
||||
const getUploadEntity = useCallback(
|
||||
(): UploadEntity | undefined => editorStorage?.fileMap.get(id),
|
||||
[editorStorage, id]
|
||||
);
|
||||
|
||||
const onUpload = useCallback(
|
||||
(url: string) => {
|
||||
if (url) {
|
||||
setIsUploaded(true);
|
||||
// Update the node view's src attribute
|
||||
updateAttributes({ src: url });
|
||||
editorStorage?.fileMap.delete(id);
|
||||
}
|
||||
},
|
||||
[editorStorage?.fileMap, id, updateAttributes]
|
||||
);
|
||||
|
||||
const uploadFile = useCallback(
|
||||
async (file: File) => {
|
||||
try {
|
||||
// @ts-expect-error - TODO: fix typings, and don't remove await from
|
||||
// here for now
|
||||
const url: string = await editor?.commands.uploadImage(file);
|
||||
|
||||
if (!url) {
|
||||
throw new Error("Something went wrong while uploading the image");
|
||||
}
|
||||
onUpload(url);
|
||||
} catch (error) {
|
||||
console.error("Error uploading file:", error);
|
||||
}
|
||||
},
|
||||
[editor.commands, onUpload]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const uploadEntity = getUploadEntity();
|
||||
|
||||
if (uploadEntity) {
|
||||
if (uploadEntity.event === "drop" && "file" in uploadEntity) {
|
||||
uploadFile(uploadEntity.file);
|
||||
} else if (uploadEntity.event === "insert" && fileInputRef.current && !hasTriggeredFilePickerRef.current) {
|
||||
const entity = editorStorage?.fileMap.get(id);
|
||||
if (entity && entity.hasOpenedFileInputOnce) return;
|
||||
fileInputRef.current.click();
|
||||
hasTriggeredFilePickerRef.current = true;
|
||||
if (!entity) return;
|
||||
editorStorage?.fileMap.set(id, { ...entity, hasOpenedFileInputOnce: true });
|
||||
}
|
||||
}
|
||||
}, [getUploadEntity, uploadFile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (node.attrs.src) {
|
||||
setIsUploaded(true);
|
||||
}
|
||||
}, [node.attrs.src]);
|
||||
|
||||
const existingFile = React.useMemo(() => {
|
||||
const entity = getUploadEntity();
|
||||
return entity && entity.event === "drop" ? entity.file : undefined;
|
||||
}, [getUploadEntity]);
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<div className="p-0 mx-0 my-2" data-drag-handle>
|
||||
{isUploaded ? (
|
||||
<CustomImageBlock
|
||||
editor={editor}
|
||||
getPos={getPos}
|
||||
node={node}
|
||||
updateAttributes={updateAttributes}
|
||||
selected={selected}
|
||||
/>
|
||||
) : (
|
||||
<CustomImageUploader
|
||||
onUpload={onUpload}
|
||||
editor={editor}
|
||||
fileInputRef={fileInputRef}
|
||||
existingFile={existingFile}
|
||||
selected={selected}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import { ChangeEvent, useCallback, useEffect, useRef } from "react";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { ImageIcon } from "lucide-react";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
// hooks
|
||||
import { useUploader, useFileUpload, useDropZone } from "@/hooks/use-file-upload";
|
||||
// plugins
|
||||
import { isFileValid } from "@/plugins/image";
|
||||
|
||||
type RefType = React.RefObject<HTMLInputElement> | ((instance: HTMLInputElement | null) => void);
|
||||
|
||||
const assignRef = (ref: RefType, value: HTMLInputElement | null) => {
|
||||
if (typeof ref === "function") {
|
||||
ref(value);
|
||||
} else if (ref && typeof ref === "object") {
|
||||
(ref as React.MutableRefObject<HTMLInputElement | null>).current = value;
|
||||
}
|
||||
};
|
||||
|
||||
export const CustomImageUploader = (props: {
|
||||
onUpload: (url: string) => void;
|
||||
editor: Editor;
|
||||
fileInputRef: RefType;
|
||||
existingFile?: File;
|
||||
selected: boolean;
|
||||
}) => {
|
||||
const { selected, onUpload, editor, fileInputRef, existingFile } = props;
|
||||
const { loading, uploadFile } = useUploader({ onUpload, editor });
|
||||
const { handleUploadClick, ref: internalRef } = useFileUpload();
|
||||
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({ uploader: uploadFile });
|
||||
|
||||
const localRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const onFileChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (isFileValid(file)) {
|
||||
uploadFile(file);
|
||||
}
|
||||
}
|
||||
},
|
||||
[uploadFile]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// no need to validate as the file is already validated before the drop onto
|
||||
// the editor
|
||||
if (existingFile) {
|
||||
uploadFile(existingFile);
|
||||
}
|
||||
}, [existingFile, uploadFile]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"image-upload-component flex items-center justify-start gap-2 py-3 px-2 rounded-lg text-custom-text-300 hover:text-custom-text-200 bg-custom-background-90 hover:bg-custom-background-80 border border-dashed border-custom-border-300 cursor-pointer transition-all duration-200 ease-in-out",
|
||||
{
|
||||
"bg-custom-background-80 text-custom-text-200": draggedInside,
|
||||
},
|
||||
{
|
||||
"text-custom-primary-200 bg-custom-primary-100/10": selected,
|
||||
}
|
||||
)}
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragEnter}
|
||||
onDragLeave={onDragLeave}
|
||||
contentEditable={false}
|
||||
onClick={handleUploadClick}
|
||||
>
|
||||
<ImageIcon className="size-4" />
|
||||
<div className="text-base font-medium">
|
||||
{loading ? "Uploading..." : draggedInside ? "Drop image here" : existingFile ? "Uploading..." : "Add an image"}
|
||||
</div>
|
||||
<input
|
||||
className="size-0 overflow-hidden"
|
||||
ref={(element) => {
|
||||
localRef.current = element;
|
||||
assignRef(fileInputRef, element);
|
||||
assignRef(internalRef as RefType, element);
|
||||
}}
|
||||
hidden
|
||||
type="file"
|
||||
accept=".jpg,.jpeg,.png,.webp"
|
||||
onChange={onFileChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./image-block";
|
||||
export * from "./image-node";
|
||||
export * from "./image-uploader";
|
||||
@@ -0,0 +1,157 @@
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
import { Image } from "@tiptap/extension-image";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// extensions
|
||||
import { CustomImageNode } from "@/extensions/custom-image";
|
||||
// plugins
|
||||
import { TrackImageDeletionPlugin, TrackImageRestorationPlugin, isFileValid } from "@/plugins/image";
|
||||
// types
|
||||
import { TFileHandler } from "@/types";
|
||||
// helpers
|
||||
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
imageComponent: {
|
||||
setImageUpload: ({ file, pos, event }: { file?: File; pos?: number; event: "insert" | "drop" }) => ReturnType;
|
||||
uploadImage: (file: File) => () => Promise<string> | undefined;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface UploadImageExtensionStorage {
|
||||
fileMap: Map<string, UploadEntity>;
|
||||
}
|
||||
|
||||
export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean };
|
||||
|
||||
export const CustomImageExtension = (props: TFileHandler) => {
|
||||
const { upload, delete: deleteImage, restore: restoreImage } = props;
|
||||
|
||||
return Image.extend<Record<string, unknown>, UploadImageExtensionStorage>({
|
||||
name: "imageComponent",
|
||||
selectable: true,
|
||||
group: "block",
|
||||
atom: true,
|
||||
draggable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: "35%",
|
||||
},
|
||||
src: {
|
||||
default: null,
|
||||
},
|
||||
height: {
|
||||
default: "auto",
|
||||
},
|
||||
["id"]: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "image-component",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["image-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
|
||||
onCreate(this) {
|
||||
const imageSources = new Set<string>();
|
||||
this.editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === this.name) {
|
||||
imageSources.add(node.attrs.src);
|
||||
}
|
||||
});
|
||||
imageSources.forEach(async (src) => {
|
||||
try {
|
||||
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
|
||||
await restoreImage(assetUrlWithWorkspaceId);
|
||||
} catch (error) {
|
||||
console.error("Error restoring image: ", error);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
|
||||
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
TrackImageDeletionPlugin(this.editor, deleteImage, this.name),
|
||||
TrackImageRestorationPlugin(this.editor, restoreImage, this.name),
|
||||
];
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
fileMap: new Map(),
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setImageUpload:
|
||||
(props: { file?: File; pos?: number; event: "insert" | "drop" }) =>
|
||||
({ commands }) => {
|
||||
// Early return if there's an invalid file being dropped
|
||||
if (props?.file && !isFileValid(props.file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// generate a unique id for the image to keep track of dropped
|
||||
// files' file data
|
||||
const fileId = uuidv4();
|
||||
if (props?.event === "drop" && props.file) {
|
||||
(this.editor.storage.imageComponent as UploadImageExtensionStorage).fileMap.set(fileId, {
|
||||
file: props.file,
|
||||
event: props.event,
|
||||
});
|
||||
} else if (props.event === "insert") {
|
||||
(this.editor.storage.imageComponent as UploadImageExtensionStorage).fileMap.set(fileId, {
|
||||
event: props.event,
|
||||
});
|
||||
}
|
||||
|
||||
const attributes = {
|
||||
id: fileId,
|
||||
};
|
||||
|
||||
if (props.pos) {
|
||||
return commands.insertContentAt(props.pos, {
|
||||
type: this.name,
|
||||
attrs: attributes,
|
||||
});
|
||||
}
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: attributes,
|
||||
});
|
||||
},
|
||||
uploadImage: (file: File) => async () => {
|
||||
const fileUrl = await upload(file);
|
||||
return fileUrl;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CustomImageNode);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./components";
|
||||
export * from "./custom-image";
|
||||
export * from "./read-only-custom-image";
|
||||
@@ -0,0 +1,54 @@
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
import { Image } from "@tiptap/extension-image";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// components
|
||||
import { CustomImageNode, UploadImageExtensionStorage } from "@/extensions/custom-image";
|
||||
|
||||
export const CustomReadOnlyImageExtension = () =>
|
||||
Image.extend<Record<string, unknown>, UploadImageExtensionStorage>({
|
||||
name: "imageComponent",
|
||||
selectable: false,
|
||||
group: "block",
|
||||
atom: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: "35%",
|
||||
},
|
||||
src: {
|
||||
default: null,
|
||||
},
|
||||
height: {
|
||||
default: "auto",
|
||||
},
|
||||
["id"]: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "image-component",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["image-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
fileMap: new Map(),
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CustomImageNode);
|
||||
},
|
||||
});
|
||||
@@ -1,11 +1,8 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "prosemirror-state";
|
||||
// plugins
|
||||
import { startImageUpload } from "@/plugins/image";
|
||||
// types
|
||||
import { UploadImage } from "@/types";
|
||||
import { EditorView } from "prosemirror-view";
|
||||
|
||||
export const DropHandlerExtension = (uploadFile: UploadImage) =>
|
||||
export const DropHandlerExtension = () =>
|
||||
Extension.create({
|
||||
name: "dropHandler",
|
||||
priority: 1000,
|
||||
@@ -15,28 +12,51 @@ export const DropHandlerExtension = (uploadFile: UploadImage) =>
|
||||
new Plugin({
|
||||
key: new PluginKey("drop-handler-plugin"),
|
||||
props: {
|
||||
handlePaste: (view, event) => {
|
||||
if (event.clipboardData && event.clipboardData.files && event.clipboardData.files[0]) {
|
||||
handlePaste: (view: EditorView, event: ClipboardEvent) => {
|
||||
if (event.clipboardData && event.clipboardData.files && event.clipboardData.files.length > 0) {
|
||||
event.preventDefault();
|
||||
const file = event.clipboardData.files[0];
|
||||
const pos = view.state.selection.from;
|
||||
startImageUpload(this.editor, file, view, pos, uploadFile);
|
||||
return true;
|
||||
const files = Array.from(event.clipboardData.files);
|
||||
const imageFiles = files.filter((file) => file.type.startsWith("image"));
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
const pos = view.state.selection.from;
|
||||
imageFiles.forEach((file, index) => {
|
||||
this.editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setImageUpload({ file, pos: pos + index, event: "drop" })
|
||||
.run();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
handleDrop: (view, event, _slice, moved) => {
|
||||
if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) {
|
||||
handleDrop: (view: EditorView, event: DragEvent, _slice: any, moved: boolean) => {
|
||||
if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) {
|
||||
event.preventDefault();
|
||||
const file = event.dataTransfer.files[0];
|
||||
const coordinates = view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
});
|
||||
if (coordinates) {
|
||||
startImageUpload(this.editor, file, view, coordinates.pos - 1, uploadFile);
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
const imageFiles = files.filter((file) => file.type.startsWith("image"));
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
const coordinates = view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
});
|
||||
|
||||
if (coordinates) {
|
||||
imageFiles.forEach((file, index) => {
|
||||
setTimeout(() => {
|
||||
this.editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setImageUpload({ file, pos: coordinates.pos + index, event: "drop" })
|
||||
.run();
|
||||
}, index * 100); // Slight delay between insertions
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
CustomCodeInlineExtension,
|
||||
CustomCodeMarkPlugin,
|
||||
CustomHorizontalRule,
|
||||
CustomImageExtension,
|
||||
CustomKeymap,
|
||||
CustomLinkExtension,
|
||||
CustomMention,
|
||||
@@ -79,7 +80,7 @@ export const CoreEditorExtensions = ({
|
||||
...(enableHistory ? {} : { history: false }),
|
||||
}),
|
||||
CustomQuoteExtension,
|
||||
DropHandlerExtension(uploadFile),
|
||||
DropHandlerExtension(),
|
||||
CustomHorizontalRule.configure({
|
||||
HTMLAttributes: {
|
||||
class: "my-4 border-custom-border-400",
|
||||
@@ -104,6 +105,12 @@ export const CoreEditorExtensions = ({
|
||||
class: "rounded-md",
|
||||
},
|
||||
}),
|
||||
CustomImageExtension({
|
||||
delete: deleteFile,
|
||||
restore: restoreFile,
|
||||
upload: uploadFile,
|
||||
cancel: cancelUploadImage ?? (() => {}),
|
||||
}),
|
||||
TiptapUnderline,
|
||||
TextStyle,
|
||||
TaskList.configure({
|
||||
@@ -142,7 +149,7 @@ export const CoreEditorExtensions = ({
|
||||
placeholder: ({ editor, node }) => {
|
||||
if (node.type.name === "heading") return `Heading ${node.attrs.level}`;
|
||||
|
||||
if (editor.storage.image.uploadInProgress) return "";
|
||||
// if (editor.storage.image.uploadInProgress) return "";
|
||||
|
||||
const shouldHidePlaceholder =
|
||||
editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image");
|
||||
|
||||
@@ -1,37 +1,33 @@
|
||||
import ImageExt from "@tiptap/extension-image";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// helpers
|
||||
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
||||
// plugins
|
||||
import {
|
||||
IMAGE_NODE_TYPE,
|
||||
ImageExtensionStorage,
|
||||
TrackImageDeletionPlugin,
|
||||
TrackImageRestorationPlugin,
|
||||
UploadImagesPlugin,
|
||||
} from "@/plugins/image";
|
||||
import { ImageExtensionStorage, TrackImageDeletionPlugin, TrackImageRestorationPlugin } from "@/plugins/image";
|
||||
// types
|
||||
import { DeleteImage, RestoreImage } from "@/types";
|
||||
// extensions
|
||||
import { CustomImageNode } from "@/extensions";
|
||||
|
||||
export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreImage, cancelUploadImage?: () => void) =>
|
||||
ImageExt.extend<any, ImageExtensionStorage>({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", "image"),
|
||||
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", "image"),
|
||||
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
|
||||
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
|
||||
};
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
UploadImagesPlugin(this.editor, cancelUploadImage),
|
||||
TrackImageDeletionPlugin(this.editor, deleteImage),
|
||||
TrackImageRestorationPlugin(this.editor, restoreImage),
|
||||
TrackImageDeletionPlugin(this.editor, deleteImage, this.name),
|
||||
TrackImageRestorationPlugin(this.editor, restoreImage, this.name),
|
||||
];
|
||||
},
|
||||
|
||||
onCreate(this) {
|
||||
const imageSources = new Set<string>();
|
||||
this.editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === IMAGE_NODE_TYPE) {
|
||||
if (node.type.name === this.name) {
|
||||
imageSources.add(node.attrs.src);
|
||||
}
|
||||
});
|
||||
@@ -64,4 +60,9 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreIm
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
// render custom image node
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CustomImageNode);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { Image } from "@tiptap/extension-image";
|
||||
// extensions
|
||||
import { CustomImageNode, UploadImageExtensionStorage } from "@/extensions";
|
||||
|
||||
export const CustomImageComponentWithoutProps = () =>
|
||||
Image.extend<Record<string, unknown>, UploadImageExtensionStorage>({
|
||||
name: "imageComponent",
|
||||
selectable: true,
|
||||
group: "block",
|
||||
atom: true,
|
||||
draggable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: "35%",
|
||||
},
|
||||
src: {
|
||||
default: null,
|
||||
},
|
||||
height: {
|
||||
default: "auto",
|
||||
},
|
||||
["id"]: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "image-component",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["image-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
fileMap: new Map(),
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CustomImageNode);
|
||||
},
|
||||
});
|
||||
|
||||
export default CustomImageComponentWithoutProps;
|
||||
@@ -1,4 +1,7 @@
|
||||
import ImageExt from "@tiptap/extension-image";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// extensions
|
||||
import { CustomImageNode } from "@/extensions";
|
||||
|
||||
export const ImageExtensionWithoutProps = () =>
|
||||
ImageExt.extend({
|
||||
@@ -13,4 +16,8 @@ export const ImageExtensionWithoutProps = () =>
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CustomImageNode);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import Moveable from "react-moveable";
|
||||
|
||||
type Props = {
|
||||
editor: Editor;
|
||||
id: string;
|
||||
};
|
||||
|
||||
const getImageElement = (editorId: string): HTMLImageElement | null =>
|
||||
document.querySelector(`#editor-container-${editorId}.active-editor .ProseMirror-selectednode`);
|
||||
|
||||
export const ImageResizer = (props: Props) => {
|
||||
const { editor, id } = props;
|
||||
// states
|
||||
const [aspectRatio, setAspectRatio] = useState(1);
|
||||
|
||||
const updateMediaSize = () => {
|
||||
const imageElement = getImageElement(id);
|
||||
|
||||
if (!imageElement) return;
|
||||
|
||||
const selection = editor.state.selection;
|
||||
|
||||
// Use the style width/height if available, otherwise fall back to the element's natural width/height
|
||||
const width = imageElement.style.width
|
||||
? Number(imageElement.style.width.replace("px", ""))
|
||||
: imageElement.getAttribute("width");
|
||||
const height = imageElement.style.height
|
||||
? Number(imageElement.style.height.replace("px", ""))
|
||||
: imageElement.getAttribute("height");
|
||||
|
||||
editor.commands.setImage({
|
||||
src: imageElement.src,
|
||||
width: width,
|
||||
height: height,
|
||||
} as any);
|
||||
editor.commands.setNodeSelection(selection.from);
|
||||
};
|
||||
|
||||
return (
|
||||
<Moveable
|
||||
target={getImageElement(id)}
|
||||
container={null}
|
||||
origin={false}
|
||||
edge={false}
|
||||
throttleDrag={0}
|
||||
keepRatio
|
||||
resizable
|
||||
throttleResize={0}
|
||||
onResizeStart={() => {
|
||||
const imageElement = getImageElement(id);
|
||||
if (imageElement) {
|
||||
const originalWidth = Number(imageElement.width);
|
||||
const originalHeight = Number(imageElement.height);
|
||||
setAspectRatio(originalWidth / originalHeight);
|
||||
}
|
||||
}}
|
||||
onResize={({ target, width, height, delta }) => {
|
||||
if (delta[0] || delta[1]) {
|
||||
let newWidth, newHeight;
|
||||
if (delta[0]) {
|
||||
// Width change detected
|
||||
newWidth = Math.max(width, 100);
|
||||
newHeight = newWidth / aspectRatio;
|
||||
} else if (delta[1]) {
|
||||
// Height change detected
|
||||
newHeight = Math.max(height, 100);
|
||||
newWidth = newHeight * aspectRatio;
|
||||
}
|
||||
target.style.width = `${newWidth}px`;
|
||||
target.style.height = `${newHeight}px`;
|
||||
}
|
||||
}}
|
||||
onResizeEnd={() => {
|
||||
updateMediaSize();
|
||||
}}
|
||||
scalable
|
||||
renderDirections={["se"]}
|
||||
onScale={({ target, transform }) => {
|
||||
target.style.transform = transform;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from "./extension";
|
||||
export * from "./image-extension-without-props";
|
||||
export * from "./image-resize";
|
||||
export * from "./read-only-image";
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import Image from "@tiptap/extension-image";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// extensions
|
||||
import { CustomImageNode } from "@/extensions";
|
||||
|
||||
export const ReadOnlyImageExtension = Image.extend({
|
||||
addAttributes() {
|
||||
@@ -12,4 +15,7 @@ export const ReadOnlyImageExtension = Image.extend({
|
||||
},
|
||||
};
|
||||
},
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CustomImageNode);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./code";
|
||||
export * from "./code-inline";
|
||||
export * from "./custom-image";
|
||||
export * from "./custom-link";
|
||||
export * from "./custom-list-keymap";
|
||||
export * from "./image";
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
TableRow,
|
||||
Table,
|
||||
CustomMention,
|
||||
CustomReadOnlyImageExtension,
|
||||
} from "@/extensions";
|
||||
// helpers
|
||||
import { isValidHttpUrl } from "@/helpers/common";
|
||||
@@ -74,6 +75,7 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
||||
class: "rounded-md",
|
||||
},
|
||||
}),
|
||||
CustomReadOnlyImageExtension(),
|
||||
TiptapUnderline,
|
||||
TextStyle,
|
||||
TaskList.configure({
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { EditorView } from "@tiptap/pm/view";
|
||||
// plugins
|
||||
import { AIHandlePlugin } from "@/plugins/ai-handle";
|
||||
import { DragHandlePlugin } from "@/plugins/drag-handle";
|
||||
import { DragHandlePlugin, nodeDOMAtCoords } from "@/plugins/drag-handle";
|
||||
|
||||
type Props = {
|
||||
aiEnabled: boolean;
|
||||
@@ -59,41 +59,6 @@ const absoluteRect = (node: Element) => {
|
||||
};
|
||||
};
|
||||
|
||||
const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
|
||||
const elements = document.elementsFromPoint(coords.x, coords.y);
|
||||
const generalSelectors = [
|
||||
"li",
|
||||
"p:not(:first-child)",
|
||||
".code-block",
|
||||
"blockquote",
|
||||
"img",
|
||||
"h1, h2, h3, h4, h5, h6",
|
||||
"[data-type=horizontalRule]",
|
||||
".table-wrapper",
|
||||
".issue-embed",
|
||||
].join(", ");
|
||||
|
||||
for (const elem of elements) {
|
||||
if (elem.matches("p:first-child") && elem.parentElement?.matches(".ProseMirror")) {
|
||||
return elem;
|
||||
}
|
||||
|
||||
// if the element is a <p> tag that is the first child of a td or th
|
||||
if (
|
||||
(elem.matches("td > p:first-child") || elem.matches("th > p:first-child")) &&
|
||||
elem?.textContent?.trim() !== ""
|
||||
) {
|
||||
return elem; // Return only if p tag is not empty in td or th
|
||||
}
|
||||
|
||||
// apply general selector
|
||||
if (elem.matches(generalSelectors)) {
|
||||
return elem;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const SideMenu = (options: SideMenuPluginProps) => {
|
||||
const { handlesConfig } = options;
|
||||
const editorSideMenu: HTMLDivElement | null = document.createElement("div");
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
toggleBulletList,
|
||||
toggleOrderedList,
|
||||
toggleTaskList,
|
||||
insertImageCommand,
|
||||
toggleHeadingOne,
|
||||
toggleHeadingTwo,
|
||||
toggleHeadingThree,
|
||||
@@ -37,7 +36,7 @@ import {
|
||||
toggleHeadingSix,
|
||||
} from "@/helpers/editor-commands";
|
||||
// types
|
||||
import { CommandProps, ISlashCommandItem, UploadImage } from "@/types";
|
||||
import { CommandProps, ISlashCommandItem } from "@/types";
|
||||
|
||||
interface CommandItemProps {
|
||||
key: string;
|
||||
@@ -63,7 +62,7 @@ const Command = Extension.create<SlashCommandOptions>({
|
||||
const { selection } = editor.state;
|
||||
|
||||
const parentNode = selection.$from.node(selection.$from.depth);
|
||||
const blockType = parentNode?.type?.name;
|
||||
const blockType = parentNode.type.name;
|
||||
|
||||
if (blockType === "codeBlock") {
|
||||
return false;
|
||||
@@ -89,7 +88,7 @@ const Command = Extension.create<SlashCommandOptions>({
|
||||
});
|
||||
|
||||
const getSuggestionItems =
|
||||
(uploadFile: UploadImage, additionalOptions?: Array<ISlashCommandItem>) =>
|
||||
(additionalOptions?: Array<ISlashCommandItem>) =>
|
||||
({ query }: { query: string }) => {
|
||||
let slashCommands: ISlashCommandItem[] = [
|
||||
{
|
||||
@@ -224,11 +223,11 @@ const getSuggestionItems =
|
||||
{
|
||||
key: "image",
|
||||
title: "Image",
|
||||
description: "Upload an image from your computer.",
|
||||
searchTerms: ["img", "photo", "picture", "media"],
|
||||
icon: <ImageIcon className="size-3.5" />,
|
||||
description: "Insert an image",
|
||||
searchTerms: ["img", "photo", "picture", "media", "upload"],
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
insertImageCommand(editor, uploadFile, null, range);
|
||||
editor.chain().focus().deleteRange(range).setImageUpload({ event: "insert" }).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -415,10 +414,10 @@ const renderItems = () => {
|
||||
};
|
||||
};
|
||||
|
||||
export const SlashCommand = (uploadFile: UploadImage, additionalOptions?: Array<ISlashCommandItem>) =>
|
||||
export const SlashCommand = (additionalOptions?: Array<ISlashCommandItem>) =>
|
||||
Command.configure({
|
||||
suggestion: {
|
||||
items: getSuggestionItems(uploadFile, additionalOptions),
|
||||
items: getSuggestionItems(additionalOptions),
|
||||
render: renderItems,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { Schema } from "@tiptap/pm/model";
|
||||
import { prosemirrorJSONToYDoc } from "y-prosemirror";
|
||||
import * as Y from "yjs";
|
||||
|
||||
const defaultSchema: Schema = new Schema({
|
||||
nodes: {
|
||||
text: {},
|
||||
doc: { content: "text*" },
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* @description converts ProseMirror JSON to Yjs document
|
||||
* @param document prosemirror JSON
|
||||
* @param fieldName
|
||||
* @param schema
|
||||
* @returns {Y.Doc} Yjs document
|
||||
*/
|
||||
export const proseMirrorJSONToBinaryString = (
|
||||
document: any,
|
||||
fieldName: string | Array<string> = "default",
|
||||
schema?: Schema
|
||||
): string => {
|
||||
if (!document) {
|
||||
throw new Error(
|
||||
`You've passed an empty or invalid document to the Transformer. Make sure to pass ProseMirror-compatible JSON. Actually passed JSON: ${document}`
|
||||
);
|
||||
}
|
||||
|
||||
// allow a single field name
|
||||
if (typeof fieldName === "string") {
|
||||
const yDoc = prosemirrorJSONToYDoc(schema ?? defaultSchema, document, fieldName);
|
||||
const docAsUint8Array = Y.encodeStateAsUpdate(yDoc);
|
||||
const base64Doc = Buffer.from(docAsUint8Array).toString("base64");
|
||||
return base64Doc;
|
||||
}
|
||||
|
||||
const yDoc = new Y.Doc();
|
||||
|
||||
fieldName.forEach((field) => {
|
||||
const update = Y.encodeStateAsUpdate(prosemirrorJSONToYDoc(schema ?? defaultSchema, document, field));
|
||||
|
||||
Y.applyUpdate(yDoc, update);
|
||||
});
|
||||
|
||||
const docAsUint8Array = Y.encodeStateAsUpdate(yDoc);
|
||||
const base64Doc = Buffer.from(docAsUint8Array).toString("base64");
|
||||
|
||||
return base64Doc;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description apply updates to a doc and return the updated doc in base64(binary) format
|
||||
* @param {Uint8Array} document
|
||||
* @param {Uint8Array} updates
|
||||
* @returns {string} base64(binary) form of the updated doc
|
||||
*/
|
||||
export const applyUpdates = (document: Uint8Array, updates: Uint8Array): string => {
|
||||
const yDoc = new Y.Doc();
|
||||
Y.applyUpdate(yDoc, document);
|
||||
Y.applyUpdate(yDoc, updates);
|
||||
|
||||
const encodedDoc = Y.encodeStateAsUpdate(yDoc);
|
||||
const base64Updates = Buffer.from(encodedDoc).toString("base64");
|
||||
return base64Updates;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description merge multiple updates into one single update
|
||||
* @param {Uint8Array[]} updates
|
||||
* @returns {Uint8Array} merged updates
|
||||
*/
|
||||
export const mergeUpdates = (updates: Uint8Array[]): Uint8Array => {
|
||||
const mergedUpdates = Y.mergeUpdates(updates);
|
||||
return mergedUpdates;
|
||||
};
|
||||
@@ -83,7 +83,6 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
|
||||
...(extensions ?? []),
|
||||
...DocumentEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
fileHandler,
|
||||
issueEmbedConfig: embedHandler?.issue,
|
||||
provider,
|
||||
userDetails: user,
|
||||
|
||||
@@ -91,6 +91,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()),
|
||||
onDestroy: () => handleEditorReady?.(false),
|
||||
});
|
||||
|
||||
// Update the ref whenever savedSelection changes
|
||||
useEffect(() => {
|
||||
savedSelectionRef.current = savedSelection;
|
||||
@@ -123,7 +124,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
editorRef.current?.commands.clearContent(emitUpdate);
|
||||
},
|
||||
setEditorValue: (content: string) => {
|
||||
editorRef.current?.commands.setContent(content);
|
||||
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
|
||||
},
|
||||
setEditorValueAtCursorPosition: (content: string) => {
|
||||
if (savedSelection) {
|
||||
@@ -131,7 +132,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
}
|
||||
},
|
||||
executeMenuItemCommand: (itemKey: TEditorCommands) => {
|
||||
const editorItems = getEditorMenuItems(editorRef.current, fileHandler.upload);
|
||||
const editorItems = getEditorMenuItems(editorRef.current);
|
||||
|
||||
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
|
||||
|
||||
@@ -147,7 +148,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
}
|
||||
},
|
||||
isMenuItemActive: (itemName: TEditorCommands): boolean => {
|
||||
const editorItems = getEditorMenuItems(editorRef.current, fileHandler.upload);
|
||||
const editorItems = getEditorMenuItems(editorRef.current);
|
||||
|
||||
const getEditorMenuItem = (itemName: TEditorCommands) => editorItems.find((item) => item.key === itemName);
|
||||
const item = getEditorMenuItem(itemName);
|
||||
@@ -214,20 +215,25 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
}
|
||||
});
|
||||
const selection = nodesArray.join("");
|
||||
console.log(selection);
|
||||
return selection;
|
||||
},
|
||||
insertText: (contentHTML, insertOnNextLine) => {
|
||||
if (!editor) return;
|
||||
if (!editorRef.current) return;
|
||||
// get selection
|
||||
const { from, to, empty } = editor.state.selection;
|
||||
const { from, to, empty } = editorRef.current.state.selection;
|
||||
if (empty) return;
|
||||
if (insertOnNextLine) {
|
||||
// move cursor to the end of the selection and insert a new line
|
||||
editor.chain().focus().setTextSelection(to).insertContent("<br />").insertContent(contentHTML).run();
|
||||
editorRef.current
|
||||
.chain()
|
||||
.focus()
|
||||
.setTextSelection(to)
|
||||
.insertContent("<br />")
|
||||
.insertContent(contentHTML)
|
||||
.run();
|
||||
} else {
|
||||
// replace selected text with the content provided
|
||||
editor.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run();
|
||||
editorRef.current.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run();
|
||||
}
|
||||
},
|
||||
getDocumentInfo: () => {
|
||||
@@ -238,7 +244,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
};
|
||||
},
|
||||
}),
|
||||
[editorRef, savedSelection, fileHandler.upload]
|
||||
[editorRef, savedSelection]
|
||||
);
|
||||
|
||||
if (!editor) {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { DragEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { isFileValid } from "@/plugins/image";
|
||||
|
||||
export const useUploader = ({ onUpload, editor }: { onUpload: (url: string) => void; editor: Editor }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const uploadFile = useCallback(
|
||||
async (file: File) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// @ts-expect-error - TODO: fix typings, and don't remove await from
|
||||
// here for now
|
||||
const url: string = await editor?.commands.uploadImage(file);
|
||||
|
||||
if (!url) {
|
||||
throw new Error("Something went wrong while uploading the image");
|
||||
}
|
||||
onUpload(url);
|
||||
} catch (errPayload: any) {
|
||||
console.log(errPayload);
|
||||
const error = errPayload?.response?.data?.error || "Something went wrong";
|
||||
console.error(error);
|
||||
}
|
||||
setLoading(false);
|
||||
},
|
||||
[onUpload, editor]
|
||||
);
|
||||
|
||||
return { loading, uploadFile };
|
||||
};
|
||||
|
||||
export const useFileUpload = () => {
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleUploadClick = useCallback(() => {
|
||||
fileInput.current?.click();
|
||||
}, []);
|
||||
|
||||
return { ref: fileInput, handleUploadClick };
|
||||
};
|
||||
export const useDropZone = ({ uploader }: { uploader: (file: File) => void }) => {
|
||||
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||
const [draggedInside, setDraggedInside] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const dragStartHandler = () => {
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const dragEndHandler = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
document.body.addEventListener("dragstart", dragStartHandler);
|
||||
document.body.addEventListener("dragend", dragEndHandler);
|
||||
|
||||
return () => {
|
||||
document.body.removeEventListener("dragstart", dragStartHandler);
|
||||
document.body.removeEventListener("dragend", dragEndHandler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(e: DragEvent<HTMLDivElement>) => {
|
||||
setDraggedInside(false);
|
||||
if (e.dataTransfer.files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileList = e.dataTransfer.files;
|
||||
|
||||
const files: File[] = [];
|
||||
|
||||
for (let i = 0; i < fileList.length; i += 1) {
|
||||
const item = fileList.item(i);
|
||||
if (item) {
|
||||
files.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (files.some((file) => file.type.indexOf("image") === -1)) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const filteredFiles = files.filter((f) => f.type.indexOf("image") !== -1);
|
||||
|
||||
const file = filteredFiles.length > 0 ? filteredFiles[0] : undefined;
|
||||
|
||||
if (file) {
|
||||
const isValid = isFileValid(file);
|
||||
if (isValid) {
|
||||
uploader(file);
|
||||
}
|
||||
}
|
||||
},
|
||||
[uploader]
|
||||
);
|
||||
|
||||
const onDragEnter = () => {
|
||||
setDraggedInside(true);
|
||||
};
|
||||
|
||||
const onDragLeave = () => {
|
||||
setDraggedInside(false);
|
||||
};
|
||||
|
||||
return { isDragging, draggedInside, onDragEnter, onDragLeave, onDrop };
|
||||
};
|
||||
@@ -58,7 +58,7 @@ export const useReadOnlyEditor = ({
|
||||
// for syncing swr data on tab refocus etc
|
||||
useEffect(() => {
|
||||
if (initialValue === null || initialValue === undefined) return;
|
||||
if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue);
|
||||
if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue, false, { preserveWhitespace: "full" });
|
||||
}, [editor, initialValue]);
|
||||
|
||||
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||
@@ -68,7 +68,7 @@ export const useReadOnlyEditor = ({
|
||||
editorRef.current?.commands.clearContent();
|
||||
},
|
||||
setEditorValue: (content: string) => {
|
||||
editorRef.current?.commands.setContent(content);
|
||||
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
|
||||
},
|
||||
getMarkDown: (): string => {
|
||||
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
|
||||
|
||||
@@ -30,7 +30,7 @@ const createDragHandleElement = (): HTMLElement => {
|
||||
return dragHandleElement;
|
||||
};
|
||||
|
||||
const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
|
||||
export const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
|
||||
const elements = document.elementsFromPoint(coords.x, coords.y);
|
||||
const generalSelectors = [
|
||||
"li",
|
||||
@@ -42,13 +42,34 @@ const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
|
||||
"[data-type=horizontalRule]",
|
||||
".table-wrapper",
|
||||
".issue-embed",
|
||||
".image-upload-component",
|
||||
].join(", ");
|
||||
|
||||
const hasNestedImg = (el: Element): boolean => {
|
||||
if (el.tagName.toLowerCase() === "img") return true;
|
||||
// @ts-expect-error todo
|
||||
for (const child of el.children) {
|
||||
if (hasNestedImg(child)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
for (const elem of elements) {
|
||||
const elemHasNestedImg = hasNestedImg(elem);
|
||||
if (elem.matches("p:first-child") && elem.parentElement?.matches(".ProseMirror")) {
|
||||
return elem;
|
||||
}
|
||||
|
||||
// if the element is a <p> tag and has a nested img i.e. the new image
|
||||
// component
|
||||
if (elem.matches("p") && elemHasNestedImg) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (elem.matches("div") && elemHasNestedImg) {
|
||||
return elem;
|
||||
}
|
||||
|
||||
// if the element is a <p> tag that is the first child of a td or th
|
||||
if (
|
||||
(elem.matches("td > p:first-child") || elem.matches("th > p:first-child")) &&
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { EditorState, Plugin, Transaction } from "@tiptap/pm/state";
|
||||
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
|
||||
// plugins
|
||||
import { IMAGE_NODE_TYPE, deleteKey, type ImageNode } from "@/plugins/image";
|
||||
import { type ImageNode } from "@/plugins/image";
|
||||
// types
|
||||
import { DeleteImage } from "@/types";
|
||||
|
||||
export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImage): Plugin =>
|
||||
export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImage, nodeType: string): Plugin =>
|
||||
new Plugin({
|
||||
key: deleteKey,
|
||||
key: new PluginKey(`delete-${nodeType}`),
|
||||
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
|
||||
const newImageSources = new Set<string>();
|
||||
newState.doc.descendants((node) => {
|
||||
if (node.type.name === IMAGE_NODE_TYPE) {
|
||||
if (node.type.name === nodeType) {
|
||||
newImageSources.add(node.attrs.src);
|
||||
}
|
||||
});
|
||||
@@ -25,7 +25,7 @@ export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImag
|
||||
// iterate through all the nodes in the old state
|
||||
oldState.doc.descendants((oldNode) => {
|
||||
// if the node is not an image, then return as no point in checking
|
||||
if (oldNode.type.name !== IMAGE_NODE_TYPE) return;
|
||||
if (oldNode.type.name !== nodeType) return;
|
||||
|
||||
// Check if the node has been deleted or replaced
|
||||
if (!newImageSources.has(oldNode.attrs.src)) {
|
||||
@@ -35,7 +35,7 @@ export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImag
|
||||
|
||||
removedImages.forEach(async (node) => {
|
||||
const src = node.attrs.src;
|
||||
editor.storage.image.deletedImageSet.set(src, true);
|
||||
editor.storage[nodeType].deletedImageSet.set(src, true);
|
||||
await onNodeDeleted(src, deleteImage);
|
||||
});
|
||||
});
|
||||
@@ -46,6 +46,7 @@ export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImag
|
||||
|
||||
async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise<void> {
|
||||
try {
|
||||
if (!src) return;
|
||||
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
|
||||
await deleteImage(assetUrlWithWorkspaceId);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { EditorState, Plugin, Transaction } from "@tiptap/pm/state";
|
||||
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
|
||||
// plugins
|
||||
import { IMAGE_NODE_TYPE, ImageNode, restoreKey } from "@/plugins/image";
|
||||
import { ImageNode } from "@/plugins/image";
|
||||
// types
|
||||
import { RestoreImage } from "@/types";
|
||||
|
||||
export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: RestoreImage): Plugin =>
|
||||
export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: RestoreImage, nodeType: string): Plugin =>
|
||||
new Plugin({
|
||||
key: restoreKey,
|
||||
key: new PluginKey(`restore-${nodeType}`),
|
||||
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
|
||||
const oldImageSources = new Set<string>();
|
||||
oldState.doc.descendants((node) => {
|
||||
if (node.type.name === IMAGE_NODE_TYPE) {
|
||||
if (node.type.name === nodeType) {
|
||||
oldImageSources.add(node.attrs.src);
|
||||
}
|
||||
});
|
||||
@@ -22,20 +22,21 @@ export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: Restor
|
||||
const addedImages: ImageNode[] = [];
|
||||
|
||||
newState.doc.descendants((node, pos) => {
|
||||
if (node.type.name !== IMAGE_NODE_TYPE) return;
|
||||
if (node.type.name !== nodeType) return;
|
||||
if (pos < 0 || pos > newState.doc.content.size) return;
|
||||
if (oldImageSources.has(node.attrs.src)) return;
|
||||
addedImages.push(node as ImageNode);
|
||||
});
|
||||
|
||||
addedImages.forEach(async (image) => {
|
||||
const wasDeleted = editor.storage.image.deletedImageSet.get(image.attrs.src);
|
||||
const src = image.attrs.src;
|
||||
const wasDeleted = editor.storage[nodeType].deletedImageSet.get(src);
|
||||
if (wasDeleted === undefined) {
|
||||
editor.storage.image.deletedImageSet.set(image.attrs.src, false);
|
||||
editor.storage[nodeType].deletedImageSet.set(src, false);
|
||||
} else if (wasDeleted === true) {
|
||||
try {
|
||||
await onNodeRestored(image.attrs.src, restoreImage);
|
||||
editor.storage.image.deletedImageSet.set(image.attrs.src, false);
|
||||
await onNodeRestored(src, restoreImage);
|
||||
editor.storage[nodeType].deletedImageSet.set(src, false);
|
||||
} catch (error) {
|
||||
console.error("Error restoring image: ", error);
|
||||
}
|
||||
@@ -48,6 +49,7 @@ export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: Restor
|
||||
|
||||
async function onNodeRestored(src: string, restoreImage: RestoreImage): Promise<void> {
|
||||
try {
|
||||
if (!src) return;
|
||||
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
|
||||
await restoreImage(assetUrlWithWorkspaceId);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
export function isFileValid(file: File): boolean {
|
||||
export function isFileValid(file: File, showAlert = true): boolean {
|
||||
if (!file) {
|
||||
alert("No file selected. Please select a file to upload.");
|
||||
if (showAlert) {
|
||||
alert("No file selected. Please select a file to upload.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const allowedTypes = ["image/jpeg", "image/jpg", "image/png", "image/webp"];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
alert("Invalid file type. Please select a JPEG, JPG, PNG, or WEBP image file.");
|
||||
if (showAlert) {
|
||||
alert("Invalid file type. Please select a JPEG, JPG, PNG, or WEBP image file.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert("File size too large. Please select a file smaller than 5MB.");
|
||||
if (showAlert) {
|
||||
alert("File size too large. Please select a file smaller than 5MB.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,9 +21,7 @@ export { isCellSelection } from "@/extensions/table/table/utilities/is-cell-sele
|
||||
// helpers
|
||||
export * from "@/helpers/common";
|
||||
export * from "@/helpers/editor-commands";
|
||||
export * from "@/helpers/yjs";
|
||||
export * from "@/extensions/table/table";
|
||||
export { startImageUpload } from "@/plugins/image";
|
||||
|
||||
// components
|
||||
export * from "@/components/menus";
|
||||
|
||||
@@ -4,10 +4,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: 1;
|
||||
transition:
|
||||
opacity 0.2s ease 0.2s,
|
||||
top 0.2s ease,
|
||||
left 0.2s ease;
|
||||
transition: opacity 0.2s ease 0.2s;
|
||||
transform: translateX(-50%);
|
||||
|
||||
&.side-menu-hidden {
|
||||
@@ -39,7 +36,7 @@
|
||||
}
|
||||
/* end ai handle */
|
||||
|
||||
.ProseMirror:not(.dragging) .ProseMirror-selectednode {
|
||||
.ProseMirror:not(.dragging) .ProseMirror-selectednode:not(.node-imageComponent):not(.node-image) {
|
||||
position: relative;
|
||||
cursor: grab;
|
||||
outline: none !important;
|
||||
@@ -63,6 +60,15 @@
|
||||
border-radius: 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.node-imageComponent,
|
||||
&.node-image {
|
||||
--horizontal-offset: 0px;
|
||||
|
||||
&::after {
|
||||
background-color: rgba(var(--color-background-100), 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* for targeting the task list items */
|
||||
@@ -96,7 +102,8 @@ ol > li:nth-child(n + 100).ProseMirror-selectednode:not(.dragging)::after {
|
||||
margin-left: -35px;
|
||||
}
|
||||
|
||||
.ProseMirror img {
|
||||
.ProseMirror node-image,
|
||||
.ProseMirror node-imageComponent {
|
||||
transition: filter 0.1s ease-in-out;
|
||||
cursor: pointer;
|
||||
|
||||
|
||||
@@ -122,18 +122,21 @@
|
||||
|
||||
/* Custom image styles */
|
||||
.ProseMirror img {
|
||||
transition: filter 0.1s ease-in-out;
|
||||
margin-top: 8px;
|
||||
margin-top: 0 !important;
|
||||
margin-bottom: 0;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
filter: brightness(90%);
|
||||
}
|
||||
&:not(.read-only-image) {
|
||||
transition: filter 0.1s ease-in-out;
|
||||
|
||||
&.ProseMirror-selectednode {
|
||||
outline: 3px solid rgba(var(--color-primary-100));
|
||||
filter: brightness(90%);
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
filter: brightness(90%);
|
||||
}
|
||||
|
||||
&.ProseMirror-selectednode {
|
||||
outline: 3px solid rgba(var(--color-primary-100));
|
||||
filter: brightness(90%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,26 +264,6 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
|
||||
transition: opacity 0.2s ease-out;
|
||||
}
|
||||
|
||||
.img-placeholder {
|
||||
position: relative;
|
||||
width: 35%;
|
||||
margin-top: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 45%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(var(--color-text-200));
|
||||
border-top-color: rgba(var(--color-text-800));
|
||||
animation: spinning 0.6s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spinning {
|
||||
to {
|
||||
|
||||
@@ -4,7 +4,7 @@ export default defineConfig((options: Options) => ({
|
||||
entry: ["src/index.ts", "src/lib.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
clean: false,
|
||||
external: ["react"],
|
||||
injectStyle: true,
|
||||
...options,
|
||||
|
||||
@@ -8,7 +8,7 @@ module.exports = {
|
||||
plugins: ["react", "@typescript-eslint", "import"],
|
||||
settings: {
|
||||
next: {
|
||||
rootDir: ["web/", "space/", "admin/", "packages/*/"],
|
||||
rootDir: ["."],
|
||||
},
|
||||
},
|
||||
globals: {
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"version": "0.22.0",
|
||||
"main": "index.js",
|
||||
"license": "MIT",
|
||||
"devDependencies": {},
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^7.1.1",
|
||||
"@typescript-eslint/parser": "^7.1.1",
|
||||
@@ -14,6 +13,6 @@
|
||||
"eslint-config-turbo": "^1.12.4",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"typescript": "^5.3.3"
|
||||
"typescript": "5.4.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./use-outside-click-detector";
|
||||
@@ -0,0 +1,29 @@
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
export const useOutsideClickDetector = (
|
||||
ref: React.RefObject<HTMLElement>,
|
||||
callback: () => void,
|
||||
useCapture = false
|
||||
) => {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||
// check for the closest element with attribute name data-prevent-outside-click
|
||||
const preventOutsideClickElement = (
|
||||
event.target as HTMLElement | undefined
|
||||
)?.closest("[data-prevent-outside-click]");
|
||||
// if the closest element with attribute name data-prevent-outside-click is found, return
|
||||
if (preventOutsideClickElement) {
|
||||
return;
|
||||
}
|
||||
// else call the callback
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("mousedown", handleClick, useCapture);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClick, useCapture);
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./hooks";
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@plane/helpers",
|
||||
"version": "0.22.0",
|
||||
"description": "Helper functions shared across multiple apps internally",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist/**"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup ./index.ts --format esm,cjs --dts --external react --minify"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.5.4",
|
||||
"@types/react": "^18.3.5",
|
||||
"typescript": "^5.6.2",
|
||||
"tsup": "^7.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "tsconfig/react-library.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react"
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["dist", "build", "node_modules"]
|
||||
}
|
||||
@@ -2,5 +2,6 @@
|
||||
"name": "@plane/types",
|
||||
"version": "0.22.0",
|
||||
"private": true,
|
||||
"types": "./src/index.d.ts",
|
||||
"main": "./src/index.d.ts"
|
||||
}
|
||||
|
||||
+1
-11
@@ -1,14 +1,4 @@
|
||||
type TLogoProps = {
|
||||
in_use: "emoji" | "icon";
|
||||
emoji?: {
|
||||
value?: string;
|
||||
url?: string;
|
||||
};
|
||||
icon?: {
|
||||
name?: string;
|
||||
color?: string;
|
||||
};
|
||||
};
|
||||
import { TLogoProps } from "../common";
|
||||
|
||||
export type IFavorite = {
|
||||
id: string;
|
||||
|
||||
Vendored
+1
@@ -77,6 +77,7 @@ export interface IWorkspaceMember {
|
||||
last_name?: string;
|
||||
joining_date?: string;
|
||||
display_name?: string;
|
||||
last_login_medium?: string;
|
||||
}
|
||||
|
||||
export interface IWorkspaceMemberMe {
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"@blueprintjs/popover2": "^1.13.3",
|
||||
"@headlessui/react": "^1.7.3",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@plane/helpers": "*",
|
||||
"clsx": "^2.0.0",
|
||||
"emoji-picker-react": "^4.5.16",
|
||||
"lodash": "^4.17.21",
|
||||
@@ -65,6 +66,6 @@
|
||||
"tailwindcss": "^3.4.3",
|
||||
"tsconfig": "*",
|
||||
"tsup": "^7.2.0",
|
||||
"typescript": "4.7.4"
|
||||
"typescript": "5.4.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,13 @@ import sortBy from "lodash/sortBy";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
// popper-js
|
||||
import { usePopper } from "react-popper";
|
||||
// plane helpers
|
||||
import { useOutsideClickDetector } from "@plane/helpers";
|
||||
// components
|
||||
import { DropdownButton } from "./common";
|
||||
import { DropdownOptions } from "./common/options";
|
||||
// hooks
|
||||
import { useDropdownKeyPressed } from "../hooks/use-dropdown-key-pressed";
|
||||
import useOutsideClickDetector from "../hooks/use-outside-click-detector";
|
||||
// helper
|
||||
import { cn } from "../../helpers";
|
||||
// types
|
||||
|
||||
@@ -4,12 +4,13 @@ import sortBy from "lodash/sortBy";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
// popper-js
|
||||
import { usePopper } from "react-popper";
|
||||
// plane helpers
|
||||
import { useOutsideClickDetector } from "@plane/helpers";
|
||||
// components
|
||||
import { DropdownButton } from "./common";
|
||||
import { DropdownOptions } from "./common/options";
|
||||
// hooks
|
||||
import { useDropdownKeyPressed } from "../hooks/use-dropdown-key-pressed";
|
||||
import useOutsideClickDetector from "../hooks/use-outside-click-detector";
|
||||
// helper
|
||||
import { cn } from "../../helpers";
|
||||
// types
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
// plane helpers
|
||||
import { useOutsideClickDetector } from "@plane/helpers";
|
||||
// components
|
||||
import { ContextMenuItem } from "./item";
|
||||
// helpers
|
||||
import { cn } from "../../../helpers";
|
||||
// hooks
|
||||
import useOutsideClickDetector from "../../hooks/use-outside-click-detector";
|
||||
import { usePlatformOS } from "../../hooks/use-platform-os";
|
||||
|
||||
export type TContextMenuItem = {
|
||||
|
||||
@@ -3,9 +3,10 @@ import ReactDOM from "react-dom";
|
||||
import { Menu } from "@headlessui/react";
|
||||
import { usePopper } from "react-popper";
|
||||
import { ChevronDown, MoreHorizontal } from "lucide-react";
|
||||
// plane helpers
|
||||
import { useOutsideClickDetector } from "@plane/helpers";
|
||||
// hooks
|
||||
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
|
||||
import useOutsideClickDetector from "../hooks/use-outside-click-detector";
|
||||
// helpers
|
||||
import { cn } from "../../helpers";
|
||||
// types
|
||||
|
||||
@@ -3,9 +3,10 @@ import { usePopper } from "react-popper";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
import { Check, ChevronDown, Search } from "lucide-react";
|
||||
import { createPortal } from "react-dom";
|
||||
// plane helpers
|
||||
import { useOutsideClickDetector } from "@plane/helpers";
|
||||
// hooks
|
||||
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
|
||||
import useOutsideClickDetector from "../hooks/use-outside-click-detector";
|
||||
// helpers
|
||||
import { cn } from "../../helpers";
|
||||
// types
|
||||
|
||||
@@ -2,9 +2,10 @@ import React, { useRef, useState } from "react";
|
||||
import { usePopper } from "react-popper";
|
||||
import { Listbox } from "@headlessui/react";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
// plane helpers
|
||||
import { useOutsideClickDetector } from "@plane/helpers";
|
||||
// hooks
|
||||
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
|
||||
import useOutsideClickDetector from "../hooks/use-outside-click-detector";
|
||||
// helpers
|
||||
import { cn } from "../../helpers";
|
||||
// types
|
||||
|
||||
@@ -2,10 +2,11 @@ import React, { useRef, useState } from "react";
|
||||
import { usePopper } from "react-popper";
|
||||
import { Popover, Tab } from "@headlessui/react";
|
||||
import EmojiPicker from "emoji-picker-react";
|
||||
// plane helpers
|
||||
import { useOutsideClickDetector } from "@plane/helpers";
|
||||
// helpers
|
||||
import { cn } from "../../helpers";
|
||||
// hooks
|
||||
import useOutsideClickDetector from "../hooks/use-outside-click-detector";
|
||||
import { LucideIconsList } from "./lucide-icons-list";
|
||||
// helpers
|
||||
import { EmojiIconPickerTypes, TABS_LIST, TCustomEmojiPicker } from "./emoji-icon-helper";
|
||||
|
||||
@@ -2,12 +2,13 @@ import React, { useRef, useState } from "react";
|
||||
import { usePopper } from "react-popper";
|
||||
import EmojiPicker from "emoji-picker-react";
|
||||
import { Popover, Tab } from "@headlessui/react";
|
||||
// plane helpers
|
||||
import { useOutsideClickDetector } from "@plane/helpers";
|
||||
// components
|
||||
import { IconsList } from "./icons-list";
|
||||
// helpers
|
||||
import { cn } from "../../helpers";
|
||||
// hooks
|
||||
import useOutsideClickDetector from "../hooks/use-outside-click-detector";
|
||||
import { EmojiIconPickerTypes, TABS_LIST, TCustomEmojiPicker } from "./emoji-icon-helper";
|
||||
|
||||
export const CustomEmojiIconPicker: React.FC<TCustomEmojiPicker> = (props) => {
|
||||
|
||||
@@ -12,7 +12,7 @@ export const headerStyle: IHeaderProperties = {
|
||||
[EHeaderVariant.PRIMARY]:
|
||||
"relative flex w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100",
|
||||
[EHeaderVariant.SECONDARY]: "!py-0 overflow-y-hidden border-b border-custom-border-200 justify-between",
|
||||
[EHeaderVariant.TERNARY]: "flex flex-wrap justify-between py-2 border-b border-custom-border-200 gap-2",
|
||||
[EHeaderVariant.TERNARY]: "flex flex-wrap justify-between py-2 border-b border-custom-border-200 gap-2 bg-custom-background-100 z-[12]",
|
||||
};
|
||||
export const minHeights: IHeaderProperties = {
|
||||
[EHeaderVariant.PRIMARY]: "",
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
// TODO: move it to helpers package
|
||||
const useOutsideClickDetector = (ref: React.RefObject<HTMLElement>, callback: () => void, useCapture = false) => {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||
// get all the element with attribute name data-prevent-outside-click
|
||||
const preventOutsideClickElements = document.querySelectorAll("[data-prevent-outside-click]");
|
||||
// check if the click target is any of the elements with attribute name data-prevent-outside-click
|
||||
for (let i = 0; i < preventOutsideClickElements.length; i++) {
|
||||
if (preventOutsideClickElements[i].contains(event.target as Node)) {
|
||||
// if the click target is any of the elements with attribute name data-prevent-outside-click, return
|
||||
return;
|
||||
}
|
||||
}
|
||||
// get all the element with attribute name data-delay-outside-click
|
||||
const delayOutsideClickElements = document.querySelectorAll("[data-delay-outside-click]");
|
||||
// check if the click target is any of the elements with attribute name data-delay-outside-click
|
||||
for (let i = 0; i < delayOutsideClickElements.length; i++) {
|
||||
if (delayOutsideClickElements[i].contains(event.target as Node)) {
|
||||
// if the click target is any of the elements with attribute name data-delay-outside-click, delay the callback
|
||||
setTimeout(() => {
|
||||
callback();
|
||||
}, 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// else, call the callback immediately
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("mousedown", handleClick, useCapture);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClick, useCapture);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export default useOutsideClickDetector;
|
||||
@@ -2,7 +2,7 @@ import * as React from "react";
|
||||
|
||||
import { ISvgIcons } from "./type";
|
||||
|
||||
export const DiscordIcon: React.FC<ISvgIcons> = ({ width = "24", height = "24", className, color }) => (
|
||||
export const DiscordIcon: React.FC<ISvgIcons> = ({ width = "24", height = "24", className }) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
@@ -11,15 +11,21 @@ export const DiscordIcon: React.FC<ISvgIcons> = ({ width = "24", height = "24",
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clipPath="url(#clip0_282_229)">
|
||||
<path
|
||||
fill={color ? color : "rgb(var(--color-text-200))"}
|
||||
d="M16.9312 3.64157C15.6346 3.04643 14.2662 2.62206 12.8604 2.37907C12.8476 2.37657 12.8343 2.37821 12.8225 2.38375C12.8106 2.38929 12.8009 2.39845 12.7946 2.4099C12.6196 2.7224 12.4246 3.1299 12.2879 3.45157C10.7724 3.22139 9.23088 3.22139 7.7154 3.45157C7.5633 3.09515 7.39165 2.7474 7.20123 2.4099C7.19467 2.39871 7.18486 2.38977 7.1731 2.38426C7.16135 2.37876 7.1482 2.37695 7.1354 2.37907C5.72944 2.62155 4.36101 3.04595 3.06457 3.64157C3.05359 3.64617 3.04429 3.65402 3.0379 3.66407C0.444567 7.53823 -0.266266 11.3166 0.0829005 15.0474C0.0837487 15.0567 0.0864772 15.0656 0.0909192 15.0738C0.0953611 15.082 0.101423 15.0892 0.108734 15.0949C1.6184 16.2134 3.30716 17.0672 5.1029 17.6199C5.11556 17.6236 5.12903 17.6233 5.14153 17.6191C5.15403 17.615 5.16497 17.6071 5.1729 17.5966C5.55895 17.072 5.90069 16.5162 6.19457 15.9349C6.19866 15.9269 6.20103 15.9182 6.2015 15.9093C6.20198 15.9003 6.20056 15.8914 6.19733 15.8831C6.1941 15.8747 6.18914 15.8671 6.18278 15.8609C6.17641 15.8546 6.16878 15.8497 6.1604 15.8466C5.62159 15.6404 5.09995 15.3918 4.6004 15.1032C4.59124 15.0979 4.58354 15.0905 4.57797 15.0815C4.5724 15.0725 4.56914 15.0622 4.56848 15.0517C4.56782 15.0411 4.56978 15.0306 4.57418 15.021C4.57859 15.0113 4.58531 15.003 4.59373 14.9966C4.69893 14.9179 4.80229 14.8367 4.90373 14.7532C4.91261 14.746 4.92331 14.7414 4.93464 14.74C4.94597 14.7385 4.95748 14.7402 4.9679 14.7449C8.24123 16.2391 11.7846 16.2391 15.0196 14.7449C15.0301 14.74 15.0418 14.7382 15.0533 14.7397C15.0648 14.7412 15.0756 14.7459 15.0846 14.7532C15.1846 14.8349 15.2896 14.9182 15.3954 14.9966C15.4037 15.0029 15.4104 15.0111 15.4148 15.0205C15.4193 15.03 15.4213 15.0404 15.4208 15.0508C15.4203 15.0612 15.4173 15.0714 15.412 15.0804C15.4067 15.0894 15.3993 15.0969 15.3904 15.1024C14.892 15.3937 14.3699 15.6424 13.8296 15.8457C13.8212 15.849 13.8135 15.8539 13.8071 15.8603C13.8008 15.8666 13.7958 15.8743 13.7926 15.8827C13.7894 15.8911 13.788 15.9001 13.7884 15.9091C13.7889 15.9181 13.7913 15.9269 13.7954 15.9349C14.0954 16.5166 14.4387 17.0699 14.8162 17.5957C14.824 17.6064 14.8349 17.6145 14.8475 17.6186C14.86 17.6228 14.8736 17.623 14.8862 17.6191C16.685 17.0681 18.3765 16.2142 19.8879 15.0941C19.8953 15.0889 19.9014 15.0822 19.906 15.0744C19.9106 15.0667 19.9135 15.058 19.9146 15.0491C20.3312 10.7349 19.2162 6.9874 16.9571 3.66573C16.9518 3.65453 16.9426 3.64564 16.9312 3.64073V3.64157ZM6.68373 12.7749C5.6979 12.7749 4.88623 11.8707 4.88623 10.7591C4.88623 9.64823 5.6829 8.74323 6.68373 8.74323C7.69207 8.74323 8.49707 9.65657 8.48123 10.7599C8.48123 11.8707 7.68457 12.7749 6.68373 12.7749ZM13.3296 12.7749C12.3437 12.7749 11.5321 11.8707 11.5321 10.7591C11.5321 9.64823 12.3279 8.74323 13.3296 8.74323C14.3379 8.74323 15.1429 9.65657 15.1271 10.7599C15.1271 11.8707 14.3387 12.7749 13.3296 12.7749Z"
|
||||
/>
|
||||
<g clip-path="url(#clip0_4112_17440)">
|
||||
<mask id="mask0_4112_17440" maskUnits="userSpaceOnUse" x="0" y="0" width={width} height={height}>
|
||||
<path d="M24 0H0V24H24V0Z" fill="white" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_4112_17440)">
|
||||
<path
|
||||
d="M18.9312 5.63864C17.6346 5.0435 16.2662 4.61913 14.8604 4.37614C14.8476 4.37364 14.8343 4.37528 14.8225 4.38082C14.8106 4.38636 14.8009 4.39552 14.7946 4.40697C14.6196 4.71947 14.4246 5.12697 14.2879 5.44864C12.7724 5.21846 11.2309 5.21846 9.7154 5.44864C9.5633 5.09222 9.39165 4.74447 9.20123 4.40697C9.19467 4.39578 9.18486 4.38684 9.1731 4.38133C9.16135 4.37583 9.1482 4.37402 9.1354 4.37614C7.72944 4.61862 6.36101 5.04302 5.06457 5.63864C5.05359 5.64324 5.04429 5.65109 5.0379 5.66114C2.44457 9.5353 1.73373 13.3137 2.0829 17.0445C2.08375 17.0538 2.08648 17.0627 2.09092 17.0709C2.09536 17.0791 2.10142 17.0863 2.10873 17.092C3.6184 18.2105 5.30716 19.0643 7.1029 19.617C7.11556 19.6207 7.12903 19.6204 7.14153 19.6162C7.15403 19.6121 7.16497 19.6042 7.1729 19.5937C7.55895 19.0691 7.90069 18.5133 8.19457 17.932C8.19866 17.924 8.20103 17.9153 8.2015 17.9064C8.20198 17.8974 8.20056 17.8885 8.19733 17.8802C8.1941 17.8718 8.18914 17.8642 8.18278 17.858C8.17641 17.8517 8.16878 17.8468 8.1604 17.8437C7.62159 17.6375 7.09995 17.3889 6.6004 17.1003C6.59124 17.095 6.58354 17.0876 6.57797 17.0786C6.5724 17.0696 6.56914 17.0593 6.56848 17.0488C6.56782 17.0382 6.56978 17.0277 6.57418 17.0181C6.57859 17.0084 6.58531 17.0001 6.59373 16.9937C6.69893 16.915 6.80229 16.8338 6.90373 16.7503C6.91261 16.7431 6.92331 16.7385 6.93464 16.7371C6.94597 16.7356 6.95748 16.7373 6.9679 16.742C10.2412 18.2362 13.7846 18.2362 17.0196 16.742C17.0301 16.7371 17.0418 16.7353 17.0533 16.7368C17.0648 16.7383 17.0756 16.743 17.0846 16.7503C17.1846 16.832 17.2896 16.9153 17.3954 16.9937C17.4037 17 17.4104 17.0082 17.4148 17.0176C17.4193 17.0271 17.4213 17.0375 17.4208 17.0479C17.4203 17.0583 17.4173 17.0685 17.412 17.0775C17.4067 17.0865 17.3993 17.094 17.3904 17.0995C16.892 17.3908 16.3699 17.6395 15.8296 17.8428C15.8212 17.8461 15.8135 17.851 15.8071 17.8574C15.8008 17.8637 15.7958 17.8714 15.7926 17.8798C15.7894 17.8882 15.788 17.8972 15.7884 17.9062C15.7889 17.9152 15.7913 17.924 15.7954 17.932C16.0954 18.5137 16.4387 19.067 16.8162 19.5928C16.824 19.6035 16.8349 19.6116 16.8475 19.6157C16.86 19.6199 16.8736 19.6201 16.8862 19.6162C18.685 19.0652 20.3765 18.2113 21.8879 17.0912C21.8953 17.086 21.9014 17.0793 21.906 17.0715C21.9106 17.0638 21.9135 17.0551 21.9146 17.0462C22.3312 12.732 21.2162 8.98447 18.9571 5.6628C18.9518 5.6516 18.9426 5.64355 18.9312 5.63864ZM8.68373 14.772C7.6979 14.772 6.88623 13.8678 6.88623 12.7562C6.88623 11.6453 7.6829 10.7403 8.68373 10.7403C9.69207 10.7403 10.4971 11.6536 10.4812 12.757C10.4812 13.8678 9.68457 14.772 8.68373 14.772ZM15.3296 14.772C14.3437 14.772 13.5321 13.8678 13.5321 12.7562C13.5321 11.6453 14.3279 10.7403 15.3296 10.7403C16.3379 10.7403 17.1429 11.6536 17.1271 12.757C17.1271 13.8678 16.3387 14.772 15.3296 14.772Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_282_229">
|
||||
<rect width={width} height={height} />
|
||||
<clipPath id="clip0_4112_17440">
|
||||
<rect width={width} height={height} fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
const useOutSideClick = (ref: any, callback: any) => {
|
||||
const handleClick = (e: any) => {
|
||||
if (ref.current && !ref.current.contains(e.target)) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("click", handleClick);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClick);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export default useOutSideClick;
|
||||
@@ -69,7 +69,9 @@ export const isCommentEmpty = (comment: string | undefined): boolean => {
|
||||
// return true if comment is undefined
|
||||
if (!comment) return true;
|
||||
return (
|
||||
comment?.trim() === "" || comment === "<p></p>" || isEmptyHtmlString(comment ?? "", ["img", "mention-component"])
|
||||
comment?.trim() === "" ||
|
||||
comment === "<p></p>" ||
|
||||
isEmptyHtmlString(comment ?? "", ["img", "mention-component", "image-component"])
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+4
-4
@@ -17,10 +17,10 @@
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@headlessui/react": "^1.7.13",
|
||||
"@mui/material": "^5.14.1",
|
||||
"@plane/constants": "*",
|
||||
"@plane/editor": "*",
|
||||
"@plane/types": "*",
|
||||
"@plane/ui": "*",
|
||||
"@plane/constants": "*",
|
||||
"@sentry/nextjs": "^8",
|
||||
"axios": "^1.7.4",
|
||||
"clsx": "^2.0.0",
|
||||
@@ -34,7 +34,7 @@
|
||||
"mobx": "^6.10.0",
|
||||
"mobx-react": "^9.1.1",
|
||||
"mobx-utils": "^6.0.8",
|
||||
"next": "^14.2.3",
|
||||
"next": "^14.2.12",
|
||||
"next-themes": "^0.2.1",
|
||||
"nprogress": "^0.2.0",
|
||||
"react": "^18.3.1",
|
||||
@@ -44,7 +44,6 @@
|
||||
"react-popper": "^2.3.0",
|
||||
"swr": "^2.2.2",
|
||||
"tailwind-merge": "^2.0.0",
|
||||
"typescript": "4.9.5",
|
||||
"uuid": "^9.0.0",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
@@ -61,6 +60,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^5.48.2",
|
||||
"eslint-config-custom": "*",
|
||||
"tailwind-config-custom": "*",
|
||||
"tsconfig": "*"
|
||||
"tsconfig": "*",
|
||||
"typescript": "5.4.5"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-4
@@ -23,10 +23,7 @@
|
||||
"SENTRY_PROJECT_ID",
|
||||
"NEXT_PUBLIC_SENTRY_ENVIRONMENT",
|
||||
"NEXT_PUBLIC_SENTRY_DSN",
|
||||
"SENTRY_MONITORING_ENABLED",
|
||||
"NEXT_PUBLIC_PRO_PLAN_MONTHLY_PAYMENT_URL",
|
||||
"NEXT_PUBLIC_PRO_PLAN_YEARLY_PAYMENT_URL",
|
||||
"NEXT_PUBLIC_PLANE_ONE_PAYMENT_URL"
|
||||
"SENTRY_MONITORING_ENABLED"
|
||||
],
|
||||
"tasks": {
|
||||
"build": {
|
||||
|
||||
+5
-2
@@ -9,12 +9,15 @@ module.exports = {
|
||||
moduleDirectory: ["node_modules", "."],
|
||||
},
|
||||
},
|
||||
next: {
|
||||
rootDir: ["."],
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"import/order": [
|
||||
"error",
|
||||
{
|
||||
groups: ["builtin", "external", "internal", "parent", "sibling",],
|
||||
groups: ["builtin", "external", "internal", "parent", "sibling"],
|
||||
pathGroups: [
|
||||
{
|
||||
pattern: "react",
|
||||
@@ -39,7 +42,7 @@ module.exports = {
|
||||
{
|
||||
pattern: "@/**",
|
||||
group: "internal",
|
||||
}
|
||||
},
|
||||
],
|
||||
pathGroupsExcludedImportTypes: ["builtin", "internal", "react"],
|
||||
alphabetize: {
|
||||
|
||||
+1
-2
@@ -24,7 +24,7 @@ const ArchivedIssueDetailsPage = observer(() => {
|
||||
|
||||
const { getProjectById } = useProject();
|
||||
|
||||
const { isLoading, data: swrArchivedIssueDetails } = useSWR(
|
||||
const { isLoading } = useSWR(
|
||||
workspaceSlug && projectId && archivedIssueId
|
||||
? `ARCHIVED_ISSUE_DETAIL_${workspaceSlug}_${projectId}_${archivedIssueId}`
|
||||
: null,
|
||||
@@ -65,7 +65,6 @@ const ArchivedIssueDetailsPage = observer(() => {
|
||||
<div className="h-full w-full space-y-3 divide-y-2 divide-custom-border-200 overflow-y-auto">
|
||||
{workspaceSlug && projectId && archivedIssueId && (
|
||||
<IssueDetailRoot
|
||||
swrIssueDetails={swrArchivedIssueDetails}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
issueId={archivedIssueId.toString()}
|
||||
|
||||
+1
-6
@@ -32,11 +32,7 @@ const IssueDetailsPage = observer(() => {
|
||||
const { getProjectById } = useProject();
|
||||
const { toggleIssueDetailSidebar, issueDetailSidebarCollapsed } = useAppTheme();
|
||||
// fetching issue details
|
||||
const {
|
||||
isLoading,
|
||||
data: swrIssueDetails,
|
||||
error,
|
||||
} = useSWR(
|
||||
const { isLoading, error } = useSWR(
|
||||
workspaceSlug && projectId && issueId ? `ISSUE_DETAIL_${workspaceSlug}_${projectId}_${issueId}` : null,
|
||||
workspaceSlug && projectId && issueId
|
||||
? () => fetchIssue(workspaceSlug.toString(), projectId.toString(), issueId.toString())
|
||||
@@ -95,7 +91,6 @@ const IssueDetailsPage = observer(() => {
|
||||
projectId &&
|
||||
issueId && (
|
||||
<IssueDetailRoot
|
||||
swrIssueDetails={swrIssueDetails}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
issueId={issueId.toString()}
|
||||
|
||||
+11
-2
@@ -15,10 +15,11 @@ import { PageEditInformationPopover } from "@/components/pages";
|
||||
import { convertHexEmojiToDecimal } from "@/helpers/emoji.helper";
|
||||
import { getPageName } from "@/helpers/page.helper";
|
||||
// hooks
|
||||
import { usePage, useProject } from "@/hooks/store";
|
||||
import { usePage, useProject, useUser, useUserPermissions } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web components
|
||||
import { PageDetailsHeaderExtraActions } from "@/plane-web/components/pages";
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "ee/constants/user-permissions";
|
||||
|
||||
export interface IPagesHeaderProps {
|
||||
showButton?: boolean;
|
||||
@@ -32,10 +33,17 @@ export const PageDetailsHeader = observer(() => {
|
||||
// store hooks
|
||||
const { currentProjectDetails, loader } = useProject();
|
||||
const page = usePage(pageId?.toString() ?? "");
|
||||
const { name, logo_props, updatePageLogo } = page;
|
||||
const { name, logo_props, updatePageLogo, owned_by } = page;
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { data: currentUser } = useUser();
|
||||
// use platform
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
|
||||
const isOwner = owned_by === currentUser?.id;
|
||||
|
||||
const isEditable = isAdmin || isOwner;
|
||||
|
||||
const handlePageLogoUpdate = async (data: TLogoProps) => {
|
||||
if (data) {
|
||||
updatePageLogo(data)
|
||||
@@ -144,6 +152,7 @@ export const PageDetailsHeader = observer(() => {
|
||||
? EmojiIconPickerTypes.EMOJI
|
||||
: EmojiIconPickerTypes.ICON
|
||||
}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</div>
|
||||
<Tooltip tooltipContent={pageTitle} position="bottom" isMobile={isMobile}>
|
||||
|
||||
+39
-19
@@ -1,34 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { FileText } from "lucide-react";
|
||||
// ui
|
||||
import { Breadcrumbs, Button, Header } from "@plane/ui";
|
||||
// plane types
|
||||
import { TPage } from "@plane/types";
|
||||
// plane ui
|
||||
import { Breadcrumbs, Button, Header, setToast, TOAST_TYPE } from "@plane/ui";
|
||||
// helpers
|
||||
import { BreadcrumbLink, Logo } from "@/components/common";
|
||||
// constants
|
||||
import { EPageAccess } from "@/constants/page";
|
||||
// hooks
|
||||
import { useCommandPalette, useEventTracker, useProject, useUserPermissions } from "@/hooks/store";
|
||||
import { useEventTracker, useProject, useProjectPages, useUserPermissions } from "@/hooks/store";
|
||||
// plane web hooks
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
|
||||
|
||||
export const PagesListHeader = observer(() => {
|
||||
// states
|
||||
const [isCreatingPage, setIsCreatingPage] = useState(false);
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const pageType = searchParams.get("type");
|
||||
// store hooks
|
||||
const { toggleCreatePageModal } = useCommandPalette();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
const { currentProjectDetails, loader } = useProject();
|
||||
const { createPage } = useProjectPages();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
|
||||
// auth
|
||||
const canUserCreatePage = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
|
||||
EUserPermissionsLevel.PROJECT
|
||||
);
|
||||
// handle page create
|
||||
const handleCreatePage = async () => {
|
||||
setIsCreatingPage(true);
|
||||
setTrackElement("Project pages page");
|
||||
|
||||
const payload: Partial<TPage> = {
|
||||
access: pageType === "private" ? EPageAccess.PRIVATE : EPageAccess.PUBLIC,
|
||||
};
|
||||
|
||||
await createPage(payload)
|
||||
.then((res) => {
|
||||
const pageId = `/${workspaceSlug}/projects/${currentProjectDetails?.id}/pages/${res?.id}`;
|
||||
router.push(pageId);
|
||||
})
|
||||
.catch((err) =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: err?.data?.error || "Page could not be created. Please try again.",
|
||||
})
|
||||
)
|
||||
.finally(() => setIsCreatingPage(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<Header>
|
||||
@@ -60,18 +90,8 @@ export const PagesListHeader = observer(() => {
|
||||
</Header.LeftItem>
|
||||
{canUserCreatePage ? (
|
||||
<Header.RightItem>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTrackElement("Project pages page");
|
||||
toggleCreatePageModal({
|
||||
isOpen: true,
|
||||
pageAccess: pageType === "private" ? EPageAccess.PRIVATE : EPageAccess.PUBLIC,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Add page
|
||||
<Button variant="primary" size="sm" onClick={handleCreatePage} loading={isCreatingPage}>
|
||||
{isCreatingPage ? "Adding" : "Add page"}
|
||||
</Button>
|
||||
</Header.RightItem>
|
||||
) : (
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ const ProjectSettingLayout: FC<IProjectSettingLayout> = (props) => {
|
||||
<ProjectSettingsSidebar />
|
||||
</div>
|
||||
<div className="flex flex-col relative w-full overflow-hidden">
|
||||
<div className="w-full overflow-x-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-page-x md:px-9 py-page-y">
|
||||
<div className="h-full w-full overflow-x-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-page-x md:px-9 py-page-y">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { FC, ReactNode } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { AppHeader } from "@/components/core";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store";
|
||||
// plane web constants
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
|
||||
// local components
|
||||
import { WorkspaceSettingHeader } from "./header";
|
||||
import { MobileWorkspaceSettingsTabs } from "./mobile-header-tabs";
|
||||
@@ -12,23 +18,36 @@ export interface IWorkspaceSettingLayout {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function WorkspaceSettingLayout(props: IWorkspaceSettingLayout) {
|
||||
const WorkspaceSettingLayout: FC<IWorkspaceSettingLayout> = observer((props) => {
|
||||
const { children } = props;
|
||||
|
||||
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
|
||||
|
||||
// derived values
|
||||
const isWorkspaceAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<WorkspaceSettingHeader />} />
|
||||
<MobileWorkspaceSettingsTabs />
|
||||
<div className="inset-y-0 flex flex-row vertical-scrollbar scrollbar-lg h-full w-full overflow-y-auto">
|
||||
<div className="px-page-x !pr-0 py-page-y flex-shrink-0 overflow-y-hidden sm:hidden hidden md:block lg:block">
|
||||
<WorkspaceSettingsSidebar />
|
||||
</div>
|
||||
<div className="flex flex-col relative w-full overflow-hidden">
|
||||
<div className="w-full h-full overflow-x-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-page-x md:px-9 py-page-y">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
{workspaceUserInfo && !isWorkspaceAdmin ? (
|
||||
<NotAuthorizedView section="settings" />
|
||||
) : (
|
||||
<>
|
||||
<div className="px-page-x !pr-0 py-page-y flex-shrink-0 overflow-y-hidden sm:hidden hidden md:block lg:block">
|
||||
<WorkspaceSettingsSidebar />
|
||||
</div>
|
||||
<div className="flex flex-col relative w-full overflow-hidden">
|
||||
<div className="w-full h-full overflow-x-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-page-x md:px-9 py-page-y">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default WorkspaceSettingLayout;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { FC, useEffect, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane helpers
|
||||
import { useOutsideClickDetector } from "@plane/helpers";
|
||||
// components
|
||||
import {
|
||||
SidebarDropdown,
|
||||
@@ -14,7 +16,6 @@ import { SidebarFavoritesMenu } from "@/components/workspace/sidebar/favorites/f
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useAppTheme, useUserPermissions } from "@/hooks/store";
|
||||
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
|
||||
// plane web components
|
||||
import useSize from "@/hooks/use-window-size";
|
||||
import { SidebarAppSwitcher } from "@/plane-web/components/sidebar";
|
||||
|
||||
@@ -156,7 +156,7 @@ const OnboardingPage = observer(() => {
|
||||
/>
|
||||
) : step === EOnboardingSteps.WORKSPACE_CREATE_OR_JOIN ? (
|
||||
<CreateOrJoinWorkspaces
|
||||
invitations={invitations ?? []}
|
||||
invitations={invitations ?? []}
|
||||
totalSteps={totalSteps}
|
||||
stepChange={stepChange}
|
||||
finishOnboarding={finishOnboarding}
|
||||
|
||||
@@ -6,6 +6,8 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
// icons
|
||||
import { ChevronLeft, LogOut, MoveLeft, Plus, UserPlus } from "lucide-react";
|
||||
// plane helpers
|
||||
import { useOutsideClickDetector } from "@plane/helpers";
|
||||
// ui
|
||||
import { TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
|
||||
// components
|
||||
@@ -16,7 +18,6 @@ import { PROFILE_ACTION_LINKS } from "@/constants/profile";
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useAppTheme, useUser, useUserSettings, useWorkspace } from "@/hooks/store";
|
||||
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
const WORKSPACE_ACTION_LINKS = [
|
||||
|
||||
@@ -15,7 +15,7 @@ export const ProductUpdates: FC<ProductUpdatesProps> = observer(() => (
|
||||
target="_blank"
|
||||
className="flex w-full items-center justify-start text-xs hover:bg-custom-background-80"
|
||||
>
|
||||
<span className="text-xs">What's new?</span>
|
||||
<span className="text-xs">What's new</span>
|
||||
</Link>
|
||||
</CustomMenu.MenuItem>
|
||||
));
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { FC, useEffect, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { UseFormRegister, UseFormSetFocus } from "react-hook-form";
|
||||
// plane helpers
|
||||
import { useOutsideClickDetector } from "@plane/helpers";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
// components
|
||||
@@ -17,7 +19,6 @@ import { EIssueLayoutTypes } from "@/constants/issue";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
import useKeypress from "@/hooks/use-keypress";
|
||||
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
|
||||
|
||||
export type TQuickAddIssueFormRoot = {
|
||||
isOpen: boolean;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { TActivityFilters, ACTIVITY_FILTER_TYPE_OPTIONS, TActivityFilterOption }
|
||||
export type TActivityFilterRoot = {
|
||||
selectedFilters: TActivityFilters[];
|
||||
toggleFilter: (filter: TActivityFilters) => void;
|
||||
isIntakeIssue?: boolean;
|
||||
};
|
||||
|
||||
export const ActivityFilterRoot: FC<TActivityFilterRoot> = (props) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Button } from "@plane/ui";
|
||||
import { MARKETING_PRICING_PAGE_LINK } from "@/constants/common";
|
||||
|
||||
export const BillingRoot = () => (
|
||||
<section className="w-full overflow-y-auto md:pr-9 pr-4">
|
||||
<section className="w-full overflow-y-auto">
|
||||
<div>
|
||||
<div className="flex items-center border-b border-custom-border-100 pb-3.5">
|
||||
<h3 className="text-xl font-medium">Billing and Plans</h3>
|
||||
|
||||
@@ -25,8 +25,8 @@ export const useMemberColumns = () => {
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "Full Name",
|
||||
content: "Full Name",
|
||||
key: "Full name",
|
||||
content: "Full name",
|
||||
thClassName: "text-left",
|
||||
tdRender: (rowData: RowData) => (
|
||||
<NameColumn
|
||||
@@ -38,21 +38,36 @@ export const useMemberColumns = () => {
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
key: "Display Name",
|
||||
content: "Display Name",
|
||||
key: "Display name",
|
||||
content: "Display name",
|
||||
tdRender: (rowData: RowData) => <div className="w-32">{rowData.member.display_name}</div>,
|
||||
},
|
||||
|
||||
{
|
||||
key: "Account Type",
|
||||
content: "Account Type",
|
||||
key: "Email address",
|
||||
content: "Email address",
|
||||
tdRender: (rowData: RowData) => <div className="w-48 truncate">{rowData.member.email}</div>,
|
||||
},
|
||||
|
||||
{
|
||||
key: "Account type",
|
||||
content: "Account type",
|
||||
tdRender: (rowData: RowData) => <AccountTypeColumn rowData={rowData} workspaceSlug={workspaceSlug as string} />,
|
||||
},
|
||||
|
||||
{
|
||||
key: "Joining Date",
|
||||
content: "Joining Date",
|
||||
key: "Authentication",
|
||||
content: "Authentication",
|
||||
tdRender: (rowData: RowData) => (
|
||||
<div className="capitalize">{rowData.member.last_login_medium?.replace("-", " ")}</div>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
key: "Joining date",
|
||||
content: "Joining date",
|
||||
tdRender: (rowData: RowData) => <div>{getFormattedDate(rowData?.member?.joining_date || "")}</div>,
|
||||
},
|
||||
];
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user