Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22836ea03e | |||
| 13cc8b0e96 | |||
| 9addcde553 | |||
| 26a9b7fced | |||
| 62dd80874f | |||
| 9ae1ce0a9a | |||
| 00cc338c07 | |||
| 4432be15e4 | |||
| 20893c6017 | |||
| 95f43a7bb6 | |||
| fd7eedc343 | |||
| d2c9b437f4 | |||
| 2f57d0e138 | |||
| 59ddc02a31 | |||
| 3ac20741d9 | |||
| 8ea0772a1b | |||
| bddad8932b | |||
| 8acea7f599 | |||
| a908bf9edd | |||
| 79fff4744a | |||
| 369d927321 | |||
| 996d11de12 | |||
| 0345336d90 | |||
| 75d14e7c3a | |||
| 76fdb81249 | |||
| 88669af141 | |||
| 71dcbd938e | |||
| 4060412b18 | |||
| 9d715683f7 | |||
| 0eb97e32cd | |||
| 3f708e451c | |||
| e962770a5f | |||
| 4bd6ee5014 | |||
| e9372adcf4 | |||
| ae3b588081 | |||
| 7183cffdb7 | |||
| c779fc095c | |||
| b5493a31f8 | |||
| 25eb727eb9 | |||
| ade4d290f5 | |||
| 39ca600091 | |||
| cfdb3373c9 | |||
| 95175ab939 | |||
| 42e928138c | |||
| 8db51ab295 |
@@ -2,7 +2,4 @@ module.exports = {
|
||||
root: true,
|
||||
extends: ["@plane/eslint-config/next.js"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
project: true,
|
||||
},
|
||||
};
|
||||
|
||||
+1
-2
@@ -35,19 +35,18 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "7.51.5",
|
||||
"swr": "^2.2.4",
|
||||
"tailwindcss": "3.3.2",
|
||||
"uuid": "^9.0.1",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "*",
|
||||
"@plane/tailwind-config": "*",
|
||||
"@plane/typescript-config": "*",
|
||||
"@types/node": "18.16.1",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@types/zxcvbn": "^4.4.4",
|
||||
"tailwind-config-custom": "*",
|
||||
"typescript": "5.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const sharedConfig = require("tailwind-config-custom/tailwind.config.js");
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const sharedConfig = require("@plane/tailwind-config/tailwind.config.js");
|
||||
|
||||
module.exports = {
|
||||
presets: [sharedConfig],
|
||||
|
||||
@@ -177,9 +177,10 @@ class ProjectRecentVisitSerializer(serializers.ModelSerializer):
|
||||
fields = ["id", "name", "logo_props", "project_members", "identifier"]
|
||||
|
||||
def get_project_members(self, obj):
|
||||
members = ProjectMember.objects.filter(project_id=obj.id).values_list(
|
||||
"member", flat=True
|
||||
)
|
||||
members = ProjectMember.objects.filter(
|
||||
project_id=obj.id, member__is_bot=False, is_active=True
|
||||
).values_list("member", flat=True)
|
||||
|
||||
return members
|
||||
|
||||
|
||||
@@ -248,7 +249,7 @@ class WorkspaceHomePreferenceSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = WorkspaceHomePreference
|
||||
fields = ["key", "is_enabled", "sort_order"]
|
||||
read_only_fields = ["worspace", "created_by", "update_by"]
|
||||
read_only_fields = ["workspace", "created_by", "updated_by"]
|
||||
|
||||
|
||||
class StickySerializer(BaseSerializer):
|
||||
|
||||
@@ -29,7 +29,7 @@ from plane.app.views import (
|
||||
WorkspaceDraftIssueViewSet,
|
||||
QuickLinkViewSet,
|
||||
UserRecentVisitViewSet,
|
||||
WorkspacePreferenceViewSet,
|
||||
WorkspaceHomePreferenceViewSet,
|
||||
WorkspaceStickyViewSet,
|
||||
)
|
||||
|
||||
@@ -233,12 +233,12 @@ urlpatterns = [
|
||||
# Widgets
|
||||
path(
|
||||
"workspaces/<str:slug>/home-preferences/",
|
||||
WorkspacePreferenceViewSet.as_view(),
|
||||
WorkspaceHomePreferenceViewSet.as_view(),
|
||||
name="workspace-home-preference",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/home-preferences/<str:key>/",
|
||||
WorkspacePreferenceViewSet.as_view(),
|
||||
WorkspaceHomePreferenceViewSet.as_view(),
|
||||
name="workspace-home-preference",
|
||||
),
|
||||
path(
|
||||
|
||||
@@ -41,7 +41,8 @@ from .workspace.base import (
|
||||
|
||||
from .workspace.draft import WorkspaceDraftIssueViewSet
|
||||
|
||||
from .workspace.preference import WorkspacePreferenceViewSet
|
||||
from .workspace.home import WorkspaceHomePreferenceViewSet
|
||||
|
||||
from .workspace.favorite import (
|
||||
WorkspaceFavoriteEndpoint,
|
||||
WorkspaceFavoriteGroupEndpoint,
|
||||
|
||||
@@ -132,6 +132,18 @@ class CycleViewSet(BaseViewSet):
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"issue_cycle__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group__in=["backlog", "unstarted", "started"],
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
status=Case(
|
||||
When(
|
||||
@@ -214,6 +226,7 @@ class CycleViewSet(BaseViewSet):
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
"completed_issues",
|
||||
"pending_issues",
|
||||
"assignee_ids",
|
||||
"status",
|
||||
"version",
|
||||
@@ -245,6 +258,7 @@ class CycleViewSet(BaseViewSet):
|
||||
# meta fields
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
"pending_issues",
|
||||
"completed_issues",
|
||||
"assignee_ids",
|
||||
"status",
|
||||
|
||||
@@ -53,10 +53,10 @@ from .. import BaseAPIView
|
||||
def dashboard_overview_stats(self, request, slug):
|
||||
assigned_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
(Q(assignees__in=[request.user]) & Q(issue_assignee__deleted_at__isnull=True)),
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__member=request.user,
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user],
|
||||
)
|
||||
.filter(
|
||||
Q(
|
||||
@@ -133,10 +133,13 @@ def dashboard_overview_stats(self, request, slug):
|
||||
|
||||
completed_issues_count = (
|
||||
Issue.issue_objects.filter(
|
||||
(
|
||||
Q(assignees__in=[request.user])
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__member=request.user,
|
||||
assignees__in=[request.user],
|
||||
state__group="completed",
|
||||
)
|
||||
.filter(
|
||||
@@ -176,10 +179,13 @@ def dashboard_assigned_issues(self, request, slug):
|
||||
# get all the assigned issues
|
||||
assigned_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
(
|
||||
Q(assignees__in=[request.user])
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
assignees__in=[request.user],
|
||||
)
|
||||
.filter(**filters)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
@@ -707,17 +713,17 @@ class DashboardEndpoint(BaseAPIView):
|
||||
|
||||
updated_dashboard_widgets = []
|
||||
for widget_key in widgets_to_fetch:
|
||||
widget = DeprecatedWidget.objects.filter(key=widget_key).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
widget = DeprecatedWidget.objects.filter(
|
||||
key=widget_key
|
||||
).values_list("id", flat=True)
|
||||
if widget:
|
||||
updated_dashboard_widgets.append(
|
||||
DashboardWidget(
|
||||
DeprecatedDashboardWidget(
|
||||
widget_id=widget, dashboard_id=dashboard.id
|
||||
)
|
||||
)
|
||||
|
||||
DashboardWidget.objects.bulk_create(
|
||||
DeprecatedDashboardWidget.objects.bulk_create(
|
||||
updated_dashboard_widgets, batch_size=100
|
||||
)
|
||||
|
||||
@@ -733,7 +739,7 @@ class DashboardEndpoint(BaseAPIView):
|
||||
)
|
||||
.annotate(
|
||||
dashboard_filters=Subquery(
|
||||
DashboardWidget.objects.filter(
|
||||
DeprecatedDashboardWidget.objects.filter(
|
||||
widget_id=OuterRef("pk"),
|
||||
dashboard_id=dashboard.id,
|
||||
filters__isnull=False,
|
||||
@@ -792,7 +798,7 @@ class DashboardEndpoint(BaseAPIView):
|
||||
|
||||
class WidgetsEndpoint(BaseAPIView):
|
||||
def patch(self, request, dashboard_id, widget_id):
|
||||
dashboard_widget = DashboardWidget.objects.filter(
|
||||
dashboard_widget = DeprecatedDashboardWidget.objects.filter(
|
||||
widget_id=widget_id, dashboard_id=dashboard_id
|
||||
).first()
|
||||
dashboard_widget.is_visible = request.data.get(
|
||||
|
||||
@@ -573,6 +573,8 @@ class PageDuplicateEndpoint(BaseAPIView):
|
||||
page.name = f"{page.name} (Copy)"
|
||||
page.description_binary = None
|
||||
page.owned_by = request.user
|
||||
page.created_by = request.user
|
||||
page.updated_by = request.user
|
||||
page.save()
|
||||
|
||||
for project_id in project_ids:
|
||||
|
||||
+2
-7
@@ -5,17 +5,12 @@ from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.db.models import Workspace
|
||||
from plane.app.serializers.workspace import WorkspaceHomePreferenceSerializer
|
||||
|
||||
# Django imports
|
||||
|
||||
from django.db.models import Count
|
||||
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
|
||||
class WorkspacePreferenceViewSet(BaseAPIView):
|
||||
class WorkspaceHomePreferenceViewSet(BaseAPIView):
|
||||
model = WorkspaceHomePreference
|
||||
|
||||
def get_serializer_class(self):
|
||||
@@ -72,7 +67,7 @@ class WorkspacePreferenceViewSet(BaseAPIView):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def patch(self, request, slug, key):
|
||||
preference = WorkspaceHomePreference.objects.filter(
|
||||
key=key, workspace__slug=slug
|
||||
key=key, workspace__slug=slug, user=request.user
|
||||
).first()
|
||||
|
||||
if preference:
|
||||
@@ -39,9 +39,9 @@ class WorkspaceStickyViewSet(BaseViewSet):
|
||||
)
|
||||
def list(self, request, slug):
|
||||
query = request.query_params.get("query", False)
|
||||
stickies = self.get_queryset()
|
||||
stickies = self.get_queryset().order_by("-sort_order")
|
||||
if query:
|
||||
stickies = stickies.filter(name__icontains=query)
|
||||
stickies = stickies.filter(description_stripped__icontains=query)
|
||||
|
||||
return self.paginate(
|
||||
request=request,
|
||||
@@ -49,7 +49,7 @@ class WorkspaceStickyViewSet(BaseViewSet):
|
||||
on_results=lambda stickies: StickySerializer(stickies, many=True).data,
|
||||
default_per_page=20,
|
||||
)
|
||||
|
||||
|
||||
@allow_permission(allowed_roles=[], creator=True, model=Sticky, level="WORKSPACE")
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
@@ -375,8 +375,11 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
||||
|
||||
state_distribution = (
|
||||
Issue.issue_objects.filter(
|
||||
(
|
||||
Q(assignees__in=[user_id])
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
workspace__slug=slug,
|
||||
assignees__in=[user_id],
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
@@ -391,8 +394,11 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
||||
|
||||
priority_distribution = (
|
||||
Issue.issue_objects.filter(
|
||||
(
|
||||
Q(assignees__in=[user_id])
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
workspace__slug=slug,
|
||||
assignees__in=[user_id],
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
@@ -426,8 +432,11 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
||||
|
||||
assigned_issues_count = (
|
||||
Issue.issue_objects.filter(
|
||||
(
|
||||
Q(assignees__in=[user_id])
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
workspace__slug=slug,
|
||||
assignees__in=[user_id],
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
@@ -438,8 +447,11 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
||||
pending_issues_count = (
|
||||
Issue.issue_objects.filter(
|
||||
~Q(state__group__in=["completed", "cancelled"]),
|
||||
(
|
||||
Q(assignees__in=[user_id])
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
workspace__slug=slug,
|
||||
assignees__in=[user_id],
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
@@ -449,8 +461,11 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
||||
|
||||
completed_issues_count = (
|
||||
Issue.issue_objects.filter(
|
||||
(
|
||||
Q(assignees__in=[user_id])
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
workspace__slug=slug,
|
||||
assignees__in=[user_id],
|
||||
state__group="completed",
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
|
||||
@@ -16,9 +16,9 @@ from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@shared_task
|
||||
def workspace_invitation(email, workspace_id, token, current_site, invitor):
|
||||
def workspace_invitation(email, workspace_id, token, current_site, inviter):
|
||||
try:
|
||||
user = User.objects.get(email=invitor)
|
||||
user = User.objects.get(email=inviter)
|
||||
|
||||
workspace = Workspace.objects.get(pk=workspace_id)
|
||||
workspace_member_invite = WorkspaceMemberInvite.objects.get(
|
||||
@@ -26,7 +26,7 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
|
||||
)
|
||||
|
||||
# Relative link
|
||||
relative_link = f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&email={email}&slug={workspace.slug}"
|
||||
relative_link = f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&email={email}&slug={workspace.slug}" # noqa: E501
|
||||
|
||||
# The complete url including the domain
|
||||
abs_url = str(current_site) + relative_link
|
||||
@@ -42,7 +42,7 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
|
||||
) = get_email_configuration()
|
||||
|
||||
# Subject of the email
|
||||
subject = f"{user.first_name or user.display_name or user.email} has invited you to join them in {workspace.name} on Plane"
|
||||
subject = f"{user.first_name or user.display_name or user.email} has invited you to join them in {workspace.name} on Plane" # noqa: E501
|
||||
|
||||
context = {
|
||||
"email": email,
|
||||
@@ -78,11 +78,9 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
|
||||
)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
logging.getLogger("plane").info("Email sent succesfully")
|
||||
|
||||
logging.getLogger("plane").info("Email sent successfully")
|
||||
return
|
||||
except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist) as e:
|
||||
log_exception(e)
|
||||
except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist):
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
|
||||
@@ -5,6 +5,9 @@ from django.db import models
|
||||
# Module imports
|
||||
from .base import BaseModel
|
||||
|
||||
# Third party imports
|
||||
from plane.utils.html_processor import strip_tags
|
||||
|
||||
|
||||
class Sticky(BaseModel):
|
||||
name = models.TextField(null=True, blank=True)
|
||||
@@ -33,6 +36,12 @@ class Sticky(BaseModel):
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
None
|
||||
if (self.description_html == "" or self.description_html is None)
|
||||
else strip_tags(self.description_html)
|
||||
)
|
||||
if self._state.adding:
|
||||
# Get the maximum sequence value from the database
|
||||
last_id = Sticky.objects.filter(workspace=self.workspace).aggregate(
|
||||
|
||||
@@ -290,11 +290,12 @@ class InstanceAdminSignInEndpoint(View):
|
||||
# Fetch the user
|
||||
user = User.objects.filter(email=email).first()
|
||||
|
||||
# is_active
|
||||
if not user.is_active:
|
||||
# Error out if the user is not present
|
||||
if not user:
|
||||
exc = AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DEACTIVATED"],
|
||||
error_message="ADMIN_USER_DEACTIVATED",
|
||||
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DOES_NOT_EXIST"],
|
||||
error_message="ADMIN_USER_DOES_NOT_EXIST",
|
||||
payload={"email": email},
|
||||
)
|
||||
url = urljoin(
|
||||
base_host(request=request, is_admin=True),
|
||||
@@ -302,12 +303,11 @@ class InstanceAdminSignInEndpoint(View):
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
# Error out if the user is not present
|
||||
if not user:
|
||||
# is_active
|
||||
if not user.is_active:
|
||||
exc = AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DOES_NOT_EXIST"],
|
||||
error_message="ADMIN_USER_DOES_NOT_EXIST",
|
||||
payload={"email": email},
|
||||
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DEACTIVATED"],
|
||||
error_message="ADMIN_USER_DEACTIVATED",
|
||||
)
|
||||
url = urljoin(
|
||||
base_host(request=request, is_admin=True),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# base requirements
|
||||
|
||||
# django
|
||||
Django==4.2.17
|
||||
Django==4.2.18
|
||||
# rest framework
|
||||
djangorestframework==3.15.2
|
||||
# postgres
|
||||
|
||||
+6
-6
@@ -16,10 +16,10 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@hocuspocus/extension-database": "^2.11.3",
|
||||
"@hocuspocus/extension-logger": "^2.11.3",
|
||||
"@hocuspocus/extension-redis": "^2.13.5",
|
||||
"@hocuspocus/server": "^2.11.3",
|
||||
"@hocuspocus/extension-database": "^2.15.0",
|
||||
"@hocuspocus/extension-logger": "^2.15.0",
|
||||
"@hocuspocus/extension-redis": "^2.15.0",
|
||||
"@hocuspocus/server": "^2.15.0",
|
||||
"@plane/constants": "*",
|
||||
"@plane/editor": "*",
|
||||
"@plane/types": "*",
|
||||
@@ -40,9 +40,9 @@
|
||||
"pino-http": "^10.3.0",
|
||||
"pino-pretty": "^11.2.2",
|
||||
"uuid": "^10.0.0",
|
||||
"y-prosemirror": "^1.2.9",
|
||||
"y-prosemirror": "^1.2.15",
|
||||
"y-protocols": "^1.0.6",
|
||||
"yjs": "^13.6.14"
|
||||
"yjs": "^13.6.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.25.6",
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// types
|
||||
import { TXAxisValues, TYAxisValues } from "@plane/types";
|
||||
|
||||
export const ANALYTICS_TABS = [
|
||||
{ key: "scope_and_demand", title: "Scope and Demand" },
|
||||
{ key: "custom", title: "Custom Analytics" },
|
||||
];
|
||||
|
||||
export const ANALYTICS_X_AXIS_VALUES: { value: TXAxisValues; label: string }[] =
|
||||
[
|
||||
{
|
||||
value: "state_id",
|
||||
label: "State name",
|
||||
},
|
||||
{
|
||||
value: "state__group",
|
||||
label: "State group",
|
||||
},
|
||||
{
|
||||
value: "priority",
|
||||
label: "Priority",
|
||||
},
|
||||
{
|
||||
value: "labels__id",
|
||||
label: "Label",
|
||||
},
|
||||
{
|
||||
value: "assignees__id",
|
||||
label: "Assignee",
|
||||
},
|
||||
{
|
||||
value: "estimate_point__value",
|
||||
label: "Estimate point",
|
||||
},
|
||||
{
|
||||
value: "issue_cycle__cycle_id",
|
||||
label: "Cycle",
|
||||
},
|
||||
{
|
||||
value: "issue_module__module_id",
|
||||
label: "Module",
|
||||
},
|
||||
{
|
||||
value: "completed_at",
|
||||
label: "Completed date",
|
||||
},
|
||||
{
|
||||
value: "target_date",
|
||||
label: "Due date",
|
||||
},
|
||||
{
|
||||
value: "start_date",
|
||||
label: "Start date",
|
||||
},
|
||||
{
|
||||
value: "created_at",
|
||||
label: "Created date",
|
||||
},
|
||||
];
|
||||
|
||||
export const ANALYTICS_Y_AXIS_VALUES: { value: TYAxisValues; label: string }[] =
|
||||
[
|
||||
{
|
||||
value: "issue_count",
|
||||
label: "Issue Count",
|
||||
},
|
||||
{
|
||||
value: "estimate",
|
||||
label: "Estimate",
|
||||
},
|
||||
];
|
||||
|
||||
export const ANALYTICS_DATE_KEYS = [
|
||||
"completed_at",
|
||||
"target_date",
|
||||
"start_date",
|
||||
"created_at",
|
||||
];
|
||||
@@ -0,0 +1,10 @@
|
||||
export const ISSUE_REACTION_EMOJI_CODES = [
|
||||
"128077",
|
||||
"128078",
|
||||
"128516",
|
||||
"128165",
|
||||
"128533",
|
||||
"129505",
|
||||
"9992",
|
||||
"128064",
|
||||
];
|
||||
@@ -23,3 +23,7 @@ export const WEBSITE_URL =
|
||||
// support email
|
||||
export const SUPPORT_EMAIL =
|
||||
process.env.NEXT_PUBLIC_SUPPORT_EMAIL || "support@plane.so";
|
||||
// marketing links
|
||||
export const MARKETING_PRICING_PAGE_LINK = "https://plane.so/pricing";
|
||||
export const MARKETING_CONTACT_US_PAGE_LINK = "https://plane.so/contact";
|
||||
export const MARKETING_PLANE_ONE_PAGE_LINK = "https://plane.so/one";
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum E_ARCHIVE_ERROR_CODES {
|
||||
"INVALID_ARCHIVE_STATE_GROUP" = 4091,
|
||||
"INVALID_ISSUE_START_DATE" = 4101,
|
||||
"INVALID_ISSUE_TARGET_DATE" = 4102,
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum E_SORT_ORDER {
|
||||
ASC = "asc",
|
||||
DESC = "desc",
|
||||
}
|
||||
@@ -1,7 +1,4 @@
|
||||
// nivo
|
||||
import { Theme } from "@nivo/core";
|
||||
|
||||
export const CHARTS_THEME: Theme = {
|
||||
export const CHARTS_THEME = {
|
||||
background: "transparent",
|
||||
text: {
|
||||
color: "rgb(var(--color-text-200))",
|
||||
@@ -29,7 +26,7 @@ export const CHARTS_THEME: Theme = {
|
||||
},
|
||||
};
|
||||
|
||||
export const DEFAULT_MARGIN = {
|
||||
export const CHART_DEFAULT_MARGIN = {
|
||||
top: 50,
|
||||
right: 50,
|
||||
bottom: 50,
|
||||
@@ -1,8 +1,11 @@
|
||||
export * from "./ai";
|
||||
export * from "./analytics";
|
||||
export * from "./auth";
|
||||
export * from "./endpoints";
|
||||
export * from "./event";
|
||||
export * from "./file";
|
||||
export * from "./filter";
|
||||
export * from "./graph";
|
||||
export * from "./instance";
|
||||
export * from "./issue";
|
||||
export * from "./metadata";
|
||||
@@ -10,3 +13,4 @@ export * from "./state";
|
||||
export * from "./swr";
|
||||
export * from "./user";
|
||||
export * from "./workspace";
|
||||
export * from "./stickies";
|
||||
|
||||
@@ -43,3 +43,26 @@ export const ARCHIVABLE_STATE_GROUPS = [
|
||||
STATE_GROUPS.completed.key,
|
||||
STATE_GROUPS.cancelled.key,
|
||||
];
|
||||
|
||||
export const PROGRESS_STATE_GROUPS_DETAILS = [
|
||||
{
|
||||
key: "completed_issues",
|
||||
title: "Completed",
|
||||
color: "#16A34A",
|
||||
},
|
||||
{
|
||||
key: "started_issues",
|
||||
title: "Started",
|
||||
color: "#F59E0B",
|
||||
},
|
||||
{
|
||||
key: "unstarted_issues",
|
||||
title: "Unstarted",
|
||||
color: "#3A3A3A",
|
||||
},
|
||||
{
|
||||
key: "backlog_issues",
|
||||
title: "Backlog",
|
||||
color: "#A3A3A3",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const STICKIES_PER_PAGE = 30;
|
||||
@@ -12,14 +12,12 @@
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"import": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs"
|
||||
"import": "./dist/index.mjs"
|
||||
},
|
||||
"./lib": {
|
||||
"require": "./dist/lib.js",
|
||||
"types": "./dist/lib.d.mts",
|
||||
"import": "./dist/lib.mjs",
|
||||
"module": "./dist/lib.mjs"
|
||||
"import": "./dist/lib.mjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
@@ -36,7 +34,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.26.4",
|
||||
"@hocuspocus/provider": "^2.13.5",
|
||||
"@hocuspocus/provider": "^2.15.0",
|
||||
"@plane/types": "*",
|
||||
"@plane/ui": "*",
|
||||
"@plane/utils": "*",
|
||||
@@ -67,21 +65,21 @@
|
||||
"prosemirror-codemark": "^0.4.2",
|
||||
"prosemirror-utils": "^1.2.2",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-markdown": "^0.8.9",
|
||||
"tiptap-markdown": "^0.8.10",
|
||||
"uuid": "^10.0.0",
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"y-prosemirror": "^1.2.5",
|
||||
"y-prosemirror": "^1.2.15",
|
||||
"y-protocols": "^1.0.6",
|
||||
"yjs": "^13.6.15"
|
||||
"yjs": "^13.6.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "*",
|
||||
"@plane/tailwind-config": "*",
|
||||
"@plane/typescript-config": "*",
|
||||
"@types/node": "18.15.3",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwind-config-custom": "*",
|
||||
"tsup": "^7.2.0",
|
||||
"typescript": "5.3.3"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import { Extensions } from "@tiptap/core";
|
||||
import { AnyExtension } from "@tiptap/core";
|
||||
import { SlashCommands } from "@/extensions";
|
||||
// plane editor types
|
||||
import { TIssueEmbedConfig } from "@/plane-editor/types";
|
||||
@@ -13,15 +14,24 @@ type Props = {
|
||||
userDetails: TUserDetails;
|
||||
};
|
||||
|
||||
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
|
||||
const { disabledExtensions } = _props;
|
||||
const extensions: Extensions = disabledExtensions?.includes("slash-commands")
|
||||
? []
|
||||
: [
|
||||
SlashCommands({
|
||||
disabledExtensions,
|
||||
}),
|
||||
];
|
||||
|
||||
return extensions;
|
||||
type ExtensionConfig = {
|
||||
isEnabled: (disabledExtensions: TExtensions[]) => boolean;
|
||||
getExtension: (props: Props) => AnyExtension;
|
||||
};
|
||||
|
||||
const extensionRegistry: ExtensionConfig[] = [
|
||||
{
|
||||
isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"),
|
||||
getExtension: () => SlashCommands({}),
|
||||
},
|
||||
];
|
||||
|
||||
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
|
||||
const { disabledExtensions = [] } = _props;
|
||||
|
||||
const documentExtensions = extensionRegistry
|
||||
.filter((config) => config.isEnabled(disabledExtensions))
|
||||
.map((config) => config.getExtension(_props));
|
||||
|
||||
return documentExtensions;
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { TSlashCommandAdditionalOption } from "@/extensions";
|
||||
import { TExtensions } from "@/types";
|
||||
|
||||
type Props = {
|
||||
disabledExtensions: TExtensions[];
|
||||
disabledExtensions?: TExtensions[];
|
||||
};
|
||||
|
||||
export const coreEditorAdditionalSlashCommandOptions = (props: Props): TSlashCommandAdditionalOption[] => {
|
||||
|
||||
@@ -202,8 +202,7 @@ export const ImageItem = (editor: Editor): EditorMenuItem<"image"> => ({
|
||||
key: "image",
|
||||
name: "Image",
|
||||
isActive: () => editor?.isActive("image") || editor?.isActive("imageComponent"),
|
||||
command: ({ savedSelection }) =>
|
||||
insertImage({ editor, event: "insert", pos: savedSelection?.from ?? editor.state.selection.from }),
|
||||
command: () => insertImage({ editor, event: "insert", pos: editor.state.selection.from }),
|
||||
icon: ImageIcon,
|
||||
});
|
||||
|
||||
|
||||
@@ -39,11 +39,11 @@ import {
|
||||
setText,
|
||||
} from "@/helpers/editor-commands";
|
||||
// types
|
||||
import { CommandProps, ISlashCommandItem, TExtensions, TSlashCommandSectionKeys } from "@/types";
|
||||
import { CommandProps, ISlashCommandItem, TSlashCommandSectionKeys } from "@/types";
|
||||
// plane editor extensions
|
||||
import { coreEditorAdditionalSlashCommandOptions } from "@/plane-editor/extensions";
|
||||
// local types
|
||||
import { TSlashCommandAdditionalOption } from "./root";
|
||||
import { TExtensionProps } from "./root";
|
||||
|
||||
export type TSlashCommandSection = {
|
||||
key: TSlashCommandSectionKeys;
|
||||
@@ -51,13 +51,8 @@ export type TSlashCommandSection = {
|
||||
items: ISlashCommandItem[];
|
||||
};
|
||||
|
||||
type TArgs = {
|
||||
additionalOptions?: TSlashCommandAdditionalOption[];
|
||||
disabledExtensions: TExtensions[];
|
||||
};
|
||||
|
||||
export const getSlashCommandFilteredSections =
|
||||
(args: TArgs) =>
|
||||
(args: TExtensionProps) =>
|
||||
({ query }: { query: string }): TSlashCommandSection[] => {
|
||||
const { additionalOptions, disabledExtensions } = args;
|
||||
const SLASH_COMMAND_SECTIONS: TSlashCommandSection[] = [
|
||||
|
||||
@@ -103,9 +103,9 @@ const renderItems = () => {
|
||||
};
|
||||
};
|
||||
|
||||
type TExtensionProps = {
|
||||
export type TExtensionProps = {
|
||||
additionalOptions?: TSlashCommandAdditionalOption[];
|
||||
disabledExtensions: TExtensions[];
|
||||
disabledExtensions?: TExtensions[];
|
||||
};
|
||||
|
||||
export const SlashCommands = (props: TExtensionProps) =>
|
||||
|
||||
@@ -1,27 +1,21 @@
|
||||
import { MutableRefObject } from "react";
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
import { Editor } from "@tiptap/react";
|
||||
|
||||
export const insertContentAtSavedSelection = (
|
||||
editorRef: MutableRefObject<Editor | null>,
|
||||
content: string,
|
||||
savedSelection: Selection
|
||||
) => {
|
||||
if (!editorRef.current || editorRef.current.isDestroyed) {
|
||||
export const insertContentAtSavedSelection = (editor: Editor, content: string) => {
|
||||
if (!editor || editor.isDestroyed) {
|
||||
console.error("Editor reference is not available or has been destroyed.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!savedSelection) {
|
||||
if (!editor.state.selection) {
|
||||
console.error("Saved selection is invalid.");
|
||||
return;
|
||||
}
|
||||
|
||||
const docSize = editorRef.current.state.doc.content.size;
|
||||
const safePosition = Math.max(0, Math.min(savedSelection.anchor, docSize));
|
||||
const docSize = editor.state.doc.content.size;
|
||||
const safePosition = Math.max(0, Math.min(editor.state.selection.anchor, docSize));
|
||||
|
||||
try {
|
||||
editorRef.current.chain().focus().insertContentAt(safePosition, content).run();
|
||||
editor.chain().focus().insertContentAt(safePosition, content).run();
|
||||
} catch (error) {
|
||||
console.error("An error occurred while inserting content at saved selection:", error);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useImperativeHandle, useRef, MutableRefObject, useState, useEffect } from "react";
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import { DOMSerializer } from "@tiptap/pm/model";
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
import { EditorProps } from "@tiptap/pm/view";
|
||||
import { useEditor as useTiptapEditor, Editor, Extensions } from "@tiptap/react";
|
||||
import { useEditor as useTiptapEditor, Extensions } from "@tiptap/react";
|
||||
import { useImperativeHandle, MutableRefObject, useEffect } from "react";
|
||||
import * as Y from "yjs";
|
||||
// components
|
||||
import { EditorMenuItem, getEditorMenuItems } from "@/components/menus";
|
||||
import { getEditorMenuItems } from "@/components/menus";
|
||||
// extensions
|
||||
import { CoreEditorExtensions } from "@/extensions";
|
||||
// helpers
|
||||
@@ -71,14 +70,12 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
provider,
|
||||
autofocus = false,
|
||||
} = props;
|
||||
// states
|
||||
const [savedSelection, setSavedSelection] = useState<Selection | null>(null);
|
||||
// refs
|
||||
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||
const savedSelectionRef = useRef(savedSelection);
|
||||
|
||||
const editor = useTiptapEditor(
|
||||
{
|
||||
editable,
|
||||
immediatelyRender: false,
|
||||
shouldRerenderOnTransaction: false,
|
||||
autofocus,
|
||||
editorProps: {
|
||||
...CoreEditorProps({
|
||||
@@ -100,8 +97,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
],
|
||||
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
|
||||
onCreate: () => handleEditorReady?.(true),
|
||||
onTransaction: ({ editor }) => {
|
||||
setSavedSelection(editor.state.selection);
|
||||
onTransaction: () => {
|
||||
onTransaction?.();
|
||||
},
|
||||
onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()),
|
||||
@@ -110,23 +106,17 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
[editable]
|
||||
);
|
||||
|
||||
// Update the ref whenever savedSelection changes
|
||||
useEffect(() => {
|
||||
savedSelectionRef.current = savedSelection;
|
||||
}, [savedSelection]);
|
||||
|
||||
// Effect for syncing SWR data
|
||||
useEffect(() => {
|
||||
// value is null when intentionally passed where syncing is not yet
|
||||
// supported and value is undefined when the data from swr is not populated
|
||||
if (value === null || value === undefined) return;
|
||||
if (value == null) return;
|
||||
if (editor && !editor.isDestroyed && !editor.storage.imageComponent.uploadInProgress) {
|
||||
try {
|
||||
editor.commands.setContent(value, false, { preserveWhitespace: "full" });
|
||||
const currentSavedSelection = savedSelectionRef.current;
|
||||
if (currentSavedSelection) {
|
||||
if (editor.state.selection) {
|
||||
const docLength = editor.state.doc.content.size;
|
||||
const relativePosition = Math.min(currentSavedSelection.from, docLength - 1);
|
||||
const relativePosition = Math.min(editor.state.selection.from, docLength - 1);
|
||||
editor.commands.setTextSelection(relativePosition);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -138,46 +128,40 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
useImperativeHandle(
|
||||
forwardedRef,
|
||||
() => ({
|
||||
blur: () => editorRef.current?.commands.blur(),
|
||||
blur: () => editor.commands.blur(),
|
||||
scrollToNodeViaDOMCoordinates(behavior?: ScrollBehavior, pos?: number) {
|
||||
const resolvedPos = pos ?? savedSelection?.from;
|
||||
if (!editorRef.current || !resolvedPos) return;
|
||||
scrollToNodeViaDOMCoordinates(editorRef.current, resolvedPos, behavior);
|
||||
const resolvedPos = pos ?? editor.state.selection.from;
|
||||
if (!editor || !resolvedPos) return;
|
||||
scrollToNodeViaDOMCoordinates(editor, resolvedPos, behavior);
|
||||
},
|
||||
getCurrentCursorPosition: () => savedSelection?.from,
|
||||
getCurrentCursorPosition: () => editor.state.selection.from,
|
||||
clearEditor: (emitUpdate = false) => {
|
||||
editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
|
||||
editor?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
|
||||
},
|
||||
setEditorValue: (content: string) => {
|
||||
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
|
||||
editor?.commands.setContent(content, false, { preserveWhitespace: "full" });
|
||||
},
|
||||
setEditorValueAtCursorPosition: (content: string) => {
|
||||
if (savedSelection) {
|
||||
insertContentAtSavedSelection(editorRef, content, savedSelection);
|
||||
if (editor.state.selection) {
|
||||
insertContentAtSavedSelection(editor, content);
|
||||
}
|
||||
},
|
||||
executeMenuItemCommand: (props) => {
|
||||
const { itemKey } = props;
|
||||
const editorItems = getEditorMenuItems(editorRef.current);
|
||||
const editorItems = getEditorMenuItems(editor);
|
||||
|
||||
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
|
||||
|
||||
const item = getEditorMenuItem(itemKey);
|
||||
if (item) {
|
||||
if (item.key === "image") {
|
||||
(item as EditorMenuItem<"image">).command({
|
||||
savedSelection: savedSelectionRef.current,
|
||||
});
|
||||
} else {
|
||||
item.command(props);
|
||||
}
|
||||
item.command(props);
|
||||
} else {
|
||||
console.warn(`No command found for item: ${itemKey}`);
|
||||
}
|
||||
},
|
||||
isMenuItemActive: (props) => {
|
||||
const { itemKey } = props;
|
||||
const editorItems = getEditorMenuItems(editorRef.current);
|
||||
const editorItems = getEditorMenuItems(editor);
|
||||
|
||||
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
|
||||
const item = getEditorMenuItem(itemKey);
|
||||
@@ -187,20 +171,20 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
},
|
||||
onHeadingChange: (callback: (headings: IMarking[]) => void) => {
|
||||
// Subscribe to update event emitted from headers extension
|
||||
editorRef.current?.on("update", () => {
|
||||
callback(editorRef.current?.storage.headingList.headings);
|
||||
editor?.on("update", () => {
|
||||
callback(editor?.storage.headingList.headings);
|
||||
});
|
||||
// Return a function to unsubscribe to the continuous transactions of
|
||||
// the editor on unmounting the component that has subscribed to this
|
||||
// method
|
||||
return () => {
|
||||
editorRef.current?.off("update");
|
||||
editor?.off("update");
|
||||
};
|
||||
},
|
||||
getHeadings: () => editorRef?.current?.storage.headingList.headings,
|
||||
getHeadings: () => editor?.storage.headingList.headings,
|
||||
onStateChange: (callback: () => void) => {
|
||||
// Subscribe to editor state changes
|
||||
editorRef.current?.on("transaction", () => {
|
||||
editor?.on("transaction", () => {
|
||||
callback();
|
||||
});
|
||||
|
||||
@@ -208,17 +192,17 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
// the editor on unmounting the component that has subscribed to this
|
||||
// method
|
||||
return () => {
|
||||
editorRef.current?.off("transaction");
|
||||
editor?.off("transaction");
|
||||
};
|
||||
},
|
||||
getMarkDown: (): string => {
|
||||
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
|
||||
const markdownOutput = editor?.storage.markdown.getMarkdown();
|
||||
return markdownOutput;
|
||||
},
|
||||
getDocument: () => {
|
||||
const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null;
|
||||
const documentHTML = editorRef.current?.getHTML() ?? "<p></p>";
|
||||
const documentJSON = editorRef.current?.getJSON() ?? null;
|
||||
const documentHTML = editor?.getHTML() ?? "<p></p>";
|
||||
const documentJSON = editor.getJSON() ?? null;
|
||||
|
||||
return {
|
||||
binary: documentBinary,
|
||||
@@ -227,19 +211,19 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
};
|
||||
},
|
||||
scrollSummary: (marking: IMarking): void => {
|
||||
if (!editorRef.current) return;
|
||||
scrollSummary(editorRef.current, marking);
|
||||
if (!editor) return;
|
||||
scrollSummary(editor, marking);
|
||||
},
|
||||
isEditorReadyToDiscard: () => editorRef.current?.storage.imageComponent.uploadInProgress === false,
|
||||
isEditorReadyToDiscard: () => editor?.storage.imageComponent.uploadInProgress === false,
|
||||
setFocusAtPosition: (position: number) => {
|
||||
if (!editorRef.current || editorRef.current.isDestroyed) {
|
||||
if (!editor || editor.isDestroyed) {
|
||||
console.error("Editor reference is not available or has been destroyed.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const docSize = editorRef.current.state.doc.content.size;
|
||||
const docSize = editor.state.doc.content.size;
|
||||
const safePosition = Math.max(0, Math.min(position, docSize));
|
||||
editorRef.current
|
||||
editor
|
||||
.chain()
|
||||
.insertContentAt(safePosition, [{ type: "paragraph" }])
|
||||
.focus()
|
||||
@@ -249,17 +233,17 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
}
|
||||
},
|
||||
getSelectedText: () => {
|
||||
if (!editorRef.current) return null;
|
||||
if (!editor) return null;
|
||||
|
||||
const { state } = editorRef.current;
|
||||
const { state } = editor;
|
||||
const { from, to, empty } = state.selection;
|
||||
|
||||
if (empty) return null;
|
||||
|
||||
const nodesArray: string[] = [];
|
||||
state.doc.nodesBetween(from, to, (node, _pos, parent) => {
|
||||
if (parent === state.doc && editorRef.current) {
|
||||
const serializer = DOMSerializer.fromSchema(editorRef.current?.schema);
|
||||
if (parent === state.doc && editor) {
|
||||
const serializer = DOMSerializer.fromSchema(editor.schema);
|
||||
const dom = serializer.serializeNode(node);
|
||||
const tempDiv = document.createElement("div");
|
||||
tempDiv.appendChild(dom);
|
||||
@@ -270,28 +254,21 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
return selection;
|
||||
},
|
||||
insertText: (contentHTML, insertOnNextLine) => {
|
||||
if (!editorRef.current) return;
|
||||
// get selection
|
||||
const { from, to, empty } = editorRef.current.state.selection;
|
||||
if (!editor) return;
|
||||
const { from, to, empty } = editor.state.selection;
|
||||
if (empty) return;
|
||||
if (insertOnNextLine) {
|
||||
// move cursor to the end of the selection and insert a new line
|
||||
editorRef.current
|
||||
.chain()
|
||||
.focus()
|
||||
.setTextSelection(to)
|
||||
.insertContent("<br />")
|
||||
.insertContent(contentHTML)
|
||||
.run();
|
||||
editor.chain().focus().setTextSelection(to).insertContent("<br />").insertContent(contentHTML).run();
|
||||
} else {
|
||||
// replace selected text with the content provided
|
||||
editorRef.current.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run();
|
||||
editor.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run();
|
||||
}
|
||||
},
|
||||
getDocumentInfo: () => ({
|
||||
characters: editorRef?.current?.storage?.characterCount?.characters?.() ?? 0,
|
||||
paragraphs: getParagraphCount(editorRef?.current?.state),
|
||||
words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0,
|
||||
characters: editor?.storage?.characterCount?.characters?.() ?? 0,
|
||||
paragraphs: getParagraphCount(editor?.state),
|
||||
words: editor?.storage?.characterCount?.words?.() ?? 0,
|
||||
}),
|
||||
setProviderDocument: (value) => {
|
||||
const document = provider?.document;
|
||||
@@ -301,16 +278,12 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
emitRealTimeUpdate: (message: TDocumentEventsServer) => provider?.sendStateless(message),
|
||||
listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) },
|
||||
}),
|
||||
[editorRef, savedSelection]
|
||||
[editor]
|
||||
);
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// the editorRef is used to access the editor instance from outside the hook
|
||||
// and should only be used after editor is initialized
|
||||
editorRef.current = editor;
|
||||
|
||||
return editor;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useImperativeHandle, useRef, MutableRefObject, useEffect } from "react";
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import { EditorProps } from "@tiptap/pm/view";
|
||||
import { useEditor as useCustomEditor, Editor, Extensions } from "@tiptap/react";
|
||||
import { useEditor as useTiptapEditor, Extensions } from "@tiptap/react";
|
||||
import { useImperativeHandle, MutableRefObject, useEffect } from "react";
|
||||
import * as Y from "yjs";
|
||||
// extensions
|
||||
import { CoreReadOnlyEditorExtensions } from "@/extensions";
|
||||
@@ -11,13 +11,7 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node";
|
||||
// props
|
||||
import { CoreReadOnlyEditorProps } from "@/props";
|
||||
// types
|
||||
import type {
|
||||
EditorReadOnlyRefApi,
|
||||
TExtensions,
|
||||
TDocumentEventsServer,
|
||||
TFileHandler,
|
||||
TReadOnlyMentionHandler,
|
||||
} from "@/types";
|
||||
import type { EditorReadOnlyRefApi, TExtensions, TFileHandler, TReadOnlyMentionHandler } from "@/types";
|
||||
|
||||
interface CustomReadOnlyEditorProps {
|
||||
disabledExtensions: TExtensions[];
|
||||
@@ -46,8 +40,10 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
|
||||
provider,
|
||||
} = props;
|
||||
|
||||
const editor = useCustomEditor({
|
||||
const editor = useTiptapEditor({
|
||||
editable: false,
|
||||
immediatelyRender: true,
|
||||
shouldRerenderOnTransaction: false,
|
||||
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
|
||||
editorProps: {
|
||||
...CoreReadOnlyEditorProps({
|
||||
@@ -77,23 +73,21 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
|
||||
if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue, false, { preserveWhitespace: "full" });
|
||||
}, [editor, initialValue]);
|
||||
|
||||
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
clearEditor: (emitUpdate = false) => {
|
||||
editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
|
||||
editor?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
|
||||
},
|
||||
setEditorValue: (content: string) => {
|
||||
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
|
||||
editor?.commands.setContent(content, false, { preserveWhitespace: "full" });
|
||||
},
|
||||
getMarkDown: (): string => {
|
||||
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
|
||||
const markdownOutput = editor?.storage.markdown.getMarkdown();
|
||||
return markdownOutput;
|
||||
},
|
||||
getDocument: () => {
|
||||
const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null;
|
||||
const documentHTML = editorRef.current?.getHTML() ?? "<p></p>";
|
||||
const documentJSON = editorRef.current?.getJSON() ?? null;
|
||||
const documentHTML = editor?.getHTML() ?? "<p></p>";
|
||||
const documentJSON = editor?.getJSON() ?? null;
|
||||
|
||||
return {
|
||||
binary: documentBinary,
|
||||
@@ -102,35 +96,22 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
|
||||
};
|
||||
},
|
||||
scrollSummary: (marking: IMarking): void => {
|
||||
if (!editorRef.current) return;
|
||||
scrollSummary(editorRef.current, marking);
|
||||
if (!editor) return;
|
||||
scrollSummary(editor, marking);
|
||||
},
|
||||
getDocumentInfo: () => ({
|
||||
characters: editorRef?.current?.storage?.characterCount?.characters?.() ?? 0,
|
||||
paragraphs: getParagraphCount(editorRef?.current?.state),
|
||||
words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0,
|
||||
}),
|
||||
onHeadingChange: (callback: (headings: IMarking[]) => void) => {
|
||||
// Subscribe to update event emitted from headers extension
|
||||
editorRef.current?.on("update", () => {
|
||||
callback(editorRef.current?.storage.headingList.headings);
|
||||
});
|
||||
// Return a function to unsubscribe to the continuous transactions of
|
||||
// the editor on unmounting the component that has subscribed to this
|
||||
// method
|
||||
return () => {
|
||||
editorRef.current?.off("update");
|
||||
getDocumentInfo: () => {
|
||||
if (!editor) return;
|
||||
return {
|
||||
characters: editor.storage?.characterCount?.characters?.() ?? 0,
|
||||
paragraphs: getParagraphCount(editor.state),
|
||||
words: editor.storage?.characterCount?.words?.() ?? 0,
|
||||
};
|
||||
},
|
||||
emitRealTimeUpdate: (message: TDocumentEventsServer) => provider?.sendStateless(message),
|
||||
listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) },
|
||||
getHeadings: () => editorRef?.current?.storage.headingList.headings,
|
||||
}));
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
editorRef.current = editor;
|
||||
return editor;
|
||||
};
|
||||
|
||||
@@ -86,10 +86,6 @@ export type EditorReadOnlyRefApi = {
|
||||
paragraphs: number;
|
||||
words: number;
|
||||
};
|
||||
onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void;
|
||||
getHeadings: () => IMarking[];
|
||||
emitRealTimeUpdate: (action: TDocumentEventsServer) => void;
|
||||
listenToRealTimeUpdate: () => TDocumentEventEmitter | undefined;
|
||||
};
|
||||
|
||||
export interface EditorRefApi extends EditorReadOnlyRefApi {
|
||||
@@ -105,6 +101,10 @@ export interface EditorRefApi extends EditorReadOnlyRefApi {
|
||||
getSelectedText: () => string | null;
|
||||
insertText: (contentHTML: string, insertOnNextLine?: boolean) => void;
|
||||
setProviderDocument: (value: Uint8Array) => void;
|
||||
onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void;
|
||||
getHeadings: () => IMarking[];
|
||||
emitRealTimeUpdate: (action: TDocumentEventsServer) => void;
|
||||
listenToRealTimeUpdate: () => TDocumentEventEmitter | undefined;
|
||||
}
|
||||
|
||||
// editor props
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
.ProseMirror p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: rgb(var(--color-text-400));
|
||||
color: var(--color-placeholder);
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
@@ -34,7 +34,7 @@
|
||||
.ProseMirror p.is-empty::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: rgb(var(--color-text-400));
|
||||
color: var(--color-placeholder);
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
@@ -145,7 +145,7 @@ ul[data-type="taskList"] li > label input[type="checkbox"] {
|
||||
position: relative;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-color: rgb(var(--color-background-100));
|
||||
background-color: transparent;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
width: 0.8rem;
|
||||
@@ -192,7 +192,7 @@ ul[data-type="taskList"] li > div {
|
||||
|
||||
ul[data-type="taskList"] li[data-checked="true"] {
|
||||
& > div > p.editor-paragraph-block {
|
||||
color: rgb(var(--color-text-400));
|
||||
color: var(--color-placeholder);
|
||||
}
|
||||
|
||||
[data-text-color] {
|
||||
|
||||
@@ -1,42 +1,6 @@
|
||||
:root {
|
||||
/* text colors */
|
||||
--editor-colors-gray-text: #5c5e63;
|
||||
--editor-colors-peach-text: #ff5b59;
|
||||
--editor-colors-pink-text: #f65385;
|
||||
--editor-colors-orange-text: #fd9038;
|
||||
--editor-colors-green-text: #0fc27b;
|
||||
--editor-colors-light-blue-text: #17bee9;
|
||||
--editor-colors-dark-blue-text: #266df0;
|
||||
--editor-colors-purple-text: #9162f9;
|
||||
/* end text colors */
|
||||
}
|
||||
|
||||
/* text background colors */
|
||||
[data-theme="light"],
|
||||
[data-theme="light-contrast"] {
|
||||
--editor-colors-gray-background: #d6d6d8;
|
||||
--editor-colors-peach-background: #ffd5d7;
|
||||
--editor-colors-pink-background: #fdd4e3;
|
||||
--editor-colors-orange-background: #ffe3cd;
|
||||
--editor-colors-green-background: #c3f0de;
|
||||
--editor-colors-light-blue-background: #c5eff9;
|
||||
--editor-colors-dark-blue-background: #c9dafb;
|
||||
--editor-colors-purple-background: #e3d8fd;
|
||||
}
|
||||
[data-theme="dark"],
|
||||
[data-theme="dark-contrast"] {
|
||||
--editor-colors-gray-background: #404144;
|
||||
--editor-colors-peach-background: #593032;
|
||||
--editor-colors-pink-background: #562e3d;
|
||||
--editor-colors-orange-background: #583e2a;
|
||||
--editor-colors-green-background: #1d4a3b;
|
||||
--editor-colors-light-blue-background: #1f495c;
|
||||
--editor-colors-dark-blue-background: #223558;
|
||||
--editor-colors-purple-background: #3d325a;
|
||||
}
|
||||
/* end text background colors */
|
||||
|
||||
.editor-container {
|
||||
--color-placeholder: rgba(var(--color-text-100), 0.5);
|
||||
|
||||
/* font sizes and line heights */
|
||||
&.large-font {
|
||||
--font-size-h1: 1.75rem;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const sharedConfig = require("tailwind-config-custom/tailwind.config.js");
|
||||
const sharedConfig = require("@plane/tailwind-config/tailwind.config.js");
|
||||
|
||||
module.exports = {
|
||||
// prefix ui lib classes to avoid conflicting with the app
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
|
||||
},
|
||||
"dependencies": {
|
||||
"@plane/utils": "*"
|
||||
"@plane/utils": "*",
|
||||
"intl-messageformat": "^10.7.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "*",
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import React, { createContext, useEffect } from "react";
|
||||
import { Language, languages } from "../config";
|
||||
import { TranslationStore } from "./store";
|
||||
|
||||
// Create the store instance
|
||||
const translationStore = new TranslationStore();
|
||||
|
||||
// Create Context
|
||||
export const TranslationContext = createContext<TranslationStore>(translationStore);
|
||||
|
||||
export const TranslationProvider = observer(({ children }: { children: React.ReactNode }) => {
|
||||
// Handle storage events for cross-tab synchronization
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (event: StorageEvent) => {
|
||||
if (event.key === "userLanguage" && event.newValue) {
|
||||
const newLang = event.newValue as Language;
|
||||
if (languages.includes(newLang)) {
|
||||
translationStore.setLanguage(newLang);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("storage", handleStorageChange);
|
||||
return () => window.removeEventListener("storage", handleStorageChange);
|
||||
}, []);
|
||||
|
||||
return <TranslationContext.Provider value={translationStore}>{children}</TranslationContext.Provider>;
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { makeObservable, observable } from "mobx";
|
||||
import { Language, fallbackLng, languages, translations } from "../config";
|
||||
|
||||
export class TranslationStore {
|
||||
currentLocale: Language = fallbackLng;
|
||||
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
currentLocale: observable.ref,
|
||||
});
|
||||
this.initializeLanguage();
|
||||
}
|
||||
|
||||
get availableLanguages() {
|
||||
return languages;
|
||||
}
|
||||
|
||||
t(key: string) {
|
||||
return translations[this.currentLocale]?.[key] || translations[fallbackLng][key] || key;
|
||||
}
|
||||
|
||||
setLanguage(lng: Language) {
|
||||
try {
|
||||
localStorage.setItem("userLanguage", lng);
|
||||
this.currentLocale = lng;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
initializeLanguage() {
|
||||
if (typeof window === "undefined") return;
|
||||
const savedLocale = localStorage.getItem("userLanguage") as Language;
|
||||
if (savedLocale && languages.includes(savedLocale)) {
|
||||
this.setLanguage(savedLocale);
|
||||
} else {
|
||||
const browserLang = navigator.language.split("-")[0] as Language;
|
||||
const newLocale = languages.includes(browserLang as Language) ? (browserLang as Language) : fallbackLng;
|
||||
this.setLanguage(newLocale);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import en from "../locales/en/translations.json";
|
||||
import fr from "../locales/fr/translations.json";
|
||||
import es from "../locales/es/translations.json";
|
||||
import ja from "../locales/ja/translations.json";
|
||||
|
||||
export type Language = (typeof languages)[number];
|
||||
export type Translations = {
|
||||
[key: string]: {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const fallbackLng = "en";
|
||||
export const languages = ["en", "fr", "es", "ja"] as const;
|
||||
export const translations: Translations = {
|
||||
en,
|
||||
fr,
|
||||
es,
|
||||
ja,
|
||||
};
|
||||
|
||||
export const SUPPORTED_LANGUAGES = [
|
||||
{
|
||||
label: "English",
|
||||
value: "en",
|
||||
},
|
||||
{
|
||||
label: "French",
|
||||
value: "fr",
|
||||
},
|
||||
{
|
||||
label: "Spanish",
|
||||
value: "es",
|
||||
},
|
||||
{
|
||||
label: "Japanese",
|
||||
value: "ja",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./language";
|
||||
@@ -0,0 +1,13 @@
|
||||
import { TLanguage, ILanguageOption } from "../types";
|
||||
|
||||
export const FALLBACK_LANGUAGE: TLanguage = "en";
|
||||
|
||||
export const SUPPORTED_LANGUAGES: ILanguageOption[] = [
|
||||
{ label: "English", value: "en" },
|
||||
{ label: "Français", value: "fr" },
|
||||
{ label: "Español", value: "es" },
|
||||
{ label: "日本語", value: "ja" },
|
||||
{ label: "中文", value: "zh-CN" },
|
||||
];
|
||||
|
||||
export const STORAGE_KEY = "userLanguage";
|
||||
@@ -0,0 +1,19 @@
|
||||
import { observer } from "mobx-react";
|
||||
import React, { createContext } from "react";
|
||||
// store
|
||||
import { TranslationStore } from "../store";
|
||||
|
||||
export const TranslationContext = createContext<TranslationStore | null>(null);
|
||||
|
||||
interface TranslationProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the translation store to the application
|
||||
*/
|
||||
export const TranslationProvider: React.FC<TranslationProviderProps> = observer(({ children }) => {
|
||||
const [store] = React.useState(() => new TranslationStore());
|
||||
|
||||
return <TranslationContext.Provider value={store}>{children}</TranslationContext.Provider>;
|
||||
});
|
||||
@@ -1,17 +1,35 @@
|
||||
import { useContext } from "react";
|
||||
import { TranslationContext } from "../components";
|
||||
import { Language } from "../config";
|
||||
import { useContext } from 'react';
|
||||
// context
|
||||
import { TranslationContext } from '../context';
|
||||
// types
|
||||
import { ILanguageOption, TLanguage } from '../types';
|
||||
|
||||
export function useTranslation() {
|
||||
export type TTranslationStore = {
|
||||
t: (key: string, params?: Record<string, any>) => string;
|
||||
currentLocale: TLanguage;
|
||||
changeLanguage: (lng: TLanguage) => void;
|
||||
languages: ILanguageOption[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides the translation store to the application
|
||||
* @returns {TTranslationStore}
|
||||
* @returns {(key: string, params?: Record<string, any>) => string} t: method to translate the key with params
|
||||
* @returns {TLanguage} currentLocale - current locale language
|
||||
* @returns {(lng: TLanguage) => void} changeLanguage - method to change the language
|
||||
* @returns {ILanguageOption[]} languages - available languages
|
||||
* @throws {Error} if the TranslationProvider is not used
|
||||
*/
|
||||
export function useTranslation(): TTranslationStore {
|
||||
const store = useContext(TranslationContext);
|
||||
if (!store) {
|
||||
throw new Error("useTranslation must be used within a TranslationProvider");
|
||||
throw new Error('useTranslation must be used within a TranslationProvider');
|
||||
}
|
||||
|
||||
return {
|
||||
t: (key: string) => store.t(key),
|
||||
t: store.t.bind(store),
|
||||
currentLocale: store.currentLocale,
|
||||
changeLanguage: (lng: Language) => store.setLanguage(lng),
|
||||
changeLanguage: (lng: TLanguage) => store.setLanguage(lng),
|
||||
languages: store.availableLanguages,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./config";
|
||||
export * from "./components";
|
||||
export * from "./constants";
|
||||
export * from "./context";
|
||||
export * from "./hooks";
|
||||
export * from "./types";
|
||||
|
||||
@@ -19,12 +19,12 @@
|
||||
"remove_members": "Supprimer des membres",
|
||||
"add": "Ajouter",
|
||||
"remove": "Supprimer",
|
||||
"add_new": "Ajouter nouveau",
|
||||
"add_new": "Ajouter un nouveau",
|
||||
"remove_selected": "Supprimer la sélection",
|
||||
"first_name": "Prénom",
|
||||
"last_name": "Nom de famille",
|
||||
"email": "Email",
|
||||
"display_name": "Nom d'affichage",
|
||||
"display_name": "Pseudonyme",
|
||||
"role": "Rôle",
|
||||
"timezone": "Fuseau horaire",
|
||||
"avatar": "Avatar",
|
||||
@@ -32,7 +32,7 @@
|
||||
"password": "Mot de passe",
|
||||
"change_cover": "Modifier la couverture",
|
||||
"language": "Langue",
|
||||
"saving": "Enregistrement...",
|
||||
"saving": "Enregistrement en cours...",
|
||||
"save_changes": "Enregistrer les modifications",
|
||||
"deactivate_account": "Désactiver le compte",
|
||||
"deactivate_account_description": "Lors de la désactivation d'un compte, toutes les données et ressources de ce compte seront définitivement supprimées et ne pourront pas être récupérées.",
|
||||
@@ -43,14 +43,14 @@
|
||||
"activity": "Activité",
|
||||
"appearance": "Apparence",
|
||||
"notifications": "Notifications",
|
||||
"workspaces": "Workspaces",
|
||||
"create_workspace": "Créer un workspace",
|
||||
"workspaces": "Espaces de travail",
|
||||
"create_workspace": "Créer un espace de travail",
|
||||
"invitations": "Invitations",
|
||||
"summary": "Résumé",
|
||||
"assigned": "Assigné",
|
||||
"created": "Créé",
|
||||
"subscribed": "Souscrit",
|
||||
"you_do_not_have_the_permission_to_access_this_page": "Vous n'avez pas les permissions pour accéder à cette page.",
|
||||
"you_do_not_have_the_permission_to_access_this_page": "Vous n'avez pas les permissions d'accéder à cette page.",
|
||||
"failed_to_sign_out_please_try_again": "Impossible de se déconnecter. Veuillez réessayer.",
|
||||
"password_changed_successfully": "Mot de passe changé avec succès.",
|
||||
"something_went_wrong_please_try_again": "Quelque chose s'est mal passé. Veuillez réessayer.",
|
||||
@@ -62,7 +62,7 @@
|
||||
"this_field_is_required": "Ce champ est requis",
|
||||
"passwords_dont_match": "Les mots de passe ne correspondent pas",
|
||||
"please_enter_your_password": "Veuillez entrer votre mot de passe.",
|
||||
"password_length_should_me_more_than_8_characters": "La longueur du mot de passe doit être supérieure à 8 caractères.",
|
||||
"password_length_should_me_more_than_8_characters": "La longueur du mot de passe doit faire au moins 8 caractères.",
|
||||
"password_is_weak": "Le mot de passe est faible.",
|
||||
"password_is_strong": "Le mot de passe est fort.",
|
||||
"load_more": "Charger plus",
|
||||
@@ -106,8 +106,8 @@
|
||||
"comments_description": "Me notifier lorsqu'un utilisateur commente un problème",
|
||||
"mentions": "Mention",
|
||||
"mentions_description": "Me notifier uniquement lorsqu'un utilisateur mentionne un problème",
|
||||
"create_your_workspace": "Créer votre workspace",
|
||||
"only_your_instance_admin_can_create_workspaces": "Seuls les administrateurs de votre instance peuvent créer des workspaces",
|
||||
"create_your_workspace": "Créer votre espace de travail",
|
||||
"only_your_instance_admin_can_create_workspaces": "Seuls les administrateurs de votre instance peuvent créer des espaces de travail",
|
||||
"only_your_instance_admin_can_create_workspaces_description": "Si vous connaissez l'adresse email de votre administrateur d'instance, cliquez sur le bouton ci-dessous pour les contacter.",
|
||||
"go_back": "Retour",
|
||||
"request_instance_admin": "Demander à l'administrateur de l'instance",
|
||||
@@ -135,7 +135,7 @@
|
||||
"old_password": "Mot de passe actuel",
|
||||
"general_settings": "Paramètres généraux",
|
||||
"sign_out": "Déconnexion",
|
||||
"signing_out": "Déconnexion",
|
||||
"signing_out": "Déconnexion en cours",
|
||||
"active_cycles": "Cycles actifs",
|
||||
"active_cycles_description": "Surveillez les cycles dans les projets, suivez les issues de haute priorité et zoomez sur les cycles qui nécessitent attention.",
|
||||
"on_demand_snapshots_of_all_your_cycles": "Captures instantanées sur demande de tous vos cycles",
|
||||
@@ -190,17 +190,17 @@
|
||||
"project_created_successfully_description": "Projet créé avec succès. Vous pouvez maintenant ajouter des issues à ce projet.",
|
||||
"project_cover_image_alt": "Image de couverture du projet",
|
||||
"name_is_required": "Le nom est requis",
|
||||
"title_should_be_less_than_255_characters": "Le titre doit être inférieur à 255 caractères",
|
||||
"title_should_be_less_than_255_characters": "Le titre ne doit pas dépasser 255 caractères",
|
||||
"project_name": "Nom du projet",
|
||||
"project_id_must_be_at_least_1_character": "Le projet ID doit être au moins de 1 caractère",
|
||||
"project_id_must_be_at_most_5_characters": "Le projet ID doit être au plus de 5 caractères",
|
||||
"project_id_must_be_at_least_1_character": "L'ID du projet doit être au moins de 1 caractère",
|
||||
"project_id_must_be_at_most_5_characters": "L'ID du projet doit être au plus de 5 caractères",
|
||||
"project_id": "ID du projet",
|
||||
"project_id_tooltip_content": "Aide à identifier les issues du projet de manière unique. Max 5 caractères.",
|
||||
"description_placeholder": "Description...",
|
||||
"only_alphanumeric_non_latin_characters_allowed": "Seuls les caractères alphanumériques et non latins sont autorisés.",
|
||||
"project_id_is_required": "Le projet ID est requis",
|
||||
"project_id_is_required": "L'ID du projet est requis",
|
||||
"select_network": "Sélectionner le réseau",
|
||||
"lead": "Lead",
|
||||
"lead": "Responsable",
|
||||
"private": "Privé",
|
||||
"public": "Public",
|
||||
"accessible_only_by_invite": "Accessible uniquement par invitation",
|
||||
@@ -245,11 +245,11 @@
|
||||
"contact_sales": "Contacter les ventes",
|
||||
"hyper_mode": "Mode hyper",
|
||||
"keyboard_shortcuts": "Raccourcis clavier",
|
||||
"whats_new": "Nouveautés?",
|
||||
"whats_new": "Nouveautés ?",
|
||||
"version": "Version",
|
||||
"we_are_having_trouble_fetching_the_updates": "Nous avons des difficultés à récupérer les mises à jour.",
|
||||
"our_changelogs": "nos changelogs",
|
||||
"for_the_latest_updates": "pour les dernières mises à jour.",
|
||||
"our_changelogs": "Notre journal de modifications",
|
||||
"for_the_latest_updates": "Pour les dernières mises à jour.",
|
||||
"please_visit": "Veuillez visiter",
|
||||
"docs": "Documentation",
|
||||
"full_changelog": "Journal complet",
|
||||
@@ -316,5 +316,5 @@
|
||||
"remove_parent_issue": "Supprimer le problème parent",
|
||||
"add_parent": "Ajouter un parent",
|
||||
"loading_members": "Chargement des membres...",
|
||||
"inbox": "boîte de réception"
|
||||
"inbox": "Boîte de réception"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
{
|
||||
"submit": "提交",
|
||||
"cancel": "取消",
|
||||
"loading": "加载中",
|
||||
"error": "错误",
|
||||
"success": "成功",
|
||||
"warning": "警告",
|
||||
"info": "信息",
|
||||
"close": "关闭",
|
||||
"yes": "是",
|
||||
"no": "否",
|
||||
"ok": "确定",
|
||||
"name": "名称",
|
||||
"description": "描述",
|
||||
"search": "搜索",
|
||||
"add_member": "添加成员",
|
||||
"remove_member": "移除成员",
|
||||
"add_members": "添加成员",
|
||||
"remove_members": "移除成员",
|
||||
"add": "添加",
|
||||
"remove": "移除",
|
||||
"add_new": "新增",
|
||||
"remove_selected": "移除选中项",
|
||||
"first_name": "名字",
|
||||
"last_name": "姓氏",
|
||||
"email": "电子邮件",
|
||||
"display_name": "显示名称",
|
||||
"role": "角色",
|
||||
"timezone": "时区",
|
||||
"avatar": "头像",
|
||||
"cover_image": "封面图片",
|
||||
"password": "密码",
|
||||
"change_cover": "更换封面",
|
||||
"language": "语言",
|
||||
"saving": "保存中...",
|
||||
"save_changes": "保存更改",
|
||||
"deactivate_account": "停用账户",
|
||||
"deactivate_account_description": "停用账户后,该账户内的所有数据和资源将被永久删除,且无法恢复。",
|
||||
"profile_settings": "个人资料设置",
|
||||
"your_account": "账户",
|
||||
"profile": "个人资料",
|
||||
"security": "安全",
|
||||
"activity": "活动",
|
||||
"appearance": "外观",
|
||||
"notifications": "通知",
|
||||
"workspaces": "工作区",
|
||||
"create_workspace": "创建工作区",
|
||||
"invitations": "邀请",
|
||||
"summary": "摘要",
|
||||
"assigned": "已分配",
|
||||
"created": "已创建",
|
||||
"subscribed": "已订阅",
|
||||
"you_do_not_have_the_permission_to_access_this_page": "您没有权限访问此页面。",
|
||||
"failed_to_sign_out_please_try_again": "登出失败,请重试。",
|
||||
"password_changed_successfully": "密码已成功更改。",
|
||||
"something_went_wrong_please_try_again": "出错了,请重试。",
|
||||
"change_password": "更改密码",
|
||||
"passwords_dont_match": "密码不匹配",
|
||||
"current_password": "当前密码",
|
||||
"new_password": "新密码",
|
||||
"confirm_password": "确认密码",
|
||||
"this_field_is_required": "此字段为必填项",
|
||||
"changing_password": "正在更改密码",
|
||||
"please_enter_your_password": "请输入您的密码。",
|
||||
"password_length_should_me_more_than_8_characters": "密码长度应大于8个字符。",
|
||||
"password_is_weak": "密码强度较弱。",
|
||||
"password_is_strong": "密码强度较强。",
|
||||
"load_more": "加载更多",
|
||||
"select_or_customize_your_interface_color_scheme": "选择或自定义界面配色方案。",
|
||||
"theme": "主题",
|
||||
"system_preference": "系统偏好",
|
||||
"light": "浅色",
|
||||
"dark": "深色",
|
||||
"light_contrast": "浅色高对比",
|
||||
"dark_contrast": "深色高对比",
|
||||
"custom": "自定义主题",
|
||||
"select_your_theme": "选择主题",
|
||||
"customize_your_theme": "自定义主题",
|
||||
"background_color": "背景颜色",
|
||||
"text_color": "文字颜色",
|
||||
"primary_color": "主色调",
|
||||
"sidebar_background_color": "侧边栏背景颜色",
|
||||
"sidebar_text_color": "侧边栏文字颜色",
|
||||
"set_theme": "设置主题",
|
||||
"enter_a_valid_hex_code_of_6_characters": "请输入6位有效的十六进制代码",
|
||||
"background_color_is_required": "背景颜色为必填项",
|
||||
"text_color_is_required": "文字颜色为必填项",
|
||||
"primary_color_is_required": "主色调为必填项",
|
||||
"sidebar_background_color_is_required": "侧边栏背景颜色为必填项",
|
||||
"sidebar_text_color_is_required": "侧边栏文字颜色为必填项",
|
||||
"updating_theme": "正在更新主题",
|
||||
"theme_updated_successfully": "主题已成功更新",
|
||||
"failed_to_update_the_theme": "主题更新失败",
|
||||
"email_notifications": "电子邮件通知",
|
||||
"stay_in_the_loop_on_issues_you_are_subscribed_to_enable_this_to_get_notified": "启用此功能以接收您订阅问题的通知。",
|
||||
"email_notification_setting_updated_successfully": "电子邮件通知设置已成功更新",
|
||||
"failed_to_update_email_notification_setting": "电子邮件通知设置更新失败",
|
||||
"notify_me_when": "在以下情况下通知我",
|
||||
"property_changes": "属性更改",
|
||||
"property_changes_description": "当负责人、优先级、估算等属性更改时通知。",
|
||||
"state_change": "状态更改",
|
||||
"state_change_description": "当问题状态更改时通知",
|
||||
"issue_completed": "问题完成",
|
||||
"issue_completed_description": "仅在问题完成时通知",
|
||||
"comments": "评论",
|
||||
"comments_description": "当有人在问题中添加评论时通知",
|
||||
"mentions": "提及",
|
||||
"mentions_description": "仅在评论或描述中提及您时通知",
|
||||
"create_your_workspace": "创建工作区",
|
||||
"only_your_instance_admin_can_create_workspaces": "只有实例管理员可以创建工作区",
|
||||
"only_your_instance_admin_can_create_workspaces_description": "如果您知道实例管理员的电子邮件地址,请点击以下按钮联系他们。",
|
||||
"go_back": "返回",
|
||||
"request_instance_admin": "请求实例管理员",
|
||||
"plane_logo": "Plane 徽标",
|
||||
"workspace_creation_disabled": "工作区创建已禁用",
|
||||
"workspace_request_subject": "新工作区请求",
|
||||
"workspace_request_body": "实例管理员:\n\n请在 URL [/workspace-name] 处创建一个新的工作区。[工作区创建目的]\n\n谢谢。\n{{firstName}} {{lastName}}\n{{email}}",
|
||||
"creating_workspace": "正在创建工作区",
|
||||
"workspace_created_successfully": "工作区已成功创建",
|
||||
"create_workspace_page": "创建工作区页面",
|
||||
"workspace_could_not_be_created_please_try_again": "无法创建工作区,请重试。",
|
||||
"workspace_could_not_be_created_please_try_again_description": "创建工作区时出错,请重试。",
|
||||
"this_is_a_required_field": "此字段为必填项。",
|
||||
"name_your_workspace": "为工作区命名",
|
||||
"workspaces_names_can_contain_only_space_dash_and_alphanumeric_characters": "工作区名称只能包含空格、破折号和字母数字字符。",
|
||||
"limit_your_name_to_80_characters": "名称长度限制为80个字符。",
|
||||
"set_your_workspace_url": "设置工作区URL",
|
||||
"limit_your_url_to_48_characters": "URL长度限制为48个字符。",
|
||||
"how_many_people_will_use_this_workspace": "有多少人将使用此工作区?",
|
||||
"how_many_people_will_use_this_workspace_description": "这将帮助确定购买的席位数量。",
|
||||
"select_a_range": "选择一个范围",
|
||||
"urls_can_contain_only_dash_and_alphanumeric_characters": "URL只能包含破折号和字母数字字符。",
|
||||
"something_familiar_and_recognizable_is_always_best": "熟悉且易于识别的名称总是最佳选择。",
|
||||
"workspace_url_is_already_taken": "工作区URL已被占用!",
|
||||
"old_password": "旧密码",
|
||||
"general_settings": "常规设置",
|
||||
"sign_out": "登出",
|
||||
"signing_out": "正在登出",
|
||||
"active_cycles": "活跃周期",
|
||||
"active_cycles_description": "监控所有项目的周期,跟踪高优先级问题,并关注需要关注的周期。",
|
||||
"on_demand_snapshots_of_all_your_cycles": "所有周期的按需快照",
|
||||
"upgrade": "升级",
|
||||
"10000_feet_view": "所有活跃周期的概览",
|
||||
"10000_feet_view_description": "放大查看所有项目的周期,而不是单独查看每个项目的周期。",
|
||||
"get_snapshot_of_each_active_cycle": "获取每个活跃周期的快照",
|
||||
"get_snapshot_of_each_active_cycle_description": "跟踪所有活跃周期的高级指标,查看进度并了解范围与截止日期的关系。",
|
||||
"compare_burndowns": "比较燃尽图",
|
||||
"compare_burndowns_description": "监控每个团队的表现,查看每个周期的燃尽报告。",
|
||||
"quickly_see_make_or_break_issues": "快速查看关键问题",
|
||||
"quickly_see_make_or_break_issues_description": "预览每个周期中与截止日期相关的高优先级问题。一键查看所有周期的详细信息。",
|
||||
"zoom_into_cycles_that_need_attention": "关注需要关注的周期",
|
||||
"zoom_into_cycles_that_need_attention_description": "一键调查未达预期的周期状态。",
|
||||
"stay_ahead_of_blockers": "提前解决阻碍",
|
||||
"stay_ahead_of_blockers_description": "发现项目间的挑战,查看其他视图中不明显的周期依赖关系。",
|
||||
"analytics": "分析",
|
||||
"workspace_invites": "工作区邀请",
|
||||
"workspace_settings": "工作区设置",
|
||||
"enter_god_mode": "进入上帝模式",
|
||||
"workspace_logo": "工作区徽标",
|
||||
"new_issue": "新建问题",
|
||||
"home": "首页",
|
||||
"your_work": "您的工作",
|
||||
"drafts": "草稿",
|
||||
"projects": "项目",
|
||||
"views": "视图",
|
||||
"workspace": "工作区",
|
||||
"archives": "归档",
|
||||
"settings": "设置",
|
||||
"failed_to_move_favorite": "移动收藏失败",
|
||||
"your_favorites": "您的收藏",
|
||||
"no_favorites_yet": "尚无收藏",
|
||||
"create_folder": "创建文件夹",
|
||||
"new_folder": "新建文件夹",
|
||||
"favorite_updated_successfully": "收藏已成功更新",
|
||||
"favorite_created_successfully": "收藏已成功创建",
|
||||
"folder_already_exists": "文件夹已存在",
|
||||
"folder_name_cannot_be_empty": "文件夹名称不能为空",
|
||||
"something_went_wrong": "出错了",
|
||||
"failed_to_reorder_favorite": "重新排序收藏失败",
|
||||
"favorite_removed_successfully": "收藏已成功移除",
|
||||
"failed_to_create_favorite": "创建收藏失败",
|
||||
"failed_to_rename_favorite": "重命名收藏失败",
|
||||
"project_link_copied_to_clipboard": "项目链接已复制到剪贴板",
|
||||
"link_copied": "链接已复制",
|
||||
"your_projects": "您的项目",
|
||||
"add_project": "添加项目",
|
||||
"create_project": "创建项目",
|
||||
"failed_to_remove_project_from_favorites": "无法从收藏中移除项目,请重试。",
|
||||
"project_created_successfully": "项目已成功创建",
|
||||
"project_created_successfully_description": "项目已成功创建。您现在可以开始添加问题。",
|
||||
"project_cover_image_alt": "项目封面图片",
|
||||
"name_is_required": "名称为必填项",
|
||||
"title_should_be_less_than_255_characters": "标题应少于255个字符",
|
||||
"project_name": "项目名称",
|
||||
"project_id_must_be_at_least_1_character": "项目ID至少为1个字符",
|
||||
"project_id_must_be_at_most_5_characters": "项目ID最多为5个字符",
|
||||
"project_id": "项目ID",
|
||||
"project_id_tooltip_content": "用于唯一标识项目中的问题。最多5个字符。",
|
||||
"description_placeholder": "描述...",
|
||||
"only_alphanumeric_non_latin_characters_allowed": "仅允许字母数字和非拉丁字符。",
|
||||
"project_id_is_required": "项目ID为必填项",
|
||||
"select_network": "选择网络",
|
||||
"lead": "负责人",
|
||||
"private": "私有",
|
||||
"public": "公开",
|
||||
"accessible_only_by_invite": "仅限邀请访问",
|
||||
"anyone_in_the_workspace_except_guests_can_join": "除访客外,工作区中的任何人都可以加入",
|
||||
"creating": "创建中",
|
||||
"creating_project": "正在创建项目",
|
||||
"adding_project_to_favorites": "正在将项目添加到收藏",
|
||||
"project_added_to_favorites": "项目已添加到收藏",
|
||||
"couldnt_add_the_project_to_favorites": "无法将项目添加到收藏,请重试。",
|
||||
"removing_project_from_favorites": "正在从收藏中移除项目",
|
||||
"project_removed_from_favorites": "项目已从收藏中移除",
|
||||
"couldnt_remove_the_project_from_favorites": "无法从收藏中移除项目,请重试。",
|
||||
"add_to_favorites": "添加到收藏",
|
||||
"remove_from_favorites": "从收藏中移除",
|
||||
"publish_settings": "发布设置",
|
||||
"publish": "发布",
|
||||
"copy_link": "复制链接",
|
||||
"leave_project": "离开项目",
|
||||
"join_the_project_to_rearrange": "加入项目以重新排序",
|
||||
"drag_to_rearrange": "拖动以重新排序",
|
||||
"congrats": "恭喜!",
|
||||
"project": "项目",
|
||||
"open_project": "打开项目",
|
||||
"issues": "问题",
|
||||
"cycles": "周期",
|
||||
"modules": "模块",
|
||||
"pages": "页面",
|
||||
"intake": "接收",
|
||||
"time_tracking": "时间跟踪",
|
||||
"work_management": "工作管理",
|
||||
"projects_and_issues": "项目与问题",
|
||||
"projects_and_issues_description": "在此项目上启用或禁用。",
|
||||
"cycles_description": "按时间段对项目工作进行时间盒管理,并调整频率。",
|
||||
"modules_description": "将工作分组为类似子项目的设置,拥有自己的负责人和分配人。",
|
||||
"views_description": "保存排序、过滤和显示选项以供以后使用或共享。",
|
||||
"pages_description": "写下一些内容。",
|
||||
"intake_description": "启用此功能以接收您订阅问题的通知。",
|
||||
"time_tracking_description": "跟踪问题和项目所花费的时间。",
|
||||
"work_management_description": "轻松管理工作和项目。",
|
||||
"documentation": "文档",
|
||||
"message_support": "联系支持",
|
||||
"contact_sales": "联系销售",
|
||||
"hyper_mode": "超速模式",
|
||||
"keyboard_shortcuts": "键盘快捷键",
|
||||
"whats_new": "新功能",
|
||||
"version": "版本",
|
||||
"we_are_having_trouble_fetching_the_updates": "获取更新时出现问题。",
|
||||
"our_changelogs": "我们的更新日志",
|
||||
"for_the_latest_updates": "获取最新更新,请访问",
|
||||
"please_visit": "请访问",
|
||||
"docs": "文档",
|
||||
"full_changelog": "完整更新日志",
|
||||
"support": "支持",
|
||||
"discord": "Discord",
|
||||
"powered_by_plane_pages": "由 Plane Pages 提供支持",
|
||||
"please_select_at_least_one_invitation": "请至少选择一个邀请。",
|
||||
"please_select_at_least_one_invitation_description": "请至少选择一个邀请以加入工作区。",
|
||||
"we_see_that_someone_has_invited_you_to_join_a_workspace": "我们发现有人邀请您加入一个工作区",
|
||||
"join_a_workspace": "加入工作区",
|
||||
"we_see_that_someone_has_invited_you_to_join_a_workspace_description": "我们发现有人邀请您加入一个工作区",
|
||||
"join_a_workspace_description": "加入工作区",
|
||||
"accept_and_join": "接受并加入",
|
||||
"go_home": "返回首页",
|
||||
"no_pending_invites": "没有待处理的邀请",
|
||||
"you_can_see_here_if_someone_invites_you_to_a_workspace": "如果有人邀请您加入工作区,您可以在此处查看",
|
||||
"back_to_home": "返回首页",
|
||||
"workspace_name": "工作区名称",
|
||||
"deactivate_your_account": "停用您的账户",
|
||||
"deactivate_your_account_description": "停用后,您将无法再分配问题,工作区的计费也将停止。要重新激活账户,您需要使用此电子邮件地址收到工作区邀请。",
|
||||
"deactivating": "正在停用",
|
||||
"confirm": "确认",
|
||||
"draft_created": "草稿已创建",
|
||||
"issue_created_successfully": "问题已成功创建",
|
||||
"draft_creation_failed": "草稿创建失败",
|
||||
"issue_creation_failed": "问题创建失败",
|
||||
"draft_issue": "草稿问题",
|
||||
"issue_updated_successfully": "问题已成功更新",
|
||||
"issue_could_not_be_updated": "无法更新问题",
|
||||
"create_a_draft": "创建草稿",
|
||||
"save_to_drafts": "保存为草稿",
|
||||
"save": "保存",
|
||||
"updating": "正在更新",
|
||||
"create_new_issue": "创建新问题",
|
||||
"editor_is_not_ready_to_discard_changes": "编辑器尚未准备好丢弃更改",
|
||||
"failed_to_move_issue_to_project": "无法将问题移动到项目",
|
||||
"create_more": "创建更多",
|
||||
"add_to_project": "添加到项目",
|
||||
"discard": "丢弃",
|
||||
"duplicate_issue_found": "发现重复问题",
|
||||
"duplicate_issues_found": "发现重复问题",
|
||||
"no_matching_results": "没有匹配的结果",
|
||||
"title_is_required": "标题为必填项",
|
||||
"title": "标题",
|
||||
"state": "状态",
|
||||
"priority": "优先级",
|
||||
"none": "无",
|
||||
"urgent": "紧急",
|
||||
"high": "高",
|
||||
"medium": "中",
|
||||
"low": "低",
|
||||
"members": "成员",
|
||||
"assignee": "分配人",
|
||||
"assignees": "分配人",
|
||||
"you": "您",
|
||||
"labels": "标签",
|
||||
"create_new_label": "创建新标签",
|
||||
"start_date": "开始日期",
|
||||
"due_date": "截止日期",
|
||||
"cycle": "周期",
|
||||
"estimate": "估算",
|
||||
"change_parent_issue": "更改父问题",
|
||||
"remove_parent_issue": "移除父问题",
|
||||
"add_parent": "添加父问题",
|
||||
"loading_members": "正在加载成员...",
|
||||
"inbox": "收件箱"
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import IntlMessageFormat from "intl-messageformat";
|
||||
import get from "lodash/get";
|
||||
import { makeAutoObservable } from "mobx";
|
||||
// constants
|
||||
import { FALLBACK_LANGUAGE, SUPPORTED_LANGUAGES, STORAGE_KEY } from "../constants";
|
||||
// types
|
||||
import { TLanguage, ILanguageOption, ITranslations } from "../types";
|
||||
|
||||
/**
|
||||
* Mobx store class for handling translations and language changes in the application
|
||||
* Provides methods to translate keys with params and change the language
|
||||
* Uses IntlMessageFormat to format the translations
|
||||
*/
|
||||
export class TranslationStore {
|
||||
// List of translations for each language
|
||||
private translations: ITranslations = {};
|
||||
// Cache for IntlMessageFormat instances
|
||||
private messageCache: Map<string, IntlMessageFormat> = new Map();
|
||||
// Current language
|
||||
currentLocale: TLanguage = FALLBACK_LANGUAGE;
|
||||
|
||||
/**
|
||||
* Constructor for the TranslationStore class
|
||||
*/
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
this.initializeLanguage();
|
||||
this.loadTranslations();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads translations from JSON files and initializes the message cache
|
||||
*/
|
||||
private async loadTranslations() {
|
||||
try {
|
||||
// dynamic import of translations
|
||||
const translations = {
|
||||
en: (await import("../locales/en/translations.json")).default,
|
||||
fr: (await import("../locales/fr/translations.json")).default,
|
||||
es: (await import("../locales/es/translations.json")).default,
|
||||
ja: (await import("../locales/ja/translations.json")).default,
|
||||
"zh-CN": (await import("../locales/zh-CN/translations.json")).default,
|
||||
};
|
||||
this.translations = translations;
|
||||
this.messageCache.clear(); // Clear cache when translations change
|
||||
} catch (error) {
|
||||
console.error("Failed to load translations:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Initializes the language based on the local storage or browser language */
|
||||
private initializeLanguage() {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const savedLocale = localStorage.getItem(STORAGE_KEY) as TLanguage;
|
||||
if (this.isValidLanguage(savedLocale)) {
|
||||
this.setLanguage(savedLocale);
|
||||
return;
|
||||
}
|
||||
|
||||
const browserLang = this.getBrowserLanguage();
|
||||
this.setLanguage(browserLang);
|
||||
}
|
||||
|
||||
/** Checks if the language is valid based on the supported languages */
|
||||
private isValidLanguage(lang: string | null): lang is TLanguage {
|
||||
return lang !== null && this.availableLanguages.some((l) => l.value === lang);
|
||||
}
|
||||
|
||||
/** Checks if a language code is similar to any supported language */
|
||||
private findSimilarLanguage(lang: string): TLanguage | null {
|
||||
// Convert to lowercase for case-insensitive comparison
|
||||
const normalizedLang = lang.toLowerCase();
|
||||
|
||||
// Find a supported language that includes or is included in the browser language
|
||||
const similarLang = this.availableLanguages.find(
|
||||
(l) => normalizedLang.includes(l.value.toLowerCase()) || l.value.toLowerCase().includes(normalizedLang)
|
||||
);
|
||||
|
||||
return similarLang ? similarLang.value : null;
|
||||
}
|
||||
|
||||
/** Gets the browser language based on the navigator.language */
|
||||
private getBrowserLanguage(): TLanguage {
|
||||
const browserLang = navigator.language;
|
||||
|
||||
// Check exact match first
|
||||
if (this.isValidLanguage(browserLang)) {
|
||||
return browserLang;
|
||||
}
|
||||
|
||||
// Check base language without region code
|
||||
const baseLang = browserLang.split("-")[0];
|
||||
if (this.isValidLanguage(baseLang)) {
|
||||
return baseLang as TLanguage;
|
||||
}
|
||||
|
||||
// Try to find a similar language
|
||||
const similarLang = this.findSimilarLanguage(browserLang) || this.findSimilarLanguage(baseLang);
|
||||
|
||||
return similarLang || FALLBACK_LANGUAGE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the cache key for the given key and locale
|
||||
* @param key - the key to get the cache key for
|
||||
* @param locale - the locale to get the cache key for
|
||||
* @returns the cache key for the given key and locale
|
||||
*/
|
||||
private getCacheKey(key: string, locale: TLanguage): string {
|
||||
return `${locale}:${key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the IntlMessageFormat instance for the given key and locale
|
||||
* Returns cached instance if available
|
||||
* Throws an error if the key is not found in the translations
|
||||
*/
|
||||
private getMessageInstance(key: string, locale: TLanguage): IntlMessageFormat | null {
|
||||
const cacheKey = this.getCacheKey(key, locale);
|
||||
|
||||
// Check if the cache already has the key
|
||||
if (this.messageCache.has(cacheKey)) {
|
||||
return this.messageCache.get(cacheKey) || null;
|
||||
}
|
||||
|
||||
// Get the message from the translations
|
||||
const message = get(this.translations[locale], key);
|
||||
if (!message) return null;
|
||||
|
||||
try {
|
||||
const formatter = new IntlMessageFormat(message as any, locale);
|
||||
this.messageCache.set(cacheKey, formatter);
|
||||
return formatter;
|
||||
} catch (error) {
|
||||
console.error(`Failed to create message formatter for key "${key}":`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates a key with params using the current locale
|
||||
* Falls back to the default language if the translation is not found
|
||||
* Returns the key itself if the translation is not found
|
||||
* @param key - The key to translate
|
||||
* @param params - The params to format the translation with
|
||||
* @returns The translated string
|
||||
*/
|
||||
t(key: string, params?: Record<string, any>): string {
|
||||
try {
|
||||
// Try current locale
|
||||
let formatter = this.getMessageInstance(key, this.currentLocale);
|
||||
|
||||
// Fallback to default language if necessary
|
||||
if (!formatter && this.currentLocale !== FALLBACK_LANGUAGE) {
|
||||
formatter = this.getMessageInstance(key, FALLBACK_LANGUAGE);
|
||||
}
|
||||
|
||||
// If we have a formatter, use it
|
||||
if (formatter) {
|
||||
return formatter.format(params || {}) as string;
|
||||
}
|
||||
|
||||
// Last resort: return the key itself
|
||||
return key;
|
||||
} catch (error) {
|
||||
console.error(`Translation error for key "${key}":`, error);
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the current language and updates the translations
|
||||
* @param lng - The new language
|
||||
*/
|
||||
setLanguage(lng: TLanguage): void {
|
||||
try {
|
||||
if (!this.isValidLanguage(lng)) {
|
||||
throw new Error(`Invalid language: ${lng}`);
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(STORAGE_KEY, lng);
|
||||
}
|
||||
this.currentLocale = lng;
|
||||
this.messageCache.clear(); // Clear cache when language changes
|
||||
if (typeof window !== "undefined") {
|
||||
document.documentElement.lang = lng;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to set language:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the available language options for the dropdown
|
||||
* @returns An array of language options
|
||||
*/
|
||||
get availableLanguages(): ILanguageOption[] {
|
||||
return SUPPORTED_LANGUAGES;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./language";
|
||||
export * from "./translation";
|
||||
@@ -0,0 +1,6 @@
|
||||
export type TLanguage = "en" | "fr" | "es" | "ja" | "zh-CN";
|
||||
|
||||
export interface ILanguageOption {
|
||||
label: string;
|
||||
value: TLanguage;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface ITranslation {
|
||||
[key: string]: string | ITranslation;
|
||||
}
|
||||
|
||||
export interface ITranslations {
|
||||
[locale: string]: ITranslation;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
build/*
|
||||
dist/*
|
||||
out/*
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["@plane/eslint-config/library.js"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@plane/propel",
|
||||
"version": "0.24.1",
|
||||
"private": true,
|
||||
"exports": {
|
||||
"./globals.css": "./src/globals.css",
|
||||
"./components/*": "./src/*.tsx"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "*",
|
||||
"@plane/tailwind-config": "*",
|
||||
"@plane/typescript-config": "*",
|
||||
"@types/react": "18.3.1",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.469.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 47.4% 11.2%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 47.4% 11.2%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 100% 50%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--ring: 215 20.2% 65.1%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 224 71% 4%;
|
||||
--foreground: 213 31% 91%;
|
||||
--muted: 223 47% 11%;
|
||||
--muted-foreground: 215.4 16.3% 56.9%;
|
||||
--accent: 216 34% 17%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--popover: 224 71% 4%;
|
||||
--popover-foreground: 215 20.2% 65.1%;
|
||||
--border: 216 34% 17%;
|
||||
--input: 216 34% 17%;
|
||||
--card: 224 71% 4%;
|
||||
--card-foreground: 213 31% 91%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 1.2%;
|
||||
--secondary: 222.2 47.4% 11.2%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--destructive: 0 63% 31%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--ring: 216 34% 17%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply font-sans antialiased bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
import sharedConfig from "@plane/tailwind-config/tailwind.config";
|
||||
|
||||
export default {
|
||||
...sharedConfig,
|
||||
content: ["./src/**/*.{ts,tsx}"],
|
||||
} satisfies Config;
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@plane/typescript-config/react-library.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import axios, { AxiosInstance, AxiosRequestConfig } from "axios";
|
||||
import { IndexedDBService } from "./indexedDB.service";
|
||||
|
||||
/**
|
||||
* Abstract base class for making HTTP requests using axios
|
||||
@@ -34,7 +35,19 @@ export abstract class APIService {
|
||||
(error) => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
const currentPath = window.location.pathname;
|
||||
window.location.replace(`/${currentPath ? `?next_path=${currentPath}` : ``}`);
|
||||
let prefix = "/";
|
||||
let updatedPath = currentPath;
|
||||
|
||||
// Check for special path prefixes
|
||||
if (currentPath.startsWith("/god-mode")) {
|
||||
prefix = "/god-mode";
|
||||
updatedPath = currentPath.replace("/god-mode", "");
|
||||
} else if (currentPath.startsWith("/spaces")) {
|
||||
prefix = "/spaces";
|
||||
updatedPath = currentPath.replace("/spaces", "");
|
||||
}
|
||||
|
||||
window.location.replace(`${prefix}${updatedPath ? `?next_path=${updatedPath}` : ""}`);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./auth.service";
|
||||
export * from "./sites-auth.service";
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
// types
|
||||
import { IEmailCheckData, IEmailCheckResponse } from "@plane/types";
|
||||
// services
|
||||
import { APIService } from "../api.service";
|
||||
|
||||
/**
|
||||
* Service class for handling authentication-related operations for Plane space application
|
||||
* Provides methods for user authentication, password management, and session handling
|
||||
* @extends {APIService}
|
||||
* @remarks This service is only available for plane sites
|
||||
*/
|
||||
export class SitesAuthService extends APIService {
|
||||
/**
|
||||
* Creates an instance of SitesAuthService
|
||||
* Initializes with the base API URL
|
||||
*/
|
||||
constructor(BASE_URL?: string) {
|
||||
super(BASE_URL || API_BASE_URL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an email exists in the system
|
||||
* @param {IEmailCheckData} data - Email data to verify
|
||||
* @returns {Promise<IEmailCheckResponse>} Response indicating email status
|
||||
* @throws {Error} Throws response data if the request fails
|
||||
*/
|
||||
async emailCheck(data: IEmailCheckData): Promise<IEmailCheckResponse> {
|
||||
return this.post("/auth/spaces/email-check/", data, { headers: {} })
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique code for magic link authentication
|
||||
* @param {{ email: string }} data - Object containing the email address
|
||||
* @returns {Promise<any>} Response containing the generated unique code
|
||||
* @throws {Error} Throws response data if the request fails
|
||||
*/
|
||||
async generateUniqueCode(data: { email: string }): Promise<any> {
|
||||
return this.post("/auth/spaces/magic-generate/", data, { headers: {} })
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export * from "./cycle-analytics.service";
|
||||
export * from "./cycle-archive.service";
|
||||
export * from "./cycle-operations.service";
|
||||
export * from "./cycle.service";
|
||||
export * from "./sites-cycle.service";
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// plane imports
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import { TPublicCycle } from "@plane/types";
|
||||
// api service
|
||||
import { APIService } from "../api.service";
|
||||
|
||||
/**
|
||||
* Service class for managing cycles within plane sites application.
|
||||
* Extends APIService to handle HTTP requests to the cycle-related endpoints.
|
||||
* @extends {APIService}
|
||||
* @remarks This service is only available for plane sites
|
||||
*/
|
||||
export class SitesCycleService extends APIService {
|
||||
constructor(BASE_URL?: string) {
|
||||
super(BASE_URL || API_BASE_URL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves list of cycles for a specific anchor.
|
||||
* @param anchor - The anchor identifier for the published entity
|
||||
* @returns {Promise<TPublicCycle[]>} The list of cycles
|
||||
* @throws {Error} If the request fails
|
||||
*/
|
||||
async list(anchor: string): Promise<TPublicCycle[]> {
|
||||
return this.get(`/api/public/anchor/${anchor}/cycles/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
+22
-4
@@ -1,7 +1,12 @@
|
||||
import axios from "axios";
|
||||
// services
|
||||
import { APIService } from "@/services/api.service";
|
||||
// api service
|
||||
import { APIService } from "../api.service";
|
||||
|
||||
/**
|
||||
* Service class for handling file upload operations
|
||||
* Handles file uploads
|
||||
* @extends {APIService}
|
||||
*/
|
||||
export class FileUploadService extends APIService {
|
||||
private cancelSource: any;
|
||||
|
||||
@@ -9,7 +14,17 @@ export class FileUploadService extends APIService {
|
||||
super("");
|
||||
}
|
||||
|
||||
async uploadFile(url: string, data: FormData): Promise<void> {
|
||||
/**
|
||||
* Uploads a file to the specified signed URL
|
||||
* @param {string} url - The URL to upload the file to
|
||||
* @param {FormData} data - The form data to upload
|
||||
* @returns {Promise<void>} Promise resolving to void
|
||||
* @throws {Error} If the request fails
|
||||
*/
|
||||
async uploadFile(
|
||||
url: string,
|
||||
data: FormData,
|
||||
): Promise<void> {
|
||||
this.cancelSource = axios.CancelToken.source();
|
||||
return this.post(url, data, {
|
||||
headers: {
|
||||
@@ -28,7 +43,10 @@ export class FileUploadService extends APIService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the upload
|
||||
*/
|
||||
cancelUpload() {
|
||||
this.cancelSource.cancel("Upload canceled");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// plane imports
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
// api service
|
||||
import { APIService } from "../api.service";
|
||||
// helpers
|
||||
import { getAssetIdFromUrl } from "./helper";
|
||||
|
||||
/**
|
||||
* Service class for managing file operations within plane applications.
|
||||
* Extends APIService to handle HTTP requests to the file-related endpoints.
|
||||
* @extends {APIService}
|
||||
*/
|
||||
export class FileService extends APIService {
|
||||
/**
|
||||
* Creates an instance of FileService
|
||||
* @param {string} BASE_URL - The base URL for API requests
|
||||
*/
|
||||
constructor(BASE_URL?: string) {
|
||||
super(BASE_URL || API_BASE_URL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a new asset
|
||||
* @param {string} assetPath - The asset path
|
||||
* @returns {Promise<void>} Promise resolving to void
|
||||
* @throws {Error} If the request fails
|
||||
*/
|
||||
async deleteNewAsset(assetPath: string): Promise<void> {
|
||||
return this.delete(assetPath)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an old editor asset
|
||||
* @param {string} workspaceId - The workspace identifier
|
||||
* @param {string} src - The asset source
|
||||
* @returns {Promise<any>} Promise resolving to void
|
||||
* @throws {Error} If the request fails
|
||||
*/
|
||||
async deleteOldEditorAsset(workspaceId: string, src: string): Promise<any> {
|
||||
const assetKey = getAssetIdFromUrl(src);
|
||||
return this.delete(`/api/workspaces/file-assets/${workspaceId}/${assetKey}/`)
|
||||
.then((response) => response?.status)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores an old editor asset
|
||||
* @param {string} workspaceId - The workspace identifier
|
||||
* @param {string} src - The asset source
|
||||
* @returns {Promise<void>} Promise resolving to void
|
||||
* @throws {Error} If the request fails
|
||||
*/
|
||||
async restoreOldEditorAsset(workspaceId: string, src: string): Promise<void> {
|
||||
const assetKey = getAssetIdFromUrl(src);
|
||||
return this.post(`/api/workspaces/file-assets/${workspaceId}/${assetKey}/restore/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { TFileMetaDataLite, TFileSignedURLResponse } from "@plane/types";
|
||||
|
||||
/**
|
||||
* @description from the provided signed URL response, generate a payload to be used to upload the file
|
||||
* @param {TFileSignedURLResponse} signedURLResponse
|
||||
* @param {File} file
|
||||
* @returns {FormData} file upload request payload
|
||||
*/
|
||||
export const generateFileUploadPayload = (signedURLResponse: TFileSignedURLResponse, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
Object.entries(signedURLResponse.upload_data.fields).forEach(([key, value]) => formData.append(key, value));
|
||||
formData.append("file", file);
|
||||
return formData;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description returns the necessary file meta data to upload a file
|
||||
* @param {File} file
|
||||
* @returns {TFileMetaDataLite} payload with file info
|
||||
*/
|
||||
export const getFileMetaDataForUpload = (file: File): TFileMetaDataLite => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
});
|
||||
|
||||
/**
|
||||
* @description this function returns the assetId from the asset source
|
||||
* @param {string} src
|
||||
* @returns {string} assetId
|
||||
*/
|
||||
export const getAssetIdFromUrl = (src: string): string => {
|
||||
const sourcePaths = src.split("/");
|
||||
const assetUrl = sourcePaths[sourcePaths.length - 1];
|
||||
return assetUrl;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./file-upload.service";
|
||||
export * from "./sites-file.service";
|
||||
export * from "./file.service";
|
||||
+52
-34
@@ -1,22 +1,40 @@
|
||||
// plane imports
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
// local services
|
||||
import { TFileEntityInfo, TFileSignedURLResponse } from "@plane/types";
|
||||
import { FileUploadService } from "./file-upload.service";
|
||||
// helpers
|
||||
import { generateFileUploadPayload, getAssetIdFromUrl, getFileMetaDataForUpload } from "@/helpers/file.helper";
|
||||
// services
|
||||
import { APIService } from "@/services/api.service";
|
||||
import { FileUploadService } from "@/services/file-upload.service";
|
||||
import { FileService } from "./file.service";
|
||||
import { generateFileUploadPayload, getAssetIdFromUrl, getFileMetaDataForUpload } from "./helper";
|
||||
|
||||
export class FileService extends APIService {
|
||||
/**
|
||||
* Service class for managing file operations within plane sites application.
|
||||
* Extends FileService to manage file-related operations.
|
||||
* @extends {FileService}
|
||||
* @remarks This service is only available for plane sites
|
||||
*/
|
||||
export class SitesFileService extends FileService {
|
||||
private cancelSource: any;
|
||||
fileUploadService: FileUploadService;
|
||||
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
/**
|
||||
* Creates an instance of SitesFileService
|
||||
* @param {string} BASE_URL - The base URL for API requests
|
||||
*/
|
||||
constructor(BASE_URL?: string) {
|
||||
super(BASE_URL || API_BASE_URL);
|
||||
this.cancelUpload = this.cancelUpload.bind(this);
|
||||
// services
|
||||
this.fileUploadService = new FileUploadService();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the upload status of an asset
|
||||
* @param {string} anchor - The anchor identifier
|
||||
* @param {string} assetId - The asset identifier
|
||||
* @returns {Promise<void>} Promise resolving to void
|
||||
* @throws {Error} If the request fails
|
||||
*/
|
||||
private async updateAssetUploadStatus(anchor: string, assetId: string): Promise<void> {
|
||||
return this.patch(`/api/public/assets/v2/anchor/${anchor}/${assetId}/`)
|
||||
.then((response) => response?.data)
|
||||
@@ -25,6 +43,14 @@ export class FileService extends APIService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the upload status of multiple assets
|
||||
* @param {string} anchor - The anchor identifier
|
||||
* @param {string} entityId - The entity identifier
|
||||
* @param {Object} data - The data payload
|
||||
* @returns {Promise<void>} Promise resolving to void
|
||||
* @throws {Error} If the request fails
|
||||
*/
|
||||
async updateBulkAssetsUploadStatus(
|
||||
anchor: string,
|
||||
entityId: string,
|
||||
@@ -39,6 +65,14 @@ export class FileService extends APIService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a file to the specified anchor
|
||||
* @param {string} anchor - The anchor identifier
|
||||
* @param {TFileEntityInfo} data - The data payload
|
||||
* @param {File} file - The file to upload
|
||||
* @returns {Promise<TFileSignedURLResponse>} Promise resolving to the signed URL response
|
||||
* @throws {Error} If the request fails
|
||||
*/
|
||||
async uploadAsset(anchor: string, data: TFileEntityInfo, file: File): Promise<TFileSignedURLResponse> {
|
||||
const fileMetaData = getFileMetaDataForUpload(file);
|
||||
return this.post(`/api/public/assets/v2/anchor/${anchor}/`, {
|
||||
@@ -57,23 +91,13 @@ export class FileService extends APIService {
|
||||
});
|
||||
}
|
||||
|
||||
async deleteNewAsset(assetPath: string): Promise<void> {
|
||||
return this.delete(assetPath)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async deleteOldEditorAsset(workspaceId: string, src: string): Promise<any> {
|
||||
const assetKey = getAssetIdFromUrl(src);
|
||||
return this.delete(`/api/workspaces/file-assets/${workspaceId}/${assetKey}/`)
|
||||
.then((response) => response?.status)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a new asset
|
||||
* @param {string} workspaceSlug - The workspace slug
|
||||
* @param {string} src - The asset source
|
||||
* @returns {Promise<void>} Promise resolving to void
|
||||
* @throws {Error} If the request fails
|
||||
*/
|
||||
async restoreNewAsset(workspaceSlug: string, src: string): Promise<void> {
|
||||
// remove the last slash and get the asset id
|
||||
const assetId = getAssetIdFromUrl(src);
|
||||
@@ -84,16 +108,10 @@ export class FileService extends APIService {
|
||||
});
|
||||
}
|
||||
|
||||
async restoreOldEditorAsset(workspaceId: string, src: string): Promise<void> {
|
||||
const assetKey = getAssetIdFromUrl(src);
|
||||
return this.post(`/api/workspaces/file-assets/${workspaceId}/${assetKey}/restore/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the upload
|
||||
*/
|
||||
cancelUpload() {
|
||||
this.cancelSource.cancel("Upload cancelled");
|
||||
this.cancelSource.cancelUpload();
|
||||
}
|
||||
}
|
||||
@@ -10,3 +10,7 @@ export * from "./module";
|
||||
export * from "./user";
|
||||
export * from "./project";
|
||||
export * from "./workspace";
|
||||
export * from "./file";
|
||||
export * from "./label";
|
||||
export * from "./state";
|
||||
export * from "./issue";
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
export abstract class IndexedDBService {
|
||||
private dbName: string;
|
||||
private version: number;
|
||||
private db: IDBDatabase | null = null;
|
||||
|
||||
constructor(dbName: string, version: number) {
|
||||
this.dbName = dbName;
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(this.dbName, this.version);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
if (!db.objectStoreNames.contains("workspaces")) {
|
||||
db.createObjectStore("workspaces", { keyPath: "id" });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async save(workspaces: any[]): Promise<void> {
|
||||
if (!this.db) throw new Error("Database not initialized");
|
||||
|
||||
const transaction = this.db.transaction("workspaces", "readwrite");
|
||||
const store = transaction.objectStore("workspaces");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Clear existing data
|
||||
store.clear();
|
||||
|
||||
// Add new workspaces
|
||||
workspaces.forEach((workspace) => {
|
||||
store.add(workspace);
|
||||
});
|
||||
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
|
||||
async query(): Promise<any[]> {
|
||||
if (!this.db) throw new Error("Database not initialized");
|
||||
|
||||
const transaction = this.db.transaction("workspaces", "readonly");
|
||||
const store = transaction.objectStore("workspaces");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./sites-issue.service";
|
||||
@@ -0,0 +1,244 @@
|
||||
// plane imports
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import { IPublicIssue, TIssuePublicComment, TPublicIssuesResponse } from "@plane/types";
|
||||
// api service
|
||||
import { APIService } from "../api.service";
|
||||
|
||||
/**
|
||||
* Service class for managing issues within plane sites application
|
||||
* Extends the APIService class to handle HTTP requests to the issue-related endpoints
|
||||
* @extends {APIService}
|
||||
* @remarks This service is only available for plane sites
|
||||
*/
|
||||
export class SitesIssueService extends APIService {
|
||||
constructor(BASE_URL?: string) {
|
||||
super(BASE_URL || API_BASE_URL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a paginated list of issues for a specific anchor
|
||||
* @param {string} anchor - The anchor identifier
|
||||
* @param {any} params - Optional query parameters
|
||||
* @returns {Promise<TPublicIssuesResponse>} Promise resolving to a paginated list of issues
|
||||
* @throws {Error} If the API request fails
|
||||
*/
|
||||
async list(anchor: string, params: any): Promise<TPublicIssuesResponse> {
|
||||
return this.get(`/api/public/anchor/${anchor}/issues/`, {
|
||||
params,
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves details of a specific issue
|
||||
* @param {string} anchor - The anchor identifier
|
||||
* @param {string} issueID - The issue identifier
|
||||
* @returns {Promise<IPublicIssue>} Promise resolving to the issue details
|
||||
* @throws {Error} If the API request fails
|
||||
*/
|
||||
async retrieve(anchor: string, issueID: string): Promise<IPublicIssue> {
|
||||
return this.get(`/api/public/anchor/${anchor}/issues/${issueID}/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the votes associated with a specific issue
|
||||
* @param {string} anchor - The anchor identifier
|
||||
* @param {string} issueID - The issue identifier
|
||||
* @returns {Promise<any>} Promise resolving to the votes
|
||||
* @throws {Error} If the API request fails
|
||||
*/
|
||||
async listVotes(anchor: string, issueID: string): Promise<any> {
|
||||
return this.get(`/api/public/anchor/${anchor}/issues/${issueID}/votes/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new vote for a specific issue
|
||||
* @param {string} anchor - The anchor identifier
|
||||
* @param {string} issueID - The issue identifier
|
||||
* @param {any} data - The vote data
|
||||
* @returns {Promise<any>} Promise resolving to the created vote
|
||||
* @throws {Error} If the API request fails
|
||||
*/
|
||||
async addVote(anchor: string, issueID: string, data: any): Promise<any> {
|
||||
return this.post(`/api/public/anchor/${anchor}/issues/${issueID}/votes/`, data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a vote for a specific issue
|
||||
* @param {string} anchor - The anchor identifier
|
||||
* @param {string} issueID - The issue identifier
|
||||
* @returns {Promise<any>} Promise resolving to the deletion response
|
||||
* @throws {Error} If the API request fails
|
||||
*/
|
||||
async removeVote(anchor: string, issueID: string): Promise<any> {
|
||||
return this.delete(`/api/public/anchor/${anchor}/issues/${issueID}/votes/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the reactions associated with a specific issue
|
||||
* @param {string} anchor - The anchor identifier
|
||||
* @param {string} issueID - The issue identifier
|
||||
* @returns {Promise<any>} Promise resolving to the reactions
|
||||
* @throws {Error} If the API request fails
|
||||
*/
|
||||
async listReactions(anchor: string, issueID: string): Promise<any> {
|
||||
return this.get(`/api/public/anchor/${anchor}/issues/${issueID}/reactions/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new reaction for a specific issue
|
||||
* @param {string} anchor - The anchor identifier
|
||||
* @param {string} issueID - The issue identifier
|
||||
* @param {any} data - The reaction data
|
||||
* @returns {Promise<any>} Promise resolving to the created reaction
|
||||
* @throws {Error} If the API request fails
|
||||
*/
|
||||
async addReaction(anchor: string, issueID: string, data: any): Promise<any> {
|
||||
return this.post(`/api/public/anchor/${anchor}/issues/${issueID}/reactions/`, data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a reaction for a specific issue
|
||||
* @param {string} anchor - The anchor identifier
|
||||
* @param {string} issueID - The issue identifier
|
||||
* @param {string} reactionId - The reaction identifier
|
||||
* @returns {Promise<any>} Promise resolving to the deletion response
|
||||
* @throws {Error} If the API request fails
|
||||
*/
|
||||
async removeReaction(anchor: string, issueID: string, reactionId: string): Promise<any> {
|
||||
return this.delete(`/api/public/anchor/${anchor}/issues/${issueID}/reactions/${reactionId}/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the comments associated with a specific issue
|
||||
* @param {string} anchor - The anchor identifier
|
||||
* @param {string} issueID - The issue identifier
|
||||
* @returns {Promise<any>} Promise resolving to the comments
|
||||
* @throws {Error} If the API request fails
|
||||
*/
|
||||
async listComments(anchor: string, issueID: string): Promise<any> {
|
||||
return this.get(`/api/public/anchor/${anchor}/issues/${issueID}/comments/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new comment for a specific issue
|
||||
* @param {string} anchor - The anchor identifier
|
||||
* @param {string} issueID - The issue identifier
|
||||
* @param {any} data - The comment data
|
||||
* @returns {Promise<TIssuePublicComment>} Promise resolving to the created comment
|
||||
* @throws {Error} If the API request fails
|
||||
*/
|
||||
async addComment(anchor: string, issueID: string, data: any): Promise<TIssuePublicComment> {
|
||||
return this.post(`/api/public/anchor/${anchor}/issues/${issueID}/comments/`, data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a comment for a specific issue
|
||||
* @param {string} anchor - The anchor identifier
|
||||
* @param {string} issueID - The issue identifier
|
||||
* @param {string} commentId - The comment identifier
|
||||
* @param {any} data - The updated comment data
|
||||
* @returns {Promise<any>} Promise resolving to the updated comment
|
||||
* @throws {Error} If the API request fails
|
||||
*/
|
||||
async updateComment(anchor: string, issueID: string, commentId: string, data: any): Promise<any> {
|
||||
return this.patch(`/api/public/anchor/${anchor}/issues/${issueID}/comments/${commentId}/`, data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a comment for a specific issue
|
||||
* @param {string} anchor - The anchor identifier
|
||||
* @param {string} issueID - The issue identifier
|
||||
* @param {string} commentId - The comment identifier
|
||||
* @returns {Promise<any>} Promise resolving to the deletion response
|
||||
* @throws {Error} If the API request fails
|
||||
*/
|
||||
async removeComment(anchor: string, issueID: string, commentId: string): Promise<any> {
|
||||
return this.delete(`/api/public/anchor/${anchor}/issues/${issueID}/comments/${commentId}/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new reaction for a specific comment
|
||||
* @param {string} anchor - The anchor identifier
|
||||
* @param {string} commentId - The comment identifier
|
||||
* @param {any} data - The reaction data
|
||||
* @returns {Promise<any>} Promise resolving to the created reaction
|
||||
* @throws {Error} If the API request fails
|
||||
*/
|
||||
async addCommentReaction(
|
||||
anchor: string,
|
||||
commentId: string,
|
||||
data: {
|
||||
reaction: string;
|
||||
}
|
||||
): Promise<any> {
|
||||
return this.post(`/api/public/anchor/${anchor}/comments/${commentId}/reactions/`, data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a reaction for a specific comment
|
||||
* @param {string} anchor - The anchor identifier
|
||||
* @param {string} commentId - The comment identifier
|
||||
* @param {string} reactionHex - The reaction identifier
|
||||
* @returns {Promise<any>} Promise resolving to the deletion response
|
||||
* @throws {Error} If the API request fails
|
||||
*/
|
||||
async removeCommentReaction(anchor: string, commentId: string, reactionHex: string): Promise<any> {
|
||||
return this.delete(`/api/public/anchor/${anchor}/comments/${commentId}/reactions/${reactionHex}/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./sites-label.service";
|
||||
@@ -0,0 +1,31 @@
|
||||
// plane imports
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import { IIssueLabel } from "@plane/types";
|
||||
// api service
|
||||
import { APIService } from "../api.service";
|
||||
|
||||
/**
|
||||
* Service class for managing labels within plane sites application.
|
||||
* Extends APIService to handle HTTP requests to the label-related endpoints.
|
||||
* @extends {APIService}
|
||||
* @remarks This service is only available for plane sites
|
||||
*/
|
||||
export class SitesLabelService extends APIService {
|
||||
constructor(BASE_URL?: string) {
|
||||
super(BASE_URL || API_BASE_URL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a list of labels for a specific anchor.
|
||||
* @param {string} anchor - The anchor identifier
|
||||
* @returns {Promise<IIssueLabel[]>} The list of labels
|
||||
* @throws {Error} If the API request fails
|
||||
*/
|
||||
async list(anchor: string): Promise<IIssueLabel[]> {
|
||||
return this.get(`/api/public/anchor/${anchor}/labels/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./link.service";
|
||||
export * from "./module.service";
|
||||
export * from "./operations.service";
|
||||
export * from "./sites-module.service";
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// plane imports
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
// api service
|
||||
import { TPublicModule } from "@plane/types";
|
||||
import { APIService } from "../api.service";
|
||||
|
||||
/**
|
||||
* Service class for managing modules within plane sites application.
|
||||
* Extends APIService to handle HTTP requests to the module-related endpoints.
|
||||
* @extends {APIService}
|
||||
* @remarks This service is only available for plane sites
|
||||
*/
|
||||
export class SitesModuleService extends APIService {
|
||||
constructor(BASE_URL?: string) {
|
||||
super(BASE_URL || API_BASE_URL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a list of modules for a specific anchor.
|
||||
* @param {string} anchor - The anchor identifier
|
||||
* @returns {Promise<TPublicModule[]>} The list of modules
|
||||
* @throws {Error} If the API request fails
|
||||
*/
|
||||
async list(anchor: string): Promise<TPublicModule[]> {
|
||||
return this.get(`/api/public/anchor/${anchor}/modules/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./view.service";
|
||||
export * from "./view.service";
|
||||
export * from "./sites-publish.service";
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// plane imports
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import { TProjectPublishSettings } from "@plane/types";
|
||||
// api service
|
||||
import { APIService } from "../api.service";
|
||||
|
||||
/**
|
||||
* Service class for managing project publish operations within plane sites application.
|
||||
* Extends APIService to handle HTTP requests to the project publish-related endpoints.
|
||||
* @extends {APIService}
|
||||
* @remarks This service is only available for plane sites
|
||||
*/
|
||||
export class SitesProjectPublishService extends APIService {
|
||||
constructor(BASE_URL?: string) {
|
||||
super(BASE_URL || API_BASE_URL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves publish settings for a specific anchor.
|
||||
* @param {string} anchor - The anchor identifier
|
||||
* @returns {Promise<TProjectPublishSettings>} The publish settings
|
||||
* @throws {Error} If the API request fails
|
||||
*/
|
||||
async retrieveSettingsByAnchor(anchor: string): Promise<TProjectPublishSettings> {
|
||||
return this.get(`/api/public/anchor/${anchor}/settings/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves publish settings for a specific project.
|
||||
* @param {string} workspaceSlug - The workspace slug
|
||||
* @param {string} projectID - The project identifier
|
||||
* @returns {Promise<TProjectPublishSettings>} The publish settings
|
||||
* @throws {Error} If the API request fails
|
||||
*/
|
||||
async retrieveSettingsByProjectId(workspaceSlug: string, projectID: string): Promise<TProjectPublishSettings> {
|
||||
return this.get(`/api/public/workspaces/${workspaceSlug}/projects/${projectID}/anchor/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./sites-state.service";
|
||||
@@ -0,0 +1,31 @@
|
||||
// plane imports
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import { IState } from "@plane/types";
|
||||
// api service
|
||||
import { APIService } from "../api.service";
|
||||
|
||||
/**
|
||||
* Service class for managing states within plane sites application.
|
||||
* Extends APIService to handle HTTP requests to the state-related endpoints.
|
||||
* @extends {APIService}
|
||||
* @remarks This service is only available for plane sites
|
||||
*/
|
||||
export class SitesStateService extends APIService {
|
||||
constructor(BASE_URL?: string) {
|
||||
super(BASE_URL || API_BASE_URL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a list of states for a specific anchor.
|
||||
* @param {string} anchor - The anchor identifier
|
||||
* @returns {Promise<IState[]>} The list of states
|
||||
* @throws {Error} If the API request fails
|
||||
*/
|
||||
async list(anchor: string): Promise<IState[]> {
|
||||
return this.get(`/api/public/anchor/${anchor}/states/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./favorite.service";
|
||||
export * from "./user.service";
|
||||
export * from "./sites-member.service";
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// plane imports
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import { TPublicMember } from "@plane/types";
|
||||
// api service
|
||||
import { APIService } from "../api.service";
|
||||
|
||||
/**
|
||||
* Service class for managing members operations within plane sites application.
|
||||
* Extends APIService to handle HTTP requests to the member-related endpoints.
|
||||
* @extends {APIService}
|
||||
* @remarks This service is only available for plane sites
|
||||
*/
|
||||
export class SitesMemberService extends APIService {
|
||||
constructor(BASE_URL?: string) {
|
||||
super(BASE_URL || API_BASE_URL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a list of members for a specific anchor.
|
||||
* @param {string} anchor - The anchor identifier
|
||||
* @returns {Promise<TPublicMember[]>} The list of members
|
||||
* @throws {Error} If the API request fails
|
||||
*/
|
||||
async list(anchor: string): Promise<TPublicMember[]> {
|
||||
return this.get(`/api/public/anchor/${anchor}/members/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// plane imports
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import type { IUser } from "@plane/types";
|
||||
import type { IUser, TUserProfile } from "@plane/types";
|
||||
// api service
|
||||
import { APIService } from "../api.service";
|
||||
|
||||
@@ -18,6 +18,60 @@ export class UserService extends APIService {
|
||||
super(BASE_URL || API_BASE_URL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the current user details
|
||||
* @returns {Promise<IUser>} Promise resolving to the current user details\
|
||||
* @remarks This method uses the validateStatus: null option to bypass interceptors for unauthorized errors.
|
||||
*/
|
||||
async me(): Promise<IUser> {
|
||||
return this.get("/api/users/me/", { validateStatus: null })
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the current user details
|
||||
* @param {Partial<IUser>} data Data to update the user with
|
||||
* @returns {Promise<IUser>} Promise resolving to the updated user details
|
||||
* @throws {Error} If the API request fails
|
||||
*/
|
||||
async update(data: Partial<IUser>): Promise<IUser> {
|
||||
return this.patch("/api/users/me/", data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the current user's profile details
|
||||
* @returns {Promise<TUserProfile>} Promise resolving to the current user's profile details
|
||||
* @throws {Error} If the API request fails
|
||||
*/
|
||||
async profile(): Promise<TUserProfile> {
|
||||
return this.get("/api/users/me/profile/")
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the current user's profile details
|
||||
* @param {Partial<TUserProfile>} data Data to update the user's profile with
|
||||
* @returns {Promise<TUserProfile>} Promise resolving to the updated user's profile details
|
||||
* @throws {Error} If the API request fails
|
||||
*/
|
||||
async updateProfile(data: Partial<TUserProfile>): Promise<TUserProfile> {
|
||||
return this.patch("/api/users/me/profile/", data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the current instance admin details
|
||||
* @returns {Promise<IUser>} Promise resolving to the current instance admin details
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
build/*
|
||||
dist/*
|
||||
out/*
|
||||
@@ -0,0 +1,9 @@
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["@plane/eslint-config/library.js"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
project: true,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@plane/shared-state",
|
||||
"version": "0.24.1",
|
||||
"description": "Shared state shared across multiple apps internally",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.22.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "*",
|
||||
"@types/node": "^22.5.4",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { makeObservable, observable } from "mobx";
|
||||
import { IWorkspaceStore } from "./workspace.store";
|
||||
|
||||
export interface IUserStore {
|
||||
user: any;
|
||||
workspaces: Map<string, IWorkspaceStore>;
|
||||
isLoading: boolean;
|
||||
error: any;
|
||||
}
|
||||
|
||||
export class UserStore implements IUserStore {
|
||||
user: any = null;
|
||||
workspaces: Map<string, IWorkspaceStore> = new Map();
|
||||
isLoading: boolean = false;
|
||||
error: any = null;
|
||||
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
user: observable.ref,
|
||||
workspaces: observable,
|
||||
isLoading: observable.ref,
|
||||
error: observable.ref,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// userStore.ts
|
||||
|
||||
// class UserStore {
|
||||
// user: User | null = null;
|
||||
// workspaces: Workspace[] = [];
|
||||
// isLoading = false;
|
||||
// error: string | null = null;
|
||||
// private indexedDBService: IndexedDBService;
|
||||
|
||||
// constructor() {
|
||||
// makeAutoObservable(this);
|
||||
// this.indexedDBService = new IndexedDBService();
|
||||
// this.init();
|
||||
// }
|
||||
|
||||
// private async init() {
|
||||
// try {
|
||||
// await this.indexedDBService.init();
|
||||
// await this.loadWorkspacesFromIndexedDB();
|
||||
// } catch (error) {
|
||||
// runInAction(() => {
|
||||
// this.error = "Failed to initialize store";
|
||||
// console.error("Store initialization error:", error);
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
|
||||
// setUser(user: User | null) {
|
||||
// this.user = user;
|
||||
// }
|
||||
|
||||
// async loadWorkspacesFromIndexedDB() {
|
||||
// try {
|
||||
// const workspaces = await this.indexedDBService.getWorkspaces();
|
||||
// runInAction(() => {
|
||||
// this.workspaces = workspaces;
|
||||
// });
|
||||
// } catch (error) {
|
||||
// runInAction(() => {
|
||||
// this.error = "Failed to load workspaces from IndexedDB";
|
||||
// console.error("Load workspaces error:", error);
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
|
||||
// async fetchAndSyncWorkspaces() {
|
||||
// this.isLoading = true;
|
||||
// this.error = null;
|
||||
|
||||
// try {
|
||||
// // Simulate API call to fetch workspaces
|
||||
// const response = await fetch("/api/workspaces");
|
||||
// const workspaces = await response.json();
|
||||
|
||||
// // Save to IndexedDB
|
||||
// await this.indexedDBService.saveWorkspaces(workspaces);
|
||||
|
||||
// // Update MobX store
|
||||
// runInAction(() => {
|
||||
// this.workspaces = workspaces;
|
||||
// this.isLoading = false;
|
||||
// });
|
||||
// } catch (error) {
|
||||
// runInAction(() => {
|
||||
// this.error = "Failed to fetch workspaces";
|
||||
// this.isLoading = false;
|
||||
// console.error("Fetch workspaces error:", error);
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
|
||||
// // Additional methods for workspace management
|
||||
// async addWorkspace(workspace: Omit<Workspace, "id" | "createdAt" | "updatedAt">) {
|
||||
// this.isLoading = true;
|
||||
// this.error = null;
|
||||
|
||||
// try {
|
||||
// // Simulate API call to create workspace
|
||||
// const response = await fetch("/api/workspaces", {
|
||||
// method: "POST",
|
||||
// body: JSON.stringify(workspace),
|
||||
// });
|
||||
// const newWorkspace = await response.json();
|
||||
|
||||
// // Update local storage and state
|
||||
// const updatedWorkspaces = [...this.workspaces, newWorkspace];
|
||||
// await this.indexedDBService.saveWorkspaces(updatedWorkspaces);
|
||||
|
||||
// runInAction(() => {
|
||||
// this.workspaces.push(newWorkspace);
|
||||
// this.isLoading = false;
|
||||
// });
|
||||
// } catch (error) {
|
||||
// runInAction(() => {
|
||||
// this.error = "Failed to add workspace";
|
||||
// this.isLoading = false;
|
||||
// console.error("Add workspace error:", error);
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
|
||||
// logout() {
|
||||
// this.user = null;
|
||||
// this.workspaces = [];
|
||||
// // Optionally clear IndexedDB data
|
||||
// this.indexedDBService.init().then(() => {
|
||||
// this.indexedDBService.saveWorkspaces([]);
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
@@ -0,0 +1,28 @@
|
||||
import { makeObservable, observable } from "mobx";
|
||||
|
||||
export interface IWorkspaceStore {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export class WorkspaceStore implements IWorkspaceStore {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
constructor(data: IWorkspaceStore) {
|
||||
makeObservable(this, {
|
||||
id: observable.ref,
|
||||
name: observable.ref,
|
||||
createdAt: observable.ref,
|
||||
updatedAt: observable.ref,
|
||||
});
|
||||
this.id = data.id;
|
||||
this.name = data.name;
|
||||
this.createdAt = data.createdAt;
|
||||
this.updatedAt = data.updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@plane/typescript-config/react-library.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react",
|
||||
"lib": ["esnext", "dom"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["./src"],
|
||||
"exclude": ["dist", "build", "node_modules"]
|
||||
}
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "tailwind-config-custom",
|
||||
"name": "@plane/tailwind-config",
|
||||
"version": "0.24.1",
|
||||
"description": "common tailwind configuration across monorepo",
|
||||
"main": "index.js",
|
||||
"main": "tailwind.config.js",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
@@ -10,7 +10,7 @@
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^2.8.8",
|
||||
"prettier-plugin-tailwindcss": "^0.3.0",
|
||||
"tailwindcss": "^3.2.7",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.6"
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -7,6 +7,7 @@ export interface IEmailCheckData {
|
||||
export interface IEmailCheckResponse {
|
||||
status: "MAGIC_CODE" | "CREDENTIAL";
|
||||
existing: boolean;
|
||||
is_password_autoset: boolean;
|
||||
}
|
||||
|
||||
export interface ILoginTokenResponse {
|
||||
|
||||
Vendored
+8
-3
@@ -104,6 +104,7 @@ export interface ICycle extends TProgressSnapshot {
|
||||
project_detail: IProjectDetails;
|
||||
progress: any[];
|
||||
version: number;
|
||||
pending_issues: number;
|
||||
}
|
||||
|
||||
export interface CycleIssueResponse {
|
||||
@@ -120,9 +121,7 @@ export interface CycleIssueResponse {
|
||||
sub_issues_count: number;
|
||||
}
|
||||
|
||||
export type SelectCycleType =
|
||||
| (ICycle & { actionType: "edit" | "delete" | "create-issue" })
|
||||
| undefined;
|
||||
export type SelectCycleType = (ICycle & { actionType: "edit" | "delete" | "create-issue" }) | undefined;
|
||||
|
||||
export type CycleDateCheckData = {
|
||||
start_date: string;
|
||||
@@ -132,3 +131,9 @@ export type CycleDateCheckData = {
|
||||
|
||||
export type TCycleEstimateType = "issues" | "points";
|
||||
export type TCyclePlotType = "burndown" | "burnup";
|
||||
|
||||
export type TPublicCycle = {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user