diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml index 8a3ace585..0141f7a15 100644 --- a/.github/workflows/build-branch.yml +++ b/.github/workflows/build-branch.yml @@ -273,7 +273,7 @@ jobs: run: | cp ./deploy/selfhost/install.sh deploy/selfhost/setup.sh sed -i 's/${APP_RELEASE:-stable}/${APP_RELEASE:-'${REL_VERSION}'}/g' deploy/selfhost/docker-compose.yml - sed -i 's/APP_RELEASE=stable/APP_RELEASE='${REL_VERSION}'/g' deploy/selfhost/variables.env + # sed -i 's/APP_RELEASE=stable/APP_RELEASE='${REL_VERSION}'/g' deploy/selfhost/variables.env - name: Create Release id: create_release diff --git a/.gitignore b/.gitignore index 80607b92f..36f85dc78 100644 --- a/.gitignore +++ b/.gitignore @@ -78,10 +78,17 @@ pnpm-workspace.yaml .npmrc .secrets tmp/ + ## packages dist .temp/ deploy/selfhost/plane-app/ + ## Storybook *storybook.log output.css + +dev-editor +# Redis +*.rdb +*.rdb.gz diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0f5e99cb8..68ef89085 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,6 +15,22 @@ Without said minimal reproduction, we won't be able to investigate all [issues]( You can open a new issue with this [issue form](https://github.com/makeplane/plane/issues/new). +### Naming conventions for issues + +When opening a new issue, please use a clear and concise title that follows this format: + +- For bugs: `🐛 Bug: [short description]` +- For features: `🚀 Feature: [short description]` +- For improvements: `🛠️ Improvement: [short description]` +- For documentation: `📘 Docs: [short description]` + +**Examples:** +- `🐛 Bug: API token expiry time not saving correctly` +- `📘 Docs: Clarify RAM requirement for local setup` +- `🚀 Feature: Allow custom time selection for token expiration` + +This helps us triage and manage issues more efficiently. + ## Projects setup and Architecture ### Requirements @@ -23,6 +39,8 @@ You can open a new issue with this [issue form](https://github.com/makeplane/pla - Python version 3.8+ - Postgres version v14 - Redis version v6.2.7 +- **Memory**: Minimum **12 GB RAM** recommended + > ⚠️ Running the project on a system with only 8 GB RAM may lead to setup failures or memory crashes (especially during Docker container build/start or dependency install). Use cloud environments like GitHub Codespaces or upgrade local RAM if possible. ### Setup the project diff --git a/ENV_SETUP.md b/ENV_SETUP.md index cdcf6be37..775d6a55f 100644 --- a/ENV_SETUP.md +++ b/ENV_SETUP.md @@ -43,9 +43,6 @@ NGINX_PORT=80 # Debug value for api server use it as 0 for production use DEBUG=0 CORS_ALLOWED_ORIGINS="http://localhost" -# Error logs -SENTRY_DSN="" -SENTRY_ENVIRONMENT="development" # Database Settings POSTGRES_USER="plane" POSTGRES_PASSWORD="plane" diff --git a/README.md b/README.md index 3588f20e2..dad8b4558 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,10 @@

- Website • - Releases • - Twitter • - Documentation + Website • + Releases • + Twitter • + Documentation

@@ -39,7 +39,7 @@

-Meet [Plane](https://dub.sh/plane-website-readme), an open-source project management tool to track issues, run ~sprints~ cycles, and manage product roadmaps without the chaos of managing the tool itself. 🧘‍♀️ +Meet [Plane](https://plane.so/), an open-source project management tool to track issues, run ~sprints~ cycles, and manage product roadmaps without the chaos of managing the tool itself. 🧘‍♀️ > Plane is evolving every day. Your suggestions, ideas, and reported bugs help us immensely. Do not hesitate to join in the conversation on [Discord](https://discord.com/invite/A92xrEGCge) or raise a GitHub issue. We read everything and respond to most. diff --git a/admin/app/authentication/github/form.tsx b/admin/app/authentication/github/form.tsx index 4842675cd..91795ea70 100644 --- a/admin/app/authentication/github/form.tsx +++ b/admin/app/authentication/github/form.tsx @@ -43,6 +43,7 @@ export const InstanceGithubConfigForm: FC = (props) => { defaultValues: { GITHUB_CLIENT_ID: config["GITHUB_CLIENT_ID"], GITHUB_CLIENT_SECRET: config["GITHUB_CLIENT_SECRET"], + GITHUB_ORGANIZATION_ID: config["GITHUB_ORGANIZATION_ID"], }, }); @@ -93,6 +94,19 @@ export const InstanceGithubConfigForm: FC = (props) => { error: Boolean(errors.GITHUB_CLIENT_SECRET), required: true, }, + { + key: "GITHUB_ORGANIZATION_ID", + type: "text", + label: "Organization ID", + description: ( + <> + The organization github ID. + + ), + placeholder: "123456789", + error: Boolean(errors.GITHUB_ORGANIZATION_ID), + required: false, + }, ]; const GITHUB_SERVICE_FIELD: TCopyField[] = [ @@ -150,6 +164,7 @@ export const InstanceGithubConfigForm: FC = (props) => { reset({ GITHUB_CLIENT_ID: response.find((item) => item.key === "GITHUB_CLIENT_ID")?.value, GITHUB_CLIENT_SECRET: response.find((item) => item.key === "GITHUB_CLIENT_SECRET")?.value, + GITHUB_ORGANIZATION_ID: response.find((item) => item.key === "GITHUB_ORGANIZATION_ID")?.value, }); }) .catch((err) => console.error(err)); diff --git a/admin/next.config.js b/admin/next.config.js index 2109cec69..421f645e8 100644 --- a/admin/next.config.js +++ b/admin/next.config.js @@ -9,6 +9,19 @@ const nextConfig = { unoptimized: true, }, basePath: process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "", + transpilePackages: [ + "@plane/constants", + "@plane/editor", + "@plane/hooks", + "@plane/i18n", + "@plane/logger", + "@plane/propel", + "@plane/services", + "@plane/shared-state", + "@plane/types", + "@plane/ui", + "@plane/utils", + ], }; module.exports = nextConfig; diff --git a/admin/package.json b/admin/package.json index 5f3fd5e45..3b6cf3e6b 100644 --- a/admin/package.json +++ b/admin/package.json @@ -1,7 +1,7 @@ { "name": "admin", "description": "Admin UI for Plane", - "version": "0.25.3", + "version": "0.26.0", "license": "AGPL-3.0", "private": true, "scripts": { @@ -21,7 +21,6 @@ "@plane/ui": "*", "@plane/utils": "*", "@plane/services": "*", - "@sentry/nextjs": "^8.54.0", "@tailwindcss/typography": "^0.5.9", "@types/lodash": "^4.17.0", "autoprefixer": "10.4.14", @@ -30,7 +29,7 @@ "lucide-react": "^0.469.0", "mobx": "^6.12.0", "mobx-react": "^9.1.1", - "next": "^14.2.20", + "next": "^14.2.26", "next-themes": "^0.2.1", "postcss": "^8.4.38", "react": "^18.3.1", diff --git a/aio/Dockerfile-app b/aio/Dockerfile-app index 54b5269e3..7ffe3f803 100644 --- a/aio/Dockerfile-app +++ b/aio/Dockerfile-app @@ -145,11 +145,8 @@ RUN chmod +x /app/pg-setup.sh # APPLICATION ENVIRONMENT SETTINGS # ***************************************************************************** ENV APP_DOMAIN=localhost - ENV WEB_URL=http://${APP_DOMAIN} ENV DEBUG=0 -ENV SENTRY_DSN= -ENV SENTRY_ENVIRONMENT=production ENV CORS_ALLOWED_ORIGINS=http://${APP_DOMAIN},https://${APP_DOMAIN} # Secret Key ENV SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5 diff --git a/apiserver/.env.example b/apiserver/.env.example index ff3f353c7..b56494c35 100644 --- a/apiserver/.env.example +++ b/apiserver/.env.example @@ -3,10 +3,6 @@ DEBUG=0 CORS_ALLOWED_ORIGINS="http://localhost" -# Error logs -SENTRY_DSN="" -SENTRY_ENVIRONMENT="development" - # Database Settings POSTGRES_USER="plane" POSTGRES_PASSWORD="plane" diff --git a/apiserver/file.txt b/apiserver/file.txt deleted file mode 100644 index e69de29bb..000000000 diff --git a/apiserver/package.json b/apiserver/package.json index 4bf4b1920..9c019c942 100644 --- a/apiserver/package.json +++ b/apiserver/package.json @@ -1,6 +1,6 @@ { "name": "plane-api", - "version": "0.25.3", + "version": "0.26.0", "license": "AGPL-3.0", "private": true, "description": "API server powering Plane's backend" diff --git a/apiserver/plane/api/serializers/cycle.py b/apiserver/plane/api/serializers/cycle.py index ea3c4eb3d..ba22e25f9 100644 --- a/apiserver/plane/api/serializers/cycle.py +++ b/apiserver/plane/api/serializers/cycle.py @@ -1,4 +1,5 @@ # Third party imports +import pytz from rest_framework import serializers # Module imports @@ -18,6 +19,14 @@ class CycleSerializer(BaseSerializer): completed_estimates = serializers.FloatField(read_only=True) started_estimates = serializers.FloatField(read_only=True) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + project = self.context.get("project") + if project and project.timezone: + project_timezone = pytz.timezone(project.timezone) + self.fields["start_date"].timezone = project_timezone + self.fields["end_date"].timezone = project_timezone + def validate(self, data): if ( data.get("start_date", None) is not None @@ -30,7 +39,15 @@ class CycleSerializer(BaseSerializer): data.get("start_date", None) is not None and data.get("end_date", None) is not None ): - project_id = self.initial_data.get("project_id") or self.instance.project_id + project_id = self.initial_data.get("project_id") or ( + self.instance.project_id + if self.instance and hasattr(self.instance, "project_id") + else None + ) + + if not project_id: + raise serializers.ValidationError("Project ID is required") + is_start_date_end_date_equal = ( True if str(data.get("start_date")) == str(data.get("end_date")) diff --git a/apiserver/plane/api/serializers/project.py b/apiserver/plane/api/serializers/project.py index 744084ab1..c76652e1e 100644 --- a/apiserver/plane/api/serializers/project.py +++ b/apiserver/plane/api/serializers/project.py @@ -16,7 +16,6 @@ class ProjectSerializer(BaseSerializer): member_role = serializers.IntegerField(read_only=True) is_deployed = serializers.BooleanField(read_only=True) cover_image_url = serializers.CharField(read_only=True) - inbox_view = serializers.BooleanField(read_only=True, source="intake_view") class Meta: model = Project diff --git a/apiserver/plane/api/urls/intake.py b/apiserver/plane/api/urls/intake.py index a47d010ee..4ef41d5f0 100644 --- a/apiserver/plane/api/urls/intake.py +++ b/apiserver/plane/api/urls/intake.py @@ -4,16 +4,6 @@ from plane.api.views import IntakeIssueAPIEndpoint urlpatterns = [ - path( - "workspaces//projects//inbox-issues/", - IntakeIssueAPIEndpoint.as_view(), - name="inbox-issue", - ), - path( - "workspaces//projects//inbox-issues//", - IntakeIssueAPIEndpoint.as_view(), - name="inbox-issue", - ), path( "workspaces//projects//intake-issues/", IntakeIssueAPIEndpoint.as_view(), diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 3665e3b0f..3e27ffdc4 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -39,7 +39,7 @@ from plane.db.models import ( UserFavorite, ) from plane.utils.analytics_plot import burndown_plot - +from plane.utils.host import base_host from .base import BaseAPIView from plane.bgtasks.webhook_task import model_activity @@ -137,10 +137,14 @@ class CycleAPIEndpoint(BaseAPIView): ) def get(self, request, slug, project_id, pk=None): + project = Project.objects.get(workspace__slug=slug, pk=project_id) if pk: queryset = self.get_queryset().filter(archived_at__isnull=True).get(pk=pk) data = CycleSerializer( - queryset, fields=self.fields, expand=self.expand + queryset, + fields=self.fields, + expand=self.expand, + context={"project": project}, ).data return Response(data, status=status.HTTP_200_OK) queryset = self.get_queryset().filter(archived_at__isnull=True) @@ -152,7 +156,11 @@ class CycleAPIEndpoint(BaseAPIView): start_date__lte=timezone.now(), end_date__gte=timezone.now() ) data = CycleSerializer( - queryset, many=True, fields=self.fields, expand=self.expand + queryset, + many=True, + fields=self.fields, + expand=self.expand, + context={"project": project}, ).data return Response(data, status=status.HTTP_200_OK) @@ -163,7 +171,11 @@ class CycleAPIEndpoint(BaseAPIView): request=request, queryset=(queryset), on_results=lambda cycles: CycleSerializer( - cycles, many=True, fields=self.fields, expand=self.expand + cycles, + many=True, + fields=self.fields, + expand=self.expand, + context={"project": project}, ).data, ) @@ -174,7 +186,11 @@ class CycleAPIEndpoint(BaseAPIView): request=request, queryset=(queryset), on_results=lambda cycles: CycleSerializer( - cycles, many=True, fields=self.fields, expand=self.expand + cycles, + many=True, + fields=self.fields, + expand=self.expand, + context={"project": project}, ).data, ) @@ -185,7 +201,11 @@ class CycleAPIEndpoint(BaseAPIView): request=request, queryset=(queryset), on_results=lambda cycles: CycleSerializer( - cycles, many=True, fields=self.fields, expand=self.expand + cycles, + many=True, + fields=self.fields, + expand=self.expand, + context={"project": project}, ).data, ) @@ -198,14 +218,22 @@ class CycleAPIEndpoint(BaseAPIView): request=request, queryset=(queryset), on_results=lambda cycles: CycleSerializer( - cycles, many=True, fields=self.fields, expand=self.expand + cycles, + many=True, + fields=self.fields, + expand=self.expand, + context={"project": project}, ).data, ) return self.paginate( request=request, queryset=(queryset), on_results=lambda cycles: CycleSerializer( - cycles, many=True, fields=self.fields, expand=self.expand + cycles, + many=True, + fields=self.fields, + expand=self.expand, + context={"project": project}, ).data, ) @@ -251,7 +279,7 @@ class CycleAPIEndpoint(BaseAPIView): current_instance=None, actor_id=request.user.id, slug=slug, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -323,7 +351,7 @@ class CycleAPIEndpoint(BaseAPIView): current_instance=current_instance, actor_id=request.user.id, slug=slug, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -694,7 +722,7 @@ class CycleIssueAPIEndpoint(BaseAPIView): ), epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) # Return all Cycle Issues return Response( @@ -1168,7 +1196,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): ), epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) return Response({"message": "Success"}, status=status.HTTP_200_OK) diff --git a/apiserver/plane/api/views/intake.py b/apiserver/plane/api/views/intake.py index faefc3761..93acb0664 100644 --- a/apiserver/plane/api/views/intake.py +++ b/apiserver/plane/api/views/intake.py @@ -18,8 +18,9 @@ from plane.api.serializers import IntakeIssueSerializer, IssueSerializer from plane.app.permissions import ProjectLitePermission from plane.bgtasks.issue_activities_task import issue_activity from plane.db.models import Intake, IntakeIssue, Issue, Project, ProjectMember, State - +from plane.utils.host import base_host from .base import BaseAPIView +from plane.db.models.intake import SourceType class IntakeIssueAPIEndpoint(BaseAPIView): @@ -125,7 +126,7 @@ class IntakeIssueAPIEndpoint(BaseAPIView): intake_id=intake.id, project_id=project_id, issue=issue, - source=request.data.get("source", "IN-APP"), + source=SourceType.IN_APP, ) # Create an Issue Activity issue_activity.delay( @@ -297,7 +298,7 @@ class IntakeIssueAPIEndpoint(BaseAPIView): current_instance=current_instance, epoch=int(timezone.now().timestamp()), notification=False, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), intake=str(intake_issue.id), ) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 9f9b189ae..efbdf07f9 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -56,6 +56,8 @@ from plane.db.models import ( from plane.settings.storage import S3Storage from plane.bgtasks.storage_metadata_task import get_asset_object_metadata from .base import BaseAPIView +from plane.utils.host import base_host +from plane.bgtasks.webhook_task import model_activity class WorkspaceIssueAPIEndpoint(BaseAPIView): @@ -321,6 +323,17 @@ class IssueAPIEndpoint(BaseAPIView): current_instance=None, epoch=int(timezone.now().timestamp()), ) + + # Send the model activity + model_activity.delay( + model_name="issue", + model_id=str(serializer.data["id"]), + requested_data=request.data, + current_instance=None, + actor_id=request.user.id, + slug=slug, + origin=base_host(request=request, is_app=True), + ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -1048,7 +1061,7 @@ class IssueAttachmentEndpoint(BaseAPIView): current_instance=None, epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) # Get the storage metadata @@ -1108,7 +1121,7 @@ class IssueAttachmentEndpoint(BaseAPIView): current_instance=json.dumps(serializer.data, cls=DjangoJSONEncoder), epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) # Update the attachment diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index 9e4f4143c..9995bb806 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -33,6 +33,7 @@ from plane.db.models import ( from .base import BaseAPIView from plane.bgtasks.webhook_task import model_activity +from plane.utils.host import base_host class ModuleAPIEndpoint(BaseAPIView): @@ -174,7 +175,7 @@ class ModuleAPIEndpoint(BaseAPIView): current_instance=None, actor_id=request.user.id, slug=slug, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) module = Module.objects.get(pk=serializer.data["id"]) serializer = ModuleSerializer(module) @@ -226,7 +227,7 @@ class ModuleAPIEndpoint(BaseAPIView): current_instance=current_instance, actor_id=request.user.id, slug=slug, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) return Response(serializer.data, status=status.HTTP_200_OK) @@ -280,6 +281,7 @@ class ModuleAPIEndpoint(BaseAPIView): project_id=str(project_id), current_instance=json.dumps({"module_name": str(module.name)}), epoch=int(timezone.now().timestamp()), + origin=base_host(request=request, is_app=True), ) module.delete() # Delete the module issues @@ -449,6 +451,7 @@ class ModuleIssueAPIEndpoint(BaseAPIView): } ), epoch=int(timezone.now().timestamp()), + origin=base_host(request=request, is_app=True), ) return Response( diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index e98f35d57..5ceb06a63 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -30,6 +30,7 @@ from plane.db.models import ( ) from plane.bgtasks.webhook_task import model_activity, webhook_activity from .base import BaseAPIView +from plane.utils.host import base_host class ProjectAPIEndpoint(BaseAPIView): @@ -228,7 +229,7 @@ class ProjectAPIEndpoint(BaseAPIView): current_instance=None, actor_id=request.user.id, slug=slug, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) serializer = ProjectSerializer(project) @@ -238,7 +239,7 @@ class ProjectAPIEndpoint(BaseAPIView): if "already exists" in str(e): return Response( {"name": "The project name is already taken"}, - status=status.HTTP_410_GONE, + status=status.HTTP_409_CONFLICT, ) except Workspace.DoesNotExist: return Response( @@ -247,7 +248,7 @@ class ProjectAPIEndpoint(BaseAPIView): except ValidationError: return Response( {"identifier": "The project identifier is already taken"}, - status=status.HTTP_410_GONE, + status=status.HTTP_409_CONFLICT, ) def patch(self, request, slug, pk): @@ -258,9 +259,7 @@ class ProjectAPIEndpoint(BaseAPIView): ProjectSerializer(project).data, cls=DjangoJSONEncoder ) - intake_view = request.data.get( - "inbox_view", request.data.get("intake_view", project.intake_view) - ) + intake_view = request.data.get("intake_view", project.intake_view) if project.archived_at: return Response( @@ -297,7 +296,7 @@ class ProjectAPIEndpoint(BaseAPIView): current_instance=current_instance, actor_id=request.user.id, slug=slug, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) serializer = ProjectSerializer(project) @@ -307,7 +306,7 @@ class ProjectAPIEndpoint(BaseAPIView): if "already exists" in str(e): return Response( {"name": "The project name is already taken"}, - status=status.HTTP_410_GONE, + status=status.HTTP_409_CONFLICT, ) except (Project.DoesNotExist, Workspace.DoesNotExist): return Response( @@ -316,7 +315,7 @@ class ProjectAPIEndpoint(BaseAPIView): except ValidationError: return Response( {"identifier": "The project identifier is already taken"}, - status=status.HTTP_410_GONE, + status=status.HTTP_409_CONFLICT, ) def delete(self, request, slug, pk): @@ -334,7 +333,7 @@ class ProjectAPIEndpoint(BaseAPIView): new_value=None, actor_id=request.user.id, slug=slug, - current_site=request.META.get("HTTP_ORIGIN"), + current_site=base_host(request=request, is_app=True), event_id=project.id, old_identifier=None, new_identifier=None, diff --git a/apiserver/plane/app/serializers/api.py b/apiserver/plane/app/serializers/api.py index 264a58f92..009f7a611 100644 --- a/apiserver/plane/app/serializers/api.py +++ b/apiserver/plane/app/serializers/api.py @@ -1,5 +1,7 @@ from .base import BaseSerializer from plane.db.models import APIToken, APIActivityLog +from rest_framework import serializers +from django.utils import timezone class APITokenSerializer(BaseSerializer): @@ -17,10 +19,17 @@ class APITokenSerializer(BaseSerializer): class APITokenReadSerializer(BaseSerializer): + is_active = serializers.SerializerMethodField() + class Meta: model = APIToken exclude = ("token",) + def get_is_active(self, obj: APIToken) -> bool: + if obj.expired_at is None: + return True + return timezone.now() < obj.expired_at + class APIActivityLogSerializer(BaseSerializer): class Meta: diff --git a/apiserver/plane/app/serializers/issue.py b/apiserver/plane/app/serializers/issue.py index 8df915c23..e2e943805 100644 --- a/apiserver/plane/app/serializers/issue.py +++ b/apiserver/plane/app/serializers/issue.py @@ -352,8 +352,19 @@ class IssueRelationSerializer(BaseSerializer): "state_id", "priority", "assignee_ids", + "created_by", + "created_at", + "updated_at", + "updated_by", + ] + read_only_fields = [ + "workspace", + "project", + "created_by", + "created_at", + "updated_by", + "updated_at", ] - read_only_fields = ["workspace", "project"] class RelatedIssueSerializer(BaseSerializer): @@ -383,8 +394,19 @@ class RelatedIssueSerializer(BaseSerializer): "state_id", "priority", "assignee_ids", + "created_by", + "created_at", + "updated_by", + "updated_at", + ] + read_only_fields = [ + "workspace", + "project", + "created_by", + "created_at", + "updated_by", + "updated_at", ] - read_only_fields = ["workspace", "project"] class IssueAssigneeSerializer(BaseSerializer): diff --git a/apiserver/plane/app/urls/intake.py b/apiserver/plane/app/urls/intake.py index 397579262..ac4b7ca5c 100644 --- a/apiserver/plane/app/urls/intake.py +++ b/apiserver/plane/app/urls/intake.py @@ -1,7 +1,11 @@ from django.urls import path -from plane.app.views import IntakeViewSet, IntakeIssueViewSet +from plane.app.views import ( + IntakeViewSet, + IntakeIssueViewSet, + IntakeWorkItemDescriptionVersionEndpoint, +) urlpatterns = [ @@ -53,4 +57,14 @@ urlpatterns = [ ), name="inbox-issue", ), + path( + "workspaces//projects//intake-work-items//description-versions/", + IntakeWorkItemDescriptionVersionEndpoint.as_view(), + name="intake-work-item-versions", + ), + path( + "workspaces//projects//intake-work-items//description-versions//", + IntakeWorkItemDescriptionVersionEndpoint.as_view(), + name="intake-work-item-versions", + ), ] diff --git a/apiserver/plane/app/urls/issue.py b/apiserver/plane/app/urls/issue.py index 6c5e45033..db56a6240 100644 --- a/apiserver/plane/app/urls/issue.py +++ b/apiserver/plane/app/urls/issue.py @@ -25,7 +25,7 @@ from plane.app.views import ( IssueAttachmentV2Endpoint, IssueBulkUpdateDateEndpoint, IssueVersionEndpoint, - IssueDescriptionVersionEndpoint, + WorkItemDescriptionVersionEndpoint, IssueMetaEndpoint, IssueDetailIdentifierEndpoint, ) @@ -263,22 +263,22 @@ urlpatterns = [ path( "workspaces//projects//issues//versions/", IssueVersionEndpoint.as_view(), - name="page-versions", + name="issue-versions", ), path( "workspaces//projects//issues//versions//", IssueVersionEndpoint.as_view(), - name="page-versions", + name="issue-versions", ), path( - "workspaces//projects//issues//description-versions/", - IssueDescriptionVersionEndpoint.as_view(), - name="page-versions", + "workspaces//projects//work-items//description-versions/", + WorkItemDescriptionVersionEndpoint.as_view(), + name="work-item-versions", ), path( - "workspaces//projects//issues//description-versions//", - IssueDescriptionVersionEndpoint.as_view(), - name="page-versions", + "workspaces//projects//work-items//description-versions//", + WorkItemDescriptionVersionEndpoint.as_view(), + name="work-item-versions", ), path( "workspaces//projects//issues//meta/", diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index ba63920f6..7baba9bb0 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -144,7 +144,7 @@ from .issue.sub_issue import SubIssuesEndpoint from .issue.subscriber import IssueSubscriberViewSet -from .issue.version import IssueVersionEndpoint, IssueDescriptionVersionEndpoint +from .issue.version import IssueVersionEndpoint, WorkItemDescriptionVersionEndpoint from .module.base import ( ModuleViewSet, @@ -184,7 +184,11 @@ from .estimate.base import ( EstimatePointEndpoint, ) -from .intake.base import IntakeViewSet, IntakeIssueViewSet +from .intake.base import ( + IntakeViewSet, + IntakeIssueViewSet, + IntakeWorkItemDescriptionVersionEndpoint, +) from .analytic.base import ( AnalyticsEndpoint, diff --git a/apiserver/plane/app/views/asset/v2.py b/apiserver/plane/app/views/asset/v2.py index da36b91a0..46e988be2 100644 --- a/apiserver/plane/app/views/asset/v2.py +++ b/apiserver/plane/app/views/asset/v2.py @@ -137,7 +137,7 @@ class UserAssetsV2Endpoint(BaseAPIView): if type not in allowed_types: return Response( { - "error": "Invalid file type. Only JPEG and PNG files are allowed.", + "error": "Invalid file type. Only JPEG, PNG, WebP, JPG and GIF files are allowed.", "status": False, }, status=status.HTTP_400_BAD_REQUEST, @@ -351,7 +351,7 @@ class WorkspaceFileAssetEndpoint(BaseAPIView): if type not in allowed_types: return Response( { - "error": "Invalid file type. Only JPEG and PNG files are allowed.", + "error": "Invalid file type. Only JPEG, PNG, WebP, JPG and GIF files are allowed.", "status": False, }, status=status.HTTP_400_BAD_REQUEST, @@ -552,7 +552,7 @@ class ProjectAssetEndpoint(BaseAPIView): if type not in allowed_types: return Response( { - "error": "Invalid file type. Only JPEG and PNG files are allowed.", + "error": "Invalid file type. Only JPEG, PNG, WebP, JPG and GIF files are allowed.", "status": False, }, status=status.HTTP_400_BAD_REQUEST, @@ -683,7 +683,7 @@ class ProjectBulkAssetEndpoint(BaseAPIView): # For some cases, the bulk api is called after the issue is deleted creating # an integrity error try: - assets.update(issue_id=entity_id) + assets.update(issue_id=entity_id, project_id=project_id) except IntegrityError: pass diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py index 84a161619..e88acaf82 100644 --- a/apiserver/plane/app/views/cycle/base.py +++ b/apiserver/plane/app/views/cycle/base.py @@ -51,8 +51,7 @@ from plane.db.models import ( ) from plane.utils.analytics_plot import burndown_plot from plane.bgtasks.recent_visited_task import recent_visited_task - -# Module imports +from plane.utils.host import base_host from .. import BaseAPIView, BaseViewSet from plane.bgtasks.webhook_task import model_activity from plane.utils.timezone_converter import convert_to_utc, user_timezone_converter @@ -268,7 +267,7 @@ class CycleViewSet(BaseViewSet): ) datetime_fields = ["start_date", "end_date"] data = user_timezone_converter( - data, datetime_fields, request.user.user_timezone + data, datetime_fields, project_timezone ) return Response(data, status=status.HTTP_200_OK) @@ -318,9 +317,13 @@ class CycleViewSet(BaseViewSet): .first() ) + # Fetch the project timezone + project = Project.objects.get(id=self.kwargs.get("project_id")) + project_timezone = project.timezone + datetime_fields = ["start_date", "end_date"] cycle = user_timezone_converter( - cycle, datetime_fields, request.user.user_timezone + cycle, datetime_fields, project_timezone ) # Send the model activity @@ -331,7 +334,7 @@ class CycleViewSet(BaseViewSet): current_instance=None, actor_id=request.user.id, slug=slug, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) return Response(cycle, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -407,9 +410,13 @@ class CycleViewSet(BaseViewSet): "created_by", ).first() + # Fetch the project timezone + project = Project.objects.get(id=self.kwargs.get("project_id")) + project_timezone = project.timezone + datetime_fields = ["start_date", "end_date"] cycle = user_timezone_converter( - cycle, datetime_fields, request.user.user_timezone + cycle, datetime_fields, project_timezone ) # Send the model activity @@ -420,7 +427,7 @@ class CycleViewSet(BaseViewSet): current_instance=current_instance, actor_id=request.user.id, slug=slug, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) return Response(cycle, status=status.HTTP_200_OK) @@ -480,10 +487,11 @@ class CycleViewSet(BaseViewSet): ) queryset = queryset.first() + # Fetch the project timezone + project = Project.objects.get(id=self.kwargs.get("project_id")) + project_timezone = project.timezone datetime_fields = ["start_date", "end_date"] - data = user_timezone_converter( - data, datetime_fields, request.user.user_timezone - ) + data = user_timezone_converter(data, datetime_fields, project_timezone) recent_visited_task.delay( slug=slug, @@ -532,7 +540,7 @@ class CycleViewSet(BaseViewSet): current_instance=None, epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) # TODO: Soft delete the cycle break the onetoone relationship with cycle issue cycle.delete() @@ -1071,7 +1079,7 @@ class TransferCycleIssueEndpoint(BaseAPIView): ), epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) return Response({"message": "Success"}, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/cycle/issue.py b/apiserver/plane/app/views/cycle/issue.py index 6e131d428..9b9e1ad30 100644 --- a/apiserver/plane/app/views/cycle/issue.py +++ b/apiserver/plane/app/views/cycle/issue.py @@ -27,7 +27,7 @@ from plane.utils.issue_filters import issue_filters from plane.utils.order_queryset import order_issue_queryset from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator from plane.app.permissions import allow_permission, ROLE - +from plane.utils.host import base_host class CycleIssueViewSet(BaseViewSet): serializer_class = CycleIssueSerializer @@ -291,7 +291,7 @@ class CycleIssueViewSet(BaseViewSet): ), epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) return Response({"message": "success"}, status=status.HTTP_201_CREATED) @@ -317,7 +317,7 @@ class CycleIssueViewSet(BaseViewSet): current_instance=None, epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) cycle_issue.delete() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/intake/base.py b/apiserver/plane/app/views/intake/base.py index fb10bc002..1ca9e3970 100644 --- a/apiserver/plane/app/views/intake/base.py +++ b/apiserver/plane/app/views/intake/base.py @@ -27,16 +27,24 @@ from plane.db.models import ( Project, ProjectMember, CycleIssue, + IssueDescriptionVersion, ) from plane.app.serializers import ( IssueCreateSerializer, - IssueSerializer, + IssueDetailSerializer, IntakeSerializer, IntakeIssueSerializer, IntakeIssueDetailSerializer, + IssueDescriptionVersionDetailSerializer, ) from plane.utils.issue_filters import issue_filters from plane.bgtasks.issue_activities_task import issue_activity +from plane.bgtasks.issue_description_version_task import issue_description_version_task +from plane.app.views.base import BaseAPIView +from plane.utils.timezone_converter import user_timezone_converter +from plane.utils.global_paginator import paginate +from plane.utils.host import base_host +from plane.db.models.intake import SourceType class IntakeViewSet(BaseViewSet): @@ -87,7 +95,7 @@ class IntakeIssueViewSet(BaseViewSet): serializer_class = IntakeIssueSerializer model = IntakeIssue - filterset_fields = ["statulls"] + filterset_fields = ["status"] def get_queryset(self): return ( @@ -218,7 +226,7 @@ class IntakeIssueViewSet(BaseViewSet): workspace__slug=slug, project_id=project_id, member=request.user, - role=5, + role=ROLE.GUEST.value, is_active=True, ).exists() and not project.guest_view_all_features @@ -271,7 +279,7 @@ class IntakeIssueViewSet(BaseViewSet): intake_id=intake_id.id, project_id=project_id, issue_id=serializer.data["id"], - source=request.data.get("source", "IN-APP"), + source=SourceType.IN_APP, ) # Create an Issue Activity issue_activity.delay( @@ -283,9 +291,16 @@ class IntakeIssueViewSet(BaseViewSet): current_instance=None, epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), intake=str(intake_issue.id), ) + # updated issue description version + issue_description_version_task.delay( + updated_issue=json.dumps(request.data, cls=DjangoJSONEncoder), + issue_id=str(serializer.data["id"]), + user_id=request.user.id, + is_creating=True, + ) intake_issue = ( IntakeIssue.objects.select_related("issue") .prefetch_related("issue__labels", "issue__assignees") @@ -385,13 +400,15 @@ class IntakeIssueViewSet(BaseViewSet): ), "description": issue_data.get("description", issue.description), } + current_instance = json.dumps( + IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder + ) issue_serializer = IssueCreateSerializer( issue, data=issue_data, partial=True, context={"project_id": project_id} ) if issue_serializer.is_valid(): - current_instance = issue # Log all the updates requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder) if issue is not None: @@ -401,15 +418,18 @@ class IntakeIssueViewSet(BaseViewSet): actor_id=str(request.user.id), issue_id=str(issue.id), project_id=str(project_id), - current_instance=json.dumps( - IssueSerializer(current_instance).data, - cls=DjangoJSONEncoder, - ), + current_instance=current_instance, epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), intake=str(intake_issue.id), ) + # updated issue description version + issue_description_version_task.delay( + updated_issue=current_instance, + issue_id=str(pk), + user_id=request.user.id, + ) issue_serializer.save() else: return Response( @@ -467,7 +487,7 @@ class IntakeIssueViewSet(BaseViewSet): current_instance=current_instance, epoch=int(timezone.now().timestamp()), notification=False, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), intake=(intake_issue.id), ) @@ -549,7 +569,7 @@ class IntakeIssueViewSet(BaseViewSet): workspace__slug=slug, project_id=project_id, member=request.user, - role=5, + role=ROLE.GUEST.value, is_active=True, ).exists() and not project.guest_view_all_features @@ -557,7 +577,7 @@ class IntakeIssueViewSet(BaseViewSet): ): return Response( {"error": "You are not allowed to view this issue"}, - status=status.HTTP_400_BAD_REQUEST, + status=status.HTTP_403_FORBIDDEN, ) issue = IntakeIssueDetailSerializer(intake_issue).data return Response(issue, status=status.HTTP_200_OK) @@ -584,3 +604,80 @@ class IntakeIssueViewSet(BaseViewSet): intake_issue.delete() return Response(status=status.HTTP_204_NO_CONTENT) + + +class IntakeWorkItemDescriptionVersionEndpoint(BaseAPIView): + def process_paginated_result(self, fields, results, timezone): + paginated_data = results.values(*fields) + + datetime_fields = ["created_at", "updated_at"] + paginated_data = user_timezone_converter( + paginated_data, datetime_fields, timezone + ) + + return paginated_data + + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def get(self, request, slug, project_id, work_item_id, pk=None): + project = Project.objects.get(pk=project_id) + issue = Issue.objects.get( + workspace__slug=slug, project_id=project_id, pk=work_item_id + ) + + if ( + ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + member=request.user, + role=ROLE.GUEST.value, + is_active=True, + ).exists() + and not project.guest_view_all_features + and not issue.created_by == request.user + ): + return Response( + {"error": "You are not allowed to view this issue"}, + status=status.HTTP_403_FORBIDDEN, + ) + + if pk: + issue_description_version = IssueDescriptionVersion.objects.get( + workspace__slug=slug, + project_id=project_id, + issue_id=work_item_id, + pk=pk, + ) + + serializer = IssueDescriptionVersionDetailSerializer( + issue_description_version + ) + return Response(serializer.data, status=status.HTTP_200_OK) + + cursor = request.GET.get("cursor", None) + + required_fields = [ + "id", + "workspace", + "project", + "issue", + "last_saved_at", + "owned_by", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + + issue_description_versions_queryset = IssueDescriptionVersion.objects.filter( + workspace__slug=slug, project_id=project_id, issue_id=work_item_id + ) + + paginated_data = paginate( + base_queryset=issue_description_versions_queryset, + queryset=issue_description_versions_queryset, + cursor=cursor, + on_result=lambda results: self.process_paginated_result( + required_fields, results, request.user.user_timezone + ), + ) + return Response(paginated_data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/issue/archive.py b/apiserver/plane/app/views/issue/archive.py index 4f1e357da..48b317c84 100644 --- a/apiserver/plane/app/views/issue/archive.py +++ b/apiserver/plane/app/views/issue/archive.py @@ -37,7 +37,7 @@ from plane.utils.order_queryset import order_issue_queryset from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator from plane.app.permissions import allow_permission, ROLE from plane.utils.error_codes import ERROR_CODES - +from plane.utils.host import base_host # Module imports from .. import BaseViewSet, BaseAPIView @@ -259,7 +259,7 @@ class IssueArchiveViewSet(BaseViewSet): ), epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) issue.archived_at = timezone.now().date() issue.save() @@ -287,7 +287,7 @@ class IssueArchiveViewSet(BaseViewSet): ), epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) issue.archived_at = None issue.save() @@ -333,7 +333,7 @@ class BulkArchiveIssuesEndpoint(BaseAPIView): ), epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) issue.archived_at = timezone.now().date() bulk_archive_issues.append(issue) diff --git a/apiserver/plane/app/views/issue/attachment.py b/apiserver/plane/app/views/issue/attachment.py index d519a5269..0ff85572f 100644 --- a/apiserver/plane/app/views/issue/attachment.py +++ b/apiserver/plane/app/views/issue/attachment.py @@ -21,7 +21,7 @@ from plane.bgtasks.issue_activities_task import issue_activity from plane.app.permissions import allow_permission, ROLE from plane.settings.storage import S3Storage from plane.bgtasks.storage_metadata_task import get_asset_object_metadata - +from plane.utils.host import base_host class IssueAttachmentEndpoint(BaseAPIView): serializer_class = IssueAttachmentSerializer @@ -48,7 +48,7 @@ class IssueAttachmentEndpoint(BaseAPIView): current_instance=json.dumps(serializer.data, cls=DjangoJSONEncoder), epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -67,7 +67,7 @@ class IssueAttachmentEndpoint(BaseAPIView): current_instance=None, epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) return Response(status=status.HTTP_204_NO_CONTENT) @@ -155,7 +155,7 @@ class IssueAttachmentV2Endpoint(BaseAPIView): current_instance=None, epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) return Response(status=status.HTTP_204_NO_CONTENT) @@ -213,7 +213,7 @@ class IssueAttachmentV2Endpoint(BaseAPIView): current_instance=json.dumps(serializer.data, cls=DjangoJSONEncoder), epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) # Update the attachment diff --git a/apiserver/plane/app/views/issue/base.py b/apiserver/plane/app/views/issue/base.py index 79ffe35d8..2a7e9d021 100644 --- a/apiserver/plane/app/views/issue/base.py +++ b/apiserver/plane/app/views/issue/base.py @@ -45,6 +45,7 @@ from plane.db.models import ( ProjectMember, CycleIssue, UserRecentVisit, + ModuleIssue, ) from plane.utils.grouper import ( issue_group_values, @@ -60,6 +61,7 @@ from plane.bgtasks.recent_visited_task import recent_visited_task from plane.utils.global_paginator import paginate from plane.bgtasks.webhook_task import model_activity from plane.bgtasks.issue_description_version_task import issue_description_version_task +from plane.utils.host import base_host class IssueListEndpoint(BaseAPIView): @@ -378,7 +380,7 @@ class IssueViewSet(BaseViewSet): current_instance=None, epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) issue = ( issue_queryset_grouper( @@ -428,7 +430,7 @@ class IssueViewSet(BaseViewSet): current_instance=None, actor_id=request.user.id, slug=slug, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) # updated issue description version issue_description_version_task.delay( @@ -564,7 +566,7 @@ class IssueViewSet(BaseViewSet): ): return Response( {"error": "You are not allowed to view this issue"}, - status=status.HTTP_400_BAD_REQUEST, + status=status.HTTP_403_FORBIDDEN, ) recent_visited_task.delay( @@ -631,7 +633,7 @@ class IssueViewSet(BaseViewSet): ) current_instance = json.dumps( - IssueSerializer(issue).data, cls=DjangoJSONEncoder + IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder ) requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) @@ -649,7 +651,7 @@ class IssueViewSet(BaseViewSet): current_instance=current_instance, epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) model_activity.delay( model_name="issue", @@ -658,7 +660,7 @@ class IssueViewSet(BaseViewSet): current_instance=current_instance, actor_id=request.user.id, slug=slug, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) # updated issue description version issue_description_version_task.delay( @@ -690,7 +692,8 @@ class IssueViewSet(BaseViewSet): current_instance={}, epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), + subscriber=False, ) return Response(status=status.HTTP_204_NO_CONTENT) @@ -738,6 +741,13 @@ class BulkDeleteIssuesEndpoint(BaseAPIView): total_issues = len(issues) + # First, delete all related cycle issues + CycleIssue.objects.filter(issue_id__in=issue_ids).delete() + + # Then, delete all related module issues + ModuleIssue.objects.filter(issue_id__in=issue_ids).delete() + + # Finally, delete the issues themselves issues.delete() return Response( @@ -1025,9 +1035,17 @@ class IssueBulkUpdateDateEndpoint(BaseAPIView): """ Validate that start date is before target date. """ + from datetime import datetime + start = new_start or current_start target = new_target or current_target + # Convert string dates to datetime objects if they're strings + if isinstance(start, str): + start = datetime.strptime(start, "%Y-%m-%d").date() + if isinstance(target, str): + target = datetime.strptime(target, "%Y-%m-%d").date() + if start and target and start > target: return False return True @@ -1269,7 +1287,7 @@ class IssueDetailIdentifierEndpoint(BaseAPIView): ): return Response( {"error": "You are not allowed to view this issue"}, - status=status.HTTP_400_BAD_REQUEST, + status=status.HTTP_403_FORBIDDEN, ) recent_visited_task.delay( diff --git a/apiserver/plane/app/views/issue/comment.py b/apiserver/plane/app/views/issue/comment.py index 91d27bff2..2d81201c9 100644 --- a/apiserver/plane/app/views/issue/comment.py +++ b/apiserver/plane/app/views/issue/comment.py @@ -17,7 +17,7 @@ from plane.app.serializers import IssueCommentSerializer, CommentReactionSeriali from plane.app.permissions import allow_permission, ROLE from plane.db.models import IssueComment, ProjectMember, CommentReaction, Project, Issue from plane.bgtasks.issue_activities_task import issue_activity - +from plane.utils.host import base_host class IssueCommentViewSet(BaseViewSet): serializer_class = IssueCommentSerializer @@ -87,7 +87,7 @@ class IssueCommentViewSet(BaseViewSet): current_instance=None, epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -105,7 +105,13 @@ class IssueCommentViewSet(BaseViewSet): issue_comment, data=request.data, partial=True ) if serializer.is_valid(): - serializer.save() + if ( + "comment_html" in request.data + and request.data["comment_html"] != issue_comment.comment_html + ): + serializer.save(edited_at=timezone.now()) + else: + serializer.save() issue_activity.delay( type="comment.activity.updated", requested_data=requested_data, @@ -115,7 +121,7 @@ class IssueCommentViewSet(BaseViewSet): current_instance=current_instance, epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -138,7 +144,7 @@ class IssueCommentViewSet(BaseViewSet): current_instance=current_instance, epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) return Response(status=status.HTTP_204_NO_CONTENT) @@ -182,7 +188,7 @@ class CommentReactionViewSet(BaseViewSet): current_instance=None, epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -216,7 +222,7 @@ class CommentReactionViewSet(BaseViewSet): ), epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) comment_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/issue/link.py b/apiserver/plane/app/views/issue/link.py index 85faa8368..45cab8479 100644 --- a/apiserver/plane/app/views/issue/link.py +++ b/apiserver/plane/app/views/issue/link.py @@ -15,7 +15,7 @@ from plane.app.serializers import IssueLinkSerializer from plane.app.permissions import ProjectEntityPermission from plane.db.models import IssueLink from plane.bgtasks.issue_activities_task import issue_activity - +from plane.utils.host import base_host class IssueLinkViewSet(BaseViewSet): permission_classes = [ProjectEntityPermission] @@ -52,7 +52,7 @@ class IssueLinkViewSet(BaseViewSet): current_instance=None, epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -77,7 +77,7 @@ class IssueLinkViewSet(BaseViewSet): current_instance=current_instance, epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -98,7 +98,7 @@ class IssueLinkViewSet(BaseViewSet): current_instance=current_instance, epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) issue_link.delete() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/issue/reaction.py b/apiserver/plane/app/views/issue/reaction.py index 7fe53b456..b92970382 100644 --- a/apiserver/plane/app/views/issue/reaction.py +++ b/apiserver/plane/app/views/issue/reaction.py @@ -15,7 +15,7 @@ from plane.app.serializers import IssueReactionSerializer from plane.app.permissions import allow_permission, ROLE from plane.db.models import IssueReaction from plane.bgtasks.issue_activities_task import issue_activity - +from plane.utils.host import base_host class IssueReactionViewSet(BaseViewSet): serializer_class = IssueReactionSerializer @@ -53,7 +53,7 @@ class IssueReactionViewSet(BaseViewSet): current_instance=None, epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -78,7 +78,7 @@ class IssueReactionViewSet(BaseViewSet): ), epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) issue_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/issue/relation.py b/apiserver/plane/app/views/issue/relation.py index 4b50e4b03..0a8ffa2f9 100644 --- a/apiserver/plane/app/views/issue/relation.py +++ b/apiserver/plane/app/views/issue/relation.py @@ -27,7 +27,7 @@ from plane.db.models import ( ) from plane.bgtasks.issue_activities_task import issue_activity from plane.utils.issue_relation_mapper import get_actual_relation - +from plane.utils.host import base_host class IssueRelationViewSet(BaseViewSet): serializer_class = IssueRelationSerializer @@ -253,7 +253,7 @@ class IssueRelationViewSet(BaseViewSet): current_instance=None, epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) if relation_type in ["blocking", "start_after", "finish_after"]: @@ -290,6 +290,6 @@ class IssueRelationViewSet(BaseViewSet): current_instance=current_instance, epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/issue/sub_issue.py b/apiserver/plane/app/views/issue/sub_issue.py index 19e2522d2..e9199ed04 100644 --- a/apiserver/plane/app/views/issue/sub_issue.py +++ b/apiserver/plane/app/views/issue/sub_issue.py @@ -22,7 +22,7 @@ from plane.db.models import Issue, IssueLink, FileAsset, CycleIssue from plane.bgtasks.issue_activities_task import issue_activity from plane.utils.timezone_converter import user_timezone_converter from collections import defaultdict - +from plane.utils.host import base_host class SubIssuesEndpoint(BaseAPIView): permission_classes = [ProjectEntityPermission] @@ -176,7 +176,7 @@ class SubIssuesEndpoint(BaseAPIView): current_instance=json.dumps({"parent": str(sub_issue_id)}), epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) for sub_issue_id in sub_issue_ids ] diff --git a/apiserver/plane/app/views/issue/version.py b/apiserver/plane/app/views/issue/version.py index ab26ca5a6..9f8d5c29d 100644 --- a/apiserver/plane/app/views/issue/version.py +++ b/apiserver/plane/app/views/issue/version.py @@ -3,7 +3,13 @@ from rest_framework import status from rest_framework.response import Response # Module imports -from plane.db.models import IssueVersion, IssueDescriptionVersion +from plane.db.models import ( + IssueVersion, + IssueDescriptionVersion, + Project, + ProjectMember, + Issue, +) from ..base import BaseAPIView from plane.app.serializers import ( IssueVersionDetailSerializer, @@ -66,7 +72,7 @@ class IssueVersionEndpoint(BaseAPIView): return Response(paginated_data, status=status.HTTP_200_OK) -class IssueDescriptionVersionEndpoint(BaseAPIView): +class WorkItemDescriptionVersionEndpoint(BaseAPIView): def process_paginated_result(self, fields, results, timezone): paginated_data = results.values(*fields) @@ -78,10 +84,34 @@ class IssueDescriptionVersionEndpoint(BaseAPIView): return paginated_data @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) - def get(self, request, slug, project_id, issue_id, pk=None): + def get(self, request, slug, project_id, work_item_id, pk=None): + project = Project.objects.get(pk=project_id) + issue = Issue.objects.get( + workspace__slug=slug, project_id=project_id, pk=work_item_id + ) + + if ( + ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + member=request.user, + role=ROLE.GUEST.value, + is_active=True, + ).exists() + and not project.guest_view_all_features + and not issue.created_by == request.user + ): + return Response( + {"error": "You are not allowed to view this issue"}, + status=status.HTTP_403_FORBIDDEN, + ) + if pk: issue_description_version = IssueDescriptionVersion.objects.get( - workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk + workspace__slug=slug, + project_id=project_id, + issue_id=work_item_id, + pk=pk, ) serializer = IssueDescriptionVersionDetailSerializer( @@ -105,8 +135,8 @@ class IssueDescriptionVersionEndpoint(BaseAPIView): ] issue_description_versions_queryset = IssueDescriptionVersion.objects.filter( - workspace__slug=slug, project_id=project_id, issue_id=issue_id - ) + workspace__slug=slug, project_id=project_id, issue_id=work_item_id + ).order_by("-created_at") paginated_data = paginate( base_queryset=issue_description_versions_queryset, queryset=issue_description_versions_queryset, diff --git a/apiserver/plane/app/views/module/base.py b/apiserver/plane/app/views/module/base.py index d9118de0a..62840f555 100644 --- a/apiserver/plane/app/views/module/base.py +++ b/apiserver/plane/app/views/module/base.py @@ -61,7 +61,7 @@ from plane.utils.timezone_converter import user_timezone_converter from plane.bgtasks.webhook_task import model_activity from .. import BaseAPIView, BaseViewSet from plane.bgtasks.recent_visited_task import recent_visited_task - +from plane.utils.host import base_host class ModuleViewSet(BaseViewSet): model = Module @@ -376,7 +376,7 @@ class ModuleViewSet(BaseViewSet): current_instance=None, actor_id=request.user.id, slug=slug, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) datetime_fields = ["created_at", "updated_at"] module = user_timezone_converter( @@ -768,7 +768,7 @@ class ModuleViewSet(BaseViewSet): current_instance=current_instance, actor_id=request.user.id, slug=slug, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) datetime_fields = ["created_at", "updated_at"] @@ -795,7 +795,7 @@ class ModuleViewSet(BaseViewSet): current_instance=json.dumps({"module_name": str(module.name)}), epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) for issue in module_issues ] diff --git a/apiserver/plane/app/views/module/issue.py b/apiserver/plane/app/views/module/issue.py index 832e9a5cd..089d73ef9 100644 --- a/apiserver/plane/app/views/module/issue.py +++ b/apiserver/plane/app/views/module/issue.py @@ -34,7 +34,7 @@ from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPagina # Module imports from .. import BaseViewSet - +from plane.utils.host import base_host class ModuleIssueViewSet(BaseViewSet): serializer_class = ModuleIssueSerializer @@ -221,7 +221,7 @@ class ModuleIssueViewSet(BaseViewSet): current_instance=None, epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) for issue in issues ] @@ -261,7 +261,7 @@ class ModuleIssueViewSet(BaseViewSet): current_instance=None, epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) for module in modules ] @@ -284,7 +284,7 @@ class ModuleIssueViewSet(BaseViewSet): ), epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) module_issue.delete() @@ -309,7 +309,7 @@ class ModuleIssueViewSet(BaseViewSet): ), epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) module_issue.delete() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py index 00e1ad1eb..46290d7a5 100644 --- a/apiserver/plane/app/views/project/base.py +++ b/apiserver/plane/app/views/project/base.py @@ -39,6 +39,7 @@ from plane.utils.cache import cache_response from plane.bgtasks.webhook_task import model_activity, webhook_activity from plane.bgtasks.recent_visited_task import recent_visited_task from plane.utils.exception_logger import log_exception +from plane.utils.host import base_host class ProjectViewSet(BaseViewSet): @@ -179,6 +180,7 @@ class ProjectViewSet(BaseViewSet): "inbox_view", "guest_view_all_features", "project_lead", + "network", "created_at", "updated_at", "created_by", @@ -330,7 +332,7 @@ class ProjectViewSet(BaseViewSet): current_instance=None, actor_id=request.user.id, slug=slug, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) serializer = ProjectListSerializer(project) @@ -340,7 +342,7 @@ class ProjectViewSet(BaseViewSet): if "already exists" in str(e): return Response( {"name": "The project name is already taken"}, - status=status.HTTP_410_GONE, + status=status.HTTP_409_CONFLICT, ) except Workspace.DoesNotExist: return Response( @@ -349,7 +351,7 @@ class ProjectViewSet(BaseViewSet): except serializers.ValidationError: return Response( {"identifier": "The project identifier is already taken"}, - status=status.HTTP_410_GONE, + status=status.HTTP_409_CONFLICT, ) def partial_update(self, request, slug, pk=None): @@ -408,7 +410,7 @@ class ProjectViewSet(BaseViewSet): current_instance=current_instance, actor_id=request.user.id, slug=slug, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) serializer = ProjectListSerializer(project) return Response(serializer.data, status=status.HTTP_200_OK) @@ -418,7 +420,7 @@ class ProjectViewSet(BaseViewSet): if "already exists" in str(e): return Response( {"name": "The project name is already taken"}, - status=status.HTTP_410_GONE, + status=status.HTTP_409_CONFLICT, ) except (Project.DoesNotExist, Workspace.DoesNotExist): return Response( @@ -427,7 +429,7 @@ class ProjectViewSet(BaseViewSet): except serializers.ValidationError: return Response( {"identifier": "The project identifier is already taken"}, - status=status.HTTP_410_GONE, + status=status.HTTP_409_CONFLICT, ) def destroy(self, request, slug, pk): @@ -453,7 +455,7 @@ class ProjectViewSet(BaseViewSet): new_value=None, actor_id=request.user.id, slug=slug, - current_site=request.META.get("HTTP_ORIGIN"), + current_site=base_host(request=request, is_app=True), event_id=project.id, old_identifier=None, new_identifier=None, diff --git a/apiserver/plane/app/views/project/invite.py b/apiserver/plane/app/views/project/invite.py index e4d46e89f..72c0bae06 100644 --- a/apiserver/plane/app/views/project/invite.py +++ b/apiserver/plane/app/views/project/invite.py @@ -16,18 +16,18 @@ from rest_framework.permissions import AllowAny # Module imports from .base import BaseViewSet, BaseAPIView from plane.app.serializers import ProjectMemberInviteSerializer - from plane.app.permissions import allow_permission, ROLE - from plane.db.models import ( ProjectMember, Workspace, ProjectMemberInvite, User, WorkspaceMember, + Project, IssueUserProperty, ) - +from plane.db.models.project import ProjectNetwork +from plane.utils.host import base_host class ProjectInvitationsViewset(BaseViewSet): serializer_class = ProjectMemberInviteSerializer @@ -99,7 +99,7 @@ class ProjectInvitationsViewset(BaseViewSet): project_invitations = ProjectMemberInvite.objects.bulk_create( project_invitations, batch_size=10, ignore_conflicts=True ) - current_site = request.META.get("HTTP_ORIGIN") + current_site = base_host(request=request, is_app=True) # Send invitations for invitation in project_invitations: @@ -128,6 +128,7 @@ class UserProjectInvitationsViewset(BaseViewSet): .select_related("workspace", "workspace__owner", "project") ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def create(self, request, slug): project_ids = request.data.get("project_ids", []) @@ -136,11 +137,20 @@ class UserProjectInvitationsViewset(BaseViewSet): member=request.user, workspace__slug=slug, is_active=True ) - if workspace_member.role not in [ROLE.ADMIN.value, ROLE.MEMBER.value]: - return Response( - {"error": "You do not have permission to join the project"}, - status=status.HTTP_403_FORBIDDEN, - ) + # Get all the projects + projects = Project.objects.filter( + id__in=project_ids, workspace__slug=slug + ).only("id", "network") + # Check if user has permission to join each project + for project in projects: + if ( + project.network == ProjectNetwork.SECRET.value + and workspace_member.role != ROLE.ADMIN.value + ): + return Response( + {"error": "Only workspace admins can join private project"}, + status=status.HTTP_403_FORBIDDEN, + ) workspace_role = workspace_member.role workspace = workspace_member.workspace diff --git a/apiserver/plane/app/views/project/member.py b/apiserver/plane/app/views/project/member.py index 55d2d4a58..7b910509c 100644 --- a/apiserver/plane/app/views/project/member.py +++ b/apiserver/plane/app/views/project/member.py @@ -10,11 +10,7 @@ from plane.app.serializers import ( ProjectMemberRoleSerializer, ) -from plane.app.permissions import ( - ProjectMemberPermission, - ProjectLitePermission, - WorkspaceUserPermission, -) +from plane.app.permissions import WorkspaceUserPermission from plane.db.models import Project, ProjectMember, IssueUserProperty, WorkspaceMember from plane.bgtasks.project_add_user_email_task import project_add_user_email @@ -26,14 +22,6 @@ class ProjectMemberViewSet(BaseViewSet): serializer_class = ProjectMemberAdminSerializer model = ProjectMember - def get_permissions(self): - if self.action == "leave": - self.permission_classes = [ProjectLitePermission] - else: - self.permission_classes = [ProjectMemberPermission] - - return super(ProjectMemberViewSet, self).get_permissions() - search_fields = ["member__display_name", "member__first_name"] def get_queryset(self): @@ -187,12 +175,20 @@ class ProjectMemberViewSet(BaseViewSet): ) return Response(serializer.data, status=status.HTTP_200_OK) - @allow_permission([ROLE.ADMIN]) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def partial_update(self, request, slug, project_id, pk): project_member = ProjectMember.objects.get( pk=pk, workspace__slug=slug, project_id=project_id, is_active=True ) - if request.user.id == project_member.member_id: + + # Fetch the workspace role of the project member + workspace_role = WorkspaceMember.objects.get( + workspace__slug=slug, member=project_member.member, is_active=True + ).role + is_workspace_admin = workspace_role == ROLE.ADMIN.value + + # Check if the user is not editing their own role if they are not an admin + if request.user.id == project_member.member_id and not is_workspace_admin: return Response( {"error": "You cannot update your own role"}, status=status.HTTP_400_BAD_REQUEST, @@ -205,9 +201,6 @@ class ProjectMemberViewSet(BaseViewSet): is_active=True, ) - workspace_role = WorkspaceMember.objects.get( - workspace__slug=slug, member=project_member.member, is_active=True - ).role if workspace_role in [5] and int( request.data.get("role", project_member.role) ) in [15, 20]: @@ -222,6 +215,7 @@ class ProjectMemberViewSet(BaseViewSet): "role" in request.data and int(request.data.get("role", project_member.role)) > requested_project_member.role + and not is_workspace_admin ): return Response( {"error": "You cannot update a role that is higher than your own role"}, diff --git a/apiserver/plane/app/views/timezone/base.py b/apiserver/plane/app/views/timezone/base.py index 77c877047..840fdbdbc 100644 --- a/apiserver/plane/app/views/timezone/base.py +++ b/apiserver/plane/app/views/timezone/base.py @@ -21,221 +21,161 @@ class TimezoneEndpoint(APIView): throttle_classes = [AuthenticationThrottle] - @method_decorator(cache_page(60 * 60 * 24)) + @method_decorator(cache_page(60 * 60 * 2)) def get(self, request): - timezone_mapping = { - "-1100": [ - ("Midway Island", "Pacific/Midway"), - ("American Samoa", "Pacific/Pago_Pago"), - ], - "-1000": [ - ("Hawaii", "Pacific/Honolulu"), - ("Aleutian Islands", "America/Adak"), - ], - "-0930": [("Marquesas Islands", "Pacific/Marquesas")], - "-0900": [ - ("Alaska", "America/Anchorage"), - ("Gambier Islands", "Pacific/Gambier"), - ], - "-0800": [ - ("Pacific Time (US and Canada)", "America/Los_Angeles"), - ("Baja California", "America/Tijuana"), - ], - "-0700": [ - ("Mountain Time (US and Canada)", "America/Denver"), - ("Arizona", "America/Phoenix"), - ("Chihuahua, Mazatlan", "America/Chihuahua"), - ], - "-0600": [ - ("Central Time (US and Canada)", "America/Chicago"), - ("Saskatchewan", "America/Regina"), - ("Guadalajara, Mexico City, Monterrey", "America/Mexico_City"), - ("Tegucigalpa, Honduras", "America/Tegucigalpa"), - ("Costa Rica", "America/Costa_Rica"), - ], - "-0500": [ - ("Eastern Time (US and Canada)", "America/New_York"), - ("Lima", "America/Lima"), - ("Bogota", "America/Bogota"), - ("Quito", "America/Guayaquil"), - ("Chetumal", "America/Cancun"), - ], - "-0430": [("Caracas (Old Venezuela Time)", "America/Caracas")], - "-0400": [ - ("Atlantic Time (Canada)", "America/Halifax"), - ("Caracas", "America/Caracas"), - ("Santiago", "America/Santiago"), - ("La Paz", "America/La_Paz"), - ("Manaus", "America/Manaus"), - ("Georgetown", "America/Guyana"), - ("Bermuda", "Atlantic/Bermuda"), - ], - "-0330": [("Newfoundland Time (Canada)", "America/St_Johns")], - "-0300": [ - ("Buenos Aires", "America/Argentina/Buenos_Aires"), - ("Brasilia", "America/Sao_Paulo"), - ("Greenland", "America/Godthab"), - ("Montevideo", "America/Montevideo"), - ("Falkland Islands", "Atlantic/Stanley"), - ], - "-0200": [ - ( - "South Georgia and the South Sandwich Islands", - "Atlantic/South_Georgia", - ) - ], - "-0100": [ - ("Azores", "Atlantic/Azores"), - ("Cape Verde Islands", "Atlantic/Cape_Verde"), - ], - "+0000": [ - ("Dublin", "Europe/Dublin"), - ("Reykjavik", "Atlantic/Reykjavik"), - ("Lisbon", "Europe/Lisbon"), - ("Monrovia", "Africa/Monrovia"), - ("Casablanca", "Africa/Casablanca"), - ], - "+0100": [ - ("Central European Time (Berlin, Rome, Paris)", "Europe/Paris"), - ("West Central Africa", "Africa/Lagos"), - ("Algiers", "Africa/Algiers"), - ("Lagos", "Africa/Lagos"), - ("Tunis", "Africa/Tunis"), - ], - "+0200": [ - ("Eastern European Time (Cairo, Helsinki, Kyiv)", "Europe/Kiev"), - ("Athens", "Europe/Athens"), - ("Jerusalem", "Asia/Jerusalem"), - ("Johannesburg", "Africa/Johannesburg"), - ("Harare, Pretoria", "Africa/Harare"), - ], - "+0300": [ - ("Moscow Time", "Europe/Moscow"), - ("Baghdad", "Asia/Baghdad"), - ("Nairobi", "Africa/Nairobi"), - ("Kuwait, Riyadh", "Asia/Riyadh"), - ], - "+0330": [("Tehran", "Asia/Tehran")], - "+0400": [ - ("Abu Dhabi", "Asia/Dubai"), - ("Baku", "Asia/Baku"), - ("Yerevan", "Asia/Yerevan"), - ("Astrakhan", "Europe/Astrakhan"), - ("Tbilisi", "Asia/Tbilisi"), - ("Mauritius", "Indian/Mauritius"), - ], - "+0500": [ - ("Islamabad", "Asia/Karachi"), - ("Karachi", "Asia/Karachi"), - ("Tashkent", "Asia/Tashkent"), - ("Yekaterinburg", "Asia/Yekaterinburg"), - ("Maldives", "Indian/Maldives"), - ("Chagos", "Indian/Chagos"), - ], - "+0530": [ - ("Chennai", "Asia/Kolkata"), - ("Kolkata", "Asia/Kolkata"), - ("Mumbai", "Asia/Kolkata"), - ("New Delhi", "Asia/Kolkata"), - ("Sri Jayawardenepura", "Asia/Colombo"), - ], - "+0545": [("Kathmandu", "Asia/Kathmandu")], - "+0600": [ - ("Dhaka", "Asia/Dhaka"), - ("Almaty", "Asia/Almaty"), - ("Bishkek", "Asia/Bishkek"), - ("Thimphu", "Asia/Thimphu"), - ], - "+0630": [ - ("Yangon (Rangoon)", "Asia/Yangon"), - ("Cocos Islands", "Indian/Cocos"), - ], - "+0700": [ - ("Bangkok", "Asia/Bangkok"), - ("Hanoi", "Asia/Ho_Chi_Minh"), - ("Jakarta", "Asia/Jakarta"), - ("Novosibirsk", "Asia/Novosibirsk"), - ("Krasnoyarsk", "Asia/Krasnoyarsk"), - ], - "+0800": [ - ("Beijing", "Asia/Shanghai"), - ("Singapore", "Asia/Singapore"), - ("Perth", "Australia/Perth"), - ("Hong Kong", "Asia/Hong_Kong"), - ("Ulaanbaatar", "Asia/Ulaanbaatar"), - ("Palau", "Pacific/Palau"), - ], - "+0845": [("Eucla", "Australia/Eucla")], - "+0900": [ - ("Tokyo", "Asia/Tokyo"), - ("Seoul", "Asia/Seoul"), - ("Yakutsk", "Asia/Yakutsk"), - ], - "+0930": [ - ("Adelaide", "Australia/Adelaide"), - ("Darwin", "Australia/Darwin"), - ], - "+1000": [ - ("Sydney", "Australia/Sydney"), - ("Brisbane", "Australia/Brisbane"), - ("Guam", "Pacific/Guam"), - ("Vladivostok", "Asia/Vladivostok"), - ("Tahiti", "Pacific/Tahiti"), - ], - "+1030": [("Lord Howe Island", "Australia/Lord_Howe")], - "+1100": [ - ("Solomon Islands", "Pacific/Guadalcanal"), - ("Magadan", "Asia/Magadan"), - ("Norfolk Island", "Pacific/Norfolk"), - ("Bougainville Island", "Pacific/Bougainville"), - ("Chokurdakh", "Asia/Srednekolymsk"), - ], - "+1200": [ - ("Auckland", "Pacific/Auckland"), - ("Wellington", "Pacific/Auckland"), - ("Fiji Islands", "Pacific/Fiji"), - ("Anadyr", "Asia/Anadyr"), - ], - "+1245": [("Chatham Islands", "Pacific/Chatham")], - "+1300": [("Nuku'alofa", "Pacific/Tongatapu"), ("Samoa", "Pacific/Apia")], - "+1400": [("Kiritimati Island", "Pacific/Kiritimati")], - } + timezone_locations = [ + ('Midway Island', 'Pacific/Midway'), # UTC-11:00 + ('American Samoa', 'Pacific/Pago_Pago'), # UTC-11:00 + ('Hawaii', 'Pacific/Honolulu'), # UTC-10:00 + ('Aleutian Islands', 'America/Adak'), # UTC-10:00 (DST: UTC-09:00) + ('Marquesas Islands', 'Pacific/Marquesas'), # UTC-09:30 + ('Alaska', 'America/Anchorage'), # UTC-09:00 (DST: UTC-08:00) + ('Gambier Islands', 'Pacific/Gambier'), # UTC-09:00 + ('Pacific Time (US and Canada)', 'America/Los_Angeles'), # UTC-08:00 (DST: UTC-07:00) + ('Baja California', 'America/Tijuana'), # UTC-08:00 (DST: UTC-07:00) + ('Mountain Time (US and Canada)', 'America/Denver'), # UTC-07:00 (DST: UTC-06:00) + ('Arizona', 'America/Phoenix'), # UTC-07:00 + ('Chihuahua, Mazatlan', 'America/Chihuahua'), # UTC-07:00 (DST: UTC-06:00) + ('Central Time (US and Canada)', 'America/Chicago'), # UTC-06:00 (DST: UTC-05:00) + ('Saskatchewan', 'America/Regina'), # UTC-06:00 + ('Guadalajara, Mexico City, Monterrey', 'America/Mexico_City'), # UTC-06:00 (DST: UTC-05:00) + ('Tegucigalpa, Honduras', 'America/Tegucigalpa'), # UTC-06:00 + ('Costa Rica', 'America/Costa_Rica'), # UTC-06:00 + ('Eastern Time (US and Canada)', 'America/New_York'), # UTC-05:00 (DST: UTC-04:00) + ('Lima', 'America/Lima'), # UTC-05:00 + ('Bogota', 'America/Bogota'), # UTC-05:00 + ('Quito', 'America/Guayaquil'), # UTC-05:00 + ('Chetumal', 'America/Cancun'), # UTC-05:00 (DST: UTC-04:00) + ('Caracas (Old Venezuela Time)', 'America/Caracas'), # UTC-04:30 + ('Atlantic Time (Canada)', 'America/Halifax'), # UTC-04:00 (DST: UTC-03:00) + ('Caracas', 'America/Caracas'), # UTC-04:00 + ('Santiago', 'America/Santiago'), # UTC-04:00 (DST: UTC-03:00) + ('La Paz', 'America/La_Paz'), # UTC-04:00 + ('Manaus', 'America/Manaus'), # UTC-04:00 + ('Georgetown', 'America/Guyana'), # UTC-04:00 + ('Bermuda', 'Atlantic/Bermuda'), # UTC-04:00 (DST: UTC-03:00) + ('Newfoundland Time (Canada)', 'America/St_Johns'), # UTC-03:30 (DST: UTC-02:30) + ('Buenos Aires', 'America/Argentina/Buenos_Aires'), # UTC-03:00 + ('Brasilia', 'America/Sao_Paulo'), # UTC-03:00 + ('Greenland', 'America/Godthab'), # UTC-03:00 (DST: UTC-02:00) + ('Montevideo', 'America/Montevideo'), # UTC-03:00 + ('Falkland Islands', 'Atlantic/Stanley'), # UTC-03:00 + ('South Georgia and the South Sandwich Islands', 'Atlantic/South_Georgia'), # UTC-02:00 + ('Azores', 'Atlantic/Azores'), # UTC-01:00 (DST: UTC+00:00) + ('Cape Verde Islands', 'Atlantic/Cape_Verde'), # UTC-01:00 + ('Dublin', 'Europe/Dublin'), # UTC+00:00 (DST: UTC+01:00) + ('Reykjavik', 'Atlantic/Reykjavik'), # UTC+00:00 + ('Lisbon', 'Europe/Lisbon'), # UTC+00:00 (DST: UTC+01:00) + ('Monrovia', 'Africa/Monrovia'), # UTC+00:00 + ('Casablanca', 'Africa/Casablanca'), # UTC+00:00 (DST: UTC+01:00) + ('Central European Time (Berlin, Rome, Paris)', 'Europe/Paris'), # UTC+01:00 (DST: UTC+02:00) + ('West Central Africa', 'Africa/Lagos'), # UTC+01:00 + ('Algiers', 'Africa/Algiers'), # UTC+01:00 + ('Lagos', 'Africa/Lagos'), # UTC+01:00 + ('Tunis', 'Africa/Tunis'), # UTC+01:00 + ('Eastern European Time (Cairo, Helsinki, Kyiv)', 'Europe/Kiev'), # UTC+02:00 (DST: UTC+03:00) + ('Athens', 'Europe/Athens'), # UTC+02:00 (DST: UTC+03:00) + ('Jerusalem', 'Asia/Jerusalem'), # UTC+02:00 (DST: UTC+03:00) + ('Johannesburg', 'Africa/Johannesburg'), # UTC+02:00 + ('Harare, Pretoria', 'Africa/Harare'), # UTC+02:00 + ('Moscow Time', 'Europe/Moscow'), # UTC+03:00 + ('Baghdad', 'Asia/Baghdad'), # UTC+03:00 + ('Nairobi', 'Africa/Nairobi'), # UTC+03:00 + ('Kuwait, Riyadh', 'Asia/Riyadh'), # UTC+03:00 + ('Tehran', 'Asia/Tehran'), # UTC+03:30 (DST: UTC+04:30) + ('Abu Dhabi', 'Asia/Dubai'), # UTC+04:00 + ('Baku', 'Asia/Baku'), # UTC+04:00 (DST: UTC+05:00) + ('Yerevan', 'Asia/Yerevan'), # UTC+04:00 (DST: UTC+05:00) + ('Astrakhan', 'Europe/Astrakhan'), # UTC+04:00 + ('Tbilisi', 'Asia/Tbilisi'), # UTC+04:00 + ('Mauritius', 'Indian/Mauritius'), # UTC+04:00 + ('Islamabad', 'Asia/Karachi'), # UTC+05:00 + ('Karachi', 'Asia/Karachi'), # UTC+05:00 + ('Tashkent', 'Asia/Tashkent'), # UTC+05:00 + ('Yekaterinburg', 'Asia/Yekaterinburg'), # UTC+05:00 + ('Maldives', 'Indian/Maldives'), # UTC+05:00 + ('Chagos', 'Indian/Chagos'), # UTC+05:00 + ('Chennai', 'Asia/Kolkata'), # UTC+05:30 + ('Kolkata', 'Asia/Kolkata'), # UTC+05:30 + ('Mumbai', 'Asia/Kolkata'), # UTC+05:30 + ('New Delhi', 'Asia/Kolkata'), # UTC+05:30 + ('Sri Jayawardenepura', 'Asia/Colombo'), # UTC+05:30 + ('Kathmandu', 'Asia/Kathmandu'), # UTC+05:45 + ('Dhaka', 'Asia/Dhaka'), # UTC+06:00 + ('Almaty', 'Asia/Almaty'), # UTC+06:00 + ('Bishkek', 'Asia/Bishkek'), # UTC+06:00 + ('Thimphu', 'Asia/Thimphu'), # UTC+06:00 + ('Yangon (Rangoon)', 'Asia/Yangon'), # UTC+06:30 + ('Cocos Islands', 'Indian/Cocos'), # UTC+06:30 + ('Bangkok', 'Asia/Bangkok'), # UTC+07:00 + ('Hanoi', 'Asia/Ho_Chi_Minh'), # UTC+07:00 + ('Jakarta', 'Asia/Jakarta'), # UTC+07:00 + ('Novosibirsk', 'Asia/Novosibirsk'), # UTC+07:00 + ('Krasnoyarsk', 'Asia/Krasnoyarsk'), # UTC+07:00 + ('Beijing', 'Asia/Shanghai'), # UTC+08:00 + ('Singapore', 'Asia/Singapore'), # UTC+08:00 + ('Perth', 'Australia/Perth'), # UTC+08:00 + ('Hong Kong', 'Asia/Hong_Kong'), # UTC+08:00 + ('Ulaanbaatar', 'Asia/Ulaanbaatar'), # UTC+08:00 + ('Palau', 'Pacific/Palau'), # UTC+08:00 + ('Eucla', 'Australia/Eucla'), # UTC+08:45 + ('Tokyo', 'Asia/Tokyo'), # UTC+09:00 + ('Seoul', 'Asia/Seoul'), # UTC+09:00 + ('Yakutsk', 'Asia/Yakutsk'), # UTC+09:00 + ('Adelaide', 'Australia/Adelaide'), # UTC+09:30 (DST: UTC+10:30) + ('Darwin', 'Australia/Darwin'), # UTC+09:30 + ('Sydney', 'Australia/Sydney'), # UTC+10:00 (DST: UTC+11:00) + ('Brisbane', 'Australia/Brisbane'), # UTC+10:00 + ('Guam', 'Pacific/Guam'), # UTC+10:00 + ('Vladivostok', 'Asia/Vladivostok'), # UTC+10:00 + ('Tahiti', 'Pacific/Tahiti'), # UTC+10:00 + ('Lord Howe Island', 'Australia/Lord_Howe'), # UTC+10:30 (DST: UTC+11:00) + ('Solomon Islands', 'Pacific/Guadalcanal'), # UTC+11:00 + ('Magadan', 'Asia/Magadan'), # UTC+11:00 + ('Norfolk Island', 'Pacific/Norfolk'), # UTC+11:00 + ('Bougainville Island', 'Pacific/Bougainville'), # UTC+11:00 + ('Chokurdakh', 'Asia/Srednekolymsk'), # UTC+11:00 + ('Auckland', 'Pacific/Auckland'), # UTC+12:00 (DST: UTC+13:00) + ('Wellington', 'Pacific/Auckland'), # UTC+12:00 (DST: UTC+13:00) + ('Fiji Islands', 'Pacific/Fiji'), # UTC+12:00 (DST: UTC+13:00) + ('Anadyr', 'Asia/Anadyr'), # UTC+12:00 + ('Chatham Islands', 'Pacific/Chatham'), # UTC+12:45 (DST: UTC+13:45) + ("Nuku'alofa", 'Pacific/Tongatapu'), # UTC+13:00 + ('Samoa', 'Pacific/Apia'), # UTC+13:00 (DST: UTC+14:00) + ('Kiritimati Island', 'Pacific/Kiritimati') # UTC+14:00 + ] timezone_list = [] now = datetime.now() # Process timezone mapping - for offset, locations in timezone_mapping.items(): - sign = "-" if offset.startswith("-") else "+" - hours = offset[1:3] - minutes = offset[3:] if len(offset) > 3 else "00" + for friendly_name, tz_identifier in timezone_locations: - for friendly_name, tz_identifier in locations: - try: - tz = pytz.timezone(tz_identifier) - current_offset = now.astimezone(tz).strftime("%z") + try: + tz = pytz.timezone(tz_identifier) + current_offset = now.astimezone(tz).strftime("%z") - # converting and formatting UTC offset to GMT offset - current_utc_offset = now.astimezone(tz).utcoffset() - total_seconds = int(current_utc_offset.total_seconds()) - hours_offset = total_seconds // 3600 - minutes_offset = abs(total_seconds % 3600) // 60 - gmt_offset = ( - f"GMT{'+' if hours_offset >= 0 else '-'}" - f"{abs(hours_offset):02}:{minutes_offset:02}" - ) + # converting and formatting UTC offset to GMT offset + current_utc_offset = now.astimezone(tz).utcoffset() + total_seconds = int(current_utc_offset.total_seconds()) + hours_offset = total_seconds // 3600 + minutes_offset = abs(total_seconds % 3600) // 60 + offset = ( + f"{'+' if hours_offset >= 0 else '-'}" + f"{abs(hours_offset):02}:{minutes_offset:02}" + ) - timezone_value = { - "offset": int(current_offset), - "utc_offset": f"UTC{sign}{hours}:{minutes}", - "gmt_offset": gmt_offset, - "value": tz_identifier, - "label": f"{friendly_name}", - } + timezone_value = { + "offset": int(current_offset), + "utc_offset": f"UTC{offset}", + "gmt_offset": f"GMT{offset}", + "value": tz_identifier, + "label": f"{friendly_name}", + } - timezone_list.append(timezone_value) - except pytz.exceptions.UnknownTimeZoneError: - continue + timezone_list.append(timezone_value) + except pytz.exceptions.UnknownTimeZoneError: + continue # Sort by offset and then by label timezone_list.sort(key=lambda x: (x["offset"], x["label"])) diff --git a/apiserver/plane/app/views/view/base.py b/apiserver/plane/app/views/view/base.py index 2016c9b28..5d66fc65c 100644 --- a/apiserver/plane/app/views/view/base.py +++ b/apiserver/plane/app/views/view/base.py @@ -432,7 +432,7 @@ class IssueViewViewSet(BaseViewSet): ): return Response( {"error": "You are not allowed to view this issue"}, - status=status.HTTP_400_BAD_REQUEST, + status=status.HTTP_403_FORBIDDEN, ) serializer = IssueViewSerializer(issue_view) diff --git a/apiserver/plane/app/views/webhook/base.py b/apiserver/plane/app/views/webhook/base.py index d7ff5cf3a..0ed4ba9e0 100644 --- a/apiserver/plane/app/views/webhook/base.py +++ b/apiserver/plane/app/views/webhook/base.py @@ -29,7 +29,7 @@ class WebhookEndpoint(BaseAPIView): if "already exists" in str(e): return Response( {"error": "URL already exists for the workspace"}, - status=status.HTTP_410_GONE, + status=status.HTTP_409_CONFLICT, ) raise IntegrityError diff --git a/apiserver/plane/app/views/workspace/base.py b/apiserver/plane/app/views/workspace/base.py index 5be9a7558..c627f19b6 100644 --- a/apiserver/plane/app/views/workspace/base.py +++ b/apiserver/plane/app/views/workspace/base.py @@ -119,7 +119,9 @@ class WorkSpaceViewSet(BaseViewSet): ) # Get total members and role - total_members=WorkspaceMember.objects.filter(workspace_id=serializer.data["id"]).count() + total_members = WorkspaceMember.objects.filter( + workspace_id=serializer.data["id"] + ).count() data = serializer.data data["total_members"] = total_members data["role"] = 20 @@ -134,7 +136,7 @@ class WorkSpaceViewSet(BaseViewSet): if "already exists" in str(e): return Response( {"slug": "The workspace with the slug already exists"}, - status=status.HTTP_410_GONE, + status=status.HTTP_409_CONFLICT, ) @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") @@ -167,10 +169,9 @@ class UserWorkSpacesEndpoint(BaseAPIView): .values("count") ) - role = ( - WorkspaceMember.objects.filter(workspace=OuterRef("id"), member=request.user, is_active=True) - .values("role") - ) + role = WorkspaceMember.objects.filter( + workspace=OuterRef("id"), member=request.user, is_active=True + ).values("role") workspace = ( Workspace.objects.prefetch_related( diff --git a/apiserver/plane/app/views/workspace/draft.py b/apiserver/plane/app/views/workspace/draft.py index fa161cbab..9503781f1 100644 --- a/apiserver/plane/app/views/workspace/draft.py +++ b/apiserver/plane/app/views/workspace/draft.py @@ -36,7 +36,7 @@ from plane.db.models import ( from .. import BaseViewSet from plane.bgtasks.issue_activities_task import issue_activity from plane.utils.issue_filters import issue_filters - +from plane.utils.host import base_host class WorkspaceDraftIssueViewSet(BaseViewSet): model = DraftIssue @@ -241,7 +241,7 @@ class WorkspaceDraftIssueViewSet(BaseViewSet): current_instance=None, epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) if request.data.get("cycle_id", None): @@ -270,7 +270,7 @@ class WorkspaceDraftIssueViewSet(BaseViewSet): ), epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) if request.data.get("module_ids", []): @@ -300,7 +300,7 @@ class WorkspaceDraftIssueViewSet(BaseViewSet): current_instance=None, epoch=int(timezone.now().timestamp()), notification=True, - origin=request.META.get("HTTP_ORIGIN"), + origin=base_host(request=request, is_app=True), ) for module in request.data.get("module_ids", []) ] diff --git a/apiserver/plane/app/views/workspace/invite.py b/apiserver/plane/app/views/workspace/invite.py index fd3f97c19..a60dd3fc9 100644 --- a/apiserver/plane/app/views/workspace/invite.py +++ b/apiserver/plane/app/views/workspace/invite.py @@ -7,7 +7,6 @@ import jwt from django.conf import settings from django.core.exceptions import ValidationError from django.core.validators import validate_email -from django.db.models import Count from django.utils import timezone # Third party modules @@ -26,7 +25,8 @@ from plane.bgtasks.event_tracking_task import workspace_invite_event from plane.bgtasks.workspace_invitation_task import workspace_invitation from plane.db.models import User, Workspace, WorkspaceMember, WorkspaceMemberInvite from plane.utils.cache import invalidate_cache, invalidate_cache_directly - +from plane.utils.host import base_host +from plane.utils.ip_address import get_client_ip from .. import BaseViewSet @@ -122,7 +122,7 @@ class WorkspaceInvitationsViewset(BaseViewSet): workspace_invitations, batch_size=10, ignore_conflicts=True ) - current_site = request.META.get("HTTP_ORIGIN") + current_site = base_host(request=request, is_app=True) # Send invitations for invitation in workspace_invitations: @@ -213,7 +213,7 @@ class WorkspaceJoinEndpoint(BaseAPIView): user=user.id if user is not None else None, email=email, user_agent=request.META.get("HTTP_USER_AGENT"), - ip=request.META.get("REMOTE_ADDR"), + ip=get_client_ip(request=request), event_name="MEMBER_ACCEPTED", accepted_from="EMAIL", ) diff --git a/apiserver/plane/app/views/workspace/member.py b/apiserver/plane/app/views/workspace/member.py index 9541f9980..5dde2f78c 100644 --- a/apiserver/plane/app/views/workspace/member.py +++ b/apiserver/plane/app/views/workspace/member.py @@ -68,10 +68,11 @@ class WorkSpaceMemberViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) - if workspace_member.role > int(request.data.get("role")): - _ = ProjectMember.objects.filter( + # If a user is moved to a guest role he can't have any other role in projects + if "role" in request.data and int(request.data.get("role")) == 5: + ProjectMember.objects.filter( workspace__slug=slug, member_id=workspace_member.member_id - ).update(role=int(request.data.get("role"))) + ).update(role=5) serializer = WorkSpaceMemberSerializer( workspace_member, data=request.data, partial=True diff --git a/apiserver/plane/authentication/adapter/base.py b/apiserver/plane/authentication/adapter/base.py index c7a8c43d3..f788dcb41 100644 --- a/apiserver/plane/authentication/adapter/base.py +++ b/apiserver/plane/authentication/adapter/base.py @@ -15,8 +15,8 @@ from plane.db.models import Profile, User, WorkspaceMemberInvite from plane.license.utils.instance_value import get_configuration_value from .error import AuthenticationException, AUTHENTICATION_ERROR_CODES from plane.bgtasks.user_activation_email_task import user_activation_email -from plane.authentication.utils.host import base_host - +from plane.utils.host import base_host +from plane.utils.ip_address import get_client_ip class Adapter: """Common interface for all auth providers""" @@ -108,7 +108,7 @@ class Adapter: user.last_login_medium = self.provider user.last_active = timezone.now() user.last_login_time = timezone.now() - user.last_login_ip = self.request.META.get("REMOTE_ADDR") + user.last_login_ip = get_client_ip(request=self.request) user.last_login_uagent = self.request.META.get("HTTP_USER_AGENT") user.token_updated_at = timezone.now() # If user is not active, send the activation email and set the user as active diff --git a/apiserver/plane/authentication/adapter/error.py b/apiserver/plane/authentication/adapter/error.py index 63fafffbe..dcbe039fb 100644 --- a/apiserver/plane/authentication/adapter/error.py +++ b/apiserver/plane/authentication/adapter/error.py @@ -36,10 +36,12 @@ AUTHENTICATION_ERROR_CODES = { "OAUTH_NOT_CONFIGURED": 5104, "GOOGLE_NOT_CONFIGURED": 5105, "GITHUB_NOT_CONFIGURED": 5110, + "GITHUB_USER_NOT_IN_ORG": 5122, "GITLAB_NOT_CONFIGURED": 5111, "GOOGLE_OAUTH_PROVIDER_ERROR": 5115, "GITHUB_OAUTH_PROVIDER_ERROR": 5120, "GITLAB_OAUTH_PROVIDER_ERROR": 5121, + # Reset Password "INVALID_PASSWORD_TOKEN": 5125, "EXPIRED_PASSWORD_TOKEN": 5130, diff --git a/apiserver/plane/authentication/provider/oauth/github.py b/apiserver/plane/authentication/provider/oauth/github.py index 1808aa515..4a7808c8a 100644 --- a/apiserver/plane/authentication/provider/oauth/github.py +++ b/apiserver/plane/authentication/provider/oauth/github.py @@ -18,11 +18,16 @@ from plane.authentication.adapter.error import ( class GitHubOAuthProvider(OauthAdapter): token_url = "https://github.com/login/oauth/access_token" userinfo_url = "https://api.github.com/user" + org_membership_url = f"https://api.github.com/orgs" + provider = "github" scope = "read:user user:email" + organization_scope = "read:org" + + def __init__(self, request, code=None, state=None, callback=None): - GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET = get_configuration_value( + GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_ORGANIZATION_ID = get_configuration_value( [ { "key": "GITHUB_CLIENT_ID", @@ -32,6 +37,10 @@ class GitHubOAuthProvider(OauthAdapter): "key": "GITHUB_CLIENT_SECRET", "default": os.environ.get("GITHUB_CLIENT_SECRET"), }, + { + "key": "GITHUB_ORGANIZATION_ID", + "default": os.environ.get("GITHUB_ORGANIZATION_ID"), + }, ] ) @@ -43,6 +52,10 @@ class GitHubOAuthProvider(OauthAdapter): client_id = GITHUB_CLIENT_ID client_secret = GITHUB_CLIENT_SECRET + self.organization_id = GITHUB_ORGANIZATION_ID + + if self.organization_id: + self.scope += f" {self.organization_scope}" redirect_uri = f"""{"https" if request.is_secure() else "http"}://{request.get_host()}/auth/github/callback/""" url_params = { @@ -113,12 +126,26 @@ class GitHubOAuthProvider(OauthAdapter): error_message="GITHUB_OAUTH_PROVIDER_ERROR", ) + def is_user_in_organization(self, github_username): + headers = {"Authorization": f"Bearer {self.token_data.get('access_token')}"} + response = requests.get(f"{self.org_membership_url}/{self.organization_id}/memberships/{github_username}", headers=headers) + return response.status_code == 200 # 200 means the user is a member + def set_user_data(self): user_info_response = self.get_user_response() headers = { "Authorization": f"Bearer {self.token_data.get('access_token')}", "Accept": "application/json", } + + if self.organization_id: + if not self.is_user_in_organization(user_info_response.get("login")): + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITHUB_USER_NOT_IN_ORG"], + error_message="GITHUB_USER_NOT_IN_ORG", + ) + + email = self.__get_email(headers=headers) super().set_user_data( { diff --git a/apiserver/plane/authentication/utils/host.py b/apiserver/plane/authentication/utils/host.py index 4046c1e20..c4625279c 100644 --- a/apiserver/plane/authentication/utils/host.py +++ b/apiserver/plane/authentication/utils/host.py @@ -1,18 +1,16 @@ -# Python imports -from urllib.parse import urlsplit - # Django imports from django.conf import settings +from django.http import HttpRequest +# Third party imports +from rest_framework.request import Request +# Module imports +from plane.utils.ip_address import get_client_ip -def base_host(request, is_admin=False, is_space=False, is_app=False): +def base_host(request: Request | HttpRequest, is_admin: bool = False, is_space: bool = False, is_app: bool = False) -> str: """Utility function to return host / origin from the request""" # Calculate the base origin from request - base_origin = str( - request.META.get("HTTP_ORIGIN") - or f"{urlsplit(request.META.get('HTTP_REFERER')).scheme}://{urlsplit(request.META.get('HTTP_REFERER')).netloc}" - or f"""{"https" if request.is_secure() else "http"}://{request.get_host()}""" - ) + base_origin = settings.WEB_URL or settings.APP_BASE_URL # Admin redirections if is_admin: @@ -38,5 +36,5 @@ def base_host(request, is_admin=False, is_space=False, is_app=False): return base_origin -def user_ip(request): - return str(request.META.get("REMOTE_ADDR")) +def user_ip(request: Request | HttpRequest) -> str: + return get_client_ip(request=request) diff --git a/apiserver/plane/authentication/utils/login.py b/apiserver/plane/authentication/utils/login.py index ba7f9d1e1..f8c0ed842 100644 --- a/apiserver/plane/authentication/utils/login.py +++ b/apiserver/plane/authentication/utils/login.py @@ -3,8 +3,8 @@ from django.contrib.auth import login from django.conf import settings # Module imports -from plane.authentication.utils.host import base_host - +from plane.utils.host import base_host +from plane.utils.ip_address import get_client_ip def user_login(request, user, is_app=False, is_admin=False, is_space=False): login(request=request, user=user) @@ -15,7 +15,7 @@ def user_login(request, user, is_app=False, is_admin=False, is_space=False): device_info = { "user_agent": request.META.get("HTTP_USER_AGENT", ""), - "ip_address": request.META.get("REMOTE_ADDR", ""), + "ip_address": get_client_ip(request=request), "domain": base_host( request=request, is_app=is_app, is_admin=is_admin, is_space=is_space ), diff --git a/apiserver/plane/authentication/views/app/email.py b/apiserver/plane/authentication/views/app/email.py index 805b273a1..7e91b21c1 100644 --- a/apiserver/plane/authentication/views/app/email.py +++ b/apiserver/plane/authentication/views/app/email.py @@ -19,7 +19,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) - +from plane.utils.path_validator import validate_next_path class SignInAuthEndpoint(View): def post(self, request): @@ -34,7 +34,7 @@ class SignInAuthEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) # Base URL join url = urljoin( base_host(request=request, is_app=True), "sign-in?" + urlencode(params) @@ -58,7 +58,7 @@ class SignInAuthEndpoint(View): params = exc.get_error_dict() # Next path if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = urljoin( base_host(request=request, is_app=True), "sign-in?" + urlencode(params) ) @@ -76,7 +76,7 @@ class SignInAuthEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = urljoin( base_host(request=request, is_app=True), "sign-in?" + urlencode(params) ) @@ -92,7 +92,7 @@ class SignInAuthEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = urljoin( base_host(request=request, is_app=True), "sign-in?" + urlencode(params) ) @@ -111,7 +111,7 @@ class SignInAuthEndpoint(View): user_login(request=request, user=user, is_app=True) # Get the redirection path if next_path: - path = str(next_path) + path = str(validate_next_path(next_path)) else: path = get_redirection_path(user=user) @@ -121,7 +121,7 @@ class SignInAuthEndpoint(View): except AuthenticationException as e: params = e.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = urljoin( base_host(request=request, is_app=True), "sign-in?" + urlencode(params) ) @@ -141,7 +141,7 @@ class SignUpAuthEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = urljoin( base_host(request=request, is_app=True), "?" + urlencode(params) ) @@ -161,7 +161,7 @@ class SignUpAuthEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = urljoin( base_host(request=request, is_app=True), "?" + urlencode(params) ) @@ -179,7 +179,7 @@ class SignUpAuthEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = urljoin( base_host(request=request, is_app=True), "?" + urlencode(params) ) @@ -197,7 +197,7 @@ class SignUpAuthEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = urljoin( base_host(request=request, is_app=True), "?" + urlencode(params) ) @@ -216,7 +216,7 @@ class SignUpAuthEndpoint(View): user_login(request=request, user=user, is_app=True) # Get the redirection path if next_path: - path = next_path + path = str(validate_next_path(next_path)) else: path = get_redirection_path(user=user) # redirect to referer path @@ -225,7 +225,7 @@ class SignUpAuthEndpoint(View): except AuthenticationException as e: params = e.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = urljoin( base_host(request=request, is_app=True), "?" + urlencode(params) ) diff --git a/apiserver/plane/authentication/views/app/github.py b/apiserver/plane/authentication/views/app/github.py index f1a15474c..f558bcd4b 100644 --- a/apiserver/plane/authentication/views/app/github.py +++ b/apiserver/plane/authentication/views/app/github.py @@ -16,7 +16,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) - +from plane.utils.path_validator import validate_next_path class GitHubOauthInitiateEndpoint(View): def get(self, request): @@ -35,7 +35,7 @@ class GitHubOauthInitiateEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = urljoin( base_host(request=request, is_app=True), "?" + urlencode(params) ) @@ -49,7 +49,7 @@ class GitHubOauthInitiateEndpoint(View): except AuthenticationException as e: params = e.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = urljoin( base_host(request=request, is_app=True), "?" + urlencode(params) ) @@ -70,7 +70,7 @@ class GitHubCallbackEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = urljoin(base_host, "?" + urlencode(params)) return HttpResponseRedirect(url) @@ -81,7 +81,7 @@ class GitHubCallbackEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = urljoin(base_host, "?" + urlencode(params)) return HttpResponseRedirect(url) @@ -94,7 +94,7 @@ class GitHubCallbackEndpoint(View): user_login(request=request, user=user, is_app=True) # Get the redirection path if next_path: - path = next_path + path = str(validate_next_path(next_path)) else: path = get_redirection_path(user=user) # redirect to referer path @@ -103,6 +103,6 @@ class GitHubCallbackEndpoint(View): except AuthenticationException as e: params = e.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = urljoin(base_host, "?" + urlencode(params)) return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/app/gitlab.py b/apiserver/plane/authentication/views/app/gitlab.py index bc0c9c8d7..c3a0f5876 100644 --- a/apiserver/plane/authentication/views/app/gitlab.py +++ b/apiserver/plane/authentication/views/app/gitlab.py @@ -16,7 +16,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) - +from plane.utils.path_validator import validate_next_path class GitLabOauthInitiateEndpoint(View): def get(self, request): @@ -24,7 +24,7 @@ class GitLabOauthInitiateEndpoint(View): request.session["host"] = base_host(request=request, is_app=True) next_path = request.GET.get("next_path") if next_path: - request.session["next_path"] = str(next_path) + request.session["next_path"] = str(validate_next_path(next_path)) # Check instance configuration instance = Instance.objects.first() @@ -35,7 +35,7 @@ class GitLabOauthInitiateEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = urljoin( base_host(request=request, is_app=True), "?" + urlencode(params) ) @@ -49,7 +49,7 @@ class GitLabOauthInitiateEndpoint(View): except AuthenticationException as e: params = e.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = urljoin( base_host(request=request, is_app=True), "?" + urlencode(params) ) @@ -81,7 +81,7 @@ class GitLabCallbackEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = urljoin(base_host, "?" + urlencode(params)) return HttpResponseRedirect(url) @@ -94,7 +94,7 @@ class GitLabCallbackEndpoint(View): user_login(request=request, user=user, is_app=True) # Get the redirection path if next_path: - path = next_path + path = str(validate_next_path(next_path)) else: path = get_redirection_path(user=user) # redirect to referer path @@ -103,6 +103,6 @@ class GitLabCallbackEndpoint(View): except AuthenticationException as e: params = e.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = urljoin(base_host, "?" + urlencode(params)) return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/app/google.py b/apiserver/plane/authentication/views/app/google.py index 46c0d1980..2caf9f51b 100644 --- a/apiserver/plane/authentication/views/app/google.py +++ b/apiserver/plane/authentication/views/app/google.py @@ -18,7 +18,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) - +from plane.utils.path_validator import validate_next_path class GoogleOauthInitiateEndpoint(View): def get(self, request): @@ -36,7 +36,7 @@ class GoogleOauthInitiateEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = urljoin( base_host(request=request, is_app=True), "?" + urlencode(params) ) @@ -51,7 +51,7 @@ class GoogleOauthInitiateEndpoint(View): except AuthenticationException as e: params = e.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = urljoin( base_host(request=request, is_app=True), "?" + urlencode(params) ) @@ -72,7 +72,7 @@ class GoogleCallbackEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = urljoin(base_host, "?" + urlencode(params)) return HttpResponseRedirect(url) if not code: @@ -82,7 +82,7 @@ class GoogleCallbackEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = next_path + params["next_path"] = str(validate_next_path(next_path)) url = urljoin(base_host, "?" + urlencode(params)) return HttpResponseRedirect(url) try: @@ -95,11 +95,11 @@ class GoogleCallbackEndpoint(View): # Get the redirection path path = get_redirection_path(user=user) # redirect to referer path - url = urljoin(base_host, str(next_path) if next_path else path) + url = urljoin(base_host, str(validate_next_path(next_path)) if next_path else path) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = urljoin(base_host, "?" + urlencode(params)) return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/app/magic.py b/apiserver/plane/authentication/views/app/magic.py index b3bf8c777..4b1bdb02e 100644 --- a/apiserver/plane/authentication/views/app/magic.py +++ b/apiserver/plane/authentication/views/app/magic.py @@ -26,6 +26,7 @@ from plane.authentication.adapter.error import ( AUTHENTICATION_ERROR_CODES, ) from plane.authentication.rate_limit import AuthenticationThrottle +from plane.utils.path_validator import validate_next_path class MagicGenerateEndpoint(APIView): @@ -43,14 +44,13 @@ class MagicGenerateEndpoint(APIView): ) return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) - origin = request.META.get("HTTP_ORIGIN", "/") email = request.data.get("email", "").strip().lower() try: validate_email(email) adapter = MagicCodeProvider(request=request, key=email) key, token = adapter.initiate() # If the smtp is configured send through here - magic_link.delay(email, key, token, origin) + magic_link.delay(email, key, token) return Response({"key": str(key)}, status=status.HTTP_200_OK) except AuthenticationException as e: params = e.get_error_dict() @@ -73,7 +73,7 @@ class MagicSignInEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = urljoin( base_host(request=request, is_app=True), "sign-in?" + urlencode(params) ) @@ -89,7 +89,7 @@ class MagicSignInEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = urljoin( base_host(request=request, is_app=True), "sign-in?" + urlencode(params) ) @@ -122,7 +122,7 @@ class MagicSignInEndpoint(View): except AuthenticationException as e: params = e.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = urljoin( base_host(request=request, is_app=True), "sign-in?" + urlencode(params) ) @@ -145,7 +145,7 @@ class MagicSignUpEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = urljoin( base_host(request=request, is_app=True), "?" + urlencode(params) ) @@ -159,7 +159,7 @@ class MagicSignUpEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = urljoin( base_host(request=request, is_app=True), "?" + urlencode(params) ) @@ -177,7 +177,7 @@ class MagicSignUpEndpoint(View): user_login(request=request, user=user, is_app=True) # Get the redirection path if next_path: - path = str(next_path) + path = str(validate_next_path(next_path)) else: path = get_redirection_path(user=user) # redirect to referer path @@ -187,7 +187,7 @@ class MagicSignUpEndpoint(View): except AuthenticationException as e: params = e.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = urljoin( base_host(request=request, is_app=True), "?" + urlencode(params) ) diff --git a/apiserver/plane/authentication/views/app/password_management.py b/apiserver/plane/authentication/views/app/password_management.py index 5b8d383c7..bbc5658c4 100644 --- a/apiserver/plane/authentication/views/app/password_management.py +++ b/apiserver/plane/authentication/views/app/password_management.py @@ -80,7 +80,7 @@ class ForgotPasswordEndpoint(APIView): if user: # Get the reset token for user uidb64, token = generate_password_token(user=user) - current_site = request.META.get("HTTP_ORIGIN") + current_site = base_host(request=request, is_app=True) # send the forgot password email forgot_password.delay( user.first_name, user.email, uidb64, token, current_site diff --git a/apiserver/plane/authentication/views/common.py b/apiserver/plane/authentication/views/common.py index cdcf6bc96..7a18072ae 100644 --- a/apiserver/plane/authentication/views/common.py +++ b/apiserver/plane/authentication/views/common.py @@ -44,10 +44,21 @@ class ChangePasswordEndpoint(APIView): def post(self, request): user = User.objects.get(pk=request.user.id) - old_password = request.data.get("old_password", False) + # If the user password is not autoset then we need to check the old passwords + if not user.is_password_autoset: + old_password = request.data.get("old_password", False) + if not old_password: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["MISSING_PASSWORD"], + error_message="MISSING_PASSWORD", + payload={"error": "Old password is missing"}, + ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) + + # Get the new password new_password = request.data.get("new_password", False) - if not old_password or not new_password: + if not new_password: exc = AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES["MISSING_PASSWORD"], error_message="MISSING_PASSWORD", @@ -55,7 +66,9 @@ class ChangePasswordEndpoint(APIView): ) return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) - if not user.check_password(old_password): + + # If the user password is not autoset then we need to check the old passwords + if not user.is_password_autoset and not user.check_password(old_password): exc = AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES["INCORRECT_OLD_PASSWORD"], error_message="INCORRECT_OLD_PASSWORD", diff --git a/apiserver/plane/authentication/views/space/email.py b/apiserver/plane/authentication/views/space/email.py index 278cdf80b..6fa2d4517 100644 --- a/apiserver/plane/authentication/views/space/email.py +++ b/apiserver/plane/authentication/views/space/email.py @@ -17,6 +17,7 @@ from plane.authentication.adapter.error import ( AUTHENTICATION_ERROR_CODES, AuthenticationException, ) +from plane.utils.path_validator import validate_next_path class SignInAuthSpaceEndpoint(View): @@ -32,7 +33,7 @@ class SignInAuthSpaceEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) @@ -51,7 +52,7 @@ class SignInAuthSpaceEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) @@ -67,7 +68,7 @@ class SignInAuthSpaceEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) @@ -82,7 +83,7 @@ class SignInAuthSpaceEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) @@ -99,7 +100,7 @@ class SignInAuthSpaceEndpoint(View): except AuthenticationException as e: params = e.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) @@ -117,7 +118,7 @@ class SignUpAuthSpaceEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) @@ -135,7 +136,7 @@ class SignUpAuthSpaceEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) # Validate the email @@ -151,7 +152,7 @@ class SignUpAuthSpaceEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) @@ -166,7 +167,7 @@ class SignUpAuthSpaceEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) @@ -183,6 +184,6 @@ class SignUpAuthSpaceEndpoint(View): except AuthenticationException as e: params = e.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/space/github.py b/apiserver/plane/authentication/views/space/github.py index 1d9d1d4ee..fec71cb48 100644 --- a/apiserver/plane/authentication/views/space/github.py +++ b/apiserver/plane/authentication/views/space/github.py @@ -15,6 +15,7 @@ from plane.authentication.adapter.error import ( AUTHENTICATION_ERROR_CODES, AuthenticationException, ) +from plane.utils.path_validator import validate_next_path class GitHubOauthInitiateSpaceEndpoint(View): @@ -34,7 +35,7 @@ class GitHubOauthInitiateSpaceEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) @@ -66,7 +67,7 @@ class GitHubCallbackSpaceEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) @@ -77,7 +78,7 @@ class GitHubCallbackSpaceEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) @@ -93,6 +94,6 @@ class GitHubCallbackSpaceEndpoint(View): except AuthenticationException as e: params = e.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/space/gitlab.py b/apiserver/plane/authentication/views/space/gitlab.py index 9fb314442..4bdcf9514 100644 --- a/apiserver/plane/authentication/views/space/gitlab.py +++ b/apiserver/plane/authentication/views/space/gitlab.py @@ -15,6 +15,7 @@ from plane.authentication.adapter.error import ( AUTHENTICATION_ERROR_CODES, AuthenticationException, ) +from plane.utils.path_validator import validate_next_path class GitLabOauthInitiateSpaceEndpoint(View): @@ -34,7 +35,7 @@ class GitLabOauthInitiateSpaceEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) @@ -66,7 +67,7 @@ class GitLabCallbackSpaceEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) @@ -77,7 +78,7 @@ class GitLabCallbackSpaceEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) @@ -93,6 +94,6 @@ class GitLabCallbackSpaceEndpoint(View): except AuthenticationException as e: params = e.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/space/google.py b/apiserver/plane/authentication/views/space/google.py index 479a18883..03ad97793 100644 --- a/apiserver/plane/authentication/views/space/google.py +++ b/apiserver/plane/authentication/views/space/google.py @@ -15,6 +15,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) +from plane.utils.path_validator import validate_next_path class GoogleOauthInitiateSpaceEndpoint(View): @@ -33,7 +34,7 @@ class GoogleOauthInitiateSpaceEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) @@ -46,7 +47,7 @@ class GoogleOauthInitiateSpaceEndpoint(View): except AuthenticationException as e: params = e.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) @@ -65,7 +66,7 @@ class GoogleCallbackSpaceEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) if not code: @@ -75,7 +76,7 @@ class GoogleCallbackSpaceEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = next_path + params["next_path"] = str(validate_next_path(next_path)) url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) try: @@ -89,6 +90,6 @@ class GoogleCallbackSpaceEndpoint(View): except AuthenticationException as e: params = e.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/space/magic.py b/apiserver/plane/authentication/views/space/magic.py index 7c23d5fc3..cb682137c 100644 --- a/apiserver/plane/authentication/views/space/magic.py +++ b/apiserver/plane/authentication/views/space/magic.py @@ -23,7 +23,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) - +from plane.utils.path_validator import validate_next_path class MagicGenerateSpaceEndpoint(APIView): permission_classes = [AllowAny] @@ -38,14 +38,14 @@ class MagicGenerateSpaceEndpoint(APIView): ) return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) - origin = base_host(request=request, is_space=True) + email = request.data.get("email", "").strip().lower() try: validate_email(email) adapter = MagicCodeProvider(request=request, key=email) key, token = adapter.initiate() # If the smtp is configured send through here - magic_link.delay(email, key, token, origin) + magic_link.delay(email, key, token) return Response({"key": str(key)}, status=status.HTTP_200_OK) except AuthenticationException as e: return Response(e.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) @@ -67,7 +67,7 @@ class MagicSignInSpaceEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) @@ -80,7 +80,7 @@ class MagicSignInSpaceEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) @@ -121,7 +121,7 @@ class MagicSignUpSpaceEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) # Existing User @@ -134,7 +134,7 @@ class MagicSignUpSpaceEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) @@ -152,6 +152,6 @@ class MagicSignUpSpaceEndpoint(View): except AuthenticationException as e: params = e.get_error_dict() if next_path: - params["next_path"] = str(next_path) + params["next_path"] = str(validate_next_path(next_path)) url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/space/password_management.py b/apiserver/plane/authentication/views/space/password_management.py index 710d0db2f..bff3e3485 100644 --- a/apiserver/plane/authentication/views/space/password_management.py +++ b/apiserver/plane/authentication/views/space/password_management.py @@ -90,7 +90,7 @@ class ForgotPasswordSpaceEndpoint(APIView): if user: # Get the reset token for user uidb64, token = generate_password_token(user=user) - current_site = request.META.get("HTTP_ORIGIN") + current_site = base_host(request=request, is_space=True) # send the forgot password email forgot_password.delay( user.first_name, user.email, uidb64, token, current_site diff --git a/apiserver/plane/authentication/views/space/signout.py b/apiserver/plane/authentication/views/space/signout.py index babd18ee9..11e617436 100644 --- a/apiserver/plane/authentication/views/space/signout.py +++ b/apiserver/plane/authentication/views/space/signout.py @@ -7,6 +7,7 @@ from django.utils import timezone # Module imports from plane.authentication.utils.host import base_host, user_ip from plane.db.models import User +from plane.utils.path_validator import validate_next_path class SignOutAuthSpaceEndpoint(View): @@ -21,8 +22,8 @@ class SignOutAuthSpaceEndpoint(View): user.save() # Log the user out logout(request) - url = f"{base_host(request=request, is_space=True)}{next_path}" + url = f"{base_host(request=request, is_space=True)}{str(validate_next_path(next_path)) if next_path else ''}" return HttpResponseRedirect(url) except Exception: - url = f"{base_host(request=request, is_space=True)}{next_path}" + url = f"{base_host(request=request, is_space=True)}{str(validate_next_path(next_path)) if next_path else ''}" return HttpResponseRedirect(url) diff --git a/apiserver/plane/bgtasks/issue_activities_task.py b/apiserver/plane/bgtasks/issue_activities_task.py index efc7ce8da..fcd75f8e3 100644 --- a/apiserver/plane/bgtasks/issue_activities_task.py +++ b/apiserver/plane/bgtasks/issue_activities_task.py @@ -32,7 +32,7 @@ from plane.settings.redis import redis_instance from plane.utils.exception_logger import log_exception from plane.bgtasks.webhook_task import webhook_activity from plane.utils.issue_relation_mapper import get_inverse_relation -from plane.utils.valid_uuid import is_valid_uuid +from plane.utils.uuid import is_valid_uuid # Track Changes in name @@ -307,6 +307,10 @@ def track_labels( # Set of newly added labels for added_label in added_labels: + # validate uuids + if not is_valid_uuid(added_label): + continue + label = Label.objects.get(pk=added_label) issue_activities.append( IssueActivity( @@ -327,6 +331,10 @@ def track_labels( # Set of dropped labels for dropped_label in dropped_labels: + # validate uuids + if not is_valid_uuid(dropped_label): + continue + label = Label.objects.get(pk=dropped_label) issue_activities.append( IssueActivity( @@ -373,6 +381,10 @@ def track_assignees( bulk_subscribers = [] for added_asignee in added_assignees: + # validate uuids + if not is_valid_uuid(added_asignee): + continue + assignee = User.objects.get(pk=added_asignee) issue_activities.append( IssueActivity( @@ -406,6 +418,10 @@ def track_assignees( ) for dropped_assignee in dropped_assginees: + # validate uuids + if not is_valid_uuid(dropped_assignee): + continue + assignee = User.objects.get(pk=dropped_assignee) issue_activities.append( IssueActivity( @@ -466,7 +482,7 @@ def track_estimate_points( ), old_value=old_estimate.value if old_estimate else None, new_value=new_estimate.value if new_estimate else None, - field="estimate_point", + field="estimate_" + new_estimate.estimate.type, project_id=project_id, workspace_id=workspace_id, comment="updated the estimate point to ", diff --git a/apiserver/plane/bgtasks/magic_link_code_task.py b/apiserver/plane/bgtasks/magic_link_code_task.py index 848ea623f..1a0e9ba03 100644 --- a/apiserver/plane/bgtasks/magic_link_code_task.py +++ b/apiserver/plane/bgtasks/magic_link_code_task.py @@ -16,7 +16,7 @@ from plane.utils.exception_logger import log_exception @shared_task -def magic_link(email, key, token, current_site): +def magic_link(email, key, token): try: ( EMAIL_HOST, diff --git a/apiserver/plane/celery.py b/apiserver/plane/celery.py index 29af49bdc..0ffa4689b 100644 --- a/apiserver/plane/celery.py +++ b/apiserver/plane/celery.py @@ -1,8 +1,16 @@ +# Python imports import os +import logging + +# Third party imports from celery import Celery -from plane.settings.redis import redis_instance +from pythonjsonlogger.jsonlogger import JsonFormatter +from celery.signals import after_setup_logger, after_setup_task_logger from celery.schedules import crontab +# Module imports +from plane.settings.redis import redis_instance + # Set the default Django settings module for the 'celery' program. os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production") @@ -47,6 +55,28 @@ app.conf.beat_schedule = { }, } + +# Setup logging +@after_setup_logger.connect +def setup_loggers(logger, *args, **kwargs): + formatter = JsonFormatter( + '"%(levelname)s %(asctime)s %(module)s %(name)s %(message)s' + ) + handler = logging.StreamHandler() + handler.setFormatter(fmt=formatter) + logger.addHandler(handler) + + +@after_setup_task_logger.connect +def setup_task_loggers(logger, *args, **kwargs): + formatter = JsonFormatter( + '"%(levelname)s %(asctime)s %(module)s %(name)s %(message)s' + ) + handler = logging.StreamHandler() + handler.setFormatter(fmt=formatter) + logger.addHandler(handler) + + # Load task modules from all registered Django app configs. app.autodiscover_tasks() diff --git a/apiserver/plane/db/management/commands/fix_duplicate_sequences.py b/apiserver/plane/db/management/commands/fix_duplicate_sequences.py new file mode 100644 index 000000000..1bf4d4452 --- /dev/null +++ b/apiserver/plane/db/management/commands/fix_duplicate_sequences.py @@ -0,0 +1,102 @@ +# Django imports +from django.core.management.base import BaseCommand, CommandError +from django.db.models import Max +from django.db import connection, transaction + +# Module imports +from plane.db.models import Project, Issue, IssueSequence +from plane.utils.uuid import convert_uuid_to_integer + + +class Command(BaseCommand): + help = "Fix duplicate sequences" + + def add_arguments(self, parser): + # Positional argument + parser.add_argument("issue_identifier", type=str, help="Issue Identifier") + + def strict_str_to_int(self, s): + if not s.isdigit() and not (s.startswith("-") and s[1:].isdigit()): + raise ValueError("Invalid integer string") + return int(s) + + def handle(self, *args, **options): + workspace_slug = input("Workspace slug: ") + + if not workspace_slug: + raise CommandError("Workspace slug is required") + + issue_identifier = options.get("issue_identifier", False) + + # Validate issue_identifier + if not issue_identifier: + raise CommandError("Issue identifier is required") + + # Validate issue identifier + try: + identifier = issue_identifier.split("-") + + if len(identifier) != 2: + raise ValueError("Invalid issue identifier format") + + project_identifier = identifier[0] + issue_sequence = self.strict_str_to_int(identifier[1]) + + # Fetch the project + project = Project.objects.get( + identifier__iexact=project_identifier, workspace__slug=workspace_slug + ) + + # Get the issues + issues = Issue.objects.filter(project=project, sequence_id=issue_sequence) + # Check if there are duplicate issues + if not issues.count() > 1: + raise CommandError( + "No duplicate issues found with the given identifier" + ) + + self.stdout.write( + self.style.SUCCESS( + f"{issues.count()} issues found with identifier {issue_identifier}" + ) + ) + with transaction.atomic(): + # This ensures only one transaction per project can execute this code at a time + lock_key = convert_uuid_to_integer(project.id) + + # Acquire an exclusive lock using the project ID as the lock key + with connection.cursor() as cursor: + # Get an exclusive lock using the project ID as the lock key + cursor.execute("SELECT pg_advisory_xact_lock(%s)", [lock_key]) + + # Get the maximum sequence ID for the project + last_sequence = IssueSequence.objects.filter(project=project).aggregate( + largest=Max("sequence") + )["largest"] + + bulk_issues = [] + bulk_issue_sequences = [] + + issue_sequence_map = { + isq.issue_id: isq + for isq in IssueSequence.objects.filter(project=project) + } + + # change the ids of duplicate issues + for index, issue in enumerate(issues[1:]): + updated_sequence_id = last_sequence + index + 1 + issue.sequence_id = updated_sequence_id + bulk_issues.append(issue) + + # Find the same issue sequence instance from the above queryset + sequence_identifier = issue_sequence_map.get(issue.id) + if sequence_identifier: + sequence_identifier.sequence = updated_sequence_id + bulk_issue_sequences.append(sequence_identifier) + + Issue.objects.bulk_update(bulk_issues, ["sequence_id"]) + IssueSequence.objects.bulk_update(bulk_issue_sequences, ["sequence"]) + + self.stdout.write(self.style.SUCCESS("Sequence IDs updated successfully")) + except Exception as e: + raise CommandError(str(e)) diff --git a/apiserver/plane/db/management/commands/update_deleted_workspace_slug.py b/apiserver/plane/db/management/commands/update_deleted_workspace_slug.py new file mode 100644 index 000000000..3d07e8e34 --- /dev/null +++ b/apiserver/plane/db/management/commands/update_deleted_workspace_slug.py @@ -0,0 +1,78 @@ +import time +from django.core.management.base import BaseCommand +from django.db import transaction +from plane.db.models import Workspace + + +class Command(BaseCommand): + help = "Updates the slug of a soft-deleted workspace by appending the epoch timestamp" + + def add_arguments(self, parser): + parser.add_argument( + "slug", + type=str, + help="The slug of the workspace to update", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Run the command without making any changes", + ) + + def handle(self, *args, **options): + slug = options["slug"] + dry_run = options["dry_run"] + + # Get the workspace with the specified slug + try: + workspace = Workspace.all_objects.get(slug=slug) + except Workspace.DoesNotExist: + self.stdout.write( + self.style.ERROR(f"Workspace with slug '{slug}' not found.") + ) + return + + # Check if the workspace is soft-deleted + if workspace.deleted_at is None: + self.stdout.write( + self.style.WARNING( + f"Workspace '{workspace.name}' (slug: {workspace.slug}) is not deleted." + ) + ) + return + + # Check if the slug already has a timestamp appended + if "__" in workspace.slug and workspace.slug.split("__")[-1].isdigit(): + self.stdout.write( + self.style.WARNING( + f"Workspace '{workspace.name}' (slug: {workspace.slug}) already has a timestamp appended." + ) + ) + return + + # Get the deletion timestamp + deletion_timestamp = int(workspace.deleted_at.timestamp()) + + # Create the new slug with the deletion timestamp + new_slug = f"{workspace.slug}__{deletion_timestamp}" + + if dry_run: + self.stdout.write( + f"Would update workspace '{workspace.name}' slug from '{workspace.slug}' to '{new_slug}'" + ) + else: + try: + with transaction.atomic(): + workspace.slug = new_slug + workspace.save(update_fields=["slug"]) + self.stdout.write( + self.style.SUCCESS( + f"Updated workspace '{workspace.name}' slug from '{workspace.slug}' to '{new_slug}'" + ) + ) + except Exception as e: + self.stdout.write( + self.style.ERROR( + f"Error updating workspace '{workspace.name}': {str(e)}" + ) + ) \ No newline at end of file diff --git a/apiserver/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py b/apiserver/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py index edca91f2c..f7998c4a0 100644 --- a/apiserver/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py +++ b/apiserver/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py @@ -3,7 +3,6 @@ from django.conf import settings from django.db import migrations, models import django.db.models.deletion -from sentry_sdk import capture_exception import uuid @@ -29,7 +28,6 @@ def create_issue_relation(apps, schema_editor): ) except Exception as e: print(e) - capture_exception(e) def update_issue_priority_choice(apps, schema_editor): diff --git a/apiserver/plane/db/migrations/0093_page_moved_to_page_page_moved_to_project_and_more.py b/apiserver/plane/db/migrations/0093_page_moved_to_page_page_moved_to_project_and_more.py new file mode 100644 index 000000000..52b27d4e6 --- /dev/null +++ b/apiserver/plane/db/migrations/0093_page_moved_to_page_page_moved_to_project_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.17 on 2025-03-04 19:29 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0092_alter_deprecateddashboardwidget_unique_together_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="page", + name="moved_to_page", + field=models.UUIDField(blank=True, null=True), + ), + migrations.AddField( + model_name="page", + name="moved_to_project", + field=models.UUIDField(blank=True, null=True), + ), + migrations.AddField( + model_name="pageversion", + name="sub_pages_data", + field=models.JSONField(blank=True, default=dict), + ), + ] diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index 3cf46c919..04e5a27f6 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -82,4 +82,4 @@ from .label import Label from .device import Device, DeviceSession -from .sticky import Sticky +from .sticky import Sticky \ No newline at end of file diff --git a/apiserver/plane/db/models/intake.py b/apiserver/plane/db/models/intake.py index 3860b97f2..2f698ae1b 100644 --- a/apiserver/plane/db/models/intake.py +++ b/apiserver/plane/db/models/intake.py @@ -31,6 +31,10 @@ class Intake(ProjectBaseModel): ordering = ("name",) +class SourceType(models.TextChoices): + IN_APP = "IN_APP" + + class IntakeIssue(ProjectBaseModel): intake = models.ForeignKey( "db.Intake", related_name="issue_intake", on_delete=models.CASCADE diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index fe5e9937c..dad7aab3f 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -6,7 +6,7 @@ from django.conf import settings from django.contrib.postgres.fields import ArrayField from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator -from django.db import models, transaction +from django.db import models, transaction, connection from django.utils import timezone from django.db.models import Q from django import apps @@ -15,8 +15,8 @@ from django import apps from plane.utils.html_processor import strip_tags from plane.db.mixins import SoftDeletionManager from plane.utils.exception_logger import log_exception -from .base import BaseModel from .project import ProjectBaseModel +from plane.utils.uuid import convert_uuid_to_integer def get_default_properties(): @@ -209,11 +209,18 @@ class Issue(ProjectBaseModel): if self._state.adding: with transaction.atomic(): - last_sequence = ( - IssueSequence.objects.filter(project=self.project) - .select_for_update() - .aggregate(largest=models.Max("sequence"))["largest"] - ) + # Create a lock for this specific project using an advisory lock + # This ensures only one transaction per project can execute this code at a time + lock_key = convert_uuid_to_integer(self.project.id) + + with connection.cursor() as cursor: + # Get an exclusive lock using the project ID as the lock key + cursor.execute("SELECT pg_advisory_xact_lock(%s)", [lock_key]) + + # Get the last sequence for the project + last_sequence = IssueSequence.objects.filter( + project=self.project + ).aggregate(largest=models.Max("sequence"))["largest"] self.sequence_id = last_sequence + 1 if last_sequence else 1 # Strip the html tags using html parser self.description_stripped = ( diff --git a/apiserver/plane/db/models/page.py b/apiserver/plane/db/models/page.py index 91ffcf023..5f4fb2744 100644 --- a/apiserver/plane/db/models/page.py +++ b/apiserver/plane/db/models/page.py @@ -50,6 +50,8 @@ class Page(BaseModel): projects = models.ManyToManyField( "db.Project", related_name="pages", through="db.ProjectPage" ) + moved_to_page = models.UUIDField(null=True, blank=True) + moved_to_project = models.UUIDField(null=True, blank=True) class Meta: verbose_name = "Page" @@ -172,6 +174,7 @@ class PageVersion(BaseModel): description_html = models.TextField(blank=True, default="

") description_stripped = models.TextField(blank=True, null=True) description_json = models.JSONField(default=dict, blank=True) + sub_pages_data = models.JSONField(default=dict, blank=True) class Meta: verbose_name = "Page Version" diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index c97c550ee..c4d097ac8 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -1,6 +1,7 @@ # Python imports import pytz from uuid import uuid4 +from enum import Enum # Django imports from django.conf import settings @@ -17,6 +18,15 @@ from .base import BaseModel ROLE_CHOICES = ((20, "Admin"), (15, "Member"), (5, "Guest")) +class ProjectNetwork(Enum): + SECRET = 0 + PUBLIC = 2 + + @classmethod + def choices(cls): + return [(0, "Secret"), (2, "Public")] + + def get_default_props(): return { "filters": { diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py index 2c0370a61..e1af103f3 100644 --- a/apiserver/plane/db/models/workspace.py +++ b/apiserver/plane/db/models/workspace.py @@ -1,6 +1,9 @@ # Python imports from django.db.models.functions import Ln import pytz +import time +from django.utils import timezone +from typing import Optional, Any, Tuple, Dict # Django imports from django.conf import settings @@ -149,6 +152,34 @@ class Workspace(BaseModel): return self.logo return None + def delete( + self, + using: Optional[str] = None, + soft: bool = True, + *args: Any, + **kwargs: Any + ): + """ + Override the delete method to append epoch timestamp to the slug when soft deleting. + + Args: + using: The database alias to use for the deletion. + soft: Whether to perform a soft delete (True) or hard delete (False). + *args: Additional positional arguments. + **kwargs: Additional keyword arguments. + """ + # Call the parent class's delete method first + result = super().delete(using=using, soft=soft, *args, **kwargs) + + # If it's a soft delete and the model still exists (not hard deleted) + if soft and hasattr(self, 'deleted_at') and self.deleted_at: + # Use the deleted_at timestamp to update the slug + deletion_timestamp: int = int(self.deleted_at.timestamp()) + self.slug = f"{self.slug}__{deletion_timestamp}" + self.save(update_fields=["slug"]) + + return result + class Meta: verbose_name = "Workspace" verbose_name_plural = "Workspaces" @@ -391,7 +422,7 @@ class WorkspaceHomePreference(BaseModel): class WorkspaceUserPreference(BaseModel): """Preference for the workspace for a user""" - class UserPreferenceKeys(models.TextChoices): + class UserPreferenceKeys(models.TextChoices): VIEWS = "views", "Views" ACTIVE_CYCLES = "active_cycles", "Active Cycles" ANALYTICS = "analytics", "Analytics" diff --git a/apiserver/plane/license/api/views/admin.py b/apiserver/plane/license/api/views/admin.py index 97f0e446e..e1e386082 100644 --- a/apiserver/plane/license/api/views/admin.py +++ b/apiserver/plane/license/api/views/admin.py @@ -33,6 +33,7 @@ from plane.authentication.adapter.error import ( AUTHENTICATION_ERROR_CODES, AuthenticationException, ) +from plane.utils.ip_address import get_client_ip class InstanceAdminEndpoint(BaseAPIView): @@ -217,7 +218,7 @@ class InstanceAdminSignUpEndpoint(View): user.is_active = True user.last_active = timezone.now() user.last_login_time = timezone.now() - user.last_login_ip = request.META.get("REMOTE_ADDR") + user.last_login_ip = get_client_ip(request=request) user.last_login_uagent = request.META.get("HTTP_USER_AGENT") user.token_updated_at = timezone.now() user.save() @@ -344,7 +345,7 @@ class InstanceAdminSignInEndpoint(View): user.is_active = True user.last_active = timezone.now() user.last_login_time = timezone.now() - user.last_login_ip = request.META.get("REMOTE_ADDR") + user.last_login_ip = get_client_ip(request=request) user.last_login_uagent = request.META.get("HTTP_USER_AGENT") user.token_updated_at = timezone.now() user.save() diff --git a/apiserver/plane/license/api/views/workspace.py b/apiserver/plane/license/api/views/workspace.py index 607016cc3..8b5eaac6b 100644 --- a/apiserver/plane/license/api/views/workspace.py +++ b/apiserver/plane/license/api/views/workspace.py @@ -109,5 +109,5 @@ class InstanceWorkSpaceEndpoint(BaseAPIView): if "already exists" in str(e): return Response( {"slug": "The workspace with the slug already exists"}, - status=status.HTTP_410_GONE, + status=status.HTTP_409_CONFLICT, ) diff --git a/apiserver/plane/license/management/commands/configure_instance.py b/apiserver/plane/license/management/commands/configure_instance.py index 8458df5df..ce6bbf7a0 100644 --- a/apiserver/plane/license/management/commands/configure_instance.py +++ b/apiserver/plane/license/management/commands/configure_instance.py @@ -71,6 +71,12 @@ class Command(BaseCommand): "category": "GITHUB", "is_encrypted": True, }, + { + "key": "GITHUB_ORGANIZATION_ID", + "value": os.environ.get("GITHUB_ORGANIZATION_ID"), + "category": "GITHUB", + "is_encrypted": False, + }, { "key": "GITLAB_HOST", "value": os.environ.get("GITLAB_HOST"), diff --git a/apiserver/plane/middleware/api_log_middleware.py b/apiserver/plane/middleware/api_log_middleware.py deleted file mode 100644 index c7a0841ad..000000000 --- a/apiserver/plane/middleware/api_log_middleware.py +++ /dev/null @@ -1,39 +0,0 @@ -from plane.db.models import APIActivityLog - - -class APITokenLogMiddleware: - def __init__(self, get_response): - self.get_response = get_response - - def __call__(self, request): - request_body = request.body - response = self.get_response(request) - self.process_request(request, response, request_body) - return response - - def process_request(self, request, response, request_body): - api_key_header = "X-Api-Key" - api_key = request.headers.get(api_key_header) - # If the API key is present, log the request - if api_key: - try: - APIActivityLog.objects.create( - token_identifier=api_key, - path=request.path, - method=request.method, - query_params=request.META.get("QUERY_STRING", ""), - headers=str(request.headers), - body=(request_body.decode("utf-8") if request_body else None), - response_body=( - response.content.decode("utf-8") if response.content else None - ), - response_code=response.status_code, - ip_address=request.META.get("REMOTE_ADDR", None), - user_agent=request.META.get("HTTP_USER_AGENT", None), - ) - - except Exception as e: - print(e) - # If the token does not exist, you can decide whether to log this as an invalid attempt - - return None diff --git a/apiserver/plane/middleware/logger.py b/apiserver/plane/middleware/logger.py new file mode 100644 index 000000000..166de17c2 --- /dev/null +++ b/apiserver/plane/middleware/logger.py @@ -0,0 +1,111 @@ +# Python imports +import logging +import time + +# Django imports +from django.http import HttpRequest + +# Third party imports +from rest_framework.request import Request + +# Module imports +from plane.utils.ip_address import get_client_ip +from plane.db.models import APIActivityLog + + +api_logger = logging.getLogger("plane.api.request") + + +class RequestLoggerMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def _should_log_route(self, request: Request | HttpRequest) -> bool: + """ + Determines whether a route should be logged based on the request and status code. + """ + # Don't log health checks + if request.path == "/" and request.method == "GET": + return False + return True + + def __call__(self, request): + # get the start time + start_time = time.time() + + # Get the response + response = self.get_response(request) + + # calculate the duration + duration = time.time() - start_time + + # Check if logging is required + log_true = self._should_log_route(request=request) + + # If logging is not required, return the response + if not log_true: + return response + + user_id = ( + request.user.id + if getattr(request, "user") + and getattr(request.user, "is_authenticated", False) + else None + ) + + user_agent = request.META.get("HTTP_USER_AGENT", "") + + # Log the request information + api_logger.info( + f"{request.method} {request.get_full_path()} {response.status_code}", + extra={ + "path": request.path, + "method": request.method, + "status_code": response.status_code, + "duration_ms": int(duration * 1000), + "remote_addr": get_client_ip(request), + "user_agent": user_agent, + "user_id": user_id, + }, + ) + + # return the response + return response + + +class APITokenLogMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + request_body = request.body + response = self.get_response(request) + self.process_request(request, response, request_body) + return response + + def process_request(self, request, response, request_body): + api_key_header = "X-Api-Key" + api_key = request.headers.get(api_key_header) + # If the API key is present, log the request + if api_key: + try: + APIActivityLog.objects.create( + token_identifier=api_key, + path=request.path, + method=request.method, + query_params=request.META.get("QUERY_STRING", ""), + headers=str(request.headers), + body=(request_body.decode("utf-8") if request_body else None), + response_body=( + response.content.decode("utf-8") if response.content else None + ), + response_code=response.status_code, + ip_address=get_client_ip(request=request), + user_agent=request.META.get("HTTP_USER_AGENT", None), + ) + + except Exception as e: + api_logger.exception(e) + # If the token does not exist, you can decide whether to log this as an invalid attempt + + return None diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 10776f9af..444ec0a4f 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -7,13 +7,9 @@ from urllib.parse import urlparse # Third party imports import dj_database_url -import sentry_sdk # Django imports from django.core.management.utils import get_random_secret_key -from sentry_sdk.integrations.celery import CeleryIntegration -from sentry_sdk.integrations.django import DjangoIntegration -from sentry_sdk.integrations.redis import RedisIntegration from corsheaders.defaults import default_headers @@ -26,7 +22,7 @@ SECRET_KEY = os.environ.get("SECRET_KEY", get_random_secret_key()) DEBUG = int(os.environ.get("DEBUG", "0")) # Allowed Hosts -ALLOWED_HOSTS = ["*"] +ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "*").split(",") # Application definition INSTALLED_APPS = [ @@ -62,7 +58,8 @@ MIDDLEWARE = [ "django.middleware.clickjacking.XFrameOptionsMiddleware", "crum.CurrentRequestUserMiddleware", "django.middleware.gzip.GZipMiddleware", - "plane.middleware.api_log_middleware.APITokenLogMiddleware", + "plane.middleware.logger.APITokenLogMiddleware", + "plane.middleware.logger.RequestLoggerMiddleware", ] # Rest Framework settings @@ -267,25 +264,6 @@ CELERY_IMPORTS = ( "plane.bgtasks.issue_description_version_sync", ) -# Sentry Settings -# Enable Sentry Settings -if bool(os.environ.get("SENTRY_DSN", False)) and os.environ.get( - "SENTRY_DSN" -).startswith("https://"): - sentry_sdk.init( - dsn=os.environ.get("SENTRY_DSN", ""), - integrations=[ - DjangoIntegration(), - RedisIntegration(), - CeleryIntegration(monitor_beat_tasks=True), - ], - traces_sample_rate=1, - send_default_pii=True, - environment=os.environ.get("SENTRY_ENVIRONMENT", "development"), - profiles_sample_rate=float(os.environ.get("SENTRY_PROFILE_SAMPLE_RATE", 0)), - ) - - FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880)) # Unsplash Access key @@ -337,7 +315,7 @@ ADMIN_BASE_URL = os.environ.get("ADMIN_BASE_URL", None) SPACE_BASE_URL = os.environ.get("SPACE_BASE_URL", None) APP_BASE_URL = os.environ.get("APP_BASE_URL") LIVE_BASE_URL = os.environ.get("LIVE_BASE_URL") - +WEB_URL = os.environ.get("WEB_URL") HARD_DELETE_AFTER_DAYS = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 60)) @@ -413,4 +391,8 @@ ATTACHMENT_MIME_TYPES = [ "text/xml", "text/csv", "application/xml", + # SQL + "application/x-sql", + # Gzip + "application/x-gzip", ] diff --git a/apiserver/plane/settings/local.py b/apiserver/plane/settings/local.py index d33115e2b..db60501f7 100644 --- a/apiserver/plane/settings/local.py +++ b/apiserver/plane/settings/local.py @@ -37,26 +37,41 @@ if not os.path.exists(LOG_DIR): LOGGING = { "version": 1, - "disable_existing_loggers": False, + "disable_existing_loggers": True, "formatters": { "verbose": { "format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}", "style": "{", - } + }, + "json": { + "()": "pythonjsonlogger.jsonlogger.JsonFormatter", + "fmt": "%(levelname)s %(asctime)s %(module)s %(name)s %(message)s", + }, }, "handlers": { "console": { "level": "DEBUG", "class": "logging.StreamHandler", - "formatter": "verbose", + "formatter": "json", } }, "loggers": { - "django.request": { + "plane.api.request": { + "level": "INFO", + "handlers": ["console"], + "propagate": False, + }, + "plane.api": {"level": "INFO", "handlers": ["console"], "propagate": False}, + "plane.worker": {"level": "INFO", "handlers": ["console"], "propagate": False}, + "plane.exception": { + "level": "ERROR", + "handlers": ["console"], + "propagate": False, + }, + "plane.external": { + "level": "INFO", "handlers": ["console"], - "level": "DEBUG", "propagate": False, }, - "plane": {"handlers": ["console"], "level": "DEBUG", "propagate": False}, }, } diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py index 9390a2847..abd95d006 100644 --- a/apiserver/plane/settings/production.py +++ b/apiserver/plane/settings/production.py @@ -26,11 +26,10 @@ if not os.path.exists(LOG_DIR): # Logging configuration LOGGING = { "version": 1, - "disable_existing_loggers": False, + "disable_existing_loggers": True, "formatters": { "verbose": { - "format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}", - "style": "{", + "format": "%(asctime)s [%(process)d] %(levelname)s %(name)s: %(message)s" }, "json": { "()": "pythonjsonlogger.jsonlogger.JsonFormatter", @@ -40,7 +39,7 @@ LOGGING = { "handlers": { "console": { "class": "logging.StreamHandler", - "formatter": "verbose", + "formatter": "json", "level": "INFO", }, "file": { @@ -59,16 +58,30 @@ LOGGING = { }, }, "loggers": { - "django": {"handlers": ["console", "file"], "level": "INFO", "propagate": True}, - "django.request": { - "handlers": ["console", "file"], - "level": "INFO", + "plane.api.request": { + "level": "DEBUG" if DEBUG else "INFO", + "handlers": ["console"], "propagate": False, }, - "plane": { + "plane.api": { + "level": "DEBUG" if DEBUG else "INFO", + "handlers": ["console"], + "propagate": False, + }, + "plane.worker": { + "level": "DEBUG" if DEBUG else "INFO", + "handlers": ["console"], + "propagate": False, + }, + "plane.exception": { "level": "DEBUG" if DEBUG else "ERROR", "handlers": ["console", "file"], "propagate": False, }, + "plane.external": { + "level": "INFO", + "handlers": ["console"], + "propagate": False, + }, }, } diff --git a/apiserver/plane/space/utils/grouper.py b/apiserver/plane/space/utils/grouper.py index 274058842..b334999de 100644 --- a/apiserver/plane/space/utils/grouper.py +++ b/apiserver/plane/space/utils/grouper.py @@ -3,6 +3,9 @@ from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField from django.db.models import Q, UUIDField, Value, F, Case, When, JSONField, CharField from django.db.models.functions import Coalesce, JSONObject, Concat +from django.db.models import QuerySet + +from typing import List, Optional, Dict, Any, Union # Module imports from plane.db.models import ( @@ -17,13 +20,25 @@ from plane.db.models import ( ) -def issue_queryset_grouper(queryset, group_by, sub_group_by): +def issue_queryset_grouper( + queryset: QuerySet[Issue], group_by: Optional[str], sub_group_by: Optional[str] +) -> QuerySet[Issue]: FIELD_MAPPER = { "label_ids": "labels__id", "assignee_ids": "assignees__id", "module_ids": "issue_module__module_id", } + GROUP_FILTER_MAPPER = { + "assignees__id": Q(issue_assignee__deleted_at__isnull=True), + "labels__id": Q(label_issue__deleted_at__isnull=True), + "issue_module__module_id": Q(issue_module__deleted_at__isnull=True), + } + + for group_key in [group_by, sub_group_by]: + if group_key in GROUP_FILTER_MAPPER: + queryset = queryset.filter(GROUP_FILTER_MAPPER[group_key]) + annotations_map = { "assignee_ids": ( "assignees__id", @@ -50,7 +65,9 @@ def issue_queryset_grouper(queryset, group_by, sub_group_by): return queryset.annotate(**default_annotations) -def issue_on_results(issues, group_by, sub_group_by): +def issue_on_results( + issues: QuerySet[Issue], group_by: Optional[str], sub_group_by: Optional[str] +) -> List[Dict[str, Any]]: FIELD_MAPPER = { "labels__id": "label_ids", "assignees__id": "assignee_ids", @@ -160,7 +177,12 @@ def issue_on_results(issues, group_by, sub_group_by): return issues -def issue_group_values(field, slug, project_id=None, filters=dict): +def issue_group_values( + field: str, + slug: str, + project_id: Optional[str] = None, + filters: Dict[str, Any] = {}, +) -> List[Union[str, Any]]: if field == "state_id": queryset = State.objects.filter( is_triage=False, workspace__slug=slug diff --git a/apiserver/plane/space/views/asset.py b/apiserver/plane/space/views/asset.py index 3e1d4d6f7..d2537671f 100644 --- a/apiserver/plane/space/views/asset.py +++ b/apiserver/plane/space/views/asset.py @@ -96,7 +96,7 @@ class EntityAssetEndpoint(BaseAPIView): if type not in allowed_types: return Response( { - "error": "Invalid file type. Only JPEG and PNG files are allowed.", + "error": "Invalid file type. Only JPEG, PNG, WebP, JPG and GIF files are allowed.", "status": False, }, status=status.HTTP_400_BAD_REQUEST, diff --git a/apiserver/plane/tests/api/test_workspace.py b/apiserver/plane/tests/api/test_workspace.py index c1e487fbe..d63eab2e0 100644 --- a/apiserver/plane/tests/api/test_workspace.py +++ b/apiserver/plane/tests/api/test_workspace.py @@ -41,4 +41,4 @@ class WorkSpaceCreateReadUpdateDelete(AuthenticatedAPITest): response = self.client.post( url, {"name": "Plane", "slug": "pla-ne"}, format="json" ) - self.assertEqual(response.status_code, status.HTTP_410_GONE) + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) diff --git a/apiserver/plane/utils/exception_logger.py b/apiserver/plane/utils/exception_logger.py index f802f286b..6ccccd32d 100644 --- a/apiserver/plane/utils/exception_logger.py +++ b/apiserver/plane/utils/exception_logger.py @@ -5,19 +5,14 @@ import traceback # Django imports from django.conf import settings -# Third party imports -from sentry_sdk import capture_exception - def log_exception(e): # Log the error - logger = logging.getLogger("plane") - logger.error(e) + logger = logging.getLogger("plane.exception") + logger.exception(e) if settings.DEBUG: # Print the traceback if in debug mode print(traceback.format_exc()) - # Capture in sentry if configured - capture_exception(e) return diff --git a/apiserver/plane/utils/grouper.py b/apiserver/plane/utils/grouper.py index e139cdcc5..89e154a7f 100644 --- a/apiserver/plane/utils/grouper.py +++ b/apiserver/plane/utils/grouper.py @@ -1,7 +1,7 @@ # Django imports from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField -from django.db.models import Q, UUIDField, Value +from django.db.models import Q, UUIDField, Value, QuerySet from django.db.models.functions import Coalesce # Module imports @@ -15,16 +15,31 @@ from plane.db.models import ( State, WorkspaceMember, ) +from typing import Optional, Dict, Tuple, Any, Union, List -def issue_queryset_grouper(queryset, group_by, sub_group_by): - FIELD_MAPPER = { +def issue_queryset_grouper( + queryset: QuerySet[Issue], + group_by: Optional[str], + sub_group_by: Optional[str], +) -> QuerySet[Issue]: + FIELD_MAPPER: Dict[str, str] = { "label_ids": "labels__id", "assignee_ids": "assignees__id", "module_ids": "issue_module__module_id", } - annotations_map = { + GROUP_FILTER_MAPPER: Dict[str, Q] = { + "assignees__id": Q(issue_assignee__deleted_at__isnull=True), + "labels__id": Q(label_issue__deleted_at__isnull=True), + "issue_module__module_id": Q(issue_module__deleted_at__isnull=True), + } + + for group_key in [group_by, sub_group_by]: + if group_key in GROUP_FILTER_MAPPER: + queryset = queryset.filter(GROUP_FILTER_MAPPER[group_key]) + + annotations_map: Dict[str, Tuple[str, Q]] = { "assignee_ids": ( "assignees__id", ~Q(assignees__id__isnull=True) & Q(issue_assignee__deleted_at__isnull=True), @@ -42,7 +57,8 @@ def issue_queryset_grouper(queryset, group_by, sub_group_by): ), ), } - default_annotations = { + + default_annotations: Dict[str, Any] = { key: Coalesce( ArrayAgg(field, distinct=True, filter=condition), Value([], output_field=ArrayField(UUIDField())), @@ -54,16 +70,20 @@ def issue_queryset_grouper(queryset, group_by, sub_group_by): return queryset.annotate(**default_annotations) -def issue_on_results(issues, group_by, sub_group_by): - FIELD_MAPPER = { +def issue_on_results( + issues: QuerySet[Issue], + group_by: Optional[str], + sub_group_by: Optional[str], +) -> List[Dict[str, Any]]: + FIELD_MAPPER: Dict[str, str] = { "labels__id": "label_ids", "assignees__id": "assignee_ids", "issue_module__module_id": "module_ids", } - original_list = ["assignee_ids", "label_ids", "module_ids"] + original_list: List[str] = ["assignee_ids", "label_ids", "module_ids"] - required_fields = [ + required_fields: List[str] = [ "id", "name", "state_id", @@ -98,62 +118,72 @@ def issue_on_results(issues, group_by, sub_group_by): original_list.append(sub_group_by) required_fields.extend(original_list) - return issues.values(*required_fields) + return list(issues.values(*required_fields)) -def issue_group_values(field, slug, project_id=None, filters=dict): +def issue_group_values( + field: str, + slug: str, + project_id: Optional[str] = None, + filters: Dict[str, Any] = {}, +) -> List[Union[str, Any]]: if field == "state_id": queryset = State.objects.filter( is_triage=False, workspace__slug=slug ).values_list("id", flat=True) if project_id: return list(queryset.filter(project_id=project_id)) - else: - return list(queryset) + return list(queryset) + if field == "labels__id": queryset = Label.objects.filter(workspace__slug=slug).values_list( "id", flat=True ) if project_id: return list(queryset.filter(project_id=project_id)) + ["None"] - else: - return list(queryset) + ["None"] + return list(queryset) + ["None"] + if field == "assignees__id": if project_id: - return ProjectMember.objects.filter( - workspace__slug=slug, project_id=project_id, is_active=True - ).values_list("member_id", flat=True) - else: return list( - WorkspaceMember.objects.filter( - workspace__slug=slug, is_active=True + ProjectMember.objects.filter( + workspace__slug=slug, project_id=project_id, is_active=True ).values_list("member_id", flat=True) ) + return list( + WorkspaceMember.objects.filter( + workspace__slug=slug, is_active=True + ).values_list("member_id", flat=True) + ) + if field == "issue_module__module_id": queryset = Module.objects.filter(workspace__slug=slug).values_list( "id", flat=True ) if project_id: return list(queryset.filter(project_id=project_id)) + ["None"] - else: - return list(queryset) + ["None"] + return list(queryset) + ["None"] + if field == "cycle_id": queryset = Cycle.objects.filter(workspace__slug=slug).values_list( "id", flat=True ) if project_id: return list(queryset.filter(project_id=project_id)) + ["None"] - else: - return list(queryset) + ["None"] + return list(queryset) + ["None"] + if field == "project_id": queryset = Project.objects.filter(workspace__slug=slug).values_list( "id", flat=True ) return list(queryset) + if field == "priority": return ["low", "medium", "high", "urgent", "none"] + if field == "state__group": return ["backlog", "unstarted", "started", "completed", "cancelled"] + if field == "target_date": queryset = ( Issue.issue_objects.filter(workspace__slug=slug) @@ -163,8 +193,8 @@ def issue_group_values(field, slug, project_id=None, filters=dict): ) if project_id: return list(queryset.filter(project_id=project_id)) - else: - return list(queryset) + return list(queryset) + if field == "start_date": queryset = ( Issue.issue_objects.filter(workspace__slug=slug) @@ -174,8 +204,7 @@ def issue_group_values(field, slug, project_id=None, filters=dict): ) if project_id: return list(queryset.filter(project_id=project_id)) - else: - return list(queryset) + return list(queryset) if field == "created_by": queryset = ( @@ -186,7 +215,6 @@ def issue_group_values(field, slug, project_id=None, filters=dict): ) if project_id: return list(queryset.filter(project_id=project_id)) - else: - return list(queryset) + return list(queryset) return [] diff --git a/apiserver/plane/utils/host.py b/apiserver/plane/utils/host.py index 4046c1e20..c4914d7ff 100644 --- a/apiserver/plane/utils/host.py +++ b/apiserver/plane/utils/host.py @@ -1,18 +1,21 @@ -# Python imports -from urllib.parse import urlsplit - # Django imports from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.http import HttpRequest +# Third party imports +from rest_framework.request import Request -def base_host(request, is_admin=False, is_space=False, is_app=False): +# Module imports +from plane.utils.ip_address import get_client_ip + +def base_host(request: Request | HttpRequest, is_admin: bool = False, is_space: bool = False, is_app: bool = False) -> str: """Utility function to return host / origin from the request""" # Calculate the base origin from request - base_origin = str( - request.META.get("HTTP_ORIGIN") - or f"{urlsplit(request.META.get('HTTP_REFERER')).scheme}://{urlsplit(request.META.get('HTTP_REFERER')).netloc}" - or f"""{"https" if request.is_secure() else "http"}://{request.get_host()}""" - ) + base_origin = settings.WEB_URL or settings.APP_BASE_URL + + if not base_origin: + raise ImproperlyConfigured("APP_BASE_URL or WEB_URL is not set") # Admin redirections if is_admin: @@ -38,5 +41,5 @@ def base_host(request, is_admin=False, is_space=False, is_app=False): return base_origin -def user_ip(request): - return str(request.META.get("REMOTE_ADDR")) +def user_ip(request: Request | HttpRequest) -> str: + return get_client_ip(request=request) diff --git a/apiserver/plane/utils/path_validator.py b/apiserver/plane/utils/path_validator.py new file mode 100644 index 000000000..ba81e9cab --- /dev/null +++ b/apiserver/plane/utils/path_validator.py @@ -0,0 +1,21 @@ +# Python imports +from urllib.parse import urlparse + + +def validate_next_path(next_path: str) -> str: + """Validates that next_path is a valid path and extracts only the path component.""" + parsed_url = urlparse(next_path) + + # Ensure next_path is not an absolute URL + if parsed_url.scheme or parsed_url.netloc: + next_path = parsed_url.path # Extract only the path component + + # Ensure it starts with a forward slash (indicating a valid relative path) + if not next_path.startswith("/"): + return "" + + # Ensure it does not contain dangerous path traversal sequences + if ".." in next_path: + return "" + + return next_path diff --git a/apiserver/plane/utils/uuid.py b/apiserver/plane/utils/uuid.py new file mode 100644 index 000000000..03f695fdb --- /dev/null +++ b/apiserver/plane/utils/uuid.py @@ -0,0 +1,22 @@ +# Python imports +import uuid +import hashlib + + +def is_valid_uuid(uuid_str): + """Check if a string is a valid UUID version 4""" + try: + uuid_obj = uuid.UUID(uuid_str) + return uuid_obj.version == 4 + except ValueError: + return False + + +def convert_uuid_to_integer(uuid_val: uuid.UUID) -> int: + """Convert a UUID to a 64-bit signed integer""" + # Ensure UUID is a string + uuid_value: str = str(uuid_val) + # Hash to 64-bit signed int + h: bytes = hashlib.sha256(uuid_value.encode()).digest() + bigint: int = int.from_bytes(h[:8], byteorder="big", signed=True) + return bigint diff --git a/apiserver/plane/utils/valid_uuid.py b/apiserver/plane/utils/valid_uuid.py deleted file mode 100644 index a44105136..000000000 --- a/apiserver/plane/utils/valid_uuid.py +++ /dev/null @@ -1,8 +0,0 @@ -import uuid - -def is_valid_uuid(uuid_str): - try: - uuid.UUID(uuid_str, version=4) - return True - except ValueError: - return False diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index ff220984d..ad48a32ec 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -26,8 +26,6 @@ faker==25.0.0 django-filter==24.2 # json model jsonmodels==2.7.0 -# sentry -sentry-sdk==2.8.0 # storage django-storages==1.14.2 # user management @@ -45,7 +43,7 @@ scout-apm==3.1.0 # xlsx generation openpyxl==3.1.2 # logging -python-json-logger==2.0.7 +python-json-logger==3.3.0 # html parser beautifulsoup4==4.12.3 # analytics diff --git a/apiserver/requirements/production.txt b/apiserver/requirements/production.txt index ed763c0df..f09c60806 100644 --- a/apiserver/requirements/production.txt +++ b/apiserver/requirements/production.txt @@ -1,3 +1,3 @@ -r base.txt # server -gunicorn==22.0.0 +gunicorn==23.0.0 diff --git a/app.json b/app.json index 600b524d2..6b1ab7528 100644 --- a/app.json +++ b/app.json @@ -6,16 +6,8 @@ "website": "https://plane.so/", "success_url": "/", "stack": "heroku-22", - "keywords": [ - "plane", - "project management", - "django", - "next" - ], - "addons": [ - "heroku-postgresql:mini", - "heroku-redis:mini" - ], + "keywords": ["plane", "project management", "django", "next"], + "addons": ["heroku-postgresql:mini", "heroku-redis:mini"], "buildpacks": [ { "url": "https://github.com/heroku/heroku-buildpack-python.git" @@ -61,10 +53,6 @@ "description": "AWS Bucket Name to use for S3", "value": "" }, - "SENTRY_DSN": { - "description": "", - "value": "" - }, "WEB_URL": { "description": "Web URL for Plane this will be used for redirections in the emails", "value": "" @@ -82,4 +70,4 @@ "value": "" } } -} \ No newline at end of file +} diff --git a/deploy/selfhost/docker-compose.yml b/deploy/selfhost/docker-compose.yml index 8e6dab531..baca1c3ca 100644 --- a/deploy/selfhost/docker-compose.yml +++ b/deploy/selfhost/docker-compose.yml @@ -42,8 +42,6 @@ x-live-env: &live-env x-app-env: &app-env WEB_URL: ${WEB_URL:-http://localhost} DEBUG: ${DEBUG:-0} - SENTRY_DSN: ${SENTRY_DSN} - SENTRY_ENVIRONMENT: ${SENTRY_ENVIRONMENT:-production} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS} GUNICORN_WORKERS: 1 USE_MINIO: ${USE_MINIO:-1} @@ -55,7 +53,7 @@ x-app-env: &app-env services: web: - image: ${DOCKERHUB_USER:-makeplane}/plane-frontend:${APP_RELEASE:-stable} + image: artifacts.plane.so/makeplane/plane-frontend:${APP_RELEASE:-stable} command: node web/server.js web deploy: replicas: ${WEB_REPLICAS:-1} @@ -66,7 +64,7 @@ services: - worker space: - image: ${DOCKERHUB_USER:-makeplane}/plane-space:${APP_RELEASE:-stable} + image: artifacts.plane.so/makeplane/plane-space:${APP_RELEASE:-stable} command: node space/server.js space deploy: replicas: ${SPACE_REPLICAS:-1} @@ -78,7 +76,7 @@ services: - web admin: - image: ${DOCKERHUB_USER:-makeplane}/plane-admin:${APP_RELEASE:-stable} + image: artifacts.plane.so/makeplane/plane-admin:${APP_RELEASE:-stable} command: node admin/server.js admin deploy: replicas: ${ADMIN_REPLICAS:-1} @@ -89,7 +87,7 @@ services: - web live: - image: ${DOCKERHUB_USER:-makeplane}/plane-live:${APP_RELEASE:-stable} + image: artifacts.plane.so/makeplane/plane-live:${APP_RELEASE:-stable} command: node live/dist/server.js live environment: <<: [*live-env] @@ -102,7 +100,7 @@ services: - web api: - image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable} + image: artifacts.plane.so/makeplane/plane-backend:${APP_RELEASE:-stable} command: ./bin/docker-entrypoint-api.sh deploy: replicas: ${API_REPLICAS:-1} @@ -118,7 +116,7 @@ services: - plane-mq worker: - image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable} + image: artifacts.plane.so/makeplane/plane-backend:${APP_RELEASE:-stable} command: ./bin/docker-entrypoint-worker.sh deploy: replicas: ${WORKER_REPLICAS:-1} @@ -135,7 +133,7 @@ services: - plane-mq beat-worker: - image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable} + image: artifacts.plane.so/makeplane/plane-backend:${APP_RELEASE:-stable} command: ./bin/docker-entrypoint-beat.sh deploy: replicas: ${BEAT_WORKER_REPLICAS:-1} @@ -152,7 +150,7 @@ services: - plane-mq migrator: - image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable} + image: artifacts.plane.so/makeplane/plane-backend:${APP_RELEASE:-stable} command: ./bin/docker-entrypoint-migrator.sh deploy: replicas: 1 @@ -214,7 +212,7 @@ services: # Comment this if you already have a reverse proxy running proxy: - image: ${DOCKERHUB_USER:-makeplane}/plane-proxy:${APP_RELEASE:-stable} + image: artifacts.plane.so/makeplane/plane-proxy:${APP_RELEASE:-stable} ports: - target: 80 published: ${NGINX_PORT:-80} diff --git a/deploy/selfhost/install.sh b/deploy/selfhost/install.sh index 1ee0d6f35..5ce2d5964 100755 --- a/deploy/selfhost/install.sh +++ b/deploy/selfhost/install.sh @@ -5,7 +5,7 @@ SCRIPT_DIR=$PWD SERVICE_FOLDER=plane-app PLANE_INSTALL_DIR=$PWD/$SERVICE_FOLDER export APP_RELEASE=stable -export DOCKERHUB_USER=makeplane +export DOCKERHUB_USER=artifacts.plane.so/makeplane export PULL_POLICY=${PULL_POLICY:-if_not_present} export GH_REPO=makeplane/plane export RELEASE_DOWNLOAD_URL="https://github.com/$GH_REPO/releases/download" @@ -631,7 +631,7 @@ if [ -f "$DOCKER_ENV_PATH" ]; then CUSTOM_BUILD=$(getEnvValue "CUSTOM_BUILD" "$DOCKER_ENV_PATH") if [ -z "$DOCKERHUB_USER" ]; then - DOCKERHUB_USER=makeplane + DOCKERHUB_USER=artifacts.plane.so/makeplane updateEnvFile "DOCKERHUB_USER" "$DOCKERHUB_USER" "$DOCKER_ENV_PATH" fi diff --git a/deploy/selfhost/swarm.sh b/deploy/selfhost/swarm.sh index 49fe77576..c58f05e51 100755 --- a/deploy/selfhost/swarm.sh +++ b/deploy/selfhost/swarm.sh @@ -5,7 +5,7 @@ SERVICE_FOLDER=plane-app SCRIPT_DIR=$PWD PLANE_INSTALL_DIR=$PWD/$SERVICE_FOLDER export APP_RELEASE="stable" -export DOCKERHUB_USER=makeplane +export DOCKERHUB_USER=artifacts.plane.so/makeplane export GH_REPO=makeplane/plane export RELEASE_DOWNLOAD_URL="https://github.com/$GH_REPO/releases/download" @@ -596,7 +596,7 @@ if [ -f "$DOCKER_ENV_PATH" ]; then APP_RELEASE=$(getEnvValue "APP_RELEASE" "$DOCKER_ENV_PATH") if [ -z "$DOCKERHUB_USER" ]; then - DOCKERHUB_USER=makeplane + DOCKERHUB_USER=artifacts.plane.so/makeplane updateEnvFile "DOCKERHUB_USER" "$DOCKERHUB_USER" "$DOCKER_ENV_PATH" fi diff --git a/deploy/selfhost/variables.env b/deploy/selfhost/variables.env index 9b3d6b99a..78031a4ac 100644 --- a/deploy/selfhost/variables.env +++ b/deploy/selfhost/variables.env @@ -12,8 +12,6 @@ LIVE_REPLICAS=1 NGINX_PORT=80 WEB_URL=http://${APP_DOMAIN} DEBUG=0 -SENTRY_DSN= -SENTRY_ENVIRONMENT=production CORS_ALLOWED_ORIGINS=http://${APP_DOMAIN} API_BASE_URL=http://api:8000 @@ -62,4 +60,4 @@ GUNICORN_WORKERS=1 MINIO_ENDPOINT_SSL=0 # API key rate limit -API_KEY_RATE_LIMIT="60/minute" +API_KEY_RATE_LIMIT=60/minute diff --git a/live/package.json b/live/package.json index cd05ed0c0..9616b1513 100644 --- a/live/package.json +++ b/live/package.json @@ -1,6 +1,6 @@ { "name": "live", - "version": "0.25.3", + "version": "0.26.0", "license": "AGPL-3.0", "description": "A realtime collaborative server powers Plane's rich text editor", "main": "./src/server.ts", @@ -23,8 +23,6 @@ "@plane/constants": "*", "@plane/editor": "*", "@plane/types": "*", - "@sentry/node": "^9.0.1", - "@sentry/profiling-node": "^8.28.0", "@tiptap/core": "2.10.4", "@tiptap/html": "2.11.0", "axios": "^1.8.3", diff --git a/live/src/core/config/sentry-config.ts b/live/src/core/config/sentry-config.ts deleted file mode 100644 index f879763ec..000000000 --- a/live/src/core/config/sentry-config.ts +++ /dev/null @@ -1,19 +0,0 @@ -import * as Sentry from "@sentry/node"; -import { nodeProfilingIntegration } from "@sentry/profiling-node"; - -// Ensure to call this before importing any other modules! -Sentry.init({ - dsn: process.env.LIVE_SENTRY_DSN, - environment: process.env.LIVE_SENTRY_ENVIRONMENT || "development", - - integrations: [ - // Add our Profiling integration - nodeProfilingIntegration(), - ], - // Add Tracing by setting tracesSampleRate - // We recommend adjusting this value in production - tracesSampleRate: Number(process.env.LIVE_SENTRY_TRACES_SAMPLE_RATE) || 0.5, - // Set sampling rate for profiling - // This is relative to tracesSampleRate - profilesSampleRate: 1.0, -}); diff --git a/live/src/server.ts b/live/src/server.ts index 93f56bdb5..6a47ef791 100644 --- a/live/src/server.ts +++ b/live/src/server.ts @@ -1,11 +1,8 @@ -import * as Sentry from "@sentry/node"; import compression from "compression"; import cors from "cors"; import expressWs from "express-ws"; import express from "express"; import helmet from "helmet"; -// config -import "@/core/config/sentry-config.js"; // hocuspocus server import { getHocusPocusServer } from "@/core/hocuspocus-server.js"; // helpers @@ -15,7 +12,7 @@ import { errorHandler } from "@/core/helpers/error-handler.js"; // types import { TConvertDocumentRequestBody } from "@/core/types/common.js"; -const app = express(); +const app: any = express(); expressWs(app); app.set("port", process.env.PORT || 3000); @@ -92,8 +89,6 @@ app.use((_req, res) => { res.status(404).send("Not Found"); }); -Sentry.setupExpressErrorHandler(app); - app.use(errorHandler); const liveServer = app.listen(app.get("port"), () => { diff --git a/live/tsconfig.json b/live/tsconfig.json index 54de4c245..622dc2232 100644 --- a/live/tsconfig.json +++ b/live/tsconfig.json @@ -16,9 +16,6 @@ "skipLibCheck": true, "sourceMap": true, "inlineSources": true, - // Set `sourceRoot` to "/" to strip the build path prefix - // from generated source code references. - // This improves issue grouping in Sentry. "sourceRoot": "/" }, "include": ["src/**/*.ts", "tsup.config.ts"], diff --git a/package.json b/package.json index 0896da124..cfcc346bb 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "plane", "description": "Open-source project management that unlocks customer value", "repository": "https://github.com/makeplane/plane.git", - "version": "0.25.3", + "version": "0.26.0", "license": "AGPL-3.0", "private": true, "workspaces": [ @@ -24,7 +24,7 @@ "devDependencies": { "prettier": "latest", "prettier-plugin-tailwindcss": "^0.5.4", - "turbo": "^2.4.2" + "turbo": "^2.5.0" }, "resolutions": { "nanoid": "3.3.8", diff --git a/packages/constants/package.json b/packages/constants/package.json index 6ef1a9edc..ede41908d 100644 --- a/packages/constants/package.json +++ b/packages/constants/package.json @@ -1,6 +1,6 @@ { "name": "@plane/constants", - "version": "0.25.3", + "version": "0.26.0", "private": true, "main": "./src/index.ts", "license": "AGPL-3.0" diff --git a/packages/constants/src/chart.ts b/packages/constants/src/chart.ts index f921b8b37..bddd0fd38 100644 --- a/packages/constants/src/chart.ts +++ b/packages/constants/src/chart.ts @@ -1,2 +1,2 @@ export const LABEL_CLASSNAME = "uppercase text-custom-text-300/60 text-sm tracking-wide"; -export const AXIS_LINE_CLASSNAME = "text-custom-text-400/70"; +export const AXIS_LABEL_CLASSNAME = "uppercase text-custom-text-300/60 text-sm tracking-wide"; diff --git a/packages/constants/src/emoji.ts b/packages/constants/src/emoji.ts index 48be02b01..9ba145e1c 100644 --- a/packages/constants/src/emoji.ts +++ b/packages/constants/src/emoji.ts @@ -8,3 +8,18 @@ export const ISSUE_REACTION_EMOJI_CODES = [ "9992", "128064", ]; + +export const RANDOM_EMOJI_CODES = [ + "8986", + "9200", + "128204", + "127773", + "127891", + "128076", + "128077", + "128187", + "128188", + "128512", + "128522", + "128578", +]; diff --git a/packages/constants/src/inbox.ts b/packages/constants/src/inbox.ts index cf5270a04..2d94c1f04 100644 --- a/packages/constants/src/inbox.ts +++ b/packages/constants/src/inbox.ts @@ -1,91 +1,97 @@ import { TInboxDuplicateIssueDetails, TIssue } from "@plane/types"; export enum EInboxIssueCurrentTab { - OPEN = "open", - CLOSED = "closed", + OPEN = "open", + CLOSED = "closed", } export enum EInboxIssueStatus { - PENDING = -2, - DECLINED = -1, - SNOOZED = 0, - ACCEPTED = 1, - DUPLICATE = 2, + PENDING = -2, + DECLINED = -1, + SNOOZED = 0, + ACCEPTED = 1, + DUPLICATE = 2, +} + +export enum EInboxIssueSource { + IN_APP = "IN_APP", + FORMS = "FORMS", + EMAIL = "EMAIL", } export type TInboxIssueCurrentTab = EInboxIssueCurrentTab; export type TInboxIssueStatus = EInboxIssueStatus; export type TInboxIssue = { - id: string; - status: TInboxIssueStatus; - snoozed_till: Date | null; - duplicate_to: string | undefined; - source: string; - issue: TIssue; - created_by: string; - duplicate_issue_detail: TInboxDuplicateIssueDetails | undefined; + id: string; + status: TInboxIssueStatus; + snoozed_till: Date | null; + duplicate_to: string | undefined; + source: EInboxIssueSource | undefined; + issue: TIssue; + created_by: string; + duplicate_issue_detail: TInboxDuplicateIssueDetails | undefined; }; export const INBOX_STATUS: { - key: string; - status: TInboxIssueStatus; - i18n_title: string; - i18n_description: () => string; + key: string; + status: TInboxIssueStatus; + i18n_title: string; + i18n_description: () => string; }[] = [ - { - key: "pending", - i18n_title: "inbox_issue.status.pending.title", - status: EInboxIssueStatus.PENDING, - i18n_description: () => `inbox_issue.status.pending.description`, - }, - { - key: "declined", - i18n_title: "inbox_issue.status.declined.title", - status: EInboxIssueStatus.DECLINED, - i18n_description: () => `inbox_issue.status.declined.description`, - }, - { - key: "snoozed", - i18n_title: "inbox_issue.status.snoozed.title", - status: EInboxIssueStatus.SNOOZED, - i18n_description: () => `inbox_issue.status.snoozed.description`, - }, - { - key: "accepted", - i18n_title: "inbox_issue.status.accepted.title", - status: EInboxIssueStatus.ACCEPTED, - i18n_description: () => `inbox_issue.status.accepted.description`, - }, - { - key: "duplicate", - i18n_title: "inbox_issue.status.duplicate.title", - status: EInboxIssueStatus.DUPLICATE, - i18n_description: () => `inbox_issue.status.duplicate.description`, - }, + { + key: "pending", + i18n_title: "inbox_issue.status.pending.title", + status: EInboxIssueStatus.PENDING, + i18n_description: () => `inbox_issue.status.pending.description`, + }, + { + key: "declined", + i18n_title: "inbox_issue.status.declined.title", + status: EInboxIssueStatus.DECLINED, + i18n_description: () => `inbox_issue.status.declined.description`, + }, + { + key: "snoozed", + i18n_title: "inbox_issue.status.snoozed.title", + status: EInboxIssueStatus.SNOOZED, + i18n_description: () => `inbox_issue.status.snoozed.description`, + }, + { + key: "accepted", + i18n_title: "inbox_issue.status.accepted.title", + status: EInboxIssueStatus.ACCEPTED, + i18n_description: () => `inbox_issue.status.accepted.description`, + }, + { + key: "duplicate", + i18n_title: "inbox_issue.status.duplicate.title", + status: EInboxIssueStatus.DUPLICATE, + i18n_description: () => `inbox_issue.status.duplicate.description`, + }, ]; export const INBOX_ISSUE_ORDER_BY_OPTIONS = [ - { - key: "issue__created_at", - i18n_label: "inbox_issue.order_by.created_at", - }, - { - key: "issue__updated_at", - i18n_label: "inbox_issue.order_by.updated_at", - }, - { - key: "issue__sequence_id", - i18n_label: "inbox_issue.order_by.id", - }, + { + key: "issue__created_at", + i18n_label: "inbox_issue.order_by.created_at", + }, + { + key: "issue__updated_at", + i18n_label: "inbox_issue.order_by.updated_at", + }, + { + key: "issue__sequence_id", + i18n_label: "inbox_issue.order_by.id", + }, ]; export const INBOX_ISSUE_SORT_BY_OPTIONS = [ - { - key: "asc", - i18n_label: "common.sort.asc", - }, - { - key: "desc", - i18n_label: "common.sort.desc", - }, + { + key: "asc", + i18n_label: "common.sort.asc", + }, + { + key: "desc", + i18n_label: "common.sort.desc", + }, ]; diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts index 7fedff05d..f974dd64b 100644 --- a/packages/constants/src/index.ts +++ b/packages/constants/src/index.ts @@ -14,6 +14,7 @@ export * from "./state"; export * from "./swr"; export * from "./tab-indices"; export * from "./user"; +export * from "./payment"; export * from "./workspace"; export * from "./stickies"; export * from "./cycle"; @@ -29,3 +30,5 @@ export * from "./event-tracker"; export * from "./spreadsheet"; export * from "./dashboard"; export * from "./page"; +export * from "./emoji"; +export * from "./subscription"; diff --git a/packages/constants/src/issue/common.ts b/packages/constants/src/issue/common.ts index cccf44b41..03634337a 100644 --- a/packages/constants/src/issue/common.ts +++ b/packages/constants/src/issue/common.ts @@ -41,6 +41,7 @@ export enum EIssueGroupBYServerToProperty { export enum EIssueServiceType { ISSUES = "issues", EPICS = "epics", + WORK_ITEMS = "work-items", } export enum EIssuesStoreType { diff --git a/packages/constants/src/issue/filter.ts b/packages/constants/src/issue/filter.ts index 5d1d694b7..687a2bd71 100644 --- a/packages/constants/src/issue/filter.ts +++ b/packages/constants/src/issue/filter.ts @@ -339,6 +339,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = { "-updated_at", "start_date", "-priority", + "target_date", ], type: [null, "active", "backlog"], }, diff --git a/packages/constants/src/payment.ts b/packages/constants/src/payment.ts new file mode 100644 index 000000000..6c82b0e30 --- /dev/null +++ b/packages/constants/src/payment.ts @@ -0,0 +1,163 @@ +import { IPaymentProduct, TBillingFrequency, TProductBillingFrequency } from "@plane/types"; + +/** + * Enum representing different product subscription types + */ +export enum EProductSubscriptionEnum { + FREE = "FREE", + ONE = "ONE", + PRO = "PRO", + BUSINESS = "BUSINESS", + ENTERPRISE = "ENTERPRISE", +} + +/** + * Default billing frequency for each product subscription type + */ +export const DEFAULT_PRODUCT_BILLING_FREQUENCY: TProductBillingFrequency = { + [EProductSubscriptionEnum.FREE]: undefined, + [EProductSubscriptionEnum.ONE]: undefined, + [EProductSubscriptionEnum.PRO]: "month", + [EProductSubscriptionEnum.BUSINESS]: "month", + [EProductSubscriptionEnum.ENTERPRISE]: "month", +}; + +/** + * Subscription types that support billing frequency toggle (monthly/yearly) + */ +export const SUBSCRIPTION_WITH_BILLING_FREQUENCY = [ + EProductSubscriptionEnum.PRO, + EProductSubscriptionEnum.BUSINESS, + EProductSubscriptionEnum.ENTERPRISE, +]; + +/** + * Mapping of product subscription types to their respective payment product details + * Used to provide information about each product's pricing and features + */ +export const PLANE_COMMUNITY_PRODUCTS: Record = { + [EProductSubscriptionEnum.PRO]: { + id: EProductSubscriptionEnum.PRO, + name: "Plane Pro", + description: + "More views, more cycles powers, more pages features, new reports, and better dashboards are waiting to be unlocked.", + type: "PRO", + prices: [ + { + id: `price_monthly_${EProductSubscriptionEnum.PRO}`, + unit_amount: 800, + recurring: "month", + currency: "usd", + workspace_amount: 800, + product: EProductSubscriptionEnum.PRO, + }, + { + id: `price_yearly_${EProductSubscriptionEnum.PRO}`, + unit_amount: 7200, + recurring: "year", + currency: "usd", + workspace_amount: 7200, + product: EProductSubscriptionEnum.PRO, + }, + ], + payment_quantity: 1, + is_active: true, + }, + [EProductSubscriptionEnum.BUSINESS]: { + id: EProductSubscriptionEnum.BUSINESS, + name: "Plane Business", + description: + "The earliest packaging of Business at $10 a seat a month billed annually, $12 a seat a month billed monthly for Plane Cloud", + type: "BUSINESS", + prices: [ + { + id: `price_yearly_${EProductSubscriptionEnum.BUSINESS}`, + unit_amount: 0, + recurring: "year", + currency: "usd", + workspace_amount: 0, + product: EProductSubscriptionEnum.BUSINESS, + }, + { + id: `price_monthly_${EProductSubscriptionEnum.BUSINESS}`, + unit_amount: 0, + recurring: "month", + currency: "usd", + workspace_amount: 0, + product: EProductSubscriptionEnum.BUSINESS, + }, + ], + payment_quantity: 1, + is_active: false, + }, + [EProductSubscriptionEnum.ENTERPRISE]: { + id: EProductSubscriptionEnum.ENTERPRISE, + name: "Plane Enterprise", + description: "", + type: "ENTERPRISE", + prices: [ + { + id: `price_yearly_${EProductSubscriptionEnum.ENTERPRISE}`, + unit_amount: 0, + recurring: "year", + currency: "usd", + workspace_amount: 0, + product: EProductSubscriptionEnum.ENTERPRISE, + }, + { + id: `price_monthly_${EProductSubscriptionEnum.ENTERPRISE}`, + unit_amount: 0, + recurring: "month", + currency: "usd", + workspace_amount: 0, + product: EProductSubscriptionEnum.ENTERPRISE, + }, + ], + payment_quantity: 1, + is_active: false, + }, +}; + +/** + * URL for the "Talk to Sales" page where users can contact sales team + */ +export const TALK_TO_SALES_URL = "https://plane.so/talk-to-sales"; + +/** + * Mapping of subscription types to their respective upgrade/redirection URLs based on billing frequency + * Used for self-hosted installations to redirect users to appropriate upgrade pages + */ +export const SUBSCRIPTION_REDIRECTION_URLS: Record> = { + [EProductSubscriptionEnum.FREE]: { + month: TALK_TO_SALES_URL, + year: TALK_TO_SALES_URL, + }, + [EProductSubscriptionEnum.ONE]: { + month: TALK_TO_SALES_URL, + year: TALK_TO_SALES_URL, + }, + [EProductSubscriptionEnum.PRO]: { + month: "https://app.plane.so/upgrade/pro/self-hosted?plan=month", + year: "https://app.plane.so/upgrade/pro/self-hosted?plan=year", + }, + [EProductSubscriptionEnum.BUSINESS]: { + month: TALK_TO_SALES_URL, + year: TALK_TO_SALES_URL, + }, + [EProductSubscriptionEnum.ENTERPRISE]: { + month: TALK_TO_SALES_URL, + year: TALK_TO_SALES_URL, + }, +}; + +/** + * Mapping of subscription types to their respective marketing webpage URLs + * Used to direct users to learn more about each plan's features and pricing + */ +export const SUBSCRIPTION_WEBPAGE_URLS: Record = { + [EProductSubscriptionEnum.FREE]: TALK_TO_SALES_URL, + [EProductSubscriptionEnum.ONE]: TALK_TO_SALES_URL, + [EProductSubscriptionEnum.PRO]: "https://plane.so/pro", + [EProductSubscriptionEnum.BUSINESS]: "https://plane.so/business", + [EProductSubscriptionEnum.ENTERPRISE]: "https://plane.so/business", +}; diff --git a/packages/constants/src/project.ts b/packages/constants/src/project.ts index 93a29a6c4..df22641e8 100644 --- a/packages/constants/src/project.ts +++ b/packages/constants/src/project.ts @@ -1,5 +1,7 @@ -// icons -import { TProjectAppliedDisplayFilterKeys, TProjectOrderByOptions } from "@plane/types"; +// plane imports +import { IProject, TProjectAppliedDisplayFilterKeys, TProjectOrderByOptions } from "@plane/types"; +// local imports +import { RANDOM_EMOJI_CODES } from "./emoji"; export type TNetworkChoiceIconKey = "Lock" | "Globe2"; @@ -132,3 +134,18 @@ export const PROJECT_ERROR_MESSAGES = { i18n_message: "workspace_projects.error.issue_delete", }, }; + +export const DEFAULT_PROJECT_FORM_VALUES: Partial = { + cover_image_url: PROJECT_UNSPLASH_COVERS[Math.floor(Math.random() * PROJECT_UNSPLASH_COVERS.length)], + description: "", + logo_props: { + in_use: "emoji", + emoji: { + value: RANDOM_EMOJI_CODES[Math.floor(Math.random() * RANDOM_EMOJI_CODES.length)], + }, + }, + identifier: "", + name: "", + network: 2, + project_lead: null, +}; diff --git a/packages/constants/src/state.ts b/packages/constants/src/state.ts index 9f5db17c7..fa0f5d277 100644 --- a/packages/constants/src/state.ts +++ b/packages/constants/src/state.ts @@ -1,9 +1,4 @@ -export type TStateGroups = - | "backlog" - | "unstarted" - | "started" - | "completed" - | "cancelled"; +export type TStateGroups = "backlog" | "unstarted" | "started" | "completed" | "cancelled"; export type TDraggableData = { groupKey: TStateGroups; @@ -14,40 +9,43 @@ export const STATE_GROUPS: { [key in TStateGroups]: { key: TStateGroups; label: string; + defaultStateName: string; color: string; }; } = { backlog: { key: "backlog", label: "Backlog", + defaultStateName: "Backlog", color: "#d9d9d9", }, unstarted: { key: "unstarted", label: "Unstarted", + defaultStateName: "Todo", color: "#3f76ff", }, started: { key: "started", label: "Started", + defaultStateName: "In Progress", color: "#f59e0b", }, completed: { key: "completed", label: "Completed", + defaultStateName: "Done", color: "#16a34a", }, cancelled: { key: "cancelled", label: "Canceled", + defaultStateName: "Cancelled", color: "#dc2626", }, }; -export const ARCHIVABLE_STATE_GROUPS = [ - STATE_GROUPS.completed.key, - STATE_GROUPS.cancelled.key, -]; +export const ARCHIVABLE_STATE_GROUPS = [STATE_GROUPS.completed.key, STATE_GROUPS.cancelled.key]; export const COMPLETED_STATE_GROUPS = [STATE_GROUPS.completed.key]; export const PENDING_STATE_GROUPS = [ STATE_GROUPS.backlog.key, diff --git a/packages/constants/src/subscription.ts b/packages/constants/src/subscription.ts new file mode 100644 index 000000000..c2d2cfa13 --- /dev/null +++ b/packages/constants/src/subscription.ts @@ -0,0 +1,42 @@ +export const ENTERPRISE_PLAN_FEATURES = [ + "Private + managed deployments", + "GAC", + "LDAP support", + "Databases + Formulas", + "Unlimited and full Automation Flows", + "Full-suite professional services", +]; + +export const BUSINESS_PLAN_FEATURES = [ + "Project Templates", + "Workflows + Approvals", + "Decision + Loops Automation", + "Custom Reports", + "Nested Pages", + "Intake Forms", +]; + +export const PRO_PLAN_FEATURES = [ + "Dashboards + Reports", + "Full Time Tracking + Bulk Ops", + "Teamspaces", + "Trigger And Action", + "Wikis", + "Popular integrations", +]; + +export const ONE_PLAN_FEATURES = [ + "OIDC + SAML for SSO", + "Active Cycles", + "Real-time collab + public views and page", + "Link pages in issues and vice-versa", + "Time-tracking + limited bulk ops", + "Docker, Kubernetes and more", +]; + +export const FREE_PLAN_UPGRADE_FEATURES = [ + "OIDC + SAML for SSO", + "Time Tracking and Bulk Ops", + "Integrations", + "Public Views and Pages", +]; diff --git a/packages/constants/src/workspace.ts b/packages/constants/src/workspace.ts index 06c9fb659..c1c60f392 100644 --- a/packages/constants/src/workspace.ts +++ b/packages/constants/src/workspace.ts @@ -83,14 +83,14 @@ export const WORKSPACE_SETTINGS = { key: "general", i18n_label: "workspace_settings.settings.general.title", href: `/settings`, - access: [EUserWorkspaceRoles.ADMIN], + access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER], highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/`, }, members: { key: "members", i18n_label: "workspace_settings.settings.members.title", href: `/settings/members`, - access: [EUserWorkspaceRoles.ADMIN], + access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER], highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`, }, "billing-and-plans": { @@ -123,6 +123,10 @@ export const WORKSPACE_SETTINGS = { }, }; +export const WORKSPACE_SETTINGS_ACCESS = Object.fromEntries( + Object.entries(WORKSPACE_SETTINGS).map(([_, { href, access }]) => [href, access]) +); + export const WORKSPACE_SETTINGS_LINKS: { key: string; i18n_label: string; diff --git a/packages/decorators/.eslintignore b/packages/decorators/.eslintignore new file mode 100644 index 000000000..31ca21417 --- /dev/null +++ b/packages/decorators/.eslintignore @@ -0,0 +1,4 @@ +node_modules +build/* +dist/* +out/* diff --git a/packages/decorators/.eslintrc.js b/packages/decorators/.eslintrc.js new file mode 100644 index 000000000..c1728ac28 --- /dev/null +++ b/packages/decorators/.eslintrc.js @@ -0,0 +1,10 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + root: true, + extends: ["@plane/eslint-config/library.js"], + parser: "@typescript-eslint/parser", + parserOptions: { + project: true, + }, +}; + diff --git a/packages/decorators/README.md b/packages/decorators/README.md new file mode 100644 index 000000000..e9c308e9e --- /dev/null +++ b/packages/decorators/README.md @@ -0,0 +1,99 @@ +# @plane/decorators + +A lightweight TypeScript decorator library for building Express.js controllers with a clean, declarative syntax. + +## Features + +- TypeScript-first design +- Decorators for HTTP methods (GET, POST, PUT, PATCH, DELETE) +- WebSocket support +- Middleware support +- No build step required - works directly with TypeScript files + +## Installation + +This package is part of the Plane workspace and can be used by adding it to your project's dependencies: + +```json +{ + "dependencies": { + "@plane/decorators": "*" + } +} +``` + +## Usage + +### Basic REST Controller + +```typescript +import { Controller, Get, Post, BaseController } from "@plane/decorators"; +import { Router, Request, Response } from "express"; + +@Controller("/api/users") +class UserController extends BaseController { + @Get("/") + async getUsers(req: Request, res: Response) { + return res.json({ users: [] }); + } + + @Post("/") + async createUser(req: Request, res: Response) { + return res.json({ success: true }); + } +} + +// Register routes +const router = Router(); +const userController = new UserController(); +userController.registerRoutes(router); +``` + +### WebSocket Controller + +```typescript +import { + Controller, + WebSocket, + BaseWebSocketController, +} from "@plane/decorators"; +import { Request } from "express"; +import { WebSocket as WS } from "ws"; + +@Controller("/ws/chat") +class ChatController extends BaseWebSocketController { + @WebSocket("/") + handleConnection(ws: WS, req: Request) { + ws.on("message", (message) => { + ws.send(`Received: ${message}`); + }); + } +} + +// Register WebSocket routes +const router = require("express-ws")(app).router; +const chatController = new ChatController(); +chatController.registerWebSocketRoutes(router); +``` + +## API Reference + +### Decorators + +- `@Controller(baseRoute: string)` - Class decorator for defining a base route +- `@Get(route: string)` - Method decorator for HTTP GET endpoints +- `@Post(route: string)` - Method decorator for HTTP POST endpoints +- `@Put(route: string)` - Method decorator for HTTP PUT endpoints +- `@Patch(route: string)` - Method decorator for HTTP PATCH endpoints +- `@Delete(route: string)` - Method decorator for HTTP DELETE endpoints +- `@WebSocket(route: string)` - Method decorator for WebSocket endpoints +- `@Middleware(middleware: RequestHandler)` - Method decorator for applying middleware + +### Classes + +- `BaseController` - Base class for REST controllers +- `BaseWebSocketController` - Base class for WebSocket controllers + +## License + +This project is licensed under the [GNU Affero General Public License v3.0](https://github.com/makeplane/plane/blob/master/LICENSE.txt). diff --git a/packages/decorators/package.json b/packages/decorators/package.json new file mode 100644 index 000000000..92c49b969 --- /dev/null +++ b/packages/decorators/package.json @@ -0,0 +1,42 @@ +{ + "name": "@plane/decorators", + "version": "0.1.0", + "description": "Controller and route decorators for Express.js applications", + "license": "AGPL-3.0", + "private": true, + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist/**" + ], + "scripts": { + "build": "tsup src/index.ts --format esm,cjs --dts --external express,ws", + "dev": "tsup src/index.ts --format esm,cjs --watch --dts --external express,ws", + "lint": "eslint src --ext .ts,.tsx", + "lint:errors": "eslint src --ext .ts,.tsx --quiet" + }, + "dependencies": { + "reflect-metadata": "^0.2.2", + "express": "^4.21.2" + }, + "devDependencies": { + "@plane/eslint-config": "*", + "@types/express": "^4.17.21", + "@types/reflect-metadata": "^0.1.0", + "@plane/typescript-config": "*", + "@types/node": "^20.14.9", + "@types/ws": "^8.5.10", + "tsup": "8.4.0", + "typescript": "^5.3.3" + }, + "peerDependencies": { + "express": ">=4.21.2", + "ws": ">=8.0.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + } + } +} diff --git a/packages/decorators/src/controller.ts b/packages/decorators/src/controller.ts new file mode 100644 index 000000000..a9185a848 --- /dev/null +++ b/packages/decorators/src/controller.ts @@ -0,0 +1,61 @@ +import { RequestHandler, Router } from "express"; +import "reflect-metadata"; + +type HttpMethod = + | "get" + | "post" + | "put" + | "delete" + | "patch" + | "options" + | "head" + | "ws"; + +interface ControllerInstance { + [key: string]: unknown; +} + +interface ControllerConstructor { + new (...args: any[]): ControllerInstance; + prototype: ControllerInstance; +} + +export function registerControllers( + router: Router, + Controller: ControllerConstructor, +): void { + const instance = new Controller(); + const baseRoute = Reflect.getMetadata("baseRoute", Controller) as string; + + Object.getOwnPropertyNames(Controller.prototype).forEach((methodName) => { + if (methodName === "constructor") return; // Skip the constructor + + const method = Reflect.getMetadata( + "method", + instance, + methodName, + ) as HttpMethod; + const route = Reflect.getMetadata("route", instance, methodName) as string; + const middlewares = + (Reflect.getMetadata( + "middlewares", + instance, + methodName, + ) as RequestHandler[]) || []; + + if (method && route) { + const handler = instance[methodName] as unknown; + + if (typeof handler === "function") { + if (method !== "ws") { + ( + router[method] as ( + path: string, + ...handlers: RequestHandler[] + ) => void + )(`${baseRoute}${route}`, ...middlewares, handler.bind(instance)); + } + } + } + }); +} diff --git a/packages/decorators/src/index.ts b/packages/decorators/src/index.ts new file mode 100644 index 000000000..ef7136059 --- /dev/null +++ b/packages/decorators/src/index.ts @@ -0,0 +1,15 @@ +// Export individual decorators +export { Controller, Middleware } from "./rest"; +export { Get, Post, Put, Patch, Delete } from "./rest"; +export { WebSocket } from "./websocket"; +export { registerControllers } from "./controller"; +export { registerWebSocketControllers } from "./websocket-controller"; + +// Also provide namespaced exports for better organization +import * as RestDecorators from "./rest"; +import * as WebSocketDecorators from "./websocket"; + +// Named namespace exports +export const Rest = RestDecorators; +export const WebSocketNS = WebSocketDecorators; + diff --git a/packages/decorators/src/rest.ts b/packages/decorators/src/rest.ts new file mode 100644 index 000000000..68c0fba54 --- /dev/null +++ b/packages/decorators/src/rest.ts @@ -0,0 +1,61 @@ +import "reflect-metadata"; +import { RequestHandler } from "express"; + +// Define valid HTTP methods +type RestMethod = "get" | "post" | "put" | "patch" | "delete"; + +/** + * Controller decorator + * @param baseRoute + * @returns + */ +export function Controller(baseRoute: string = ""): ClassDecorator { + return function (target: Function) { + Reflect.defineMetadata("baseRoute", baseRoute, target); + }; +} + +/** + * Factory function to create HTTP method decorators + * @param method HTTP method to handle + * @returns Method decorator + */ +function createHttpMethodDecorator( + method: RestMethod +): (route: string) => MethodDecorator { + return function (route: string): MethodDecorator { + return function ( + target: object, + propertyKey: string | symbol, + descriptor: PropertyDescriptor + ) { + Reflect.defineMetadata("method", method, target, propertyKey); + Reflect.defineMetadata("route", route, target, propertyKey); + }; + }; +} + +// Export HTTP method decorators using the factory +export const Get = createHttpMethodDecorator("get"); +export const Post = createHttpMethodDecorator("post"); +export const Put = createHttpMethodDecorator("put"); +export const Patch = createHttpMethodDecorator("patch"); +export const Delete = createHttpMethodDecorator("delete"); + +/** + * Middleware decorator + * @param middleware + * @returns + */ +export function Middleware(middleware: RequestHandler): MethodDecorator { + return function ( + target: object, + propertyKey: string | symbol, + descriptor: PropertyDescriptor, + ) { + const middlewares = + Reflect.getMetadata("middlewares", target, propertyKey) || []; + middlewares.push(middleware); + Reflect.defineMetadata("middlewares", middlewares, target, propertyKey); + }; +} diff --git a/packages/decorators/src/websocket-controller.ts b/packages/decorators/src/websocket-controller.ts new file mode 100644 index 000000000..85a018da0 --- /dev/null +++ b/packages/decorators/src/websocket-controller.ts @@ -0,0 +1,85 @@ +import { Router, Request } from "express"; +import type { WebSocket } from "ws"; +import "reflect-metadata"; + +interface ControllerInstance { + [key: string]: unknown; +} + +interface ControllerConstructor { + new (...args: any[]): ControllerInstance; + prototype: ControllerInstance; +} + +export function registerWebSocketControllers( + router: Router, + Controller: ControllerConstructor, + existingInstance?: ControllerInstance, +): void { + const instance = existingInstance || new Controller(); + const baseRoute = Reflect.getMetadata("baseRoute", Controller) as string; + + Object.getOwnPropertyNames(Controller.prototype).forEach((methodName) => { + if (methodName === "constructor") return; // Skip the constructor + + const method = Reflect.getMetadata( + "method", + instance, + methodName, + ) as string; + const route = Reflect.getMetadata("route", instance, methodName) as string; + + if (method === "ws" && route) { + const handler = instance[methodName] as unknown; + + if ( + typeof handler === "function" && + typeof (router as any).ws === "function" + ) { + (router as any).ws( + `${baseRoute}${route}`, + (ws: WebSocket, req: Request) => { + try { + handler.call(instance, ws, req); + } catch (error) { + console.error( + `WebSocket error in ${Controller.name}.${methodName}`, + error, + ); + ws.close( + 1011, + error instanceof Error + ? error.message + : "Internal server error", + ); + } + }, + ); + } + } + }); +} + +/** + * Base controller class for WebSocket endpoints + */ +export abstract class BaseWebSocketController { + protected router: Router; + + constructor() { + this.router = Router(); + } + + /** + * Get the base route for this controller + */ + protected getBaseRoute(): string { + return Reflect.getMetadata("baseRoute", this.constructor) || ""; + } + + /** + * Abstract method to handle WebSocket connections + * Implement this in your derived class + */ + abstract handleConnection(ws: WebSocket, req: Request): void; +} diff --git a/packages/decorators/src/websocket.ts b/packages/decorators/src/websocket.ts new file mode 100644 index 000000000..5b6b6a7b1 --- /dev/null +++ b/packages/decorators/src/websocket.ts @@ -0,0 +1,17 @@ +import "reflect-metadata"; + +/** + * WebSocket method decorator + * @param route + * @returns + */ +export function WebSocket(route: string): MethodDecorator { + return function ( + target: object, + propertyKey: string | symbol, + descriptor: PropertyDescriptor, + ) { + Reflect.defineMetadata("method", "ws", target, propertyKey); + Reflect.defineMetadata("route", route, target, propertyKey); + }; +} diff --git a/packages/decorators/tsconfig.json b/packages/decorators/tsconfig.json new file mode 100644 index 000000000..02b459b9f --- /dev/null +++ b/packages/decorators/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "@plane/typescript-config/node-library.json", + "compilerOptions": { + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "lib": ["ES2020"], + "rootDir": ".", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": [ + "./src" + ], + "exclude": [ + "dist", + "build", + "node_modules" + ] +} diff --git a/packages/decorators/tsup.config.ts b/packages/decorators/tsup.config.ts new file mode 100644 index 000000000..757dd8ba3 --- /dev/null +++ b/packages/decorators/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + splitting: false, + sourcemap: true, + clean: true, + external: ['express', 'ws'], + treeshake: true, +}); \ No newline at end of file diff --git a/packages/editor/package.json b/packages/editor/package.json index 801a24210..cfbd0861e 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -1,6 +1,6 @@ { "name": "@plane/editor", - "version": "0.25.3", + "version": "0.26.0", "description": "Core Editor that powers Plane", "license": "AGPL-3.0", "private": true, diff --git a/packages/editor/src/core/components/editors/document/collaborative-editor.tsx b/packages/editor/src/core/components/editors/document/collaborative-editor.tsx index 751c79101..623ec9508 100644 --- a/packages/editor/src/core/components/editors/document/collaborative-editor.tsx +++ b/packages/editor/src/core/components/editors/document/collaborative-editor.tsx @@ -1,5 +1,7 @@ import { Extensions } from "@tiptap/core"; import React from "react"; +// plane imports +import { cn } from "@plane/utils"; // components import { DocumentContentLoader, PageRenderer } from "@/components/editors"; // constants @@ -73,7 +75,11 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => { if (!editor) return null; - if (!hasServerSynced && !hasServerConnectionFailed) return ; + const blockWidthClassName = cn("w-full max-w-[720px] mx-auto transition-all duration-200 ease-in-out", { + "max-w-[1152px]": displayConfig.wideLayout, + }); + + if (!hasServerSynced && !hasServerConnectionFailed) return ; return ( { bubbleMenuEnabled={bubbleMenuEnabled} displayConfig={displayConfig} editor={editor} - editorContainerClassName={editorContainerClassNames} + editorContainerClassName={cn(editorContainerClassNames, "document-editor")} id={id} tabIndex={tabIndex} /> diff --git a/packages/editor/src/core/components/editors/document/loader.tsx b/packages/editor/src/core/components/editors/document/loader.tsx index ab9c3b479..ece0d4b77 100644 --- a/packages/editor/src/core/components/editors/document/loader.tsx +++ b/packages/editor/src/core/components/editors/document/loader.tsx @@ -1,42 +1,51 @@ -// ui +// plane imports import { Loader } from "@plane/ui"; +import { cn } from "@plane/utils"; -export const DocumentContentLoader = () => ( -
- -
-
- -
- -
- - -
-
- -
- - -
- - -
-
- -
- -
- -
- -
+type Props = { + className?: string; +}; + +export const DocumentContentLoader = (props: Props) => { + const { className } = props; + + return ( +
+ +
+ +
+ +
+ +
+
+ +
+ + +
+ + +
+
+ +
+ +
+
+
+
+ +
+ +
-
- -
-); + +
+ ); +}; diff --git a/packages/editor/src/core/components/editors/document/page-renderer.tsx b/packages/editor/src/core/components/editors/document/page-renderer.tsx index a29768656..0be3c17c1 100644 --- a/packages/editor/src/core/components/editors/document/page-renderer.tsx +++ b/packages/editor/src/core/components/editors/document/page-renderer.tsx @@ -1,20 +1,6 @@ -import { useCallback, useRef, useState } from "react"; -import { - autoUpdate, - computePosition, - flip, - hide, - shift, - useDismiss, - useFloating, - useInteractions, -} from "@floating-ui/react"; -import { Node } from "@tiptap/pm/model"; -import { EditorView } from "@tiptap/pm/view"; -import { Editor, ReactRenderer } from "@tiptap/react"; +import { Editor } from "@tiptap/react"; // components import { EditorContainer, EditorContentWrapper } from "@/components/editors"; -import { LinkView, LinkViewProps } from "@/components/links"; import { AIFeaturesMenu, BlockMenu, EditorBubbleMenu } from "@/components/menus"; // types import { TAIHandler, TDisplayConfig } from "@/types"; @@ -31,133 +17,24 @@ type IPageRenderer = { export const PageRenderer = (props: IPageRenderer) => { const { aiHandler, bubbleMenuEnabled, displayConfig, editor, editorContainerClassName, id, tabIndex } = props; - // states - const [linkViewProps, setLinkViewProps] = useState(); - const [isOpen, setIsOpen] = useState(false); - const [coordinates, setCoordinates] = useState<{ x: number; y: number }>(); - const [cleanup, setCleanup] = useState(() => () => {}); - - const { refs, floatingStyles, context } = useFloating({ - open: isOpen, - onOpenChange: setIsOpen, - middleware: [flip(), shift(), hide({ strategy: "referenceHidden" })], - whileElementsMounted: autoUpdate, - }); - - const dismiss = useDismiss(context, { - ancestorScroll: true, - }); - - const { getFloatingProps } = useInteractions([dismiss]); - - const floatingElementRef = useRef(null); - - const closeLinkView = () => setIsOpen(false); - - const handleLinkHover = useCallback( - (event: React.MouseEvent) => { - if (!editor) return; - const target = event.target as HTMLElement; - const view = editor.view as EditorView; - - if (!target || !view) return; - const pos = view.posAtDOM(target, 0); - if (!pos || pos < 0) return; - - if (target.nodeName !== "A") return; - - const node = view.state.doc.nodeAt(pos) as Node; - if (!node || !node.isAtom) return; - - // we need to check if any of the marks are links - const marks = node.marks; - - if (!marks) return; - - const linkMark = marks.find((mark) => mark.type.name === "link"); - - if (!linkMark) return; - - if (floatingElementRef.current) { - floatingElementRef.current?.remove(); - } - - if (cleanup) cleanup(); - - const href = linkMark.attrs.href; - const componentLink = new ReactRenderer(LinkView, { - props: { - view: "LinkPreview", - url: href, - editor: editor, - from: pos, - to: pos + node.nodeSize, - }, - editor, - }); - - const referenceElement = target as HTMLElement; - const floatingElement = componentLink.element as HTMLElement; - - floatingElementRef.current = floatingElement; - - const cleanupFunc = autoUpdate(referenceElement, floatingElement, () => { - computePosition(referenceElement, floatingElement, { - placement: "bottom", - middleware: [ - flip(), - shift(), - hide({ - strategy: "referenceHidden", - }), - ], - }).then(({ x, y }) => { - setCoordinates({ x: x - 300, y: y - 50 }); - setIsOpen(true); - setLinkViewProps({ - closeLinkView: closeLinkView, - view: "LinkPreview", - url: href, - editor: editor, - from: pos, - to: pos + node.nodeSize, - }); - }); - }); - - setCleanup(cleanupFunc); - }, - [editor, cleanup] - ); return ( - <> -
- - - {editor.isEditable && ( -
- {bubbleMenuEnabled && } - - -
- )} -
-
- {isOpen && linkViewProps && coordinates && ( -
- -
- )} - +
+ + + {editor.isEditable && ( +
+ {bubbleMenuEnabled && } + + +
+ )} +
+
); }; diff --git a/packages/editor/src/core/components/editors/document/read-only-editor.tsx b/packages/editor/src/core/components/editors/document/read-only-editor.tsx index fa1770f0c..54a1f96e2 100644 --- a/packages/editor/src/core/components/editors/document/read-only-editor.tsx +++ b/packages/editor/src/core/components/editors/document/read-only-editor.tsx @@ -1,5 +1,7 @@ import { Extensions } from "@tiptap/core"; import { forwardRef, MutableRefObject } from "react"; +// plane imports +import { cn } from "@plane/utils"; // components import { PageRenderer } from "@/components/editors"; // constants @@ -79,7 +81,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => { bubbleMenuEnabled={false} displayConfig={displayConfig} editor={editor} - editorContainerClassName={editorContainerClassName} + editorContainerClassName={cn(editorContainerClassName, "document-editor")} id={id} /> ); diff --git a/packages/editor/src/core/components/editors/editor-container.tsx b/packages/editor/src/core/components/editors/editor-container.tsx index b742a5265..d0811cd41 100644 --- a/packages/editor/src/core/components/editors/editor-container.tsx +++ b/packages/editor/src/core/components/editors/editor-container.tsx @@ -1,22 +1,25 @@ import { Editor } from "@tiptap/react"; -import { FC, ReactNode } from "react"; +import { FC, ReactNode, useRef } from "react"; // plane utils import { cn } from "@plane/utils"; // constants import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config"; // types import { TDisplayConfig } from "@/types"; +// components +import { LinkViewContainer } from "./link-view-container"; interface EditorContainerProps { children: ReactNode; displayConfig: TDisplayConfig; - editor: Editor | null; + editor: Editor; editorContainerClassName: string; id: string; } export const EditorContainer: FC = (props) => { const { children, displayConfig, editor, editorContainerClassName, id } = props; + const containerRef = useRef(null); const handleContainerClick = (event: React.MouseEvent) => { if (event.target !== event.currentTarget) return; @@ -44,15 +47,23 @@ export const EditorContainer: FC = (props) => { return; } - // Insert a new paragraph at the end of the document - const endPosition = editor?.state.doc.content.size; - editor?.chain().insertContentAt(endPosition, { type: "paragraph" }).run(); + // Get the last node in the document + const docSize = editor.state.doc.content.size; + const lastNodePos = editor.state.doc.resolve(Math.max(0, docSize - 2)); + const lastNode = lastNodePos.node(); - // Focus the newly added paragraph for immediate editing - editor - .chain() - .setTextSelection(endPosition + 1) - .run(); + // Check if the last node is a not paragraph + if (lastNode && lastNode.type.name !== "paragraph") { + // If last node is not a paragraph, insert a new paragraph at the end + const endPosition = editor?.state.doc.content.size; + editor?.chain().insertContentAt(endPosition, { type: "paragraph" }).run(); + + // Focus the newly added paragraph for immediate editing + editor + .chain() + .setTextSelection(endPosition + 1) + .run(); + } } catch (error) { console.error("An error occurred while handling container click to insert new empty node at bottom:", error); } @@ -66,21 +77,26 @@ export const EditorContainer: FC = (props) => { }; return ( -
- {children} -
+ <> +
+ {children} + +
+ ); }; diff --git a/packages/editor/src/core/components/editors/editor-content.tsx b/packages/editor/src/core/components/editors/editor-content.tsx index b05457f2e..8171d06d9 100644 --- a/packages/editor/src/core/components/editors/editor-content.tsx +++ b/packages/editor/src/core/components/editors/editor-content.tsx @@ -1,5 +1,5 @@ -import { FC, ReactNode } from "react"; import { Editor, EditorContent } from "@tiptap/react"; +import { FC, ReactNode } from "react"; interface EditorContentProps { children?: ReactNode; diff --git a/packages/editor/src/core/components/editors/link-view-container.tsx b/packages/editor/src/core/components/editors/link-view-container.tsx new file mode 100644 index 000000000..41263a996 --- /dev/null +++ b/packages/editor/src/core/components/editors/link-view-container.tsx @@ -0,0 +1,126 @@ +import { autoUpdate, flip, hide, shift, useDismiss, useFloating, useInteractions } from "@floating-ui/react"; +import { Editor, useEditorState } from "@tiptap/react"; +import { FC, useCallback, useEffect, useState } from "react"; +// components +import { LinkView, LinkViewProps } from "@/components/links"; + +interface LinkViewContainerProps { + editor: Editor; + containerRef: React.RefObject; +} + +export const LinkViewContainer: FC = ({ editor, containerRef }) => { + const [linkViewProps, setLinkViewProps] = useState(); + const [isOpen, setIsOpen] = useState(false); + const [virtualElement, setVirtualElement] = useState(null); + + const editorState = useEditorState({ + editor, + selector: ({ editor }: { editor: Editor }) => ({ + linkExtensionStorage: editor.storage.link, + }), + }); + + const { refs, floatingStyles, context } = useFloating({ + open: isOpen, + onOpenChange: setIsOpen, + elements: { + reference: virtualElement, + }, + middleware: [ + flip({ + fallbackPlacements: ["top", "bottom"], + }), + shift({ + padding: 5, + }), + hide(), + ], + whileElementsMounted: autoUpdate, + placement: "bottom-start", + }); + + const dismiss = useDismiss(context); + + const { getReferenceProps, getFloatingProps } = useInteractions([dismiss]); + + const handleLinkHover = useCallback( + (event: MouseEvent) => { + if (!editor || editorState.linkExtensionStorage.isBubbleMenuOpen) return; + + // Find the closest anchor tag from the event target + const target = (event.target as HTMLElement)?.closest("a"); + if (!target) return; + + const referenceProps = getReferenceProps(); + Object.entries(referenceProps).forEach(([key, value]) => { + target.setAttribute(key, value as string); + }); + + const view = editor.view; + if (!view) return; + + try { + const pos = view.posAtDOM(target, 0); + if (pos === undefined || pos < 0) return; + + const node = view.state.doc.nodeAt(pos); + if (!node) return; + + const linkMark = node.marks?.find((mark) => mark.type.name === "link"); + if (!linkMark) return; + + setVirtualElement(target); + + // Only update if not already open or if hovering over a different link + if (!isOpen || (linkViewProps && (linkViewProps.from !== pos || linkViewProps.to !== pos + node.nodeSize))) { + setLinkViewProps({ + view: "LinkPreview", // Always start with preview for new links + url: linkMark.attrs.href, + text: node.text || "", + editor: editor, + from: pos, + to: pos + node.nodeSize, + closeLinkView: () => { + setIsOpen(false); + editorState.linkExtensionStorage.isPreviewOpen = false; + }, + }); + setIsOpen(true); + } + } catch (error) { + console.error("Error handling link hover:", error); + } + }, + [editor, editorState.linkExtensionStorage, getReferenceProps, isOpen, linkViewProps] + ); + + // Set up event listeners + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + container.addEventListener("mouseover", handleLinkHover); + + return () => { + container.removeEventListener("mouseover", handleLinkHover); + }; + }, [handleLinkHover]); + + // Close link view when bubble menu opens + useEffect(() => { + if (editorState.linkExtensionStorage.isBubbleMenuOpen && isOpen) { + setIsOpen(false); + } + }, [editorState.linkExtensionStorage, isOpen]); + + return ( + <> + {isOpen && linkViewProps && virtualElement && ( +
+ +
+ )} + + ); +}; diff --git a/packages/editor/src/core/components/links/index.ts b/packages/editor/src/core/components/links/index.ts index 8e123098e..4bd24e373 100644 --- a/packages/editor/src/core/components/links/index.ts +++ b/packages/editor/src/core/components/links/index.ts @@ -1,4 +1,3 @@ export * from "./link-edit-view"; -export * from "./link-input-view"; export * from "./link-preview"; export * from "./link-view"; diff --git a/packages/editor/src/core/components/links/link-edit-view.tsx b/packages/editor/src/core/components/links/link-edit-view.tsx index 665e7500a..ad66ce4b4 100644 --- a/packages/editor/src/core/components/links/link-edit-view.tsx +++ b/packages/editor/src/core/components/links/link-edit-view.tsx @@ -1,131 +1,145 @@ -import { useEffect, useRef, useState } from "react"; import { Node } from "@tiptap/pm/model"; import { Link2Off } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; // components -import { LinkViewProps } from "@/components/links"; +import { LinkViewProps, LinkViews } from "@/components/links"; // helpers import { isValidHttpUrl } from "@/helpers/common"; -const InputView = ({ - label, - defaultValue, - placeholder, - onChange, -}: { +interface InputViewProps { label: string; - defaultValue: string; + value: string; placeholder: string; - onChange: (e: React.ChangeEvent) => void; -}) => ( + onChange: (value: string) => void; + autoFocus?: boolean; +} + +const InputView = ({ label, value, placeholder, onChange, autoFocus }: InputViewProps) => (
{ - e.stopPropagation(); - }} - className="w-[280px] outline-none bg-custom-background-90 text-custom-text-900 text-sm" - defaultValue={defaultValue} - onChange={onChange} + onClick={(e) => e.stopPropagation()} + className="w-[280px] outline-none bg-custom-background-90 text-custom-text-900 text-sm border border-custom-border-300 rounded-md p-2" + value={value} + onChange={(e) => onChange(e.target.value)} + autoFocus={autoFocus} />
); -export const LinkEditView = ({ - viewProps, -}: { +interface LinkEditViewProps { viewProps: LinkViewProps; - switchView: (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => void; -}) => { - const { editor, from, to } = viewProps; + switchView: (view: LinkViews) => void; +} - const [positionRef, setPositionRef] = useState({ from: from, to: to }); - const [localUrl, setLocalUrl] = useState(viewProps.url); +export const LinkEditView = ({ viewProps }: LinkEditViewProps) => { + const { editor, from, to, url: initialUrl, text: initialText, closeLinkView } = viewProps; - const linkRemoved = useRef(); - - const getText = (from: number, to: number) => { - if (to >= editor.state.doc.content.size) return ""; - - const text = editor.state.doc.textBetween(from, to, "\n"); - return text; - }; - - const handleUpdateLink = (url: string) => { - setLocalUrl(url); - }; + // State + const [positionRef] = useState({ from, to }); + const [localUrl, setLocalUrl] = useState(initialUrl); + const [localText, setLocalText] = useState(initialText ?? ""); + const [linkRemoved, setLinkRemoved] = useState(false); + const hasSubmitted = useRef(false); + // Effects useEffect( - () => () => { - if (linkRemoved.current) return; - - const url = isValidHttpUrl(localUrl) ? localUrl : viewProps.url; - - if (to >= editor.state.doc.content.size) return; - - editor.view.dispatch(editor.state.tr.removeMark(from, to, editor.schema.marks.link)); - editor.view.dispatch(editor.state.tr.addMark(from, to, editor.schema.marks.link.create({ href: url }))); - }, - [localUrl, editor, from, to, viewProps.url] + () => + // Cleanup effect: Remove link if not submitted and url is empty + () => { + if (!hasSubmitted.current && !linkRemoved && initialUrl === "") { + try { + removeLink(); + } catch (e) {} + } + }, + [linkRemoved, initialUrl] ); - const handleUpdateText = (text: string) => { - if (text === "") { - return; + // Sync state with props + useEffect(() => { + setLocalUrl(initialUrl); + }, [initialUrl]); + + useEffect(() => { + if (initialText) setLocalText(initialText); + }, [initialText]); + + // Handlers + const handleTextChange = useCallback((value: string) => { + if (value.trim() !== "") setLocalText(value); + }, []); + + const applyChanges = useCallback((): boolean => { + if (linkRemoved) return false; + hasSubmitted.current = true; + + const { url, isValid } = isValidHttpUrl(localUrl); + if (to >= editor.state.doc.content.size || !isValid) return false; + + // Apply URL change + const tr = editor.state.tr; + tr.removeMark(from, to, editor.schema.marks.link).addMark(from, to, editor.schema.marks.link.create({ href: url })); + editor.view.dispatch(tr); + + // Apply text change if different + if (localText !== initialText) { + const node = editor.view.state.doc.nodeAt(from) as Node; + if (!node || !node.marks) return false; + + editor + .chain() + .setTextSelection(from) + .deleteRange({ from: positionRef.from, to: positionRef.to }) + .insertContent(localText) + .setTextSelection({ from, to: from + localText.length }) + .run(); + // + // Restore marks + node.marks.forEach((mark) => { + editor.chain().setMark(mark.type.name, mark.attrs).run(); + }); } - const node = editor.view.state.doc.nodeAt(from) as Node; - if (!node) return; - const marks = node.marks; - if (!marks) return; + return true; + }, [editor, from, to, initialText, localText, localUrl]); - editor.chain().setTextSelection(from).run(); - - editor.chain().deleteRange({ from: positionRef.from, to: positionRef.to }).run(); - editor.chain().insertContent(text).run(); - - editor - .chain() - .setTextSelection({ - from: from, - to: from + text.length, - }) - .run(); - - setPositionRef({ from: from, to: from + text.length }); - - marks.forEach((mark) => { - editor.chain().setMark(mark.type.name, mark.attrs).run(); - }); - }; - - const removeLink = () => { + const removeLink = useCallback(() => { editor.view.dispatch(editor.state.tr.removeMark(from, to, editor.schema.marks.link)); - linkRemoved.current = true; - viewProps.closeLinkView(); - }; + setLinkRemoved(true); + closeLinkView(); + }, [editor, from, to, closeLinkView]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.stopPropagation(); + if (applyChanges()) { + closeLinkView(); + setLocalUrl(""); + setLocalText(""); + } + } + }, + [applyChanges, closeLinkView] + ); return (
e.key === "Enter" && viewProps.closeLinkView()} - className="shadow-md rounded p-2 flex flex-col gap-3 bg-custom-background-90 border-custom-border-100 border-2" + onKeyDown={handleKeyDown} + className="shadow-md rounded p-2 flex flex-col gap-3 bg-custom-background-90 border-custom-border-100 border-2 animate-in fade-in translate-y-1" + style={{ + transition: "all 0.1s cubic-bezier(.55, .085, .68, .53)", + }} + tabIndex={0} > - handleUpdateLink(e.target.value)} - /> - handleUpdateText(e.target.value)} - /> + +
-
diff --git a/packages/editor/src/core/components/links/link-input-view.tsx b/packages/editor/src/core/components/links/link-input-view.tsx deleted file mode 100644 index a66d80e6d..000000000 --- a/packages/editor/src/core/components/links/link-input-view.tsx +++ /dev/null @@ -1,7 +0,0 @@ -// components -import { LinkViewProps } from "@/components/links"; - -export const LinkInputView = ({}: { - viewProps: LinkViewProps; - switchView: (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => void; -}) =>

LinkInputView

; diff --git a/packages/editor/src/core/components/links/link-preview.tsx b/packages/editor/src/core/components/links/link-preview.tsx index 1237c7c98..dd41f2306 100644 --- a/packages/editor/src/core/components/links/link-preview.tsx +++ b/packages/editor/src/core/components/links/link-preview.tsx @@ -1,13 +1,13 @@ import { Copy, GlobeIcon, Link2Off, PencilIcon } from "lucide-react"; // components -import { LinkViewProps } from "@/components/links"; +import { LinkViewProps, LinkViews } from "@/components/links"; export const LinkPreview = ({ viewProps, switchView, }: { viewProps: LinkViewProps; - switchView: (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => void; + switchView: (view: LinkViews) => void; }) => { const { editor, from, to, url } = viewProps; @@ -22,20 +22,32 @@ export const LinkPreview = ({ }; return ( -
+
-

{url.length > 40 ? url.slice(0, 40) + "..." : url}

+

{url?.length > 40 ? url.slice(0, 40) + "..." : url}

- - - + {editor.isEditable && ( + <> + + + + )}
diff --git a/packages/editor/src/core/components/links/link-view.tsx b/packages/editor/src/core/components/links/link-view.tsx index 988250387..699f94e40 100644 --- a/packages/editor/src/core/components/links/link-view.tsx +++ b/packages/editor/src/core/components/links/link-view.tsx @@ -1,22 +1,25 @@ -import { CSSProperties, useEffect, useState } from "react"; import { Editor } from "@tiptap/react"; +import { CSSProperties, useEffect, useState } from "react"; // components -import { LinkEditView, LinkInputView, LinkPreview } from "@/components/links"; +import { LinkEditView, LinkPreview } from "@/components/links"; + +export type LinkViews = "LinkPreview" | "LinkEditView"; export interface LinkViewProps { - view?: "LinkPreview" | "LinkEditView" | "LinkInputView"; + view?: LinkViews; editor: Editor; from: number; to: number; url: string; + text?: string; closeLinkView: () => void; } export const LinkView = (props: LinkViewProps & { style: CSSProperties }) => { - const [currentView, setCurrentView] = useState(props.view ?? "LinkInputView"); + const [currentView, setCurrentView] = useState(props.view ?? "LinkPreview"); const [prevFrom, setPrevFrom] = useState(props.from); - const switchView = (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => { + const switchView = (view: LinkViews) => { setCurrentView(view); }; @@ -27,16 +30,10 @@ export const LinkView = (props: LinkViewProps & { style: CSSProperties }) => { } }, []); - const renderView = () => { - switch (currentView) { - case "LinkPreview": - return ; - case "LinkEditView": - return ; - case "LinkInputView": - return ; - } - }; - - return renderView(); + return ( + <> + {currentView === "LinkPreview" && } + {currentView === "LinkEditView" && } + + ); }; diff --git a/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx b/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx index 333edf78a..1dd47c5bb 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx @@ -23,11 +23,11 @@ export const BubbleMenuLinkSelector: FC = (props) => { const handleLinkSubmit = useCallback(() => { const input = inputRef.current; if (!input) return; - let url = input.value; + const url = input.value; if (!url) return; - if (!url.startsWith("http")) url = `http://${url}`; - if (isValidHttpUrl(url)) { - setLinkEditor(editor, url); + const { isValid, url: validatedUrl } = isValidHttpUrl(url); + if (isValid) { + setLinkEditor(editor, validatedUrl); setIsOpen(false); setError(false); } else { diff --git a/packages/editor/src/core/components/menus/bubble-menu/root.tsx b/packages/editor/src/core/components/menus/bubble-menu/root.tsx index 149c6f6c2..02eb8d486 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/root.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/root.tsx @@ -91,6 +91,7 @@ export const EditorBubbleMenu: FC = (props: { editor: Edi empty || !editor.isEditable || editor.isActive("image") || + editor.isActive("imageComponent") || isNodeSelection(selection) || isCellSelection(selection) || isSelecting @@ -102,7 +103,12 @@ export const EditorBubbleMenu: FC = (props: { editor: Edi tippyOptions: { moveTransition: "transform 0.15s ease-out", duration: [300, 0], + zIndex: 9, + onShow: () => { + props.editor.storage.link.isBubbleMenuOpen = true; + }, onHidden: () => { + props.editor.storage.link.isBubbleMenuOpen = false; setIsNodeSelectorOpen(false); setIsLinkSelectorOpen(false); setIsColorSelectorOpen(false); diff --git a/packages/editor/src/core/components/menus/menu-items.ts b/packages/editor/src/core/components/menus/menu-items.ts index 8cb4accc5..4268ccb6c 100644 --- a/packages/editor/src/core/components/menus/menu-items.ts +++ b/packages/editor/src/core/components/menus/menu-items.ts @@ -142,8 +142,8 @@ export const UnderLineItem = (editor: Editor): EditorMenuItem<"underline"> => ({ icon: UnderlineIcon, }); -export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strike"> => ({ - key: "strike", +export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strikethrough"> => ({ + key: "strikethrough", name: "Strikethrough", isActive: () => editor?.isActive("strike"), command: () => toggleStrike(editor), diff --git a/packages/editor/src/core/constants/common.ts b/packages/editor/src/core/constants/common.ts index bae06d303..8961bcd91 100644 --- a/packages/editor/src/core/constants/common.ts +++ b/packages/editor/src/core/constants/common.ts @@ -87,7 +87,7 @@ export const TEXT_ALIGNMENT_ITEMS: ToolbarMenuItem<"text-align">[] = [ }, ]; -const BASIC_MARK_ITEMS: ToolbarMenuItem<"bold" | "italic" | "underline" | "strike">[] = [ +const BASIC_MARK_ITEMS: ToolbarMenuItem<"bold" | "italic" | "underline" | "strikethrough">[] = [ { itemKey: "bold", renderKey: "bold", @@ -113,7 +113,7 @@ const BASIC_MARK_ITEMS: ToolbarMenuItem<"bold" | "italic" | "underline" | "strik editors: ["lite", "document"], }, { - itemKey: "strike", + itemKey: "strikethrough", renderKey: "strikethrough", name: "Strikethrough", icon: Strikethrough, diff --git a/packages/editor/src/core/constants/config.ts b/packages/editor/src/core/constants/config.ts index 788454f96..ac6d63dd1 100644 --- a/packages/editor/src/core/constants/config.ts +++ b/packages/editor/src/core/constants/config.ts @@ -5,6 +5,7 @@ export const DEFAULT_DISPLAY_CONFIG: TDisplayConfig = { fontSize: "large-font", fontStyle: "sans-serif", lineSpacing: "regular", + wideLayout: false, }; export const ACCEPTED_FILE_MIME_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"]; diff --git a/packages/editor/src/core/extensions/clipboard.ts b/packages/editor/src/core/extensions/clipboard.ts new file mode 100644 index 000000000..252f0a113 --- /dev/null +++ b/packages/editor/src/core/extensions/clipboard.ts @@ -0,0 +1,89 @@ +import { Extension } from "@tiptap/core"; +import { Fragment, Node } from "@tiptap/pm/model"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; + +export const MarkdownClipboard = Extension.create({ + name: "markdownClipboard", + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey("markdownClipboard"), + props: { + clipboardTextSerializer: (slice) => { + const markdownSerializer = this.editor.storage.markdown.serializer; + const isTableRow = slice.content.firstChild?.type?.name === "tableRow"; + const nodeSelect = slice.openStart === 0 && slice.openEnd === 0; + + if (nodeSelect) { + return markdownSerializer.serialize(slice.content); + } + + const processTableContent = (tableNode: Node | Fragment) => { + let result = ""; + tableNode.content?.forEach?.((tableRowNode: Node | Fragment) => { + tableRowNode.content?.forEach?.((cell: Node) => { + const cellContent = cell.content ? markdownSerializer.serialize(cell.content) : ""; + result += cellContent + "\n"; + }); + }); + return result; + }; + + if (isTableRow) { + const rowsCount = slice.content?.childCount || 0; + const cellsCount = slice.content?.firstChild?.content?.childCount || 0; + if (rowsCount === 1 || cellsCount === 1) { + return processTableContent(slice.content); + } else { + return markdownSerializer.serialize(slice.content); + } + } + + const traverseToParentOfLeaf = ( + node: Node | null, + parent: Fragment | Node, + depth: number + ): Node | Fragment => { + let currentNode = node; + let currentParent = parent; + let currentDepth = depth; + + while (currentNode && currentDepth > 1 && currentNode.content?.firstChild) { + if (currentNode.content?.childCount > 1) { + if (currentNode.content.firstChild?.type?.name === "listItem") { + return currentParent; + } else { + return currentNode.content; + } + } + + currentParent = currentNode; + currentNode = currentNode.content?.firstChild || null; + currentDepth--; + } + + return currentParent; + }; + + if (slice.content.childCount > 1) { + return markdownSerializer.serialize(slice.content); + } else { + const targetNode = traverseToParentOfLeaf(slice.content.firstChild, slice.content, slice.openStart); + + let currentNode = targetNode; + while (currentNode && currentNode.content && currentNode.childCount === 1 && currentNode.firstChild) { + currentNode = currentNode.firstChild; + } + if (currentNode instanceof Node && currentNode.isText) { + return currentNode.text; + } + + return markdownSerializer.serialize(targetNode); + } + }, + }, + }), + ]; + }, +}); diff --git a/packages/editor/src/core/extensions/core-without-props.ts b/packages/editor/src/core/extensions/core-without-props.ts index 9bad7a5c7..ed9f5c1a4 100644 --- a/packages/editor/src/core/extensions/core-without-props.ts +++ b/packages/editor/src/core/extensions/core-without-props.ts @@ -66,7 +66,7 @@ export const CoreEditorExtensionsWithoutProps = [ autolink: true, linkOnPaste: true, protocols: ["http", "https"], - validate: (url: string) => isValidHttpUrl(url), + validate: (url: string) => isValidHttpUrl(url).isValid, HTMLAttributes: { class: "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", diff --git a/packages/editor/src/core/extensions/custom-image/components/image-node.tsx b/packages/editor/src/core/extensions/custom-image/components/image-node.tsx index de2a20ab9..e525bc6da 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-node.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/image-node.tsx @@ -2,6 +2,7 @@ import { Editor, NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { useEffect, useRef, useState } from "react"; // extensions import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image"; +import { getExtensionStorage } from "@/helpers/get-extension-storage"; export type CustoBaseImageNodeViewProps = { getPos: () => number; @@ -76,7 +77,7 @@ export const CustomImageNode = (props: CustomImageNodeProps) => { failedToLoadImage={failedToLoadImage} getPos={getPos} loadImageFromFileSystem={setImageFromFileSystem} - maxFileSize={editor.storage.imageComponent?.maxFileSize} + maxFileSize={getExtensionStorage(editor, "imageComponent").maxFileSize} node={node} setIsUploaded={setIsUploaded} selected={selected} diff --git a/packages/editor/src/core/extensions/custom-image/custom-image.ts b/packages/editor/src/core/extensions/custom-image/custom-image.ts index 3e35fd0e3..4f1b3c8db 100644 --- a/packages/editor/src/core/extensions/custom-image/custom-image.ts +++ b/packages/editor/src/core/extensions/custom-image/custom-image.ts @@ -35,6 +35,9 @@ export const getImageComponentImageFileMap = (editor: Editor) => export interface UploadImageExtensionStorage { assetsUploadStatus: TFileHandler["assetsUploadStatus"]; fileMap: Map; + deletedImageSet: Map; + uploadInProgress: boolean; + maxFileSize: number; } export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean }; diff --git a/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts b/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts index 78237d678..0d8a7cc55 100644 --- a/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts +++ b/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts @@ -7,7 +7,7 @@ import { CustomImageNode, UploadImageExtensionStorage } from "@/extensions/custo import { TReadOnlyFileHandler } from "@/types"; export const CustomReadOnlyImageExtension = (props: TReadOnlyFileHandler) => { - const { getAssetSrc } = props; + const { getAssetSrc, restore: restoreImageFn } = props; return Image.extend, UploadImageExtensionStorage>({ name: "imageComponent", @@ -52,6 +52,9 @@ export const CustomReadOnlyImageExtension = (props: TReadOnlyFileHandler) => { addStorage() { return { fileMap: new Map(), + deletedImageSet: new Map(), + uploadInProgress: false, + maxFileSize: 0, // escape markdown for images markdown: { serialize() {}, @@ -63,6 +66,9 @@ export const CustomReadOnlyImageExtension = (props: TReadOnlyFileHandler) => { addCommands() { return { getImageSource: (path: string) => async () => await getAssetSrc(path), + restoreImage: (src) => async () => { + await restoreImageFn(src); + }, }; }, diff --git a/packages/editor/src/core/extensions/custom-link/extension.tsx b/packages/editor/src/core/extensions/custom-link/extension.tsx index ee065f512..27c1bb598 100644 --- a/packages/editor/src/core/extensions/custom-link/extension.tsx +++ b/packages/editor/src/core/extensions/custom-link/extension.tsx @@ -73,7 +73,12 @@ declare module "@tiptap/core" { } } -export const CustomLinkExtension = Mark.create({ +export type CustomLinkStorage = { + isPreviewOpen: boolean; + posToInsert: { from: number; to: number }; +}; + +export const CustomLinkExtension = Mark.create({ name: "link", priority: 1000, @@ -242,4 +247,12 @@ export const CustomLinkExtension = Mark.create({ return plugins; }, + + addStorage() { + return { + isPreviewOpen: false, + isBubbleMenuOpen: false, + posToInsert: { from: 0, to: 0 }, + }; + }, }); diff --git a/packages/editor/src/core/extensions/extensions.tsx b/packages/editor/src/core/extensions/extensions.tsx index 8b9290d62..ff200cd32 100644 --- a/packages/editor/src/core/extensions/extensions.tsx +++ b/packages/editor/src/core/extensions/extensions.tsx @@ -29,6 +29,7 @@ import { TableCell, TableHeader, TableRow, + MarkdownClipboard, } from "@/extensions"; // helpers import { isValidHttpUrl } from "@/helpers/common"; @@ -101,7 +102,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => { autolink: true, linkOnPaste: true, protocols: ["http", "https"], - validate: (url: string) => isValidHttpUrl(url), + validate: (url: string) => isValidHttpUrl(url).isValid, HTMLAttributes: { class: "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", @@ -130,10 +131,11 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => { CustomCodeInlineExtension, Markdown.configure({ html: true, - transformCopiedText: true, + transformCopiedText: false, transformPastedText: true, breaks: true, }), + MarkdownClipboard, Table, TableHeader, TableCell, diff --git a/packages/editor/src/core/extensions/headers.ts b/packages/editor/src/core/extensions/headers.ts index 3960d5f03..958cf6ca3 100644 --- a/packages/editor/src/core/extensions/headers.ts +++ b/packages/editor/src/core/extensions/headers.ts @@ -8,7 +8,11 @@ export interface IMarking { sequence: number; } -export const HeadingListExtension = Extension.create({ +export type HeadingExtensionStorage = { + headings: IMarking[]; +}; + +export const HeadingListExtension = Extension.create({ name: "headingList", addStorage() { diff --git a/packages/editor/src/core/extensions/image/extension.tsx b/packages/editor/src/core/extensions/image/extension.tsx index f549719f2..6766b4d0c 100644 --- a/packages/editor/src/core/extensions/image/extension.tsx +++ b/packages/editor/src/core/extensions/image/extension.tsx @@ -1,13 +1,13 @@ import ImageExt from "@tiptap/extension-image"; import { ReactNodeViewRenderer } from "@tiptap/react"; +// extensions +import { CustomImageNode } from "@/extensions"; // helpers import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary"; // plugins import { ImageExtensionStorage, TrackImageDeletionPlugin, TrackImageRestorationPlugin } from "@/plugins/image"; // types import { TFileHandler } from "@/types"; -// extensions -import { CustomImageNode } from "@/extensions"; export const ImageExtension = (fileHandler: TFileHandler) => { const { diff --git a/packages/editor/src/core/extensions/image/image-component-without-props.tsx b/packages/editor/src/core/extensions/image/image-component-without-props.tsx index 7167e622c..c17bcc559 100644 --- a/packages/editor/src/core/extensions/image/image-component-without-props.tsx +++ b/packages/editor/src/core/extensions/image/image-component-without-props.tsx @@ -1,10 +1,10 @@ import { mergeAttributes } from "@tiptap/core"; import { Image } from "@tiptap/extension-image"; // extensions -import { UploadImageExtensionStorage } from "@/extensions"; +import { ImageExtensionStorage } from "@/plugins/image"; export const CustomImageComponentWithoutProps = () => - Image.extend, UploadImageExtensionStorage>({ + Image.extend, ImageExtensionStorage>({ name: "imageComponent", selectable: true, group: "block", @@ -48,6 +48,8 @@ export const CustomImageComponentWithoutProps = () => return { fileMap: new Map(), deletedImageSet: new Map(), + uploadInProgress: false, + maxFileSize: 0, assetsUploadStatus: {}, }; }, diff --git a/packages/editor/src/core/extensions/index.ts b/packages/editor/src/core/extensions/index.ts index d1fa0ce6d..e98607585 100644 --- a/packages/editor/src/core/extensions/index.ts +++ b/packages/editor/src/core/extensions/index.ts @@ -23,3 +23,4 @@ export * from "./quote"; export * from "./read-only-extensions"; export * from "./side-menu"; export * from "./text-align"; +export * from "./clipboard"; diff --git a/packages/editor/src/core/extensions/mentions/extension-config.ts b/packages/editor/src/core/extensions/mentions/extension-config.ts index 827137a1d..e75fc9156 100644 --- a/packages/editor/src/core/extensions/mentions/extension-config.ts +++ b/packages/editor/src/core/extensions/mentions/extension-config.ts @@ -1,15 +1,22 @@ import { mergeAttributes } from "@tiptap/core"; import Mention, { MentionOptions } from "@tiptap/extension-mention"; +import { MarkdownSerializerState } from "@tiptap/pm/markdown"; +import { Node as NodeType } from "@tiptap/pm/model"; // types import { TMentionHandler } from "@/types"; // local types -import { EMentionComponentAttributeNames } from "./types"; +import { EMentionComponentAttributeNames, TMentionComponentAttributes } from "./types"; export type TMentionExtensionOptions = MentionOptions & { renderComponent: TMentionHandler["renderComponent"]; + getMentionedEntityDetails: TMentionHandler["getMentionedEntityDetails"]; }; -export const CustomMentionExtensionConfig = Mention.extend({ +export type MentionExtensionStorage = { + mentionsOpen: boolean; +}; + +export const CustomMentionExtensionConfig = Mention.extend({ addAttributes() { return { [EMentionComponentAttributeNames.ID]: { @@ -40,9 +47,26 @@ export const CustomMentionExtensionConfig = Mention.extend { - const { searchCallback, renderComponent } = props; + const { searchCallback, renderComponent, getMentionedEntityDetails } = props; return CustomMentionExtensionConfig.extend({ addOptions(this) { return { ...this.parent?.(), renderComponent, + getMentionedEntityDetails, }; }, diff --git a/packages/editor/src/core/extensions/read-only-extensions.tsx b/packages/editor/src/core/extensions/read-only-extensions.tsx index b949fe6b7..3881c548b 100644 --- a/packages/editor/src/core/extensions/read-only-extensions.tsx +++ b/packages/editor/src/core/extensions/read-only-extensions.tsx @@ -24,6 +24,7 @@ import { CustomTextAlignExtension, CustomCalloutReadOnlyExtension, CustomColorExtension, + MarkdownClipboard, } from "@/extensions"; // helpers import { isValidHttpUrl } from "@/helpers/common"; @@ -86,7 +87,7 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => { autolink: true, linkOnPaste: true, protocols: ["http", "https"], - validate: (url: string) => isValidHttpUrl(url), + validate: (url: string) => isValidHttpUrl(url).isValid, HTMLAttributes: { class: "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", @@ -114,8 +115,9 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => { CustomCodeInlineExtension, Markdown.configure({ html: true, - transformCopiedText: true, + transformCopiedText: false, }), + MarkdownClipboard, Table, TableHeader, TableCell, diff --git a/packages/editor/src/core/extensions/table/table/table-controls.ts b/packages/editor/src/core/extensions/table/table/table-controls.ts index bd5f8f589..052922579 100644 --- a/packages/editor/src/core/extensions/table/table/table-controls.ts +++ b/packages/editor/src/core/extensions/table/table/table-controls.ts @@ -16,6 +16,22 @@ export function tableControls() { }, }, props: { + handleTripleClickOn(view, pos, node, nodePos, event, direct) { + if (node.type.name === 'tableCell') { + event.preventDefault(); + const $pos = view.state.doc.resolve(pos); + const line = $pos.parent; + const linePos = $pos.start(); + const start = linePos; + const end = linePos + line.nodeSize - 1; + const tr = view.state.tr.setSelection( + TextSelection.create(view.state.doc, start, end) + ); + view.dispatch(tr); + return true; + } + return false; + }, handleDOMEvents: { mousemove: (view, event) => { const pluginState = key.getState(view.state); diff --git a/packages/editor/src/core/helpers/common.ts b/packages/editor/src/core/helpers/common.ts index 8638d2c15..36075caf2 100644 --- a/packages/editor/src/core/helpers/common.ts +++ b/packages/editor/src/core/helpers/common.ts @@ -44,16 +44,48 @@ export const getTrimmedHTML = (html: string) => { return html; }; -export const isValidHttpUrl = (string: string): boolean => { - let url: URL; +export const isValidHttpUrl = (string: string): { isValid: boolean; url: string } => { + // List of potentially dangerous protocols to block + const blockedProtocols = ["javascript:", "data:", "vbscript:", "file:", "about:"]; + // First try with the original string try { - url = new URL(string); + const url = new URL(string); + + // Check for potentially dangerous protocols + const protocol = url.protocol.toLowerCase(); + if (blockedProtocols.some((p) => protocol === p)) { + return { + isValid: false, + url: string, + }; + } + + // If URL has any valid protocol, return as is + if (url.protocol && url.protocol !== "") { + return { + isValid: true, + url: string, + }; + } } catch (_) { - return false; + // Original string wasn't a valid URL - that's okay, we'll try with https } - return url.protocol === "http:" || url.protocol === "https:"; + // Try again with https:// prefix + try { + const urlWithHttps = `https://${string}`; + new URL(urlWithHttps); + return { + isValid: true, + url: urlWithHttps, + }; + } catch (_) { + return { + isValid: false, + url: string, + }; + } }; export const getParagraphCount = (editorState: EditorState | undefined) => { diff --git a/packages/editor/src/core/helpers/editor-commands.ts b/packages/editor/src/core/helpers/editor-commands.ts index 71072f097..39796ac24 100644 --- a/packages/editor/src/core/helpers/editor-commands.ts +++ b/packages/editor/src/core/helpers/editor-commands.ts @@ -1,10 +1,10 @@ import { Editor, Range } from "@tiptap/core"; +// types +import { InsertImageComponentProps } from "@/extensions"; // extensions import { replaceCodeWithText } from "@/extensions/code/utils/replace-code-block-with-text"; // helpers import { findTableAncestor } from "@/helpers/common"; -// types -import { InsertImageComponentProps } from "@/extensions"; export const setText = (editor: Editor, range?: Range) => { if (range) editor.chain().focus().deleteRange(range).setNode("paragraph").run(); diff --git a/packages/editor/src/core/helpers/get-extension-storage.ts b/packages/editor/src/core/helpers/get-extension-storage.ts new file mode 100644 index 000000000..0107f8425 --- /dev/null +++ b/packages/editor/src/core/helpers/get-extension-storage.ts @@ -0,0 +1,23 @@ +import { Editor } from "@tiptap/core"; +import { + CustomLinkStorage, + HeadingExtensionStorage, + MentionExtensionStorage, + UploadImageExtensionStorage, +} from "@/extensions"; +import { ImageExtensionStorage } from "@/plugins/image"; + +type ExtensionNames = "imageComponent" | "image" | "link" | "headingList" | "mention"; + +interface ExtensionStorageMap { + imageComponent: UploadImageExtensionStorage; + image: ImageExtensionStorage; + link: CustomLinkStorage; + headingList: HeadingExtensionStorage; + mention: MentionExtensionStorage; +} + +export const getExtensionStorage = ( + editor: Editor, + extensionName: K +): ExtensionStorageMap[K] => editor.storage[extensionName]; diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index e520305ba..cf9d04d83 100644 --- a/packages/editor/src/core/hooks/use-editor.ts +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -145,8 +145,8 @@ export const useEditor = (props: CustomEditorProps) => { clearEditor: (emitUpdate = false) => { editor?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run(); }, - setEditorValue: (content: string) => { - editor?.commands.setContent(content, false, { preserveWhitespace: "full" }); + setEditorValue: (content: string, emitUpdate = false) => { + editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: "full" }); }, setEditorValueAtCursorPosition: (content: string) => { if (editor?.state.selection) { diff --git a/packages/editor/src/core/hooks/use-read-only-editor.ts b/packages/editor/src/core/hooks/use-read-only-editor.ts index 6d33c0f8a..b50b56b02 100644 --- a/packages/editor/src/core/hooks/use-read-only-editor.ts +++ b/packages/editor/src/core/hooks/use-read-only-editor.ts @@ -77,8 +77,8 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { clearEditor: (emitUpdate = false) => { editor?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run(); }, - setEditorValue: (content: string) => { - editor?.commands.setContent(content, false, { preserveWhitespace: "full" }); + setEditorValue: (content: string, emitUpdate = false) => { + editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: "full" }); }, getMarkDown: (): string => { const markdownOutput = editor?.storage.markdown.getMarkdown(); diff --git a/packages/editor/src/core/types/config.ts b/packages/editor/src/core/types/config.ts index 12df0aa42..4c91fec5d 100644 --- a/packages/editor/src/core/types/config.ts +++ b/packages/editor/src/core/types/config.ts @@ -29,4 +29,5 @@ export type TDisplayConfig = { fontStyle?: TEditorFontStyle; fontSize?: TEditorFontSize; lineSpacing?: TEditorLineSpacing; + wideLayout?: boolean; }; diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index edf696ab8..647f52e7d 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -32,7 +32,7 @@ export type TEditorCommands = | "bold" | "italic" | "underline" - | "strike" + | "strikethrough" | "bulleted-list" | "numbered-list" | "to-do-list" @@ -84,7 +84,7 @@ export type EditorReadOnlyRefApi = { json: JSONContent | null; }; clearEditor: (emitUpdate?: boolean) => void; - setEditorValue: (content: string) => void; + setEditorValue: (content: string, emitUpdate?: boolean) => void; scrollSummary: (marking: IMarking) => void; getDocumentInfo: () => { characters: number; @@ -131,13 +131,13 @@ export interface IEditorProps { placeholder?: string | ((isFocused: boolean, value: string) => string); tabIndex?: number; value?: string | null; + bubbleMenuEnabled?: boolean; } export interface ILiteTextEditor extends IEditorProps { extensions?: Extensions; } export interface IRichTextEditor extends IEditorProps { extensions?: Extensions; - bubbleMenuEnabled?: boolean; dragDropEnabled?: boolean; } @@ -196,3 +196,15 @@ export type TRealtimeConfig = { url: string; queryParams: TWebhookConnectionQueryParams; }; + +export interface EditorEvents { + beforeCreate: never; + create: never; + update: never; + selectionUpdate: never; + transaction: never; + focus: never; + blur: never; + destroy: never; + ready: { height: number }; +} diff --git a/packages/editor/src/core/types/mention.ts b/packages/editor/src/core/types/mention.ts index 20f1ec0dc..b7a65f8b4 100644 --- a/packages/editor/src/core/types/mention.ts +++ b/packages/editor/src/core/types/mention.ts @@ -1,5 +1,5 @@ // plane types -import { TSearchEntities } from "@plane/types"; +import { IUserLite, TSearchEntities } from "@plane/types"; export type TMentionSuggestion = { entity_identifier: string; @@ -20,6 +20,7 @@ export type TMentionComponentProps = Pick React.ReactNode; + getMentionedEntityDetails?: (entity_identifier: string) => { display_name: string } | undefined; }; export type TMentionHandler = TReadOnlyMentionHandler & { diff --git a/packages/editor/src/styles/editor.css b/packages/editor/src/styles/editor.css index be686a5cc..ba910d144 100644 --- a/packages/editor/src/styles/editor.css +++ b/packages/editor/src/styles/editor.css @@ -8,7 +8,7 @@ -moz-user-select: text; -ms-user-select: text; user-select: text; - outline: none; + outline: none !important; cursor: text; font-family: var(--font-style); font-size: var(--font-size-regular); diff --git a/packages/editor/src/styles/variables.css b/packages/editor/src/styles/variables.css index ea70fe1ab..44690cf52 100644 --- a/packages/editor/src/styles/variables.css +++ b/packages/editor/src/styles/variables.css @@ -1,3 +1,47 @@ +: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 */ + + /* layout */ + --normal-content-width: 720px; + --wide-content-width: 1152px; + --normal-content-margin: 20px; + --wide-content-margin: 96px; + /* end layout */ +} + +/* text background colors */ +[data-theme*="light"] { + --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"] { + --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 */ + +/* font size and style */ .editor-container { --color-placeholder: rgba(var(--color-text-100), 0.5); @@ -47,6 +91,8 @@ /* end font sizes and line heights */ /* font styles */ + --font-style: "Inter", sans-serif; + &.sans-serif { --font-style: "Inter", sans-serif; } @@ -102,3 +148,94 @@ } /* end spacing */ } +/* end font size and style */ + +/* layout config */ +#page-toolbar-container { + container-name: page-toolbar-container; + container-type: inline-size; + + .page-toolbar-content { + --header-width: var(--normal-content-width); + + &.wide-layout { + --header-width: var(--wide-content-width); + } + + padding-left: calc(((100% - var(--header-width)) / 2) - 10px); + } +} + +#page-content-container { + container-name: page-content-container; + container-type: inline-size; +} + +.editor-container.document-editor { + --editor-content-width: var(--normal-content-width); + + &.wide-layout { + --editor-content-width: var(--wide-content-width); + } + + .ProseMirror { + max-width: var(--editor-content-width); + margin: 0 auto; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + } +} + +/* keep a static padding of 96px for wide layouts for container width >912px and <1344px */ +@container page-toolbar-container (min-width: 912px) and (max-width: 1344px) { + .page-toolbar-content.wide-layout { + padding-left: var(--wide-content-margin) !important; + } +} + +/* keep a static padding of 96px for wide layouts for container width <912px */ +@container page-toolbar-container (max-width: 912) { + .page-toolbar-content.wide-layout { + padding-left: var(--wide-content-margin) !important; + } +} +/* end layout config */ + +/* keep a static padding of 20px for wide layouts for container width <760px */ +@container page-toolbar-container (max-width: 760px) { + .page-toolbar-content { + padding-left: var(--normal-content-margin) !important; + } +} +/* end layout config */ + +/* keep a static padding of 96px for wide layouts for container width >912px and <1344px */ +@container page-content-container (min-width: 912px) and (max-width: 1344px) { + .editor-container.wide-layout, + .page-header-container { + padding-left: var(--wide-content-margin); + padding-right: var(--wide-content-margin); + } +} + +/* keep a static padding of 20px for wide layouts for container width <912px */ +@container page-content-container (max-width: 912px) { + .editor-container.wide-layout, + .page-header-container { + padding-left: var(--normal-content-margin); + padding-right: var(--normal-content-margin); + } +} + +/* keep a static padding of 20px for normal layouts for container width <760px */ +@container page-content-container (max-width: 760px) { + .editor-container:not(.wide-layout), + .page-header-container { + padding-left: var(--normal-content-margin); + padding-right: var(--normal-content-margin); + } + + .page-summary-container { + display: none; + } +} +/* end layout config */ diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index 3a637efef..9ec353684 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -1,7 +1,7 @@ { "name": "@plane/eslint-config", "private": true, - "version": "0.25.3", + "version": "0.26.0", "license": "AGPL-3.0", "files": [ "library.js", diff --git a/packages/hooks/package.json b/packages/hooks/package.json index 72e7c1b74..d444bedda 100644 --- a/packages/hooks/package.json +++ b/packages/hooks/package.json @@ -1,6 +1,6 @@ { "name": "@plane/hooks", - "version": "0.25.3", + "version": "0.26.0", "license": "AGPL-3.0", "description": "React hooks that are shared across multiple apps internally", "private": true, diff --git a/packages/i18n/package.json b/packages/i18n/package.json index b3cfc7eb9..efcc0af93 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@plane/i18n", - "version": "0.25.3", + "version": "0.26.0", "license": "AGPL-3.0", "description": "I18n shared across multiple apps internally", "private": true, diff --git a/packages/i18n/src/constants/language.ts b/packages/i18n/src/constants/language.ts index 132ee68c9..d3d3a887a 100644 --- a/packages/i18n/src/constants/language.ts +++ b/packages/i18n/src/constants/language.ts @@ -17,6 +17,11 @@ export const SUPPORTED_LANGUAGES: ILanguageOption[] = [ { label: "Українська", value: "ua" }, { label: "Polski", value: "pl" }, { label: "한국어", value: "ko" }, + { label: "Português Brasil", value: "pt-BR" }, + { label: "Indonesian", value: "id" }, + { label: "Română", value: "ro" }, + { label: "Tiếng việt", value: "vi-VN" }, + { label: "Türkçe", value: "tr-TR" }, ]; export const LANGUAGE_STORAGE_KEY = "userLanguage"; diff --git a/packages/i18n/src/locales/cs/translations.json b/packages/i18n/src/locales/cs/translations.json index 1315ba75c..4c002389b 100644 --- a/packages/i18n/src/locales/cs/translations.json +++ b/packages/i18n/src/locales/cs/translations.json @@ -348,7 +348,7 @@ "couldnt_remove_the_project_from_favorites": "Nepodařilo se odstranit projekt z oblíbených. Zkuste to prosím znovu.", "add_to_favorites": "Přidat do oblíbených", "remove_from_favorites": "Odebrat z oblíbených", - "publish_settings": "Nastavení publikování", + "publish_project": "Publikovat projekt", "publish": "Publikovat", "copy_link": "Kopírovat odkaz", "leave_project": "Opustit projekt", @@ -499,6 +499,7 @@ "re_generate_key": "Znovu generovat klíč", "export": "Exportovat", "member": "{count, plural, one{# člen} few{# členové} other{# členů}}", + "new_password_must_be_different_from_old_password": "Nové heslo musí být odlišné od starého hesla", "project_view": { "sort_by": { @@ -589,10 +590,10 @@ "default": "Zatím nemáte žádné nedávné položky." }, "filters": { - "all": "Všechny položky", + "all": "Vše", "projects": "Projekty", "pages": "Stránky", - "issues": "Pracovní položky" + "issues": "Úkoly" } }, "new_at_plane": { @@ -866,7 +867,8 @@ "deleting": "Mazání", "pending": "Čekající", "invite": "Pozvat", - "view": "Pohled" + "view": "Pohled", + "deactivated_user": "Deaktivovaný uživatel" }, "chart": { @@ -1733,32 +1735,111 @@ } }, "estimates": { - "title": "Povolit odhady v projektu", - "description": "Pomáhají komunikovat složitost a vytížení týmu." + "label": "Odhady", + "title": "Povolit odhady pro můj projekt", + "description": "Pomáhají vám komunikovat složitost a pracovní zátěž týmu.", + "no_estimate": "Bez odhadu", + "new": "Nový systém odhadů", + "create": { + "custom": "Vlastní", + "start_from_scratch": "Začít od nuly", + "choose_template": "Vybrat šablonu", + "choose_estimate_system": "Vybrat systém odhadů", + "enter_estimate_point": "Zadat odhad", + "step": "Krok {step} z {total}", + "label": "Vytvořit odhad" + }, + "toasts": { + "created": { + "success": { + "title": "Odhad vytvořen", + "message": "Odhad byl úspěšně vytvořen" + }, + "error": { + "title": "Vytvoření odhadu selhalo", + "message": "Nepodařilo se vytvořit nový odhad, zkuste to prosím znovu." + } + }, + "updated": { + "success": { + "title": "Odhad upraven", + "message": "Odhad byl aktualizován ve vašem projektu." + }, + "error": { + "title": "Úprava odhadu selhala", + "message": "Nepodařilo se upravit odhad, zkuste to prosím znovu" + } + }, + "enabled": { + "success": { + "title": "Úspěch!", + "message": "Odhady byly povoleny." + } + }, + "disabled": { + "success": { + "title": "Úspěch!", + "message": "Odhady byly zakázány." + }, + "error": { + "title": "Chyba!", + "message": "Odhad nemohl být zakázán. Zkuste to prosím znovu" + } + } + }, + "validation": { + "min_length": "Odhad musí být větší než 0.", + "unable_to_process": "Nemůžeme zpracovat váš požadavek, zkuste to prosím znovu.", + "numeric": "Odhad musí být číselná hodnota.", + "character": "Odhad musí být znakový.", + "empty": "Hodnota odhadu nemůže být prázdná.", + "already_exists": "Hodnota odhadu již existuje.", + "unsaved_changes": "Máte neuložené změny. Před kliknutím na hotovo je prosím uložte", + "remove_empty": "Odhad nemůže být prázdný. Zadejte hodnotu do každého pole nebo odstraňte ta, pro která nemáte hodnoty." + }, + "systems": { + "points": { + "label": "Body", + "fibonacci": "Fibonacci", + "linear": "Lineární", + "squares": "Čtverce", + "custom": "Vlastní" + }, + "categories": { + "label": "Kategorie", + "t_shirt_sizes": "Velikosti triček", + "easy_to_hard": "Od snadného po těžké", + "custom": "Vlastní" + }, + "time": { + "label": "Čas", + "hours": "Hodiny" + } + } }, "automations": { "label": "Automatizace", "auto-archive": { - "title": "Automaticky archivovat uzavřené položky", - "description": "Plane bude archivovat dokončené nebo zrušené položky.", - "duration": "Archivovat položky uzavřené déle než" + "title": "Automaticky archivovat uzavřené pracovní položky", + "description": "Plane bude automaticky archivovat pracovní položky, které byly dokončeny nebo zrušeny.", + "duration": "Automaticky archivovat pracovní položky, které jsou uzavřené po dobu" }, "auto-close": { - "title": "Automaticky uzavírat položky", - "description": "Plane uzavře neaktivní položky.", - "duration": "Uzavřít položky neaktivní déle než", - "auto_close_status": "Stav pro automatické uzavření" + "title": "Automaticky uzavírat pracovní položky", + "description": "Plane automaticky uzavře pracovní položky, které nebyly dokončeny nebo zrušeny.", + "duration": "Automaticky uzavřít pracovní položky, které jsou neaktivní po dobu", + "auto_close_status": "Stav automatického uzavření" } }, "empty_state": { "labels": { - "title": "Žádné štítky", - "description": "Vytvářejte štítky pro organizaci položek." + "title": "Zatím žádné štítky", + "description": "Vytvořte štítky pro organizaci a filtrování pracovních položek ve vašem projektu." }, "estimates": { - "title": "Žádné systémy odhadů", - "description": "Vytvořte systém odhadů pro komunikaci vytížení.", + "title": "Zatím žádné systémy odhadů", + "description": "Vytvořte sadu odhadů pro komunikaci množství práce na pracovní položku.", "primary_button": "Přidat systém odhadů" } } @@ -1774,6 +1855,12 @@ "remove_filters_to_see_all_cycles": "Odeberte filtry pro zobrazení všech cyklů", "remove_search_criteria_to_see_all_cycles": "Odeberte kritéria pro zobrazení všech cyklů", "only_completed_cycles_can_be_archived": "Lze archivovat pouze dokončené cykly", + "start_date": "Začátek data", + "end_date": "Konec data", + "in_your_timezone": "V časovém pásmu", + "transfer_work_items": "Převést {count} pracovních položek", + "date_range": "Období data", + "add_date": "Přidat datum", "active_cycle": { "label": "Aktivní cyklus", "progress": "Pokrok", @@ -2365,5 +2452,11 @@ "module": { "label": "{count, plural, one {Modul} few {Moduly} other {Modulů}}", "no_module": "Žádný modul" + }, + + "description_versions": { + "last_edited_by": "Naposledy upraveno uživatelem", + "previously_edited_by": "Dříve upraveno uživatelem", + "edited_by": "Upraveno uživatelem" } } diff --git a/packages/i18n/src/locales/de/translations.json b/packages/i18n/src/locales/de/translations.json index 4a3a5c06d..6629d0796 100644 --- a/packages/i18n/src/locales/de/translations.json +++ b/packages/i18n/src/locales/de/translations.json @@ -348,7 +348,7 @@ "couldnt_remove_the_project_from_favorites": "Projekt konnte nicht aus den Favoriten entfernt werden. Bitte versuchen Sie es erneut.", "add_to_favorites": "Zu Favoriten hinzufügen", "remove_from_favorites": "Aus Favoriten entfernen", - "publish_settings": "Veröffentlichungseinstellungen", + "publish_project": "Projekt veröffentlichen", "publish": "Veröffentlichen", "copy_link": "Link kopieren", "leave_project": "Projekt verlassen", @@ -499,6 +499,8 @@ "re_generate_key": "Schlüssel neu generieren", "export": "Exportieren", "member": "{count, plural, one{# Mitglied} few{# Mitglieder} other{# Mitglieder}}", + "new_password_must_be_different_from_old_password": "Das neue Passwort muss von dem alten Passwort abweichen", + "project_view": { "sort_by": { "created_at": "Erstellt am", @@ -585,7 +587,7 @@ "default": "Sie haben noch keine kürzlichen Elemente." }, "filters": { - "all": "Alle Elemente", + "all": "Alle", "projects": "Projekte", "pages": "Seiten", "issues": "Arbeitselemente" @@ -860,7 +862,8 @@ "deleting": "Wird gelöscht", "pending": "Ausstehend", "invite": "Einladen", - "view": "Ansicht" + "view": "Ansicht", + "deactivated_user": "Deaktivierter Benutzer" }, "chart": { "x_axis": "X-Achse", @@ -1708,21 +1711,100 @@ } }, "estimates": { - "title": "Schätzungen im Projekt aktivieren", - "description": "Hilft, die Komplexität und Auslastung für das Team zu kommunizieren." + "label": "Schätzungen", + "title": "Schätzungen für mein Projekt aktivieren", + "description": "Sie helfen dir, die Komplexität und Arbeitsbelastung des Teams zu kommunizieren.", + "no_estimate": "Keine Schätzung", + "new": "Neues Schätzungssystem", + "create": { + "custom": "Benutzerdefiniert", + "start_from_scratch": "Von Grund auf neu", + "choose_template": "Vorlage wählen", + "choose_estimate_system": "Schätzungssystem wählen", + "enter_estimate_point": "Schätzung eingeben", + "step": "Schritt {step} von {total}", + "label": "Schätzung erstellen" + }, + "toasts": { + "created": { + "success": { + "title": "Schätzung erstellt", + "message": "Die Schätzung wurde erfolgreich erstellt" + }, + "error": { + "title": "Schätzungserstellung fehlgeschlagen", + "message": "Wir konnten die neue Schätzung nicht erstellen, bitte versuche es erneut." + } + }, + "updated": { + "success": { + "title": "Schätzung geändert", + "message": "Die Schätzung wurde in deinem Projekt aktualisiert." + }, + "error": { + "title": "Schätzungsänderung fehlgeschlagen", + "message": "Wir konnten die Schätzung nicht ändern, bitte versuche es erneut" + } + }, + "enabled": { + "success": { + "title": "Erfolg!", + "message": "Schätzungen wurden aktiviert." + } + }, + "disabled": { + "success": { + "title": "Erfolg!", + "message": "Schätzungen wurden deaktiviert." + }, + "error": { + "title": "Fehler!", + "message": "Schätzung konnte nicht deaktiviert werden. Bitte versuche es erneut" + } + } + }, + "validation": { + "min_length": "Die Schätzung muss größer als 0 sein.", + "unable_to_process": "Wir können deine Anfrage nicht verarbeiten, bitte versuche es erneut.", + "numeric": "Die Schätzung muss ein numerischer Wert sein.", + "character": "Die Schätzung muss ein Zeichenwert sein.", + "empty": "Der Schätzungswert darf nicht leer sein.", + "already_exists": "Der Schätzungswert existiert bereits.", + "unsaved_changes": "Du hast ungespeicherte Änderungen. Bitte speichere sie, bevor du auf Fertig klickst", + "remove_empty": "Die Schätzung darf nicht leer sein. Gib einen Wert in jedes Feld ein oder entferne die Felder, für die du keine Werte hast." + }, + "systems": { + "points": { + "label": "Punkte", + "fibonacci": "Fibonacci", + "linear": "Linear", + "squares": "Quadrate", + "custom": "Benutzerdefiniert" + }, + "categories": { + "label": "Kategorien", + "t_shirt_sizes": "T-Shirt-Größen", + "easy_to_hard": "Einfach bis schwer", + "custom": "Benutzerdefiniert" + }, + "time": { + "label": "Zeit", + "hours": "Stunden" + } + } }, "automations": { - "label": "Automatisierung", + "label": "Automatisierungen", "auto-archive": { - "title": "Abgeschlossene Elemente automatisch archivieren", - "description": "Plane archiviert abgeschlossene oder abgebrochene Elemente.", - "duration": "Archivieren von Elementen, die seit mehr als folgendem Zeitraum abgeschlossen sind" + "title": "Geschlossene Arbeitselemente automatisch archivieren", + "description": "Plane wird Arbeitselemente automatisch archivieren, die abgeschlossen oder abgebrochen wurden.", + "duration": "Arbeitselemente automatisch archivieren, die seit" }, "auto-close": { - "title": "Elemente automatisch schließen", - "description": "Plane schließt inaktive Elemente.", - "duration": "Schließen von Elementen, die seit mehr als folgendem Zeitraum inaktiv sind", - "auto_close_status": "Status für automatisches Schließen" + "title": "Arbeitselemente automatisch schließen", + "description": "Plane wird Arbeitselemente automatisch schließen, die nicht abgeschlossen oder abgebrochen wurden.", + "duration": "Inaktive Arbeitselemente automatisch schließen seit", + "auto_close_status": "Status der automatischen Schließung" } }, "empty_state": { @@ -1747,6 +1829,12 @@ "remove_filters_to_see_all_cycles": "Entfernen Sie Filter, um alle Zyklen anzuzeigen", "remove_search_criteria_to_see_all_cycles": "Entfernen Sie Suchkriterien, um alle Zyklen anzuzeigen", "only_completed_cycles_can_be_archived": "Nur abgeschlossene Zyklen können archiviert werden", + "start_date": "Startdatum", + "end_date": "Enddatum", + "in_your_timezone": "In Ihrer Zeitzone", + "transfer_work_items": "Übertragen von {count} Arbeitselementen", + "date_range": "Datumsbereich", + "add_date": "Datum hinzufügen", "active_cycle": { "label": "Aktiver Zyklus", "progress": "Fortschritt", @@ -2313,12 +2401,20 @@ "manual": "Manuell" } }, + "cycle": { "label": "{count, plural, one {Zyklus} few {Zyklen} other {Zyklen}}", "no_cycle": "Kein Zyklus" }, + "module": { "label": "{count, plural, one {Modul} few {Module} other {Module}}", "no_module": "Kein Modul" + }, + + "description_versions": { + "last_edited_by": "Zuletzt bearbeitet von", + "previously_edited_by": "Zuvor bearbeitet von", + "edited_by": "Bearbeitet von" } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/en/core.json b/packages/i18n/src/locales/en/core.json index 14207c5a8..a17de227e 100644 --- a/packages/i18n/src/locales/en/core.json +++ b/packages/i18n/src/locales/en/core.json @@ -9,7 +9,7 @@ "workspace": "Workspace", "views": "Views", "analytics": "Analytics", - "work_items": "Work Items", + "work_items": "Work items", "cycles": "Cycles", "modules": "Modules", "intake": "Intake", diff --git a/packages/i18n/src/locales/en/translations.json b/packages/i18n/src/locales/en/translations.json index 6beb2917b..002b4f89f 100644 --- a/packages/i18n/src/locales/en/translations.json +++ b/packages/i18n/src/locales/en/translations.json @@ -180,7 +180,7 @@ "couldnt_remove_the_project_from_favorites": "Couldn't remove the project from favorites. Please try again.", "add_to_favorites": "Add to favorites", "remove_from_favorites": "Remove from favorites", - "publish_settings": "Publish settings", + "publish_project": "Publish project", "publish": "Publish", "copy_link": "Copy link", "leave_project": "Leave project", @@ -331,6 +331,9 @@ "re_generate_key": "Re-generate key", "export": "Export", "member": "{count, plural, one{# member} other{# members}}", + "new_password_must_be_different_from_old_password": "New password must be different from old password", + "edited": "edited", + "bot": "Bot", "project_view": { "sort_by": { @@ -421,7 +424,7 @@ "default": "You don't have any recents yet." }, "filters": { - "all": "All items", + "all": "All", "projects": "Projects", "pages": "Pages", "issues": "Work items" @@ -627,7 +630,8 @@ "clear_sorting": "Clear sorting", "show_weekends": "Show weekends", "enable": "Enable", - "disable": "Disable" + "disable": "Disable", + "copy_markdown": "Copy markdown" }, "name": "Name", "discard": "Discard", @@ -698,7 +702,8 @@ "deleting": "Deleting", "pending": "Pending", "invite": "Invite", - "view": "View" + "view": "View", + "deactivated_user": "Deactivated user" }, "chart": { @@ -1565,8 +1570,87 @@ } }, "estimates": { + "label": "Estimates", "title": "Enable estimates for my project", - "description": "They help you in communicating complexity and workload of the team." + "description": "They help you in communicating complexity and workload of the team.", + "no_estimate": "No estimate", + "new": "New estimate system", + "create": { + "custom": "Custom", + "start_from_scratch": "Start from scratch", + "choose_template": "Choose a template", + "choose_estimate_system": "Choose an estimate system", + "enter_estimate_point": "Enter estimate", + "step": "Step {step} of {total}", + "label": "Create estimate" + }, + "toasts": { + "created": { + "success": { + "title": "Estimate created", + "message": "The estimate has been created successfully" + }, + "error": { + "title": "Estimate creation failed", + "message": "We were unable to create the new estimate, please try again." + } + }, + "updated": { + "success": { + "title": "Estimate modified", + "message": "The estimate has been updated in your project." + }, + "error": { + "title": "Estimate modification failed", + "message": "We were unable to modify the estimate, please try again" + } + }, + "enabled": { + "success": { + "title": "Success!", + "message": "Estimates have been enabled." + } + }, + "disabled": { + "success": { + "title": "Success!", + "message": "Estimates have been disabled." + }, + "error": { + "title": "Error!", + "message": "Estimate could not be disabled. Please try again" + } + } + }, + "validation": { + "min_length": "Estimate needs to be greater than 0.", + "unable_to_process": "We are unable to process your request, please try again.", + "numeric": "Estimate needs to be a numeric value.", + "character": "Estimate needs to be a character value.", + "empty": "Estimate value cannot be empty.", + "already_exists": "Estimate value already exists.", + "unsaved_changes": "You have some unsaved changes, Please save them before clicking on done", + "remove_empty": "Estimate can't be empty. Enter a value in each field or remove those you don't have values for." + }, + "systems": { + "points": { + "label": "Points", + "fibonacci": "Fibonacci", + "linear": "Linear", + "squares": "Squares", + "custom": "Custom" + }, + "categories": { + "label": "Categories", + "t_shirt_sizes": "T-Shirt Sizes", + "easy_to_hard": "Easy to hard", + "custom": "Custom" + }, + "time": { + "label": "Time", + "hours": "Hours" + } + } }, "automations": { "label": "Automations", @@ -1606,6 +1690,12 @@ "remove_filters_to_see_all_cycles": "Remove the filters to see all cycles", "remove_search_criteria_to_see_all_cycles": "Remove the search criteria to see all cycles", "only_completed_cycles_can_be_archived": "Only completed cycles can be archived", + "start_date": "Start date", + "end_date": "End date", + "in_your_timezone": "In your timezone", + "transfer_work_items": "Transfer {count} work items", + "date_range": "Date range", + "add_date": "Add date", "active_cycle": { "label": "Active cycle", "progress": "Progress", @@ -2197,5 +2287,11 @@ "module": { "label": "{count, plural, one {Module} other {Modules}}", "no_module": "No module" + }, + + "description_versions": { + "last_edited_by": "Last edited by", + "previously_edited_by": "Previously edited by", + "edited_by": "Edited by" } } diff --git a/packages/i18n/src/locales/es/translations.json b/packages/i18n/src/locales/es/translations.json index c349364d4..20f6da592 100644 --- a/packages/i18n/src/locales/es/translations.json +++ b/packages/i18n/src/locales/es/translations.json @@ -352,7 +352,7 @@ "couldnt_remove_the_project_from_favorites": "No se pudo eliminar el proyecto de favoritos. Por favor, inténtalo de nuevo.", "add_to_favorites": "Agregar a favoritos", "remove_from_favorites": "Eliminar de favoritos", - "publish_settings": "Configuración de publicación", + "publish_project": "Publicar proyecto", "publish": "Publicar", "copy_link": "Copiar enlace", "leave_project": "Abandonar proyecto", @@ -503,6 +503,9 @@ "re_generate_key": "Regenerar clave", "export": "Exportar", "member": "{count, plural, one{# miembro} other{# miembros}}", + "new_password_must_be_different_from_old_password": "La nueva contraseña debe ser diferente a la contraseña anterior", + "edited": "Modificado", + "bot": "Bot", "project_view": { "sort_by": { @@ -593,7 +596,7 @@ "default": "Aún no tienes elementos recientes." }, "filters": { - "all": "Todos los elementos", + "all": "Todos", "projects": "Proyectos", "pages": "Páginas", "issues": "Elementos de trabajo" @@ -869,7 +872,8 @@ "deleting": "Eliminando", "pending": "Pendiente", "invite": "Invitar", - "view": "Ver" + "view": "Ver", + "deactivated_user": "Usuario desactivado" }, "chart": { @@ -1735,32 +1739,111 @@ } }, "estimates": { - "title": "Habilitar estimaciones para mi proyecto", - "description": "Ayudan a comunicar la complejidad y la carga de trabajo del equipo." + "label": "Estimaciones", + "title": "Activar estimaciones para mi proyecto", + "description": "Te ayudan a comunicar la complejidad y la carga de trabajo del equipo.", + "no_estimate": "Sin estimación", + "new": "Nuevo sistema de estimación", + "create": { + "custom": "Personalizado", + "start_from_scratch": "Comenzar desde cero", + "choose_template": "Elegir una plantilla", + "choose_estimate_system": "Elegir un sistema de estimación", + "enter_estimate_point": "Ingresar estimación", + "step": "Paso {step} de {total}", + "label": "Crear estimación" + }, + "toasts": { + "created": { + "success": { + "title": "Estimación creada", + "message": "La estimación se ha creado correctamente" + }, + "error": { + "title": "Error al crear la estimación", + "message": "No pudimos crear la nueva estimación, por favor inténtalo de nuevo." + } + }, + "updated": { + "success": { + "title": "Estimación modificada", + "message": "La estimación se ha actualizado en tu proyecto." + }, + "error": { + "title": "Error al modificar la estimación", + "message": "No pudimos modificar la estimación, por favor inténtalo de nuevo" + } + }, + "enabled": { + "success": { + "title": "¡Éxito!", + "message": "Las estimaciones han sido activadas." + } + }, + "disabled": { + "success": { + "title": "¡Éxito!", + "message": "Las estimaciones han sido desactivadas." + }, + "error": { + "title": "¡Error!", + "message": "No se pudo desactivar la estimación. Por favor inténtalo de nuevo" + } + } + }, + "validation": { + "min_length": "La estimación debe ser mayor que 0.", + "unable_to_process": "No podemos procesar tu solicitud, por favor inténtalo de nuevo.", + "numeric": "La estimación debe ser un valor numérico.", + "character": "La estimación debe ser un valor de carácter.", + "empty": "El valor de la estimación no puede estar vacío.", + "already_exists": "El valor de la estimación ya existe.", + "unsaved_changes": "Tienes cambios sin guardar. Por favor guárdalos antes de hacer clic en Hecho", + "remove_empty": "La estimación no puede estar vacía. Ingresa un valor en cada campo o elimina aquellos para los que no tienes valores." + }, + "systems": { + "points": { + "label": "Puntos", + "fibonacci": "Fibonacci", + "linear": "Lineal", + "squares": "Cuadrados", + "custom": "Personalizado" + }, + "categories": { + "label": "Categorías", + "t_shirt_sizes": "Tallas de camiseta", + "easy_to_hard": "Fácil a difícil", + "custom": "Personalizado" + }, + "time": { + "label": "Tiempo", + "hours": "Horas" + } + } }, "automations": { "label": "Automatizaciones", "auto-archive": { - "title": "Auto-archivar elementos de trabajo cerrados", - "description": "Plane archivará automáticamente los elementos de trabajo que se hayan completado o cancelado.", - "duration": "Auto-archivar elementos de trabajo que están cerrados por" + "title": "Archivar automáticamente elementos de trabajo cerrados", + "description": "Plane archivará automáticamente los elementos de trabajo que hayan sido completados o cancelados.", + "duration": "Archivar automáticamente elementos de trabajo cerrados durante" }, "auto-close": { - "title": "Auto-cerrar elementos de trabajo", - "description": "Plane cerrará automáticamente los elementos de trabajo que no se hayan completado o cancelado.", - "duration": "Auto-cerrar elementos de trabajo que están inactivos por", - "auto_close_status": "Estado de auto-cierre" + "title": "Cerrar automáticamente elementos de trabajo", + "description": "Plane cerrará automáticamente los elementos de trabajo que no hayan sido completados o cancelados.", + "duration": "Cerrar automáticamente elementos de trabajo inactivos durante", + "auto_close_status": "Estado de cierre automático" } }, "empty_state": { "labels": { "title": "Aún no hay etiquetas", - "description": "Crea etiquetas para ayudar a organizar y filtrar elementos de trabajo en tu proyecto." + "description": "Crea etiquetas para organizar y filtrar elementos de trabajo en tu proyecto." }, "estimates": { "title": "Aún no hay sistemas de estimación", - "description": "Crea un conjunto de estimaciones para comunicar la cantidad de trabajo por elemento de trabajo.", + "description": "Crea un conjunto de estimaciones para comunicar el volumen de trabajo por elemento de trabajo.", "primary_button": "Agregar sistema de estimación" } } @@ -1776,6 +1859,12 @@ "remove_filters_to_see_all_cycles": "Elimina los filtros para ver todos los ciclos", "remove_search_criteria_to_see_all_cycles": "Elimina los criterios de búsqueda para ver todos los ciclos", "only_completed_cycles_can_be_archived": "Solo los ciclos completados pueden ser archivados", + "start_date": "Fecha de inicio", + "end_date": "Fecha de finalización", + "in_your_timezone": "En tu zona horaria", + "transfer_work_items": "Transferir {count} elementos de trabajo", + "date_range": "Rango de fechas", + "add_date": "Agregar fecha", "active_cycle": { "label": "Ciclo activo", "progress": "Progreso", @@ -2367,5 +2456,11 @@ "module": { "label": "{count, plural, one {Módulo} other {Módulos}}", "no_module": "Sin módulo" + }, + + "description_versions": { + "last_edited_by": "Última edición por", + "previously_edited_by": "Editado anteriormente por", + "edited_by": "Editado por" } } diff --git a/packages/i18n/src/locales/fr/translations.json b/packages/i18n/src/locales/fr/translations.json index 651622b97..f307949a0 100644 --- a/packages/i18n/src/locales/fr/translations.json +++ b/packages/i18n/src/locales/fr/translations.json @@ -350,7 +350,7 @@ "couldnt_remove_the_project_from_favorites": "Impossible de supprimer le projet des favoris. Veuillez réessayer.", "add_to_favorites": "Ajouter aux favoris", "remove_from_favorites": "Supprimer des favoris", - "publish_settings": "Paramètres de publication", + "publish_project": "Publier le projet", "publish": "Publier", "copy_link": "Copier le lien", "leave_project": "Quitter le projet", @@ -501,6 +501,9 @@ "re_generate_key": "Régénérer la clé", "export": "Exporter", "member": "{count, plural, one{# membre} other{# membres}}", + "new_password_must_be_different_from_old_password": "Le nouveau mot de passe doit être différent du mot de passe précédent", + "edited": "Modifié", + "bot": "Bot", "project_view": { "sort_by": { @@ -591,7 +594,7 @@ "default": "Vous n'avez pas encore d'éléments récents." }, "filters": { - "all": "Tous les éléments", + "all": "Tous", "projects": "Projets", "pages": "Pages", "issues": "Éléments de travail" @@ -867,7 +870,8 @@ "deleting": "Suppression", "pending": "En attente", "invite": "Inviter", - "view": "Afficher" + "view": "Afficher", + "deactivated_user": "Utilisateur désactivé" }, "chart": { @@ -1733,19 +1737,98 @@ } }, "estimates": { + "label": "Estimations", "title": "Activer les estimations pour mon projet", - "description": "Elles vous aident à communiquer la complexité et la charge de travail de l'équipe." + "description": "Elles vous aident à communiquer la complexité et la charge de travail de l'équipe.", + "no_estimate": "Sans estimation", + "new": "Nouveau système d'estimation", + "create": { + "custom": "Personnalisé", + "start_from_scratch": "Commencer depuis zéro", + "choose_template": "Choisir un modèle", + "choose_estimate_system": "Choisir un système d'estimation", + "enter_estimate_point": "Saisir une estimation", + "step": "Étape {step} de {total}", + "label": "Créer une estimation" + }, + "toasts": { + "created": { + "success": { + "title": "Estimation créée", + "message": "L'estimation a été créée avec succès" + }, + "error": { + "title": "Échec de la création de l'estimation", + "message": "Nous n'avons pas pu créer la nouvelle estimation, veuillez réessayer." + } + }, + "updated": { + "success": { + "title": "Estimation modifiée", + "message": "L'estimation a été mise à jour dans votre projet." + }, + "error": { + "title": "Échec de la modification de l'estimation", + "message": "Nous n'avons pas pu modifier l'estimation, veuillez réessayer" + } + }, + "enabled": { + "success": { + "title": "Succès !", + "message": "Les estimations ont été activées." + } + }, + "disabled": { + "success": { + "title": "Succès !", + "message": "Les estimations ont été désactivées." + }, + "error": { + "title": "Erreur !", + "message": "L'estimation n'a pas pu être désactivée. Veuillez réessayer" + } + } + }, + "validation": { + "min_length": "L'estimation doit être supérieure à 0.", + "unable_to_process": "Nous ne pouvons pas traiter votre demande, veuillez réessayer.", + "numeric": "L'estimation doit être une valeur numérique.", + "character": "L'estimation doit être une valeur de caractère.", + "empty": "La valeur de l'estimation ne peut pas être vide.", + "already_exists": "La valeur de l'estimation existe déjà.", + "unsaved_changes": "Vous avez des modifications non enregistrées. Veuillez les enregistrer avant de cliquer sur Terminé", + "remove_empty": "L'estimation ne peut pas être vide. Saisissez une valeur dans chaque champ ou supprimez ceux pour lesquels vous n'avez pas de valeurs." + }, + "systems": { + "points": { + "label": "Points", + "fibonacci": "Fibonacci", + "linear": "Linéaire", + "squares": "Carrés", + "custom": "Personnalisé" + }, + "categories": { + "label": "Catégories", + "t_shirt_sizes": "Tailles de T-Shirt", + "easy_to_hard": "Facile à difficile", + "custom": "Personnalisé" + }, + "time": { + "label": "Temps", + "hours": "Heures" + } + } }, "automations": { "label": "Automatisations", "auto-archive": { "title": "Archiver automatiquement les éléments de travail fermés", - "description": "Plane archivera automatiquement les éléments de travail qui ont été terminés ou annulés.", - "duration": "Archiver automatiquement les éléments de travail qui sont fermés depuis" + "description": "Plane archivera automatiquement les éléments de travail qui ont été complétés ou annulés.", + "duration": "Archiver automatiquement les éléments de travail fermés depuis" }, "auto-close": { "title": "Fermer automatiquement les éléments de travail", - "description": "Plane fermera automatiquement les éléments de travail qui n'ont pas été terminés ou annulés.", + "description": "Plane fermera automatiquement les éléments de travail qui n'ont pas été complétés ou annulés.", "duration": "Fermer automatiquement les éléments de travail inactifs depuis", "auto_close_status": "Statut de fermeture automatique" } @@ -1753,12 +1836,12 @@ "empty_state": { "labels": { - "title": "Aucune étiquette pour le moment", - "description": "Créez des étiquettes pour aider à organiser et filtrer les éléments de travail dans votre projet." + "title": "Pas encore d'étiquettes", + "description": "Créez des étiquettes pour organiser et filtrer les éléments de travail dans votre projet." }, "estimates": { - "title": "Aucun système d'estimation pour le moment", - "description": "Créez un ensemble d'estimations pour communiquer la quantité de travail par élément de travail.", + "title": "Pas encore de systèmes d'estimation", + "description": "Créez un ensemble d'estimations pour communiquer le volume de travail par élément de travail.", "primary_button": "Ajouter un système d'estimation" } } @@ -1774,6 +1857,12 @@ "remove_filters_to_see_all_cycles": "Supprimez les filtres pour voir tous les cycles", "remove_search_criteria_to_see_all_cycles": "Supprimez les critères de recherche pour voir tous les cycles", "only_completed_cycles_can_be_archived": "Seuls les cycles terminés peuvent être archivés", + "start_date": "Date de début", + "end_date": "Date de fin", + "in_your_timezone": "Dans votre fuseau horaire", + "transfer_work_items": "Transférer {count} éléments de travail", + "date_range": "Plage de dates", + "add_date": "Ajouter une date", "active_cycle": { "label": "Cycle actif", "progress": "Progression", @@ -2365,5 +2454,11 @@ "module": { "label": "{count, plural, one {Module} other {Modules}}", "no_module": "Pas de module" + }, + + "description_versions": { + "last_edited_by": "Dernière modification par", + "previously_edited_by": "Précédemment modifié par", + "edited_by": "Modifié par" } } diff --git a/packages/i18n/src/locales/id/translations.json b/packages/i18n/src/locales/id/translations.json new file mode 100644 index 000000000..76da8fa5d --- /dev/null +++ b/packages/i18n/src/locales/id/translations.json @@ -0,0 +1,2458 @@ +{ + "sidebar": { + "projects": "Projek", + "pages": "Halaman", + "new_work_item": "Item kerja baru", + "home": "Beranda", + "your_work": "Pekerjaan anda", + "inbox": "Inbox", + "workspace": "Workspace", + "views": "Views", + "analytics": "Analitik", + "work_items": "Item kerja", + "cycles": "Siklus", + "modules": "Modul", + "intake": "Intake", + "drafts": "Draft", + "favorites": "Favorit", + "pro": "Pro", + "upgrade": "Upgrade" + }, + + "auth": { + "common": { + "email": { + "label": "Email", + "placeholder": "nama@perusahaan.com", + "errors": { + "required": "Email wajib diisi", + "invalid": "Email tidak valid" + } + }, + "password": { + "label": "Password", + "set_password": "Atur password", + "placeholder": "Masukkan password", + "confirm_password": { + "label": "Konfirmasi password", + "placeholder": "Konfirmasi password" + }, + "current_password": { + "label": "Password saat ini" + }, + "new_password": { + "label": "Password baru", + "placeholder": "Masukkan password baru" + }, + "change_password": { + "label": { + "default": "Ubah password", + "submitting": "Mengubah password" + } + }, + "errors": { + "match": "Password tidak cocok", + "empty": "Silakan masukkan password anda", + "length": "Panjang password harus lebih dari 8 karakter", + "strength": { + "weak": "Password lemah", + "strong": "Password kuat" + } + }, + "submit": "Atur password", + "toast": { + "change_password": { + "success": { + "title": "Berhasil!", + "message": "Password berhasil diubah." + }, + "error": { + "title": "Error!", + "message": "Terjadi kesalahan. Silakan coba lagi." + } + } + } + }, + "unique_code": { + "label": "Kode unik", + "placeholder": "gets-sets-flys", + "paste_code": "Tempelkan kode yang dikirim ke email anda", + "requesting_new_code": "Meminta kode baru", + "sending_code": "Mengirim kode" + }, + "already_have_an_account": "Sudah punya akun?", + "login": "Masuk", + "create_account": "Buat akun", + "new_to_plane": "Baru di Plane?", + "back_to_sign_in": "Kembali ke halaman masuk", + "resend_in": "Kirim ulang dalam {seconds} detik", + "sign_in_with_unique_code": "Masuk dengan kode unik", + "forgot_password": "Lupa password?" + }, + "sign_up": { + "header": { + "label": "Buat akun untuk mulai mengelola pekerjaan dengan tim anda.", + "step": { + "email": { + "header": "Daftar", + "sub_header": "" + }, + "password": { + "header": "Daftar", + "sub_header": "Daftar menggunakan kombinasi email-password." + }, + "unique_code": { + "header": "Daftar", + "sub_header": "Daftar menggunakan kode unik yang dikirim ke alamat email di atas." + } + } + }, + "errors": { + "password": { + "strength": "Coba atur password yang lebih kuat untuk melanjutkan" + } + } + }, + "sign_in": { + "header": { + "label": "Masuk untuk mulai mengelola pekerjaan dengan tim anda.", + "step": { + "email": { + "header": "Masuk atau daftar", + "sub_header": "" + }, + "password": { + "header": "Masuk atau daftar", + "sub_header": "Gunakan kombinasi email-password anda untuk masuk." + }, + "unique_code": { + "header": "Masuk atau daftar", + "sub_header": "Masuk menggunakan kode unik yang dikirim ke alamat email di atas." + } + } + } + }, + "forgot_password": { + "title": "Reset password anda", + "description": "Masukkan alamat email akun anda yang telah diverifikasi dan kami akan mengirimkan link reset password.", + "email_sent": "Kami telah mengirim link reset ke alamat email anda", + "send_reset_link": "Kirim link reset", + "errors": { + "smtp_not_enabled": "Kami melihat bahwa admin anda belum mengaktifkan SMTP, kami tidak dapat mengirimkan link reset password" + }, + "toast": { + "success": { + "title": "Email terkirim", + "message": "Periksa inbox anda untuk link reset password. Jika tidak muncul dalam beberapa menit, periksa folder spam." + }, + "error": { + "title": "Error!", + "message": "Terjadi kesalahan. Silakan coba lagi." + } + } + }, + "reset_password": { + "title": "Atur password baru", + "description": "Amankan akun anda dengan password yang kuat" + }, + "set_password": { + "title": "Amankan akun anda", + "description": "Mengatur password membantu anda masuk dengan aman" + }, + "sign_out": { + "toast": { + "error": { + "title": "Error!", + "message": "Gagal keluar. Silakan coba lagi." + } + } + } + }, + + "submit": "Kirim", + "cancel": "Batal", + "loading": "Memuat", + "error": "Kesalahan", + "success": "Sukses", + "warning": "Peringatan", + "info": "Info", + "close": "Tutup", + "yes": "Ya", + "no": "Tidak", + "ok": "OK", + "name": "Nama", + "description": "Deskripsi", + "search": "Cari", + "add_member": "Tambah anggota", + "adding_members": "Menambah anggota", + "remove_member": "Hapus anggota", + "add_members": "Tambah anggota", + "adding_member": "Menambah anggota", + "remove_members": "Hapus anggota", + "add": "Tambah", + "adding": "Menambah", + "remove": "Hapus", + "add_new": "Tambah baru", + "remove_selected": "Hapus yang dipilih", + "first_name": "Nama depan", + "last_name": "Nama belakang", + "email": "Email", + "display_name": "Nama tampilan", + "role": "Peran", + "timezone": "Zona waktu", + "avatar": "Avatar", + "cover_image": "Gambar sampul", + "password": "Kata sandi", + "change_cover": "Ganti sampul", + "language": "Bahasa", + "saving": "Menyimpan", + "save_changes": "Simpan perubahan", + "deactivate_account": "Nonaktifkan akun", + "deactivate_account_description": "Saat menonaktifkan akun, semua data dan sumber daya dalam akun tersebut akan dihapus secara permanen dan tidak dapat dipulihkan.", + "profile_settings": "Pengaturan profil", + "your_account": "Akun Anda", + "security": "Keamanan", + "activity": "Aktivitas", + "appearance": "Tampilan", + "notifications": "Notifikasi", + "workspaces": "Ruang kerja", + "create_workspace": "Buat ruang kerja", + "invitations": "Undangan", + "summary": "Ringkasan", + "assigned": "Ditetapkan", + "created": "Dibuat", + "subscribed": "Berlangganan", + "you_do_not_have_the_permission_to_access_this_page": "Anda tidak memiliki izin untuk mengakses halaman ini.", + "something_went_wrong_please_try_again": "Terjadi kesalahan. Silakan coba lagi.", + "load_more": "Muat lebih banyak", + "select_or_customize_your_interface_color_scheme": "Pilih atau sesuaikan skema warna antarmuka Anda.", + "theme": "Tema", + "system_preference": "Preferensi sistem", + "light": "Terang", + "dark": "Gelap", + "light_contrast": "Kontras tinggi terang", + "dark_contrast": "Kontras tinggi gelap", + "custom": "Tema kustom", + "select_your_theme": "Pilih tema Anda", + "customize_your_theme": "Sesuaikan tema Anda", + "background_color": "Warna latar belakang", + "text_color": "Warna teks", + "primary_color": "Warna utama (Tema)", + "sidebar_background_color": "Warna latar belakang sidebar", + "sidebar_text_color": "Warna teks sidebar", + "set_theme": "Atur tema", + "enter_a_valid_hex_code_of_6_characters": "Masukkan kode hex yang valid dari 6 karakter", + "background_color_is_required": "Warna latar belakang diperlukan", + "text_color_is_required": "Warna teks diperlukan", + "primary_color_is_required": "Warna utama diperlukan", + "sidebar_background_color_is_required": "Warna latar belakang sidebar diperlukan", + "sidebar_text_color_is_required": "Warna teks sidebar diperlukan", + "updating_theme": "Memperbarui tema", + "theme_updated_successfully": "Tema berhasil diperbarui", + "failed_to_update_the_theme": "Gagal memperbarui tema", + "email_notifications": "Notifikasi email", + "stay_in_the_loop_on_issues_you_are_subscribed_to_enable_this_to_get_notified": "Tetap terupdate tentang item kerja yang Anda langgani. Aktifkan ini untuk mendapatkan notifikasi.", + "email_notification_setting_updated_successfully": "Pengaturan notifikasi email berhasil diperbarui", + "failed_to_update_email_notification_setting": "Gagal memperbarui pengaturan notifikasi email", + "notify_me_when": "Beri tahu saya ketika", + "property_changes": "Perubahan properti", + "property_changes_description": "Beri tahu saya ketika properti item kerja seperti penugasannya, prioritas, estimasi, atau hal lainnya berubah.", + "state_change": "Perubahan status", + "state_change_description": "Beri tahu saya ketika item kerja berpindah ke status yang berbeda", + "issue_completed": "Item kerja selesai", + "issue_completed_description": "Beri tahu saya hanya ketika item kerja selesai", + "comments": "Komentar", + "comments_description": "Beri tahu saya ketika seseorang meninggalkan komentar pada item kerja", + "mentions": "Sebutkan", + "mentions_description": "Beri tahu saya hanya ketika seseorang menyebut saya dalam komentar atau deskripsi", + "old_password": "Kata sandi lama", + "general_settings": "Pengaturan umum", + "sign_out": "Keluar", + "signing_out": "Keluar", + "active_cycles": "Siklus aktif", + "active_cycles_description": "Pantau siklus di seluruh proyek, lacak item kerja prioritas tinggi, dan fokus pada siklus yang membutuhkan perhatian.", + "on_demand_snapshots_of_all_your_cycles": "Snapshot sesuai permintaan dari semua siklus Anda", + "upgrade": "Tingkatkan", + "10000_feet_view": "Tampilan 10.000 kaki dari semua siklus aktif.", + "10000_feet_view_description": "Perbesar untuk melihat siklus yang berjalan di seluruh proyek Anda sekaligus, bukan berpindah dari Siklus ke Siklus di setiap proyek.", + "get_snapshot_of_each_active_cycle": "Dapatkan snapshot dari setiap siklus aktif.", + "get_snapshot_of_each_active_cycle_description": "Lacak metrik tingkat tinggi untuk semua siklus aktif, lihat kemajuan mereka, dan dapatkan gambaran tentang ruang lingkup terhadap tenggat waktu.", + "compare_burndowns": "Bandingkan burndown.", + "compare_burndowns_description": "Pantau bagaimana kinerja setiap tim Anda dengan melihat laporan burndown masing-masing siklus.", + "quickly_see_make_or_break_issues": "Lihat dengan cepat item kerja yang krusial.", + "quickly_see_make_or_break_issues_description": "Prabaca item kerja prioritas tinggi untuk setiap siklus terhadap tanggal jatuh tempo. Lihat semuanya per siklus hanya dengan satu klik.", + "zoom_into_cycles_that_need_attention": "Perbesar siklus yang membutuhkan perhatian.", + "zoom_into_cycles_that_need_attention_description": "Selidiki status siklus mana pun yang tidak sesuai dengan harapan dengan satu klik.", + "stay_ahead_of_blockers": "Tetap di depan penghambat.", + "stay_ahead_of_blockers_description": "Identifikasi tantangan dari satu proyek ke proyek lainnya dan lihat ketergantungan antar siklus yang tidak terlihat dari tampilan lain mana pun.", + "analytics": "Analitik", + "workspace_invites": "Undangan ruang kerja", + "enter_god_mode": "Masuk ke mode dewa", + "workspace_logo": "Logo ruang kerja", + "new_issue": "Item kerja baru", + "your_work": "Pekerjaan Anda", + "drafts": "Draf", + "projects": "Proyek", + "views": "Tampilan", + "workspace": "Ruang kerja", + "archives": "Arsip", + "settings": "Pengaturan", + "failed_to_move_favorite": "Gagal memindahkan favorit", + "favorites": "Favorit", + "no_favorites_yet": "Belum ada favorit", + "create_folder": "Buat folder", + "new_folder": "Folder baru", + "favorite_updated_successfully": "Favorit berhasil diperbarui", + "favorite_created_successfully": "Favorit berhasil dibuat", + "folder_already_exists": "Folder sudah ada", + "folder_name_cannot_be_empty": "Nama folder tidak boleh kosong", + "something_went_wrong": "Terjadi kesalahan", + "failed_to_reorder_favorite": "Gagal mengatur ulang favorit", + "favorite_removed_successfully": "Favorit berhasil dihapus", + "failed_to_create_favorite": "Gagal membuat favorit", + "failed_to_rename_favorite": "Gagal mengganti nama favorit", + "project_link_copied_to_clipboard": "Tautan proyek disalin ke clipboard", + "link_copied": "Tautan disalin", + "add_project": "Tambah proyek", + "create_project": "Buat proyek", + "failed_to_remove_project_from_favorites": "Tidak dapat menghapus proyek dari favorit. Silakan coba lagi.", + "project_created_successfully": "Proyek berhasil dibuat", + "project_created_successfully_description": "Proyek berhasil dibuat. Anda sekarang dapat mulai menambahkan item kerja ke dalamnya.", + "project_cover_image_alt": "Gambar sampul proyek", + "name_is_required": "Nama diperlukan", + "title_should_be_less_than_255_characters": "Judul harus kurang dari 255 karakter", + "project_name": "Nama proyek", + "project_id_must_be_at_least_1_character": "ID proyek harus minimal 1 karakter", + "project_id_must_be_at_most_5_characters": "ID proyek maksimal 5 karakter", + "project_id": "ID proyek", + "project_id_tooltip_content": "Membantu Anda mengidentifikasi item kerja dalam proyek secara unik. Maksimal 5 karakter.", + "description_placeholder": "Deskripsi", + "only_alphanumeric_non_latin_characters_allowed": "Hanya karakter alfanumerik & Non-latin yang diizinkan.", + "project_id_is_required": "ID proyek diperlukan", + "project_id_allowed_char": "Hanya karakter alfanumerik & Non-latin yang diizinkan.", + "project_id_min_char": "ID proyek harus minimal 1 karakter", + "project_id_max_char": "ID proyek maksimal 5 karakter", + "project_description_placeholder": "Masukkan deskripsi proyek", + "select_network": "Pilih jaringan", + "lead": "Pemimpin", + "date_range": "Rentang tanggal", + "private": "Pribadi", + "public": "Umum", + "accessible_only_by_invite": "Diakses hanya dengan undangan", + "anyone_in_the_workspace_except_guests_can_join": "Siapa saja di ruang kerja kecuali Tamu dapat bergabung", + "creating": "Membuat", + "creating_project": "Membuat proyek", + "adding_project_to_favorites": "Menambahkan proyek ke favorit", + "project_added_to_favorites": "Proyek ditambahkan ke favorit", + "couldnt_add_the_project_to_favorites": "Tidak dapat menambahkan proyek ke favorit. Silakan coba lagi.", + "removing_project_from_favorites": "Menghapus proyek dari favorit", + "project_removed_from_favorites": "Proyek dihapus dari favorit", + "couldnt_remove_the_project_from_favorites": "Tidak dapat menghapus proyek dari favorit. Silakan coba lagi.", + "add_to_favorites": "Tambah ke favorit", + "remove_from_favorites": "Hapus dari favorit", + "publish_project": "Publikasikan proyek", + "publish": "Publikasikan", + "copy_link": "Salin tautan", + "leave_project": "Tinggalkan proyek", + "join_the_project_to_rearrange": "Bergabunglah dengan proyek untuk menyusun ulang", + "drag_to_rearrange": "Seret untuk menyusun ulang", + "congrats": "Selamat!", + "open_project": "Buka proyek", + "issues": "Item kerja", + "cycles": "Siklus", + "modules": "Modul", + "pages": "Halaman", + "intake": "Penerimaan", + "time_tracking": "Pelacakan waktu", + "work_management": "Manajemen kerja", + "projects_and_issues": "Proyek dan item kerja", + "projects_and_issues_description": "Aktifkan atau nonaktifkan ini untuk proyek ini.", + "cycles_description": "Batasi waktu kerja sesuai keinginan Anda per proyek dan ubah frekuensi dari satu periode ke periode berikutnya.", + "modules_description": "Kerja kelompok menjadi pengaturan sub-proyek dengan pemimpin dan penugasnya sendiri.", + "views_description": "Simpan jenis, filter, dan opsi tampilan untuk nanti atau bagikan.", + "pages_description": "Tulis apapun seperti yang Anda tulis.", + "intake_description": "Tetap terhubung dengan item kerja yang Anda ikuti. Aktifkan ini untuk mendapatkan pemberitahuan.", + "time_tracking_description": "Lacak waktu yang dihabiskan untuk item kerja dan proyek.", + "work_management_description": "Kelola pekerjaan dan proyek Anda dengan mudah.", + "documentation": "Dokumentasi", + "message_support": "Pesan dukungan", + "contact_sales": "Hubungi penjualan", + "hyper_mode": "Mode Hyper", + "keyboard_shortcuts": "Pintasan keyboard", + "whats_new": "Apa yang baru?", + "version": "Versi", + "we_are_having_trouble_fetching_the_updates": "Kami mengalami kesulitan mengambil pembaruan.", + "our_changelogs": "changelog kami", + "for_the_latest_updates": "untuk pembaruan terbaru.", + "please_visit": "Silakan kunjungi", + "docs": "Dokumen", + "full_changelog": "Changelog lengkap", + "support": "Dukungan", + "discord": "Discord", + "powered_by_plane_pages": "Ditenagai oleh Plane Pages", + "please_select_at_least_one_invitation": "Silakan pilih setidaknya satu undangan.", + "please_select_at_least_one_invitation_description": "Silakan pilih setidaknya satu undangan untuk bergabung dengan ruang kerja.", + "we_see_that_someone_has_invited_you_to_join_a_workspace": "Kami melihat bahwa seseorang telah mengundang Anda untuk bergabung dengan ruang kerja", + "join_a_workspace": "Bergabunglah dengan ruang kerja", + "we_see_that_someone_has_invited_you_to_join_a_workspace_description": "Kami melihat bahwa seseorang telah mengundang Anda untuk bergabung dengan ruang kerja", + "join_a_workspace_description": "Bergabunglah dengan ruang kerja", + "accept_and_join": "Terima & Bergabung", + "go_home": "Kembali ke Beranda", + "no_pending_invites": "Tidak ada undangan yang tertunda", + "you_can_see_here_if_someone_invites_you_to_a_workspace": "Anda dapat melihat di sini jika seseorang mengundang Anda untuk bergabung dengan ruang kerja", + "back_to_home": "Kembali ke beranda", + "workspace_name": "nama-ruang-kerja", + "deactivate_your_account": "Nonaktifkan akun Anda", + "deactivate_your_account_description": "Setelah dinonaktifkan, Anda tidak akan dapat ditugaskan item kerja dan ditagih untuk ruang kerja Anda. Untuk mengaktifkan kembali akun Anda, Anda akan memerlukan undangan ke ruang kerja di alamat email ini.", + "deactivating": "Menonaktifkan", + "confirm": "Konfirmasi", + "confirming": "Mengonfirmasi", + "draft_created": "Draf dibuat", + "issue_created_successfully": "Item kerja berhasil dibuat", + "draft_creation_failed": "Pembuatan draf gagal", + "issue_creation_failed": "Pembuatan item kerja gagal", + "draft_issue": "Draf item kerja", + "issue_updated_successfully": "Item kerja berhasil diperbarui", + "issue_could_not_be_updated": "Item kerja tidak dapat diperbarui", + "create_a_draft": "Buat draf", + "save_to_drafts": "Simpan ke Draf", + "save": "Simpan", + "update": "Perbarui", + "updating": "Memperbarui", + "create_new_issue": "Buat item kerja baru", + "editor_is_not_ready_to_discard_changes": "Editor belum siap untuk membuang perubahan", + "failed_to_move_issue_to_project": "Gagal memindahkan item kerja ke proyek", + "create_more": "Buat lebih banyak", + "add_to_project": "Tambahkan ke proyek", + "discard": "Buang", + "duplicate_issue_found": "Item kerja duplikat ditemukan", + "duplicate_issues_found": "Item kerja duplikat ditemukan", + "no_matching_results": "Tidak ada hasil yang cocok", + "title_is_required": "Judul diperlukan", + "title": "Judul", + "state": "Negara", + "priority": "Prioritas", + "none": "Tidak ada", + "urgent": "Penting", + "high": "Tinggi", + "medium": "Sedang", + "low": "Rendah", + "members": "Anggota", + "assignee": "Penugas", + "assignees": "Penugas", + "you": "Anda", + "labels": "Label", + "create_new_label": "Buat label baru", + "start_date": "Tanggal mulai", + "end_date": "Tanggal akhir", + "due_date": "Tanggal jatuh tempo", + "estimate": "Perkiraan", + "change_parent_issue": "Ubah item kerja induk", + "remove_parent_issue": "Hapus item kerja induk", + "add_parent": "Tambahkan induk", + "loading_members": "Memuat anggota", + "view_link_copied_to_clipboard": "Tautan tampilan disalin ke clipboard.", + "required": "Diperlukan", + "optional": "Opsional", + "Cancel": "Batal", + "edit": "Sunting", + "archive": "Arsip", + "restore": "Pulihkan", + "open_in_new_tab": "Buka di tab baru", + "delete": "Hapus", + "deleting": "Menghapus", + "make_a_copy": "Buat salinan", + "move_to_project": "Pindahkan ke proyek", + "good": "Bagus", + "morning": "pagi", + "afternoon": "siang", + "evening": "malam", + "show_all": "Tampilkan semua", + "show_less": "Tampilkan lebih sedikit", + "no_data_yet": "Belum ada data", + "syncing": "Menyinkronkan", + "add_work_item": "Tambahkan item kerja", + "advanced_description_placeholder": "Tekan '/' untuk perintah", + "create_work_item": "Buat item kerja", + "attachments": "Lampiran", + "declining": "Menolak", + "declined": "Ditolak", + "decline": "Tolak", + "unassigned": "Belum ditugaskan", + "work_items": "Item kerja", + "add_link": "Tambahkan tautan", + "points": "Poin", + "no_assignee": "Tidak ada penugas", + "no_assignees_yet": "Belum ada penugas", + "no_labels_yet": "Belum ada label", + "ideal": "Ideal", + "current": "Saat ini", + "no_matching_members": "Tidak ada anggota yang cocok", + "leaving": "Meninggalkan", + "removing": "Menghapus", + "leave": "Tinggalkan", + "refresh": "Segarkan", + "refreshing": "Menyegarkan", + "refresh_status": "Status segar", + "prev": "Sebelumnya", + "next": "Selanjutnya", + "re_generating": "Menghasilkan kembali", + "re_generate": "Hasilkan kembali", + "re_generate_key": "Hasilkan kembali kunci", + "export": "Ekspor", + "member": "{count, plural, one{# anggota} other{# anggota}}", + "new_password_must_be_different_from_old_password": "Kata sandi baru harus berbeda dari kata sandi lama", + + "project_view": { + "sort_by": { + "created_at": "Dibuat pada", + "updated_at": "Diperbarui pada", + "name": "Nama" + } + }, + + "toast": { + "success": "Sukses!", + "error": "Kesalahan!" + }, + + "links": { + "toasts": { + "created": { + "title": "Tautan dibuat", + "message": "Tautan telah berhasil dibuat" + }, + "not_created": { + "title": "Tautan tidak dibuat", + "message": "Tautan tidak dapat dibuat" + }, + "updated": { + "title": "Tautan diperbarui", + "message": "Tautan telah berhasil diperbarui" + }, + "not_updated": { + "title": "Tautan tidak diperbarui", + "message": "Tautan tidak dapat diperbarui" + }, + "removed": { + "title": "Tautan dihapus", + "message": "Tautan telah berhasil dihapus" + }, + "not_removed": { + "title": "Tautan tidak dihapus", + "message": "Tautan tidak dapat dihapus" + } + } + }, + + "home": { + "empty": { + "quickstart_guide": "Panduan pemula Anda", + "not_right_now": "Tidak sekarang", + "create_project": { + "title": "Buat proyek", + "description": "Sebagian besar hal dimulai dengan proyek di Plane.", + "cta": "Mulai sekarang" + }, + "invite_team": { + "title": "Undang tim Anda", + "description": "Bangun, kirim, dan kelola dengan rekan kerja.", + "cta": "Ajak mereka" + }, + "configure_workspace": { + "title": "Atur ruang kerja Anda.", + "description": "Hidupkan atau matikan fitur atau lebih dari itu.", + "cta": "Konfigurasi ruang kerja ini" + }, + "personalize_account": { + "title": "Jadikan Plane milik Anda.", + "description": "Pilih gambar Anda, warna, dan lainnya.", + "cta": "Personalisasi sekarang" + }, + "widgets": { + "title": "Sepi Tanpa Widget, Nyalakan Mereka", + "description": "Sepertinya semua widget Anda dimatikan. Aktifkan sekarang untuk meningkatkan pengalaman Anda!", + "primary_button": { + "text": "Kelola widget" + } + } + }, + "quick_links": { + "empty": "Simpan tautan ke hal-hal kerja yang ingin Anda miliki.", + "add": "Tambahkan Tautan Cepat", + "title": "Tautan Cepat", + "title_plural": "Tautan Cepat" + }, + "recents": { + "title": "Terbaru", + "empty": { + "project": "Proyek terbaru Anda akan muncul di sini setelah Anda mengunjunginya.", + "page": "Halaman terbaru Anda akan muncul di sini setelah Anda mengunjunginya.", + "issue": "Item kerja terbaru Anda akan muncul di sini setelah Anda mengunjunginya.", + "default": "Anda belum memiliki yang terbaru." + }, + "filters": { + "all": "Semua", + "projects": "Proyek", + "pages": "Halaman", + "issues": "Item kerja" + } + }, + "new_at_plane": { + "title": "Baru di Plane" + }, + "quick_tutorial": { + "title": "Tutorial cepat" + }, + "widget": { + "reordered_successfully": "Widget berhasil diurutkan ulang.", + "reordering_failed": "Kesalahan terjadi saat mengurutkan ulang widget." + }, + "manage_widgets": "Kelola widget", + "title": "Beranda", + "star_us_on_github": "Bintang kami di GitHub" + }, + + "link": { + "modal": { + "url": { + "text": "URL", + "required": "URL tidak valid", + "placeholder": "Ketik atau tempel URL" + }, + "title": { + "text": "Judul tampilan", + "placeholder": "Apa yang ingin Anda lihat sebagai tautan ini" + } + } + }, + + "common": { + "all": "Semua", + "states": "Negara-negara", + "state": "Negara", + "state_groups": "Kelompok negara", + "state_group": "Kelompok negara", + "priorities": "Prioritas", + "priority": "Prioritas", + "team_project": "Proyek tim", + "project": "Proyek", + "cycle": "Siklus", + "cycles": "Siklus", + "module": "Modul", + "modules": "Modul", + "labels": "Label", + "label": "Label", + "assignees": "Penugas", + "assignee": "Penugas", + "created_by": "Dibuat oleh", + "none": "Tidak ada", + "link": "Tautan", + "estimates": "Perkiraan", + "estimate": "Perkiraan", + "created_at": "Dibuat pada", + "completed_at": "Selesai pada", + "layout": "Tata letak", + "filters": "Filter", + "display": "Tampilan", + "load_more": "Muat lebih banyak", + "activity": "Aktivitas", + "analytics": "Analitik", + "dates": "Tanggal", + "success": "Sukses!", + "something_went_wrong": "Ada yang salah", + "error": { + "label": "Kesalahan!", + "message": "Terjadi kesalahan. Silakan coba lagi." + }, + "group_by": "Kelompok berdasarkan", + "epic": "Epik", + "epics": "Epik", + "work_item": "Item kerja", + "work_items": "Item kerja", + "sub_work_item": "Sub-item kerja", + "add": "Tambah", + "warning": "Peringatan", + "updating": "Memperbarui", + "adding": "Menambahkan", + "update": "Perbarui", + "creating": "Membuat", + "create": "Buat", + "cancel": "Batalkan", + "description": "Deskripsi", + "title": "Judul", + "attachment": "Lampiran", + "general": "Umum", + "features": "Fitur", + "automation": "Otomatisasi", + "project_name": "Nama proyek", + "project_id": "ID proyek", + "project_timezone": "Zona waktu proyek", + "created_on": "Dibuat pada", + "update_project": "Perbarui proyek", + "identifier_already_exists": "Pengidentifikasi sudah ada", + "add_more": "Tambah lebih banyak", + "defaults": "Pola dasar", + "add_label": "Tambah label", + "customize_time_range": "Sesuaikan rentang waktu", + "loading": "Memuat", + "attachments": "Lampiran", + "property": "Properti", + "properties": "Properti", + "parent": "Induk", + "page": "Halaman", + "remove": "Hapus", + "archiving": "Mengarsipkan", + "archive": "Arsip", + "access": { + "public": "Publik", + "private": "Pribadi" + }, + "done": "Selesai", + "sub_work_items": "Sub-item kerja", + "comment": "Komentar", + "workspace_level": "Tingkat ruang kerja", + "order_by": { + "label": "Urutkan berdasarkan", + "manual": "Manual", + "last_created": "Terakhir dibuat", + "last_updated": "Terakhir diperbarui", + "start_date": "Tanggal mulai", + "due_date": "Tanggal jatuh tempo", + "asc": "Menaik", + "desc": "Menurun", + "updated_on": "Diperbarui pada" + }, + "sort": { + "asc": "Menaik", + "desc": "Menurun", + "created_on": "Dibuat pada", + "updated_on": "Diperbarui pada" + }, + "comments": "Komentar", + "updates": "Pembaruan", + "clear_all": "Hapus semua", + "copied": "Disalin!", + "link_copied": "Tautan disalin!", + "link_copied_to_clipboard": "Tautan disalin ke clipboard", + "copied_to_clipboard": "Tautan item kerja disalin ke clipboard", + "is_copied_to_clipboard": "Item kerja disalin ke clipboard", + "no_links_added_yet": "Belum ada tautan yang ditambahkan", + "add_link": "Tambah tautan", + "links": "Tautan", + "go_to_workspace": "Pergi ke ruang kerja", + "progress": "Kemajuan", + "optional": "Opsional", + "join": "Bergabung", + "go_back": "Kembali", + "continue": "Lanjutkan", + "resend": "Kirim ulang", + "relations": "Hubungan", + "errors": { + "default": { + "title": "Kesalahan!", + "message": "Sesuatu telah salah. Silakan coba lagi." + }, + "required": "Bidang ini diperlukan", + "entity_required": "{entity} diperlukan" + }, + "update_link": "Perbarui tautan", + "attach": "Lampirkan", + "create_new": "Buat baru", + "add_existing": "Tambah yang ada", + "type_or_paste_a_url": "Ketik atau tempel URL", + "url_is_invalid": "URL tidak valid", + "display_title": "Judul tampilan", + "link_title_placeholder": "Apa yang ingin Anda lihat pada tautan ini", + "url": "URL", + "side_peek": "Tampilan samping", + "modal": "Modal", + "full_screen": "Layar penuh", + "close_peek_view": "Tutup tampilan peek", + "toggle_peek_view_layout": "Alihkan tata letak tampilan peek", + "options": "Opsi", + "duration": "Durasi", + "today": "Hari ini", + "week": "Minggu", + "month": "Bulan", + "quarter": "Kuartal", + "press_for_commands": "Tekan '/' untuk perintah", + "click_to_add_description": "Klik untuk menambahkan deskripsi", + "search": { + "label": "Pencarian", + "placeholder": "Ketik untuk mencari", + "no_matches_found": "Tidak ada kecocokan ditemukan", + "no_matching_results": "Tidak ada hasil yang cocok" + }, + "actions": { + "edit": "Edit", + "make_a_copy": "Buat salinan", + "open_in_new_tab": "Buka di tab baru", + "copy_link": "Salin tautan", + "archive": "Arsip", + "restore": "Pulihkan", + "delete": "Hapus", + "remove_relation": "Hapus hubungan", + "subscribe": "Berlangganan", + "unsubscribe": "Berhenti berlangganan", + "clear_sorting": "Hapus pengurutan", + "show_weekends": "Tampilkan akhir pekan", + "enable": "Aktifkan", + "disable": "Nonaktifkan" + }, + "name": "Nama", + "discard": "Buang", + "confirm": "Konfirmasi", + "confirming": "Mengonfirmasi", + "read_the_docs": "Baca dokumen", + "default": "Bawaan", + "active": "Aktif", + "enabled": "Diaktifkan", + "disabled": "Dinonaktifkan", + "mandate": "Mandat", + "mandatory": "Wajib", + "yes": "Ya", + "no": "Tidak", + "please_wait": "Silakan tunggu", + "enabling": "Mengaktifkan", + "disabling": "Menonaktifkan", + "beta": "Beta", + "or": "atau", + "next": "Selanjutnya", + "back": "Kembali", + "cancelling": "Membatalkan", + "configuring": "Mengkonfigurasi", + "clear": "Bersihkan", + "import": "Impor", + "connect": "Sambungkan", + "authorizing": "Mengautentikasi", + "processing": "Memproses", + "no_data_available": "Tidak ada data tersedia", + "from": "dari {name}", + "authenticated": "Terautentikasi", + "select": "Pilih", + "upgrade": "Tingkatkan", + "add_seats": "Tambahkan Kursi", + "projects": "Proyek", + "workspace": "Ruang kerja", + "workspaces": "Ruang kerja", + "team": "Tim", + "teams": "Tim", + "entity": "Entitas", + "entities": "Entitas", + "task": "Tugas", + "tasks": "Tugas", + "section": "Bagian", + "sections": "Bagian", + "edit": "Edit", + "connecting": "Menghubungkan", + "connected": "Terhubung", + "disconnect": "Putuskan", + "disconnecting": "Memutuskan", + "installing": "Menginstal", + "install": "Instal", + "reset": "Atur ulang", + "live": "Langsung", + "change_history": "Riwayat Perubahan", + "coming_soon": "Segera hadir", + "members": "Anggota", + "you": "Anda", + "upgrade_cta": { + "higher_subscription": "Tingkatkan ke langganan yang lebih tinggi", + "talk_to_sales": "Bicaralah dengan Penjualan" + }, + "category": "Kategori", + "categories": "Kategori", + "saving": "Menyimpan", + "save_changes": "Simpan perubahan", + "delete": "Hapus", + "deleting": "Menghapus", + "pending": "Tertunda", + "invite": "Undang", + "view": "Lihat", + "deactivated_user": "Pengguna dinonaktifkan" + }, + + "chart": { + "x_axis": "Sumbu-X", + "y_axis": "Sumbu-Y", + "metric": "Metrik" + }, + + "form": { + "title": { + "required": "Judul wajib diisi", + "max_length": "Judul harus kurang dari {length} karakter" + } + }, + + "entity": { + "grouping_title": "Pengelompokan {entity}", + "priority": "Prioritas {entity}", + "all": "Semua {entity}", + "drop_here_to_move": "Letakkan di sini untuk memindahkan {entity}", + "delete": { + "label": "Hapus {entity}", + "success": "{entity} berhasil dihapus", + "failed": "Gagal menghapus {entity}" + }, + "update": { + "failed": "Gagal memperbarui {entity}", + "success": "{entity} berhasil diperbarui" + }, + "link_copied_to_clipboard": "Tautan {entity} disalin ke papan klip", + "fetch": { + "failed": "Terjadi kesalahan saat mengambil {entity}" + }, + "add": { + "success": "{entity} berhasil ditambahkan", + "failed": "Terjadi kesalahan saat menambahkan {entity}" + } + }, + + "epic": { + "all": "Semua Epik", + "label": "{count, plural, one {Epik} other {Epik}}", + "new": "Epik Baru", + "adding": "Menambahkan epik", + "create": { + "success": "Epik berhasil dibuat" + }, + "add": { + "press_enter": "Tekan 'Enter' untuk menambahkan epik lain", + "label": "Tambahkan Epik" + }, + "title": { + "label": "Judul Epik", + "required": "Judul epik wajib diisi." + } + }, + + "issue": { + "label": "{count, plural, one {Item Kerja} other {Item Kerja}}", + "all": "Semua Item Kerja", + "edit": "Edit item kerja", + "title": { + "label": "Judul item kerja", + "required": "Judul item kerja diperlukan." + }, + "add": { + "press_enter": "Tekan 'Enter' untuk menambahkan item kerja lainnya", + "label": "Tambah item kerja", + "cycle": { + "failed": "Item kerja tidak dapat ditambahkan ke siklus. Silakan coba lagi.", + "success": "{count, plural, one {Item Kerja} other {Item Kerja}} berhasil ditambahkan ke siklus.", + "loading": "Menambahkan {count, plural, one {item kerja} other {item kerja}} ke siklus" + }, + "assignee": "Tambah penugasan", + "start_date": "Tambah tanggal mulai", + "due_date": "Tambah tanggal jatuh tempo", + "parent": "Tambah item kerja induk", + "sub_issue": "Tambah sub-item kerja", + "relation": "Tambah hubungan", + "link": "Tambah tautan", + "existing": "Tambah item kerja yang ada" + }, + "remove": { + "label": "Hapus item kerja", + "cycle": { + "loading": "Menghapus item kerja dari siklus", + "success": "Item kerja berhasil dihapus dari siklus.", + "failed": "Item kerja tidak dapat dihapus dari siklus. Silakan coba lagi." + }, + "module": { + "loading": "Menghapus item kerja dari modul", + "success": "Item kerja berhasil dihapus dari modul.", + "failed": "Item kerja tidak dapat dihapus dari modul. Silakan coba lagi." + }, + "parent": { + "label": "Hapus item kerja induk" + } + }, + "new": "Item Kerja Baru", + "adding": "Menambahkan item kerja", + "create": { + "success": "Item kerja berhasil dibuat" + }, + "priority": { + "urgent": "Mendesak", + "high": "Tinggi", + "medium": "Sedang", + "low": "Rendah" + }, + "display": { + "properties": { + "label": "Tampilkan Properti", + "id": "ID", + "issue_type": "Tipe item kerja", + "sub_issue_count": "Jumlah sub-item kerja", + "attachment_count": "Jumlah lampiran", + "created_on": "Dibuat pada", + "sub_issue": "Sub-item kerja", + "work_item_count": "Jumlah item kerja" + }, + "extra": { + "show_sub_issues": "Tampilkan sub-item kerja", + "show_empty_groups": "Tampilkan grup kosong" + } + }, + "layouts": { + "ordered_by_label": "Tata letak ini diurutkan berdasarkan", + "list": "Daftar", + "kanban": "Papan", + "calendar": "Kalender", + "spreadsheet": "Tabel", + "gantt": "Garis Waktu", + "title": { + "list": "Tata Letak Daftar", + "kanban": "Tata Letak Papan", + "calendar": "Tata Letak Kalender", + "spreadsheet": "Tata Letak Tabel", + "gantt": "Tata Letak Garis Waktu" + } + }, + "states": { + "active": "Aktif", + "backlog": "Backlog" + }, + "comments": { + "placeholder": "Tambah komentar", + "switch": { + "private": "Beralih ke komentar pribadi", + "public": "Beralih ke komentar publik" + }, + "create": { + "success": "Komentar berhasil dibuat", + "error": "Gagal membuat komentar. Silakan coba lagi nanti." + }, + "update": { + "success": "Komentar berhasil diperbarui", + "error": "Gagal memperbarui komentar. Silakan coba lagi nanti." + }, + "remove": { + "success": "Komentar berhasil dihapus", + "error": "Gagal menghapus komentar. Silakan coba lagi nanti." + }, + "upload": { + "error": "Gagal mengunggah aset. Silakan coba lagi nanti." + } + }, + "empty_state": { + "issue_detail": { + "title": "Item kerja tidak ada", + "description": "Item kerja yang Anda cari tidak ada, telah diarsipkan, atau telah dihapus.", + "primary_button": { + "text": "Lihat item kerja lainnya" + } + } + }, + "sibling": { + "label": "Item kerja sejawat" + }, + "archive": { + "description": "Hanya item kerja yang selesai atau dibatalkan\n yang dapat diarsipkan", + "label": "Arsip Item kerja", + "confirm_message": "Apakah Anda yakin ingin mengarsipkan item kerja ini? Semua item kerja yang diarsipkan dapat dipulihkan nanti.", + "success": { + "label": "Sukses mengarsipkan", + "message": "Arsip Anda dapat ditemukan di arsip proyek." + }, + "failed": { + "message": "Item kerja tidak dapat diarsipkan. Silakan coba lagi." + } + }, + "restore": { + "success": { + "title": "Sukses memulihkan", + "message": "Item kerja Anda dapat ditemukan di item kerja proyek." + }, + "failed": { + "message": "Item kerja tidak dapat dipulihkan. Silakan coba lagi." + } + }, + "relation": { + "relates_to": "Berhubungan dengan", + "duplicate": "Duplikat dari", + "blocked_by": "Diblokir oleh", + "blocking": "Memblokir" + }, + "copy_link": "Salin tautan item kerja", + "delete": { + "label": "Hapus item kerja", + "error": "Kesalahan saat menghapus item kerja" + }, + "subscription": { + "actions": { + "subscribed": "Item kerja telah berhasil disubscribe", + "unsubscribed": "Item kerja telah berhasil dibatalkan subscribe" + } + }, + "select": { + "error": "Silakan pilih setidaknya satu item kerja", + "empty": "Tidak ada item kerja yang dipilih", + "add_selected": "Tambah item kerja yang dipilih" + }, + "open_in_full_screen": "Buka item kerja dalam layar penuh" + }, + + "attachment": { + "error": "File tidak dapat dilampirkan. Coba unggah lagi.", + "only_one_file_allowed": "Hanya satu file yang dapat diunggah pada satu waktu.", + "file_size_limit": "File harus berukuran {size}MB atau lebih kecil.", + "drag_and_drop": "Seret dan jatuhkan di mana saja untuk mengunggah", + "delete": "Hapus lampiran" + }, + + "label": { + "select": "Pilih label", + "create": { + "success": "Label berhasil dibuat", + "failed": "Gagal membuat label", + "already_exists": "Label sudah ada", + "type": "Ketik untuk menambah label baru" + } + }, + + "sub_work_item": { + "update": { + "success": "Sub-item kerja berhasil diperbarui", + "error": "Kesalahan saat memperbarui sub-item kerja" + }, + "remove": { + "success": "Sub-item kerja berhasil dihapus", + "error": "Kesalahan saat menghapus sub-item kerja" + } + }, + + "view": { + "label": "{count, plural, one {Tampilan} other {Tampilan}}", + "create": { + "label": "Buat Tampilan" + }, + "update": { + "label": "Perbarui Tampilan" + } + }, + + "inbox_issue": { + "status": { + "pending": { + "title": "Menunggu", + "description": "Menunggu" + }, + "declined": { + "title": "Ditolak", + "description": "Ditolak" + }, + "snoozed": { + "title": "Ditunda", + "description": "{days, plural, one{# hari} other{# hari}} tersisa" + }, + "accepted": { + "title": "Diterima", + "description": "Diterima" + }, + "duplicate": { + "title": "Duplikat", + "description": "Duplikat" + } + }, + "modals": { + "decline": { + "title": "Tolak item kerja", + "content": "Apakah Anda yakin ingin menolak item kerja {value}?" + }, + "delete": { + "title": "Hapus item kerja", + "content": "Apakah Anda yakin ingin menghapus item kerja {value}?", + "success": "Item kerja berhasil dihapus" + } + }, + "errors": { + "snooze_permission": "Hanya admin proyek yang bisa menunda/menghapus penundaan item kerja", + "accept_permission": "Hanya admin proyek yang bisa menerima item kerja", + "decline_permission": "Hanya admin proyek yang bisa menolak item kerja" + }, + "actions": { + "accept": "Terima", + "decline": "Tolak", + "snooze": "Tunda", + "unsnooze": "Hapus penundaan", + "copy": "Salin tautan item kerja", + "delete": "Hapus", + "open": "Buka item kerja", + "mark_as_duplicate": "Tandai sebagai duplikat", + "move": "Pindahkan {value} ke item kerja proyek" + }, + "source": { + "in-app": "dalam aplikasi" + }, + "order_by": { + "created_at": "Dibuat pada", + "updated_at": "Diperbarui pada", + "id": "ID" + }, + "label": "Pendapat", + "page_label": "{workspace} - Pendapat", + "modal": { + "title": "Buat item kerja pendapat" + }, + "tabs": { + "open": "Terbuka", + "closed": "Tutup" + }, + "empty_state": { + "sidebar_open_tab": { + "title": "Tidak ada item kerja terbuka", + "description": "Temukan item kerja terbuka di sini. Buat item kerja baru." + }, + "sidebar_closed_tab": { + "title": "Tidak ada item kerja tertutup", + "description": "Semua item kerja yang diterima atau ditolak dapat ditemukan di sini." + }, + "sidebar_filter": { + "title": "Tidak ada item kerja yang cocok", + "description": "Tidak ada item kerja yang cocok dengan filter yang diterapkan dalam pendapat. Buat item kerja baru." + }, + "detail": { + "title": "Pilih item kerja untuk melihat detailnya." + } + } + }, + + "workspace_creation": { + "heading": "Buat ruang kerja Anda", + "subheading": "Untuk mulai menggunakan Plane, Anda perlu membuat atau bergabung dengan ruang kerja.", + "form": { + "name": { + "label": "Nama ruang kerja Anda", + "placeholder": "Sesuatu yang familiar dan dapat dikenali selalu lebih baik." + }, + "url": { + "label": "Atur URL ruang kerja Anda", + "placeholder": "Ketik atau tempel URL", + "edit_slug": "Anda hanya dapat mengedit slug URL" + }, + "organization_size": { + "label": "Berapa banyak orang yang akan menggunakan ruang kerja ini?", + "placeholder": "Pilih rentang" + } + }, + "errors": { + "creation_disabled": { + "title": "Hanya admin instansi Anda yang dapat membuat ruang kerja", + "description": "Jika Anda tahu alamat email admin instansi Anda, klik tombol di bawah ini untuk menghubungi mereka.", + "request_button": "Minta admin instansi" + }, + "validation": { + "name_alphanumeric": "Nama ruang kerja hanya boleh berisi (' '), ('-'), ('_') dan karakter alfanumerik.", + "name_length": "Batasi nama Anda hingga 80 karakter.", + "url_alphanumeric": "URL hanya boleh berisi ('-') dan karakter alfanumerik.", + "url_length": "Batasi URL Anda hingga 48 karakter.", + "url_already_taken": "URL ruang kerja sudah diambil!" + } + }, + "request_email": { + "subject": "Meminta ruang kerja baru", + "body": "Hai admin instansi,\n\nTolong buat ruang kerja baru dengan URL [/workspace-name] untuk [tujuan pembuatan ruang kerja].\n\nTerima kasih,\n{firstName} {lastName}\n{email}" + }, + "button": { + "default": "Buat ruang kerja", + "loading": "Membuat ruang kerja" + }, + "toast": { + "success": { + "title": "Sukses", + "message": "Ruang kerja berhasil dibuat" + }, + "error": { + "title": "Kesalahan", + "message": "Ruang kerja tidak dapat dibuat. Silakan coba lagi." + } + } + }, + + "workspace_dashboard": { + "empty_state": { + "general": { + "title": "Ikhtisar proyek, aktivitas, dan metrik Anda", + "description": "Selamat datang di Plane, kami sangat senang memiliki Anda di sini. Buat proyek pertama Anda dan lacak item kerja Anda, dan halaman ini akan berubah menjadi ruang yang membantu Anda berkembang. Admin juga akan melihat item yang membantu tim mereka berkembang.", + "primary_button": { + "text": "Bangun proyek pertama Anda", + "comic": { + "title": "Segalanya dimulai dengan proyek di Plane", + "description": "Sebuah proyek bisa menjadi roadmap produk, kampanye pemasaran, atau meluncurkan mobil baru." + } + } + } + } + }, + + "workspace_analytics": { + "label": "Analitik", + "page_label": "{workspace} - Analitik", + "open_tasks": "Jumlah tugas terbuka", + "error": "Terjadi kesalahan dalam mengambil data.", + "work_items_closed_in": "Item kerja yang ditutup dalam", + "selected_projects": "Proyek yang dipilih", + "total_members": "Jumlah anggota total", + "total_cycles": "Jumlah siklus total", + "total_modules": "Jumlah modul total", + "pending_work_items": { + "title": "Item kerja yang menunggu", + "empty_state": "Analisis item kerja yang menunggu oleh rekan kerja muncul di sini." + }, + "work_items_closed_in_a_year": { + "title": "Item kerja yang ditutup dalam setahun", + "empty_state": "Tutup item kerja untuk melihat analisis dari item kerja tersebut dalam bentuk grafik." + }, + "most_work_items_created": { + "title": "Paling banyak item kerja yang dibuat", + "empty_state": "Rekan kerja dan jumlah item kerja yang mereka buat muncul di sini." + }, + "most_work_items_closed": { + "title": "Paling banyak item kerja yang ditutup", + "empty_state": "Rekan kerja dan jumlah item kerja yang mereka tutup muncul di sini." + }, + "tabs": { + "scope_and_demand": "Lingkup dan Permintaan", + "custom": "Analitik Kustom" + }, + "empty_state": { + "general": { + "title": "Lacak kemajuan, beban kerja, dan alokasi. Temukan tren, hilangkan penghalang, dan percepat pekerjaan", + "description": "Lihat lingkup dibandingkan permintaan, perkiraan, dan lingkup cree. Dapatkan kinerja oleh anggota tim dan tim, dan pastikan proyek Anda berjalan tepat waktu.", + "primary_button": { + "text": "Mulai proyek pertama Anda", + "comic": { + "title": "Analitik bekerja terbaik dengan Siklus + Modul", + "description": "Pertama, bagi item kerja Anda ke dalam Siklus dan, jika memungkinkan, kelompokkan item kerja yang menjangkau lebih dari satu siklus ke dalam Modul. Lihat kedua fungsi pada navigasi kiri." + } + } + } + } + }, + + "workspace_projects": { + "label": "{count, plural, one {Proyek} other {Proyek}}", + "create": { + "label": "Tambah Proyek" + }, + "network": { + "label": "Jaringan", + "private": { + "title": "Pribadi", + "description": "Dapat diakses hanya dengan undangan" + }, + "public": { + "title": "Umum", + "description": "Siapa pun di ruang kerja kecuali Tamu dapat bergabung" + } + }, + "error": { + "permission": "Anda tidak memiliki izin untuk melakukan tindakan ini.", + "cycle_delete": "Gagal menghapus siklus", + "module_delete": "Gagal menghapus modul", + "issue_delete": "Gagal menghapus item kerja" + }, + "state": { + "backlog": "Backlog", + "unstarted": "Belum dimulai", + "started": "Dimulai", + "completed": "Selesai", + "cancelled": "Dibatalkan" + }, + "sort": { + "manual": "Manual", + "name": "Nama", + "created_at": "Tanggal dibuat", + "members_length": "Jumlah anggota" + }, + "scope": { + "my_projects": "Proyek saya", + "archived_projects": "Diarsipkan" + }, + "common": { + "months_count": "{months, plural, one{# bulan} other{# bulan}}" + }, + "empty_state": { + "general": { + "title": "Tidak ada proyek aktif", + "description": "Anggap setiap proyek sebagai induk untuk pekerjaan yang terarah pada tujuan. Proyek adalah tempat di mana Pekerjaan, Siklus, dan Modul tinggal dan, bersama rekan-rekan Anda, membantu Anda mencapai tujuan tersebut. Buat proyek baru atau filter untuk proyek yang diarsipkan.", + "primary_button": { + "text": "Mulai proyek pertama Anda", + "comic": { + "title": "Segalanya dimulai dengan proyek di Plane", + "description": "Sebuah proyek bisa menjadi roadmap produk, kampanye pemasaran, atau meluncurkan mobil baru." + } + } + }, + "no_projects": { + "title": "Tidak ada proyek", + "description": "Untuk membuat item kerja atau mengelola pekerjaan Anda, Anda perlu membuat proyek atau menjadi bagian dari salah satunya.", + "primary_button": { + "text": "Mulai proyek pertama Anda", + "comic": { + "title": "Segalanya dimulai dengan proyek di Plane", + "description": "Sebuah proyek bisa menjadi roadmap produk, kampanye pemasaran, atau meluncurkan mobil baru." + } + } + }, + "filter": { + "title": "Tidak ada proyek yang cocok", + "description": "Tidak ada proyek yang terdeteksi dengan kriteria yang cocok. \n Buat proyek baru sebagai gantinya." + }, + "search": { + "description": "Tidak ada proyek yang terdeteksi dengan kriteria yang cocok.\nBuat proyek baru sebagai gantinya" + } + } + }, + + "workspace_views": { + "add_view": "Tambah tampilan", + "empty_state": { + "all-issues": { + "title": "Tidak ada item kerja dalam proyek", + "description": "Proyek pertama sudah selesai! Sekarang, bagi pekerjaan Anda menjadi bagian yang dapat dilacak dengan item kerja. Mari kita mulai!", + "primary_button": { + "text": "Buat item kerja baru" + } + }, + "assigned": { + "title": "Belum ada item kerja", + "description": "Item kerja yang ditugaskan kepada Anda dapat dilacak dari sini.", + "primary_button": { + "text": "Buat item kerja baru" + } + }, + "created": { + "title": "Belum ada item kerja", + "description": "Semua item kerja yang dibuat oleh Anda akan muncul di sini, lacak mereka langsung di sini.", + "primary_button": { + "text": "Buat item kerja baru" + } + }, + "subscribed": { + "title": "Belum ada item kerja", + "description": "Langgan item kerja yang Anda minati, lacak semuanya di sini." + }, + "custom-view": { + "title": "Belum ada item kerja", + "description": "Item kerja yang menerapkan filter ini, lacak semuanya di sini." + } + } + }, + + "workspace_settings": { + "label": "Pengaturan ruang kerja", + "page_label": "{workspace} - Pengaturan Umum", + "key_created": "Kunci dibuat", + "copy_key": "Salin dan simpan kunci rahasia ini di Halaman Plane. Anda tidak dapat melihat kunci ini setelah Anda menekan Tutup. File CSV yang berisi kunci telah diunduh.", + "token_copied": "Token disalin ke clipboard.", + "settings": { + "general": { + "title": "Umum", + "upload_logo": "Unggah logo", + "edit_logo": "Edit logo", + "name": "Nama ruang kerja", + "company_size": "Ukuran perusahaan", + "url": "URL ruang kerja", + "update_workspace": "Perbarui ruang kerja", + "delete_workspace": "Hapus ruang kerja ini", + "delete_workspace_description": "Ketika menghapus ruang kerja, semua data dan sumber daya di dalam ruang kerja tersebut akan dihapus secara permanen dan tidak dapat dipulihkan.", + "delete_btn": "Hapus ruang kerja ini", + "delete_modal": { + "title": "Apakah Anda yakin ingin menghapus ruang kerja ini?", + "description": "Anda memiliki percobaan aktif untuk salah satu rencana berbayar kami. Silakan batalkan terlebih dahulu untuk melanjutkan.", + "dismiss": "Tutup", + "cancel": "Batalkan percobaan", + "success_title": "Ruang kerja dihapus.", + "success_message": "Anda akan segera diarahkan ke halaman profil Anda.", + "error_title": "Itu tidak berhasil.", + "error_message": "Silakan coba lagi." + }, + "errors": { + "name": { + "required": "Nama diperlukan", + "max_length": "Nama ruang kerja tidak boleh lebih dari 80 karakter" + }, + "company_size": { + "required": "Ukuran perusahaan diperlukan", + "select_a_range": "Pilih ukuran organisasi" + } + } + }, + "members": { + "title": "Anggota", + "add_member": "Tambah anggota", + "pending_invites": "Undangan yang tertunda", + "invitations_sent_successfully": "Undangan berhasil dikirim", + "leave_confirmation": "Apakah Anda yakin ingin meninggalkan ruang kerja? Anda tidak akan lagi memiliki akses ke ruang kerja ini. Tindakan ini tidak dapat dibatalkan.", + "details": { + "full_name": "Nama lengkap", + "display_name": "Nama tampilan", + "email_address": "Alamat email", + "account_type": "Tipe akun", + "authentication": "Autentikasi", + "joining_date": "Tanggal bergabung" + }, + "modal": { + "title": "Undang orang untuk berkolaborasi", + "description": "Undang orang untuk berkolaborasi di ruang kerja Anda.", + "button": "Kirim undangan", + "button_loading": "Mengirim undangan", + "placeholder": "name@company.com", + "errors": { + "required": "Kami perlu alamat email untuk mengundang mereka.", + "invalid": "Email tidak valid" + } + } + }, + "billing_and_plans": { + "title": "Penagihan & Rencana", + "current_plan": "Rencana saat ini", + "free_plan": "Anda saat ini menggunakan rencana gratis", + "view_plans": "Lihat rencana" + }, + "exports": { + "title": "Ekspor", + "exporting": "Mengeskpor", + "previous_exports": "Ekspor sebelumnya", + "export_separate_files": "Ekspor data ke file terpisah", + "modal": { + "title": "Ekspor ke", + "toasts": { + "success": { + "title": "Ekspor berhasil", + "message": "Anda akan dapat mengunduh {entity} yang diekspor dari ekspor sebelumnya." + }, + "error": { + "title": "Ekspor gagal", + "message": "Ekspor tidak berhasil. Silakan coba lagi." + } + } + } + }, + "webhooks": { + "title": "Webhook", + "add_webhook": "Tambah webhook", + "modal": { + "title": "Buat webhook", + "details": "Detail webhook", + "payload": "Payload URL", + "question": "Peristiwa apa yang ingin Anda picu untuk webhook ini?", + "error": "URL diperlukan" + }, + "secret_key": { + "title": "Kunci rahasia", + "message": "Hasilkan token untuk masuk ke payload webhook" + }, + "options": { + "all": "Kirim saya semuanya", + "individual": "Pilih peristiwa individu" + }, + "toasts": { + "created": { + "title": "Webhook dibuat", + "message": "Webhook telah berhasil dibuat" + }, + "not_created": { + "title": "Webhook tidak dibuat", + "message": "Webhook tidak dapat dibuat" + }, + "updated": { + "title": "Webhook diperbarui", + "message": "Webhook telah berhasil diperbarui" + }, + "not_updated": { + "title": "Webhook tidak diperbarui", + "message": "Webhook tidak dapat diperbarui" + }, + "removed": { + "title": "Webhook dihapus", + "message": "Webhook telah berhasil dihapus" + }, + "not_removed": { + "title": "Webhook tidak dihapus", + "message": "Webhook tidak dapat dihapus" + }, + "secret_key_copied": { + "message": "Kunci rahasia disalin ke clipboard." + }, + "secret_key_not_copied": { + "message": "Terjadi kesalahan saat menyalin kunci rahasia." + } + } + }, + "api_tokens": { + "title": "Token API", + "add_token": "Tambah token API", + "create_token": "Buat token", + "never_expires": "Tidak pernah kedaluwarsa", + "generate_token": "Hasilkan token", + "generating": "Menghasilkan", + "delete": { + "title": "Hapus token API", + "description": "Setiap aplikasi yang menggunakan token ini tidak akan memiliki akses ke data Plane. Tindakan ini tidak dapat dibatalkan.", + "success": { + "title": "Sukses!", + "message": "Token API telah berhasil dihapus" + }, + "error": { + "title": "Kesalahan!", + "message": "Token API tidak dapat dihapus" + } + } + } + }, + "empty_state": { + "api_tokens": { + "title": "Belum ada token API yang dibuat", + "description": "API Plane dapat digunakan untuk mengintegrasikan data Anda di Plane dengan sistem eksternal mana pun. Buat token untuk memulai." + }, + "webhooks": { + "title": "Belum ada webhook yang ditambahkan", + "description": "Buat webhook untuk menerima pembaruan waktu nyata dan mengotomatiskan tindakan." + }, + "exports": { + "title": "Belum ada ekspor", + "description": "Setiap kali Anda mengekspor, Anda juga akan memiliki salinan di sini untuk referensi." + }, + "imports": { + "title": "Belum ada impor", + "description": "Temukan semua impor Anda sebelumnya di sini dan unduh." + } + } + }, + + "profile": { + "label": "Profil", + "page_label": "Pekerjaan Anda", + "work": "Pekerjaan", + "details": { + "joined_on": "Bergabung pada", + "time_zone": "Zona waktu" + }, + "stats": { + "workload": "Beban kerja", + "overview": "Ikhtisar", + "created": "Item kerja yang dibuat", + "assigned": "Item kerja yang ditugaskan", + "subscribed": "Item kerja yang disubscribe", + "state_distribution": { + "title": "Item kerja berdasarkan status", + "empty": "Buat item kerja untuk melihatnya berdasarkan status dalam grafik untuk analisis yang lebih baik." + }, + "priority_distribution": { + "title": "Item kerja berdasarkan Prioritas", + "empty": "Buat item kerja untuk melihatnya berdasarkan prioritas dalam grafik untuk analisis yang lebih baik." + }, + "recent_activity": { + "title": "Aktivitas terkini", + "empty": "Kami tidak dapat menemukan data. Silakan lihat input Anda", + "button": "Unduh aktivitas hari ini", + "button_loading": "Mengunduh" + } + }, + "actions": { + "profile": "Profil", + "security": "Keamanan", + "activity": "Aktivitas", + "appearance": "Tampilan", + "notifications": "Notifikasi" + }, + "tabs": { + "summary": "Ringkasan", + "assigned": "Ditugaskan", + "created": "Dibuat", + "subscribed": "Disubscribe", + "activity": "Aktivitas" + }, + "empty_state": { + "activity": { + "title": "Belum ada aktivitas", + "description": "Mulai dengan membuat item kerja baru! Tambahkan detail dan properti. Jelajahi lebih lanjut di Plane untuk melihat aktivitas Anda." + }, + "assigned": { + "title": "Tidak ada item kerja yang ditugaskan kepada Anda", + "description": "Item kerja yang ditugaskan kepada Anda dapat dilacak dari sini." + }, + "created": { + "title": "Belum ada item kerja", + "description": "Semua item kerja yang dibuat oleh Anda hadir di sini, dan lacak langsung di sini." + }, + "subscribed": { + "title": "Belum ada item kerja", + "description": "Langganan item kerja yang Anda minati, lacak semuanya di sini." + } + } + }, + + "project_settings": { + "general": { + "enter_project_id": "Masukkan ID proyek", + "please_select_a_timezone": "Silakan pilih zona waktu", + "archive_project": { + "title": "Arsipkan proyek", + "description": "Mengarsipkan proyek akan menghapus proyek Anda dari navigasi samping meskipun Anda masih dapat mengaksesnya dari halaman proyek Anda. Anda dapat memulihkan proyek tersebut atau menghapusnya kapan saja.", + "button": "Arsipkan proyek" + }, + "delete_project": { + "title": "Hapus proyek", + "description": "Ketika menghapus proyek, semua data dan sumber daya di dalam proyek tersebut akan dihapus secara permanen dan tidak dapat dipulihkan.", + "button": "Hapus proyek saya" + }, + "toast": { + "success": "Proyek berhasil diperbarui", + "error": "Proyek tidak dapat diperbarui. Silakan coba lagi." + } + }, + "members": { + "label": "Anggota", + "project_lead": "Pemimpin proyek", + "default_assignee": "Penugas default", + "guest_super_permissions": { + "title": "Beri akses tampilan untuk semua item kerja untuk pengguna tamu:", + "sub_heading": "Ini akan memungkinkan tamu untuk memiliki akses tampilan ke semua item kerja proyek." + }, + "invite_members": { + "title": "Undang anggota", + "sub_heading": "Undang anggota untuk bekerja di proyek Anda.", + "select_co_worker": "Pilih rekan kerja" + } + }, + "states": { + "describe_this_state_for_your_members": "Jelaskan status ini untuk anggota Anda.", + "empty_state": { + "title": "Tidak ada status yang tersedia untuk grup {groupKey}", + "description": "Silakan buat status baru" + } + }, + "labels": { + "label_title": "Judul label", + "label_title_is_required": "Judul label diperlukan", + "label_max_char": "Nama label tidak boleh lebih dari 255 karakter", + "toast": { + "error": "Kesalahan saat memperbarui label" + } + }, + "estimates": { + "label": "Perkiraan", + "title": "Aktifkan perkiraan untuk proyek saya", + "description": "Ini membantu Anda dalam mengkomunikasikan kompleksitas dan beban kerja tim.", + "no_estimate": "Tidak ada perkiraan", + "new": "Sistem perkiraan baru", + "create": { + "custom": "Kustom", + "start_from_scratch": "Mulai dari awal", + "choose_template": "Pilih template", + "choose_estimate_system": "Pilih sistem perkiraan", + "enter_estimate_point": "Masukkan perkiraan", + "step": "Langkah {step} dari {total}", + "label": "Buat perkiraan" + }, + "toasts": { + "created": { + "success": { + "title": "Perkiraan dibuat", + "message": "Perkiraan telah berhasil dibuat" + }, + "error": { + "title": "Pembuatan perkiraan gagal", + "message": "Kami tidak dapat membuat perkiraan baru, silakan coba lagi." + } + }, + "updated": { + "success": { + "title": "Perkiraan dimodifikasi", + "message": "Perkiraan telah diperbarui dalam proyek Anda." + }, + "error": { + "title": "Modifikasi perkiraan gagal", + "message": "Kami tidak dapat memodifikasi perkiraan, silakan coba lagi" + } + }, + "enabled": { + "success": { + "title": "Berhasil!", + "message": "Perkiraan telah diaktifkan." + } + }, + "disabled": { + "success": { + "title": "Berhasil!", + "message": "Perkiraan telah dinonaktifkan." + }, + "error": { + "title": "Kesalahan!", + "message": "Perkiraan tidak dapat dinonaktifkan. Silakan coba lagi" + } + } + }, + "validation": { + "min_length": "Perkiraan harus lebih besar dari 0.", + "unable_to_process": "Kami tidak dapat memproses permintaan Anda, silakan coba lagi.", + "numeric": "Perkiraan harus berupa nilai numerik.", + "character": "Perkiraan harus berupa nilai karakter.", + "empty": "Nilai perkiraan tidak boleh kosong.", + "already_exists": "Nilai perkiraan sudah ada.", + "unsaved_changes": "Anda memiliki beberapa perubahan yang belum disimpan, Harap simpan sebelum mengklik selesai", + "remove_empty": "Perkiraan tidak boleh kosong. Masukkan nilai di setiap bidang atau hapus yang tidak memiliki nilai." + }, + "systems": { + "points": { + "label": "Poin", + "fibonacci": "Fibonacci", + "linear": "Linear", + "squares": "Kuadrat", + "custom": "Kustom" + }, + "categories": { + "label": "Kategori", + "t_shirt_sizes": "Ukuran Baju", + "easy_to_hard": "Mudah ke sulit", + "custom": "Kustom" + }, + "time": { + "label": "Waktu", + "hours": "Jam" + } + } + }, + "automations": { + "label": "Otomatisasi", + "auto-archive": { + "title": "Arsip otomatis item kerja yang ditutup", + "description": "Plane akan mengarsipkan secara otomatis item kerja yang telah selesai atau dibatalkan.", + "duration": "Arsip otomatis item kerja yang ditutup selama" + }, + "auto-close": { + "title": "Tutup otomatis item kerja", + "description": "Plane akan menutup secara otomatis item kerja yang belum selesai atau dibatalkan.", + "duration": "Tutup otomatis item kerja yang tidak aktif selama", + "auto_close_status": "Status penutupan otomatis" + } + }, + + "empty_state": { + "labels": { + "title": "Belum ada label", + "description": "Buat label untuk membantu mengatur dan memfilter item kerja dalam proyek Anda." + }, + "estimates": { + "title": "Belum ada sistem perkiraan", + "description": "Buat serangkaian perkiraan untuk mengkomunikasikan jumlah pekerjaan per item kerja.", + "primary_button": "Tambah sistem perkiraan" + } + } + }, + + "project_cycles": { + "add_cycle": "Tambah siklus", + "more_details": "Detail lebih lanjut", + "cycle": "Siklus", + "update_cycle": "Perbarui siklus", + "create_cycle": "Buat siklus", + "no_matching_cycles": "Tidak ada siklus yang cocok", + "remove_filters_to_see_all_cycles": "Hapus filter untuk melihat semua siklus", + "remove_search_criteria_to_see_all_cycles": "Hapus kriteria pencarian untuk melihat semua siklus", + "only_completed_cycles_can_be_archived": "Hanya siklus yang diselesaikan yang dapat diarsipkan", + "active_cycle": { + "label": "Siklus aktif", + "progress": "Kemajuan", + "chart": "Grafik burndown", + "priority_issue": "Item kerja prioritas", + "assignees": "Penugasan", + "issue_burndown": "Burndown item kerja", + "ideal": "Ideal", + "current": "Sekarang", + "labels": "Label" + }, + "upcoming_cycle": { + "label": "Siklus mendatang" + }, + "completed_cycle": { + "label": "Siklus selesai" + }, + "status": { + "days_left": "Hari tersisa", + "completed": "Selesai", + "yet_to_start": "Belum dimulai", + "in_progress": "Sedang berlangsung", + "draft": "Draf" + }, + "action": { + "restore": { + "title": "Pulihkan siklus", + "success": { + "title": "Siklus dipulihkan", + "description": "Siklus telah dipulihkan." + }, + "failed": { + "title": "Pemulihan siklus gagal", + "description": "Siklus tidak dapat dipulihkan. Silakan coba lagi." + } + }, + "favorite": { + "loading": "Menambahkan siklus ke favorit", + "success": { + "description": "Siklus ditambahkan ke favorit.", + "title": "Sukses!" + }, + "failed": { + "description": "Gagal menambahkan siklus ke favorit. Silakan coba lagi.", + "title": "Kesalahan!" + } + }, + "unfavorite": { + "loading": "Menghapus siklus dari favorit", + "success": { + "description": "Siklus dihapus dari favorit.", + "title": "Sukses!" + }, + "failed": { + "description": "Gagal menghapus siklus dari favorit. Silakan coba lagi.", + "title": "Kesalahan!" + } + }, + "update": { + "loading": "Memperbarui siklus", + "success": { + "description": "Siklus berhasil diperbarui.", + "title": "Sukses!" + }, + "failed": { + "description": "Kesalahan saat memperbarui siklus. Silakan coba lagi.", + "title": "Kesalahan!" + }, + "error": { + "already_exists": "Anda sudah memiliki siklus pada tanggal yang diberikan, jika Anda ingin membuat siklus draf, Anda dapat melakukannya dengan menghapus kedua tanggal tersebut." + } + } + }, + "empty_state": { + "general": { + "title": "Kelompokkan dan bagi pekerjaan Anda dalam Siklus.", + "description": "Pecah pekerjaan menjadi bagian yang dibatasi waktu, kerjakan mundur dari tenggat waktu proyek Anda untuk menetapkan tanggal, dan buat kemajuan nyata sebagai tim.", + "primary_button": { + "text": "Tetapkan siklus pertama Anda", + "comic": { + "title": "Siklus adalah batas waktu berulang.", + "description": "Sprint, iterasi, dan istilah lain apa pun yang Anda gunakan untuk pelacakan pekerjaan mingguan atau dua mingguan adalah siklus." + } + } + }, + "no_issues": { + "title": "Tidak ada item kerja yang ditambahkan ke siklus", + "description": "Tambahkan atau buat item kerja yang ingin Anda batasi waktu dan kirim dalam siklus ini", + "primary_button": { + "text": "Buat item kerja baru" + }, + "secondary_button": { + "text": "Tambah item kerja yang ada" + } + }, + "completed_no_issues": { + "title": "Tidak ada item kerja dalam siklus", + "description": "Tidak ada item kerja dalam siklus. Item kerja baik ditransfer atau disembunyikan. Untuk melihat item kerja yang disembunyikan jika ada, perbarui properti tampilan Anda sesuai." + }, + "active": { + "title": "Tidak ada siklus aktif", + "description": "Siklus aktif mencakup periode apa pun yang mencakup tanggal hari ini dalam rentangnya. Temukan kemajuan dan detail siklus aktif di sini." + }, + "archived": { + "title": "Belum ada siklus yang diarsipkan", + "description": "Untuk membersihkan proyek Anda, arsipkan siklus yang telah diselesaikan. Temukan di sini setelah diarsipkan." + } + } + }, + + "project_issues": { + "empty_state": { + "no_issues": { + "title": "Buat item kerja dan tugaskan kepada seseorang, bahkan kepada diri Anda sendiri", + "description": "Anggap item kerja sebagai pekerjaan, tugas, atau JTBD. Yang kami suka. Item kerja dan sub-item kerjanya biasanya merupakan tindakan berbasis waktu yang ditugaskan kepada anggota tim Anda. Tim Anda membuat, menetapkan, dan menyelesaikan item kerja untuk memindahkan proyek Anda menuju tujuannya.", + "primary_button": { + "text": "Buat item kerja pertama Anda", + "comic": { + "title": "Item kerja adalah blok bangunan di Plane.", + "description": "Mendesain ulang UI Plane, Mengganti merek perusahaan, atau Meluncurkan sistem injeksi bahan bakar baru adalah contoh item kerja yang kemungkinan besar memiliki sub-item kerja." + } + } + }, + "no_archived_issues": { + "title": "Belum ada item kerja yang diarsipkan", + "description": "Secara manual atau melalui otomatisasi, Anda dapat mengarsipkan item kerja yang telah selesai atau dibatalkan. Temukan di sini setelah diarsipkan.", + "primary_button": { + "text": "Tetapkan otomatisasi" + } + }, + "issues_empty_filter": { + "title": "Tidak ada item kerja ditemukan yang cocok dengan filter yang diterapkan", + "secondary_button": { + "text": "Bersihkan semua filter" + } + } + } + }, + + "project_module": { + "add_module": "Tambah Modul", + "update_module": "Perbarui Modul", + "create_module": "Buat Modul", + "archive_module": "Arsipkan Modul", + "restore_module": "Pulihkan Modul", + "delete_module": "Hapus modul", + "empty_state": { + "general": { + "title": "Peta tonggak proyek Anda ke Modul dan lacak pekerjaan terakumulasi dengan mudah.", + "description": "Sekelompok item kerja yang tergolong dalam induk yang logis dan hierarkis membentuk satu modul. Anggap saja mereka sebagai cara untuk melacak pekerjaan berdasarkan tonggak proyek. Mereka memiliki periode dan tenggat waktu sendiri serta analitik untuk membantu Anda melihat seberapa dekat atau jauh Anda dari tonggak tersebut.", + "primary_button": { + "text": "Buat modul pertama Anda", + "comic": { + "title": "Modul membantu mengelompokkan pekerjaan menurut hierarki.", + "description": "Modul kereta, modul sasis, dan modul gudang adalah contoh bagus dari pengelompokan ini." + } + } + }, + "no_issues": { + "title": "Tidak ada item kerja dalam modul", + "description": "Buat atau tambahkan item kerja yang ingin Anda capai sebagai bagian dari modul ini", + "primary_button": { + "text": "Buat item kerja baru" + }, + "secondary_button": { + "text": "Tambahkan item kerja yang ada" + } + }, + "archived": { + "title": "Belum ada Modul yang diarsipkan", + "description": "Untuk membersihkan proyek Anda, arsipkan modul yang telah selesai atau dibatalkan. Temukan di sini setelah diarsipkan." + }, + "sidebar": { + "in_active": "Modul ini belum aktif.", + "invalid_date": "Tanggal tidak valid. Silakan masukkan tanggal yang valid." + } + }, + "quick_actions": { + "archive_module": "Arsipkan modul", + "archive_module_description": "Hanya modul yang telah diselesaikan atau dibatalkan\n yang dapat diarsipkan.", + "delete_module": "Hapus modul" + }, + "toast": { + "copy": { + "success": "Tautan modul disalin ke clipboard" + }, + "delete": { + "success": "Modul berhasil dihapus", + "error": "Gagal menghapus modul" + } + } + }, + + "project_views": { + "empty_state": { + "general": { + "title": "Simpan tampilan yang difilter untuk proyek Anda. Buat sebanyak yang Anda perlukan", + "description": "Tampilan adalah sekumpulan filter yang disimpan yang Anda gunakan secara sering atau ingin akses mudah. Semua rekan Anda dalam proyek dapat melihat tampilan semua orang dan memilih yang paling sesuai dengan kebutuhan mereka.", + "primary_button": { + "text": "Buat tampilan pertama Anda", + "comic": { + "title": "Tampilan bekerja berdasarkan properti item kerja.", + "description": "Anda dapat membuat tampilan dari sini dengan sebanyak mungkin properti sebagai filter yang Anda anggap sesuai." + } + } + }, + "filter": { + "title": "Tidak ada tampilan yang cocok", + "description": "Tidak ada tampilan yang cocok dengan kriteria pencarian. \n Buat tampilan baru sebagai gantinya." + } + } + }, + + "project_page": { + "empty_state": { + "general": { + "title": "Tulis catatan, dokumen, atau seluruh basis pengetahuan. Dapatkan Galileo, asisten AI Plane, untuk membantu Anda memulai", + "description": "Halaman adalah ruang pemikiran di Plane. Catat notul rapat, format dengan mudah, sertakan item kerja, tata letak menggunakan perpustakaan komponen, dan simpan semua di dalam konteks proyek Anda. Untuk menyelesaikan dokumen dengan cepat, panggil Galileo, AI Plane, dengan pintasan atau dengan mengklik tombol.", + "primary_button": { + "text": "Buat halaman pertama Anda" + } + }, + "private": { + "title": "Belum ada halaman pribadi", + "description": "Simpan pemikiran pribadi Anda di sini. Ketika Anda sudah siap untuk berbagi, tim hanya seklik jarak.", + "primary_button": { + "text": "Buat halaman pertama Anda" + } + }, + "public": { + "title": "Belum ada halaman publik", + "description": "Lihat halaman yang dibagikan dengan semua orang di proyek Anda tepat di sini.", + "primary_button": { + "text": "Buat halaman pertama Anda" + } + }, + "archived": { + "title": "Belum ada halaman yang diarsipkan", + "description": "Arsipkan halaman yang tidak ada dalam radar Anda. Akses di sini saat diperlukan." + } + } + }, + + "command_k": { + "empty_state": { + "search": { + "title": "Tidak ada hasil ditemukan" + } + } + }, + + "issue_relation": { + "empty_state": { + "search": { + "title": "Tidak ada item kerja yang cocok ditemukan" + }, + "no_issues": { + "title": "Tidak ada item kerja ditemukan" + } + } + }, + + "issue_comment": { + "empty_state": { + "general": { + "title": "Belum ada komentar", + "description": "Komentar dapat digunakan sebagai ruang diskusi dan tindak lanjut untuk item kerja" + } + } + }, + + "notification": { + "label": "Kotak Masuk", + "page_label": "{workspace} - Kotak Masuk", + "options": { + "mark_all_as_read": "Tandai semua sebagai dibaca", + "mark_read": "Tandai sebagai dibaca", + "mark_unread": "Tandai sebagai tidak dibaca", + "refresh": "Segarkan", + "filters": "Filter Kotak Masuk", + "show_unread": "Tampilkan yang belum dibaca", + "show_snoozed": "Tampilkan yang ditunda", + "show_archived": "Tampilkan yang diarsipkan", + "mark_archive": "Arsipkan", + "mark_unarchive": "Hapus arsip", + "mark_snooze": "Tunda", + "mark_unsnooze": "Hapus tunda" + }, + "toasts": { + "read": "Notifikasi ditandai sebagai dibaca", + "unread": "Notifikasi ditandai sebagai tidak dibaca", + "archived": "Notifikasi ditandai sebagai diarsipkan", + "unarchived": "Notifikasi ditandai sebagai dihapus arsip", + "snoozed": "Notifikasi ditunda", + "unsnoozed": "Notifikasi dihapus tunda" + }, + "empty_state": { + "detail": { + "title": "Pilih untuk melihat detail." + }, + "all": { + "title": "Tidak ada item kerja yang ditugaskan", + "description": "Pembaruan untuk item kerja yang ditugaskan kepada Anda dapat dilihat di sini" + }, + "mentions": { + "title": "Tidak ada item kerja yang ditugaskan", + "description": "Pembaruan untuk item kerja yang ditugaskan kepada Anda dapat dilihat di sini" + } + }, + "tabs": { + "all": "Semua", + "mentions": "Sebut" + }, + "filter": { + "assigned": "Ditugaskan untuk saya", + "created": "Dibuat oleh saya", + "subscribed": "Disubscribe oleh saya" + }, + "snooze": { + "1_day": "1 hari", + "3_days": "3 hari", + "5_days": "5 hari", + "1_week": "1 minggu", + "2_weeks": "2 minggu", + "custom": "Kustom" + } + }, + + "active_cycle": { + "empty_state": { + "progress": { + "title": "Tambahkan item kerja ke siklus untuk melihat kemajuannya" + }, + "chart": { + "title": "Tambahkan item kerja ke siklus untuk melihat grafik burndown." + }, + "priority_issue": { + "title": "Amati item kerja prioritas yang ditangani dalam siklus pada pandangan pertama." + }, + "assignee": { + "title": "Tambahkan penugasan ke item kerja untuk melihat pembagian kerja berdasarkan penugasan." + }, + "label": { + "title": "Tambahkan label ke item kerja untuk melihat pembagian kerja berdasarkan label." + } + } + }, + + "disabled_project": { + "empty_state": { + "inbox": { + "title": "Intake tidak diaktifkan untuk proyek ini.", + "description": "Intake membantu Anda mengelola permintaan yang masuk ke proyek Anda dan menambahkannya sebagai item kerja dalam alur kerja Anda. Aktifkan intake dari pengaturan proyek untuk mengelola permintaan.", + "primary_button": { + "text": "Kelola fitur" + } + }, + "cycle": { + "title": "Siklus tidak diaktifkan untuk proyek ini.", + "description": "Pecah pekerjaan menjadi bagian yang dibatasi waktu, kerjakan mundur dari tenggat waktu proyek Anda untuk menetapkan tanggal, dan buat kemajuan nyata sebagai tim. Aktifkan fitur siklus untuk proyek Anda agar dapat mulai menggunakannya.", + "primary_button": { + "text": "Kelola fitur" + } + }, + "module": { + "title": "Modul tidak diaktifkan untuk proyek ini.", + "description": "Modul adalah blok bangunan dari proyek Anda. Aktifkan modul dari pengaturan proyek untuk mulai menggunakannya.", + "primary_button": { + "text": "Kelola fitur" + } + }, + "page": { + "title": "Halaman tidak diaktifkan untuk proyek ini.", + "description": "Halaman adalah blok bangunan dari proyek Anda. Aktifkan halaman dari pengaturan proyek untuk mulai menggunakannya.", + "primary_button": { + "text": "Kelola fitur" + } + }, + "view": { + "title": "Tampilan tidak diaktifkan untuk proyek ini.", + "description": "Tampilan adalah blok bangunan dari proyek Anda. Aktifkan tampilan dari pengaturan proyek untuk mulai menggunakannya.", + "primary_button": { + "text": "Kelola fitur" + } + } + } + }, + "workspace_draft_issues": { + "draft_an_issue": "Draf item kerja", + "empty_state": { + "title": "Item kerja setengah jadi, dan segera, komentar akan muncul di sini.", + "description": "Untuk mencoba ini, mulai dengan menambahkan item kerja dan tinggalkan di tengah jalan atau buat draf pertama Anda di bawah ini. 😉", + "primary_button": { + "text": "Buat draf pertama Anda" + } + }, + "delete_modal": { + "title": "Hapus draf", + "description": "Apakah Anda yakin ingin menghapus draf ini? Tindakan ini tidak dapat dibatalkan." + }, + "toasts": { + "created": { + "success": "Draf berhasil dibuat", + "error": "Item kerja tidak dapat dibuat. Silakan coba lagi." + }, + "deleted": { + "success": "Draf berhasil dihapus" + } + } + }, + + "stickies": { + "title": "Catatan tempel Anda", + "placeholder": "klik untuk mengetik di sini", + "all": "Semua catatan tempel", + "no-data": "Tuliskan sebuah ide, tangkap momen aha, atau catat pemikiran brilian. Tambahkan catatan tempel untuk memulai.", + "add": "Tambah catatan tempel", + "search_placeholder": "Cari berdasarkan judul", + "delete": "Hapus catatan tempel", + "delete_confirmation": "Apakah Anda yakin ingin menghapus catatan tempel ini?", + "empty_state": { + "simple": "Tuliskan sebuah ide, tangkap momen aha, atau catat pemikiran brilian. Tambahkan catatan tempel untuk memulai.", + "general": { + "title": "Catatan tempel adalah catatan cepat dan tugas yang Anda buat secara langsung.", + "description": "Tangkap pemikiran dan ide Anda dengan mudah dengan membuat catatan tempel yang dapat Anda akses kapan saja dan dari mana saja.", + "primary_button": { + "text": "Tambah catatan tempel" + } + }, + "search": { + "title": "Itu tidak cocok dengan salah satu catatan tempel Anda.", + "description": "Coba istilah yang berbeda atau beri tahu kami\njika Anda yakin pencarian Anda benar. ", + "primary_button": { + "text": "Tambah catatan tempel" + } + } + }, + "toasts": { + "errors": { + "wrong_name": "Nama catatan tempel tidak boleh lebih dari 100 karakter.", + "already_exists": "Sudah ada catatan tempel dengan tidak ada deskripsi" + }, + "created": { + "title": "Catatan tempel berhasil dibuat", + "message": "Catatan tempel telah berhasil dibuat" + }, + "not_created": { + "title": "Catatan tempel tidak dibuat", + "message": "Catatan tempel tidak dapat dibuat" + }, + "updated": { + "title": "Catatan tempel diperbarui", + "message": "Catatan tempel telah berhasil diperbarui" + }, + "not_updated": { + "title": "Catatan tempel tidak diperbarui", + "message": "Catatan tempel tidak dapat diperbarui" + }, + "removed": { + "title": "Catatan tempel dihapus", + "message": "Catatan tempel telah berhasil dihapus" + }, + "not_removed": { + "title": "Catatan tempel tidak dihapus", + "message": "Catatan tempel tidak dapat dihapus" + } + } + }, + + "role_details": { + "guest": { + "title": "Tamu", + "description": "Anggota eksternal organisasi dapat diundang sebagai tamu." + }, + "member": { + "title": "Anggota", + "description": "Kemampuan untuk membaca, menulis, mengedit, dan menghapus entitas di dalam proyek, siklus, dan modul" + }, + "admin": { + "title": "Admin", + "description": "Semua izin diatur ke true dalam ruang kerja." + } + }, + + "user_roles": { + "product_or_project_manager": "Manajer Produk / Proyek", + "development_or_engineering": "Pengembangan / Rekayasa", + "founder_or_executive": "Pendiri / Eksekutif", + "freelancer_or_consultant": "Freelancer / Konsultan", + "marketing_or_growth": "Pemasaran / Pertumbuhan", + "sales_or_business_development": "Penjualan / Pengembangan Bisnis", + "support_or_operations": "Dukungan / Operasi", + "student_or_professor": "Mahasiswa / Profesor", + "human_resources": "Sumber Daya Manusia", + "other": "Lainnya" + }, + + "importer": { + "github": { + "title": "Github", + "description": "Impor item kerja dari repositori GitHub dan sinkronkan." + }, + "jira": { + "title": "Jira", + "description": "Impor item kerja dan epik dari proyek dan epik Jira." + } + }, + + "exporter": { + "csv": { + "title": "CSV", + "description": "Ekspor item kerja ke file CSV.", + "short_description": "Ekspor sebagai csv" + }, + "excel": { + "title": "Excel", + "description": "Ekspor item kerja ke file Excel.", + "short_description": "Ekspor sebagai excel" + }, + "xlsx": { + "title": "Excel", + "description": "Ekspor item kerja ke file Excel.", + "short_description": "Ekspor sebagai excel" + }, + "json": { + "title": "JSON", + "description": "Ekspor item kerja ke file JSON.", + "short_description": "Ekspor sebagai json" + } + }, + "default_global_view": { + "all_issues": "Semua item kerja", + "assigned": "Ditugaskan", + "created": "Dibuat", + "subscribed": "Disubscribe" + }, + + "themes": { + "theme_options": { + "system_preference": { + "label": "Preferensi sistem" + }, + "light": { + "label": "Cerah" + }, + "dark": { + "label": "Gelap" + }, + "light_contrast": { + "label": "Cerah kontras tinggi" + }, + "dark_contrast": { + "label": "Gelap kontras tinggi" + }, + "custom": { + "label": "Tema kustom" + } + } + }, + "project_modules": { + "status": { + "backlog": "Backlog", + "planned": "Direncanakan", + "in_progress": "Dalam Proses", + "paused": "Dijeda", + "completed": "Selesai", + "cancelled": "Dibatalkan" + }, + "layout": { + "list": "Tata letak daftar", + "board": "Tata letak galeri", + "timeline": "Tata letak garis waktu" + }, + "order_by": { + "name": "Nama", + "progress": "Kemajuan", + "issues": "Jumlah item kerja", + "due_date": "Tanggal jatuh tempo", + "created_at": "Tanggal dibuat", + "manual": "Manual" + } + }, + + "cycle": { + "label": "{count, plural, one {Siklus} other {Siklus}}", + "no_cycle": "Tidak ada siklus" + }, + + "module": { + "label": "{count, plural, one {Modul} other {Modul}}", + "no_module": "Tidak ada modul" + }, + + "description_versions": { + "last_edited_by": "Terakhir disunting oleh", + "previously_edited_by": "Sebelumnya disunting oleh", + "edited_by": "Disunting oleh" + } +} diff --git a/packages/i18n/src/locales/it/translations.json b/packages/i18n/src/locales/it/translations.json index ba40f5b9b..1da86f360 100644 --- a/packages/i18n/src/locales/it/translations.json +++ b/packages/i18n/src/locales/it/translations.json @@ -349,7 +349,7 @@ "couldnt_remove_the_project_from_favorites": "Impossibile rimuovere il progetto dai preferiti. Per favore, riprova.", "add_to_favorites": "Aggiungi ai preferiti", "remove_from_favorites": "Rimuovi dai preferiti", - "publish_settings": "Impostazioni di pubblicazione", + "publish_project": "Pubblica progetto", "publish": "Pubblica", "copy_link": "Copia link", "leave_project": "Lascia progetto", @@ -500,6 +500,10 @@ "re_generate_key": "Rigenera chiave", "export": "Esporta", "member": "{count, plural, one {# membro} other {# membri}}", + "new_password_must_be_different_from_old_password": "La nuova password deve essere diversa dalla password precedente", + + "edited": "Modificato", + "bot": "Bot", "project_view": { "sort_by": { @@ -590,7 +594,7 @@ "default": "Non hai ancora elementi recenti." }, "filters": { - "all": "Tutti gli elementi", + "all": "Tutti", "projects": "Progetti", "pages": "Pagine", "issues": "Elementi di lavoro" @@ -864,7 +868,8 @@ "deleting": "Eliminazione in corso", "pending": "In sospeso", "invite": "Invita", - "view": "Visualizza" + "view": "Visualizza", + "deactivated_user": "Utente disattivato" }, "chart": { @@ -1731,20 +1736,99 @@ } }, "estimates": { + "label": "Stime", "title": "Abilita le stime per il mio progetto", - "description": "Aiutano a comunicare la complessità e il carico di lavoro del team." + "description": "Ti aiutano a comunicare la complessità e il carico di lavoro del team.", + "no_estimate": "Nessuna stima", + "new": "Nuovo sistema di stima", + "create": { + "custom": "Personalizzato", + "start_from_scratch": "Inizia da zero", + "choose_template": "Scegli un modello", + "choose_estimate_system": "Scegli un sistema di stima", + "enter_estimate_point": "Inserisci stima", + "step": "Passo {step} di {total}", + "label": "Crea stima" + }, + "toasts": { + "created": { + "success": { + "title": "Stima creata", + "message": "La stima è stata creata con successo" + }, + "error": { + "title": "Creazione stima fallita", + "message": "Non siamo riusciti a creare la nuova stima, riprova." + } + }, + "updated": { + "success": { + "title": "Stima modificata", + "message": "La stima è stata aggiornata nel tuo progetto." + }, + "error": { + "title": "Modifica stima fallita", + "message": "Non siamo riusciti a modificare la stima, riprova" + } + }, + "enabled": { + "success": { + "title": "Successo!", + "message": "Le stime sono state abilitate." + } + }, + "disabled": { + "success": { + "title": "Successo!", + "message": "Le stime sono state disabilitate." + }, + "error": { + "title": "Errore!", + "message": "Impossibile disabilitare la stima. Riprova" + } + } + }, + "validation": { + "min_length": "La stima deve essere maggiore di 0.", + "unable_to_process": "Non possiamo elaborare la tua richiesta, riprova.", + "numeric": "La stima deve essere un valore numerico.", + "character": "La stima deve essere un valore di carattere.", + "empty": "Il valore della stima non può essere vuoto.", + "already_exists": "Il valore della stima esiste già.", + "unsaved_changes": "Hai delle modifiche non salvate. Salva prima di cliccare su Fatto", + "remove_empty": "La stima non può essere vuota. Inserisci un valore in ogni campo o rimuovi quelli per cui non hai valori." + }, + "systems": { + "points": { + "label": "Punti", + "fibonacci": "Fibonacci", + "linear": "Lineare", + "squares": "Quadrati", + "custom": "Personalizzato" + }, + "categories": { + "label": "Categorie", + "t_shirt_sizes": "Taglie T-Shirt", + "easy_to_hard": "Da facile a difficile", + "custom": "Personalizzato" + }, + "time": { + "label": "Tempo", + "hours": "Ore" + } + } }, "automations": { - "label": "Automazioni", + "label": "Automatizzazioni", "auto-archive": { "title": "Archivia automaticamente gli elementi di lavoro chiusi", - "description": "Plane archivierà automaticamente gli elementi di lavoro che sono stati completati o annullati.", - "duration": "Archivia automaticamente gli elementi di lavoro chiusi da" + "description": "Plane archiverà automaticamente gli elementi di lavoro che sono stati completati o annullati.", + "duration": "Archivia automaticamente gli elementi di lavoro chiusi per" }, "auto-close": { "title": "Chiudi automaticamente gli elementi di lavoro", "description": "Plane chiuderà automaticamente gli elementi di lavoro che non sono stati completati o annullati.", - "duration": "Chiudi automaticamente gli elementi di lavoro inattivi da", + "duration": "Chiudi automaticamente gli elementi di lavoro inattivi per", "auto_close_status": "Stato di chiusura automatica" } }, @@ -1772,6 +1856,12 @@ "remove_filters_to_see_all_cycles": "Rimuovi i filtri per vedere tutti i cicli", "remove_search_criteria_to_see_all_cycles": "Rimuovi i criteri di ricerca per vedere tutti i cicli", "only_completed_cycles_can_be_archived": "Solo i cicli completati possono essere archiviati", + "start_date": "Data di inizio", + "end_date": "Data di fine", + "in_your_timezone": "Nel tuo fuso orario", + "transfer_work_items": "Trasferisci {count} elementi di lavoro", + "date_range": "Intervallo di date", + "add_date": "Aggiungi data", "active_cycle": { "label": "Ciclo attivo", "progress": "Avanzamento", @@ -2363,5 +2453,11 @@ "module": { "label": "{count, plural, one {Modulo} other {Moduli}}", "no_module": "Nessun modulo" + }, + + "description_versions": { + "last_edited_by": "Ultima modifica di", + "previously_edited_by": "Precedentemente modificato da", + "edited_by": "Modificato da" } } diff --git a/packages/i18n/src/locales/ja/translations.json b/packages/i18n/src/locales/ja/translations.json index 8f3c82e05..f9765692f 100644 --- a/packages/i18n/src/locales/ja/translations.json +++ b/packages/i18n/src/locales/ja/translations.json @@ -350,7 +350,7 @@ "couldnt_remove_the_project_from_favorites": "プロジェクトをお気に入りから削除できませんでした。もう一度お試しください。", "add_to_favorites": "お気に入りに追加", "remove_from_favorites": "お気に入りから削除", - "publish_settings": "公開設定", + "publish_project": "プロジェクトを公開", "publish": "公開", "copy_link": "リンクをコピー", "leave_project": "プロジェクトを退出", @@ -501,6 +501,9 @@ "re_generate_key": "キーを再生成", "export": "エクスポート", "member": "{count, plural, other{# メンバー}}", + "new_password_must_be_different_from_old_password": "新しいパスワードは古いパスワードと異なる必要があります", + "edited": "編集済み", + "bot": "ボット", "project_view": { "sort_by": { @@ -591,7 +594,7 @@ "default": "まだ最近の項目がありません。" }, "filters": { - "all": "すべての項目", + "all": "すべて", "projects": "プロジェクト", "pages": "ページ", "issues": "作業項目" @@ -867,7 +870,8 @@ "deleting": "デリーティング", "pending": "保留中", "invite": "招待", - "view": "ビュー" + "view": "ビュー", + "deactivated_user": "無効化されたユーザー" }, "chart": { @@ -1733,20 +1737,99 @@ } }, "estimates": { + "label": "見積もり", "title": "プロジェクトの見積もりを有効にする", - "description": "チームの複雑さと作業負荷を伝えるのに役立ちます。" + "description": "チームの複雑さと作業負荷を伝えるのに役立ちます。", + "no_estimate": "見積もりなし", + "new": "新しい見積もりシステム", + "create": { + "custom": "カスタム", + "start_from_scratch": "最初から開始", + "choose_template": "テンプレートを選択", + "choose_estimate_system": "見積もりシステムを選択", + "enter_estimate_point": "見積もりを入力", + "step": "ステップ {step} の {total}", + "label": "見積もりを作成" + }, + "toasts": { + "created": { + "success": { + "title": "見積もりを作成", + "message": "見積もりが正常に作成されました" + }, + "error": { + "title": "見積もり作成に失敗", + "message": "新しい見積もりを作成できませんでした。もう一度お試しください。" + } + }, + "updated": { + "success": { + "title": "見積もりを更新", + "message": "プロジェクトの見積もりが更新されました。" + }, + "error": { + "title": "見積もり更新に失敗", + "message": "見積もりを更新できませんでした。もう一度お試しください" + } + }, + "enabled": { + "success": { + "title": "成功!", + "message": "見積もりが有効になりました。" + } + }, + "disabled": { + "success": { + "title": "成功!", + "message": "見積もりが無効になりました。" + }, + "error": { + "title": "エラー!", + "message": "見積もりを無効にできませんでした。もう一度お試しください" + } + } + }, + "validation": { + "min_length": "見積もりは0より大きい必要があります。", + "unable_to_process": "リクエストを処理できません。もう一度お試しください。", + "numeric": "見積もりは数値である必要があります。", + "character": "見積もりは文字値である必要があります。", + "empty": "見積もり値は空にできません。", + "already_exists": "見積もり値は既に存在します。", + "unsaved_changes": "未保存の変更があります。完了をクリックする前に保存してください", + "remove_empty": "見積もりは空にできません。各フィールドに値を入力するか、値がないフィールドを削除してください。" + }, + "systems": { + "points": { + "label": "ポイント", + "fibonacci": "フィボナッチ", + "linear": "リニア", + "squares": "二乗", + "custom": "カスタム" + }, + "categories": { + "label": "カテゴリー", + "t_shirt_sizes": "Tシャツサイズ", + "easy_to_hard": "簡単から難しい", + "custom": "カスタム" + }, + "time": { + "label": "時間", + "hours": "時間" + } + } }, "automations": { "label": "自動化", "auto-archive": { - "title": "完了した作業項目を自動アーカイブ", + "title": "完了した作業項目を自動的にアーカイブ", "description": "Planeは完了またはキャンセルされた作業項目を自動的にアーカイブします。", - "duration": "次の期間完了している作業項目を自動アーカイブ" + "duration": "閉じられた作業項目を自動的にアーカイブ" }, "auto-close": { - "title": "作業項目を自動クローズ", - "description": "Planeは完了またはキャンセルされていない作業項目を自動的にクローズします。", - "duration": "次の期間非アクティブな作業項目を自動クローズ", + "title": "作業項目を自動的に閉じる", + "description": "Planeは完了またはキャンセルされていない作業項目を自動的に閉じます。", + "duration": "非アクティブな作業項目を自動的に閉じる", "auto_close_status": "自動クローズステータス" } }, @@ -1774,6 +1857,12 @@ "remove_filters_to_see_all_cycles": "すべてのサイクルを表示するにはフィルターを解除してください", "remove_search_criteria_to_see_all_cycles": "すべてのサイクルを表示するには検索条件を解除してください", "only_completed_cycles_can_be_archived": "完了したサイクルのみアーカイブできます", + "start_date": "開始日", + "end_date": "終了日", + "in_your_timezone": "あなたのタイムゾーン", + "transfer_work_items": "作業項目を転送 {count}", + "date_range": "日付範囲", + "add_date": "日付を追加", "active_cycle": { "label": "アクティブなサイクル", "progress": "進捗", @@ -2365,5 +2454,11 @@ "module": { "label": "{count, plural, one {モジュール} other {モジュール}}", "no_module": "モジュールなし" + }, + + "description_versions": { + "last_edited_by": "最終編集者", + "previously_edited_by": "以前の編集者", + "edited_by": "編集者" } } diff --git a/packages/i18n/src/locales/ko/translations.json b/packages/i18n/src/locales/ko/translations.json index b3b0a29d4..f1b97d3ae 100644 --- a/packages/i18n/src/locales/ko/translations.json +++ b/packages/i18n/src/locales/ko/translations.json @@ -350,7 +350,7 @@ "couldnt_remove_the_project_from_favorites": "프로젝트를 즐겨찾기에서 제거하지 못했습니다. 다시 시도해주세요.", "add_to_favorites": "즐겨찾기에 추가", "remove_from_favorites": "즐겨찾기에서 제거", - "publish_settings": "설정 게시", + "publish_project": "프로젝트 게시", "publish": "게시", "copy_link": "링크 복사", "leave_project": "프로젝트 떠나기", @@ -501,6 +501,9 @@ "re_generate_key": "키 다시 생성", "export": "내보내기", "member": "{count, plural, one{# 멤버} other{# 멤버}}", + "new_password_must_be_different_from_old_password": "새 비밀번호는 이전 비밀번호와 다르게 설정해야 합니다", + "edited": "수정됨", + "bot": "봇", "project_view": { "sort_by": { @@ -591,7 +594,7 @@ "default": "아직 최근 항목이 없습니다." }, "filters": { - "all": "모든 항목", + "all": "모든", "projects": "프로젝트", "pages": "페이지", "issues": "작업 항목" @@ -868,7 +871,8 @@ "deleting": "삭제 중", "pending": "보류 중", "invite": "초대", - "view": "보기" + "view": "보기", + "deactivated_user": "비활성화된 사용자" }, "chart": { @@ -1735,20 +1739,99 @@ } }, "estimates": { - "title": "프로젝트에 대한 추정 활성화", - "description": "팀의 복잡성과 작업량을 전달하는 데 도움이 됩니다." + "label": "추정", + "title": "프로젝트 추정 활성화", + "description": "팀의 복잡성과 작업량을 전달하는 데 도움이 됩니다.", + "no_estimate": "추정 없음", + "new": "새 추정 시스템", + "create": { + "custom": "사용자 지정", + "start_from_scratch": "처음부터 시작", + "choose_template": "템플릿 선택", + "choose_estimate_system": "추정 시스템 선택", + "enter_estimate_point": "추정 입력", + "step": "단계 {step}/{total}", + "label": "추정 생성" + }, + "toasts": { + "created": { + "success": { + "title": "추정 생성됨", + "message": "추정이 성공적으로 생성되었습니다" + }, + "error": { + "title": "추정 생성 실패", + "message": "새 추정을 생성할 수 없습니다. 다시 시도해 주세요." + } + }, + "updated": { + "success": { + "title": "추정 수정됨", + "message": "프로젝트의 추정이 업데이트되었습니다." + }, + "error": { + "title": "추정 수정 실패", + "message": "추정을 수정할 수 없습니다. 다시 시도해 주세요" + } + }, + "enabled": { + "success": { + "title": "성공!", + "message": "추정이 활성화되었습니다." + } + }, + "disabled": { + "success": { + "title": "성공!", + "message": "추정이 비활성화되었습니다." + }, + "error": { + "title": "오류!", + "message": "추정을 비활성화할 수 없습니다. 다시 시도해 주세요" + } + } + }, + "validation": { + "min_length": "추정은 0보다 커야 합니다.", + "unable_to_process": "요청을 처리할 수 없습니다. 다시 시도해 주세요.", + "numeric": "추정은 숫자 값이어야 합니다.", + "character": "추정은 문자 값이어야 합니다.", + "empty": "추정 값은 비어있을 수 없습니다.", + "already_exists": "추정 값이 이미 존재합니다.", + "unsaved_changes": "저장되지 않은 변경 사항이 있습니다. 완료를 클릭하기 전에 저장하세요", + "remove_empty": "추정은 비어있을 수 없습니다. 각 필드에 값을 입력하거나 값이 없는 필드를 제거하세요." + }, + "systems": { + "points": { + "label": "포인트", + "fibonacci": "피보나치", + "linear": "선형", + "squares": "제곱", + "custom": "사용자 정의" + }, + "categories": { + "label": "카테고리", + "t_shirt_sizes": "티셔츠 사이즈", + "easy_to_hard": "쉬움에서 어려움", + "custom": "사용자 정의" + }, + "time": { + "label": "시간", + "hours": "시간" + } + } }, "automations": { "label": "자동화", "auto-archive": { - "title": "닫힌 작업 항목 자동 아카이브", - "description": "Plane은 완료되거나 취소된 작업 항목을 자동으로 아카이브합니다.", - "duration": "닫힌 작업 항목을 자동 아카이브" + "title": "완료된 작업 항목 자동 보관", + "description": "Plane은 완료되거나 취소된 작업 항목을 자동으로 보관합니다.", + "duration": "다음 기간 동안 닫힌 작업 항목 자동 보관" }, "auto-close": { "title": "작업 항목 자동 닫기", "description": "Plane은 완료되거나 취소되지 않은 작업 항목을 자동으로 닫습니다.", - "duration": "비활성 상태인 작업 항목 자동 닫기", + "duration": "다음 기간 동안 비활성 작업 항목 자동 닫기", "auto_close_status": "자동 닫기 상태" } }, @@ -1776,6 +1859,12 @@ "remove_filters_to_see_all_cycles": "모든 주기를 보려면 필터를 제거하세요", "remove_search_criteria_to_see_all_cycles": "모든 주기를 보려면 검색 기준을 제거하세요", "only_completed_cycles_can_be_archived": "완료된 주기만 아카이브할 수 있습니다", + "start_date": "시작일", + "end_date": "종료일", + "in_your_timezone": "내 시간대", + "transfer_work_items": "{count}개의 작업 항목 이전", + "date_range": "날짜 범위", + "add_date": "날짜 추가", "active_cycle": { "label": "활성 주기", "progress": "진행", @@ -2367,5 +2456,11 @@ "module": { "label": "{count, plural, one {모듈} other {모듈}}", "no_module": "모듈 없음" + }, + + "description_versions": { + "last_edited_by": "마지막 편집자", + "previously_edited_by": "이전 편집자", + "edited_by": "편집자" } } diff --git a/packages/i18n/src/locales/pl/translations.json b/packages/i18n/src/locales/pl/translations.json index 3cee65853..5e004ee63 100644 --- a/packages/i18n/src/locales/pl/translations.json +++ b/packages/i18n/src/locales/pl/translations.json @@ -348,7 +348,7 @@ "couldnt_remove_the_project_from_favorites": "Nie udało się usunąć projektu z ulubionych. Spróbuj ponownie.", "add_to_favorites": "Dodaj do ulubionych", "remove_from_favorites": "Usuń z ulubionych", - "publish_settings": "Ustawienia publikowania", + "publish_project": "Opublikuj projekt", "publish": "Opublikuj", "copy_link": "Kopiuj link", "leave_project": "Opuść projekt", @@ -499,6 +499,11 @@ "re_generate_key": "Wygeneruj klucz ponownie", "export": "Eksportuj", "member": "{count, plural, one{# członek} few{# członkowie} other{# członków}}", + "new_password_must_be_different_from_old_password": "Nowe hasło musi być innym niż stare hasło", + + "edited": "Edytowano", + "bot": "Bot", + "project_view": { "sort_by": { "created_at": "Utworzono dnia", @@ -585,7 +590,7 @@ "default": "Nie masz jeszcze żadnych ostatnich pozycji." }, "filters": { - "all": "Wszystkie pozycje", + "all": "Wszystkie", "projects": "Projekty", "pages": "Strony", "issues": "Elementy pracy" @@ -860,7 +865,8 @@ "deleting": "Usuwanie", "pending": "Oczekujące", "invite": "Zaproś", - "view": "Widok" + "view": "Widok", + "deactivated_user": "Dezaktywowany użytkownik" }, "chart": { "x_axis": "Oś X", @@ -1708,21 +1714,100 @@ } }, "estimates": { - "title": "Włącz szacowania w projekcie", - "description": "Pomaga komunikować złożoność i obciążenie zespołu." + "label": "Szacunki", + "title": "Włącz szacunki dla mojego projektu", + "description": "Pomagają w komunikacji o złożoności i obciążeniu zespołu.", + "no_estimate": "Bez szacunku", + "new": "Nowy system szacowania", + "create": { + "custom": "Niestandardowy", + "start_from_scratch": "Zacznij od zera", + "choose_template": "Wybierz szablon", + "choose_estimate_system": "Wybierz system szacowania", + "enter_estimate_point": "Wprowadź punkt szacunkowy", + "step": "Krok {step} z {total}", + "label": "Utwórz szacunek" + }, + "toasts": { + "created": { + "success": { + "title": "Utworzono szacunek", + "message": "Szacunek został utworzony pomyślnie" + }, + "error": { + "title": "Błąd tworzenia szacunku", + "message": "Nie udało się utworzyć nowego szacunku, spróbuj ponownie." + } + }, + "updated": { + "success": { + "title": "Zaktualizowano szacunek", + "message": "Szacunek został zaktualizowany w Twoim projekcie." + }, + "error": { + "title": "Błąd aktualizacji szacunku", + "message": "Nie udało się zaktualizować szacunku, spróbuj ponownie" + } + }, + "enabled": { + "success": { + "title": "Sukces!", + "message": "Szacunki zostały włączone." + } + }, + "disabled": { + "success": { + "title": "Sukces!", + "message": "Szacunki zostały wyłączone." + }, + "error": { + "title": "Błąd!", + "message": "Nie udało się wyłączyć szacunków. Spróbuj ponownie" + } + } + }, + "validation": { + "min_length": "Punkt szacunkowy musi być większy niż 0.", + "unable_to_process": "Nie możemy przetworzyć Twojego żądania, spróbuj ponownie.", + "numeric": "Punkt szacunkowy musi być wartością liczbową.", + "character": "Punkt szacunkowy musi być znakiem.", + "empty": "Wartość szacunku nie może być pusta.", + "already_exists": "Wartość szacunku już istnieje.", + "unsaved_changes": "Masz niezapisane zmiany. Zapisz je przed kliknięciem 'gotowe'", + "remove_empty": "Szacunek nie może być pusty. Wprowadź wartość w każde pole lub usuń te, dla których nie masz wartości." + }, + "systems": { + "points": { + "label": "Punkty", + "fibonacci": "Fibonacci", + "linear": "Liniowy", + "squares": "Kwadraty", + "custom": "Własny" + }, + "categories": { + "label": "Kategorie", + "t_shirt_sizes": "Rozmiary koszulek", + "easy_to_hard": "Od łatwego do trudnego", + "custom": "Własne" + }, + "time": { + "label": "Czas", + "hours": "Godziny" + } + } }, "automations": { - "label": "Automatyzacje", + "label": "Automatyzacja", "auto-archive": { - "title": "Automatycznie archiwizuj zamknięte elementy", - "description": "Plane będzie archiwizował ukończone lub anulowane elementy.", + "title": "Automatyczna archiwizacja zamkniętych elementów", + "description": "Plane będzie automatycznie archiwizował elementy, które zostały ukończone lub anulowane.", "duration": "Archiwizuj elementy zamknięte dłużej niż" }, "auto-close": { - "title": "Automatycznie zamykaj elementy", - "description": "Plane będzie zamykać nieaktywne elementy.", + "title": "Automatyczne zamykanie elementów", + "description": "Plane będzie automatycznie zamykał elementy, które nie zostały ukończone lub anulowane.", "duration": "Zamknij elementy nieaktywne dłużej niż", - "auto_close_status": "Stan do automatycznego zamknięcia" + "auto_close_status": "Status automatycznego zamknięcia" } }, "empty_state": { @@ -1747,6 +1832,12 @@ "remove_filters_to_see_all_cycles": "Usuń filtry, aby wyświetlić wszystkie cykle", "remove_search_criteria_to_see_all_cycles": "Usuń kryteria wyszukiwania, aby wyświetlić wszystkie cykle", "only_completed_cycles_can_be_archived": "Można archiwizować tylko ukończone cykle", + "start_date": "Data początku", + "end_date": "Data końca", + "in_your_timezone": "W Twojej strefie czasowej", + "transfer_work_items": "Przenieś {count} elementów pracy", + "date_range": "Zakres dat", + "add_date": "Dodaj datę", "active_cycle": { "label": "Aktywny cykl", "progress": "Postęp", @@ -2313,12 +2404,20 @@ "manual": "Ręcznie" } }, + "cycle": { "label": "{count, plural, one {Cykl} few {Cykle} other {Cyklów}}", "no_cycle": "Brak cyklu" }, + "module": { "label": "{count, plural, one {Moduł} few {Moduły} other {Modułów}}", "no_module": "Brak modułu" + }, + + "description_versions": { + "last_edited_by": "Ostatnio edytowane przez", + "previously_edited_by": "Wcześniej edytowane przez", + "edited_by": "Edytowane przez" } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/pt-BR/translations.json b/packages/i18n/src/locales/pt-BR/translations.json new file mode 100644 index 000000000..3f9c53980 --- /dev/null +++ b/packages/i18n/src/locales/pt-BR/translations.json @@ -0,0 +1,2459 @@ +{ + "sidebar": { + "projects": "Projetos", + "pages": "Páginas", + "new_work_item": "Novo item", + "home": "Home", + "your_work": "Seu trabalho", + "inbox": "Inbox", + "workspace": "Workspace", + "views": "Visualizações", + "analytics": "Analytics", + "work_items": "Itens", + "cycles": "Ciclos", + "modules": "Módulos", + "intake": "Intake", + "drafts": "Rascunhos", + "favorites": "Favoritos", + "pro": "Pro", + "upgrade": "Upgrade" + }, + + "auth": { + "common": { + "email": { + "label": "Email", + "placeholder": "nome@empresa.com", + "errors": { + "required": "Email é obrigatório", + "invalid": "Email inválido" + } + }, + "password": { + "label": "Senha", + "set_password": "Definir senha", + "placeholder": "Digite a senha", + "confirm_password": { + "label": "Confirmar senha", + "placeholder": "Confirmar senha" + }, + "current_password": { + "label": "Senha atual" + }, + "new_password": { + "label": "Nova senha", + "placeholder": "Digite a nova senha" + }, + "change_password": { + "label": { + "default": "Alterar senha", + "submitting": "Alterando senha" + } + }, + "errors": { + "match": "As senhas não coincidem", + "empty": "Por favor digite sua senha", + "length": "A senha deve ter mais de 8 caracteres", + "strength": { + "weak": "Senha fraca", + "strong": "Senha forte" + } + }, + "submit": "Definir senha", + "toast": { + "change_password": { + "success": { + "title": "Sucesso!", + "message": "Senha alterada com sucesso." + }, + "error": { + "title": "Erro!", + "message": "Algo deu errado. Por favor, tente novamente." + } + } + } + }, + "unique_code": { + "label": "Código único", + "placeholder": "gets-sets-flys", + "paste_code": "Cole o código enviado para seu email", + "requesting_new_code": "Solicitando novo código", + "sending_code": "Enviando código" + }, + "already_have_an_account": "Já tem uma conta?", + "login": "Login", + "create_account": "Criar conta", + "new_to_plane": "Novo no Plane?", + "back_to_sign_in": "Voltar ao login", + "resend_in": "Reenviar em {seconds} segundos", + "sign_in_with_unique_code": "Login com código único", + "forgot_password": "Esqueceu sua senha?" + }, + "sign_up": { + "header": { + "label": "Crie uma conta para começar a gerenciar trabalho com sua equipe.", + "step": { + "email": { + "header": "Cadastro", + "sub_header": "" + }, + "password": { + "header": "Cadastro", + "sub_header": "Cadastre-se usando email e senha." + }, + "unique_code": { + "header": "Cadastro", + "sub_header": "Cadastre-se usando um código único enviado para o email acima." + } + } + }, + "errors": { + "password": { + "strength": "Tente definir uma senha forte para continuar" + } + } + }, + "sign_in": { + "header": { + "label": "Faça login para começar a gerenciar trabalho com sua equipe.", + "step": { + "email": { + "header": "Login ou cadastro", + "sub_header": "" + }, + "password": { + "header": "Login ou cadastro", + "sub_header": "Use seu email e senha para fazer login." + }, + "unique_code": { + "header": "Login ou cadastro", + "sub_header": "Faça login usando um código único enviado para o email acima." + } + } + } + }, + "forgot_password": { + "title": "Redefinir sua senha", + "description": "Digite o email verificado da sua conta e enviaremos um link para redefinir sua senha.", + "email_sent": "Enviamos o link de redefinição para seu email", + "send_reset_link": "Enviar link de redefinição", + "errors": { + "smtp_not_enabled": "Vemos que seu administrador não habilitou SMTP, não poderemos enviar um link de redefinição de senha" + }, + "toast": { + "success": { + "title": "Email enviado", + "message": "Verifique sua caixa de entrada para um link de redefinição de senha. Se não aparecer em alguns minutos, verifique sua pasta de spam." + }, + "error": { + "title": "Erro!", + "message": "Algo deu errado. Por favor, tente novamente." + } + } + }, + "reset_password": { + "title": "Definir nova senha", + "description": "Proteja sua conta com uma senha forte" + }, + "set_password": { + "title": "Proteja sua conta", + "description": "Definir uma senha ajuda você a fazer login com segurança" + }, + "sign_out": { + "toast": { + "error": { + "title": "Erro!", + "message": "Falha ao sair. Por favor, tente novamente." + } + } + } + }, + + "submit": "Enviar", + "cancel": "Cancelar", + "loading": "Carregando", + "error": "Erro", + "success": "Sucesso", + "warning": "Aviso", + "info": "Informação", + "close": "Fechar", + "yes": "Sim", + "no": "Não", + "ok": "OK", + "name": "Nome", + "description": "Descrição", + "search": "Pesquisar", + "add_member": "Adicionar membro", + "adding_members": "Adicionando membros", + "remove_member": "Remover membro", + "add_members": "Adicionar membros", + "adding_member": "Adicionando membro", + "remove_members": "Remover membros", + "add": "Adicionar", + "adding": "Adicionando", + "remove": "Remover", + "add_new": "Adicionar novo", + "remove_selected": "Remover selecionado", + "first_name": "Primeiro nome", + "last_name": "Sobrenome", + "email": "E-mail", + "display_name": "Nome de exibição", + "role": "Cargo", + "timezone": "Fuso horário", + "avatar": "Avatar", + "cover_image": "Imagem de capa", + "password": "Senha", + "change_cover": "Alterar capa", + "language": "Idioma", + "saving": "Salvando", + "save_changes": "Salvar alterações", + "deactivate_account": "Desativar conta", + "deactivate_account_description": "Ao desativar uma conta, todos os dados e recursos dessa conta serão removidos permanentemente e não poderão ser recuperados.", + "profile_settings": "Configurações de perfil", + "your_account": "Sua conta", + "security": "Segurança", + "activity": "Atividade", + "appearance": "Aparência", + "notifications": "Notificações", + "workspaces": "Espaços de trabalho", + "create_workspace": "Criar espaço de trabalho", + "invitations": "Convites", + "summary": "Resumo", + "assigned": "Atribuído", + "created": "Criado", + "subscribed": "Inscrito", + "you_do_not_have_the_permission_to_access_this_page": "Você não tem permissão para acessar esta página.", + "something_went_wrong_please_try_again": "Algo deu errado. Por favor, tente novamente.", + "load_more": "Carregar mais", + "select_or_customize_your_interface_color_scheme": "Selecione ou personalize o esquema de cores da sua interface.", + "theme": "Tema", + "system_preference": "Preferência do sistema", + "light": "Claro", + "dark": "Escuro", + "light_contrast": "Alto contraste claro", + "dark_contrast": "Alto contraste escuro", + "custom": "Personalizado", + "select_your_theme": "Selecione seu tema", + "customize_your_theme": "Personalize seu tema", + "background_color": "Cor de fundo", + "text_color": "Cor do texto", + "primary_color": "Cor primária (Tema)", + "sidebar_background_color": "Cor de fundo da barra lateral", + "sidebar_text_color": "Cor do texto da barra lateral", + "set_theme": "Definir tema", + "enter_a_valid_hex_code_of_6_characters": "Insira um código hexadecimal válido de 6 caracteres", + "background_color_is_required": "A cor de fundo é obrigatória", + "text_color_is_required": "A cor do texto é obrigatória", + "primary_color_is_required": "A cor primária é obrigatória", + "sidebar_background_color_is_required": "A cor de fundo da barra lateral é obrigatória", + "sidebar_text_color_is_required": "A cor do texto da barra lateral é obrigatória", + "updating_theme": "Atualizando tema", + "theme_updated_successfully": "Tema atualizado com sucesso", + "failed_to_update_the_theme": "Falha ao atualizar o tema", + "email_notifications": "Notificações por e-mail", + "stay_in_the_loop_on_issues_you_are_subscribed_to_enable_this_to_get_notified": "Mantenha-se informado sobre os itens de trabalho aos quais você está inscrito. Ative isso para ser notificado.", + "email_notification_setting_updated_successfully": "Configuração de notificação por e-mail atualizada com sucesso", + "failed_to_update_email_notification_setting": "Falha ao atualizar a configuração de notificação por e-mail", + "notify_me_when": "Notifique-me quando", + "property_changes": "Alterações de propriedade", + "property_changes_description": "Notifique-me quando as propriedades dos itens de trabalho, como responsáveis, prioridade, estimativas ou qualquer outra coisa, mudarem.", + "state_change": "Mudança de estado", + "state_change_description": "Notifique-me quando os itens de trabalho mudarem para um estado diferente", + "issue_completed": "Item de trabalho concluído", + "issue_completed_description": "Notifique-me apenas quando um item de trabalho for concluído", + "comments": "Comentários", + "comments_description": "Notifique-me quando alguém deixar um comentário no item de trabalho", + "mentions": "Menções", + "mentions_description": "Notifique-me apenas quando alguém me mencionar nos comentários ou na descrição", + "old_password": "Senha antiga", + "general_settings": "Configurações gerais", + "sign_out": "Sair", + "signing_out": "Saindo", + "active_cycles": "Ciclos ativos", + "active_cycles_description": "Monitore os ciclos entre os projetos, rastreie os itens de trabalho de alta prioridade e amplie os ciclos que precisam de atenção.", + "on_demand_snapshots_of_all_your_cycles": "Snapshots sob demanda de todos os seus ciclos", + "upgrade": "Upgrade", + "10000_feet_view": "Visão geral de todos os ciclos ativos.", + "10000_feet_view_description": "Reduza o zoom para ver os ciclos em execução em todos os seus projetos de uma só vez, em vez de ir de ciclo para ciclo em cada projeto.", + "get_snapshot_of_each_active_cycle": "Obtenha um snapshot de cada ciclo ativo.", + "get_snapshot_of_each_active_cycle_description": "Rastreie as métricas de alto nível para todos os ciclos ativos, veja seu estado de progresso e tenha uma noção do escopo em relação aos prazos.", + "compare_burndowns": "Compare burndowns.", + "compare_burndowns_description": "Monitore o desempenho de cada uma de suas equipes com uma olhada no relatório de burndown de cada ciclo.", + "quickly_see_make_or_break_issues": "Veja rapidamente os itens de trabalho decisivos.", + "quickly_see_make_or_break_issues_description": "Visualize os itens de trabalho de alta prioridade para cada ciclo em relação aos prazos. Veja todos eles por ciclo com um clique.", + "zoom_into_cycles_that_need_attention": "Amplie os ciclos que precisam de atenção.", + "zoom_into_cycles_that_need_attention_description": "Investigue o estado de qualquer ciclo que não esteja em conformidade com as expectativas com um clique.", + "stay_ahead_of_blockers": "Fique à frente dos bloqueios.", + "stay_ahead_of_blockers_description": "Identifique desafios de um projeto para outro e veja as dependências entre ciclos que não são óbvias em nenhuma outra visualização.", + "analytics": "Análises", + "workspace_invites": "Convites para o espaço de trabalho", + "enter_god_mode": "Entrar no God Mode", + "workspace_logo": "Logo do espaço de trabalho", + "new_issue": "Novo item de trabalho", + "your_work": "Seu trabalho", + "drafts": "Rascunhos", + "projects": "Projetos", + "views": "Visualizações", + "workspace": "Espaço de trabalho", + "archives": "Arquivos", + "settings": "Configurações", + "failed_to_move_favorite": "Falha ao mover o favorito", + "favorites": "Favoritos", + "no_favorites_yet": "Nenhum favorito ainda", + "create_folder": "Criar pasta", + "new_folder": "Nova pasta", + "favorite_updated_successfully": "Favorito atualizado com sucesso", + "favorite_created_successfully": "Favorito criado com sucesso", + "folder_already_exists": "A pasta já existe", + "folder_name_cannot_be_empty": "O nome da pasta não pode estar vazio", + "something_went_wrong": "Algo deu errado", + "failed_to_reorder_favorite": "Falha ao reordenar o favorito", + "favorite_removed_successfully": "Favorito removido com sucesso", + "failed_to_create_favorite": "Falha ao criar favorito", + "failed_to_rename_favorite": "Falha ao renomear favorito", + "project_link_copied_to_clipboard": "Link do projeto copiado para a área de transferência", + "link_copied": "Link copiado", + "add_project": "Adicionar projeto", + "create_project": "Criar projeto", + "failed_to_remove_project_from_favorites": "Não foi possível remover o projeto dos favoritos. Por favor, tente novamente.", + "project_created_successfully": "Projeto criado com sucesso", + "project_created_successfully_description": "Projeto criado com sucesso. Agora você pode começar a adicionar itens de trabalho a ele.", + "project_cover_image_alt": "Imagem de capa do projeto", + "name_is_required": "Nome é obrigatório", + "title_should_be_less_than_255_characters": "O título deve ter menos de 255 caracteres", + "project_name": "Nome do projeto", + "project_id_must_be_at_least_1_character": "O ID do projeto deve ter pelo menos 1 caractere", + "project_id_must_be_at_most_5_characters": "O ID do projeto deve ter no máximo 5 caracteres", + "project_id": "ID do projeto", + "project_id_tooltip_content": "Ajuda você a identificar itens de trabalho no projeto de forma exclusiva. Máximo de 5 caracteres.", + "description_placeholder": "Descrição", + "only_alphanumeric_non_latin_characters_allowed": "Apenas caracteres alfanuméricos e não latinos são permitidos.", + "project_id_is_required": "O ID do projeto é obrigatório", + "project_id_allowed_char": "Apenas caracteres alfanuméricos e não latinos são permitidos.", + "project_id_min_char": "O ID do projeto deve ter pelo menos 1 caractere", + "project_id_max_char": "O ID do projeto deve ter no máximo 5 caracteres", + "project_description_placeholder": "Insira a descrição do projeto", + "select_network": "Selecione a rede", + "lead": "Líder", + "date_range": "Intervalo de datas", + "private": "Privado", + "public": "Público", + "accessible_only_by_invite": "Acessível apenas por convite", + "anyone_in_the_workspace_except_guests_can_join": "Qualquer pessoa no espaço de trabalho, exceto convidados, pode participar", + "creating": "Criando", + "creating_project": "Criando projeto", + "adding_project_to_favorites": "Adicionando projeto aos favoritos", + "project_added_to_favorites": "Projeto adicionado aos favoritos", + "couldnt_add_the_project_to_favorites": "Não foi possível adicionar o projeto aos favoritos. Por favor, tente novamente.", + "removing_project_from_favorites": "Removendo projeto dos favoritos", + "project_removed_from_favorites": "Projeto removido dos favoritos", + "couldnt_remove_the_project_from_favorites": "Não foi possível remover o projeto dos favoritos. Por favor, tente novamente.", + "add_to_favorites": "Adicionar aos favoritos", + "remove_from_favorites": "Remover dos favoritos", + "publish_project": "Publicar projeto", + "publish": "Publicar", + "copy_link": "Copiar link", + "leave_project": "Sair do projeto", + "join_the_project_to_rearrange": "Participe do projeto para reorganizar", + "drag_to_rearrange": "Arraste para reorganizar", + "congrats": "Parabéns!", + "open_project": "Abrir projeto", + "issues": "Itens de trabalho", + "cycles": "Ciclos", + "modules": "Módulos", + "pages": "Páginas", + "intake": "Admissão", + "time_tracking": "Rastreamento de tempo", + "work_management": "Gerenciamento de trabalho", + "projects_and_issues": "Projetos e itens de trabalho", + "projects_and_issues_description": "Ative ou desative estes neste projeto.", + "cycles_description": "Defina o tempo de trabalho como achar melhor por projeto e altere a frequência de um período para o próximo.", + "modules_description": "Agrupe o trabalho em configurações semelhantes a subprojetos com seus próprios líderes e responsáveis.", + "views_description": "Salve classificações, filtros e opções de exibição para mais tarde ou compartilhe-os.", + "pages_description": "Escreva qualquer coisa como você escreveria normalmente.", + "intake_description": "Mantenha-se informado sobre os itens de trabalho aos quais você está inscrito. Ative isso para ser notificado.", + "time_tracking_description": "Rastreie o tempo gasto em itens de trabalho e projetos.", + "work_management_description": "Gerencie seu trabalho e projetos com facilidade.", + "documentation": "Documentação", + "message_support": "Suporte por mensagem", + "contact_sales": "Contatar vendas", + "hyper_mode": "Modo Hyper", + "keyboard_shortcuts": "Atalhos do teclado", + "whats_new": "O que há de novo?", + "version": "Versão", + "we_are_having_trouble_fetching_the_updates": "Estamos tendo problemas para buscar as atualizações.", + "our_changelogs": "nossos changelogs", + "for_the_latest_updates": "para as últimas atualizações.", + "please_visit": "Por favor, visite", + "docs": "Documentos", + "full_changelog": "Changelog completo", + "support": "Suporte", + "discord": "Discord", + "powered_by_plane_pages": "Desenvolvido por Plane Pages", + "please_select_at_least_one_invitation": "Selecione pelo menos um convite.", + "please_select_at_least_one_invitation_description": "Selecione pelo menos um convite para entrar no espaço de trabalho.", + "we_see_that_someone_has_invited_you_to_join_a_workspace": "Vemos que alguém convidou você para entrar em um espaço de trabalho", + "join_a_workspace": "Entrar em um espaço de trabalho", + "we_see_that_someone_has_invited_you_to_join_a_workspace_description": "Vemos que alguém convidou você para entrar em um espaço de trabalho", + "join_a_workspace_description": "Entrar em um espaço de trabalho", + "accept_and_join": "Aceitar e entrar", + "go_home": "Ir para a página inicial", + "no_pending_invites": "Nenhum convite pendente", + "you_can_see_here_if_someone_invites_you_to_a_workspace": "Você pode ver aqui se alguém convida você para um espaço de trabalho", + "back_to_home": "Voltar para a página inicial", + "workspace_name": "nome-do-espaço-de-trabalho", + "deactivate_your_account": "Desativar sua conta", + "deactivate_your_account_description": "Uma vez desativada, você não poderá ser atribuído a itens de trabalho e ser cobrado pelo seu espaço de trabalho. Para reativar sua conta, você precisará de um convite para um espaço de trabalho neste endereço de e-mail.", + "deactivating": "Desativando", + "confirm": "Confirmar", + "confirming": "Confirmando", + "draft_created": "Rascunho criado", + "issue_created_successfully": "Item de trabalho criado com sucesso", + "draft_creation_failed": "Falha na criação do rascunho", + "issue_creation_failed": "Falha na criação do item de trabalho", + "draft_issue": "Rascunhar item de trabalho", + "issue_updated_successfully": "Item de trabalho atualizado com sucesso", + "issue_could_not_be_updated": "Não foi possível atualizar o item de trabalho", + "create_a_draft": "Criar um rascunho", + "save_to_drafts": "Salvar em rascunhos", + "save": "Salvar", + "update": "Atualizar", + "updating": "Atualizando", + "create_new_issue": "Criar novo item de trabalho", + "editor_is_not_ready_to_discard_changes": "O editor não está pronto para descartar as alterações", + "failed_to_move_issue_to_project": "Falha ao mover o item de trabalho para o projeto", + "create_more": "Criar mais", + "add_to_project": "Adicionar ao projeto", + "discard": "Descartar", + "duplicate_issue_found": "Item de trabalho duplicado encontrado", + "duplicate_issues_found": "Itens de trabalho duplicados encontrados", + "no_matching_results": "Nenhum resultado correspondente", + "title_is_required": "O título é obrigatório", + "title": "Título", + "state": "Estado", + "priority": "Prioridade", + "none": "Nenhum", + "urgent": "Urgente", + "high": "Alta", + "medium": "Média", + "low": "Baixa", + "members": "Membros", + "assignee": "Responsável", + "assignees": "Responsáveis", + "you": "Você", + "labels": "Etiquetas", + "create_new_label": "Criar nova etiqueta", + "start_date": "Data de início", + "end_date": "Data de término", + "due_date": "Data de vencimento", + "estimate": "Estimativa", + "change_parent_issue": "Alterar item de trabalho pai", + "remove_parent_issue": "Remover item de trabalho pai", + "add_parent": "Adicionar pai", + "loading_members": "Carregando membros", + "view_link_copied_to_clipboard": "Link de visualização copiado para a área de transferência.", + "required": "Obrigatório", + "optional": "Opcional", + "Cancel": "Cancelar", + "edit": "Editar", + "archive": "Arquivar", + "restore": "Restaurar", + "open_in_new_tab": "Abrir em nova aba", + "delete": "Excluir", + "deleting": "Excluindo", + "make_a_copy": "Fazer uma cópia", + "move_to_project": "Mover para o projeto", + "good": "Bom", + "morning": "manhã", + "afternoon": "tarde", + "evening": "noite", + "show_all": "Mostrar tudo", + "show_less": "Mostrar menos", + "no_data_yet": "Nenhum dado ainda", + "syncing": "Sincronizando", + "add_work_item": "Adicionar item de trabalho", + "advanced_description_placeholder": "Pressione '/' para comandos", + "create_work_item": "Criar item de trabalho", + "attachments": "Anexos", + "declining": "Recusando", + "declined": "Recusado", + "decline": "Recusar", + "unassigned": "Não atribuído", + "work_items": "Itens de trabalho", + "add_link": "Adicionar link", + "points": "Pontos", + "no_assignee": "Sem responsável", + "no_assignees_yet": "Nenhum responsável ainda", + "no_labels_yet": "Nenhuma etiqueta ainda", + "ideal": "Ideal", + "current": "Atual", + "no_matching_members": "Nenhum membro correspondente", + "leaving": "Saindo", + "removing": "Removendo", + "leave": "Sair", + "refresh": "Atualizar", + "refreshing": "Atualizando", + "refresh_status": "Status da atualização", + "prev": "Anterior", + "next": "Próximo", + "re_generating": "Regerando", + "re_generate": "Regerar", + "re_generate_key": "Regerar chave", + "export": "Exportar", + "member": "{count, plural, one{# membro} other{# membros}}", + "new_password_must_be_different_from_old_password": "Nova senha deve ser diferente da senha antiga", + "edited": "editado", + "bot": "robô", + + "project_view": { + "sort_by": { + "created_at": "Criado em", + "updated_at": "Atualizado em", + "name": "Nome" + } + }, + + "toast": { + "success": "Sucesso!", + "error": "Erro!" + }, + + "links": { + "toasts": { + "created": { + "title": "Link criado", + "message": "O link foi criado com sucesso" + }, + "not_created": { + "title": "Link não criado", + "message": "O link não pôde ser criado" + }, + "updated": { + "title": "Link atualizado", + "message": "O link foi atualizado com sucesso" + }, + "not_updated": { + "title": "Link não atualizado", + "message": "O link não pôde ser atualizado" + }, + "removed": { + "title": "Link removido", + "message": "O link foi removido com sucesso" + }, + "not_removed": { + "title": "Link não removido", + "message": "O link não pôde ser removido" + } + } + }, + + "home": { + "empty": { + "quickstart_guide": "Seu guia de início rápido", + "not_right_now": "Agora não", + "create_project": { + "title": "Criar um projeto", + "description": "A maioria das coisas começa com um projeto no Plane.", + "cta": "Começar" + }, + "invite_team": { + "title": "Convide sua equipe", + "description": "Construa, entregue e gerencie com colegas de trabalho.", + "cta": "Convidar" + }, + "configure_workspace": { + "title": "Configure seu espaço de trabalho.", + "description": "Ative ou desative recursos ou vá além disso.", + "cta": "Configurar este espaço de trabalho" + }, + "personalize_account": { + "title": "Personalize o Plane.", + "description": "Escolha sua foto, cores e muito mais.", + "cta": "Personalizar agora" + }, + "widgets": { + "title": "Está quieto sem widgets, ative-os", + "description": "Parece que todos os seus widgets estão desativados. Ative-os\nagora para melhorar sua experiência!", + "primary_button": { + "text": "Gerenciar widgets" + } + } + }, + "quick_links": { + "empty": "Salve links para os itens de trabalho que você gostaria de ter à mão.", + "add": "Adicionar link rápido", + "title": "Link rápido", + "title_plural": "Links rápidos" + }, + "recents": { + "title": "Recentes", + "empty": { + "project": "Seus projetos recentes aparecerão aqui quando você visitar um.", + "page": "Suas páginas recentes aparecerão aqui quando você visitar uma.", + "issue": "Seus itens de trabalho recentes aparecerão aqui quando você visitar um.", + "default": "Você não tem nenhum item recente ainda." + }, + "filters": { + "all": "Todos", + "projects": "Projetos", + "pages": "Páginas", + "issues": "Itens de trabalho" + } + }, + "new_at_plane": { + "title": "Novidades no Plane" + }, + "quick_tutorial": { + "title": "Tutorial rápido" + }, + "widget": { + "reordered_successfully": "Widget reordenado com sucesso.", + "reordering_failed": "Ocorreu um erro ao reordenar o widget." + }, + "manage_widgets": "Gerenciar widgets", + "title": "Página inicial", + "star_us_on_github": "Nos dê uma estrela no GitHub" + }, + + "link": { + "modal": { + "url": { + "text": "URL", + "required": "URL inválido", + "placeholder": "Digite ou cole um URL" + }, + "title": { + "text": "Título de exibição", + "placeholder": "Como você gostaria de ver este link" + } + } + }, + + "common": { + "all": "Todos", + "states": "Estados", + "state": "Estado", + "state_groups": "Grupos de estado", + "state_group": "Grupo de estado", + "priorities": "Prioridades", + "priority": "Prioridade", + "team_project": "Projeto de equipe", + "project": "Projeto", + "cycle": "Ciclo", + "cycles": "Ciclos", + "module": "Módulo", + "modules": "Módulos", + "labels": "Etiquetas", + "label": "Etiqueta", + "assignees": "Responsáveis", + "assignee": "Responsável", + "created_by": "Criado por", + "none": "Nenhum", + "link": "Link", + "estimates": "Estimativas", + "estimate": "Estimativa", + "created_at": "Criado em", + "completed_at": "Concluído em", + "layout": "Layout", + "filters": "Filtros", + "display": "Exibir", + "load_more": "Carregar mais", + "activity": "Atividade", + "analytics": "Análises", + "dates": "Datas", + "success": "Sucesso!", + "something_went_wrong": "Algo deu errado", + "error": { + "label": "Erro!", + "message": "Ocorreu algum erro. Por favor, tente novamente." + }, + "group_by": "Agrupar por", + "epic": "Épico", + "epics": "Épicos", + "work_item": "Item de trabalho", + "work_items": "Itens de trabalho", + "sub_work_item": "Sub-item de trabalho", + "add": "Adicionar", + "warning": "Aviso", + "updating": "Atualizando", + "adding": "Adicionando", + "update": "Atualizar", + "creating": "Criando", + "create": "Criar", + "cancel": "Cancelar", + "description": "Descrição", + "title": "Título", + "attachment": "Anexo", + "general": "Geral", + "features": "Funcionalidades", + "automation": "Automação", + "project_name": "Nome do projeto", + "project_id": "ID do projeto", + "project_timezone": "Fuso horário do projeto", + "created_on": "Criado em", + "update_project": "Atualizar projeto", + "identifier_already_exists": "O identificador já existe", + "add_more": "Adicionar mais", + "defaults": "Padrões", + "add_label": "Adicionar etiqueta", + "customize_time_range": "Personalizar intervalo de tempo", + "loading": "Carregando", + "attachments": "Anexos", + "property": "Propriedade", + "properties": "Propriedades", + "parent": "Pai", + "page": "Página", + "remove": "Remover", + "archiving": "Arquivando", + "archive": "Arquivar", + "access": { + "public": "Público", + "private": "Privado" + }, + "done": "Concluído", + "sub_work_items": "Sub-itens de trabalho", + "comment": "Comentário", + "workspace_level": "Nível do espaço de trabalho", + "order_by": { + "label": "Ordenar por", + "manual": "Manual", + "last_created": "Último criado", + "last_updated": "Último atualizado", + "start_date": "Data de início", + "due_date": "Data de vencimento", + "asc": "Ascendente", + "desc": "Descendente", + "updated_on": "Atualizado em" + }, + "sort": { + "asc": "Ascendente", + "desc": "Descendente", + "created_on": "Criado em", + "updated_on": "Atualizado em" + }, + "comments": "Comentários", + "updates": "Atualizações", + "clear_all": "Limpar tudo", + "copied": "Copiado!", + "link_copied": "Link copiado!", + "link_copied_to_clipboard": "Link copiado para a área de transferência", + "copied_to_clipboard": "Link do item de trabalho copiado para a área de transferência", + "is_copied_to_clipboard": "O link do item de trabalho foi copiado para a área de transferência", + "no_links_added_yet": "Nenhum link adicionado ainda", + "add_link": "Adicionar link", + "links": "Links", + "go_to_workspace": "Ir para o espaço de trabalho", + "progress": "Progresso", + "optional": "Opcional", + "join": "Participar", + "go_back": "Voltar", + "continue": "Continuar", + "resend": "Reenviar", + "relations": "Relações", + "errors": { + "default": { + "title": "Erro!", + "message": "Algo deu errado. Por favor, tente novamente." + }, + "required": "Este campo é obrigatório", + "entity_required": "{entity} é obrigatório" + }, + "update_link": "Atualizar link", + "attach": "Anexar", + "create_new": "Criar novo", + "add_existing": "Adicionar existente", + "type_or_paste_a_url": "Digite ou cole uma URL", + "url_is_invalid": "URL inválida", + "display_title": "Título de exibição", + "link_title_placeholder": "Como você gostaria de ver este link", + "url": "URL", + "side_peek": "Visualização lateral", + "modal": "Modal", + "full_screen": "Tela cheia", + "close_peek_view": "Fechar a visualização", + "toggle_peek_view_layout": "Alternar layout de visualização rápida", + "options": "Opções", + "duration": "Duração", + "today": "Hoje", + "week": "Semana", + "month": "Mês", + "quarter": "Trimestre", + "press_for_commands": "Pressione '/' para comandos", + "click_to_add_description": "Clique para adicionar descrição", + "search": { + "label": "Buscar", + "placeholder": "Digite para buscar", + "no_matches_found": "Nenhum resultado encontrado", + "no_matching_results": "Nenhum resultado correspondente" + }, + "actions": { + "edit": "Editar", + "make_a_copy": "Fazer uma cópia", + "open_in_new_tab": "Abrir em nova aba", + "copy_link": "Copiar link", + "archive": "Arquivar", + "restore": "Restaurar", + "delete": "Excluir", + "remove_relation": "Remover relação", + "subscribe": "Inscrever-se", + "unsubscribe": "Cancelar inscrição", + "clear_sorting": "Limpar ordenação", + "show_weekends": "Mostrar fins de semana", + "enable": "Habilitar", + "disable": "Desabilitar" + }, + "name": "Nome", + "discard": "Descartar", + "confirm": "Confirmar", + "confirming": "Confirmando", + "read_the_docs": "Ler a documentação", + "default": "Padrão", + "active": "Ativo", + "enabled": "Habilitado", + "disabled": "Desabilitado", + "mandate": "Mandato", + "mandatory": "Obrigatório", + "yes": "Sim", + "no": "Não", + "please_wait": "Por favor, aguarde", + "enabling": "Habilitando", + "disabling": "Desabilitando", + "beta": "Beta", + "or": "ou", + "next": "Próximo", + "back": "Voltar", + "cancelling": "Cancelando", + "configuring": "Configurando", + "clear": "Limpar", + "import": "Importar", + "connect": "Conectar", + "authorizing": "Autorizando", + "processing": "Processando", + "no_data_available": "Nenhum dado disponível", + "from": "de {name}", + "authenticated": "Autenticado", + "select": "Selecionar", + "upgrade": "Upgrade", + "add_seats": "Adicionar lugares", + "projects": "Projetos", + "workspace": "Espaço de trabalho", + "workspaces": "Espaços de trabalho", + "team": "Equipe", + "teams": "Equipes", + "entity": "Entidade", + "entities": "Entidades", + "task": "Tarefa", + "tasks": "Tarefas", + "section": "Seção", + "sections": "Seções", + "edit": "Editar", + "connecting": "Conectando", + "connected": "Conectado", + "disconnect": "Desconectar", + "disconnecting": "Desconectando", + "installing": "Instalando", + "install": "Instalar", + "reset": "Redefinir", + "live": "Ao vivo", + "change_history": "Histórico de alterações", + "coming_soon": "Em breve", + "members": "Membros", + "you": "Você", + "upgrade_cta": { + "higher_subscription": "Faça upgrade para uma assinatura superior", + "talk_to_sales": "Fale com o departamento de vendas" + }, + "category": "Categoria", + "categories": "Categorias", + "saving": "Salvando", + "save_changes": "Salvar alterações", + "delete": "Excluir", + "deleting": "Excluindo", + "pending": "Pendente", + "invite": "Convidar", + "view": "Visualizar", + "deactivated_user": "Usuário desativado" + }, + + "chart": { + "x_axis": "Eixo X", + "y_axis": "Eixo Y", + "metric": "Métrica" + }, + + "form": { + "title": { + "required": "Título é obrigatório", + "max_length": "O título deve ter menos de {length} caracteres" + } + }, + + "entity": { + "grouping_title": "Agrupamento de {entity}", + "priority": "Prioridade de {entity}", + "all": "Todos os {entity}", + "drop_here_to_move": "Solte aqui para mover o {entity}", + "delete": { + "label": "Excluir {entity}", + "success": "{entity} excluído com sucesso", + "failed": "Falha ao excluir {entity}" + }, + "update": { + "failed": "Falha ao atualizar {entity}", + "success": "{entity} atualizado com sucesso" + }, + "link_copied_to_clipboard": "Link de {entity} copiado para a área de transferência", + "fetch": { + "failed": "Erro ao buscar {entity}" + }, + "add": { + "success": "{entity} adicionado com sucesso", + "failed": "Erro ao adicionar {entity}" + } + }, + + "epic": { + "all": "Todos os Épicos", + "label": "{count, plural, one {Épico} other {Épicos}}", + "new": "Novo Épico", + "adding": "Adicionando épico", + "create": { + "success": "Épico criado com sucesso" + }, + "add": { + "press_enter": "Pressione 'Enter' para adicionar outro épico", + "label": "Adicionar Épico" + }, + "title": { + "label": "Título do Épico", + "required": "O título do épico é obrigatório." + } + }, + + "issue": { + "label": "{count, plural, one {Item de trabalho} other {Itens de trabalho}}", + "all": "Todos os Itens de trabalho", + "edit": "Editar item de trabalho", + "title": { + "label": "Título do item de trabalho", + "required": "O título do item de trabalho é obrigatório." + }, + "add": { + "press_enter": "Pressione 'Enter' para adicionar outro item de trabalho", + "label": "Adicionar item de trabalho", + "cycle": { + "failed": "Não foi possível adicionar o item de trabalho ao ciclo. Por favor, tente novamente.", + "success": "{count, plural, one {Item de trabalho} other {Itens de trabalho}} adicionado(s) ao ciclo com sucesso.", + "loading": "Adicionando {count, plural, one {item de trabalho} other {itens de trabalho}} ao ciclo" + }, + "assignee": "Adicionar responsáveis", + "start_date": "Adicionar data de início", + "due_date": "Adicionar data de vencimento", + "parent": "Adicionar item de trabalho pai", + "sub_issue": "Adicionar sub-item de trabalho", + "relation": "Adicionar relação", + "link": "Adicionar link", + "existing": "Adicionar item de trabalho existente" + }, + "remove": { + "label": "Remover item de trabalho", + "cycle": { + "loading": "Removendo item de trabalho do ciclo", + "success": "Item de trabalho removido do ciclo com sucesso.", + "failed": "Não foi possível remover o item de trabalho do ciclo. Por favor, tente novamente." + }, + "module": { + "loading": "Removendo item de trabalho do módulo", + "success": "Item de trabalho removido do módulo com sucesso.", + "failed": "Não foi possível remover o item de trabalho do módulo. Por favor, tente novamente." + }, + "parent": { + "label": "Remover item de trabalho pai" + } + }, + "new": "Novo Item de trabalho", + "adding": "Adicionando item de trabalho", + "create": { + "success": "Item de trabalho criado com sucesso" + }, + "priority": { + "urgent": "Urgente", + "high": "Alta", + "medium": "Média", + "low": "Baixa" + }, + "display": { + "properties": { + "label": "Exibir Propriedades", + "id": "ID", + "issue_type": "Tipo de Item de Trabalho", + "sub_issue_count": "Contagem de sub-itens de trabalho", + "attachment_count": "Contagem de anexos", + "created_on": "Criado em", + "sub_issue": "Sub-item de trabalho", + "work_item_count": "Contagem de itens de trabalho" + }, + "extra": { + "show_sub_issues": "Mostrar sub-itens de trabalho", + "show_empty_groups": "Mostrar grupos vazios" + } + }, + "layouts": { + "ordered_by_label": "Este layout é ordenado por", + "list": "Lista", + "kanban": "Quadro", + "calendar": "Calendário", + "spreadsheet": "Tabela", + "gantt": "Cronograma", + "title": { + "list": "Layout de Lista", + "kanban": "Layout de Quadro", + "calendar": "Layout de Calendário", + "spreadsheet": "Layout de Tabela", + "gantt": "Layout de Cronograma" + } + }, + "states": { + "active": "Ativo", + "backlog": "Backlog" + }, + "comments": { + "placeholder": "Adicionar comentário", + "switch": { + "private": "Alternar para comentário privado", + "public": "Alternar para comentário público" + }, + "create": { + "success": "Comentário criado com sucesso", + "error": "Falha ao criar o comentário. Por favor, tente novamente mais tarde." + }, + "update": { + "success": "Comentário atualizado com sucesso", + "error": "Falha ao atualizar o comentário. Por favor, tente novamente mais tarde." + }, + "remove": { + "success": "Comentário removido com sucesso", + "error": "Falha ao remover o comentário. Por favor, tente novamente mais tarde." + }, + "upload": { + "error": "Falha ao carregar o recurso. Por favor, tente novamente mais tarde." + } + }, + "empty_state": { + "issue_detail": { + "title": "O item de trabalho não existe", + "description": "O item de trabalho que você está procurando não existe, foi arquivado ou foi excluído.", + "primary_button": { + "text": "Visualizar outros itens de trabalho" + } + } + }, + "sibling": { + "label": "Itens de trabalho irmãos" + }, + "archive": { + "description": "Apenas itens de trabalho concluídos ou cancelados\npodem ser arquivados", + "label": "Arquivar Item de Trabalho", + "confirm_message": "Tem certeza de que deseja arquivar o item de trabalho? Todos os seus itens de trabalho arquivados podem ser restaurados posteriormente.", + "success": { + "label": "Sucesso ao arquivar", + "message": "Seus arquivos podem ser encontrados nos arquivos do projeto." + }, + "failed": { + "message": "Não foi possível arquivar o item de trabalho. Por favor, tente novamente." + } + }, + "restore": { + "success": { + "title": "Sucesso ao restaurar", + "message": "Seu item de trabalho pode ser encontrado nos itens de trabalho do projeto." + }, + "failed": { + "message": "Não foi possível restaurar o item de trabalho. Por favor, tente novamente." + } + }, + "relation": { + "relates_to": "Relacionado a", + "duplicate": "Duplicado de", + "blocked_by": "Bloqueado por", + "blocking": "Bloqueando" + }, + "copy_link": "Copiar link do item de trabalho", + "delete": { + "label": "Excluir item de trabalho", + "error": "Erro ao excluir item de trabalho" + }, + "subscription": { + "actions": { + "subscribed": "Item de trabalho inscrito com sucesso", + "unsubscribed": "Item de trabalho não inscrito com sucesso" + } + }, + "select": { + "error": "Selecione pelo menos um item de trabalho", + "empty": "Nenhum item de trabalho selecionado", + "add_selected": "Adicionar itens de trabalho selecionados" + }, + "open_in_full_screen": "Abrir item de trabalho em tela cheia" + }, + + "attachment": { + "error": "Não foi possível anexar o arquivo. Tente enviar novamente.", + "only_one_file_allowed": "Apenas um arquivo pode ser enviado por vez.", + "file_size_limit": "O arquivo deve ter {size}MB ou menos.", + "drag_and_drop": "Arraste e solte em qualquer lugar para enviar", + "delete": "Excluir anexo" + }, + + "label": { + "select": "Selecionar etiqueta", + "create": { + "success": "Etiqueta criada com sucesso", + "failed": "Falha ao criar etiqueta", + "already_exists": "Etiqueta já existe", + "type": "Digite para adicionar uma nova etiqueta" + } + }, + + "sub_work_item": { + "update": { + "success": "Sub-item de trabalho atualizado com sucesso", + "error": "Erro ao atualizar sub-item de trabalho" + }, + "remove": { + "success": "Sub-item de trabalho removido com sucesso", + "error": "Erro ao remover sub-item de trabalho" + } + }, + + "view": { + "label": "{count, plural, one {Visualização} other {Visualizações}}", + "create": { + "label": "Criar Visualização" + }, + "update": { + "label": "Atualizar Visualização" + } + }, + + "inbox_issue": { + "status": { + "pending": { + "title": "Pendente", + "description": "Pendente" + }, + "declined": { + "title": "Recusado", + "description": "Recusado" + }, + "snoozed": { + "title": "Adiado", + "description": "{days, plural, one{Falta # dia} other{Faltam # dias}}" + }, + "accepted": { + "title": "Aceito", + "description": "Aceito" + }, + "duplicate": { + "title": "Duplicado", + "description": "Duplicado" + } + }, + "modals": { + "decline": { + "title": "Recusar item de trabalho", + "content": "Tem certeza de que deseja recusar o item de trabalho {value}?" + }, + "delete": { + "title": "Excluir item de trabalho", + "content": "Tem certeza de que deseja excluir o item de trabalho {value}?", + "success": "Item de trabalho excluído com sucesso" + } + }, + "errors": { + "snooze_permission": "Apenas administradores do projeto podem adiar/reativar itens de trabalho", + "accept_permission": "Apenas administradores do projeto podem aceitar itens de trabalho", + "decline_permission": "Apenas administradores do projeto podem recusar itens de trabalho" + }, + "actions": { + "accept": "Aceitar", + "decline": "Recusar", + "snooze": "Adiar", + "unsnooze": "Reativar", + "copy": "Copiar link do item de trabalho", + "delete": "Excluir", + "open": "Abrir item de trabalho", + "mark_as_duplicate": "Marcar como duplicado", + "move": "Mover {value} para os itens de trabalho do projeto" + }, + "source": { + "in-app": "no aplicativo" + }, + "order_by": { + "created_at": "Criado em", + "updated_at": "Atualizado em", + "id": "ID" + }, + "label": "Admissão", + "page_label": "{workspace} - Admissão", + "modal": { + "title": "Criar item de trabalho de admissão" + }, + "tabs": { + "open": "Aberto", + "closed": "Fechado" + }, + "empty_state": { + "sidebar_open_tab": { + "title": "Nenhum item de trabalho aberto", + "description": "Encontre itens de trabalho abertos aqui. Crie um novo item de trabalho." + }, + "sidebar_closed_tab": { + "title": "Nenhum item de trabalho fechado", + "description": "Todos os itens de trabalho, sejam aceitos ou recusados, podem ser encontrados aqui." + }, + "sidebar_filter": { + "title": "Nenhum item de trabalho correspondente", + "description": "Nenhum item de trabalho corresponde ao filtro aplicado na admissão. Crie um novo item de trabalho." + }, + "detail": { + "title": "Selecione um item de trabalho para visualizar seus detalhes." + } + } + }, + + "workspace_creation": { + "heading": "Crie seu espaço de trabalho", + "subheading": "Para começar a usar o Plane, você precisa criar ou entrar em um espaço de trabalho.", + "form": { + "name": { + "label": "Nomeie seu espaço de trabalho", + "placeholder": "Algo familiar e reconhecível é sempre melhor." + }, + "url": { + "label": "Defina o URL do seu espaço de trabalho", + "placeholder": "Digite ou cole um URL", + "edit_slug": "Você só pode editar o slug do URL" + }, + "organization_size": { + "label": "Quantas pessoas usarão este espaço de trabalho?", + "placeholder": "Selecione um intervalo" + } + }, + "errors": { + "creation_disabled": { + "title": "Apenas o administrador da sua instância pode criar espaços de trabalho", + "description": "Se você souber o endereço de e-mail do administrador da sua instância, clique no botão abaixo para entrar em contato com ele.", + "request_button": "Solicitar administrador da instância" + }, + "validation": { + "name_alphanumeric": "Os nomes dos espaços de trabalho podem conter apenas (' '), ('-'), ('_') e caracteres alfanuméricos.", + "name_length": "Limite seu nome a 80 caracteres.", + "url_alphanumeric": "Os URLs podem conter apenas ('-') e caracteres alfanuméricos.", + "url_length": "Limite seu URL a 48 caracteres.", + "url_already_taken": "O URL do espaço de trabalho já está em uso!" + } + }, + "request_email": { + "subject": "Solicitando um novo espaço de trabalho", + "body": "Olá, administrador(es) da instância,\n\nPor favor, crie um novo espaço de trabalho com o URL [/nome-do-espaço-de-trabalho] para [finalidade de criar o espaço de trabalho].\n\nObrigado,\n{firstName} {lastName}\n{email}" + }, + "button": { + "default": "Criar espaço de trabalho", + "loading": "Criando espaço de trabalho" + }, + "toast": { + "success": { + "title": "Sucesso", + "message": "Espaço de trabalho criado com sucesso" + }, + "error": { + "title": "Erro", + "message": "Não foi possível criar o espaço de trabalho. Por favor, tente novamente." + } + } + }, + + "workspace_dashboard": { + "empty_state": { + "general": { + "title": "Visão geral dos seus projetos, atividades e métricas", + "description": "Bem-vindo ao Plane, estamos animados por tê-lo aqui. Crie seu primeiro projeto e rastreie seus itens de trabalho, e esta página se transformará em um espaço que ajuda você a progredir. Os administradores também verão itens que ajudam sua equipe a progredir.", + "primary_button": { + "text": "Construa seu primeiro projeto", + "comic": { + "title": "Tudo começa com um projeto no Plane", + "description": "Um projeto pode ser o planejamento de um produto, uma campanha de marketing ou o lançamento de um novo carro." + } + } + } + } + }, + + "workspace_analytics": { + "label": "Análises", + "page_label": "{workspace} - Análises", + "open_tasks": "Total de tarefas abertas", + "error": "Ocorreu algum erro ao buscar os dados.", + "work_items_closed_in": "Itens de trabalho fechados em", + "selected_projects": "Projetos selecionados", + "total_members": "Total de membros", + "total_cycles": "Total de ciclos", + "total_modules": "Total de módulos", + "pending_work_items": { + "title": "Itens de trabalho pendentes", + "empty_state": "A análise de itens de trabalho pendentes por colegas de trabalho aparece aqui." + }, + "work_items_closed_in_a_year": { + "title": "Itens de trabalho fechados em um ano", + "empty_state": "Feche os itens de trabalho para visualizar a análise dos mesmos na forma de um gráfico." + }, + "most_work_items_created": { + "title": "Itens de trabalho mais criados", + "empty_state": "Colegas de trabalho e o número de itens de trabalho criados por eles aparecem aqui." + }, + "most_work_items_closed": { + "title": "Itens de trabalho mais fechados", + "empty_state": "Colegas de trabalho e o número de itens de trabalho fechados por eles aparecem aqui." + }, + "tabs": { + "scope_and_demand": "Escopo e Demanda", + "custom": "Análises Personalizadas" + }, + "empty_state": { + "general": { + "title": "Acompanhe o progresso, as cargas de trabalho e as alocações. Identifique tendências, remova bloqueadores e mova o trabalho mais rapidamente", + "description": "Veja o escopo versus a demanda, as estimativas e o aumento do escopo. Obtenha o desempenho por membros da equipe e equipes, e certifique-se de que seu projeto seja executado no prazo.", + "primary_button": { + "text": "Comece seu primeiro projeto", + "comic": { + "title": "A análise funciona melhor com Ciclos + Módulos", + "description": "Primeiro, coloque seus itens de trabalho em Ciclos e, se puder, agrupe os itens de trabalho que abrangem mais de um ciclo em Módulos. Confira ambos na navegação à esquerda." + } + } + } + } + }, + + "workspace_projects": { + "label": "{count, plural, one {Projeto} other {Projetos}}", + "create": { + "label": "Adicionar Projeto" + }, + "network": { + "label": "Rede", + "private": { + "title": "Privado", + "description": "Acessível apenas por convite" + }, + "public": { + "title": "Público", + "description": "Qualquer pessoa no espaço de trabalho, exceto convidados, pode participar" + } + }, + "error": { + "permission": "Você não tem permissão para realizar esta ação.", + "cycle_delete": "Falha ao excluir o ciclo", + "module_delete": "Falha ao excluir o módulo", + "issue_delete": "Falha ao excluir o item de trabalho" + }, + "state": { + "backlog": "Backlog", + "unstarted": "Não iniciado", + "started": "Iniciado", + "completed": "Concluído", + "cancelled": "Cancelado" + }, + "sort": { + "manual": "Manual", + "name": "Nome", + "created_at": "Data de criação", + "members_length": "Número de membros" + }, + "scope": { + "my_projects": "Meus projetos", + "archived_projects": "Arquivados" + }, + "common": { + "months_count": "{months, plural, one{# mês} other{# meses}}" + }, + "empty_state": { + "general": { + "title": "Nenhum projeto ativo", + "description": "Pense em cada projeto como o pai do trabalho orientado a objetivos. Os projetos são onde os Trabalhos, Ciclos e Módulos vivem e, junto com seus colegas, ajudam você a atingir esse objetivo. Crie um novo projeto ou filtre os projetos arquivados.", + "primary_button": { + "text": "Comece seu primeiro projeto", + "comic": { + "title": "Tudo começa com um projeto no Plane", + "description": "Um projeto pode ser o roteiro de um produto, uma campanha de marketing ou o lançamento de um novo carro." + } + } + }, + "no_projects": { + "title": "Nenhum projeto", + "description": "Para criar itens de trabalho ou gerenciar seu trabalho, você precisa criar um projeto ou fazer parte de um.", + "primary_button": { + "text": "Comece seu primeiro projeto", + "comic": { + "title": "Tudo começa com um projeto no Plane", + "description": "Um projeto pode ser o roteiro de um produto, uma campanha de marketing ou o lançamento de um novo carro." + } + } + }, + "filter": { + "title": "Nenhum projeto correspondente", + "description": "Nenhum projeto detectado com os critérios correspondentes. \n Crie um novo projeto em vez disso." + }, + "search": { + "description": "Nenhum projeto detectado com os critérios correspondentes.\nCrie um novo projeto em vez disso" + } + } + }, + + "workspace_views": { + "add_view": "Adicionar visualização", + "empty_state": { + "all-issues": { + "title": "Nenhum item de trabalho no projeto", + "description": "Primeiro projeto concluído! Agora, divida seu trabalho em partes rastreáveis com itens de trabalho. Vamos lá!", + "primary_button": { + "text": "Criar novo item de trabalho" + } + }, + "assigned": { + "title": "Nenhum item de trabalho ainda", + "description": "Os itens de trabalho atribuídos a você podem ser rastreados aqui.", + "primary_button": { + "text": "Criar novo item de trabalho" + } + }, + "created": { + "title": "Nenhum item de trabalho ainda", + "description": "Todos os itens de trabalho criados por você vêm aqui, rastreie-os aqui diretamente.", + "primary_button": { + "text": "Criar novo item de trabalho" + } + }, + "subscribed": { + "title": "Nenhum item de trabalho ainda", + "description": "Inscreva-se nos itens de trabalho nos quais você está interessado, rastreie todos eles aqui." + }, + "custom-view": { + "title": "Nenhum item de trabalho ainda", + "description": "Itens de trabalho que se aplicam aos filtros, rastreie todos eles aqui." + } + } + }, + + "workspace_settings": { + "label": "Configurações do espaço de trabalho", + "page_label": "{workspace} - Configurações gerais", + "key_created": "Chave criada", + "copy_key": "Copie e salve esta chave secreta no Páginas do Plane. Você não pode ver esta chave depois de clicar em Fechar. Um arquivo CSV contendo a chave foi baixado.", + "token_copied": "Token copiado para a área de transferência.", + "settings": { + "general": { + "title": "Geral", + "upload_logo": "Carregar logo", + "edit_logo": "Editar logo", + "name": "Nome do espaço de trabalho", + "company_size": "Tamanho da empresa", + "url": "URL do espaço de trabalho", + "update_workspace": "Atualizar espaço de trabalho", + "delete_workspace": "Excluir este espaço de trabalho", + "delete_workspace_description": "Ao excluir um espaço de trabalho, todos os dados e recursos dentro desse espaço de trabalho serão permanentemente removidos e não poderão ser recuperados.", + "delete_btn": "Excluir este espaço de trabalho", + "delete_modal": { + "title": "Tem certeza de que deseja excluir este espaço de trabalho?", + "description": "Você tem uma avaliação ativa para um de nossos planos pagos. Cancele-o primeiro para prosseguir.", + "dismiss": "Dispensar", + "cancel": "Cancelar avaliação", + "success_title": "Espaço de trabalho excluído.", + "success_message": "Em breve, você irá para a página do seu perfil.", + "error_title": "Isso não funcionou.", + "error_message": "Tente novamente, por favor." + }, + "errors": { + "name": { + "required": "O nome é obrigatório", + "max_length": "O nome do espaço de trabalho não deve exceder 80 caracteres" + }, + "company_size": { + "required": "O tamanho da empresa é obrigatório", + "select_a_range": "Selecione o tamanho da organização" + } + } + }, + "members": { + "title": "Membros", + "add_member": "Adicionar membro", + "pending_invites": "Convites pendentes", + "invitations_sent_successfully": "Convites enviados com sucesso", + "leave_confirmation": "Tem certeza de que deseja sair do espaço de trabalho? Você não terá mais acesso a este espaço de trabalho. Esta ação não pode ser desfeita.", + "details": { + "full_name": "Nome completo", + "display_name": "Nome de exibição", + "email_address": "Endereço de e-mail", + "account_type": "Tipo de conta", + "authentication": "Autenticação", + "joining_date": "Data de adesão" + }, + "modal": { + "title": "Convidar pessoas para colaborar", + "description": "Convide pessoas para colaborar em seu espaço de trabalho.", + "button": "Enviar convites", + "button_loading": "Enviando convites", + "placeholder": "nome@empresa.com", + "errors": { + "required": "Precisamos de um endereço de e-mail para convidá-los.", + "invalid": "E-mail inválido" + } + } + }, + "billing_and_plans": { + "title": "Faturamento e planos", + "current_plan": "Plano atual", + "free_plan": "Você está usando o plano gratuito atualmente", + "view_plans": "Ver planos" + }, + "exports": { + "title": "Exportações", + "exporting": "Exportando", + "previous_exports": "Exportações anteriores", + "export_separate_files": "Exporte os dados em arquivos separados", + "modal": { + "title": "Exportar para", + "toasts": { + "success": { + "title": "Exportação bem-sucedida", + "message": "Você poderá baixar o(a) {entity} exportado(a) na exportação anterior." + }, + "error": { + "title": "Falha na exportação", + "message": "A exportação não foi bem-sucedida. Tente novamente." + } + } + } + }, + "webhooks": { + "title": "Webhooks", + "add_webhook": "Adicionar webhook", + "modal": { + "title": "Criar webhook", + "details": "Detalhes do webhook", + "payload": "URL do payload", + "question": "Quais eventos você gostaria de acionar este webhook?", + "error": "URL é obrigatório" + }, + "secret_key": { + "title": "Chave secreta", + "message": "Gere um token para fazer login no payload do webhook" + }, + "options": { + "all": "Envie-me tudo", + "individual": "Selecionar eventos individuais" + }, + "toasts": { + "created": { + "title": "Webhook criado", + "message": "O webhook foi criado com sucesso" + }, + "not_created": { + "title": "Webhook não criado", + "message": "O webhook não pôde ser criado" + }, + "updated": { + "title": "Webhook atualizado", + "message": "O webhook foi atualizado com sucesso" + }, + "not_updated": { + "title": "Webhook não atualizado", + "message": "O webhook não pôde ser atualizado" + }, + "removed": { + "title": "Webhook removido", + "message": "O webhook foi removido com sucesso" + }, + "not_removed": { + "title": "Webhook não removido", + "message": "O webhook não pôde ser removido" + }, + "secret_key_copied": { + "message": "Chave secreta copiada para a área de transferência." + }, + "secret_key_not_copied": { + "message": "Ocorreu um erro ao copiar a chave secreta." + } + } + }, + "api_tokens": { + "title": "Tokens de API", + "add_token": "Adicionar token de API", + "create_token": "Criar token", + "never_expires": "Nunca expira", + "generate_token": "Gerar token", + "generating": "Gerando", + "delete": { + "title": "Excluir token de API", + "description": "Qualquer aplicativo que use este token não terá mais acesso aos dados do Plane. Esta ação não pode ser desfeita.", + "success": { + "title": "Sucesso!", + "message": "O token de API foi excluído com sucesso" + }, + "error": { + "title": "Erro!", + "message": "O token de API não pôde ser excluído" + } + } + } + }, + "empty_state": { + "api_tokens": { + "title": "Nenhum token de API criado", + "description": "As APIs do Plane podem ser usadas para integrar seus dados no Plane com qualquer sistema externo. Crie um token para começar." + }, + "webhooks": { + "title": "Nenhum webhook adicionado", + "description": "Crie webhooks para receber atualizações em tempo real e automatizar ações." + }, + "exports": { + "title": "Nenhuma exportação ainda", + "description": "Sempre que você exportar, você também terá uma cópia aqui para referência." + }, + "imports": { + "title": "Nenhuma importação ainda", + "description": "Encontre todas as suas importações anteriores aqui e baixe-as." + } + } + }, + + "profile": { + "label": "Perfil", + "page_label": "Seu trabalho", + "work": "Trabalho", + "details": { + "joined_on": "Entrou em", + "time_zone": "Fuso horário" + }, + "stats": { + "workload": "Carga de trabalho", + "overview": "Visão geral", + "created": "Itens de trabalho criados", + "assigned": "Itens de trabalho atribuídos", + "subscribed": "Itens de trabalho inscritos", + "state_distribution": { + "title": "Itens de trabalho por estado", + "empty": "Crie itens de trabalho para visualizá-los por estado no gráfico para uma melhor análise." + }, + "priority_distribution": { + "title": "Itens de trabalho por prioridade", + "empty": "Crie itens de trabalho para visualizá-los por prioridade no gráfico para uma melhor análise." + }, + "recent_activity": { + "title": "Atividade recente", + "empty": "Não foi possível encontrar dados. Por favor, verifique suas entradas", + "button": "Baixar atividade de hoje", + "button_loading": "Baixando" + } + }, + "actions": { + "profile": "Perfil", + "security": "Segurança", + "activity": "Atividade", + "appearance": "Aparência", + "notifications": "Notificações" + }, + "tabs": { + "summary": "Resumo", + "assigned": "Atribuído", + "created": "Criado", + "subscribed": "Inscrito", + "activity": "Atividade" + }, + "empty_state": { + "activity": { + "title": "Nenhuma atividade ainda", + "description": "Comece criando um novo item de trabalho! Adicione detalhes e propriedades a ele. Explore mais no Plane para ver sua atividade." + }, + "assigned": { + "title": "Nenhum item de trabalho atribuído a você", + "description": "Os itens de trabalho atribuídos a você podem ser rastreados aqui." + }, + "created": { + "title": "Nenhum item de trabalho ainda", + "description": "Todos os itens de trabalho criados por você vêm aqui, rastreie-os aqui diretamente." + }, + "subscribed": { + "title": "Nenhum item de trabalho ainda", + "description": "Inscreva-se nos itens de trabalho nos quais você está interessado, rastreie todos eles aqui." + } + } + }, + + "project_settings": { + "general": { + "enter_project_id": "Inserir ID do projeto", + "please_select_a_timezone": "Por favor, selecione um fuso horário", + "archive_project": { + "title": "Arquivar projeto", + "description": "Arquivar um projeto removerá seu projeto da navegação lateral, embora você ainda possa acessá-lo na página de projetos. Você pode restaurar o projeto ou excluí-lo quando quiser.", + "button": "Arquivar projeto" + }, + "delete_project": { + "title": "Excluir projeto", + "description": "Ao excluir um projeto, todos os dados e recursos dentro desse projeto serão removidos permanentemente e não poderão ser recuperados.", + "button": "Excluir meu projeto" + }, + "toast": { + "success": "Projeto atualizado com sucesso", + "error": "Não foi possível atualizar o projeto. Por favor, tente novamente." + } + }, + "members": { + "label": "Membros", + "project_lead": "Líder do projeto", + "default_assignee": "Responsável padrão", + "guest_super_permissions": { + "title": "Conceder acesso de visualização a todos os itens de trabalho para usuários convidados:", + "sub_heading": "Isso permitirá que os convidados tenham acesso de visualização a todos os itens de trabalho do projeto." + }, + "invite_members": { + "title": "Convidar membros", + "sub_heading": "Convide membros para trabalhar em seu projeto.", + "select_co_worker": "Selecionar colega de trabalho" + } + }, + "states": { + "describe_this_state_for_your_members": "Descreva este estado para seus membros.", + "empty_state": { + "title": "Nenhum estado disponível para o grupo {groupKey}", + "description": "Por favor, crie um novo estado" + } + }, + "labels": { + "label_title": "Título da etiqueta", + "label_title_is_required": "O título da etiqueta é obrigatório", + "label_max_char": "O nome da etiqueta não deve exceder 255 caracteres", + "toast": { + "error": "Erro ao atualizar a etiqueta" + } + }, + "estimates": { + "label": "Estimativas", + "title": "Habilitar estimativas para meu projeto", + "description": "Elas ajudam você a comunicar a complexidade e a carga de trabalho da equipe.", + "no_estimate": "Sem estimativa", + "new": "Novo sistema de estimativa", + "create": { + "custom": "Personalizado", + "start_from_scratch": "Começar do zero", + "choose_template": "Escolher um modelo", + "choose_estimate_system": "Escolher um sistema de estimativa", + "enter_estimate_point": "Inserir estimativa", + "step": "Passo {step} de {total}", + "label": "Criar estimativa" + }, + "toasts": { + "created": { + "success": { + "title": "Estimativa criada", + "message": "A estimativa foi criada com sucesso" + }, + "error": { + "title": "Falha na criação da estimativa", + "message": "Não foi possível criar a nova estimativa, por favor tente novamente." + } + }, + "updated": { + "success": { + "title": "Estimativa modificada", + "message": "A estimativa foi atualizada em seu projeto." + }, + "error": { + "title": "Falha na modificação da estimativa", + "message": "Não foi possível modificar a estimativa, por favor tente novamente" + } + }, + "enabled": { + "success": { + "title": "Sucesso!", + "message": "As estimativas foram habilitadas." + } + }, + "disabled": { + "success": { + "title": "Sucesso!", + "message": "As estimativas foram desabilitadas." + }, + "error": { + "title": "Erro!", + "message": "Não foi possível desabilitar a estimativa. Por favor, tente novamente" + } + } + }, + "validation": { + "min_length": "A estimativa precisa ser maior que 0.", + "unable_to_process": "Não foi possível processar sua solicitação, por favor tente novamente.", + "numeric": "A estimativa precisa ser um valor numérico.", + "character": "A estimativa precisa ser um valor em caracteres.", + "empty": "O valor da estimativa não pode estar vazio.", + "already_exists": "O valor da estimativa já existe.", + "unsaved_changes": "Você tem algumas alterações não salvas. Por favor, salve-as antes de clicar em concluir", + "remove_empty": "A estimativa não pode estar vazia. Insira um valor em cada campo ou remova aqueles para os quais você não tem valores." + }, + "systems": { + "points": { + "label": "Pontos", + "fibonacci": "Fibonacci", + "linear": "Linear", + "squares": "Quadrados", + "custom": "Personalizado" + }, + "categories": { + "label": "Categorias", + "t_shirt_sizes": "Tamanhos de Camiseta", + "easy_to_hard": "Fácil a difícil", + "custom": "Personalizado" + }, + "time": { + "label": "Tempo", + "hours": "Horas" + } + } + }, + "automations": { + "label": "Automações", + "auto-archive": { + "title": "Arquivar automaticamente itens de trabalho fechados", + "description": "O Plane arquivará automaticamente os itens de trabalho que foram concluídos ou cancelados.", + "duration": "Arquivar automaticamente itens de trabalho que estão fechados por" + }, + "auto-close": { + "title": "Fechar automaticamente itens de trabalho", + "description": "O Plane fechará automaticamente os itens de trabalho que não foram concluídos ou cancelados.", + "duration": "Fechar automaticamente itens de trabalho que estão inativos por", + "auto_close_status": "Status de fechamento automático" + } + }, + + "empty_state": { + "labels": { + "title": "Nenhuma etiqueta ainda", + "description": "Crie etiquetas para ajudar a organizar e filtrar itens de trabalho em seu projeto." + }, + "estimates": { + "title": "Nenhum sistema de estimativa ainda", + "description": "Crie um conjunto de estimativas para comunicar a quantidade de trabalho por item de trabalho.", + "primary_button": "Adicionar sistema de estimativa" + } + } + }, + + "project_cycles": { + "add_cycle": "Adicionar ciclo", + "more_details": "Mais detalhes", + "cycle": "Ciclo", + "update_cycle": "Atualizar ciclo", + "create_cycle": "Criar ciclo", + "no_matching_cycles": "Nenhum ciclo correspondente", + "remove_filters_to_see_all_cycles": "Remova os filtros para ver todos os ciclos", + "remove_search_criteria_to_see_all_cycles": "Remova os critérios de pesquisa para ver todos os ciclos", + "only_completed_cycles_can_be_archived": "Apenas ciclos concluídos podem ser arquivados", + "active_cycle": { + "label": "Ciclo ativo", + "progress": "Progresso", + "chart": "Gráfico de burndown", + "priority_issue": "Itens de trabalho prioritários", + "assignees": "Responsáveis", + "issue_burndown": "Burndown de itens de trabalho", + "ideal": "Ideal", + "current": "Atual", + "labels": "Etiquetas" + }, + "upcoming_cycle": { + "label": "Próximo ciclo" + }, + "completed_cycle": { + "label": "Ciclo concluído" + }, + "status": { + "days_left": "Dias restantes", + "completed": "Concluído", + "yet_to_start": "Ainda não começou", + "in_progress": "Em progresso", + "draft": "Rascunho" + }, + "action": { + "restore": { + "title": "Restaurar ciclo", + "success": { + "title": "Ciclo restaurado", + "description": "O ciclo foi restaurado." + }, + "failed": { + "title": "Falha ao restaurar o ciclo", + "description": "Não foi possível restaurar o ciclo. Por favor, tente novamente." + } + }, + "favorite": { + "loading": "Adicionando ciclo aos favoritos", + "success": { + "description": "Ciclo adicionado aos favoritos.", + "title": "Sucesso!" + }, + "failed": { + "description": "Não foi possível adicionar o ciclo aos favoritos. Por favor, tente novamente.", + "title": "Erro!" + } + }, + "unfavorite": { + "loading": "Removendo ciclo dos favoritos", + "success": { + "description": "Ciclo removido dos favoritos.", + "title": "Sucesso!" + }, + "failed": { + "description": "Não foi possível remover o ciclo dos favoritos. Por favor, tente novamente.", + "title": "Erro!" + } + }, + "update": { + "loading": "Atualizando ciclo", + "success": { + "description": "Ciclo atualizado com sucesso.", + "title": "Sucesso!" + }, + "failed": { + "description": "Erro ao atualizar o ciclo. Por favor, tente novamente.", + "title": "Erro!" + }, + "error": { + "already_exists": "Você já tem um ciclo nas datas fornecidas, se você quiser criar um ciclo de rascunho, você pode fazer isso removendo ambas as datas." + } + } + }, + "empty_state": { + "general": { + "title": "Agrupe e defina prazos para seu trabalho em Ciclos.", + "description": "Divida o trabalho em partes com prazos definidos, trabalhe de trás para frente a partir do prazo do seu projeto para definir datas e faça um progresso tangível como equipe.", + "primary_button": { + "text": "Defina seu primeiro ciclo", + "comic": { + "title": "Ciclos são caixas de tempo repetitivas.", + "description": "Uma sprint, uma iteração ou qualquer outro termo que você use para rastreamento semanal ou quinzenal do trabalho é um ciclo." + } + } + }, + "no_issues": { + "title": "Nenhum item de trabalho adicionado ao ciclo", + "description": "Adicione ou crie itens de trabalho que você deseja definir prazos e entregar dentro deste ciclo", + "primary_button": { + "text": "Criar novo item de trabalho" + }, + "secondary_button": { + "text": "Adicionar item de trabalho existente" + } + }, + "completed_no_issues": { + "title": "Nenhum item de trabalho no ciclo", + "description": "Nenhum item de trabalho no ciclo. Os itens de trabalho são transferidos ou ocultos. Para ver os itens de trabalho ocultos, se houver, atualize suas propriedades de exibição de acordo." + }, + "active": { + "title": "Nenhum ciclo ativo", + "description": "Um ciclo ativo inclui qualquer período que abranja a data de hoje dentro de seu intervalo. Encontre o progresso e os detalhes do ciclo ativo aqui." + }, + "archived": { + "title": "Nenhum ciclo arquivado ainda", + "description": "Para organizar seu projeto, arquive os ciclos concluídos. Encontre-os aqui quando forem arquivados." + } + } + }, + + "project_issues": { + "empty_state": { + "no_issues": { + "title": "Crie um item de trabalho e atribua-o a alguém, mesmo a você mesmo", + "description": "Pense nos itens de trabalho como tarefas, trabalhos ou JTBD. O que nós gostamos. Um item de trabalho e seus subitens de trabalho são geralmente acionáveis ​​baseados no tempo atribuídos aos membros de sua equipe. Sua equipe cria, atribui e conclui itens de trabalho para mover seu projeto em direção à sua meta.", + "primary_button": { + "text": "Crie seu primeiro item de trabalho", + "comic": { + "title": "Os itens de trabalho são blocos de construção no Plane.", + "description": "Redesenhar a interface do usuário do Plane, reformular a marca da empresa ou lançar o novo sistema de injeção de combustível são exemplos de itens de trabalho que provavelmente têm subitens de trabalho." + } + } + }, + "no_archived_issues": { + "title": "Nenhum item de trabalho arquivado ainda", + "description": "Manualmente ou por meio de automação, você pode arquivar itens de trabalho que foram concluídos ou cancelados. Encontre-os aqui quando forem arquivados.", + "primary_button": { + "text": "Definir automação" + } + }, + "issues_empty_filter": { + "title": "Nenhum item de trabalho encontrado correspondendo aos filtros aplicados", + "secondary_button": { + "text": "Limpar todos os filtros" + } + } + } + }, + + "project_module": { + "add_module": "Adicionar Módulo", + "update_module": "Atualizar Módulo", + "create_module": "Criar Módulo", + "archive_module": "Arquivar Módulo", + "restore_module": "Restaurar Módulo", + "delete_module": "Excluir módulo", + "empty_state": { + "general": { + "title": "Mapeie os marcos do seu projeto para Módulos e rastreie o trabalho agregado facilmente.", + "description": "Um grupo de itens de trabalho que pertencem a um pai lógico e hierárquico forma um módulo. Pense neles como uma forma de rastrear o trabalho por marcos do projeto. Eles têm seus próprios períodos e prazos, bem como análises para ajudá-lo a ver o quão perto ou longe você está de um marco.", + "primary_button": { + "text": "Construa seu primeiro módulo", + "comic": { + "title": "Os módulos ajudam a agrupar o trabalho por hierarquia.", + "description": "Um módulo de carrinho, um módulo de chassi e um módulo de armazém são todos bons exemplos desse agrupamento." + } + } + }, + "no_issues": { + "title": "Nenhum item de trabalho no módulo", + "description": "Crie ou adicione itens de trabalho que você deseja realizar como parte deste módulo", + "primary_button": { + "text": "Criar novos itens de trabalho" + }, + "secondary_button": { + "text": "Adicionar um item de trabalho existente" + } + }, + "archived": { + "title": "Nenhum Módulo arquivado ainda", + "description": "Para organizar seu projeto, arquive os módulos concluídos ou cancelados. Encontre-os aqui quando forem arquivados." + }, + "sidebar": { + "in_active": "Este módulo ainda não está ativo.", + "invalid_date": "Data inválida. Por favor, insira uma data válida." + } + }, + "quick_actions": { + "archive_module": "Arquivar módulo", + "archive_module_description": "Apenas módulos concluídos ou cancelados\npodem ser arquivados.", + "delete_module": "Excluir módulo" + }, + "toast": { + "copy": { + "success": "Link do módulo copiado para a área de transferência" + }, + "delete": { + "success": "Módulo excluído com sucesso", + "error": "Falha ao excluir o módulo" + } + } + }, + + "project_views": { + "empty_state": { + "general": { + "title": "Salve visualizações filtradas para o seu projeto. Crie quantas precisar", + "description": "As visualizações são um conjunto de filtros salvos que você usa com frequência ou deseja acesso fácil. Todos os seus colegas em um projeto podem ver as visualizações de todos e escolher o que melhor se adapta às suas necessidades.", + "primary_button": { + "text": "Crie sua primeira visualização", + "comic": { + "title": "As visualizações funcionam sobre as propriedades do item de trabalho.", + "description": "Você pode criar uma visualização a partir daqui com quantas propriedades como filtros que você achar adequado." + } + } + }, + "filter": { + "title": "Nenhuma visualização correspondente", + "description": "Nenhuma visualização corresponde aos critérios de pesquisa.\nCrie uma nova visualização em vez disso." + } + } + }, + + "project_page": { + "empty_state": { + "general": { + "title": "Escreva uma nota, um documento ou uma base de conhecimento completa. Peça a Galileo, o assistente de IA do Plane, para ajudá-lo a começar", + "description": "As páginas são espaço para registrar pensamentos no Plane. Anote notas de reunião, formate-as facilmente, incorpore itens de trabalho, organize-os usando uma biblioteca de componentes e mantenha-os todos no contexto do seu projeto. Para facilitar qualquer documento, invoque Galileo, a IA do Plane, com um atalho ou o clique de um botão.", + "primary_button": { + "text": "Crie sua primeira página" + } + }, + "private": { + "title": "Nenhuma página privada ainda", + "description": "Mantenha seus pensamentos privados aqui. Quando estiver pronto para compartilhar, a equipe está a apenas um clique de distância.", + "primary_button": { + "text": "Crie sua primeira página" + } + }, + "public": { + "title": "Nenhuma página pública ainda", + "description": "Veja as páginas compartilhadas com todos em seu projeto aqui mesmo.", + "primary_button": { + "text": "Crie sua primeira página" + } + }, + "archived": { + "title": "Nenhuma página arquivada ainda", + "description": "Arquive as páginas que não estão no seu radar. Acesse-as aqui quando necessário." + } + } + }, + + "command_k": { + "empty_state": { + "search": { + "title": "Nenhum resultado encontrado" + } + } + }, + + "issue_relation": { + "empty_state": { + "search": { + "title": "Nenhum item de trabalho correspondente encontrado" + }, + "no_issues": { + "title": "Nenhum item de trabalho encontrado" + } + } + }, + + "issue_comment": { + "empty_state": { + "general": { + "title": "Nenhum comentário ainda", + "description": "Os comentários podem ser usados como um espaço de discussão e acompanhamento para os itens de trabalho" + } + } + }, + + "notification": { + "label": "Caixa de entrada", + "page_label": "{workspace} - Caixa de entrada", + "options": { + "mark_all_as_read": "Marcar tudo como lido", + "mark_read": "Marcar como lido", + "mark_unread": "Marcar como não lido", + "refresh": "Atualizar", + "filters": "Filtros da caixa de entrada", + "show_unread": "Mostrar não lidos", + "show_snoozed": "Mostrar adiados", + "show_archived": "Mostrar arquivados", + "mark_archive": "Arquivar", + "mark_unarchive": "Desarquivar", + "mark_snooze": "Adiar", + "mark_unsnooze": "Reativar" + }, + "toasts": { + "read": "Notificação marcada como lida", + "unread": "Notificação marcada como não lida", + "archived": "Notificação marcada como arquivada", + "unarchived": "Notificação marcada como não arquivada", + "snoozed": "Notificação adiada", + "unsnoozed": "Notificação reativada" + }, + "empty_state": { + "detail": { + "title": "Selecione para ver os detalhes." + }, + "all": { + "title": "Nenhum item de trabalho atribuído", + "description": "As atualizações para itens de trabalho atribuídos a você podem ser\nvistas aqui" + }, + "mentions": { + "title": "Nenhum item de trabalho atribuído", + "description": "As atualizações para itens de trabalho atribuídos a você podem ser\nvistas aqui" + } + }, + "tabs": { + "all": "Todos", + "mentions": "Menções" + }, + "filter": { + "assigned": "Atribuído a mim", + "created": "Criado por mim", + "subscribed": "Inscrito por mim" + }, + "snooze": { + "1_day": "1 dia", + "3_days": "3 dias", + "5_days": "5 dias", + "1_week": "1 semana", + "2_weeks": "2 semanas", + "custom": "Personalizado" + } + }, + + "active_cycle": { + "empty_state": { + "progress": { + "title": "Adicione itens de trabalho ao ciclo para visualizar seu progresso" + }, + "chart": { + "title": "Adicione itens de trabalho ao ciclo para visualizar o gráfico de burndown." + }, + "priority_issue": { + "title": "Observe os itens de trabalho de alta prioridade abordados no ciclo rapidamente." + }, + "assignee": { + "title": "Adicione responsáveis aos itens de trabalho para ver uma divisão do trabalho por responsáveis." + }, + "label": { + "title": "Adicione etiquetas aos itens de trabalho para ver a divisão do trabalho por etiquetas." + } + } + }, + "disabled_project": { + "empty_state": { + "inbox": { + "title": "A Admissão não está habilitado para o projeto.", + "description": "A Admissão ajuda você a gerenciar as solicitações recebidas para o seu projeto e adicioná-las como itens de trabalho em seu fluxo de trabalho. Habilite a admissão nas configurações do projeto para gerenciar as solicitações.", + "primary_button": { + "text": "Gerenciar funcionalidades" + } + }, + "cycle": { + "title": "Os ciclos não estão habilitados para este projeto.", + "description": "Divida o trabalho em partes com prazos definidos, trabalhe de trás para frente a partir do prazo do seu projeto para definir datas e faça um progresso tangível como equipe. Habilite o recurso de ciclos para o seu projeto para começar a usá-los.", + "primary_button": { + "text": "Gerenciar funcionalidades" + } + }, + "module": { + "title": "Os módulos não estão habilitados para o projeto.", + "description": "Os módulos são os blocos de construção do seu projeto. Habilite os módulos nas configurações do projeto para começar a usá-los.", + "primary_button": { + "text": "Gerenciar funcionalidades" + } + }, + "page": { + "title": "As páginas não estão habilitadas para o projeto.", + "description": "As páginas são os blocos de construção do seu projeto. Habilite as páginas nas configurações do projeto para começar a usá-las.", + "primary_button": { + "text": "Gerenciar funcionalidades" + } + }, + "view": { + "title": "As visualizações não estão habilitadas para o projeto.", + "description": "As visualizações são os blocos de construção do seu projeto. Habilite as visualizações nas configurações do projeto para começar a usá-las.", + "primary_button": { + "text": "Gerenciar funcionalidades" + } + } + } + }, + "workspace_draft_issues": { + "draft_an_issue": "Rascunhar um item de trabalho", + "empty_state": { + "title": "Itens de trabalho semi-escritos e, em breve, os comentários aparecerão aqui.", + "description": "Para experimentar, comece a adicionar um item de trabalho e deixe-o no meio do caminho ou crie seu primeiro rascunho abaixo. 😉", + "primary_button": { + "text": "Criar seu primeiro rascunho" + } + }, + "delete_modal": { + "title": "Excluir rascunho", + "description": "Tem certeza de que deseja excluir este rascunho? Isso não pode ser desfeito." + }, + "toasts": { + "created": { + "success": "Rascunho criado", + "error": "Não foi possível criar o item de trabalho. Por favor, tente novamente." + }, + "deleted": { + "success": "Rascunho excluído" + } + } + }, + + "stickies": { + "title": "Suas anotações", + "placeholder": "clique para digitar aqui", + "all": "Todas as anotações", + "no-data": "Anote uma ideia, capture um insight ou registre uma onda cerebral. Adicione uma anotação para começar.", + "add": "Adicionar anotação", + "search_placeholder": "Pesquisar por título", + "delete": "Excluir anotação", + "delete_confirmation": "Tem certeza de que deseja excluir esta anotação?", + "empty_state": { + "simple": "Anote uma ideia, capture um insight ou registre uma onda cerebral. Adicione uma anotação para começar.", + "general": { + "title": "As anotações são notas rápidas e tarefas que você anota rapidamente.", + "description": "Capture seus pensamentos e ideias sem esforço, criando anotações que você pode acessar a qualquer momento e de qualquer lugar.", + "primary_button": { + "text": "Adicionar anotação" + } + }, + "search": { + "title": "Isso não corresponde a nenhuma de suas anotações.", + "description": "Tente um termo diferente ou nos informe\nse você tem certeza de que sua pesquisa está correta.", + "primary_button": { + "text": "Adicionar anotação" + } + } + }, + "toasts": { + "errors": { + "wrong_name": "O nome da anotação não pode ter mais de 100 caracteres.", + "already_exists": "Já existe uma anotação sem descrição" + }, + "created": { + "title": "Anotação criada", + "message": "A anotação foi criada com sucesso" + }, + "not_created": { + "title": "Anotação não criada", + "message": "A anotação não pôde ser criada" + }, + "updated": { + "title": "Anotação atualizada", + "message": "A anotação foi atualizada com sucesso" + }, + "not_updated": { + "title": "Anotação não atualizada", + "message": "A anotação não pôde ser atualizada" + }, + "removed": { + "title": "Anotação removida", + "message": "A anotação foi removida com sucesso" + }, + "not_removed": { + "title": "Anotação não removida", + "message": "A anotação não pôde ser removida" + } + } + }, + + "role_details": { + "guest": { + "title": "Convidado", + "description": "Membros externos de organizações podem ser convidados como convidados." + }, + "member": { + "title": "Membro", + "description": "Capacidade de ler, escrever, editar e excluir entidades dentro de projetos, ciclos e módulos" + }, + "admin": { + "title": "Administrador", + "description": "Todas as permissões definidas como verdadeiras dentro do espaço de trabalho." + } + }, + + "user_roles": { + "product_or_project_manager": "Gerente de Produto / Projeto", + "development_or_engineering": "Desenvolvimento / Engenharia", + "founder_or_executive": "Fundador / Executivo", + "freelancer_or_consultant": "Freelancer / Consultor", + "marketing_or_growth": "Marketing / Crescimento", + "sales_or_business_development": "Vendas / Desenvolvimento de Negócios", + "support_or_operations": "Suporte / Operações", + "student_or_professor": "Estudante / Professor", + "human_resources": "Recursos Humanos", + "other": "Outro" + }, + + "importer": { + "github": { + "title": "Github", + "description": "Importe itens de trabalho de repositórios do GitHub e sincronize-os." + }, + "jira": { + "title": "Jira", + "description": "Importe itens de trabalho e épicos de projetos e épicos do Jira." + } + }, + + "exporter": { + "csv": { + "title": "CSV", + "description": "Exporte itens de trabalho para um arquivo CSV.", + "short_description": "Exportar como CSV" + }, + "excel": { + "title": "Excel", + "description": "Exporte itens de trabalho para um arquivo Excel.", + "short_description": "Exportar como Excel" + }, + "xlsx": { + "title": "Excel", + "description": "Exporte itens de trabalho para um arquivo Excel.", + "short_description": "Exportar como Excel" + }, + "json": { + "title": "JSON", + "description": "Exporte itens de trabalho para um arquivo JSON.", + "short_description": "Exportar como JSON" + } + }, + "default_global_view": { + "all_issues": "Todos os itens de trabalho", + "assigned": "Atribuído", + "created": "Criado", + "subscribed": "Inscrito" + }, + + "themes": { + "theme_options": { + "system_preference": { + "label": "Preferência do sistema" + }, + "light": { + "label": "Claro" + }, + "dark": { + "label": "Escuro" + }, + "light_contrast": { + "label": "Alto contraste claro" + }, + "dark_contrast": { + "label": "Alto contraste escuro" + }, + "custom": { + "label": "Tema personalizado" + } + } + }, + "project_modules": { + "status": { + "backlog": "Backlog", + "planned": "Planejado", + "in_progress": "Em Andamento", + "paused": "Pausado", + "completed": "Concluído", + "cancelled": "Cancelado" + }, + "layout": { + "list": "Layout de lista", + "board": "Layout de galeria", + "timeline": "Layout de linha do tempo" + }, + "order_by": { + "name": "Nome", + "progress": "Progresso", + "issues": "Número de itens de trabalho", + "due_date": "Data de vencimento", + "created_at": "Data de criação", + "manual": "Manual" + } + }, + + "cycle": { + "label": "{count, plural, one {Ciclo} other {Ciclos}}", + "no_cycle": "Nenhum ciclo" + }, + + "module": { + "label": "{count, plural, one {Módulo} other {Módulos}}", + "no_module": "Nenhum módulo" + }, + + "description_versions": { + "last_edited_by": "Última edição por", + "previously_edited_by": "Anteriormente editado por", + "edited_by": "Editado por" + } +} diff --git a/packages/i18n/src/locales/ro/translations.json b/packages/i18n/src/locales/ro/translations.json new file mode 100644 index 000000000..05a775c8e --- /dev/null +++ b/packages/i18n/src/locales/ro/translations.json @@ -0,0 +1,2458 @@ +{ + "sidebar": { + "projects": "Proiecte", + "pages": "Documentație", + "new_work_item": "Activitate nouă", + "home": "Acasă", + "your_work": "Munca ta", + "inbox": "Căsuță de mesaje", + "workspace": "Spațiu de lucru", + "views": "Perspective", + "analytics": "Statistici", + "work_items": "Activități", + "cycles": "Cicluri", + "modules": "Module", + "intake": "Cereri", + "drafts": "Schițe", + "favorites": "Favorite", + "pro": "Pro", + "upgrade": "Treci la versiunea superioară" + }, + + "auth": { + "common": { + "email": { + "label": "Email", + "placeholder": "nume@companie.ro", + "errors": { + "required": "Email-ul este obligatoriu", + "invalid": "Email-ul nu este valid" + } + }, + "password": { + "label": "Parolă", + "set_password": "Setează o parolă", + "placeholder": "Introdu parola", + "confirm_password": { + "label": "Confirmă parola", + "placeholder": "Confirmă parola" + }, + "current_password": { + "label": "Parola curentă" + }, + "new_password": { + "label": "Parolă nouă", + "placeholder": "Introdu parola nouă" + }, + "change_password": { + "label": { + "default": "Schimbă parola", + "submitting": "Se schimbă parola" + } + }, + "errors": { + "match": "Parolele nu se potrivesc", + "empty": "Te rugăm să introduci parola", + "length": "Parola trebuie să aibă mai mult de 8 caractere", + "strength": { + "weak": "Parola este slabă", + "strong": "Parola este puternică" + } + }, + "submit": "Setează parola", + "toast": { + "change_password": { + "success": { + "title": "Succes!", + "message": "Parola a fost schimbată cu succes." + }, + "error": { + "title": "Eroare!", + "message": "Ceva nu a funcționat. Te rugăm să încerci din nou." + } + } + } + }, + "unique_code": { + "label": "Cod unic", + "placeholder": "exemplu-cod-unic", + "paste_code": "Introdu codul trimis pe email", + "requesting_new_code": "Se solicită un cod nou", + "sending_code": "Se trimite codul" + }, + "already_have_an_account": "Ai deja un cont?", + "login": "Autentificare", + "create_account": "Creează un cont", + "new_to_plane": "Ești nou în Plane?", + "back_to_sign_in": "Înapoi la autentificare", + "resend_in": "Retrimite în {seconds} secunde", + "sign_in_with_unique_code": "Autentificare cu cod unic", + "forgot_password": "Ți-ai uitat parola?" + }, + "sign_up": { + "header": { + "label": "Creează un cont pentru a începe să-ți gestionezi activitatea împreună cu echipa ta.", + "step": { + "email": { + "header": "Înregistrare", + "sub_header": "" + }, + "password": { + "header": "Înregistrare", + "sub_header": "Înregistrează-te folosind o combinație email-parolă." + }, + "unique_code": { + "header": "Înregistrare", + "sub_header": "Înregistrează-te folosind un cod unic trimis pe adresa de email de mai sus." + } + } + }, + "errors": { + "password": { + "strength": "Setează o parolă puternică pentru a continua" + } + } + }, + "sign_in": { + "header": { + "label": "Autentifică-te pentru a începe să-ți gestionezi activitatea împreună cu echipa ta.", + "step": { + "email": { + "header": "Autentificare sau înregistrare", + "sub_header": "" + }, + "password": { + "header": "Autentificare sau înregistrare", + "sub_header": "Folosește combinația email-parolă pentru a te autentifica." + }, + "unique_code": { + "header": "Autentificare sau înregistrare", + "sub_header": "Autentifică-te folosind un cod unic trimis pe adresa de email de mai sus." + } + } + } + }, + "forgot_password": { + "title": "Resetează-ți parola", + "description": "Introdu adresa de email verificată a contului tău și îți vom trimite un link pentru resetarea parolei.", + "email_sent": "Am trimis link-ul de resetare pe adresa ta de email", + "send_reset_link": "Trimite link-ul de resetare", + "errors": { + "smtp_not_enabled": "Se pare că administratorul nu a activat SMTP, nu putem trimite link-ul de resetare a parolei" + }, + "toast": { + "success": { + "title": "Email trimis", + "message": "Verifică-ți căsuța de mesaje pentru link-ul de resetare a parolei. Dacă nu apare în câteva minute, verifică folderul de spam." + }, + "error": { + "title": "Eroare!", + "message": "Ceva nu a funcționat. Te rugăm să încerci din nou." + } + } + }, + "reset_password": { + "title": "Setează o parolă nouă", + "description": "Protejează-ți contul cu o parolă puternică" + }, + "set_password": { + "title": "Protejează-ți contul", + "description": "Setarea parolei te ajută să te autentifici în siguranță" + }, + "sign_out": { + "toast": { + "error": { + "title": "Eroare!", + "message": "Deconectarea a eșuat. Te rugăm să încerci din nou." + } + } + } + }, + + "submit": "Trimite", + "cancel": "Anulează", + "loading": "Se încarcă", + "error": "Eroare", + "success": "Succes", + "warning": "Avertisment", + "info": "Informații", + "close": "Închide", + "yes": "Da", + "no": "Nu", + "ok": "OK", + "name": "Nume", + "description": "Descriere", + "search": "Caută", + "add_member": "Adaugă membru", + "adding_members": "Se adaugă membri", + "remove_member": "Elimină membru", + "add_members": "Adaugă membri", + "adding_member": "Se adaugă membru", + "remove_members": "Elimină membri", + "add": "Adaugă", + "adding": "Se adaugă", + "remove": "Elimină", + "add_new": "Adaugă nou", + "remove_selected": "Elimină selecția", + "first_name": "Prenume", + "last_name": "Nume de familie", + "email": "Email", + "display_name": "Nume afișat", + "role": "Rol", + "timezone": "Fus orar", + "avatar": "Imagine de profil", + "cover_image": "Copertă", + "password": "Parolă", + "change_cover": "Schimbă coperta", + "language": "Limbă", + "saving": "Se salvează", + "save_changes": "Salvează modificările", + "deactivate_account": "Dezactivează contul", + "deactivate_account_description": "Când dezactivezi un cont, toate datele și activitățile din acel cont vor fi șterse permanent și nu pot fi recuperate.", + "profile_settings": "Setări profil", + "your_account": "Contul tău", + "security": "Securitate", + "activity": "Activitate", + "appearance": "Aspect", + "notifications": "Notificări", + "workspaces": "Spații de lucru", + "create_workspace": "Creează spațiu de lucru", + "invitations": "Invitații", + "summary": "Rezumat", + "assigned": "Responsabil", + "created": "Creat", + "subscribed": "Abonat", + "you_do_not_have_the_permission_to_access_this_page": "Nu ai permisiunea de a accesa această pagină.", + "something_went_wrong_please_try_again": "Ceva nu a funcționat. Te rugăm să încerci din nou.", + "load_more": "Încarcă mai mult", + "select_or_customize_your_interface_color_scheme": "Selectează sau personalizează schema de culori a interfeței.", + "theme": "Temă", + "system_preference": "Preferință de sistem", + "light": "Luminos", + "dark": "Întunecat", + "light_contrast": "Luminos - contrast ridicat", + "dark_contrast": "Întunecat - contrast ridicat", + "custom": "Temă personalizată", + "select_your_theme": "Selectează tema", + "customize_your_theme": "Personalizează tema", + "background_color": "Culoare fundal", + "text_color": "Culoare text", + "primary_color": "Culoare principală (temă)", + "sidebar_background_color": "Culoare fundal bară laterală", + "sidebar_text_color": "Culoare text bară laterală", + "set_theme": "Setează tema", + "enter_a_valid_hex_code_of_6_characters": "Introdu un cod hexadecimal valid de 6 caractere", + "background_color_is_required": "Culoarea de fundal este obligatorie", + "text_color_is_required": "Culoarea textului este obligatorie", + "primary_color_is_required": "Culoarea principală este obligatorie", + "sidebar_background_color_is_required": "Culoarea de fundal a barei laterale este obligatorie", + "sidebar_text_color_is_required": "Culoarea textului din bara laterală este obligatorie", + "updating_theme": "Se actualizează tema", + "theme_updated_successfully": "Tema a fost actualizată cu succes", + "failed_to_update_the_theme": "Eroare la actualizarea temei", + "email_notifications": "Notificări prin email", + "stay_in_the_loop_on_issues_you_are_subscribed_to_enable_this_to_get_notified": "Rămâi la curent cu activitățile la care ești abonat. Activează această opțiune pentru a primi notificări.", + "email_notification_setting_updated_successfully": "Setarea notificărilor prin email a fost actualizată cu succes", + "failed_to_update_email_notification_setting": "Eroare la actualizarea setării notificărilor prin email", + "notify_me_when": "Notifică-mă când", + "property_changes": "Se modifică proprietățile", + "property_changes_description": "Notifică-mă când proprietăți precum responsabilii, prioritatea, estimările sau altele se modifică.", + "state_change": "Se schimbă starea", + "state_change_description": "Notifică-mă când activitățile trec într-o stare diferită", + "issue_completed": "Activitate finalizată", + "issue_completed_description": "Notifică-mă doar când o activitate este finalizată", + "comments": "Comentarii", + "comments_description": "Notifică-mă când cineva lasă un comentariu la o activitate", + "mentions": "Mențiuni", + "mentions_description": "Notifică-mă doar când cineva mă menționează în comentarii sau descriere", + "old_password": "Parolă veche", + "general_settings": "Setări generale", + "sign_out": "Deconectează-te", + "signing_out": "Se deconectează", + "active_cycles": "Cicluri active", + "active_cycles_description": "Monitorizează ciclurile din proiecte, urmărește activitățile prioritare și focalizează-te pe ciclurile care necesită atenție.", + "on_demand_snapshots_of_all_your_cycles": "Instantanee la cerere ale tuturor ciclurilor tale", + "upgrade": "Treci la o versiune superioră", + "10000_feet_view": "Vedere de ansamblu asupra tuturor ciclurilor active.", + "10000_feet_view_description": "Vezi în ansamblu și simultan toate ciclurile active din proiectele tale, fără a naviga individual la fiecare ciclu.", + "get_snapshot_of_each_active_cycle": "Obține un instantaneu al fiecărui ciclu activ.", + "get_snapshot_of_each_active_cycle_description": "Urmărește statisticile generale pentru toate ciclurile active, vezi progresul și estimează volumul de muncă în raport cu termenele limită.", + "compare_burndowns": "Compară graficele de finalizare a activităților ", + "compare_burndowns_description": "Monitorizează performanța echipelor tale, analizând graficul de finalizare a activităților ale fiecărui ciclu.", + "quickly_see_make_or_break_issues": "Vezi rapid activitățile critice.", + "quickly_see_make_or_break_issues_description": "Previzualizează activitățile prioritare pentru fiecare ciclu în funcție de termene. Vizualizează-le pe toate dintr-un singur click.", + "zoom_into_cycles_that_need_attention": "Concentrează-te pe ciclurile care necesită atenție.", + "zoom_into_cycles_that_need_attention_description": "Analizează starea oricărui ciclu care nu corespunde așteptărilor, dintr-un singur click.", + "stay_ahead_of_blockers": "Anticipează blocajele.", + "stay_ahead_of_blockers_description": "Identifică provocările între proiecte și vezi dependențele între cicluri care altfel nu sunt evidente.", + "analytics": "Statistici", + "workspace_invites": "Invitațiile din spațiul de lucru", + "enter_god_mode": "Activează modul Dumnezeu", + "workspace_logo": "Sigla spațiului de lucru", + "new_issue": "Activitate nouă", + "your_work": "Munca ta", + "drafts": "Schițe", + "projects": "Proiecte", + "views": "Perspective", + "workspace": "Spațiu de lucru", + "archives": "Arhive", + "settings": "Setări", + "failed_to_move_favorite": "Nu s-a putut muta favorita", + "favorites": "Favorite", + "no_favorites_yet": "Nicio favorită încă", + "create_folder": "Creează dosar", + "new_folder": "Dosar nou", + "favorite_updated_successfully": "Favorita a fost actualizată cu succes", + "favorite_created_successfully": "Favorita a fost creată cu succes", + "folder_already_exists": "Dosarul există deja", + "folder_name_cannot_be_empty": "Numele dosarului nu poate fi gol", + "something_went_wrong": "Ceva nu a funcționat", + "failed_to_reorder_favorite": "Nu s-a putut reordona favorita", + "favorite_removed_successfully": "Favorita a fost eliminată cu succes", + "failed_to_create_favorite": "Nu s-a putut crea favorita", + "failed_to_rename_favorite": "Nu s-a putut redenumi favorita", + "project_link_copied_to_clipboard": "Link-ul proiectului a fost copiat în memoria temporară", + "link_copied": "Link copiat", + "add_project": "Adaugă proiect", + "create_project": "Creează proiect", + "failed_to_remove_project_from_favorites": "Nu s-a putut elimina proiectul din favorite. Încearcă din nou.", + "project_created_successfully": "Proiect creat cu succes", + "project_created_successfully_description": "Proiect creat cu succes. Poți începe să adaugi activități în el.", + "project_cover_image_alt": "Coperta proiectului", + "name_is_required": "Numele este obligatoriu", + "title_should_be_less_than_255_characters": "Titlul trebuie să conțină mai puțin de 255 de caractere", + "project_name": "Numele proiectului", + "project_id_must_be_at_least_1_character": "ID-ul proiectului trebuie să conțină cel puțin 1 caracter", + "project_id_must_be_at_most_5_characters": "ID-ul proiectului trebuie să conțină cel mult 5 caractere", + "project_id": "ID-ul Proiectului", + "project_id_tooltip_content": "Te ajută să identifici unic activitățile din proiect. Maxim 5 caractere.", + "description_placeholder": "Descriere", + "only_alphanumeric_non_latin_characters_allowed": "Sunt permise doar caractere alfanumerice și non-latine.", + "project_id_is_required": "ID-ul proiectului este obligatoriu", + "project_id_allowed_char": "Sunt permise doar caractere alfanumerice și non-latine.", + "project_id_min_char": "ID-ul proiectului trebuie să aibă cel puțin 1 caracter", + "project_id_max_char": "ID-ul proiectului trebuie să aibă cel mult 5 caractere", + "project_description_placeholder": "Introdu descrierea proiectului", + "select_network": "Selectează rețeaua", + "lead": "Lider", + "date_range": "Interval de date", + "private": "Privat", + "public": "Public", + "accessible_only_by_invite": "Accesibil doar prin invitație", + "anyone_in_the_workspace_except_guests_can_join": "Oricine din spațiul de lucru, cu excepția celor de tip Invitat, se poate alătura", + "creating": "Se creează", + "creating_project": "Se creează proiectul", + "adding_project_to_favorites": "Se adaugă proiectul la favorite", + "project_added_to_favorites": "Proiectul a fost adăugat la favorite", + "couldnt_add_the_project_to_favorites": "Nu s-a putut adăuga proiectul la favorite. Încearcă din nou.", + "removing_project_from_favorites": "Se elimină proiectul din favorite", + "project_removed_from_favorites": "Proiectul a fost eliminat din favorite", + "couldnt_remove_the_project_from_favorites": "Nu s-a putut elimina proiectul din favorite. Încearcă din nou.", + "add_to_favorites": "Adaugă la favorite", + "remove_from_favorites": "Elimină din favorite", + "publish_project": "Publică proiectul", + "publish": "Publică", + "copy_link": "Copiază link-ul", + "leave_project": "Părăsește proiectul", + "join_the_project_to_rearrange": "Alătură-te proiectului pentru a rearanja", + "drag_to_rearrange": "Trage pentru a rearanja", + "congrats": "Felicitări!", + "open_project": "Deschide proiectul", + "issues": "Activități", + "cycles": "Cicluri", + "modules": "Module", + "pages": "Documentație", + "intake": "Cereri", + "time_tracking": "Monitorizare timp", + "work_management": "Gestionare muncă", + "projects_and_issues": "Proiecte și activități", + "projects_and_issues_description": "Activează sau dezactivează aceste opțiuni pentru proiect.", + "cycles_description": "Împarte munca în unități de timp pentru fiecare proiect și modifică frecvența după cum consideri.", + "modules_description": "Grupează munca în sub-proiecte cu lideri și responsabili proprii.", + "views_description": "Salvează sortările, filtrele și opțiunile de afișare pentru mai târziu sau pentru a le distribui.", + "pages_description": "Scrie orice, cum știi tu să scrii.", + "intake_description": "Rămâi la curent cu activitățile la care ești abonat. Activează pentru notificări.", + "time_tracking_description": "Urmărește timpul petrecut pe activități și proiecte.", + "work_management_description": "Gestionează-ți munca și proiectele cu ușurință.", + "documentation": "Documentație", + "message_support": "Trimite mesaj la suport", + "contact_sales": "Contactează vânzările", + "hyper_mode": "Mod Hyper", + "keyboard_shortcuts": "Scurtături tastatură", + "whats_new": "Ce e nou?", + "version": "Versiune", + "we_are_having_trouble_fetching_the_updates": "Avem probleme în a prelua actualizările.", + "our_changelogs": "jurnalele noastre de modificări", + "for_the_latest_updates": "pentru cele mai recente actualizări.", + "please_visit": "Te rugăm să vizitezi", + "docs": "Documentație", + "full_changelog": "Jurnal complet al modificărilor", + "support": "Suport", + "discord": "Discord", + "powered_by_plane_pages": "Oferit de Plane Documentație", + "please_select_at_least_one_invitation": "Te rugăm să selectezi cel puțin o invitație.", + "please_select_at_least_one_invitation_description": "Te rugăm să selectezi cel puțin o invitație pentru a te alătura spațiului de lucru.", + "we_see_that_someone_has_invited_you_to_join_a_workspace": "Se pare că cineva te-a invitat să te alături unui spațiu de lucru", + "join_a_workspace": "Alătură-te unui spațiu de lucru", + "we_see_that_someone_has_invited_you_to_join_a_workspace_description": "Se pare că cineva te-a invitat să te alături unui spațiu de lucru", + "join_a_workspace_description": "Alătură-te unui spațiu de lucru", + "accept_and_join": "Acceptă și alătură-te", + "go_home": "Mergi la început", + "no_pending_invites": "Nicio invitație în așteptare", + "you_can_see_here_if_someone_invites_you_to_a_workspace": "Aici vei vedea dacă cineva te-a invitat într-un spațiu de lucru", + "back_to_home": "Înapoi la început", + "workspace_name": "nume-spațiu-de-lucru", + "deactivate_your_account": "Dezactivează-ți contul", + "deactivate_your_account_description": "Odată dezactivat, nu vei mai putea primi activități sau fi taxat pentru spațiul tău de lucru. Pentru a-ți reactiva contul, vei avea nevoie de o invitație către un spațiu de lucru la această adresă de email.", + "deactivating": "Se dezactivează", + "confirm": "Confirmă", + "confirming": "Se confirmă", + "draft_created": "Schiță creată", + "issue_created_successfully": "Activitate creată cu succes", + "draft_creation_failed": "Crearea schiței a eșuat", + "issue_creation_failed": "Crearea activității a eșuat", + "draft_issue": "Schiță activitate", + "issue_updated_successfully": "Activitate actualizată cu succes", + "issue_could_not_be_updated": "Activitatea nu a putut fi actualizată", + "create_a_draft": "Creează o schiță", + "save_to_drafts": "Salvează în schițe", + "save": "Salvează", + "update": "Actualizează", + "updating": "Se actualizează", + "create_new_issue": "Creează activate nouă", + "editor_is_not_ready_to_discard_changes": "Editorul nu este pregătit să renunțe la modificări", + "failed_to_move_issue_to_project": "Nu s-a putut muta activitatea în proiect", + "create_more": "Creează mai multe", + "add_to_project": "Adaugă la proiect", + "discard": "Renunță", + "duplicate_issue_found": "Activitate duplicată găsită", + "duplicate_issues_found": "Activități duplicate găsite", + "no_matching_results": "Nu există rezultate potrivite", + "title_is_required": "Titlul este obligatoriu", + "title": "Titlu", + "state": "Stare", + "priority": "Prioritate", + "none": "Niciuna", + "urgent": "Urgentă", + "high": "Importantă", + "medium": "Medie", + "low": "Scăzută", + "members": "Membri", + "assignee": "Responsabil", + "assignees": "Responsabili", + "you": "Tu", + "labels": "Etichete", + "create_new_label": "Creează etichetă nouă", + "start_date": "Data de început", + "end_date": "Data de sfârșit", + "due_date": "Data limită", + "estimate": "Estimare", + "change_parent_issue": "Schimbă activitatea părinte", + "remove_parent_issue": "Elimină activitatea părinte", + "add_parent": "Adaugă părinte", + "loading_members": "Se încarcă membrii", + "view_link_copied_to_clipboard": "Link-ul de perspectivă a fost copiat în memoria temporară.", + "required": "Obligatoriu", + "optional": "Opțional", + "Cancel": "Anulează", + "edit": "Editează", + "archive": "Arhivează", + "restore": "Restaurează", + "open_in_new_tab": "Deschide într-un nou tab", + "delete": "Șterge", + "deleting": "Se șterge", + "make_a_copy": "Creează o copie", + "move_to_project": "Mută în proiect", + "good": "Bună", + "morning": "dimineața", + "afternoon": "după-amiaza", + "evening": "seara", + "show_all": "Arată tot", + "show_less": "Arată mai puțin", + "no_data_yet": "Nicio dată încă", + "syncing": "Se sincronizează", + "add_work_item": "Adaugă activitate", + "advanced_description_placeholder": "Apasă '/' pentru comenzi", + "create_work_item": "Creează activitate", + "attachments": "Atașamente", + "declining": "Se refuză", + "declined": "Refuzat", + "decline": "Refuză", + "unassigned": "Fără responsabil", + "work_items": "Activități", + "add_link": "Adaugă link", + "points": "Puncte", + "no_assignee": "Fără responsabil", + "no_assignees_yet": "Niciun responsabil încă", + "no_labels_yet": "Nicio etichetă încă", + "ideal": "Ideal", + "current": "Curent", + "no_matching_members": "Niciun membru potrivit", + "leaving": "Se părăsește", + "removing": "Se elimină", + "leave": "Părăsește", + "refresh": "Reîncarcă", + "refreshing": "Se reîncarcă", + "refresh_status": "Reîncarcă statusul", + "prev": "Înapoi", + "next": "Înainte", + "re_generating": "Se regenerează", + "re_generate": "Regenerează", + "re_generate_key": "Regenerează cheia", + "export": "Exportă", + "member": "{count, plural, one{# membru} other{# membri}}", + "new_password_must_be_different_from_old_password": "Parola nouă trebuie să fie diferită de parola veche", + + "project_view": { + "sort_by": { + "created_at": "Creat la", + "updated_at": "Actualizat la", + "name": "Nume" + } + }, + + "toast": { + "success": "Succes!", + "error": "Eroare!" + }, + + "links": { + "toasts": { + "created": { + "title": "Link creat", + "message": "Link-ul a fost creat cu succes" + }, + "not_created": { + "title": "Link-ul nu a fost creat", + "message": "Link-ul nu a putut fi creat" + }, + "updated": { + "title": "Link actualizat", + "message": "Link-ul a fost actualizat cu succes" + }, + "not_updated": { + "title": "Link-ul nu a fost actualizat", + "message": "Link-ul nu a putut fi actualizat" + }, + "removed": { + "title": "Link eliminat", + "message": "Link-ul a fost eliminat cu succes" + }, + "not_removed": { + "title": "Link-ul nu a fost eliminat", + "message": "Link-ul nu a putut fi eliminat" + } + } + }, + + "home": { + "empty": { + "quickstart_guide": "Ghid de pornire rapidă", + "not_right_now": "Nu acum", + "create_project": { + "title": "Creează un proiect", + "description": "Majoritatea lucrurilor încep cu un proiect în Plane.", + "cta": "Începe acum" + }, + "invite_team": { + "title": "Invită-ți echipa", + "description": "Construiește, livrează și gestionează împreună cu colegii.", + "cta": "Invită-i" + }, + "configure_workspace": { + "title": "Configurează-ți spațiul de lucru.", + "description": "Activează sau dezactivează opțiuni sau mergi mai departe.", + "cta": "Configurează acest spațiu de lucru" + }, + "personalize_account": { + "title": "Personalizează Plane.", + "description": "Alege-ți poza de profil, culorile și multe altele.", + "cta": "Personalizează acum" + }, + "widgets": { + "title": "Este liniște fără mini-aplicații, activează-le", + "description": "Se pare că toate mini-aplicațiile tale sunt dezactivate. Activează-le acum pentru a-ți îmbunătăți experiența!", + "primary_button": { + "text": "Gestionează mini-aplicațiile" + } + } + }, + "quick_links": { + "empty": "Salvează link-uri către elementele utile pe care vrei să le ai la îndemână.", + "add": "Adaugă link rapid", + "title": "Link rapid", + "title_plural": "Linkuri rapide" + }, + "recents": { + "title": "Recente", + "empty": { + "project": "Proiectele vizitate recent vor apărea aici.", + "page": "Documentele din Documentație vizitate recent vor apărea aici.", + "issue": "Activitățile vizitate recent vor apărea aici.", + "default": "Nu ai nimic recent încă." + }, + "filters": { + "all": "Toate", + "projects": "Proiecte", + "pages": "Documentație", + "issues": "Activități" + } + }, + "new_at_plane": { + "title": "Noutăți în Plane" + }, + "quick_tutorial": { + "title": "Tutorial rapid" + }, + "widget": { + "reordered_successfully": "Mini-aplicație reordonată cu succes.", + "reordering_failed": "Eroare la reordonarea mini-aplicației." + }, + "manage_widgets": "Gestionează mini-aplicațiile", + "title": "Acasă", + "star_us_on_github": "Dă-ne o stea pe GitHub" + }, + + "link": { + "modal": { + "url": { + "text": "URL", + "required": "URL-ul nu este valid", + "placeholder": "Tastează sau lipește un URL" + }, + "title": { + "text": "Titlu afișat", + "placeholder": "Cum vrei să se vadă acest link" + } + } + }, + + "common": { + "all": "Toate", + "states": "Stări", + "state": "Stare", + "state_groups": "Grupuri de stări", + "state_group": "Grup de stare", + "priorities": "Priorități", + "priority": "Prioritate", + "team_project": "Proiect de echipă", + "project": "Proiect", + "cycle": "Ciclu", + "cycles": "Cicluri", + "module": "Modul", + "modules": "Module", + "labels": "Etichete", + "label": "Etichetă", + "assignees": "Responsabili", + "assignee": "Responsabil", + "created_by": "Creat de", + "none": "Niciuna", + "link": "Link", + "estimates": "Estimări", + "estimate": "Estimare", + "created_at": "Creat la", + "completed_at": "Finalizat la", + "layout": "Aspect", + "filters": "Filtre", + "display": "Afișare", + "load_more": "Încarcă mai mult", + "activity": "Activitate", + "analytics": "Analitice", + "dates": "Date", + "success": "Succes!", + "something_went_wrong": "Ceva a mers greșit", + "error": { + "label": "Eroare!", + "message": "A apărut o eroare. Te rugăm să încerci din nou." + }, + "group_by": "Grupează după", + "epic": "Sarcină majoră", + "epics": "Sarcini majore", + "work_item": "Activitate", + "work_items": "Activități", + "sub_work_item": "Sub-activitate", + "add": "Adaugă", + "warning": "Avertisment", + "updating": "Se actualizează", + "adding": "Se adaugă", + "update": "Actualizează", + "creating": "Se creează", + "create": "Creează", + "cancel": "Anulează", + "description": "Descriere", + "title": "Titlu", + "attachment": "Atașament", + "general": "General", + "features": "Funcționalități", + "automation": "Automatizare", + "project_name": "Nume proiect", + "project_id": "ID Proiect", + "project_timezone": "Fus orar proiect", + "created_on": "Creat la", + "update_project": "Actualizează proiectul", + "identifier_already_exists": "Identificatorul există deja", + "add_more": "Adaugă mai mult", + "defaults": "Implicit", + "add_label": "Adaugă etichetă", + "customize_time_range": "Personalizează intervalul de timp", + "loading": "Se încarcă", + "attachments": "Atașamente", + "property": "Proprietate", + "properties": "Proprietăți", + "parent": "Părinte", + "page": "Document", + "remove": "Elimină", + "archiving": "Se arhivează", + "archive": "Arhivează", + "access": { + "public": "Public", + "private": "Privat" + }, + "done": "Gata", + "sub_work_items": "Sub-activități", + "comment": "Comentariu", + "workspace_level": "La nivel de spațiu de lucru", + "order_by": { + "label": "Ordonează după", + "manual": "Manual", + "last_created": "Ultima creată", + "last_updated": "Ultima actualizată", + "start_date": "Data de început", + "due_date": "Data limită", + "asc": "Crescător", + "desc": "Descrescător", + "updated_on": "Actualizat la" + }, + "sort": { + "asc": "Crescător", + "desc": "Descrescător", + "created_on": "Creată la", + "updated_on": "Actualizată la" + }, + "comments": "Comentarii", + "updates": "Actualizări", + "clear_all": "Șterge tot", + "copied": "Copiat!", + "link_copied": "Link copiat!", + "link_copied_to_clipboard": "Link-ul a fost copiat în memoria temporară", + "copied_to_clipboard": "Link-ul activității copiat în memoria temporară", + "is_copied_to_clipboard": "Activitatea a fost copiată în memoria temporară", + "no_links_added_yet": "Niciun link adăugat încă", + "add_link": "Adaugă link", + "links": "Linkuri", + "go_to_workspace": "Mergi la spațiul de lucru", + "progress": "Progres", + "optional": "Opțional", + "join": "Alătură-te", + "go_back": "Înapoi", + "continue": "Continuă", + "resend": "Retrimite", + "relations": "Relații", + "errors": { + "default": { + "title": "Eroare!", + "message": "Ceva a funcționat greșit. Te rugăm să încerci din nou." + }, + "required": "Acest câmp este obligatoriu", + "entity_required": "{entity} este obligatoriu" + }, + "update_link": "Actualizează link-ul", + "attach": "Atașează", + "create_new": "Creează nou", + "add_existing": "Adaugă existent", + "type_or_paste_a_url": "Tastează sau lipește un URL", + "url_is_invalid": "URL-ul nu este valid", + "display_title": "Titlu afișat", + "link_title_placeholder": "Cum vrei să se vadă acest link", + "url": "URL", + "side_peek": "Previzualizare laterală", + "modal": "Fereastră modală", + "full_screen": "Ecran complet", + "close_peek_view": "Închide previzualizarea", + "toggle_peek_view_layout": "Comută aspectul previzualizării", + "options": "Opțiuni", + "duration": "Durată", + "today": "Astăzi", + "week": "Săptămână", + "month": "Lună", + "quarter": "Trimestru", + "press_for_commands": "Apasă '/' pentru comenzi", + "click_to_add_description": "Apasă pentru a adăuga descriere", + "search": { + "label": "Caută", + "placeholder": "Tastează pentru a căuta", + "no_matches_found": "Nu s-au găsit rezultate", + "no_matching_results": "Nicio potrivire găsită" + }, + "actions": { + "edit": "Editează", + "make_a_copy": "Fă o copie", + "open_in_new_tab": "Deschide într-un nou tab", + "copy_link": "Copiază link-ul", + "archive": "Arhivează", + "restore": "Restaurează", + "delete": "Șterge", + "remove_relation": "Elimină relația", + "subscribe": "Abonează-te", + "unsubscribe": "Dezabonează-te", + "clear_sorting": "Șterge sortarea", + "show_weekends": "Arată sfârșiturile de săptămână", + "enable": "Activează", + "disable": "Dezactivează" + }, + "name": "Nume", + "discard": "Renunță", + "confirm": "Confirmă", + "confirming": "Se confirmă", + "read_the_docs": "Citește documentația", + "default": "Implicit", + "active": "Activ", + "enabled": "Activat", + "disabled": "Dezactivat", + "mandate": "Împuternicire", + "mandatory": "Obligatoriu", + "yes": "Da", + "no": "Nu", + "please_wait": "Te rog așteaptă", + "enabling": "Se activează", + "disabling": "Se dezactivează", + "beta": "Testare", + "or": "sau", + "next": "Înainte", + "back": "Înapoi", + "cancelling": "Se anulează", + "configuring": "Se configurează", + "clear": "Șterge", + "import": "Importă", + "connect": "Conectează", + "authorizing": "Se autorizează", + "processing": "Se procesează", + "no_data_available": "Nicio dată disponibilă", + "from": "de la {name}", + "authenticated": "Autentificat", + "select": "Selectează", + "upgrade": "Treci la o versiune superioră", + "add_seats": "Adaugă locuri", + "projects": "Proiecte", + "workspace": "Spațiu de lucru", + "workspaces": "Spații de lucru", + "team": "Echipă", + "teams": "Echipe", + "entity": "Entitate", + "entities": "Entități", + "task": "Sarcină", + "tasks": "Sarcini", + "section": "Secțiune", + "sections": "Secțiuni", + "edit": "Editează", + "connecting": "Se conectează", + "connected": "Conectat", + "disconnect": "Deconectează", + "disconnecting": "Se deconectează", + "installing": "Se instalează", + "install": "Instalează", + "reset": "Resetează", + "live": "În direct", + "change_history": "Istoric modificări", + "coming_soon": "În curând", + "members": "Membri", + "you": "Tu", + "upgrade_cta": { + "higher_subscription": "Treci la un abonament superior", + "talk_to_sales": "Discută cu vânzările" + }, + "category": "Categorie", + "categories": "Categorii", + "saving": "Se salvează", + "save_changes": "Salvează modificările", + "delete": "Șterge", + "deleting": "Se șterge", + "pending": "În așteptare", + "invite": "Invită", + "view": "Vizualizează", + "deactivated_user": "Utilizator dezactivat" + }, + + "chart": { + "x_axis": "axa-X", + "y_axis": "axa-Y", + "metric": "Indicator" + }, + + "form": { + "title": { + "required": "Titlul este obligatoriu", + "max_length": "Titlul trebuie să conțină mai puțin de {length} caractere" + } + }, + + "entity": { + "grouping_title": "Grupare {entity}", + "priority": "Prioritate {entity}", + "all": "Toate {entity}", + "drop_here_to_move": "Trage aici pentru a muta {entity}", + "delete": { + "label": "Șterge {entity}", + "success": "{entity} a fost ștearsă cu succes", + "failed": "Ștergerea {entity} a eșuat" + }, + "update": { + "failed": "Actualizarea {entity} a eșuat", + "success": "{entity} a fost actualizată cu succes" + }, + "link_copied_to_clipboard": "Link-ul {entity} a fost copiat în memoria temporară", + "fetch": { + "failed": "Eroare la preluarea {entity}" + }, + "add": { + "success": "{entity} a fost adăugată cu succes", + "failed": "Eroare la adăugarea {entity}" + } + }, + + "epic": { + "all": "Toate Sarcinile majore", + "label": "{count, plural, one {Sarcină majoră} other {Sarcini majore}}", + "new": "Sarcină majoră", + "adding": "Se adaugă sarcină majoră", + "create": { + "success": "Sarcină majoră creată cu succes" + }, + "add": { + "press_enter": "Apasă 'Enter' pentru a adăuga o altă sarcină majoră", + "label": "Adaugă sarcină majoră" + }, + "title": { + "label": "Titlu sarcină majoră", + "required": "Titlul sarcinii majore este obligatoriu." + } + }, + + "issue": { + "label": "{count, plural, one {Activitate} other {Activități}}", + "all": "Toate activitățile", + "edit": "Editează activitatea", + "title": { + "label": "Titlul activității", + "required": "Titlul activității este obligatoriu." + }, + "add": { + "press_enter": "Apasă 'Enter' pentru a adăuga o altă activitate", + "label": "Adaugă activitate", + "cycle": { + "failed": "Activitatea nu a putut fi adăugată în ciclu. Te rugăm să încerci din nou.", + "success": "{count, plural, one {Activitate} other {Activități}} adăugată(e) în ciclu cu succes.", + "loading": "Se adaugă {count, plural, one {activitate} other {activități}} în ciclu" + }, + "assignee": "Adaugă responsabili", + "start_date": "Adaugă data de început", + "due_date": "Adaugă termenul limită", + "parent": "Adaugă activitate părinte", + "sub_issue": "Adaugă sub-activitate", + "relation": "Adaugă relație", + "link": "Adaugă link", + "existing": "Adaugă activitate existentă" + }, + "remove": { + "label": "Elimină activitatea", + "cycle": { + "loading": "Se elimină activitatea din ciclu", + "success": "Activitatea a fost eliminată din ciclu cu succes.", + "failed": "Activitatea nu a putut fi eliminată din ciclu. Te rugăm să încerci din nou." + }, + "module": { + "loading": "Se elimină activitatea din modul", + "success": "Activitatea a fost eliminată din modul cu succes.", + "failed": "Activitatea nu a putut fi eliminată din modul. Te rugăm să încerci din nou." + }, + "parent": { + "label": "Elimină activitatea părinte" + } + }, + "new": "Activitate nouă", + "adding": "Se adaugă activitatea", + "create": { + "success": "Activitatea a fost creată cu succes" + }, + "priority": { + "urgent": "Urgentă", + "high": "Ridicată", + "medium": "Medie", + "low": "Scăzută" + }, + "display": { + "properties": { + "label": "Afișează proprietățile", + "id": "ID", + "issue_type": "Tipul activității", + "sub_issue_count": "Număr de sub-activități", + "attachment_count": "Număr de atașamente", + "created_on": "Creată la", + "sub_issue": "Sub-activitate", + "work_item_count": "Număr de activități" + }, + "extra": { + "show_sub_issues": "Afișează sub-activitățile", + "show_empty_groups": "Afișează grupurile goale" + } + }, + "layouts": { + "ordered_by_label": "Această vizualizare este ordonată după", + "list": "Listă", + "kanban": "Tablă", + "calendar": "Calendar", + "spreadsheet": "Tabel", + "gantt": "Cronologic", + "title": { + "list": "Vizualizare tip Listă", + "kanban": "Vizualizare tip Tablă", + "calendar": "Vizualizare tip Calendar", + "spreadsheet": "Vizualizare tip Tabel", + "gantt": "Vizualizare tip Cronologic" + } + }, + "states": { + "active": "Active", + "backlog": "Restante" + }, + "comments": { + "placeholder": "Adaugă comentariu", + "switch": { + "private": "Comută pe comentariu privat", + "public": "Comută pe comentariu public" + }, + "create": { + "success": "Comentariu adăugat cu succes", + "error": "Adăugarea comentariului a eșuat. Te rugăm să încerci mai târziu." + }, + "update": { + "success": "Comentariu actualizat cu succes", + "error": "Actualizarea comentariului a eșuat. Te rugăm să încerci mai târziu." + }, + "remove": { + "success": "Comentariu șters cu succes", + "error": "Ștergerea comentariului a eșuat. Te rugăm să încerci mai târziu." + }, + "upload": { + "error": "Încărcarea fișierului a eșuat. Te rugăm să încerci mai târziu." + } + }, + "empty_state": { + "issue_detail": { + "title": "Activitatea nu există", + "description": "Activitatea căutată nu există, a fost arhivată sau ștearsă.", + "primary_button": { + "text": "Vezi alte activități" + } + } + }, + "sibling": { + "label": "Activități înrudite" + }, + "archive": { + "description": "Doar activitățile finalizate sau anulate\npot fi arhivate", + "label": "Arhivează activitatea", + "confirm_message": "Ești sigur că vrei să arhivezi această activitate? Toate activitățile arhivate pot fi restaurate ulterior.", + "success": { + "label": "Arhivare reușită", + "message": "Arhivele tale pot fi găsite în arhiva proiectului." + }, + "failed": { + "message": "Activitatea nu a putut fi arhivată. Te rugăm să încerci din nou." + } + }, + "restore": { + "success": { + "title": "Restaurare reușită", + "message": "Activitatea poate fi găsită în lista de activități ale proiectului." + }, + "failed": { + "message": "Activitatea nu a putut fi restaurată. Te rugăm să încerci din nou." + } + }, + "relation": { + "relates_to": "Este legată de", + "duplicate": "Duplicată a", + "blocked_by": "Blocată de", + "blocking": "Blochează" + }, + "copy_link": "Copiază link-ul activității", + "delete": { + "label": "Șterge activitatea", + "error": "Eroare la ștergerea activității" + }, + "subscription": { + "actions": { + "subscribed": "Abonarea la activitate realizată cu succes", + "unsubscribed": "Dezabonarea de la activitate realizată cu succes" + } + }, + "select": { + "error": "Selectează cel puțin o activitate", + "empty": "Nicio activitate selectată", + "add_selected": "Adaugă activitățile selectate" + }, + "open_in_full_screen": "Deschide activitatea pe tot ecranul" + }, + + "attachment": { + "error": "Fișierul nu a putut fi atașat. Încearcă să încarci din nou.", + "only_one_file_allowed": "Se poate încărca doar un fișier o dată.", + "file_size_limit": "Fișierul trebuie să aibă {size}MB sau mai puțin.", + "drag_and_drop": "Trage și plasează oriunde pentru a încărca", + "delete": "Șterge atașamentul" + }, + + "label": { + "select": "Selectează eticheta", + "create": { + "success": "Etichetă creată cu succes", + "failed": "Crearea etichetei a eșuat", + "already_exists": "Eticheta există deja", + "type": "Tastează pentru a adăuga o etichetă nouă" + } + }, + + "sub_work_item": { + "update": { + "success": "Sub-activitatea a fost actualizată cu succes", + "error": "Eroare la actualizarea sub-activității" + }, + "remove": { + "success": "Sub-activitatea a fost eliminată cu succes", + "error": "Eroare la eliminarea sub-activității" + } + }, + + "view": { + "label": "{count, plural, one {Perspectivă} other {Perspective}}", + "create": { + "label": "Creează perspectivă" + }, + "update": { + "label": "Actualizează perspectiva" + } + }, + + "inbox_issue": { + "status": { + "pending": { + "title": "În așteptare", + "description": "În așteptare" + }, + "declined": { + "title": "Respinse", + "description": "Respinse" + }, + "snoozed": { + "title": "Amânate", + "description": "{days, plural, one{# zi} other{# zile}} rămase" + }, + "accepted": { + "title": "Acceptate", + "description": "Acceptate" + }, + "duplicate": { + "title": "Duplicate", + "description": "Duplicate" + } + }, + "modals": { + "decline": { + "title": "Respinge activitatea", + "content": "Ești sigur că vrei să respingi activitatea {value}?" + }, + "delete": { + "title": "Șterge activitatea", + "content": "Ești sigur că vrei să ștergi activitatea {value}?", + "success": "Activitatea a fost ștersă cu succes" + } + }, + "errors": { + "snooze_permission": "Doar administratorii proiectului pot amâna/dezactiva amânarea activităților", + "accept_permission": "Doar administratorii proiectului pot accepta activități", + "decline_permission": "Doar administratorii proiectului pot respinge activități" + }, + "actions": { + "accept": "Acceptă", + "decline": "Respinge", + "snooze": "Amână", + "unsnooze": "Dezactivează amânarea", + "copy": "Copiază link-ul activității", + "delete": "Șterge", + "open": "Deschide activitatea", + "mark_as_duplicate": "Marchează ca duplicat", + "move": "Mută {value} în activitățile proiectului" + }, + "source": { + "in-app": "în aplicație" + }, + "order_by": { + "created_at": "Creată la", + "updated_at": "Actualizată la", + "id": "ID" + }, + "label": "Cereri", + "page_label": "{workspace} - Cereri", + "modal": { + "title": "Creează o cerere în Cereri" + }, + "tabs": { + "open": "Deschise", + "closed": "Închise" + }, + "empty_state": { + "sidebar_open_tab": { + "title": "Nicio cerere deschisă", + "description": "Găsește aici cererile primite. Creează o cerere nouă." + }, + "sidebar_closed_tab": { + "title": "Nicio cerere închisă", + "description": "Toate cererile, fie acceptate, fie respinse, pot fi găsite aici." + }, + "sidebar_filter": { + "title": "Nicio cerere gasită", + "description": "Nicio cerere nu se potrivește cu filtrul aplicat în Cereri. Creează o cerere nouă." + }, + "detail": { + "title": "Selectează o cerere pentru a-i vedea detaliile." + } + } + }, + + "workspace_creation": { + "heading": "Creează spațiul tău de lucru", + "subheading": "Pentru a începe să folosești Plane, trebuie să creezi sau să te alături unui spațiu de lucru.", + "form": { + "name": { + "label": "Denumește-ți spațiul de lucru", + "placeholder": "Cel mai bine este să alegi ceva familiar și ușor de recunoscut." + }, + "url": { + "label": "Setează URL-ul spațiului de lucru", + "placeholder": "Tastează sau lipește un URL", + "edit_slug": "Poți edita doar identificatorul URL-ului" + }, + "organization_size": { + "label": "Câți oameni vor folosi acest spațiu de lucru?", + "placeholder": "Selectează un interval" + } + }, + "errors": { + "creation_disabled": { + "title": "Doar administratorul instanței poate crea spații de lucru", + "description": "Dacă știi adresa de email a administratorului instanței tale, apasă butonul de mai jos pentru a-l contacta.", + "request_button": "Solicită administratorul instanței" + }, + "validation": { + "name_alphanumeric": "Numele spațiilor de lucru pot conține doar (' '), ('-'), ('_') și caractere alfanumerice.", + "name_length": "Limitează numele la 80 de caractere.", + "url_alphanumeric": "URL-urile pot conține doar ('-') și caractere alfanumerice.", + "url_length": "Limitează URL-ul la 48 de caractere.", + "url_already_taken": "URL-ul spațiului de lucru este deja folosit!" + } + }, + "request_email": { + "subject": "Solicitare creare spațiu de lucru nou", + "body": "Salut administrator(i) instanței,\n\nVă rog să creați un nou spațiu de lucru cu URL-ul [/workspace-name] pentru [scopul creării spațiului de lucru].\n\nMulțumesc,\n{firstName} {lastName}\n{email}" + }, + "button": { + "default": "Creează spațiu de lucru", + "loading": "Se creează spațiul de lucru" + }, + "toast": { + "success": { + "title": "Succes", + "message": "Spațiul de lucru a fost creat cu succes" + }, + "error": { + "title": "Eroare", + "message": "Spațiul de lucru nu a putut fi creat. Te rugăm să încerci din nou." + } + } + }, + + "workspace_dashboard": { + "empty_state": { + "general": { + "title": "Prezentare generală a proiectelor, activităților și statisticilor tale", + "description": "Bine ai venit în Plane, suntem încântați să te avem aici. Creează primul tău proiect și urmărește activitățile, iar această pagină se va transforma într-un spațiu care te ajută să progresezi. Administratorii vor vedea și elementele care ajută echipa lor să progreseze.", + "primary_button": { + "text": "Creează primul tău proiect", + "comic": { + "title": "Totul începe cu un proiect în Plane", + "description": "Un proiect poate fi planul de dezvoltare a unui produs, o campanie de marketing sau lansarea unei noi mașini." + } + } + } + } + }, + + "workspace_analytics": { + "label": "Statistici", + "page_label": "{workspace} - Statistici", + "open_tasks": "Total activități deschise", + "error": "A apărut o eroare la preluarea datelor.", + "work_items_closed_in": "Activități finalizate în", + "selected_projects": "Proiecte selectate", + "total_members": "Total membri", + "total_cycles": "Total cicluri", + "total_modules": "Total module", + "pending_work_items": { + "title": "Activități în așteptare", + "empty_state": "Aici apare analiza activităților în așteptare atribuite colegilor." + }, + "work_items_closed_in_a_year": { + "title": "Activități finalizate într-un an", + "empty_state": "Închide activități pentru a vedea statisticile sub formă de grafic." + }, + "most_work_items_created": { + "title": "Cele mai multe activități create", + "empty_state": "Aici vor apărea colegii și numărul de activități create de aceștia." + }, + "most_work_items_closed": { + "title": "Cele mai multe activități finalizate", + "empty_state": "Aici vor apărea colegii și numărul de activități finalizate de aceștia." + }, + "tabs": { + "scope_and_demand": "Activități asumate și cerere", + "custom": "Analitice personalizate" + }, + "empty_state": { + "general": { + "title": "Urmărește progresul, activitățile și alocările. Observă tendințele, elimină blocajele și accelerează munca", + "description": "Vezi raportul dintre activitățile asumate și cerere, estimările și eventualele extinderi neplanificate ale activităților asumate. Obține performanța pe membri și echipe și asigură-te că proiectul tău se încadrează în timp.", + "primary_button": { + "text": "Începe primul tău proiect", + "comic": { + "title": "Statisticile funcționează cel mai bine cu Cicluri + Module", + "description": "Mai întâi, încadrează-ți activitățile în Cicluri și, dacă poți, grupează-le pe cele care se întind pe mai multe cicluri în Module. Le găsești în meniul din stânga." + } + } + } + } + }, + + "workspace_projects": { + "label": "{count, plural, one {Proiect} other {Proiecte}}", + "create": { + "label": "Adaugă proiect" + }, + "network": { + "label": "Rețea", + "private": { + "title": "Privat", + "description": "Accesibil doar pe bază de invitație" + }, + "public": { + "title": "Public", + "description": "Oricine din spațiul de lucru, cu excepția celor din categoria Invitați, se poate alătura" + } + }, + "error": { + "permission": "Nu ai permisiunea să efectuezi această acțiune.", + "cycle_delete": "Ștergerea ciclului a eșuat", + "module_delete": "Ștergerea modulului a eșuat", + "issue_delete": "Ștergerea activității a eșuat" + }, + "state": { + "backlog": "Restante", + "unstarted": "Neîncepute", + "started": "În desfășurare", + "completed": "Finalizate", + "cancelled": "Anulate" + }, + "sort": { + "manual": "Manual", + "name": "Nume", + "created_at": "Data creării", + "members_length": "Număr de membri" + }, + "scope": { + "my_projects": "Proiectele mele", + "archived_projects": "Arhivate" + }, + "common": { + "months_count": "{months, plural, one{# lună} other{# luni}}" + }, + "empty_state": { + "general": { + "title": "Niciun proiect activ", + "description": "Gândește-te la fiecare proiect ca la părintele muncii orientate pe obiectiv. Proiectele sunt locul unde trăiesc Activitățile, Ciclurile și Modulele și, împreună cu colegii tăi, te ajută să îți atingi obiectivul. Creează un proiect nou sau filtrează pentru a vedea proiectele arhivate.", + "primary_button": { + "text": "Începe primul tău proiect", + "comic": { + "title": "Totul începe cu un proiect în Plane", + "description": "Un proiect poate fi o foaie de parcurs pentru un produs, o campanie de marketing sau lansarea unei noi mașini." + } + } + }, + "no_projects": { + "title": "Niciun proiect", + "description": "Pentru a crea activități sau a-ți gestiona activitatea, trebuie să creezi un proiect sau să faci parte dintr-unul.", + "primary_button": { + "text": "Începe primul tău proiect", + "comic": { + "title": "Totul începe cu un proiect în Plane", + "description": "Un proiect poate fi o foaie de parcurs pentru un produs, o campanie de marketing sau lansarea unei noi mașini." + } + } + }, + "filter": { + "title": "Niciun proiect care să corespundă filtrului", + "description": "Nu s-au găsit proiecte care să corespundă criteriilor aplicate.\n Creează un proiect nou." + }, + "search": { + "description": "Nu s-au găsit proiecte care să corespundă criteriilor.\nCreează un proiect nou." + } + } + }, + + "workspace_views": { + "add_view": "Adaugă perspectivă", + "empty_state": { + "all-issues": { + "title": "Nicio activitate în proiect", + "description": "Primul proiect este gata! Acum împarte-ți munca în bucăți gestionabile prin activități. Hai să începem!", + "primary_button": { + "text": "Creează o nouă activitate" + } + }, + "assigned": { + "title": "Nicio activitate încă", + "description": "Activitățile care ți-au fost atribuite pot fi urmărite de aici.", + "primary_button": { + "text": "Creează o nouă activitate" + } + }, + "created": { + "title": "Nicio activitate încă", + "description": "Toate activitățile create de tine vor apărea aici. Le poți urmări direct din această pagină.", + "primary_button": { + "text": "Creează o nouă activitate" + } + }, + "subscribed": { + "title": "Nicio activitate încă", + "description": "Abonează-te la activitățile care te interesează și urmărește-le pe toate aici." + }, + "custom-view": { + "title": "Nicio activitate încă", + "description": "Elementele de lucru care corespund filtrelor aplicate vor fi afișate aici." + } + } + }, + + "workspace_settings": { + "label": "Setări spațiu de lucru", + "page_label": "{workspace} - Setări generale", + "key_created": "Cheie creată", + "copy_key": "Copiază și salvează această cheie secretă în Plane Documentație. Nu vei mai putea vedea această cheie după ce închizi. Un fișier CSV care conține cheia a fost descărcat.", + "token_copied": "Token-ul a fost copiat în memoria temporară.", + "settings": { + "general": { + "title": "General", + "upload_logo": "Încarcă siglă", + "edit_logo": "Editează siglă", + "name": "Numele spațiului de lucru", + "company_size": "Dimensiunea companiei", + "url": "URL-ul spațiului de lucru", + "update_workspace": "Actualizează spațiul de lucru", + "delete_workspace": "Șterge acest spațiu de lucru", + "delete_workspace_description": "La ștergerea spațiului de lucru, toate datele și resursele din cadrul acestuia vor fi eliminate definitiv și nu vor putea fi recuperate.", + "delete_btn": "Șterge acest spațiu de lucru", + "delete_modal": { + "title": "Ești sigur că vrei să ștergi acest spațiu de lucru?", + "description": "Ai o perioadă de probă activă pentru unul dintre planurile noastre plătite. Te rugăm să o anulezi înainte de a continua.", + "dismiss": "Renunță", + "cancel": "Anulează perioadă de probă", + "success_title": "Spațiul de lucru a fost șters.", + "success_message": "Vei fi redirecționat în curând către pagina de profil.", + "error_title": "Ceva nu a funcționat.", + "error_message": "Încearcă din nou, te rog." + }, + "errors": { + "name": { + "required": "Numele este obligatoriu", + "max_length": "Numele spațiului de lucru nu trebuie să depășească 80 de caractere" + }, + "company_size": { + "required": "Dimensiunea companiei este obligatorie", + "select_a_range": "Selectează dimensiunea companiei" + } + } + }, + "members": { + "title": "Membri", + "add_member": "Adaugă membru", + "pending_invites": "Invitații în așteptare", + "invitations_sent_successfully": "Invitațiile au fost trimise cu succes", + "leave_confirmation": "Ești sigur că vrei să părăsești spațiul de lucru? Nu vei mai avea acces la acest spațiu. Această acțiune este ireversibilă.", + "details": { + "full_name": "Nume complet", + "display_name": "Nume afișat", + "email_address": "Adresă de email", + "account_type": "Tip cont", + "authentication": "Autentificare", + "joining_date": "Data înscrierii" + }, + "modal": { + "title": "Invită persoane cu care să colaborezi", + "description": "Invită persoane cu care să colaborezi în spațiul tău de lucru.", + "button": "Trimite invitațiile", + "button_loading": "Se trimit invitațiile", + "placeholder": "nume@companie.ro", + "errors": { + "required": "Avem nevoie de o adresă de email pentru a trimite invitația.", + "invalid": "Adresa de email este invalidă" + } + } + }, + "billing_and_plans": { + "title": "Facturare și Abonamente", + "current_plan": "Abonament curent", + "free_plan": "Folosești în prezent abonamentul gratuit", + "view_plans": "Vezi abonamentele" + }, + "exports": { + "title": "Exporturi", + "exporting": "Se exportă", + "previous_exports": "Exporturi anterioare", + "export_separate_files": "Exportă datele în fișiere separate", + "modal": { + "title": "Exportă în", + "toasts": { + "success": { + "title": "Export reușit", + "message": "Vei putea descărca exportul {entity} din secțiunea de exporturi anterioare." + }, + "error": { + "title": "Export eșuat", + "message": "Exportul a eșuat. Te rugăm să încerci din nou." + } + } + } + }, + "webhooks": { + "title": "Puncte de notificare (Webhooks)", + "add_webhook": "Adaugă punct de notificare (webhook)", + "modal": { + "title": "Creează punct de notificare (webhook)", + "details": "Detalii punct de notificare (webhook)", + "payload": " URL-ul de trimitere a datelor", + "question": "La ce evenimente vrei să activezi acest punct de notificare (webhook)?", + "error": "URL-ul este obligatoriu" + }, + "secret_key": { + "title": "Cheie secretă", + "message": "Generează o cheie de acces pentru a semna datele trimise la punctul de notificare (webhook)" + }, + "options": { + "all": "Trimite-mi tot", + "individual": "Selectează evenimente individuale" + }, + "toasts": { + "created": { + "title": "Punct de notificare (webhook) creat", + "message": "Punctul de notificare (webhook) a fost creat cu succes" + }, + "not_created": { + "title": "Punctul de notificare (webhook) nu a fost creat", + "message": "Punctul de notificare (webhook) nu a putut fi creat" + }, + "updated": { + "title": "Punctul de notificare (webhook) actualizat", + "message": "Punctul de notificare (webhook) a fost actualizat cu succes" + }, + "not_updated": { + "title": "Punctul de notificare (webhook) nu a fost actualizat", + "message": "Punctul de notificare (webhook) nu a putut fi actualizat" + }, + "removed": { + "title": "Punct de notificare (webhook) șters", + "message": "Punctul de notificare (webhook) a fost șters cu succes" + }, + "not_removed": { + "title": "Punctul de notificare (webhook) nu a fost șters", + "message": "Punctul de notificare (webhook) nu a putut fi șters" + }, + "secret_key_copied": { + "message": "Cheia secretă a fost copiată în memoria temporară." + }, + "secret_key_not_copied": { + "message": "A apărut o eroare la copierea cheii secrete." + } + } + }, + "api_tokens": { + "title": "Chei secrete API", + "add_token": "Adaugă cheie secretă API", + "create_token": "Creează cheie secretă", + "never_expires": "Nu expiră niciodată", + "generate_token": "Generează cheie secretă", + "generating": "Se generează", + "delete": { + "title": "Șterge cheia secretă API", + "description": "Orice aplicație care folosește această cheie secretă nu va mai avea acces la datele Plane. Această acțiune este ireversibilă.", + "success": { + "title": "Succes!", + "message": "Cheia secretă API a fost ștearsă cu succes" + }, + "error": { + "title": "Eroare!", + "message": "Cheia secretă API nu a putut fi ștearsă" + } + } + } + }, + "empty_state": { + "api_tokens": { + "title": "Nicio cheie secretă API creată", + "description": "API-ul Plane poate fi folosit pentru a integra datele tale din Plane cu orice sistem extern. Creează o cheie secretă pentru a începe." + }, + "webhooks": { + "title": "Niciun punctul de notificare (webhook) adăugat", + "description": "Creează puncte de notificare (webhooks) pentru a primi actualizări în timp real și a automatiza acțiuni." + }, + "exports": { + "title": "Niciun export efectuat", + "description": "Ori de câte ori exporți, vei avea o copie și aici pentru referință." + }, + "imports": { + "title": "Niciun import efectuat", + "description": "Găsește aici toate importurile anterioare și descarcă-le." + } + } + }, + + "profile": { + "label": "Profil", + "page_label": "Munca ta", + "work": "Muncă", + "details": { + "joined_on": "S-a înscris la", + "time_zone": "Fus orar" + }, + "stats": { + "workload": "Volum de muncă", + "overview": "Prezentare generală", + "created": "Activități create", + "assigned": "Activități atribuite", + "subscribed": "Activități urmărite", + "state_distribution": { + "title": "Activități după stare", + "empty": "Creează activități pentru a le vedea distribuite pe stări în grafic, pentru o analiză mai bună." + }, + "priority_distribution": { + "title": "Activități după prioritate", + "empty": "Creează activități pentru a le vedea distribuite pe priorități în grafic, pentru o analiză mai bună." + }, + "recent_activity": { + "title": "Activitate recentă", + "empty": "Nu am găsit date. Te rugăm să verifici activitățile tale.", + "button": "Descarcă activitatea de azi", + "button_loading": "Se descarcă" + } + }, + "actions": { + "profile": "Profil", + "security": "Securitate", + "activity": "Activitate", + "appearance": "Aspect", + "notifications": "Notificări" + }, + "tabs": { + "summary": "Sumar", + "assigned": "Atribuite", + "created": "Create", + "subscribed": "Urmărite", + "activity": "Activitate" + }, + "empty_state": { + "activity": { + "title": "Nicio activitate încă", + "description": "Începe prin a crea o nouă activitate! Adaugă detalii și proprietăți. Explorează mai mult în Plane pentru a-ți vedea activitatea." + }, + "assigned": { + "title": "Nicio activitate atribuită ție", + "description": "Elementele de lucru care ți-au fost atribuite pot fi urmărite de aici." + }, + "created": { + "title": "Nicio activitate creată", + "description": "Toate activitățile create de tine vor apărea aici. Le poți urmări direct din această pagină." + }, + "subscribed": { + "title": "Nicio activitate urmărită", + "description": "Abonează-te la activitățile care te interesează și urmărește-le pe toate aici." + } + } + }, + + "project_settings": { + "general": { + "enter_project_id": "Introdu ID-ul proiectului", + "please_select_a_timezone": "Te rugăm să selectezi un fus orar", + "archive_project": { + "title": "Arhivează proiectul", + "description": "Arhivarea unui proiect îl va elimina din navigarea laterală, dar vei putea accesa proiectul din pagina ta de proiecte. Poți restaura sau șterge proiectul oricând dorești.", + "button": "Arhivează proiectul" + }, + "delete_project": { + "title": "Șterge proiectul", + "description": "La ștergerea unui proiect, toate datele și resursele din cadrul proiectului vor fi eliminate definitiv și nu vor putea fi recuperate.", + "button": "Șterge proiectul meu" + }, + "toast": { + "success": "Proiect actualizat cu succes", + "error": "Proiectul nu a putut fi actualizat. Te rugăm să încerci din nou." + } + }, + "members": { + "label": "Membri", + "project_lead": "Lider de proiect", + "default_assignee": "Persoană atribuită implicit", + "guest_super_permissions": { + "title": "Acordă acces la perspectivă pentru toți utilizatorii de tip Invitat:", + "sub_heading": "Aceasta va permite utilizatorilor din categoria Invitați să vadă toate activitățile din proiect." + }, + "invite_members": { + "title": "Invită membri", + "sub_heading": "Invită membri să lucreze la proiectul tău.", + "select_co_worker": "Selectează colegul de echipă" + } + }, + "states": { + "describe_this_state_for_your_members": "Descrie această stare pentru membrii tăi.", + "empty_state": { + "title": "Nicio stare disponibilă pentru grupul {groupKey}", + "description": "Te rog să creezi o stare nouă" + } + }, + "labels": { + "label_title": "Titlu etichetă", + "label_title_is_required": "Titlul etichetei este obligatoriu", + "label_max_char": "Numele etichetei nu trebuie să depășească 255 de caractere", + "toast": { + "error": "Eroare la actualizarea etichetei" + } + }, + "estimates": { + "label": "Estimări", + "title": "Activează estimările pentru proiectul meu", + "description": "Te ajută să comunici complexitatea și volumul de muncă al echipei.", + "no_estimate": "Fără estimare", + "new": "Noul sistem de estimare", + "create": { + "custom": "Personalizat", + "start_from_scratch": "Începe de la zero", + "choose_template": "Alege un șablon", + "choose_estimate_system": "Alege un sistem de estimare", + "enter_estimate_point": "Introdu estimarea", + "step": "Pasul {step} de {total}", + "label": "Creează estimare" + }, + "toasts": { + "created": { + "success": { + "title": "Estimare creată", + "message": "Estimarea a fost creată cu succes" + }, + "error": { + "title": "Crearea estimării a eșuat", + "message": "Nu am putut crea noua estimare, te rugăm să încerci din nou." + } + }, + "updated": { + "success": { + "title": "Estimare modificată", + "message": "Estimarea a fost actualizată în proiectul tău." + }, + "error": { + "title": "Modificarea estimării a eșuat", + "message": "Nu am putut modifica estimarea, te rugăm să încerci din nou" + } + }, + "enabled": { + "success": { + "title": "Succes!", + "message": "Estimările au fost activate." + } + }, + "disabled": { + "success": { + "title": "Succes!", + "message": "Estimările au fost dezactivate." + }, + "error": { + "title": "Eroare!", + "message": "Estimarea nu a putut fi dezactivată. Te rugăm să încerci din nou" + } + } + }, + "validation": { + "min_length": "Estimarea trebuie să fie mai mare decât 0.", + "unable_to_process": "Nu putem procesa cererea ta, te rugăm să încerci din nou.", + "numeric": "Estimarea trebuie să fie o valoare numerică.", + "character": "Estimarea trebuie să fie o valoare de tip caracter.", + "empty": "Valoarea estimării nu poate fi goală.", + "already_exists": "Valoarea estimării există deja.", + "unsaved_changes": "Ai modificări nesalvate, te rugăm să le salvezi înainte de a finaliza", + "remove_empty": "Estimarea nu poate fi goală. Introdu o valoare în fiecare câmp sau elimină câmpurile pentru care nu ai valori." + }, + "systems": { + "points": { + "label": "Puncte", + "fibonacci": "Fibonacci", + "linear": "Linear", + "squares": "Pătrate", + "custom": "Personalizat" + }, + "categories": { + "label": "Categorii", + "t_shirt_sizes": "Mărimi tricou", + "easy_to_hard": "De la ușor la greu", + "custom": "Personalizat" + }, + "time": { + "label": "Timp", + "hours": "Ore" + } + } + }, + "automations": { + "label": "Automatizări", + "auto-archive": { + "title": "Auto-arhivează activitățile finalizate", + "description": "Plane va arhiva automat activitățile care au fost finalizate sau anulate.", + "duration": "Auto-arhivează activitățile finalizate de" + }, + "auto-close": { + "title": "Închide automat activitățile", + "description": "Plane va închide automat activitățile care nu au fost finalizate sau anulate.", + "duration": "Închide automat activitățile inactive de", + "auto_close_status": "Stare închidere automată" + } + }, + + "empty_state": { + "labels": { + "title": "Nicio etichetă încă", + "description": "Creează etichete pentru a organiza și filtra activitățile din proiect." + }, + "estimates": { + "title": "Nicio estimare configurată", + "description": "Creează un set de estimări pentru a comunica volumul de muncă pentru fiecare activitate.", + "primary_button": "Adaugă sistem de estimare" + } + } + }, + + "project_cycles": { + "add_cycle": "Adaugă ciclu", + "more_details": "Mai multe detalii", + "cycle": "Ciclu", + "update_cycle": "Actualizează ciclul", + "create_cycle": "Creează ciclu", + "no_matching_cycles": "Niciun ciclu găsit", + "remove_filters_to_see_all_cycles": "Elimină filtrele pentru a vedea toate ciclurile", + "remove_search_criteria_to_see_all_cycles": "Elimină criteriile de căutare pentru a vedea toate ciclurile", + "only_completed_cycles_can_be_archived": "Doar ciclurile finalizate pot fi arhivate", + "active_cycle": { + "label": "Ciclu activ", + "progress": "Progres", + "chart": "Ritmul de finalizare a activităților", + "priority_issue": "Activități prioritare", + "assignees": "Persoane atribuite", + "issue_burndown": "Grafic de finalizare a activităților", + "ideal": "Ideal", + "current": "Curent", + "labels": "Etichete" + }, + "upcoming_cycle": { + "label": "Ciclu viitor" + }, + "completed_cycle": { + "label": "Ciclu finalizat" + }, + "status": { + "days_left": "Zile rămase", + "completed": "Finalizat", + "yet_to_start": "Nu a început", + "in_progress": "În desfășurare", + "draft": "Schiță" + }, + "action": { + "restore": { + "title": "Restaurează ciclul", + "success": { + "title": "Ciclu restaurat", + "description": "Ciclul a fost restaurat." + }, + "failed": { + "title": "Restaurarea ciclului a eșuat", + "description": "Ciclul nu a putut fi restaurat. Te rugăm să încerci din nou." + } + }, + "favorite": { + "loading": "Se adaugă ciclul la favorite", + "success": { + "description": "Ciclul a fost adăugat la favorite.", + "title": "Succes!" + }, + "failed": { + "description": "Nu s-a putut adăuga ciclul la favorite. Te rugăm să încerci din nou.", + "title": "Eroare!" + } + }, + "unfavorite": { + "loading": "Se elimină ciclul din favorite", + "success": { + "description": "Ciclul a fost eliminat din favorite.", + "title": "Succes!" + }, + "failed": { + "description": "Nu s-a putut elimina ciclul din favorite. Te rugăm să încerci din nou.", + "title": "Eroare!" + } + }, + "update": { + "loading": "Se actualizează ciclul", + "success": { + "description": "Ciclul a fost actualizat cu succes.", + "title": "Succes!" + }, + "failed": { + "description": "Eroare la actualizarea ciclului. Te rugăm să încerci din nou.", + "title": "Eroare!" + }, + "error": { + "already_exists": "Ai deja un ciclu în datele selectate. Dacă vrei să creezi o schiță, poți face asta eliminând ambele date." + } + } + }, + "empty_state": { + "general": { + "title": "Grupează și delimitează în timp munca ta în Cicluri.", + "description": "Împarte munca în intervale de timp, stabilește datele în funcție de termenul limită al proiectului și progresează vizibil ca echipă.", + "primary_button": { + "text": "Setează primul tău ciclu", + "comic": { + "title": "Ciclurile sunt intervale repetitive de timp.", + "description": "O iterație sau orice alt termen folosit pentru urmărirea săptămânală sau bilunară a muncii este un ciclu." + } + } + }, + "no_issues": { + "title": "Nicio activitate adăugată în ciclu", + "description": "Adaugă sau creează activități pe care vrei să le implementezi în acest ciclu", + "primary_button": { + "text": "Creează o activitate nouă" + }, + "secondary_button": { + "text": "Adaugă o activitate existentă" + } + }, + "completed_no_issues": { + "title": "Nicio activitate în ciclu", + "description": "Nu există activități în ciclu. Acestea au fost fie transferate, fie ascunse. Pentru a vedea activitățile ascunse, actualizează proprietățile de afișare." + }, + "active": { + "title": "Niciun ciclu activ", + "description": "Un ciclu activ include orice perioadă care conține data de azi în intervalul său. Progresul și detaliile ciclului activ apar aici." + }, + "archived": { + "title": "Niciun ciclu arhivat încă", + "description": "Pentru a păstra proiectul ordonat, arhivează ciclurile completate. Le vei găsi aici după arhivare." + } + } + }, + + "project_issues": { + "empty_state": { + "no_issues": { + "title": "Creează o activitate și atribuie-o cuiva, chiar și ție", + "description": "Gândește-te la activități ca la sarcini sau lucruri care trebuie făcute. O activitate și sub-activitățile sale sunt acțiuni care trebuie realizate într-un interval de timp de către membrii echipei tale. Echipa creează, atribuie și finalizează activități pentru a duce proiectul spre obiectivul său.", + "primary_button": { + "text": "Creează prima ta activitate", + "comic": { + "title": "Activitățile sunt elemente de bază în Plane.", + "description": "Reproiectarea interfeței Plane, modernizarea imaginii companiei sau lansarea noului sistem de injecție sunt exemple de activități care au, cel mai probabil, sub-activități." + } + } + }, + "no_archived_issues": { + "title": "Nicio activitate arhivată încă", + "description": "Manual sau automat, poți arhiva activitățile care sunt finalizate sau anulate. Le vei găsi aici după arhivare.", + "primary_button": { + "text": "Setează automatizarea" + } + }, + "issues_empty_filter": { + "title": "Nicio activitate găsită conform filtrelor aplicate", + "secondary_button": { + "text": "Șterge toate filtrele" + } + } + } + }, + + "project_module": { + "add_module": "Adaugă Modul", + "update_module": "Actualizează Modul", + "create_module": "Creează Modul", + "archive_module": "Arhivează Modul", + "restore_module": "Restaurează Modul", + "delete_module": "Șterge modulul", + "empty_state": { + "general": { + "title": "Mapează etapele proiectului în Module și urmărește munca agregată cu ușurință.", + "description": "Un grup de activități care aparțin unui părinte logic și ierarhic formează un modul. Gândește-te la module ca la un mod de a urmări munca în funcție de etapele proiectului. Au propriile perioade, termene limită și statistici pentru a-ți arăta cât de aproape sau departe ești de un reper.", + "primary_button": { + "text": "Construiește primul tău modul", + "comic": { + "title": "Modulele ajută la organizarea muncii pe niveluri ierarhice.", + "description": "Un modul pentru caroserie, un modul pentru șasiu sau un modul pentru depozit sunt exemple bune de astfel de grupare." + } + } + }, + "no_issues": { + "title": "Nicio activitate în modul", + "description": "Creează sau adaugă activități pe care vrei să le finalizezi ca parte a acestui modul", + "primary_button": { + "text": "Creează activități noi" + }, + "secondary_button": { + "text": "Adaugă o activitate existentă" + } + }, + "archived": { + "title": "Niciun modul arhivat încă", + "description": "Pentru a păstra proiectul ordonat, arhivează modulele finalizate sau anulate. Le vei găsi aici după arhivare." + }, + "sidebar": { + "in_active": "Acest modul nu este încă activ.", + "invalid_date": "Dată invalidă. Te rugăm să introduci o dată validă." + } + }, + "quick_actions": { + "archive_module": "Arhivează modulul", + "archive_module_description": "Doar modulele finalizate sau anulate pot fi arhivate.", + "delete_module": "Șterge modulul" + }, + "toast": { + "copy": { + "success": "Link-ul modulului a fost copiat în memoria temporară" + }, + "delete": { + "success": "Modulul a fost șters cu succes", + "error": "Ștergerea modulului a eșuat" + } + } + }, + + "project_views": { + "empty_state": { + "general": { + "title": "Salvează perspective filtrate pentru proiectul tău. Creează câte ai nevoie", + "description": "Perspectivele sunt seturi de filtre salvate pe care le folosești frecvent sau la care vrei acces rapid. Toți colegii tăi dintr-un proiect pot vedea perspectivele tuturor și pot alege ce li se potrivește cel mai bine.", + "primary_button": { + "text": "Creează prima ta perspectivă", + "comic": { + "title": "Perspectivele funcționează pe baza proprietăților activităților.", + "description": "Poți crea o perspectivă de aici, cu oricâte proprietăți și filtre consideri necesare." + } + } + }, + "filter": { + "title": "Nicio perspectivă potrivită", + "description": "Nicio perspectivă nu se potrivește criteriilor de căutare.\n Creează o nouă perspectivă în schimb." + } + } + }, + + "project_page": { + "empty_state": { + "general": { + "title": "Scrie o notiță, un document sau o bază completă de cunoștințe. Folosește-l pe Galileo, Inteligența Artificială a Plane, ca să te ajute să începi", + "description": "Documentația e spațiul în care îți notezi gândurile în Plane. Ia notițe de la ședințe, formatează-le ușor, inserează activități, așază-le folosind o bibliotecă de componente și păstrează-le pe toate în contextul proiectului tău. Pentru a redacta rapid orice document, apelează la Galileo, Inteligența Artificială a Plane, cu un shortcut sau un click.", + "primary_button": { + "text": "Creează primul tău document" + } + }, + "private": { + "title": "Niciun document privată încă", + "description": "Păstrează-ți gândurile private aici. Când ești gata să le împarți, echipa e la un click distanță.", + "primary_button": { + "text": "Creează primul tău document" + } + }, + "public": { + "title": "Niciun document public încă", + "description": "Vezi aici documentele distribuite cu toată echipa ta din proiect.", + "primary_button": { + "text": "Creează primul tău document" + } + }, + "archived": { + "title": "Niciun document arhivat încă", + "description": "Arhivează documentele de care nu mai ai nevoie. Le poți accesa de aici oricând." + } + } + }, + + "command_k": { + "empty_state": { + "search": { + "title": "Niciun rezultat găsit" + } + } + }, + + "issue_relation": { + "empty_state": { + "search": { + "title": "Nu au fost găsite activități potrivite" + }, + "no_issues": { + "title": "Nu au fost găsite activități" + } + } + }, + + "issue_comment": { + "empty_state": { + "general": { + "title": "Niciun comentariu încă", + "description": "Comentariile pot fi folosite ca spațiu de discuții și urmărire pentru activități" + } + } + }, + + "notification": { + "label": "Căsuță de mesaje", + "page_label": "{workspace} - Căsuță de mesaje", + "options": { + "mark_all_as_read": "Marchează toate ca citite", + "mark_read": "Marchează ca citit", + "mark_unread": "Marchează ca necitit", + "refresh": "Reîmprospătează", + "filters": "Filtre Căsuță de mesaje", + "show_unread": "Afișează necitite", + "show_snoozed": "Afișează amânate", + "show_archived": "Afișează arhivate", + "mark_archive": "Arhivează", + "mark_unarchive": "Dezarhivează", + "mark_snooze": "Amână", + "mark_unsnooze": "Dezactivează amânarea" + }, + "toasts": { + "read": "Notificare marcată ca citită", + "unread": "Notificare marcată ca necitită", + "archived": "Notificare arhivată", + "unarchived": "Notificare dezarhivată", + "snoozed": "Notificare amânată", + "unsnoozed": "Notificare reactivată" + }, + "empty_state": { + "detail": { + "title": "Selectează pentru a vedea detalii." + }, + "all": { + "title": "Nicio activitate atribuită", + "description": "Actualizările pentru activitățile atribuite ție pot fi\nvăzute aici" + }, + "mentions": { + "title": "Nicio activitate atribuită", + "description": "Actualizările pentru activitățile atribuite ție pot fi\nvăzute aici" + } + }, + "tabs": { + "all": "Toate", + "mentions": "Mențiuni" + }, + "filter": { + "assigned": "Atribuite mie", + "created": "Create de mine", + "subscribed": "Urmărite de mine" + }, + "snooze": { + "1_day": "1 zi", + "3_days": "3 zile", + "5_days": "5 zile", + "1_week": "1 săptămână", + "2_weeks": "2 săptămâni", + "custom": "Personalizat" + } + }, + + "active_cycle": { + "empty_state": { + "progress": { + "title": "Adaugă activități în ciclu pentru a vedea progresul" + }, + "chart": { + "title": "Adaugă activități în ciclu pentru a vedea graficul de finalizare a activităților." + }, + "priority_issue": { + "title": "Observă rapid activitățile cu prioritate ridicată abordate în ciclu." + }, + "assignee": { + "title": "Adaugă responsabili pentru a vedea repartizarea muncii pe persoane." + }, + "label": { + "title": "Adaugă etichete activităților pentru a vedea repartizarea muncii pe etichete." + } + } + }, + + "disabled_project": { + "empty_state": { + "inbox": { + "title": "Funcția Cereri nu este activată pentru proiect.", + "description": "Funcția Cereri te ajută să gestionezi cererile care vin în proiectul tău și să le adaugi ca activități în fluxul tău. Activează Cereri din setările proiectului pentru a gestiona cererile.", + "primary_button": { + "text": "Gestionează funcțiile" + } + }, + "cycle": { + "title": "Funcția Cicluri nu este activată pentru acest proiect.", + "description": "Împarte munca în intervale de timp, pleacă de la termenul limită al proiectului pentru a seta date și progresează vizibil ca echipă. Activează funcția de cicluri pentru a începe să o folosești.", + "primary_button": { + "text": "Gestionează funcțiile" + } + }, + "module": { + "title": "Funcția Module nu este activată pentru proiect.", + "description": "Modulele sunt componentele de bază ale proiectului tău. Activează modulele din setările proiectului pentru a începe să le folosești.", + "primary_button": { + "text": "Gestionează funcțiile" + } + }, + "page": { + "title": "Funcția Documentație nu este activată pentru proiect.", + "description": "Paginile sunt componentele de bază ale proiectului tău. Activează paginile din setările proiectului pentru a începe să le folosești.", + "primary_button": { + "text": "Gestionează funcțiile" + } + }, + "view": { + "title": "Funcția Perspective nu este activată pentru proiect.", + "description": "Perspectivele sunt componentele de bază ale proiectului tău. Activează perspectivele din setările proiectului pentru a începe să le folosești.", + "primary_button": { + "text": "Gestionează funcțiile" + } + } + } + }, + "workspace_draft_issues": { + "draft_an_issue": "Salvează o activitate ca schiță", + "empty_state": { + "title": "Elementele de lucru scrise pe jumătate, și în curând și comentariile, vor apărea aici.", + "description": "Ca să testezi, începe să adaugi o activitate și las-o nefinalizată sau creează prima ta schiță mai jos. 😉", + "primary_button": { + "text": "Creează prima ta schiță" + } + }, + "delete_modal": { + "title": "Șterge schița", + "description": "Ești sigur că vrei să ștergi această schiță? Această acțiune este ireversibilă." + }, + "toasts": { + "created": { + "success": "Schiță creată", + "error": "Activitatea nu a putut fi creată. Te rugăm să încerci din nou." + }, + "deleted": { + "success": "Schiță ștearsă" + } + } + }, + + "stickies": { + "title": "Notițele tale", + "placeholder": "click pentru a scrie aici", + "all": "Toate notițele", + "no-data": "Notează o idee, surprinde un moment de inspirație sau înregistrează o idee. Adaugă o notiță pentru a începe.", + "add": "Adaugă notiță", + "search_placeholder": "Caută după titlu", + "delete": "Șterge notița", + "delete_confirmation": "Ești sigur că vrei să ștergi această notiță?", + "empty_state": { + "simple": "Notează o idee, surprinde un moment de inspirație sau înregistrează o idee. Adaugă o notiță pentru a începe.", + "general": { + "title": "Notițele sunt observații rapide și lucruri de făcut pe care le notezi din mers.", + "description": "Surprinde-ți gândurile și ideile fără efort, creând notițe la care poți avea acces oricând și de oriunde.", + "primary_button": { + "text": "Adaugă notiță" + } + }, + "search": { + "title": "Nu se potrivește cu nicio notiță existentă.", + "description": "Încearcă un alt termen sau anunță-ne\n dacă ești sigur că ai căutat corect.", + "primary_button": { + "text": "Adaugă notiță" + } + } + }, + "toasts": { + "errors": { + "wrong_name": "Numele notiței nu poate depăși 100 de caractere.", + "already_exists": "Există deja o notiță fără descriere" + }, + "created": { + "title": "Notiță creată", + "message": "Notița a fost creată cu succes" + }, + "not_created": { + "title": "Notiță necreată", + "message": "Notița nu a putut fi creată" + }, + "updated": { + "title": "Notiță actualizată", + "message": "Notița a fost actualizată cu succes" + }, + "not_updated": { + "title": "Notiță neactualizată", + "message": "Notița nu a putut fi actualizată" + }, + "removed": { + "title": "Notiță ștearsă", + "message": "Notița a fost ștearsă cu succes" + }, + "not_removed": { + "title": "Notiță neștearsă", + "message": "Notița nu a putut fi ștearsă" + } + } + }, + + "role_details": { + "guest": { + "title": "Invitat", + "description": "Membrii externi ai organizațiilor pot fi incluși ca invitați." + }, + "member": { + "title": "Membru", + "description": "Poate citi, scrie, edita și șterge entități în proiecte, cicluri și module" + }, + "admin": { + "title": "Administrator", + "description": "Toate permisiunile setate pe adevărat în cadrul workspace-ului." + } + }, + + "user_roles": { + "product_or_project_manager": "Manager de produs / proiect", + "development_or_engineering": "Dezvoltare / Inginerie", + "founder_or_executive": "Fondator / Director executiv", + "freelancer_or_consultant": "Liber profesionist / Consultant", + "marketing_or_growth": "Marketing / Creștere", + "sales_or_business_development": "Vânzări / Dezvoltare afaceri", + "support_or_operations": "Suport / Operațiuni", + "student_or_professor": "Student / Profesor", + "human_resources": "Resurse umane", + "other": "Altceva" + }, + + "importer": { + "github": { + "title": "Github", + "description": "Importă activități din arhivele de cod GitHub și sincronizează-le." + }, + "jira": { + "title": "Jira", + "description": "Importă activități și episoade din proiectele și episoadele Jira." + } + }, + + "exporter": { + "csv": { + "title": "CSV", + "description": "Exportă activitățile într-un fișier CSV.", + "short_description": "Exportă ca CSV" + }, + "excel": { + "title": "Excel", + "description": "Exportă activitățile într-un fișier Excel.", + "short_description": "Exportă ca Excel" + }, + "xlsx": { + "title": "Excel", + "description": "Exportă activitățile într-un fișier Excel.", + "short_description": "Exportă ca Excel" + }, + "json": { + "title": "JSON", + "description": "Exportă activitățile într-un fișier JSON.", + "short_description": "Exportă ca JSON" + } + }, + "default_global_view": { + "all_issues": "Toate activitățile", + "assigned": "Atribuite", + "created": "Create", + "subscribed": "Urmărite" + }, + + "themes": { + "theme_options": { + "system_preference": { + "label": "Preferință sistem" + }, + "light": { + "label": "Luminos" + }, + "dark": { + "label": "Întunecat" + }, + "light_contrast": { + "label": "Luminos cu contrast ridicat" + }, + "dark_contrast": { + "label": "Întunecat cu contrast ridicat" + }, + "custom": { + "label": "Temă personalizată" + } + } + }, + "project_modules": { + "status": { + "backlog": "Restante", + "planned": "Planificate", + "in_progress": "În desfășurare", + "paused": "În pauză", + "completed": "Finalizat", + "cancelled": "Anulat" + }, + "layout": { + "list": "Aspect listă", + "board": "Aspect galerie", + "timeline": "Aspect cronologic" + }, + "order_by": { + "name": "Nume", + "progress": "Progres", + "issues": "Număr de activități", + "due_date": "Termen limită", + "created_at": "Dată creare", + "manual": "Manual" + } + }, + + "cycle": { + "label": "{count, plural, one {Ciclu} other {Cicluri}}", + "no_cycle": "Niciun ciclu" + }, + + "module": { + "label": "{count, plural, one {Modul} other {Module}}", + "no_module": "Niciun modul" + }, + + "description_versions": { + "last_edited_by": "Ultima editare de către", + "previously_edited_by": "Editat anterior de către", + "edited_by": "Editat de" + } +} diff --git a/packages/i18n/src/locales/ru/translations.json b/packages/i18n/src/locales/ru/translations.json index d753b30ba..a02057af9 100644 --- a/packages/i18n/src/locales/ru/translations.json +++ b/packages/i18n/src/locales/ru/translations.json @@ -348,7 +348,7 @@ "couldnt_remove_the_project_from_favorites": "Не удалось удалить проект из избранного. Попробуйте снова.", "add_to_favorites": "Добавить в избранное", "remove_from_favorites": "Удалить из избранного", - "publish_settings": "Настройки публикации", + "publish_project": "Опубликовать проект", "publish": "Опубликовать", "copy_link": "Копировать ссылку", "leave_project": "Покинуть проект", @@ -499,6 +499,9 @@ "re_generate_key": "Перегенерировать ключ", "export": "Экспорт", "member": "{count, plural, one{# участник} few{# участника} other{# участников}}", + "new_password_must_be_different_from_old_password": "Новое пароль должен отличаться от старого пароля", + "edited": "Редактировано", + "bot": "Бот", "project_view": { "sort_by": { @@ -589,7 +592,7 @@ "default": "Пока нет недавних элементов" }, "filters": { - "all": "Все элементы", + "all": "Все", "projects": "Проекты", "pages": "Страницы", "issues": "Рабочие элементы" @@ -866,7 +869,8 @@ "deleting": "Удаление", "pending": "Ожидание", "invite": "Пригласить", - "view": "Просмотр" + "view": "Просмотр", + "deactivated_user": "Деактивированный пользователь" }, "chart": { @@ -1733,33 +1737,112 @@ } }, "estimates": { - "title": "Включить оценку для проекта", - "description": "Помогают оценивать сложность и загрузку команды." + "label": "Оценки", + "title": "Включить оценки для моего проекта", + "description": "Они помогают вам в общении о сложности и рабочей нагрузке команды.", + "no_estimate": "Без оценки", + "new": "Новая система оценок", + "create": { + "custom": "Пользовательская", + "start_from_scratch": "Начать с нуля", + "choose_template": "Выбрать шаблон", + "choose_estimate_system": "Выбрать систему оценок", + "enter_estimate_point": "Ввести оценку", + "step": "Шаг {step} из {total}", + "label": "Создать оценку" + }, + "toasts": { + "created": { + "success": { + "title": "Оценка создана", + "message": "Оценка успешно создана" + }, + "error": { + "title": "Ошибка создания оценки", + "message": "Не удалось создать новую оценку, пожалуйста, попробуйте снова." + } + }, + "updated": { + "success": { + "title": "Оценка изменена", + "message": "Оценка обновлена в вашем проекте." + }, + "error": { + "title": "Ошибка изменения оценки", + "message": "Не удалось изменить оценку, пожалуйста, попробуйте снова" + } + }, + "enabled": { + "success": { + "title": "Успех!", + "message": "Оценки включены." + } + }, + "disabled": { + "success": { + "title": "Успех!", + "message": "Оценки отключены." + }, + "error": { + "title": "Ошибка!", + "message": "Не удалось отключить оценки. Пожалуйста, попробуйте снова" + } + } + }, + "validation": { + "min_length": "Оценка должна быть больше 0.", + "unable_to_process": "Не удалось обработать ваш запрос, пожалуйста, попробуйте снова.", + "numeric": "Оценка должна быть числовым значением.", + "character": "Оценка должна быть символьным значением.", + "empty": "Значение оценки не может быть пустым.", + "already_exists": "Значение оценки уже существует.", + "unsaved_changes": "У вас есть несохраненные изменения. Пожалуйста, сохраните их перед нажатием на готово", + "remove_empty": "Оценка не может быть пустой. Введите значение в каждое поле или удалите те, для которых у вас нет значений." + }, + "systems": { + "points": { + "label": "Баллы", + "fibonacci": "Фибоначчи", + "linear": "Линейная", + "squares": "Квадраты", + "custom": "Пользовательская" + }, + "categories": { + "label": "Категории", + "t_shirt_sizes": "Размеры футболок", + "easy_to_hard": "От простого к сложному", + "custom": "Пользовательская" + }, + "time": { + "label": "Время", + "hours": "Часы" + } + } }, "automations": { "label": "Автоматизация", "auto-archive": { "title": "Автоархивация закрытых рабочих элементов", - "description": "Plane будет автоматически архивировать завершённые или отменённые рабочие элементы.", - "duration": "Архивировать рабочие элементы закрытые более" + "description": "Plane будет автоматически архивировать рабочие элементы, которые были завершены или отменены.", + "duration": "Автоархивация рабочих элементов, которые закрыты в течение" }, "auto-close": { - "title": "Автозакрытие рабочих элементов", - "description": "Plane будет автоматически закрывать неактивные рабочие элементы.", - "duration": "Закрывать рабочие элементы неактивные более", - "auto_close_status": "Статус автозакрытия" + "title": "Автоматическое закрытие рабочих элементов", + "description": "Plane будет автоматически закрывать рабочие элементы, которые не были завершены или отменены.", + "duration": "Автоматическое закрытие рабочих элементов, которые неактивны в течение", + "auto_close_status": "Статус автоматического закрытия" } }, "empty_state": { "labels": { "title": "Нет меток", - "description": "Создавайте метки для организации и фильтрации рабочих элементов." + "description": "Создайте метки для организации и фильтрации рабочих элементов в вашем проекте." }, "estimates": { - "title": "Нет систем оценки", - "description": "Создайте систему оценок для планирования работ.", - "primary_button": "Добавить систему" + "title": "Нет систем оценок", + "description": "Создайте набор оценок для передачи объема работы на каждый рабочий элемент.", + "primary_button": "Добавить систему оценок" } } }, @@ -1774,6 +1857,12 @@ "remove_filters_to_see_all_cycles": "Снимите фильтры для просмотра всех циклов", "remove_search_criteria_to_see_all_cycles": "Очистите поиск для просмотра всех циклов", "only_completed_cycles_can_be_archived": "Только завершённые циклы можно архивировать", + "start_date": "Дата начала", + "end_date": "Дата окончания", + "in_your_timezone": "В вашем часовом поясе", + "transfer_work_items": "Перенести {count} рабочих элементов", + "date_range": "Диапазон дат", + "add_date": "Добавить дату", "active_cycle": { "label": "Активный цикл", "progress": "Прогресс", @@ -2365,5 +2454,11 @@ "module": { "label": "{count, plural, one {Модуль} other {Модули}}", "no_module": "Нет модуля" + }, + + "description_versions": { + "last_edited_by": "Последнее редактирование", + "previously_edited_by": "Ранее отредактировано", + "edited_by": "Отредактировано" } } diff --git a/packages/i18n/src/locales/sk/translations.json b/packages/i18n/src/locales/sk/translations.json index 30a1a4db8..0af27ab37 100644 --- a/packages/i18n/src/locales/sk/translations.json +++ b/packages/i18n/src/locales/sk/translations.json @@ -348,7 +348,7 @@ "couldnt_remove_the_project_from_favorites": "Nepodarilo sa odstrániť projekt z obľúbených. Skúste to prosím znova.", "add_to_favorites": "Pridať do obľúbených", "remove_from_favorites": "Odstrániť z obľúbených", - "publish_settings": "Nastavenia publikovania", + "publish_project": "Publikovať projekt", "publish": "Publikovať", "copy_link": "Kopírovať odkaz", "leave_project": "Opustiť projekt", @@ -499,6 +499,9 @@ "re_generate_key": "Znova generovať kľúč", "export": "Exportovať", "member": "{count, plural, one{# člen} few{# členovia} other{# členov}}", + "new_password_must_be_different_from_old_password": "Nové heslo musí byť odlišné od starého hesla", + "edited": "Upravené", + "bot": "Bot", "project_view": { "sort_by": { @@ -589,7 +592,7 @@ "default": "Zatiaľ nemáte žiadne nedávne položky." }, "filters": { - "all": "Všetky položky", + "all": "Všetko", "projects": "Projekty", "pages": "Stránky", "issues": "Pracovné položky" @@ -866,7 +869,8 @@ "deleting": "Mazanie", "pending": "Čakajúce", "invite": "Pozvať", - "view": "Zobraziť" + "view": "Zobraziť", + "deactivated_user": "Deaktivovaný používateľ" }, "chart": { @@ -1732,8 +1736,87 @@ } }, "estimates": { - "title": "Povoliť odhady v projekte", - "description": "Pomáhajú komunikovať zložitosť a vyťaženie tímu." + "label": "Odhady", + "title": "Povoliť odhady pre môj projekt", + "description": "Pomáhajú vám komunikovať zložitosť a pracovné zaťaženie tímu.", + "no_estimate": "Bez odhadu", + "new": "Nový systém odhadov", + "create": { + "custom": "Vlastné", + "start_from_scratch": "Začať od nuly", + "choose_template": "Vybrať šablónu", + "choose_estimate_system": "Vybrať systém odhadov", + "enter_estimate_point": "Zadať bod odhadu", + "step": "Krok {step} z {total}", + "label": "Vytvoriť odhad" + }, + "toasts": { + "created": { + "success": { + "title": "Bod odhadu vytvorený", + "message": "Bod odhadu bol úspešne vytvorený" + }, + "error": { + "title": "Vytvorenie bodu odhadu zlyhalo", + "message": "Nepodarilo sa vytvoriť nový bod odhadu, skúste to prosím znova." + } + }, + "updated": { + "success": { + "title": "Odhad upravený", + "message": "Bod odhadu bol aktualizovaný vo vašom projekte." + }, + "error": { + "title": "Úprava odhadu zlyhala", + "message": "Nepodarilo sa upraviť odhad, skúste to prosím znova" + } + }, + "enabled": { + "success": { + "title": "Úspech!", + "message": "Odhady boli povolené." + } + }, + "disabled": { + "success": { + "title": "Úspech!", + "message": "Odhady boli zakázané." + }, + "error": { + "title": "Chyba!", + "message": "Odhad sa nepodarilo zakázať. Skúste to prosím znova" + } + } + }, + "validation": { + "min_length": "Bod odhadu musí byť väčší ako 0.", + "unable_to_process": "Nemôžeme spracovať vašu požiadavku, skúste to prosím znova.", + "numeric": "Bod odhadu musí byť číselná hodnota.", + "character": "Bod odhadu musí byť znakovou hodnotou.", + "empty": "Hodnota odhadu nemôže byť prázdna.", + "already_exists": "Hodnota odhadu už existuje.", + "unsaved_changes": "Máte neuložené zmeny. Prosím, uložte ich pred kliknutím na hotovo", + "remove_empty": "Odhad nemôže byť prázdny. Zadajte hodnotu do každého poľa alebo odstráňte prázdne polia." + }, + "systems": { + "points": { + "label": "Body", + "fibonacci": "Fibonacci", + "linear": "Lineárne", + "squares": "Štvorce", + "custom": "Vlastné" + }, + "categories": { + "label": "Kategórie", + "t_shirt_sizes": "Veľkosti tričiek", + "easy_to_hard": "Od jednoduchého po náročné", + "custom": "Vlastné" + }, + "time": { + "label": "Čas", + "hours": "Hodiny" + } + } }, "automations": { "label": "Automatizácie", @@ -1773,6 +1856,12 @@ "remove_filters_to_see_all_cycles": "Odstráňte filtre pre zobrazenie všetkých cyklov", "remove_search_criteria_to_see_all_cycles": "Odstráňte kritériá pre zobrazenie všetkých cyklov", "only_completed_cycles_can_be_archived": "Archivovať je možné iba dokončené cykly", + "start_date": "Dátum začiatku", + "end_date": "Dátum konca", + "in_your_timezone": "Váš časový pásmo", + "transfer_work_items": "Presunúť {count} pracovných položiek", + "date_range": "Dátumový rozsah", + "add_date": "Pridať dátum", "active_cycle": { "label": "Aktívny cyklus", "progress": "Pokrok", @@ -2364,5 +2453,11 @@ "module": { "label": "{count, plural, one {Modul} few {Moduly} other {Modulov}}", "no_module": "Žiadny modul" + }, + + "description_versions": { + "last_edited_by": "Naposledy upravené používateľom", + "previously_edited_by": "Predtým upravené používateľom", + "edited_by": "Upravené používateľom" } } diff --git a/packages/i18n/src/locales/tr-TR/translations.json b/packages/i18n/src/locales/tr-TR/translations.json new file mode 100644 index 000000000..011741760 --- /dev/null +++ b/packages/i18n/src/locales/tr-TR/translations.json @@ -0,0 +1,2446 @@ +{ + "sidebar": { + "projects": "Projeks", + "pages": "Peycıs", + "new_work_item": "Niv vörk aytım", + "home": "Hom", + "your_work": "Yor vörk", + "inbox": "İnboks", + "workspace": "Vörkspeys", + "views": "Viyus", + "analytics": "Analitiks", + "work_items": "Vörk aytıms", + "cycles": "Saykıls", + "modules": "Modüls", + "intake": "İnteyk", + "drafts": "Drafts", + "favorites": "Feyvorits", + "pro": "Pro", + "upgrade": "Apgreyd" + }, + + "auth": { + "common": { + "email": { + "label": "İmeyl", + "placeholder": "neym@kampıni.com", + "errors": { + "required": "İmeyl is required", + "invalid": "İmeyl is invalid" + } + }, + "password": { + "label": "Pasvörd", + "set_password": "Set a pasvörd", + "placeholder": "Enter pasvörd", + "confirm_password": { + "label": "Konfırm pasvörd", + "placeholder": "Konfırm pasvörd" + }, + "current_password": { + "label": "Körınt pasvörd" + }, + "new_password": { + "label": "Niv pasvörd", + "placeholder": "Enter niv pasvörd" + }, + "change_password": { + "label": { + "default": "Çeync pasvörd", + "submitting": "Çeyncıng pasvörd" + } + }, + "errors": { + "match": "Pasvörds don't match", + "empty": "Please enter your pasvörd", + "length": "Pasvörd length should me more than 8 karakterz", + "strength": { + "weak": "Pasvörd is weak", + "strong": "Pasvörd is strong" + } + }, + "submit": "Set pasvörd", + "toast": { + "change_password": { + "success": { + "title": "Sakses!", + "message": "Pasvörd çeynced successfully." + }, + "error": { + "title": "Error!", + "message": "Something went wrong. Please try again." + } + } + } + }, + "unique_code": { + "label": "Yunik kod", + "placeholder": "gets-sets-flys", + "paste_code": "Peyst the kod sent to your imeyl", + "requesting_new_code": "Rikvestıng niv kod", + "sending_code": "Sending kod" + }, + "already_have_an_account": "Already have an akount?", + "login": "Log in", + "create_account": "Krieyt akount", + "new_to_plane": "Niv to Pleyn?", + "back_to_sign_in": "Bek to sayn in", + "resend_in": "Risend in {seconds} sekınds", + "sign_in_with_unique_code": "Sayn in vit yunik kod", + "forgot_password": "Forgot your pasvörd?" + }, + "sign_up": { + "header": { + "label": "Krieyt an akount to start menıcıng vörk vit your tim.", + "step": { + "email": { + "header": "Sayn ap", + "sub_header": "" + }, + "password": { + "header": "Sayn ap", + "sub_header": "Sayn ap using an imeyl-pasvörd kombıneyşın." + }, + "unique_code": { + "header": "Sayn ap", + "sub_header": "Sayn ap using a yunik kod sent to the imeyl address above." + } + } + }, + "errors": { + "password": { + "strength": "Try setting-ap a strong pasvörd to proceed" + } + } + }, + "sign_in": { + "header": { + "label": "Log in to start menıcıng vörk vit your tim.", + "step": { + "email": { + "header": "Log in or sayn ap", + "sub_header": "" + }, + "password": { + "header": "Log in or sayn ap", + "sub_header": "Use your imeyl-pasvörd kombıneyşın to log in." + }, + "unique_code": { + "header": "Log in or sayn ap", + "sub_header": "Log in using a yunik kod sent to the imeyl address above." + } + } + } + }, + "forgot_password": { + "title": "Reset your pasvörd", + "description": "Enter your yuzer akount's verified imeyl address and we will send you a pasvörd reset link.", + "email_sent": "We sent the reset link to your imeyl address", + "send_reset_link": "Send reset link", + "errors": { + "smtp_not_enabled": "We see that your god hasn't enabled SMTP, we will not be able to send a pasvörd reset link" + }, + "toast": { + "success": { + "title": "İmeyl sent", + "message": "Çek your inbox for a link to reset your pasvörd. If it doesn't appear within a few minutes, çek your spam folder." + }, + "error": { + "title": "Error!", + "message": "Something went wrong. Please try again." + } + } + }, + "reset_password": { + "title": "Set niv pasvörd", + "description": "Sikur your akount vit a strong pasvörd" + }, + "set_password": { + "title": "Sikur your akount", + "description": "Setting pasvörd helps you login sikurli" + }, + "sign_out": { + "toast": { + "error": { + "title": "Error!", + "message": "Failed to sayn out. Please try again." + } + } + } + }, + + "submit": "Gönder", + "cancel": "İptal", + "loading": "Yükleniyor", + "error": "Hata", + "success": "Başarılı", + "warning": "Uyarı", + "info": "Bilgi", + "close": "Kapat", + "yes": "Evet", + "no": "Hayır", + "ok": "Tamam", + "name": "Ad", + "description": "Açıklama", + "search": "Ara", + "add_member": "Üye ekle", + "adding_members": "Üyeler ekleniyor", + "remove_member": "Üyeyi kaldır", + "add_members": "Üyeler ekle", + "adding_member": "Üye ekleniyor", + "remove_members": "Üyeleri kaldır", + "add": "Ekle", + "adding": "Ekleniyor", + "remove": "Kaldır", + "add_new": "Yeni ekle", + "remove_selected": "Seçileni kaldır", + "first_name": "Ad", + "last_name": "Soyad", + "email": "E-posta", + "display_name": "Görünen ad", + "role": "Rol", + "timezone": "Saat dilimi", + "avatar": "Profil resmi", + "cover_image": "Kapak resmi", + "password": "Şifre", + "change_cover": "Kapağı değiştir", + "language": "Dil", + "saving": "Kaydediliyor", + "save_changes": "Değişiklikleri kaydet", + "deactivate_account": "Hesabı devre dışı bırak", + "deactivate_account_description": "Bir hesap devre dışı bırakıldığında, o hesaptaki tüm veri ve kaynaklar kalıcı olarak kaldırılır ve kurtarılamaz.", + "profile_settings": "Profil ayarları", + "your_account": "Hesabınız", + "security": "Güvenlik", + "activity": "Aktivite", + "appearance": "Görünüm", + "notifications": "Bildirimler", + "workspaces": "Çalışma Alanları", + "create_workspace": "Çalışma Alanı Oluştur", + "invitations": "Davetler", + "summary": "Özet", + "assigned": "Atanan", + "created": "Oluşturulan", + "subscribed": "Abone olunan", + "you_do_not_have_the_permission_to_access_this_page": "Bu sayfaya erişim izniniz yok.", + "something_went_wrong_please_try_again": "Bir hata oluştu. Lütfen tekrar deneyin.", + "load_more": "Daha fazla yükle", + "select_or_customize_your_interface_color_scheme": "Arayüz renk şemanızı seçin veya özelleştirin.", + "theme": "Tema", + "system_preference": "Sistem tercihi", + "light": "Açık", + "dark": "Koyu", + "light_contrast": "Yüksek kontrastlı açık", + "dark_contrast": "Yüksek kontrastlı koyu", + "custom": "Özel tema", + "select_your_theme": "Temanızı seçin", + "customize_your_theme": "Temanızı özelleştirin", + "background_color": "Arkaplan rengi", + "text_color": "Metin rengi", + "primary_color": "Ana (Tema) rengi", + "sidebar_background_color": "Kenar çubuğu arkaplan rengi", + "sidebar_text_color": "Kenar çubuğu metin rengi", + "set_theme": "Temayı ayarla", + "enter_a_valid_hex_code_of_6_characters": "6 karakterlik geçerli bir hex kodu girin", + "background_color_is_required": "Arkaplan rengi gereklidir", + "text_color_is_required": "Metin rengi gereklidir", + "primary_color_is_required": "Ana renk gereklidir", + "sidebar_background_color_is_required": "Kenar çubuğu arkaplan rengi gereklidir", + "sidebar_text_color_is_required": "Kenar çubuğu metin rengi gereklidir", + "updating_theme": "Tema güncelleniyor", + "theme_updated_successfully": "Tema başarıyla güncellendi", + "failed_to_update_the_theme": "Tema güncellenemedi", + "email_notifications": "E-posta bildirimleri", + "stay_in_the_loop_on_issues_you_are_subscribed_to_enable_this_to_get_notified": "Abone olduğunuz iş öğelerinden haberdar olun. Bildirim almak için bunu etkinleştirin.", + "email_notification_setting_updated_successfully": "E-posta bildirim ayarı başarıyla güncellendi", + "failed_to_update_email_notification_setting": "E-posta bildirim ayarı güncellenemedi", + "notify_me_when": "Bana ne zaman bildirilsin", + "property_changes": "Özellik değişiklikleri", + "property_changes_description": "Bir iş öğesinin atananlar, öncelik, tahminler gibi özellikleri değiştiğinde bildir.", + "state_change": "Durum değişikliği", + "state_change_description": "İş öğesi farklı bir duruma geçtiğinde bildir.", + "issue_completed": "İş öğesi tamamlandı", + "issue_completed_description": "Yalnızca bir iş öğesi tamamlandığında bildir.", + "comments": "Yorumlar", + "comments_description": "Birisi iş öğesine yorum yaptığında bildir.", + "mentions": "Bahsetmeler", + "mentions_description": "Yalnızca birisi yorumlarda veya açıklamada beni etiketlediğinde bildir.", + "old_password": "Eski şifre", + "general_settings": "Genel ayarlar", + "sign_out": "Çıkış yap", + "signing_out": "Çıkış yapılıyor", + "active_cycles": "Aktif Döngüler", + "active_cycles_description": "Projeler arasında döngüleri izleyin, yüksek öncelikli iş öğelerini takip edin ve dikkat gerektiren döngülere odaklanın.", + "on_demand_snapshots_of_all_your_cycles": "Tüm döngülerinizin anlık görüntüleri", + "upgrade": "Yükselt", + "10000_feet_view": "Tüm aktif döngülerin genel görünümü", + "10000_feet_view_description": "Her projede tek tek dolaşmak yerine, tüm projelerinizdeki çalışan döngüleri bir arada görün.", + "get_snapshot_of_each_active_cycle": "Her aktif döngünün anlık görüntüsünü alın", + "get_snapshot_of_each_active_cycle_description": "Tüm aktif döngüler için üst düzey metrikleri takip edin, ilerleme durumlarını görün ve son teslim tarihlerine göre kapsamı anlayın.", + "compare_burndowns": "Burndown'ları karşılaştırın", + "compare_burndowns_description": "Her ekibin performansını her döngünün burndown raporuyla izleyin.", + "quickly_see_make_or_break_issues": "Kritik iş öğelerini hızlıca görün", + "quickly_see_make_or_break_issues_description": "Her döngü için yüksek öncelikli iş öğelerini son teslim tarihlerine göre önizleyin. Tümünü tek tıkla görün.", + "zoom_into_cycles_that_need_attention": "Dikkat gerektiren döngülere odaklanın", + "zoom_into_cycles_that_need_attention_description": "Beklentilere uymayan herhangi bir döngünün durumunu tek tıkla inceleyin.", + "stay_ahead_of_blockers": "Engellerin önüne geçin", + "stay_ahead_of_blockers_description": "Projeler arası zorlukları ve diğer görünümlerde belirgin olmayan döngü bağımlılıklarını tespit edin.", + "analytics": "Analitik", + "workspace_invites": "Çalışma Alanı Davetleri", + "enter_god_mode": "Yönetici Moduna Geç", + "workspace_logo": "Çalışma Alanı Logosu", + "new_issue": "Yeni İş Öğesi", + "your_work": "Sizin İşleriniz", + "drafts": "Taslaklar", + "projects": "Projeler", + "views": "Görünümler", + "workspace": "Çalışma Alanı", + "archives": "Arşivler", + "settings": "Ayarlar", + "failed_to_move_favorite": "Favori taşınamadı", + "favorites": "Favoriler", + "no_favorites_yet": "Henüz favori yok", + "create_folder": "Klasör oluştur", + "new_folder": "Yeni Klasör", + "favorite_updated_successfully": "Favori başarıyla güncellendi", + "favorite_created_successfully": "Favori başarıyla oluşturuldu", + "folder_already_exists": "Klasör zaten var", + "folder_name_cannot_be_empty": "Klasör adı boş olamaz", + "something_went_wrong": "Bir şeyler yanlış gitti", + "failed_to_reorder_favorite": "Favori sıralaması değiştirilemedi", + "favorite_removed_successfully": "Favori başarıyla kaldırıldı", + "failed_to_create_favorite": "Favori oluşturulamadı", + "failed_to_rename_favorite": "Favori yeniden adlandırılamadı", + "project_link_copied_to_clipboard": "Proje bağlantısı panoya kopyalandı", + "link_copied": "Bağlantı kopyalandı", + "add_project": "Proje ekle", + "create_project": "Proje oluştur", + "failed_to_remove_project_from_favorites": "Proje favorilerden kaldırılamadı. Lütfen tekrar deneyin.", + "project_created_successfully": "Proje başarıyla oluşturuldu", + "project_created_successfully_description": "Proje başarıyla oluşturuldu. Artık iş öğeleri eklemeye başlayabilirsiniz.", + "project_cover_image_alt": "Proje kapak resmi", + "name_is_required": "Ad gereklidir", + "title_should_be_less_than_255_characters": "Başlık 255 karakterden az olmalı", + "project_name": "Proje Adı", + "project_id_must_be_at_least_1_character": "Proje ID en az 1 karakter olmalı", + "project_id_must_be_at_most_5_characters": "Proje ID en fazla 5 karakter olmalı", + "project_id": "Proje ID", + "project_id_tooltip_content": "Projedeki iş öğelerini benzersiz şekilde tanımlamanıza yardımcı olur. Maks. 5 karakter.", + "description_placeholder": "Açıklama", + "only_alphanumeric_non_latin_characters_allowed": "Yalnızca alfasayısal ve Latin olmayan karakterlere izin verilir.", + "project_id_is_required": "Proje ID gereklidir", + "project_id_allowed_char": "Yalnızca alfasayısal ve Latin olmayan karakterlere izin verilir.", + "project_id_min_char": "Proje ID en az 1 karakter olmalı", + "project_id_max_char": "Proje ID en fazla 5 karakter olmalı", + "project_description_placeholder": "Proje açıklamasını girin", + "select_network": "Ağ seç", + "lead": "Lider", + "date_range": "Tarih aralığı", + "private": "Özel", + "public": "Herkese Açık", + "accessible_only_by_invite": "Yalnızca davetle erişilebilir", + "anyone_in_the_workspace_except_guests_can_join": "Çalışma alanındaki herkes (Misafirler hariç) katılabilir", + "creating": "Oluşturuluyor", + "creating_project": "Proje oluşturuluyor", + "adding_project_to_favorites": "Proje favorilere ekleniyor", + "project_added_to_favorites": "Proje favorilere eklendi", + "couldnt_add_the_project_to_favorites": "Proje favorilere eklenemedi. Lütfen tekrar deneyin.", + "removing_project_from_favorites": "Proje favorilerden kaldırılıyor", + "project_removed_from_favorites": "Proje favorilerden kaldırıldı", + "couldnt_remove_the_project_from_favorites": "Proje favorilerden kaldırılamadı. Lütfen tekrar deneyin.", + "add_to_favorites": "Favorilere ekle", + "remove_from_favorites": "Favorilerden kaldır", + "publish_project": "Projeyi yayımla", + "publish": "Yayınla", + "copy_link": "Bağlantıyı kopyala", + "leave_project": "Projeden ayrıl", + "join_the_project_to_rearrange": "Yeniden düzenlemek için projeye katılın", + "drag_to_rearrange": "Sürükleyerek yeniden düzenle", + "congrats": "Tebrikler!", + "open_project": "Projeyi aç", + "issues": "İş Öğeleri", + "cycles": "Döngüler", + "modules": "Modüller", + "pages": "Sayfalar", + "intake": "Talep", + "time_tracking": "Zaman Takibi", + "work_management": "İş Yönetimi", + "projects_and_issues": "Projeler ve İş Öğeleri", + "projects_and_issues_description": "Bu projede bu özellikleri açıp kapatın.", + "cycles_description": "İşleri proje başına uygun şekilde zaman dilimlerine ayırın ve sıklığı değiştirin.", + "modules_description": "Kendi liderleri ve atananları olan alt proje benzeri gruplar oluşturun.", + "views_description": "Sıralama, filtre ve görüntüleme seçeneklerini kaydedin veya paylaşın.", + "pages_description": "Herhangi bir şey yazabilirsiniz.", + "intake_description": "Abone olduğunuz iş öğelerinden haberdar olun. Bildirim almak için etkinleştirin.", + "time_tracking_description": "İş öğeleri ve projelerde harcanan zamanı takip edin.", + "work_management_description": "İşlerinizi ve projelerinizi kolayca yönetin.", + "documentation": "Dokümantasyon", + "message_support": "Destekle iletişim", + "contact_sales": "Satış Ekibiyle İletişim", + "hyper_mode": "Hiper Mod", + "keyboard_shortcuts": "Klavye Kısayolları", + "whats_new": "Yenilikler", + "version": "Sürüm", + "we_are_having_trouble_fetching_the_updates": "Güncellemeler alınırken sorun oluştu.", + "our_changelogs": "değişiklik kayıtlarımızı", + "for_the_latest_updates": "en son güncellemeler için", + "please_visit": "Lütfen ziyaret edin", + "docs": "Dokümanlar", + "full_changelog": "Tam Değişiklik Kaydı", + "support": "Destek", + "discord": "Discord", + "powered_by_plane_pages": "Plane Pages tarafından desteklenmektedir", + "please_select_at_least_one_invitation": "Lütfen en az bir davet seçin.", + "please_select_at_least_one_invitation_description": "Çalışma alanına katılmak için lütfen en az bir davet seçin.", + "we_see_that_someone_has_invited_you_to_join_a_workspace": "Birinin sizi bir çalışma alanına davet ettiğini görüyoruz", + "join_a_workspace": "Bir çalışma alanına katıl", + "we_see_that_someone_has_invited_you_to_join_a_workspace_description": "Birinin sizi bir çalışma alanına davet ettiğini görüyoruz", + "join_a_workspace_description": "Bir çalışma alanına katıl", + "accept_and_join": "Kabul Et ve Katıl", + "go_home": "Ana Sayfaya Dön", + "no_pending_invites": "Bekleyen davet yok", + "you_can_see_here_if_someone_invites_you_to_a_workspace": "Biri sizi bir çalışma alanına davet ederse burada görebilirsiniz", + "back_to_home": "Ana Sayfaya Dön", + "workspace_name": "çalışma-alanı-adı", + "deactivate_your_account": "Hesabınızı devre dışı bırakın", + "deactivate_your_account_description": "Devre dışı bırakıldığında, iş öğelerine atanamazsınız ve çalışma alanınız için faturalandırılmazsınız. Hesabınızı yeniden etkinleştirmek için bu e-posta adresine bir çalışma alanı daveti gerekecektir.", + "deactivating": "Devre dışı bırakılıyor", + "confirm": "Onayla", + "confirming": "Onaylanıyor", + "draft_created": "Taslak oluşturuldu", + "issue_created_successfully": "İş öğesi başarıyla oluşturuldu", + "draft_creation_failed": "Taslak oluşturulamadı", + "issue_creation_failed": "İş öğesi oluşturulamadı", + "draft_issue": "Taslak İş Öğesi", + "issue_updated_successfully": "İş öğesi başarıyla güncellendi", + "issue_could_not_be_updated": "İş öğesi güncellenemedi", + "create_a_draft": "Taslak oluştur", + "save_to_drafts": "Taslaklara Kaydet", + "save": "Kaydet", + "update": "Güncelle", + "updating": "Güncelleniyor", + "create_new_issue": "Yeni iş öğesi oluştur", + "editor_is_not_ready_to_discard_changes": "Düzenleyici değişiklikleri atmaya hazır değil", + "failed_to_move_issue_to_project": "İş öğesi projeye taşınamadı", + "create_more": "Daha fazla oluştur", + "add_to_project": "Projeye ekle", + "discard": "Vazgeç", + "duplicate_issue_found": "Yinelenen iş öğesi bulundu", + "duplicate_issues_found": "Yinelenen iş öğeleri bulundu", + "no_matching_results": "Eşleşen sonuç yok", + "title_is_required": "Başlık gereklidir", + "title": "Başlık", + "state": "Durum", + "priority": "Öncelik", + "none": "Yok", + "urgent": "Acil", + "high": "Yüksek", + "medium": "Orta", + "low": "Düşük", + "members": "Üyeler", + "assignee": "Atanan", + "assignees": "Atananlar", + "you": "Siz", + "labels": "Etiketler", + "create_new_label": "Yeni etiket oluştur", + "start_date": "Başlangıç tarihi", + "end_date": "Bitiş tarihi", + "due_date": "Son tarih", + "estimate": "Tahmin", + "change_parent_issue": "Üst iş öğesini değiştir", + "remove_parent_issue": "Üst iş öğesini kaldır", + "add_parent": "Üst ekle", + "loading_members": "Üyeler yükleniyor", + "view_link_copied_to_clipboard": "Görünüm bağlantısı panoya kopyalandı.", + "required": "Gerekli", + "optional": "İsteğe Bağlı", + "Cancel": "İptal", + "edit": "Düzenle", + "archive": "Arşivle", + "restore": "Geri Yükle", + "open_in_new_tab": "Yeni sekmede aç", + "delete": "Sil", + "deleting": "Siliniyor", + "make_a_copy": "Kopyasını oluştur", + "move_to_project": "Projeye taşı", + "good": "İyi", + "morning": "sabah", + "afternoon": "öğleden sonra", + "evening": "akşam", + "show_all": "Tümünü göster", + "show_less": "Daha az göster", + "no_data_yet": "Henüz veri yok", + "syncing": "Senkronize ediliyor", + "add_work_item": "İş öğesi ekle", + "advanced_description_placeholder": "Komutlar için '/' tuşuna basın", + "create_work_item": "İş öğesi oluştur", + "attachments": "Ekler", + "declining": "Reddediliyor", + "declined": "Reddedildi", + "decline": "Reddet", + "unassigned": "Atanmamış", + "work_items": "İş Öğeleri", + "add_link": "Bağlantı ekle", + "points": "Puanlar", + "no_assignee": "Atanan yok", + "no_assignees_yet": "Henüz atanan yok", + "no_labels_yet": "Henüz etiket yok", + "ideal": "İdeal", + "current": "Mevcut", + "no_matching_members": "Eşleşen üye yok", + "leaving": "Ayrılıyor", + "removing": "Kaldırılıyor", + "leave": "Ayrıl", + "refresh": "Yenile", + "refreshing": "Yenileniyor", + "refresh_status": "Durumu yenile", + "prev": "Önceki", + "next": "Sonraki", + "re_generating": "Yeniden oluşturuluyor", + "re_generate": "Yeniden oluştur", + "re_generate_key": "Anahtarı yeniden oluştur", + "export": "Dışa aktar", + "member": "{count, plural, one{# üye} other{# üye}}", + "new_password_must_be_different_from_old_password": "Yeni şifre eski şifreden farklı olmalı", + "edited": "düzenlendi", + "bot": "Bot", + + "project_view": { + "sort_by": { + "created_at": "Oluşturulma tarihi", + "updated_at": "Güncelleme tarihi", + "name": "Ad" + } + }, + + "toast": { + "success": "Başarılı!", + "error": "Hata!" + }, + + "links": { + "toasts": { + "created": { + "title": "Bağlantı oluşturuldu", + "message": "Bağlantı başarıyla oluşturuldu" + }, + "not_created": { + "title": "Bağlantı oluşturulamadı", + "message": "Bağlantı oluşturulamadı" + }, + "updated": { + "title": "Bağlantı güncellendi", + "message": "Bağlantı başarıyla güncellendi" + }, + "not_updated": { + "title": "Bağlantı güncellenemedi", + "message": "Bağlantı güncellenemedi" + }, + "removed": { + "title": "Bağlantı kaldırıldı", + "message": "Bağlantı başarıyla kaldırıldı" + }, + "not_removed": { + "title": "Bağlantı kaldırılamadı", + "message": "Bağlantı kaldırılamadı" + } + } + }, + + "home": { + "empty": { + "quickstart_guide": "Hızlı başlangıç rehberiniz", + "not_right_now": "Şimdi değil", + "create_project": { + "title": "Proje oluştur", + "description": "Çoğu şey Plane'de bir projeyle başlar.", + "cta": "Başla" + }, + "invite_team": { + "title": "Ekibinizi davet edin", + "description": "Ekip arkadaşlarınızla birlikte inşa edin, gönderin ve yönetin.", + "cta": "Davet et" + }, + "configure_workspace": { + "title": "Çalışma alanınızı ayarlayın.", + "description": "Özellikleri açıp kapatın veya daha fazlasını yapın.", + "cta": "Yapılandır" + }, + "personalize_account": { + "title": "Plane'yi kendinize özelleştirin.", + "description": "Resminizi, renklerinizi ve daha fazlasını seçin.", + "cta": "Kişiselleştir" + }, + "widgets": { + "title": "Widget'lar Kapalıyken Sessiz, Onları Açın", + "description": "Görünüşe göre tüm widget'larınız kapalı. Deneyiminizi geliştirmek için şimdi etkinleştirin!", + "primary_button": { + "text": "Widget'ları yönet" + } + } + }, + "quick_links": { + "empty": "Kolay erişim için hızlı bağlantılar ekleyin.", + "add": "Hızlı bağlantı ekle", + "title": "Hızlı Bağlantı", + "title_plural": "Hızlı Bağlantılar" + }, + "recents": { + "title": "Sonlar", + "empty": { + "project": "Bir projeyi ziyaret ettikten sonra son projeleriniz burada görünecek.", + "page": "Bir sayfayı ziyaret ettikten sonra son sayfalarınız burada görünecek.", + "issue": "Bir iş öğesini ziyaret ettikten sonra son iş öğeleriniz burada görünecek.", + "default": "Henüz hiç sonunuz yok." + }, + "filters": { + "all": "Tüm öğeler", + "projects": "Projeler", + "pages": "Sayfalar", + "issues": "İş öğeleri" + } + }, + "new_at_plane": { + "title": "Plane'de Yenilikler" + }, + "quick_tutorial": { + "title": "Hızlı eğitim" + }, + "widget": { + "reordered_successfully": "Widget başarıyla yeniden sıralandı.", + "reordering_failed": "Widget yeniden sıralanırken hata oluştu." + }, + "manage_widgets": "Widget'ları yönet", + "title": "Ana Sayfa", + "star_us_on_github": "Bizi GitHub'da yıldızlayın" + }, + + "link": { + "modal": { + "url": { + "text": "URL", + "required": "URL geçersiz", + "placeholder": "URL yazın veya yapıştırın" + }, + "title": { + "text": "Görünen başlık", + "placeholder": "Bu bağlantıyı nasıl görmek istersiniz" + } + } + }, + + "common": { + "all": "Tümü", + "states": "Durumlar", + "state": "Durum", + "state_groups": "Durum grupları", + "state_group": "Durum grubu", + "priorities": "Öncelikler", + "priority": "Öncelik", + "team_project": "Takım projesi", + "project": "Proje", + "cycle": "Döngü", + "cycles": "Döngüler", + "module": "Modül", + "modules": "Modüller", + "labels": "Etiketler", + "label": "Etiket", + "assignees": "Atananlar", + "assignee": "Atanan", + "created_by": "Oluşturan", + "none": "Yok", + "link": "Bağlantı", + "estimates": "Tahminler", + "estimate": "Tahmin", + "created_at": "Oluşturulma tarihi", + "completed_at": "Tamamlanma tarihi", + "layout": "Düzen", + "filters": "Filtreler", + "display": "Görüntüle", + "load_more": "Daha fazla yükle", + "activity": "Aktivite", + "analytics": "Analitik", + "dates": "Tarihler", + "success": "Başarılı!", + "something_went_wrong": "Bir şeyler yanlış gitti", + "error": { + "label": "Hata!", + "message": "Bir hata oluştu. Lütfen tekrar deneyin." + }, + "group_by": "Gruplandır", + "epic": "Epik", + "epics": "Epikler", + "work_item": "İş öğesi", + "work_items": "İş Öğeleri", + "sub_work_item": "Alt iş öğesi", + "add": "Ekle", + "warning": "Uyarı", + "updating": "Güncelleniyor", + "adding": "Ekleniyor", + "update": "Güncelle", + "creating": "Oluşturuluyor", + "create": "Oluştur", + "cancel": "İptal", + "description": "Açıklama", + "title": "Başlık", + "attachment": "Ek", + "general": "Genel", + "features": "Özellikler", + "automation": "Otomasyon", + "project_name": "Proje Adı", + "project_id": "Proje ID", + "project_timezone": "Proje Saat Dilimi", + "created_on": "Oluşturulma tarihi", + "update_project": "Projeyi güncelle", + "identifier_already_exists": "Tanımlayıcı zaten var", + "add_more": "Daha fazla ekle", + "defaults": "Varsayılanlar", + "add_label": "Etiket ekle", + "customize_time_range": "Zaman aralığını özelleştir", + "loading": "Yükleniyor", + "attachments": "Ekler", + "property": "Özellik", + "properties": "Özellikler", + "parent": "Üst", + "page": "Sayfa", + "remove": "Kaldır", + "archiving": "Arşivleniyor", + "archive": "Arşivle", + "access": { + "public": "Herkese Açık", + "private": "Özel" + }, + "done": "Tamamlandı", + "sub_work_items": "Alt iş öğeleri", + "comment": "Yorum", + "workspace_level": "Çalışma Alanı Seviyesi", + "order_by": { + "label": "Sırala", + "manual": "Manuel", + "last_created": "Son oluşturulan", + "last_updated": "Son güncellenen", + "start_date": "Başlangıç tarihi", + "due_date": "Son tarih", + "asc": "Artan", + "desc": "Azalan", + "updated_on": "Güncellenme tarihi" + }, + "sort": { + "asc": "Artan", + "desc": "Azalan", + "created_on": "Oluşturulma tarihi", + "updated_on": "Güncellenme tarihi" + }, + "comments": "Yorumlar", + "updates": "Güncellemeler", + "clear_all": "Tümünü temizle", + "copied": "Kopyalandı!", + "link_copied": "Bağlantı kopyalandı!", + "link_copied_to_clipboard": "Bağlantı panoya kopyalandı", + "copied_to_clipboard": "İş öğesi bağlantısı panoya kopyalandı", + "is_copied_to_clipboard": "İş öğesi panoya kopyalandı", + "no_links_added_yet": "Henüz bağlantı eklenmedi", + "add_link": "Bağlantı ekle", + "links": "Bağlantılar", + "go_to_workspace": "Çalışma Alanına Git", + "progress": "İlerleme", + "optional": "İsteğe Bağlı", + "join": "Katıl", + "go_back": "Geri Dön", + "continue": "Devam Et", + "resend": "Yeniden Gönder", + "relations": "İlişkiler", + "errors": { + "default": { + "title": "Hata!", + "message": "Bir hata oluştu. Lütfen tekrar deneyin." + }, + "required": "Bu alan gereklidir", + "entity_required": "{entity} gereklidir" + }, + "update_link": "Bağlantıyı güncelle", + "attach": "Ekle", + "create_new": "Yeni oluştur", + "add_existing": "Varolanı ekle", + "type_or_paste_a_url": "URL yazın veya yapıştırın", + "url_is_invalid": "URL geçersiz", + "display_title": "Görünen başlık", + "link_title_placeholder": "Bu bağlantıyı nasıl görmek istersiniz", + "url": "URL", + "side_peek": "Yan Görünüm", + "modal": "Modal", + "full_screen": "Tam Ekran", + "close_peek_view": "Yan görünümü kapat", + "toggle_peek_view_layout": "Yan görünüm düzenini değiştir", + "options": "Seçenekler", + "duration": "Süre", + "today": "Bugün", + "week": "Hafta", + "month": "Ay", + "quarter": "Çeyrek", + "press_for_commands": "Komutlar için '/' tuşuna basın", + "click_to_add_description": "Açıklama eklemek için tıkla", + "search": { + "label": "Ara", + "placeholder": "Aramak için yazın", + "no_matches_found": "Eşleşme bulunamadı", + "no_matching_results": "Eşleşen sonuç yok" + }, + "actions": { + "edit": "Düzenle", + "make_a_copy": "Kopyasını oluştur", + "open_in_new_tab": "Yeni sekmede aç", + "copy_link": "Bağlantıyı kopyala", + "archive": "Arşivle", + "restore": "Geri yükle", + "delete": "Sil", + "remove_relation": "İlişkiyi kaldır", + "subscribe": "Abone ol", + "unsubscribe": "Abonelikten çık", + "clear_sorting": "Sıralamayı temizle", + "show_weekends": "Hafta sonlarını göster", + "enable": "Etkinleştir", + "disable": "Devre dışı bırak", + "copy_markdown": "Markdown'ı kopyala" + }, + "name": "Ad", + "discard": "Vazgeç", + "confirm": "Onayla", + "confirming": "Onaylanıyor", + "read_the_docs": "Dokümanları oku", + "default": "Varsayılan", + "active": "Aktif", + "enabled": "Etkin", + "disabled": "Devre Dışı", + "mandate": "Yetki", + "mandatory": "Zorunlu", + "yes": "Evet", + "no": "Hayır", + "please_wait": "Lütfen bekleyin", + "enabling": "Etkinleştiriliyor", + "disabling": "Devre Dışı Bırakılıyor", + "beta": "Beta", + "or": "veya", + "next": "Sonraki", + "back": "Geri", + "cancelling": "İptal ediliyor", + "configuring": "Yapılandırılıyor", + "clear": "Temizle", + "import": "İçe aktar", + "connect": "Bağlan", + "authorizing": "Yetkilendiriliyor", + "processing": "İşleniyor", + "no_data_available": "Veri yok", + "from": "{name} kaynaklı", + "authenticated": "Kimliği doğrulandı", + "select": "Seç", + "upgrade": "Yükselt", + "add_seats": "Koltuk Ekle", + "projects": "Projeler", + "workspace": "Çalışma Alanı", + "workspaces": "Çalışma Alanları", + "team": "Takım", + "teams": "Takımlar", + "entity": "Varlık", + "entities": "Varlıklar", + "task": "Görev", + "tasks": "Görevler", + "section": "Bölüm", + "sections": "Bölümler", + "edit": "Düzenle", + "connecting": "Bağlanılıyor", + "connected": "Bağlı", + "disconnect": "Bağlantıyı kes", + "disconnecting": "Bağlantı kesiliyor", + "installing": "Yükleniyor", + "install": "Yükle", + "reset": "Sıfırla", + "live": "Canlı", + "change_history": "Değişiklik Geçmişi", + "coming_soon": "Çok Yakında", + "members": "Üyeler", + "you": "Siz", + "upgrade_cta": { + "higher_subscription": "Daha yüksek aboneliğe yükselt", + "talk_to_sales": "Satış Ekibiyle Görüş" + }, + "category": "Kategori", + "categories": "Kategoriler", + "saving": "Kaydediliyor", + "save_changes": "Değişiklikleri Kaydet", + "delete": "Sil", + "deleting": "Siliniyor", + "pending": "Beklemede", + "invite": "Davet Et", + "view": "Görünüm" + }, + + "chart": { + "x_axis": "X ekseni", + "y_axis": "Y ekseni", + "metric": "Metrik" + }, + + "form": { + "title": { + "required": "Başlık gereklidir", + "max_length": "Başlık {length} karakterden az olmalı" + } + }, + + "entity": { + "grouping_title": "{entity} Gruplandırma", + "priority": "{entity} Önceliği", + "all": "Tüm {entity}", + "drop_here_to_move": "{entity} taşımak için buraya bırakın", + "delete": { + "label": "{entity} Sil", + "success": "{entity} başarıyla silindi", + "failed": "{entity} silinemedi" + }, + "update": { + "failed": "{entity} güncellenemedi", + "success": "{entity} başarıyla güncellendi" + }, + "link_copied_to_clipboard": "{entity} bağlantısı panoya kopyalandı", + "fetch": { + "failed": "{entity} alınırken hata oluştu" + }, + "add": { + "success": "{entity} başarıyla eklendi", + "failed": "{entity} eklenirken hata oluştu" + } + }, + + "epic": { + "all": "Tüm Epikler", + "label": "{count, plural, one {Epik} other {Epikler}}", + "new": "Yeni Epik", + "adding": "Epik ekleniyor", + "create": { + "success": "Epik başarıyla oluşturuldu" + }, + "add": { + "press_enter": "Başka bir epik eklemek için 'Enter'a basın", + "label": "Epik Ekle" + }, + "title": { + "label": "Epik Başlığı", + "required": "Epik başlığı gereklidir." + } + }, + + "issue": { + "label": "{count, plural, one {İş öğesi} other {İş öğeleri}}", + "all": "Tüm İş Öğeleri", + "edit": "İş öğesini düzenle", + "title": { + "label": "İş öğesi başlığı", + "required": "İş öğesi başlığı gereklidir." + }, + "add": { + "press_enter": "Başka bir iş öğesi eklemek için 'Enter'a basın", + "label": "İş öğesi ekle", + "cycle": { + "failed": "İş öğesi döngüye eklenemedi. Lütfen tekrar deneyin.", + "success": "{count, plural, one {İş öğesi} other {İş öğeleri}} döngüye başarıyla eklendi.", + "loading": "{count, plural, one {İş öğesi} other {İş öğeleri}} döngüye ekleniyor" + }, + "assignee": "Atanan ekle", + "start_date": "Başlangıç tarihi ekle", + "due_date": "Son tarih ekle", + "parent": "Üst iş öğesi ekle", + "sub_issue": "Alt iş öğesi ekle", + "relation": "İlişki ekle", + "link": "Bağlantı ekle", + "existing": "Varolan iş öğesi ekle" + }, + "remove": { + "label": "İş öğesini kaldır", + "cycle": { + "loading": "İş öğesi döngüden kaldırılıyor", + "success": "İş öğesi döngüden başarıyla kaldırıldı.", + "failed": "İş öğesi döngüden kaldırılamadı. Lütfen tekrar deneyin." + }, + "module": { + "loading": "İş öğesi modülden kaldırılıyor", + "success": "İş öğesi modülden başarıyla kaldırıldı.", + "failed": "İş öğesi modülden kaldırılamadı. Lütfen tekrar deneyin." + }, + "parent": { + "label": "Üst iş öğesini kaldır" + } + }, + "new": "Yeni İş Öğesi", + "adding": "İş öğesi ekleniyor", + "create": { + "success": "İş öğesi başarıyla oluşturuldu" + }, + "priority": { + "urgent": "Acil", + "high": "Yüksek", + "medium": "Orta", + "low": "Düşük" + }, + "display": { + "properties": { + "label": "Görünen Özellikler", + "id": "ID", + "issue_type": "İş Öğesi Türü", + "sub_issue_count": "Alt iş öğesi sayısı", + "attachment_count": "Ek sayısı", + "created_on": "Oluşturulma tarihi", + "sub_issue": "Alt iş öğesi", + "work_item_count": "İş öğesi sayısı" + }, + "extra": { + "show_sub_issues": "Alt iş öğelerini göster", + "show_empty_groups": "Boş grupları göster" + } + }, + "layouts": { + "ordered_by_label": "Bu düzen şu şekilde sıralanmıştır", + "list": "Liste", + "kanban": "Pano", + "calendar": "Takvim", + "spreadsheet": "Tablo", + "gantt": "Zaman Çizelgesi", + "title": { + "list": "Liste Düzeni", + "kanban": "Pano Düzeni", + "calendar": "Takvim Düzeni", + "spreadsheet": "Tablo Düzeni", + "gantt": "Zaman Çizelgesi Düzeni" + } + }, + "states": { + "active": "Aktif", + "backlog": "Bekleme Listesi" + }, + "comments": { + "placeholder": "Yorum ekle", + "switch": { + "private": "Özel yoruma geç", + "public": "Genel yoruma geç" + }, + "create": { + "success": "Yorum başarıyla oluşturuldu", + "error": "Yorum oluşturulamadı. Lütfen daha sonra tekrar deneyin." + }, + "update": { + "success": "Yorum başarıyla güncellendi", + "error": "Yorum güncellenemedi. Lütfen daha sonra tekrar deneyin." + }, + "remove": { + "success": "Yorum başarıyla kaldırıldı", + "error": "Yorum kaldırılamadı. Lütfen daha sonra tekrar deneyin." + }, + "upload": { + "error": "Dosya yüklenemedi. Lütfen daha sonra tekrar deneyin." + } + }, + "empty_state": { + "issue_detail": { + "title": "İş öğesi mevcut değil", + "description": "Aradığınız iş öğesi mevcut değil, arşivlenmiş veya silinmiş.", + "primary_button": { + "text": "Diğer iş öğelerini görüntüle" + } + } + }, + "sibling": { + "label": "Kardeş iş öğeleri" + }, + "archive": { + "description": "Yalnızca tamamlanmış veya iptal edilmiş\niş öğeleri arşivlenebilir", + "label": "İş Öğesini Arşivle", + "confirm_message": "Bu iş öğesini arşivlemek istediğinizden emin misiniz? Arşivlenen tüm iş öğelerinizi daha sonra geri yükleyebilirsiniz.", + "success": { + "label": "Arşivleme başarılı", + "message": "Arşivleriniz proje arşivlerinde bulunabilir." + }, + "failed": { + "message": "İş öğesi arşivlenemedi. Lütfen tekrar deneyin." + } + }, + "restore": { + "success": { + "title": "Geri yükleme başarılı", + "message": "İş öğeniz proje iş öğelerinde bulunabilir." + }, + "failed": { + "message": "İş öğesi geri yüklenemedi. Lütfen tekrar deneyin." + } + }, + "relation": { + "relates_to": "İlişkili", + "duplicate": "Kopyası", + "blocked_by": "Engellendi", + "blocking": "Engelliyor" + }, + "copy_link": "İş öğesi bağlantısını kopyala", + "delete": { + "label": "İş öğesini sil", + "error": "İş öğesi silinirken hata oluştu" + }, + "subscription": { + "actions": { + "subscribed": "İş öğesine abone olundu", + "unsubscribed": "İş öğesi aboneliği sonlandırıldı" + } + }, + "select": { + "error": "Lütfen en az bir iş öğesi seçin", + "empty": "Hiç iş öğesi seçilmedi", + "add_selected": "Seçilen iş öğelerini ekle" + }, + "open_in_full_screen": "İş öğesini tam ekranda aç" + }, + + "attachment": { + "error": "Dosya eklenemedi. Tekrar yüklemeyi deneyin.", + "only_one_file_allowed": "Aynı anda yalnızca bir dosya yüklenebilir.", + "file_size_limit": "Dosya boyutu {size}MB veya daha az olmalıdır.", + "drag_and_drop": "Yüklemek için herhangi bir yere sürükleyip bırakın", + "delete": "Eki sil" + }, + + "label": { + "select": "Etiket seç", + "create": { + "success": "Etiket başarıyla oluşturuldu", + "failed": "Etiket oluşturulamadı", + "already_exists": "Etiket zaten mevcut", + "type": "Yeni etiket eklemek için yazın" + } + }, + + "sub_work_item": { + "update": { + "success": "Alt iş öğesi başarıyla güncellendi", + "error": "Alt iş öğesi güncellenirken hata oluştu" + }, + "remove": { + "success": "Alt iş öğesi başarıyla kaldırıldı", + "error": "Alt iş öğesi kaldırılırken hata oluştu" + } + }, + + "view": { + "label": "{count, plural, one {Görünüm} other {Görünümler}}", + "create": { + "label": "Görünüm Oluştur" + }, + "update": { + "label": "Görünümü Güncelle" + } + }, + + "inbox_issue": { + "status": { + "pending": { + "title": "Beklemede", + "description": "Beklemede" + }, + "declined": { + "title": "Reddedildi", + "description": "Reddedildi" + }, + "snoozed": { + "title": "Erteleme", + "description": "{days, plural, one{# gün} other{# gün}} kaldı" + }, + "accepted": { + "title": "Kabul Edildi", + "description": "Kabul Edildi" + }, + "duplicate": { + "title": "Kopya", + "description": "Kopya" + } + }, + "modals": { + "decline": { + "title": "İş öğesini reddet", + "content": "{value} iş öğesini reddetmek istediğinizden emin misiniz?" + }, + "delete": { + "title": "İş öğesini sil", + "content": "{value} iş öğesini silmek istediğinizden emin misiniz?", + "success": "İş öğesi başarıyla silindi" + } + }, + "errors": { + "snooze_permission": "Yalnızca proje yöneticileri iş öğelerini erteleyebilir/ertelemeyi kaldırabilir", + "accept_permission": "Yalnızca proje yöneticileri iş öğelerini kabul edebilir", + "decline_permission": "Yalnızca proje yöneticileri iş öğelerini reddedebilir" + }, + "actions": { + "accept": "Kabul Et", + "decline": "Reddet", + "snooze": "Ertele", + "unsnooze": "Ertelemeyi Kaldır", + "copy": "İş öğesi bağlantısını kopyala", + "delete": "Sil", + "open": "İş öğesini aç", + "mark_as_duplicate": "Kopya olarak işaretle", + "move": "{value} proje iş öğelerine taşı" + }, + "source": { + "in-app": "uygulama içi" + }, + "order_by": { + "created_at": "Oluşturulma tarihi", + "updated_at": "Güncelleme tarihi", + "id": "ID" + }, + "label": "Talep", + "page_label": "{workspace} - Talep", + "modal": { + "title": "Talep iş öğesi oluştur" + }, + "tabs": { + "open": "Açık", + "closed": "Kapalı" + }, + "empty_state": { + "sidebar_open_tab": { + "title": "Açık iş öğesi yok", + "description": "Açık iş öğelerini burada bulabilirsiniz. Yeni iş öğesi oluşturun." + }, + "sidebar_closed_tab": { + "title": "Kapalı iş öğesi yok", + "description": "Kabul edilen veya reddedilen tüm iş öğeleri burada bulunabilir." + }, + "sidebar_filter": { + "title": "Eşleşen iş öğesi yok", + "description": "Talep bölümünde uygulanan filtreyle eşleşen iş öğesi yok. Yeni bir iş öğesi oluşturun." + }, + "detail": { + "title": "Detaylarını görüntülemek için bir iş öğesi seçin." + } + } + }, + + "workspace_creation": { + "heading": "Çalışma Alanınızı Oluşturun", + "subheading": "Plane'i kullanmaya başlamak için bir çalışma alanı oluşturmalı veya katılmalısınız.", + "form": { + "name": { + "label": "Çalışma Alanınıza Ad Verin", + "placeholder": "Tanıdık ve tanınabilir bir şey her zaman iyidir." + }, + "url": { + "label": "Çalışma Alanı URL'nizi Belirleyin", + "placeholder": "URL yazın veya yapıştırın", + "edit_slug": "Yalnızca URL'nin kısa adını düzenleyebilirsiniz" + }, + "organization_size": { + "label": "Bu çalışma alanını kaç kişi kullanacak?", + "placeholder": "Bir aralık seçin" + } + }, + "errors": { + "creation_disabled": { + "title": "Yalnızca örnek yöneticiniz çalışma alanları oluşturabilir", + "description": "Örnek yöneticinizin e-posta adresini biliyorsanız, iletişime geçmek için aşağıdaki düğmeye tıklayın.", + "request_button": "Örnek yönetici iste" + }, + "validation": { + "name_alphanumeric": "Çalışma alanı adları yalnızca (' '), ('-'), ('_') ve alfasayısal karakterler içerebilir.", + "name_length": "Adınızı 80 karakterle sınırlayın.", + "url_alphanumeric": "URL'ler yalnızca ('-') ve alfasayısal karakterler içerebilir.", + "url_length": "URL'nizi 48 karakterle sınırlayın.", + "url_already_taken": "Çalışma alanı URL'si zaten alınmış!" + } + }, + "request_email": { + "subject": "Yeni çalışma alanı isteği", + "body": "Merhaba örnek yönetici(ler),\n\nLütfen [çalışma-alanı-adı] URL'si ile [çalışma alanı oluşturma amacı] için yeni bir çalışma alanı oluşturun.\n\nTeşekkürler,\n{firstName} {lastName}\n{email}" + }, + "button": { + "default": "Çalışma alanı oluştur", + "loading": "Çalışma alanı oluşturuluyor" + }, + "toast": { + "success": { + "title": "Başarılı", + "message": "Çalışma alanı başarıyla oluşturuldu" + }, + "error": { + "title": "Hata", + "message": "Çalışma alanı oluşturulamadı. Lütfen tekrar deneyin." + } + } + }, + + "workspace_dashboard": { + "empty_state": { + "general": { + "title": "Projelerinizin, aktivitenizin ve metriklerinizin genel görünümü", + "description": "Plane'e hoş geldiniz, sizi aramızda görmekten heyecan duyuyoruz. İlk projenizi oluşturun ve iş öğelerinizi takip edin, bu sayfa ilerlemenize yardımcı olacak bir alana dönüşecek. Yöneticiler ayrıca ekiplerinin ilerlemesine yardımcı olacak öğeler görecek.", + "primary_button": { + "text": "İlk projenizi oluşturun", + "comic": { + "title": "Plane'de her şey bir projeyle başlar", + "description": "Bir proje, bir ürünün yol haritası, bir pazarlama kampanyası veya yeni bir araba lansmanı olabilir." + } + } + } + } + }, + + "workspace_analytics": { + "label": "Analitik", + "page_label": "{workspace} - Analitik", + "open_tasks": "Toplam açık görev", + "error": "Veri alınırken bir hata oluştu.", + "work_items_closed_in": "Kapanan iş öğeleri", + "selected_projects": "Seçilen projeler", + "total_members": "Toplam üye", + "total_cycles": "Toplam döngü", + "total_modules": "Toplam modül", + "pending_work_items": { + "title": "Bekleyen iş öğeleri", + "empty_state": "Ekip arkadaşlarınız tarafından bekleyen iş öğelerinin analizi burada görünür." + }, + "work_items_closed_in_a_year": { + "title": "Bir yılda kapanan iş öğeleri", + "empty_state": "Aynı grafikte analizini görmek için iş öğelerini kapatın." + }, + "most_work_items_created": { + "title": "En çok iş öğesi oluşturan", + "empty_state": "Ekip arkadaşlarınız ve onların oluşturduğu iş öğesi sayıları burada görünür." + }, + "most_work_items_closed": { + "title": "En çok iş öğesi kapatan", + "empty_state": "Ekip arkadaşlarınız ve onların kapattığı iş öğesi sayıları burada görünür." + }, + "tabs": { + "scope_and_demand": "Kapsam ve Talep", + "custom": "Özel Analitik" + }, + "empty_state": { + "general": { + "title": "İlerlemeyi, iş yükünü ve tahsisatları izleyin. Eğilimleri tespit edin, engelleri kaldırın ve işleri hızlandırın", + "description": "Kapsam ve talep, tahminler ve kapsam genişlemesini görün. Takım üyeleri ve ekiplerin performansını izleyin ve projenizin zamanında ilerlemesini sağlayın.", + "primary_button": { + "text": "İlk projenizi başlatın", + "comic": { + "title": "Analitik Döngüler + Modüllerle en iyi şekilde çalışır", + "description": "Öncelikle, iş öğelerinizi Döngülere zamanlayın ve mümkünse, bir döngüden uzun süren iş öğelerini Modüllerde gruplayın. Her ikisini de sol gezintide bulabilirsiniz." + } + } + } + } + }, + + "workspace_projects": { + "label": "{count, plural, one {Proje} other {Projeler}}", + "create": { + "label": "Proje Ekle" + }, + "network": { + "label": "Ağ", + "private": { + "title": "Özel", + "description": "Yalnızca davetle erişilebilir" + }, + "public": { + "title": "Herkese Açık", + "description": "Çalışma alanındaki herkes (Misafirler hariç) katılabilir" + } + }, + "error": { + "permission": "Bu işlemi yapma izniniz yok.", + "cycle_delete": "Döngü silinemedi", + "module_delete": "Modül silinemedi", + "issue_delete": "İş öğesi silinemedi" + }, + "state": { + "backlog": "Bekleme Listesi", + "unstarted": "Başlatılmadı", + "started": "Başlatıldı", + "completed": "Tamamlandı", + "cancelled": "İptal Edildi" + }, + "sort": { + "manual": "Manuel", + "name": "Ad", + "created_at": "Oluşturulma tarihi", + "members_length": "Üye sayısı" + }, + "scope": { + "my_projects": "Projelerim", + "archived_projects": "Arşivlenmiş" + }, + "common": { + "months_count": "{months, plural, one{# ay} other{# ay}}" + }, + "empty_state": { + "general": { + "title": "Aktif proje yok", + "description": "Her projeyi hedef odaklı çalışmanın üst öğesi olarak düşünün. Projeler, İşler, Döngüler ve Modüllerin yaşadığı ve meslektaşlarınızla birlikte bu hedefe ulaşmanıza yardımcı olan yerlerdir. Yeni bir proje oluşturun veya arşivlenmiş projeler için filtreleyin.", + "primary_button": { + "text": "İlk projenizi başlatın", + "comic": { + "title": "Plane'de her şey bir projeyle başlar", + "description": "Bir proje, bir ürünün yol haritası, bir pazarlama kampanyası veya yeni bir araba lansmanı olabilir." + } + } + }, + "no_projects": { + "title": "Proje yok", + "description": "İş öğesi oluşturmak veya işlerinizi yönetmek için bir proje oluşturmalı veya bir parçası olmalısınız.", + "primary_button": { + "text": "İlk projenizi başlatın", + "comic": { + "title": "Plane'de her şey bir projeyle başlar", + "description": "Bir proje, bir ürünün yol haritası, bir pazarlama kampanyası veya yeni bir araba lansmanı olabilir." + } + } + }, + "filter": { + "title": "Eşleşen proje yok", + "description": "Eşleşen kriterlerle proje bulunamadı. \n Bunun yerine yeni bir proje oluşturun." + }, + "search": { + "description": "Eşleşen kriterlerle proje bulunamadı.\nBunun yerine yeni bir proje oluşturun" + } + } + }, + + "workspace_views": { + "add_view": "Görünüm ekle", + "empty_state": { + "all-issues": { + "title": "Projede iş öğesi yok", + "description": "İlk projeniz tamamlandı! Şimdi, işlerinizi izlenebilir parçalara bölün. Hadi başlayalım!", + "primary_button": { + "text": "Yeni iş öğesi oluştur" + } + }, + "assigned": { + "title": "Henüz iş öğesi yok", + "description": "Size atanan iş öğeleri buradan takip edilebilir.", + "primary_button": { + "text": "Yeni iş öğesi oluştur" + } + }, + "created": { + "title": "Henüz iş öğesi yok", + "description": "Sizin oluşturduğunuz tüm iş öğeleri burada görünecek, doğrudan buradan takip edin.", + "primary_button": { + "text": "Yeni iş öğesi oluştur" + } + }, + "subscribed": { + "title": "Henüz iş öğesi yok", + "description": "İlgilendiğiniz iş öğelerine abone olun, hepsini buradan takip edin." + }, + "custom-view": { + "title": "Henüz iş öğesi yok", + "description": "Filtrelere uyan iş öğeleri burada takip edilebilir." + } + } + }, + + "workspace_settings": { + "label": "Çalışma Alanı Ayarları", + "page_label": "{workspace} - Genel ayarlar", + "key_created": "Anahtar oluşturuldu", + "copy_key": "Bu gizli anahtarı Plane Pages'e kopyalayıp kaydedin. Kapat düğmesine bastıktan sonra bu anahtarı göremezsiniz. Anahtar içeren bir CSV dosyası indirildi.", + "token_copied": "Token panoya kopyalandı.", + "settings": { + "general": { + "title": "Genel", + "upload_logo": "Logo yükle", + "edit_logo": "Logoyu düzenle", + "name": "Çalışma Alanı Adı", + "company_size": "Şirket Büyüklüğü", + "url": "Çalışma Alanı URL'si", + "update_workspace": "Çalışma Alanını Güncelle", + "delete_workspace": "Bu çalışma alanını sil", + "delete_workspace_description": "Bir çalışma alanı silindiğinde, içindeki tüm veri ve kaynaklar kalıcı olarak kaldırılır ve kurtarılamaz.", + "delete_btn": "Bu çalışma alanını sil", + "delete_modal": { + "title": "Bu çalışma alanını silmek istediğinizden emin misiniz?", + "description": "Ücretli planlarımızdan birine aktif bir deneme sürümünüz var. Devam etmek için önce iptal edin.", + "dismiss": "Kapat", + "cancel": "Denemeyi iptal et", + "success_title": "Çalışma alanı silindi.", + "success_message": "Kısa süre sonra profil sayfanıza yönlendirileceksiniz.", + "error_title": "Bu işe yaramadı.", + "error_message": "Lütfen tekrar deneyin." + }, + "errors": { + "name": { + "required": "Ad gereklidir", + "max_length": "Çalışma alanı adı 80 karakteri geçmemeli" + }, + "company_size": { + "required": "Şirket büyüklüğü gereklidir", + "select_a_range": "Kuruluş büyüklüğünü seçin" + } + } + }, + "members": { + "title": "Üyeler", + "add_member": "Üye ekle", + "pending_invites": "Bekleyen davetler", + "invitations_sent_successfully": "Davetler başarıyla gönderildi", + "leave_confirmation": "Çalışma alanından ayrılmak istediğinizden emin misiniz? Artık bu çalışma alanına erişiminiz olmayacak. Bu işlem geri alınamaz.", + "details": { + "full_name": "Tam ad", + "display_name": "Görünen ad", + "email_address": "E-posta adresi", + "account_type": "Hesap türü", + "authentication": "Kimlik Doğrulama", + "joining_date": "Katılma tarihi" + }, + "modal": { + "title": "İşbirliği yapmaları için kişileri davet edin", + "description": "Kişileri çalışma alanınızda işbirliği yapmaları için davet edin.", + "button": "Davetleri gönder", + "button_loading": "Davetler gönderiliyor", + "placeholder": "isim@firma.com", + "errors": { + "required": "Davet etmek için bir e-posta adresine ihtiyacımız var.", + "invalid": "E-posta geçersiz" + } + } + }, + "billing_and_plans": { + "title": "Faturalandırma ve Planlar", + "current_plan": "Mevcut plan", + "free_plan": "Şu anda ücretsiz planı kullanıyorsunuz", + "view_plans": "Planları görüntüle" + }, + "exports": { + "title": "Dışa Aktarımlar", + "exporting": "Dışa aktarılıyor", + "previous_exports": "Önceki dışa aktarımlar", + "export_separate_files": "Verileri ayrı dosyalara aktar", + "modal": { + "title": "Şuraya aktar", + "toasts": { + "success": { + "title": "Dışa aktarma başarılı", + "message": "{entity} önceki dışa aktarmadan indirilebilir." + }, + "error": { + "title": "Dışa aktarma başarısız", + "message": "Dışa aktarma başarısız oldu. Lütfen tekrar deneyin." + } + } + } + }, + "webhooks": { + "title": "Webhook'lar", + "add_webhook": "Webhook ekle", + "modal": { + "title": "Webhook oluştur", + "details": "Webhook detayları", + "payload": "Payload URL", + "question": "Bu webhook'u hangi olaylar tetiklesin?", + "error": "URL gereklidir" + }, + "secret_key": { + "title": "Gizli anahtar", + "message": "Webhook payload'ında oturum açmak için bir token oluşturun" + }, + "options": { + "all": "Her şeyi gönder", + "individual": "Tek tek olayları seç" + }, + "toasts": { + "created": { + "title": "Webhook oluşturuldu", + "message": "Webhook başarıyla oluşturuldu" + }, + "not_created": { + "title": "Webhook oluşturulamadı", + "message": "Webhook oluşturulamadı" + }, + "updated": { + "title": "Webhook güncellendi", + "message": "Webhook başarıyla güncellendi" + }, + "not_updated": { + "title": "Webhook güncellenemedi", + "message": "Webhook güncellenemedi" + }, + "removed": { + "title": "Webhook kaldırıldı", + "message": "Webhook başarıyla kaldırıldı" + }, + "not_removed": { + "title": "Webhook kaldırılamadı", + "message": "Webhook kaldırılamadı" + }, + "secret_key_copied": { + "message": "Gizli anahtar panoya kopyalandı." + }, + "secret_key_not_copied": { + "message": "Gizli anahtar kopyalanırken hata oluştu." + } + } + }, + "api_tokens": { + "title": "API Token'ları", + "add_token": "API Token'ı ekle", + "create_token": "Token oluştur", + "never_expires": "Süresi dolmaz", + "generate_token": "Token oluştur", + "generating": "Oluşturuluyor", + "delete": { + "title": "API Token'ını sil", + "description": "Bu token'ı kullanan uygulamalar artık Plane verilerine erişemeyecek. Bu işlem geri alınamaz.", + "success": { + "title": "Başarılı!", + "message": "API token'ı başarıyla silindi" + }, + "error": { + "title": "Hata!", + "message": "API token'ı silinemedi" + } + } + } + }, + "empty_state": { + "api_tokens": { + "title": "API token'ı oluşturulmadı", + "description": "Plane API'lerini harici sistemlere entegre etmek için bir token oluşturun." + }, + "webhooks": { + "title": "Webhook eklenmedi", + "description": "Gerçek zamanlı güncellemeler almak ve otomatik eylemler gerçekleştirmek için webhook'lar oluşturun." + }, + "exports": { + "title": "Henüz dışa aktarma yok", + "description": "Dışa aktardığınızda, referans için burada bir kopya bulunur." + }, + "imports": { + "title": "Henüz içe aktarma yok", + "description": "Tüm önceki içe aktarmalarınızı burada bulabilir ve indirebilirsiniz." + } + } + }, + + "profile": { + "label": "Profil", + "page_label": "Sizin İşleriniz", + "work": "İş", + "details": { + "joined_on": "Katılma tarihi", + "time_zone": "Saat Dilimi" + }, + "stats": { + "workload": "İş Yükü", + "overview": "Genel Bakış", + "created": "Oluşturulan iş öğeleri", + "assigned": "Atanan iş öğeleri", + "subscribed": "Abone olunan iş öğeleri", + "state_distribution": { + "title": "Duruma göre iş öğeleri", + "empty": "Daha iyi analiz için durumlarına göre iş öğelerini görmek üzere iş öğesi oluşturun." + }, + "priority_distribution": { + "title": "Önceliğe göre iş öğeleri", + "empty": "Daha iyi analiz için önceliklerine göre iş öğelerini görmek üzere iş öğesi oluşturun." + }, + "recent_activity": { + "title": "Son aktiviteler", + "empty": "Veri bulunamadı. Lütfen girdilerinizi kontrol edin", + "button": "Bugünün aktivitesini indir", + "button_loading": "İndiriliyor" + } + }, + "actions": { + "profile": "Profil", + "security": "Güvenlik", + "activity": "Aktivite", + "appearance": "Görünüm", + "notifications": "Bildirimler" + }, + "tabs": { + "summary": "Özet", + "assigned": "Atanan", + "created": "Oluşturulan", + "subscribed": "Abone olunan", + "activity": "Aktivite" + }, + "empty_state": { + "activity": { + "title": "Henüz aktivite yok", + "description": "Yeni bir iş öğesi oluşturarak başlayın! Detaylar ve özellikler ekleyin. Aktivitenizi görmek için Plane'de daha fazlasını keşfedin." + }, + "assigned": { + "title": "Size atanan iş öğesi yok", + "description": "Size atanan iş öğeleri buradan takip edilebilir." + }, + "created": { + "title": "Henüz iş öğesi yok", + "description": "Sizin oluşturduğunuz tüm iş öğeleri burada görünecek, doğrudan buradan takip edin." + }, + "subscribed": { + "title": "Henüz iş öğesi yok", + "description": "İlgilendiğiniz iş öğelerine abone olun, hepsini buradan takip edin." + } + } + }, + + "project_settings": { + "general": { + "enter_project_id": "Proje ID girin", + "please_select_a_timezone": "Lütfen bir saat dilimi seçin", + "archive_project": { + "title": "Projeyi arşivle", + "description": "Bir projeyi arşivlemek, projenizi yan gezintiden kaldırır ancak yine de projeler sayfasından erişebilirsiniz. Projeyi istediğiniz zaman geri yükleyebilir veya silebilirsiniz.", + "button": "Projeyi arşivle" + }, + "delete_project": { + "title": "Projeyi sil", + "description": "Bir proje silindiğinde, içindeki tüm veri ve kaynaklar kalıcı olarak kaldırılır ve kurtarılamaz.", + "button": "Projemi sil" + }, + "toast": { + "success": "Proje başarıyla güncellendi", + "error": "Proje güncellenemedi. Lütfen tekrar deneyin." + } + }, + "members": { + "label": "Üyeler", + "project_lead": "Proje lideri", + "default_assignee": "Varsayılan atanan", + "guest_super_permissions": { + "title": "Misafir kullanıcılara tüm iş öğelerini görüntüleme izni ver:", + "sub_heading": "Bu, misafirlerin tüm proje iş öğelerini görüntülemesine izin verecektir." + }, + "invite_members": { + "title": "Üyeleri davet et", + "sub_heading": "Projenizde çalışmaları için üyeleri davet edin.", + "select_co_worker": "İş arkadaşı seç" + } + }, + "states": { + "describe_this_state_for_your_members": "Bu durumu üyeleriniz için açıklayın.", + "empty_state": { + "title": "{groupKey} grubu için durum yok", + "description": "Lütfen yeni bir durum oluşturun" + } + }, + "labels": { + "label_title": "Etiket başlığı", + "label_title_is_required": "Etiket başlığı gereklidir", + "label_max_char": "Etiket adı 255 karakteri geçmemeli", + "toast": { + "error": "Etiket güncellenirken hata oluştu" + } + }, + "estimates": { + "label": "Tahminler", + "title": "Projem için tahminleri etkinleştir", + "description": "Takımınızın karmaşıklık ve iş yükünü iletişim kurmanıza yardımcı olurlar.", + "no_estimate": "Tahmin yok", + "create": { + "custom": "Özel", + "start_from_scratch": "Sıfırdan başla", + "choose_template": "Şablon seç", + "choose_estimate_system": "Tahmin sistemi seç", + "enter_estimate_point": "Tahmin puanı girin" + }, + "toasts": { + "created": { + "success": { + "title": "Tahmin puanı oluşturuldu", + "message": "Tahmin puanı başarıyla oluşturuldu" + }, + "error": { + "title": "Tahmin puanı oluşturulamadı", + "message": "Yeni tahmin puanı oluşturulamadı, lütfen tekrar deneyin." + } + }, + "updated": { + "success": { + "title": "Tahmin değiştirildi", + "message": "Tahmin puanı projenizde güncellendi." + }, + "error": { + "title": "Tahmin değiştirilemedi", + "message": "Tahmin değiştirilemedi, lütfen tekrar deneyin" + } + }, + "enabled": { + "success": { + "title": "Başarılı!", + "message": "Tahminler etkinleştirildi." + } + }, + "disabled": { + "success": { + "title": "Başarılı!", + "message": "Tahminler devre dışı bırakıldı." + }, + "error": { + "title": "Hata!", + "message": "Tahmin devre dışı bırakılamadı. Lütfen tekrar deneyin" + } + } + }, + "validation": { + "min_length": "Tahmin puanı 0'dan büyük olmalı.", + "unable_to_process": "İsteğiniz işlenemedi, lütfen tekrar deneyin.", + "numeric": "Tahmin puanı sayısal bir değer olmalı.", + "character": "Tahmin puanı karakter değeri olmalı.", + "empty": "Tahmin değeri boş olamaz.", + "already_exists": "Tahmin değeri zaten var.", + "unsaved_changes": "Kaydedilmemiş değişiklikleriniz var, bitirmeden önce lütfen kaydedin" + } + }, + "automations": { + "label": "Otomasyonlar", + "auto-archive": { + "title": "Tamamlanan iş öğelerini otomatik arşivle", + "description": "Plane, tamamlanan veya iptal edilen iş öğelerini otomatik arşivleyecek.", + "duration": "Şu süre kapalı kalan iş öğelerini otomatik arşivle" + }, + "auto-close": { + "title": "İş öğelerini otomatik kapat", + "description": "Plane, tamamlanmamış veya iptal edilmemiş iş öğelerini otomatik kapatacak.", + "duration": "Şu süre etkin olmayan iş öğelerini otomatik kapat", + "auto_close_status": "Otomatik kapatma durumu" + } + }, + + "empty_state": { + "labels": { + "title": "Henüz etiket yok", + "description": "Projenizdeki iş öğelerini düzenlemek ve filtrelemek için etiketler oluşturun." + }, + "estimates": { + "title": "Henüz tahmin sistemi yok", + "description": "İş öğesi başına çalışma miktarını iletişim kurmak için bir tahmin seti oluşturun.", + "primary_button": "Tahmin sistemi ekle" + } + } + }, + + "project_cycles": { + "add_cycle": "Döngü ekle", + "more_details": "Daha fazla detay", + "cycle": "Döngü", + "update_cycle": "Döngüyü güncelle", + "create_cycle": "Döngü oluştur", + "no_matching_cycles": "Eşleşen döngü yok", + "remove_filters_to_see_all_cycles": "Tüm döngüleri görmek için filtreleri kaldırın", + "remove_search_criteria_to_see_all_cycles": "Tüm döngüleri görmek için arama kriterlerini kaldırın", + "only_completed_cycles_can_be_archived": "Yalnızca tamamlanmış döngüler arşivlenebilir", + "start_date": "Başlangıç tarihi", + "end_date": "Bitiş tarihi", + "in_your_timezone": "Saat diliminizde", + "transfer_work_items": "{count} iş öğesini aktar", + "date_range": "Tarih aralığı", + "add_date": "Tarih ekle", + "active_cycle": { + "label": "Aktif döngü", + "progress": "İlerleme", + "chart": "Burndown grafiği", + "priority_issue": "Öncelikli iş öğeleri", + "assignees": "Atananlar", + "issue_burndown": "İş öğesi burndown", + "ideal": "İdeal", + "current": "Mevcut", + "labels": "Etiketler" + }, + "upcoming_cycle": { + "label": "Yaklaşan döngü" + }, + "completed_cycle": { + "label": "Tamamlanan döngü" + }, + "status": { + "days_left": "Kalan gün", + "completed": "Tamamlandı", + "yet_to_start": "Başlamadı", + "in_progress": "Devam Ediyor", + "draft": "Taslak" + }, + "action": { + "restore": { + "title": "Döngüyü geri yükle", + "success": { + "title": "Döngü geri yüklendi", + "description": "Döngü başarıyla geri yüklendi." + }, + "failed": { + "title": "Döngü geri yüklenemedi", + "description": "Döngü geri yüklenemedi. Lütfen tekrar deneyin." + } + }, + "favorite": { + "loading": "Döngü favorilere ekleniyor", + "success": { + "description": "Döngü favorilere eklendi.", + "title": "Başarılı!" + }, + "failed": { + "description": "Döngü favorilere eklenemedi. Lütfen tekrar deneyin.", + "title": "Hata!" + } + }, + "unfavorite": { + "loading": "Döngü favorilerden kaldırılıyor", + "success": { + "description": "Döngü favorilerden kaldırıldı.", + "title": "Başarılı!" + }, + "failed": { + "description": "Döngü favorilerden kaldırılamadı. Lütfen tekrar deneyin.", + "title": "Hata!" + } + }, + "update": { + "loading": "Döngü güncelleniyor", + "success": { + "description": "Döngü başarıyla güncellendi.", + "title": "Başarılı!" + }, + "failed": { + "description": "Döngü güncellenirken hata oluştu. Lütfen tekrar deneyin.", + "title": "Hata!" + }, + "error": { + "already_exists": "Belirtilen tarihlerde zaten bir döngünüz var, taslak bir döngü oluşturmak istiyorsanız, her iki tarihi de kaldırarak oluşturabilirsiniz." + } + } + }, + "empty_state": { + "general": { + "title": "İşlerinizi Döngülerde gruplayın ve zamanlayın.", + "description": "İşleri zaman dilimlerine bölün, proje son teslim tarihinden geriye çalışarak tarihler belirleyin ve takım olarak somut ilerleme kaydedin.", + "primary_button": { + "text": "İlk döngünüzü ayarlayın", + "comic": { + "title": "Döngüler tekrarlayan zaman dilimleridir.", + "description": "Haftalık veya iki haftalık iş takibi için kullandığınız sprint, iterasyon veya başka bir terim bir döngüdür." + } + } + }, + "no_issues": { + "title": "Döngüye iş öğesi eklenmedi", + "description": "Bu döngüde tamamlamak istediğiniz iş öğelerini ekleyin veya oluşturun", + "primary_button": { + "text": "Yeni iş öğesi oluştur" + }, + "secondary_button": { + "text": "Varolan iş öğesi ekle" + } + }, + "completed_no_issues": { + "title": "Döngüde iş öğesi yok", + "description": "Döngüde iş öğesi yok. İş öğeleri ya aktarıldı ya da gizlendi. Gizli iş öğelerini görmek için görüntüleme özelliklerinizi güncelleyin." + }, + "active": { + "title": "Aktif döngü yok", + "description": "Aktif bir döngü, bugünün tarihini içeren herhangi bir dönemi kapsar. Aktif döngünün ilerleme ve detaylarını burada bulabilirsiniz." + }, + "archived": { + "title": "Henüz arşivlenmiş döngü yok", + "description": "Projenizi düzenli tutmak için tamamlanmış döngüleri arşivleyin. Arşivlendikten sonra burada bulabilirsiniz." + } + } + }, + + "project_issues": { + "empty_state": { + "no_issues": { + "title": "Bir iş öğesi oluşturun ve birine, hatta kendinize atayın", + "description": "İş öğelerini işler, görevler, çalışma veya JTBD olarak düşünün. Bir iş öğesi ve alt iş öğeleri genellikle takım üyelerinize atanan zaman temelli eylemlerdir. Takımınız, projenizi hedefine doğru ilerletmek için iş öğeleri oluşturur, atar ve tamamlar.", + "primary_button": { + "text": "İlk iş öğenizi oluşturun", + "comic": { + "title": "İş öğeleri Plane'de yapı taşlarıdır.", + "description": "Plane UI'yi yeniden tasarlamak, şirketi yeniden markalaştırmak veya yeni yakıt enjeksiyon sistemini başlatmak, muhtemelen alt iş öğeleri olan iş öğesi örnekleridir." + } + } + }, + "no_archived_issues": { + "title": "Henüz arşivlenmiş iş öğesi yok", + "description": "Tamamlanan veya iptal edilen iş öğelerini manuel olarak veya otomasyonla arşivleyebilirsiniz. Arşivlendikten sonra burada bulabilirsiniz.", + "primary_button": { + "text": "Otomasyon ayarla" + } + }, + "issues_empty_filter": { + "title": "Uygulanan filtrelerle eşleşen iş öğesi bulunamadı", + "secondary_button": { + "text": "Tüm filtreleri temizle" + } + } + } + }, + + "project_module": { + "add_module": "Modül Ekle", + "update_module": "Modülü Güncelle", + "create_module": "Modül Oluştur", + "archive_module": "Modülü Arşivle", + "restore_module": "Modülü Geri Yükle", + "delete_module": "Modülü sil", + "empty_state": { + "general": { + "title": "Proje kilometre taşlarınızı Modüllere eşleyin ve toplu işleri kolayca takip edin.", + "description": "Mantıksal bir üst öğeye ait iş öğeleri grubu bir modül oluşturur. Bunları bir kilometre taşını takip etmenin bir yolu olarak düşünün. Kendi dönemleri ve son teslim tarihleri ile birlikte, bir kilometre taşına ne kadar yakın veya uzak olduğunuzu görmenize yardımcı olacak analitiklere sahiptirler.", + "primary_button": { + "text": "İlk modülünüzü oluşturun", + "comic": { + "title": "Modüller işleri hiyerarşiye göre gruplamaya yardımcı olur.", + "description": "Bir araba modülü, bir şasi modülü ve bir depo modülü bu gruplandırmanın iyi örnekleridir." + } + } + }, + "no_issues": { + "title": "Modülde iş öğesi yok", + "description": "Bu modülün bir parçası olarak gerçekleştirmek istediğiniz iş öğelerini oluşturun veya ekleyin", + "primary_button": { + "text": "Yeni iş öğeleri oluştur" + }, + "secondary_button": { + "text": "Varolan bir iş öğesi ekle" + } + }, + "archived": { + "title": "Henüz arşivlenmiş Modül yok", + "description": "Projenizi düzenli tutmak için tamamlanmış veya iptal edilmiş modülleri arşivleyin. Arşivlendikten sonra burada bulabilirsiniz." + }, + "sidebar": { + "in_active": "Bu modül henüz aktif değil.", + "invalid_date": "Geçersiz tarih. Lütfen geçerli bir tarih girin." + } + }, + "quick_actions": { + "archive_module": "Modülü arşivle", + "archive_module_description": "Yalnızca tamamlanmış veya iptal edilmiş\nmodüller arşivlenebilir.", + "delete_module": "Modülü sil" + }, + "toast": { + "copy": { + "success": "Modül bağlantısı panoya kopyalandı" + }, + "delete": { + "success": "Modül başarıyla silindi", + "error": "Modül silinemedi" + } + } + }, + + "project_views": { + "empty_state": { + "general": { + "title": "Projeniz için filtreli görünümleri kaydedin. İhtiyacınız olduğu kadar oluşturun", + "description": "Görünümler, sık kullandığınız veya kolay erişim istediğiniz kayıtlı filtrelerdir. Bir projedeki tüm meslektaşlarınız herkesin görünümlerini görebilir ve ihtiyaçlarına en uygun olanı seçebilir.", + "primary_button": { + "text": "İlk görünümünüzü oluşturun", + "comic": { + "title": "Görünümler İş Öğesi özellikleri üzerinde çalışır.", + "description": "Buradan istediğiniz kadar özellikle filtre içeren bir görünüm oluşturabilirsiniz." + } + } + }, + "filter": { + "title": "Eşleşen görünüm yok", + "description": "Arama kriterleriyle eşleşen görünüm yok. \n Bunun yerine yeni bir görünüm oluşturun." + } + } + }, + + "project_page": { + "empty_state": { + "general": { + "title": "Bir not, belge veya tam bir bilgi bankası yazın. Plane'in AI asistanı Galileo'nun başlamanıza yardımcı olmasını sağlayın", + "description": "Sayfalar Plane'de düşüncelerinizi döktüğünüz alanlardır. Toplantı notları alın, kolayca biçimlendirin, iş öğelerini yerleştirin, bir bileşen kitaplığı kullanarak düzenleyin ve hepsini proje bağlamınızda tutun. Herhangi bir belgeyi hızlıca tamamlamak için bir kısayol veya düğme ile Plane'in AI'sı Galileo'yu çağırın.", + "primary_button": { + "text": "İlk sayfanızı oluşturun" + } + }, + "private": { + "title": "Henüz özel sayfa yok", + "description": "Özel düşüncelerinizi burada saklayın. Paylaşmaya hazır olduğunuzda, ekip bir tık uzağınızda.", + "primary_button": { + "text": "İlk sayfanızı oluşturun" + } + }, + "public": { + "title": "Henüz genel sayfa yok", + "description": "Projenizdeki herkesle paylaşılan sayfaları burada görün.", + "primary_button": { + "text": "İlk sayfanızı oluşturun" + } + }, + "archived": { + "title": "Henüz arşivlenmiş sayfa yok", + "description": "Radarınızda olmayan sayfaları arşivleyin. İhtiyaç duyduğunuzda buradan erişin." + } + } + }, + + "command_k": { + "empty_state": { + "search": { + "title": "Sonuç bulunamadı" + } + } + }, + + "issue_relation": { + "empty_state": { + "search": { + "title": "Eşleşen iş öğesi bulunamadı" + }, + "no_issues": { + "title": "İş öğesi bulunamadı" + } + } + }, + + "issue_comment": { + "empty_state": { + "general": { + "title": "Henüz yorum yok", + "description": "Yorumlar, iş öğeleri için tartışma ve takip alanı olarak kullanılabilir" + } + } + }, + + "notification": { + "label": "Bildirimler", + "page_label": "{workspace} - Bildirimler", + "options": { + "mark_all_as_read": "Tümünü okundu olarak işaretle", + "mark_read": "Okundu olarak işaretle", + "mark_unread": "Okunmamış olarak işaretle", + "refresh": "Yenile", + "filters": "Bildirim Filtreleri", + "show_unread": "Okunmamışları göster", + "show_snoozed": "Ertelenenleri göster", + "show_archived": "Arşivlenmişleri göster", + "mark_archive": "Arşivle", + "mark_unarchive": "Arşivden çıkar", + "mark_snooze": "Ertelenmiş", + "mark_unsnooze": "Ertelenmemiş" + }, + "toasts": { + "read": "Bildirim okundu olarak işaretlendi", + "unread": "Bildirim okunmamış olarak işaretlendi", + "archived": "Bildirim arşivlendi", + "unarchived": "Bildirim arşivden çıkarıldı", + "snoozed": "Bildirim ertelendi", + "unsnoozed": "Bildirim ertelenmedi" + }, + "empty_state": { + "detail": { + "title": "Detayları görüntülemek için seçin." + }, + "all": { + "title": "Atanan iş öğesi yok", + "description": "Size atanan iş öğelerinin güncellemelerini \n burada görebilirsiniz" + }, + "mentions": { + "title": "Atanan iş öğesi yok", + "description": "Size atanan iş öğelerinin güncellemelerini \n burada görebilirsiniz" + } + }, + "tabs": { + "all": "Tümü", + "mentions": "Bahsetmeler" + }, + "filter": { + "assigned": "Bana atanan", + "created": "Benim oluşturduğum", + "subscribed": "Abone olduğum" + }, + "snooze": { + "1_day": "1 gün", + "3_days": "3 gün", + "5_days": "5 gün", + "1_week": "1 hafta", + "2_weeks": "2 hafta", + "custom": "Özel" + } + }, + + "active_cycle": { + "empty_state": { + "progress": { + "title": "İlerlemeyi görüntülemek için döngüye iş öğeleri ekleyin" + }, + "chart": { + "title": "Burndown grafiğini görüntülemek için döngüye iş öğeleri ekleyin." + }, + "priority_issue": { + "title": "Döngüde ele alınan yüksek öncelikli iş öğelerini bir bakışta görün." + }, + "assignee": { + "title": "Atananları iş öğelerine ekleyerek iş dağılımını görün." + }, + "label": { + "title": "Etiketleri iş öğelerine ekleyerek iş dağılımını görün." + } + } + }, + + "disabled_project": { + "empty_state": { + "inbox": { + "title": "Talep bu proje için etkin değil.", + "description": "Talep, projenize gelen istekleri yönetmenize ve bunları iş akışınıza iş öğesi olarak eklemenize yardımcı olur. İstekleri yönetmek için proje ayarlarından talebi etkinleştirin.", + "primary_button": { + "text": "Özellikleri yönet" + } + }, + "cycle": { + "title": "Döngüler bu proje için etkin değil.", + "description": "İşleri zaman dilimlerine bölün, proje son teslim tarihinden geriye çalışarak tarihler belirleyin ve takım olarak somut ilerleme kaydedin. Döngüleri kullanmaya başlamak için projenizde döngü özelliğini etkinleştirin.", + "primary_button": { + "text": "Özellikleri yönet" + } + }, + "module": { + "title": "Modüller bu proje için etkin değil.", + "description": "Modüller projenizin yapı taşlarıdır. Kullanmaya başlamak için proje ayarlarından modülleri etkinleştirin.", + "primary_button": { + "text": "Özellikleri yönet" + } + }, + "page": { + "title": "Sayfalar bu proje için etkin değil.", + "description": "Sayfalar projenizin yapı taşlarıdır. Kullanmaya başlamak için proje ayarlarından sayfaları etkinleştirin.", + "primary_button": { + "text": "Özellikleri yönet" + } + }, + "view": { + "title": "Görünümler bu proje için etkin değil.", + "description": "Görünümler projenizin yapı taşlarıdır. Kullanmaya başlamak için proje ayarlarından görünümleri etkinleştirin.", + "primary_button": { + "text": "Özellikleri yönet" + } + } + } + }, + + "workspace_draft_issues": { + "draft_an_issue": "Taslak iş öğesi oluştur", + "empty_state": { + "title": "Yarı yazılmış iş öğeleri ve yakında yorumlar burada görünecek.", + "description": "Bunu denemek için bir iş öğesi eklemeye başlayın ve yarıda bırakın veya ilk taslağınızı aşağıda oluşturun. 😉", + "primary_button": { + "text": "İlk taslağınızı oluşturun" + } + }, + "delete_modal": { + "title": "Taslağı sil", + "description": "Bu taslağı silmek istediğinizden emin misiniz? Bu işlem geri alınamaz." + }, + "toasts": { + "created": { + "success": "Taslak oluşturuldu", + "error": "İş öğesi oluşturulamadı. Lütfen tekrar deneyin." + }, + "deleted": { + "success": "Taslak silindi" + } + } + }, + + "stickies": { + "title": "Yapışkan Notlarınız", + "placeholder": "buraya yazmak için tıkla", + "all": "Tüm yapışkan notlar", + "no-data": "Bir fikir yazın, bir aydınlanma anını yakalayın veya bir beyin fırtınasını kaydedin. Başlamak için bir yapışkan not ekleyin.", + "add": "Yapışkan not ekle", + "search_placeholder": "Başlığa göre ara", + "delete": "Yapışkan notu sil", + "delete_confirmation": "Bu yapışkan notu silmek istediğinizden emin misiniz?", + "empty_state": { + "simple": "Bir fikir yazın, bir aydınlanma anını yakalayın veya bir beyin fırtınasını kaydedin. Başlamak için bir yapışkan not ekleyin.", + "general": { + "title": "Yapışkan notlar anlık notlar ve yapılacaklardır.", + "description": "Düşünce ve fikirlerinizi her zaman ve her yerden erişebileceğiniz yapışkan notlar oluşturarak zahmetsizce yakalayın.", + "primary_button": { + "text": "Yapışkan not ekle" + } + }, + "search": { + "title": "Hiçbir yapışkan not eşleşmiyor.", + "description": "Farklı bir terim deneyin veya aramanızın doğru olduğundan eminseniz bize bildirin. ", + "primary_button": { + "text": "Yapışkan not ekle" + } + } + }, + "toasts": { + "errors": { + "wrong_name": "Yapışkan not adı 100 karakteri geçemez.", + "already_exists": "Açıklamasız bir yapışkan not zaten var" + }, + "created": { + "title": "Yapışkan not oluşturuldu", + "message": "Yapışkan not başarıyla oluşturuldu" + }, + "not_created": { + "title": "Yapışkan not oluşturulamadı", + "message": "Yapışkan not oluşturulamadı" + }, + "updated": { + "title": "Yapışkan not güncellendi", + "message": "Yapışkan not başarıyla güncellendi" + }, + "not_updated": { + "title": "Yapışkan not güncellenemedi", + "message": "Yapışkan not güncellenemedi" + }, + "removed": { + "title": "Yapışkan not kaldırıldı", + "message": "Yapışkan not başarıyla kaldırıldı" + }, + "not_removed": { + "title": "Yapışkan not kaldırılamadı", + "message": "Yapışkan not kaldırılamadı" + } + } + }, + + "role_details": { + "guest": { + "title": "Misafir", + "description": "Kuruluşların dış üyeleri misafir olarak davet edilebilir." + }, + "member": { + "title": "Üye", + "description": "Projeler, döngüler ve modüller içindeki varlıkları okuma, yazma, düzenleme ve silme yetkisi" + }, + "admin": { + "title": "Yönetici", + "description": "Çalışma alanı içinde tüm izinler aktif." + } + }, + + "user_roles": { + "product_or_project_manager": "Ürün / Proje Yöneticisi", + "development_or_engineering": "Geliştirme / Mühendislik", + "founder_or_executive": "Kurucu / Yönetici", + "freelancer_or_consultant": "Serbest Çalışan / Danışman", + "marketing_or_growth": "Pazarlama / Büyüme", + "sales_or_business_development": "Satış / İş Geliştirme", + "support_or_operations": "Destek / Operasyonlar", + "student_or_professor": "Öğrenci / Profesör", + "human_resources": "İnsan Kaynakları", + "other": "Diğer" + }, + + "importer": { + "github": { + "title": "Github", + "description": "GitHub depolarından iş öğelerini içe aktarın ve senkronize edin." + }, + "jira": { + "title": "Jira", + "description": "Jira projelerinden ve epiklerinden iş öğelerini içe aktarın." + } + }, + + "exporter": { + "csv": { + "title": "CSV", + "description": "İş öğelerini CSV dosyasına aktarın.", + "short_description": "CSV olarak aktar" + }, + "excel": { + "title": "Excel", + "description": "İş öğelerini Excel dosyasına aktarın.", + "short_description": "Excel olarak aktar" + }, + "xlsx": { + "title": "Excel", + "description": "İş öğelerini Excel dosyasına aktarın.", + "short_description": "Excel olarak aktar" + }, + "json": { + "title": "JSON", + "description": "İş öğelerini JSON dosyasına aktarın.", + "short_description": "JSON olarak aktar" + } + }, + + "default_global_view": { + "all_issues": "Tüm iş öğeleri", + "assigned": "Atanan", + "created": "Oluşturulan", + "subscribed": "Abone olunan" + }, + + "themes": { + "theme_options": { + "system_preference": { + "label": "Sistem tercihi" + }, + "light": { + "label": "Açık" + }, + "dark": { + "label": "Koyu" + }, + "light_contrast": { + "label": "Yüksek kontrastlı açık" + }, + "dark_contrast": { + "label": "Yüksek kontrastlı koyu" + }, + "custom": { + "label": "Özel tema" + } + } + }, + + "project_modules": { + "status": { + "backlog": "Bekleme Listesi", + "planned": "Planlandı", + "in_progress": "Devam Ediyor", + "paused": "Duraklatıldı", + "completed": "Tamamlandı", + "cancelled": "İptal Edildi" + }, + "layout": { + "list": "Liste düzeni", + "board": "Galeri düzeni", + "timeline": "Zaman çizelgesi düzeni" + }, + "order_by": { + "name": "Ad", + "progress": "İlerleme", + "issues": "İş öğesi sayısı", + "due_date": "Son tarih", + "created_at": "Oluşturulma tarihi", + "manual": "Manuel" + } + }, + + "cycle": { + "label": "{count, plural, one {Döngü} other {Döngüler}}", + "no_cycle": "Döngü yok" + }, + + "module": { + "label": "{count, plural, one {Modül} other {Modüller}}", + "no_module": "Modül yok" + }, + + "description_versions": { + "last_edited_by": "Son düzenleyen", + "previously_edited_by": "Önceki düzenleyen", + "edited_by": "Tarafından düzenlendi" + } +} diff --git a/packages/i18n/src/locales/ua/translations.json b/packages/i18n/src/locales/ua/translations.json index ace8349ff..b939f991e 100644 --- a/packages/i18n/src/locales/ua/translations.json +++ b/packages/i18n/src/locales/ua/translations.json @@ -348,7 +348,7 @@ "couldnt_remove_the_project_from_favorites": "Не вдалося вилучити проєкт із вибраного. Спробуйте ще раз.", "add_to_favorites": "Додати у вибране", "remove_from_favorites": "Вилучити з вибраного", - "publish_settings": "Налаштування публікації", + "publish_project": "Опублікувати проєкт", "publish": "Опублікувати", "copy_link": "Скопіювати посилання", "leave_project": "Вийти з проєкту", @@ -499,6 +499,10 @@ "re_generate_key": "Повторно згенерувати ключ", "export": "Експортувати", "member": "{count, plural, one{# учасник} few{# учасники} other{# учасників}}", + "new_password_must_be_different_from_old_password": "Новий пароль повинен бути відмінним від старого пароля", + "edited": "Редагувано", + "bot": "Бот", + "project_view": { "sort_by": { "created_at": "Створено", @@ -585,7 +589,7 @@ "default": "Поки у вас немає нещодавніх елементів." }, "filters": { - "all": "Усі елементи", + "all": "Усі", "projects": "Проєкти", "pages": "Сторінки", "issues": "Робочі одиниці" @@ -860,7 +864,8 @@ "deleting": "Видалення", "pending": "Очікує", "invite": "Запросити", - "view": "Подання" + "view": "Подання", + "deactivated_user": "Деактивований користувач" }, "chart": { "x_axis": "Вісь X", @@ -1708,8 +1713,87 @@ } }, "estimates": { - "title": "Увімкнути оцінки в проєкті", - "description": "Вони допомагають відображати складність і навантаження на команду." + "label": "Оцінки", + "title": "Увімкнути оцінки для мого проєкту", + "description": "Вони допомагають вам повідомляти про складність та навантаження команди.", + "no_estimate": "Без оцінки", + "new": "Нова система оцінок", + "create": { + "custom": "Власний", + "start_from_scratch": "Почати з нуля", + "choose_template": "Вибрати шаблон", + "choose_estimate_system": "Вибрати систему оцінок", + "enter_estimate_point": "Введіть оцінку", + "step": "Крок {step} з {total}", + "label": "Створити оцінку" + }, + "toasts": { + "created": { + "success": { + "title": "Оцінку створено", + "message": "Оцінку успішно створено" + }, + "error": { + "title": "Не вдалося створити оцінку", + "message": "Не вдалося створити нову оцінку, спробуйте ще раз." + } + }, + "updated": { + "success": { + "title": "Оцінку змінено", + "message": "Оцінку оновлено у вашому проєкті." + }, + "error": { + "title": "Не вдалося змінити оцінку", + "message": "Не вдалося змінити оцінку, спробуйте ще раз" + } + }, + "enabled": { + "success": { + "title": "Успіх!", + "message": "Оцінки увімкнено." + } + }, + "disabled": { + "success": { + "title": "Успіх!", + "message": "Оцінки вимкнено." + }, + "error": { + "title": "Помилка!", + "message": "Не вдалося вимкнути оцінку. Спробуйте ще раз" + } + } + }, + "validation": { + "min_length": "Оцінка має бути більшою за 0.", + "unable_to_process": "Не вдалося обробити ваш запит, спробуйте ще раз.", + "numeric": "Оцінка має бути числовим значенням.", + "character": "Оцінка має бути символьним значенням.", + "empty": "Значення оцінки не може бути порожнім.", + "already_exists": "Таке значення оцінки вже існує.", + "unsaved_changes": "У вас є незбережені зміни. Збережіть їх перед тим, як натиснути 'готово'", + "remove_empty": "Оцінка не може бути порожньою. Введіть значення в кожне поле або видаліть ті, для яких у вас немає значень." + }, + "systems": { + "points": { + "label": "Бали", + "fibonacci": "Фібоначчі", + "linear": "Лінійна", + "squares": "Квадрати", + "custom": "Власна" + }, + "categories": { + "label": "Категорії", + "t_shirt_sizes": "Розміри футболок", + "easy_to_hard": "Від легкого до складного", + "custom": "Власна" + }, + "time": { + "label": "Час", + "hours": "Години" + } + } }, "automations": { "label": "Автоматизація", @@ -1747,6 +1831,12 @@ "remove_filters_to_see_all_cycles": "Приберіть фільтри, щоб побачити всі цикли", "remove_search_criteria_to_see_all_cycles": "Приберіть критерії пошуку, щоб побачити всі цикли", "only_completed_cycles_can_be_archived": "Архівувати можна лише завершені цикли", + "start_date": "Дата початку", + "end_date": "Дата завершення", + "in_your_timezone": "У вашому часовому поясі", + "transfer_work_items": "Перенести {count} робочих одиниць", + "date_range": "Діапазон дат", + "add_date": "Додати дату", "active_cycle": { "label": "Активний цикл", "progress": "Прогрес", @@ -2313,12 +2403,20 @@ "manual": "Вручну" } }, + "cycle": { "label": "{count, plural, one {Цикл} few {Цикли} other {Циклів}}", "no_cycle": "Немає циклу" }, + "module": { "label": "{count, plural, one {Модуль} few {Модулі} other {Модулів}}", "no_module": "Немає модуля" + }, + + "description_versions": { + "last_edited_by": "Останнє редагування", + "previously_edited_by": "Раніше відредаговано", + "edited_by": "Відредаговано" } } diff --git a/packages/i18n/src/locales/vi-VN/translations.json b/packages/i18n/src/locales/vi-VN/translations.json new file mode 100644 index 000000000..225fffeb0 --- /dev/null +++ b/packages/i18n/src/locales/vi-VN/translations.json @@ -0,0 +1,2420 @@ +{ + "sidebar": { + "projects": "Dự án", + "pages": "Trang", + "new_work_item": "Mục công việc mới", + "home": "Trang chủ", + "your_work": "Công việc của tôi", + "inbox": "Hộp thư đến", + "workspace": "Không gian làm việc", + "views": "Chế độ xem", + "analytics": "Phân tích", + "work_items": "Mục công việc", + "cycles": "Chu kỳ", + "modules": "Mô-đun", + "intake": "Thu thập", + "drafts": "Bản nháp", + "favorites": "Yêu thích", + "pro": "Phiên bản Pro", + "upgrade": "Nâng cấp" + }, + "auth": { + "common": { + "email": { + "label": "Email", + "placeholder": "name@company.com", + "errors": { + "required": "Email là bắt buộc", + "invalid": "Email không hợp lệ" + } + }, + "password": { + "label": "Mật khẩu", + "set_password": "Đặt mật khẩu", + "placeholder": "Nhập mật khẩu", + "confirm_password": { + "label": "Xác nhận mật khẩu", + "placeholder": "Xác nhận mật khẩu" + }, + "current_password": { + "label": "Mật khẩu hiện tại" + }, + "new_password": { + "label": "Mật khẩu mới", + "placeholder": "Nhập mật khẩu mới" + }, + "change_password": { + "label": { + "default": "Thay đổi mật khẩu", + "submitting": "Đang thay đổi mật khẩu" + } + }, + "errors": { + "match": "Mật khẩu không khớp", + "empty": "Vui lòng nhập mật khẩu", + "length": "Mật khẩu phải dài hơn 8 ký tự", + "strength": { + "weak": "Mật khẩu yếu", + "strong": "Mật khẩu mạnh" + } + }, + "submit": "Đặt mật khẩu", + "toast": { + "change_password": { + "success": { + "title": "Thành công!", + "message": "Mật khẩu đã được thay đổi thành công." + }, + "error": { + "title": "Lỗi!", + "message": "Đã xảy ra lỗi. Vui lòng thử lại." + } + } + } + }, + "unique_code": { + "label": "Mã duy nhất", + "placeholder": "gets-sets-flys", + "paste_code": "Dán mã xác minh đã gửi đến email của bạn", + "requesting_new_code": "Đang yêu cầu mã mới", + "sending_code": "Đang gửi mã" + }, + "already_have_an_account": "Đã có tài khoản?", + "login": "Đăng nhập", + "create_account": "Tạo tài khoản", + "new_to_plane": "Lần đầu sử dụng Plane?", + "back_to_sign_in": "Quay lại đăng nhập", + "resend_in": "Gửi lại sau {seconds} giây", + "sign_in_with_unique_code": "Đăng nhập bằng mã duy nhất", + "forgot_password": "Quên mật khẩu?" + }, + "sign_up": { + "header": { + "label": "Tạo tài khoản để bắt đầu quản lý công việc cùng nhóm của bạn.", + "step": { + "email": { + "header": "Đăng ký", + "sub_header": "" + }, + "password": { + "header": "Đăng ký", + "sub_header": "Đăng ký bằng cách kết hợp email-mật khẩu." + }, + "unique_code": { + "header": "Đăng ký", + "sub_header": "Đăng ký bằng mã duy nhất được gửi đến email trên." + } + } + }, + "errors": { + "password": { + "strength": "Vui lòng đặt mật khẩu mạnh để tiếp tục" + } + } + }, + "sign_in": { + "header": { + "label": "Đăng nhập để bắt đầu quản lý công việc cùng nhóm của bạn.", + "step": { + "email": { + "header": "Đăng nhập hoặc đăng ký", + "sub_header": "" + }, + "password": { + "header": "Đăng nhập hoặc đăng ký", + "sub_header": "Đăng nhập bằng cách kết hợp email-mật khẩu của bạn." + }, + "unique_code": { + "header": "Đăng nhập hoặc đăng ký", + "sub_header": "Đăng nhập bằng mã duy nhất được gửi đến email trên." + } + } + } + }, + "forgot_password": { + "title": "Đặt lại mật khẩu", + "description": "Nhập địa chỉ email đã xác minh cho tài khoản người dùng của bạn và chúng tôi sẽ gửi cho bạn liên kết đặt lại mật khẩu.", + "email_sent": "Chúng tôi đã gửi liên kết đặt lại đến email của bạn", + "send_reset_link": "Gửi liên kết đặt lại", + "errors": { + "smtp_not_enabled": "Chúng tôi nhận thấy quản trị viên của bạn chưa bật SMTP, chúng tôi sẽ không thể gửi liên kết đặt lại mật khẩu" + }, + "toast": { + "success": { + "title": "Email đã được gửi", + "message": "Hãy kiểm tra hộp thư đến của bạn để lấy liên kết đặt lại mật khẩu. Nếu bạn không nhận được trong vòng vài phút, vui lòng kiểm tra thư mục spam." + }, + "error": { + "title": "Lỗi!", + "message": "Đã xảy ra lỗi. Vui lòng thử lại." + } + } + }, + "reset_password": { + "title": "Đặt mật khẩu mới", + "description": "Bảo vệ tài khoản của bạn bằng mật khẩu mạnh" + }, + "set_password": { + "title": "Bảo vệ tài khoản của bạn", + "description": "Đặt mật khẩu giúp bạn đăng nhập an toàn" + }, + "sign_out": { + "toast": { + "error": { + "title": "Lỗi!", + "message": "Không thể đăng xuất. Vui lòng thử lại." + } + } + } + }, + "submit": "Gửi", + "cancel": "Hủy", + "loading": "Đang tải", + "error": "Lỗi", + "success": "Thành công", + "warning": "Cảnh báo", + "info": "Thông tin", + "close": "Đóng", + "yes": "Có", + "no": "Không", + "ok": "OK", + "name": "Tên", + "description": "Mô tả", + "search": "Tìm kiếm", + "add_member": "Thêm thành viên", + "adding_members": "Đang thêm thành viên", + "remove_member": "Xóa thành viên", + "add_members": "Thêm thành viên", + "adding_member": "Đang thêm thành viên", + "remove_members": "Xóa thành viên", + "add": "Thêm", + "adding": "Đang thêm", + "remove": "Xóa", + "add_new": "Thêm mới", + "remove_selected": "Xóa đã chọn", + "first_name": "Tên", + "last_name": "Họ", + "email": "Email", + "display_name": "Tên hiển thị", + "role": "Vai trò", + "timezone": "Múi giờ", + "avatar": "Ảnh đại diện", + "cover_image": "Ảnh bìa", + "password": "Mật khẩu", + "change_cover": "Thay đổi ảnh bìa", + "language": "Ngôn ngữ", + "saving": "Đang lưu", + "save_changes": "Lưu thay đổi", + "deactivate_account": "Vô hiệu hóa tài khoản", + "deactivate_account_description": "Khi tài khoản bị vô hiệu hóa, tất cả dữ liệu và tài nguyên trong tài khoản đó sẽ bị xóa vĩnh viễn và không thể khôi phục.", + "profile_settings": "Cài đặt hồ sơ", + "your_account": "Tài khoản của bạn", + "security": "Bảo mật", + "activity": "Hoạt động", + "appearance": "Giao diện", + "notifications": "Thông báo", + "workspaces": "Không gian làm việc", + "create_workspace": "Tạo không gian làm việc", + "invitations": "Lời mời", + "summary": "Tóm tắt", + "assigned": "Đã phân công", + "created": "Đã tạo", + "subscribed": "Đã đăng ký", + "you_do_not_have_the_permission_to_access_this_page": "Bạn không có quyền truy cập trang này.", + "something_went_wrong_please_try_again": "Đã xảy ra lỗi. Vui lòng thử lại.", + "load_more": "Tải thêm", + "select_or_customize_your_interface_color_scheme": "Chọn hoặc tùy chỉnh sơ đồ màu giao diện của bạn.", + "theme": "Chủ đề", + "system_preference": "Tùy chọn hệ thống", + "light": "Sáng", + "dark": "Tối", + "light_contrast": "Sáng tương phản cao", + "dark_contrast": "Tối tương phản cao", + "custom": "Tùy chỉnh", + "select_your_theme": "Chọn chủ đề của bạn", + "customize_your_theme": "Tùy chỉnh chủ đề của bạn", + "background_color": "Màu nền", + "text_color": "Màu chữ", + "primary_color": "Màu chính (chủ đề)", + "sidebar_background_color": "Màu nền thanh bên", + "sidebar_text_color": "Màu chữ thanh bên", + "set_theme": "Đặt chủ đề", + "enter_a_valid_hex_code_of_6_characters": "Nhập mã hex hợp lệ gồm 6 ký tự", + "background_color_is_required": "Màu nền là bắt buộc", + "text_color_is_required": "Màu chữ là bắt buộc", + "primary_color_is_required": "Màu chính là bắt buộc", + "sidebar_background_color_is_required": "Màu nền thanh bên là bắt buộc", + "sidebar_text_color_is_required": "Màu chữ thanh bên là bắt buộc", + "updating_theme": "Đang cập nhật chủ đề", + "theme_updated_successfully": "Chủ đề đã được cập nhật thành công", + "failed_to_update_the_theme": "Không thể cập nhật chủ đề", + "email_notifications": "Thông báo qua email", + "stay_in_the_loop_on_issues_you_are_subscribed_to_enable_this_to_get_notified": "Cập nhật những mục công việc bạn đã đăng ký. Bật tính năng này để nhận thông báo.", + "email_notification_setting_updated_successfully": "Cài đặt thông báo email đã được cập nhật thành công", + "failed_to_update_email_notification_setting": "Không thể cập nhật cài đặt thông báo email", + "notify_me_when": "Thông báo cho tôi khi", + "property_changes": "Thay đổi thuộc tính", + "property_changes_description": "Thông báo cho tôi khi thuộc tính của mục công việc (như người phụ trách, mức độ ưu tiên, ước tính, v.v.) thay đổi.", + "state_change": "Thay đổi trạng thái", + "state_change_description": "Thông báo cho tôi khi mục công việc được chuyển sang trạng thái khác", + "issue_completed": "Mục công việc hoàn thành", + "issue_completed_description": "Chỉ thông báo cho tôi khi mục công việc hoàn thành", + "comments": "Bình luận", + "comments_description": "Thông báo cho tôi khi ai đó bình luận về mục công việc", + "mentions": "Đề cập", + "mentions_description": "Chỉ thông báo cho tôi khi ai đó đề cập đến tôi trong bình luận hoặc mô tả", + "old_password": "Mật khẩu cũ", + "general_settings": "Cài đặt chung", + "sign_out": "Đăng xuất", + "signing_out": "Đang đăng xuất", + "active_cycles": "Chu kỳ hoạt động", + "active_cycles_description": "Theo dõi chu kỳ trên các dự án, theo dõi mục công việc ưu tiên cao và chú ý đến các chu kỳ cần quan tâm.", + "on_demand_snapshots_of_all_your_cycles": "Ảnh chụp nhanh theo yêu cầu của tất cả chu kỳ của bạn", + "upgrade": "Nâng cấp", + "10000_feet_view": "Góc nhìn tổng quan về tất cả chu kỳ hoạt động.", + "10000_feet_view_description": "Phóng to tầm nhìn để xem tất cả chu kỳ đang diễn ra trong tất cả dự án cùng một lúc, thay vì xem từng chu kỳ trong mỗi dự án.", + "get_snapshot_of_each_active_cycle": "Nhận ảnh chụp nhanh của mỗi chu kỳ hoạt động.", + "get_snapshot_of_each_active_cycle_description": "Theo dõi số liệu tổng hợp cho tất cả chu kỳ hoạt động, xem trạng thái tiến độ và hiểu phạm vi liên quan đến thời hạn.", + "compare_burndowns": "So sánh biểu đồ burndown.", + "compare_burndowns_description": "Giám sát hiệu suất của từng nhóm bằng cách xem báo cáo burndown cho mỗi chu kỳ.", + "quickly_see_make_or_break_issues": "Nhanh chóng xem các vấn đề quan trọng.", + "quickly_see_make_or_break_issues_description": "Xem trước các mục công việc ưu tiên cao liên quan đến thời hạn trong mỗi chu kỳ. Xem tất cả mục công việc trong mỗi chu kỳ chỉ bằng một cú nhấp chuột.", + "zoom_into_cycles_that_need_attention": "Phóng to vào chu kỳ cần chú ý.", + "zoom_into_cycles_that_need_attention_description": "Điều tra bất kỳ trạng thái chu kỳ nào không đáp ứng mong đợi chỉ bằng một cú nhấp chuột.", + "stay_ahead_of_blockers": "Đi trước các yếu tố chặn.", + "stay_ahead_of_blockers_description": "Phát hiện thách thức từ dự án này sang dự án khác và xem các phụ thuộc giữa các chu kỳ không dễ thấy từ các chế độ xem khác.", + "analytics": "Phân tích", + "workspace_invites": "Lời mời không gian làm việc", + "enter_god_mode": "Vào chế độ quản trị viên", + "workspace_logo": "Logo không gian làm việc", + "new_issue": "Mục công việc mới", + "your_work": "Công việc của tôi", + "drafts": "Bản nháp", + "projects": "Dự án", + "views": "Chế độ xem", + "workspace": "Không gian làm việc", + "archives": "Lưu trữ", + "settings": "Cài đặt", + "failed_to_move_favorite": "Không thể di chuyển mục yêu thích", + "favorites": "Yêu thích", + "no_favorites_yet": "Chưa có mục yêu thích", + "create_folder": "Tạo thư mục", + "new_folder": "Thư mục mới", + "favorite_updated_successfully": "Đã cập nhật mục yêu thích thành công", + "favorite_created_successfully": "Đã tạo mục yêu thích thành công", + "folder_already_exists": "Thư mục đã tồn tại", + "folder_name_cannot_be_empty": "Tên thư mục không thể trống", + "something_went_wrong": "Đã xảy ra lỗi", + "failed_to_reorder_favorite": "Không thể sắp xếp lại mục yêu thích", + "favorite_removed_successfully": "Đã xóa mục yêu thích thành công", + "failed_to_create_favorite": "Không thể tạo mục yêu thích", + "failed_to_rename_favorite": "Không thể đổi tên mục yêu thích", + "project_link_copied_to_clipboard": "Đã sao chép liên kết dự án vào bảng tạm", + "link_copied": "Đã sao chép liên kết", + "add_project": "Thêm dự án", + "create_project": "Tạo dự án", + "failed_to_remove_project_from_favorites": "Không thể xóa dự án khỏi mục yêu thích. Vui lòng thử lại.", + "project_created_successfully": "Dự án đã được tạo thành công", + "project_created_successfully_description": "Dự án đã được tạo thành công. Bây giờ bạn có thể bắt đầu thêm mục công việc.", + "project_cover_image_alt": "Ảnh bìa dự án", + "name_is_required": "Tên là bắt buộc", + "title_should_be_less_than_255_characters": "Tiêu đề phải ít hơn 255 ký tự", + "project_name": "Tên dự án", + "project_id_must_be_at_least_1_character": "ID dự án phải có ít nhất 1 ký tự", + "project_id_must_be_at_most_5_characters": "ID dự án chỉ được tối đa 5 ký tự", + "project_id": "ID dự án", + "project_id_tooltip_content": "Giúp xác định duy nhất mục công việc trong dự án của bạn. Tối đa 5 ký tự.", + "description_placeholder": "Mô tả", + "only_alphanumeric_non_latin_characters_allowed": "Chỉ cho phép các ký tự chữ số và không phải Latin.", + "project_id_is_required": "ID dự án là bắt buộc", + "project_id_allowed_char": "Chỉ cho phép các ký tự chữ số và không phải Latin.", + "project_id_min_char": "ID dự án phải có ít nhất 1 ký tự", + "project_id_max_char": "ID dự án chỉ được tối đa 5 ký tự", + "project_description_placeholder": "Nhập mô tả dự án", + "select_network": "Chọn mạng", + "lead": "Người phụ trách", + "date_range": "Khoảng thời gian", + "private": "Riêng tư", + "public": "Công khai", + "accessible_only_by_invite": "Chỉ truy cập được bằng lời mời", + "anyone_in_the_workspace_except_guests_can_join": "Bất kỳ ai trong không gian làm việc ngoại trừ khách đều có thể tham gia", + "creating": "Đang tạo", + "creating_project": "Đang tạo dự án", + "adding_project_to_favorites": "Đang thêm dự án vào mục yêu thích", + "project_added_to_favorites": "Đã thêm dự án vào mục yêu thích", + "couldnt_add_the_project_to_favorites": "Không thể thêm dự án vào mục yêu thích. Vui lòng thử lại.", + "removing_project_from_favorites": "Đang xóa dự án khỏi mục yêu thích", + "project_removed_from_favorites": "Đã xóa dự án khỏi mục yêu thích", + "couldnt_remove_the_project_from_favorites": "Không thể xóa dự án khỏi mục yêu thích. Vui lòng thử lại.", + "add_to_favorites": "Thêm vào mục yêu thích", + "remove_from_favorites": "Xóa khỏi mục yêu thích", + "publish_project": "Xuất bản dự án", + "publish": "Xuất bản", + "copy_link": "Sao chép liên kết", + "leave_project": "Rời dự án", + "join_the_project_to_rearrange": "Tham gia dự án để sắp xếp lại", + "drag_to_rearrange": "Kéo để sắp xếp lại", + "congrats": "Chúc mừng!", + "open_project": "Mở dự án", + "issues": "Mục công việc", + "cycles": "Chu kỳ", + "modules": "Mô-đun", + "pages": "Trang", + "intake": "Thu thập", + "time_tracking": "Theo dõi thời gian", + "work_management": "Quản lý công việc", + "projects_and_issues": "Dự án và mục công việc", + "projects_and_issues_description": "Bật hoặc tắt các tính năng này trong dự án này. Có thể thay đổi theo thời gian phù hợp với nhu cầu.", + "cycles_description": "Thiết lập khung thời gian cho dự án theo nhu cầu, có thể thay đổi tần suất theo các khoảng thời gian khác nhau.", + "modules_description": "Nhóm công việc thành cấu trúc giống như các dự án con, với người phụ trách và người được phân công riêng.", + "views_description": "Lưu các tùy chọn sắp xếp, lọc và hiển thị để sử dụng hoặc chia sẻ sau này.", + "pages_description": "Viết bất cứ thứ gì giống như viết bất cứ thứ gì.", + "intake_description": "Cập nhật những mục công việc bạn đã đăng ký. Bật tính năng này để nhận thông báo.", + "time_tracking_description": "Theo dõi thời gian dành cho mục công việc và dự án.", + "work_management_description": "Quản lý công việc và dự án của bạn một cách dễ dàng.", + "documentation": "Tài liệu", + "message_support": "Liên hệ hỗ trợ", + "contact_sales": "Liên hệ bộ phận bán hàng", + "hyper_mode": "Chế độ siêu tốc", + "keyboard_shortcuts": "Phím tắt", + "whats_new": "Có gì mới", + "version": "Phiên bản", + "we_are_having_trouble_fetching_the_updates": "Chúng tôi đang gặp sự cố khi lấy bản cập nhật.", + "our_changelogs": "Nhật ký thay đổi của chúng tôi", + "for_the_latest_updates": "Để cập nhật mới nhất.", + "please_visit": "Vui lòng truy cập", + "docs": "Tài liệu", + "full_changelog": "Nhật ký thay đổi đầy đủ", + "support": "Hỗ trợ", + "discord": "Discord", + "powered_by_plane_pages": "Được hỗ trợ bởi Plane Pages", + "please_select_at_least_one_invitation": "Vui lòng chọn ít nhất một lời mời.", + "please_select_at_least_one_invitation_description": "Vui lòng chọn ít nhất một lời mời để tham gia không gian làm việc.", + "we_see_that_someone_has_invited_you_to_join_a_workspace": "Chúng tôi thấy có người đã mời bạn tham gia không gian làm việc", + "join_a_workspace": "Tham gia không gian làm việc", + "we_see_that_someone_has_invited_you_to_join_a_workspace_description": "Chúng tôi thấy có người đã mời bạn tham gia không gian làm việc", + "join_a_workspace_description": "Tham gia không gian làm việc", + "accept_and_join": "Chấp nhận và tham gia", + "go_home": "Về trang chủ", + "no_pending_invites": "Không có lời mời đang chờ xử lý", + "you_can_see_here_if_someone_invites_you_to_a_workspace": "Bạn có thể xem ở đây nếu ai đó mời bạn vào không gian làm việc", + "back_to_home": "Quay lại trang chủ", + "workspace_name": "Tên không gian làm việc", + "deactivate_your_account": "Vô hiệu hóa tài khoản của bạn", + "deactivate_your_account_description": "Khi đã vô hiệu hóa, bạn sẽ không được phân công công việc và sẽ không được tính vào hóa đơn của không gian làm việc. Để kích hoạt lại tài khoản, bạn cần nhận được lời mời không gian làm việc gửi đến địa chỉ email này.", + "deactivating": "Đang vô hiệu hóa", + "confirm": "Xác nhận", + "confirming": "Đang xác nhận", + "draft_created": "Đã tạo bản nháp", + "issue_created_successfully": "Đã tạo mục công việc thành công", + "draft_creation_failed": "Tạo bản nháp thất bại", + "issue_creation_failed": "Tạo mục công việc thất bại", + "draft_issue": "Mục công việc nháp", + "issue_updated_successfully": "Đã cập nhật mục công việc thành công", + "issue_could_not_be_updated": "Không thể cập nhật mục công việc", + "create_a_draft": "Tạo bản nháp", + "save_to_drafts": "Lưu vào bản nháp", + "save": "Lưu", + "update": "Cập nhật", + "updating": "Đang cập nhật", + "create_new_issue": "Tạo mục công việc mới", + "editor_is_not_ready_to_discard_changes": "Trình soạn thảo chưa sẵn sàng để hủy bỏ thay đổi", + "failed_to_move_issue_to_project": "Không thể di chuyển mục công việc đến dự án", + "create_more": "Tạo thêm", + "add_to_project": "Thêm vào dự án", + "discard": "Hủy bỏ", + "duplicate_issue_found": "Đã tìm thấy mục công việc trùng lặp", + "duplicate_issues_found": "Đã tìm thấy các mục công việc trùng lặp", + "no_matching_results": "Không có kết quả phù hợp", + "title_is_required": "Tiêu đề là bắt buộc", + "title": "Tiêu đề", + "state": "Trạng thái", + "priority": "Ưu tiên", + "none": "Không có", + "urgent": "Khẩn cấp", + "high": "Cao", + "medium": "Trung bình", + "low": "Thấp", + "members": "Thành viên", + "assignee": "Người phụ trách", + "assignees": "Người phụ trách", + "you": "Bạn", + "labels": "Nhãn", + "create_new_label": "Tạo nhãn mới", + "start_date": "Ngày bắt đầu", + "end_date": "Ngày kết thúc", + "due_date": "Ngày hết hạn", + "estimate": "Ước tính", + "change_parent_issue": "Thay đổi mục công việc cha", + "remove_parent_issue": "Xóa mục công việc cha", + "add_parent": "Thêm mục cha", + "loading_members": "Đang tải thành viên", + "view_link_copied_to_clipboard": "Đã sao chép liên kết xem vào bảng tạm", + "required": "Bắt buộc", + "optional": "Tùy chọn", + "Cancel": "Hủy", + "edit": "Chỉnh sửa", + "archive": "Lưu trữ", + "restore": "Khôi phục", + "open_in_new_tab": "Mở trong tab mới", + "delete": "Xóa", + "deleting": "Đang xóa", + "make_a_copy": "Tạo bản sao", + "move_to_project": "Di chuyển đến dự án", + "good": "Chào buổi sáng", + "morning": "Buổi sáng", + "afternoon": "Buổi chiều", + "evening": "Buổi tối", + "show_all": "Hiển thị tất cả", + "show_less": "Hiển thị ít hơn", + "no_data_yet": "Chưa có dữ liệu", + "syncing": "Đang đồng bộ", + "add_work_item": "Thêm mục công việc", + "advanced_description_placeholder": "Nhấn '/' để sử dụng lệnh", + "create_work_item": "Tạo mục công việc", + "attachments": "Tệp đính kèm", + "declining": "Đang từ chối", + "declined": "Đã từ chối", + "decline": "Từ chối", + "unassigned": "Chưa phân công", + "work_items": "Mục công việc", + "add_link": "Thêm liên kết", + "points": "Điểm", + "no_assignee": "Không có người phụ trách", + "no_assignees_yet": "Chưa có người phụ trách", + "no_labels_yet": "Chưa có nhãn", + "ideal": "Lý tưởng", + "current": "Hiện tại", + "no_matching_members": "Không có thành viên phù hợp", + "leaving": "Đang rời", + "removing": "Đang xóa", + "leave": "Rời", + "refresh": "Làm mới", + "refreshing": "Đang làm mới", + "refresh_status": "Làm mới trạng thái", + "prev": "Trước", + "next": "Tiếp", + "re_generating": "Đang tạo lại", + "re_generate": "Tạo lại", + "re_generate_key": "Tạo lại khóa", + "export": "Xuất", + "member": "{count, plural, other{# thành viên}}", + "new_password_must_be_different_from_old_password": "Mật khẩu mới phải khác mật khẩu cũ", + "edited": "đã chỉnh sửa", + "bot": "bot", + + "project_view": { + "sort_by": { + "created_at": "Thời gian tạo", + "updated_at": "Thời gian cập nhật", + "name": "Tên" + } + }, + "toast": { + "success": "Thành công!", + "error": "Lỗi!" + }, + "links": { + "toasts": { + "created": { + "title": "Đã tạo liên kết", + "message": "Liên kết đã được tạo thành công" + }, + "not_created": { + "title": "Chưa tạo liên kết", + "message": "Không thể tạo liên kết" + }, + "updated": { + "title": "Đã cập nhật liên kết", + "message": "Liên kết đã được cập nhật thành công" + }, + "not_updated": { + "title": "Chưa cập nhật liên kết", + "message": "Không thể cập nhật liên kết" + }, + "removed": { + "title": "Đã xóa liên kết", + "message": "Liên kết đã được xóa thành công" + }, + "not_removed": { + "title": "Chưa xóa liên kết", + "message": "Không thể xóa liên kết" + } + } + }, + "home": { + "empty": { + "quickstart_guide": "Hướng dẫn nhanh", + "not_right_now": "Không phải bây giờ", + "create_project": { + "title": "Tạo dự án", + "description": "Trong Plane, hầu hết mọi thứ đều bắt đầu từ dự án.", + "cta": "Bắt đầu" + }, + "invite_team": { + "title": "Mời nhóm của bạn", + "description": "Xây dựng, phát hành và quản lý cùng với đồng nghiệp.", + "cta": "Mời họ tham gia" + }, + "configure_workspace": { + "title": "Thiết lập không gian làm việc của bạn", + "description": "Bật hoặc tắt tính năng, hoặc thiết lập thêm.", + "cta": "Cấu hình không gian làm việc này" + }, + "personalize_account": { + "title": "Cá nhân hóa Plane cho bạn", + "description": "Chọn ảnh đại diện, màu sắc và nhiều hơn nữa.", + "cta": "Cá nhân hóa ngay" + }, + "widgets": { + "title": "Không có tiện ích nào trông có vẻ yên tĩnh, hãy bật chúng lên", + "description": "Có vẻ như tất cả tiện ích của bạn đều đã bị tắt. Bật chúng ngay\nđể nâng cao trải nghiệm của bạn!", + "primary_button": { + "text": "Quản lý tiện ích" + } + } + }, + "quick_links": { + "empty": "Lưu liên kết liên quan đến công việc mà bạn muốn truy cập thuận tiện.", + "add": "Thêm liên kết nhanh", + "title": "Liên kết nhanh", + "title_plural": "Liên kết nhanh" + }, + "recents": { + "title": "Gần đây", + "empty": { + "project": "Sau khi truy cập dự án, các dự án gần đây của bạn sẽ xuất hiện ở đây.", + "page": "Sau khi truy cập trang, các trang gần đây của bạn sẽ xuất hiện ở đây.", + "issue": "Sau khi truy cập mục công việc, các mục công việc gần đây của bạn sẽ xuất hiện ở đây.", + "default": "Bạn chưa có dự án gần đây nào." + }, + "filters": { + "all": "Tất cả", + "projects": "Dự án", + "pages": "Trang", + "issues": "Mục công việc" + } + }, + "new_at_plane": { + "title": "Tính năng mới của Plane" + }, + "quick_tutorial": { + "title": "Hướng dẫn nhanh" + }, + "widget": { + "reordered_successfully": "Đã sắp xếp lại tiện ích thành công.", + "reordering_failed": "Đã xảy ra lỗi khi sắp xếp lại tiện ích." + }, + "manage_widgets": "Quản lý tiện ích", + "title": "Trang chủ", + "star_us_on_github": "Gắn sao cho chúng tôi trên GitHub" + }, + "link": { + "modal": { + "url": { + "text": "URL", + "required": "URL không hợp lệ", + "placeholder": "Nhập hoặc dán URL" + }, + "title": { + "text": "Tiêu đề hiển thị", + "placeholder": "Bạn muốn hiển thị liên kết này như thế nào" + } + } + }, + "common": { + "all": "Tất cả", + "states": "Trạng thái", + "state": "Trạng thái", + "state_groups": "Nhóm trạng thái", + "state_group": "Nhóm trạng thái", + "priorities": "Ưu tiên", + "priority": "Ưu tiên", + "team_project": "Dự án nhóm", + "project": "Dự án", + "cycle": "Chu kỳ", + "cycles": "Chu kỳ", + "module": "Mô-đun", + "modules": "Mô-đun", + "labels": "Nhãn", + "label": "Nhãn", + "assignees": "Người phụ trách", + "assignee": "Người phụ trách", + "created_by": "Người tạo", + "none": "Không có", + "link": "Liên kết", + "estimates": "Ước tính", + "estimate": "Ước tính", + "created_at": "Được tạo vào", + "completed_at": "Hoàn thành vào", + "layout": "Bố cục", + "filters": "Bộ lọc", + "display": "Hiển thị", + "load_more": "Tải thêm", + "activity": "Hoạt động", + "analytics": "Phân tích", + "dates": "Ngày tháng", + "success": "Thành công!", + "something_went_wrong": "Đã xảy ra lỗi", + "error": { + "label": "Lỗi!", + "message": "Đã xảy ra lỗi. Vui lòng thử lại." + }, + "group_by": "Nhóm theo", + "epic": "Sử thi", + "epics": "Sử thi", + "work_item": "Mục công việc", + "work_items": "Mục công việc", + "sub_work_item": "Mục công việc con", + "add": "Thêm", + "warning": "Cảnh báo", + "updating": "Đang cập nhật", + "adding": "Đang thêm", + "update": "Cập nhật", + "creating": "Đang tạo", + "create": "Tạo", + "cancel": "Hủy", + "description": "Mô tả", + "title": "Tiêu đề", + "attachment": "Tệp đính kèm", + "general": "Chung", + "features": "Tính năng", + "automation": "Tự động hóa", + "project_name": "Tên dự án", + "project_id": "ID dự án", + "project_timezone": "Múi giờ dự án", + "created_on": "Được tạo vào", + "update_project": "Cập nhật dự án", + "identifier_already_exists": "Định danh đã tồn tại", + "add_more": "Thêm nữa", + "defaults": "Mặc định", + "add_label": "Thêm nhãn", + "customize_time_range": "Tùy chỉnh khoảng thời gian", + "loading": "Đang tải", + "attachments": "Tệp đính kèm", + "property": "Thuộc tính", + "properties": "Thuộc tính", + "parent": "Mục cha", + "page": "Trang", + "remove": "Xóa", + "archiving": "Đang lưu trữ", + "archive": "Lưu trữ", + "access": { + "public": "Công khai", + "private": "Riêng tư" + }, + "done": "Hoàn thành", + "sub_work_items": "Mục công việc con", + "comment": "Bình luận", + "workspace_level": "Cấp không gian làm việc", + "order_by": { + "label": "Sắp xếp theo", + "manual": "Thủ công", + "last_created": "Mới tạo nhất", + "last_updated": "Mới cập nhật nhất", + "start_date": "Ngày bắt đầu", + "due_date": "Ngày hết hạn", + "asc": "Tăng dần", + "desc": "Giảm dần", + "updated_on": "Cập nhật vào" + }, + "sort": { + "asc": "Tăng dần", + "desc": "Giảm dần", + "created_on": "Thời gian tạo", + "updated_on": "Thời gian cập nhật" + }, + "comments": "Bình luận", + "updates": "Cập nhật", + "clear_all": "Xóa tất cả", + "copied": "Đã sao chép!", + "link_copied": "Đã sao chép liên kết!", + "link_copied_to_clipboard": "Đã sao chép liên kết vào bảng tạm", + "copied_to_clipboard": "Đã sao chép liên kết mục công việc vào bảng tạm", + "is_copied_to_clipboard": "Mục công việc đã được sao chép vào bảng tạm", + "no_links_added_yet": "Chưa có liên kết nào được thêm", + "add_link": "Thêm liên kết", + "links": "Liên kết", + "go_to_workspace": "Đi đến không gian làm việc", + "progress": "Tiến độ", + "optional": "Tùy chọn", + "join": "Tham gia", + "go_back": "Quay lại", + "continue": "Tiếp tục", + "resend": "Gửi lại", + "relations": "Mối quan hệ", + "errors": { + "default": { + "title": "Lỗi!", + "message": "Đã xảy ra lỗi. Vui lòng thử lại." + }, + "required": "Trường này là bắt buộc", + "entity_required": "{entity} là bắt buộc" + }, + "update_link": "Cập nhật liên kết", + "attach": "Đính kèm", + "create_new": "Tạo mới", + "add_existing": "Thêm mục hiện có", + "type_or_paste_a_url": "Nhập hoặc dán URL", + "url_is_invalid": "URL không hợp lệ", + "display_title": "Tiêu đề hiển thị", + "link_title_placeholder": "Bạn muốn hiển thị liên kết này như thế nào", + "url": "URL", + "side_peek": "Xem lướt bên cạnh", + "modal": "Cửa sổ", + "full_screen": "Toàn màn hình", + "close_peek_view": "Đóng chế độ xem lướt", + "toggle_peek_view_layout": "Chuyển đổi bố cục xem lướt", + "options": "Tùy chọn", + "duration": "Thời lượng", + "today": "Hôm nay", + "week": "Tuần", + "month": "Tháng", + "quarter": "Quý", + "press_for_commands": "Nhấn '/' để sử dụng lệnh", + "click_to_add_description": "Nhấp để thêm mô tả", + "search": { + "label": "Tìm kiếm", + "placeholder": "Nhập nội dung tìm kiếm", + "no_matches_found": "Không tìm thấy kết quả phù hợp", + "no_matching_results": "Không có kết quả phù hợp" + }, + "actions": { + "edit": "Chỉnh sửa", + "make_a_copy": "Tạo bản sao", + "open_in_new_tab": "Mở trong tab mới", + "copy_link": "Sao chép liên kết", + "archive": "Lưu trữ", + "delete": "Xóa", + "remove_relation": "Xóa mối quan hệ", + "subscribe": "Đăng ký", + "unsubscribe": "Hủy đăng ký", + "clear_sorting": "Xóa sắp xếp", + "show_weekends": "Hiển thị cuối tuần", + "enable": "Bật", + "disable": "Tắt" + }, + "name": "Tên", + "discard": "Hủy bỏ", + "confirm": "Xác nhận", + "confirming": "Đang xác nhận", + "read_the_docs": "Đọc tài liệu", + "default": "Mặc định", + "active": "Hoạt động", + "enabled": "Đã bật", + "disabled": "Đã tắt", + "mandate": "Ủy quyền", + "mandatory": "Bắt buộc", + "yes": "Có", + "no": "Không", + "please_wait": "Vui lòng đợi", + "enabling": "Đang bật", + "disabling": "Đang tắt", + "beta": "Phiên bản beta", + "or": "Hoặc", + "next": "Tiếp theo", + "back": "Quay lại", + "cancelling": "Đang hủy", + "configuring": "Đang cấu hình", + "clear": "Xóa", + "import": "Nhập", + "connect": "Kết nối", + "authorizing": "Đang xác thực", + "processing": "Đang xử lý", + "no_data_available": "Không có dữ liệu", + "from": "Từ {name}", + "authenticated": "Đã xác thực", + "select": "Chọn", + "upgrade": "Nâng cấp", + "add_seats": "Thêm vị trí", + "projects": "Dự án", + "workspace": "Không gian làm việc", + "workspaces": "Không gian làm việc", + "team": "Nhóm", + "teams": "Nhóm", + "entity": "Thực thể", + "entities": "Thực thể", + "task": "Nhiệm vụ", + "tasks": "Nhiệm vụ", + "section": "Phần", + "sections": "Phần", + "edit": "Chỉnh sửa", + "connecting": "Đang kết nối", + "connected": "Đã kết nối", + "disconnect": "Ngắt kết nối", + "disconnecting": "Đang ngắt kết nối", + "installing": "Đang cài đặt", + "install": "Cài đặt", + "reset": "Đặt lại", + "live": "Trực tiếp", + "change_history": "Lịch sử thay đổi", + "coming_soon": "Sắp ra mắt", + "members": "Thành viên", + "you": "Bạn", + "upgrade_cta": { + "higher_subscription": "Nâng cấp lên gói cao hơn", + "talk_to_sales": "Liên hệ bộ phận bán hàng" + }, + "category": "Danh mục", + "categories": "Danh mục", + "saving": "Đang lưu", + "save_changes": "Lưu thay đổi", + "delete": "Xóa", + "deleting": "Đang xóa", + "pending": "Đang chờ xử lý", + "invite": "Mời", + "view": "Xem", + "deactivated_user": "Người dùng bị vô hiệu hóa" + }, + "chart": { + "x_axis": "Trục X", + "y_axis": "Trục Y", + "metric": "Chỉ số" + }, + "form": { + "title": { + "required": "Tiêu đề là bắt buộc", + "max_length": "Tiêu đề phải ít hơn {length} ký tự" + } + }, + "entity": { + "grouping_title": "Nhóm {entity}", + "priority": "Ưu tiên {entity}", + "all": "Tất cả {entity}", + "drop_here_to_move": "Kéo thả vào đây để di chuyển {entity}", + "delete": { + "label": "Xóa {entity}", + "success": "Đã xóa {entity} thành công", + "failed": "Xóa {entity} thất bại" + }, + "update": { + "failed": "Cập nhật {entity} thất bại", + "success": "Đã cập nhật {entity} thành công" + }, + "link_copied_to_clipboard": "Đã sao chép liên kết {entity} vào bảng tạm", + "fetch": { + "failed": "Đã xảy ra lỗi khi tải {entity}" + }, + "add": { + "success": "Đã thêm {entity} thành công", + "failed": "Đã xảy ra lỗi khi thêm {entity}" + } + }, + "epic": { + "all": "Tất cả sử thi", + "label": "{count, plural, one {sử thi} other {sử thi}}", + "new": "Sử thi mới", + "adding": "Đang thêm sử thi", + "create": { + "success": "Đã tạo sử thi thành công" + }, + "add": { + "press_enter": "Nhấn 'Enter' để thêm sử thi khác", + "label": "Thêm sử thi" + }, + "title": { + "label": "Tiêu đề sử thi", + "required": "Tiêu đề sử thi là bắt buộc" + } + }, + "issue": { + "label": "{count, plural, one {mục công việc} other {mục công việc}}", + "all": "Tất cả mục công việc", + "edit": "Chỉnh sửa mục công việc", + "title": { + "label": "Tiêu đề mục công việc", + "required": "Tiêu đề mục công việc là bắt buộc" + }, + "add": { + "press_enter": "Nhấn 'Enter' để thêm mục công việc khác", + "label": "Thêm mục công việc", + "cycle": { + "failed": "Không thể thêm mục công việc vào chu kỳ. Vui lòng thử lại.", + "success": "{count, plural, one {Mục công việc} other {Mục công việc}} đã được thêm vào chu kỳ thành công.", + "loading": "Đang thêm {count, plural, one {mục công việc} other {mục công việc}} vào chu kỳ" + }, + "assignee": "Thêm người phụ trách", + "start_date": "Thêm ngày bắt đầu", + "due_date": "Thêm ngày hết hạn", + "parent": "Thêm mục công việc cha", + "sub_issue": "Thêm mục công việc con", + "relation": "Thêm mối quan hệ", + "link": "Thêm liên kết", + "existing": "Thêm mục công việc hiện có" + }, + "remove": { + "label": "Xóa mục công việc", + "cycle": { + "loading": "Đang xóa mục công việc khỏi chu kỳ", + "success": "Đã xóa mục công việc khỏi chu kỳ thành công.", + "failed": "Không thể xóa mục công việc khỏi chu kỳ. Vui lòng thử lại." + }, + "module": { + "loading": "Đang xóa mục công việc khỏi mô-đun", + "success": "Đã xóa mục công việc khỏi mô-đun thành công.", + "failed": "Không thể xóa mục công việc khỏi mô-đun. Vui lòng thử lại." + }, + "parent": { + "label": "Xóa mục công việc cha" + } + }, + "new": "Mục công việc mới", + "adding": "Đang thêm mục công việc", + "create": { + "success": "Đã tạo mục công việc thành công" + }, + "priority": { + "urgent": "Khẩn cấp", + "high": "Cao", + "medium": "Trung bình", + "low": "Thấp" + }, + "display": { + "properties": { + "label": "Hiển thị thuộc tính", + "id": "ID", + "issue_type": "Loại mục công việc", + "sub_issue_count": "Số lượng mục công việc con", + "attachment_count": "Số lượng tệp đính kèm", + "created_on": "Được tạo vào", + "sub_issue": "Mục công việc con", + "work_item_count": "Số lượng mục công việc" + }, + "extra": { + "show_sub_issues": "Hiển thị mục công việc con", + "show_empty_groups": "Hiển thị nhóm trống" + } + }, + "layouts": { + "ordered_by_label": "Bố cục này được sắp xếp theo", + "list": "Danh sách", + "kanban": "Kanban", + "calendar": "Lịch", + "spreadsheet": "Bảng tính", + "gantt": "Dòng thời gian", + "title": { + "list": "Bố cục danh sách", + "kanban": "Bố cục kanban", + "calendar": "Bố cục lịch", + "spreadsheet": "Bố cục bảng tính", + "gantt": "Bố cục dòng thời gian" + } + }, + "states": { + "active": "Hoạt động", + "backlog": "Tồn đọng" + }, + "comments": { + "placeholder": "Thêm bình luận", + "switch": { + "private": "Chuyển sang bình luận riêng tư", + "public": "Chuyển sang bình luận công khai" + }, + "create": { + "success": "Đã tạo bình luận thành công", + "error": "Không thể tạo bình luận. Vui lòng thử lại sau." + }, + "update": { + "success": "Đã cập nhật bình luận thành công", + "error": "Không thể cập nhật bình luận. Vui lòng thử lại sau." + }, + "remove": { + "success": "Đã xóa bình luận thành công", + "error": "Không thể xóa bình luận. Vui lòng thử lại sau." + }, + "upload": { + "error": "Không thể tải lên tài nguyên. Vui lòng thử lại sau." + } + }, + "empty_state": { + "issue_detail": { + "title": "Mục công việc không tồn tại", + "description": "Mục công việc bạn đang tìm kiếm không tồn tại, đã được lưu trữ hoặc đã bị xóa.", + "primary_button": { + "text": "Xem các mục công việc khác" + } + } + }, + "sibling": { + "label": "Mục công việc cùng cấp" + }, + "archive": { + "description": "Chỉ những mục công việc đã hoàn thành hoặc đã hủy\ncó thể được lưu trữ", + "label": "Lưu trữ mục công việc", + "confirm_message": "Bạn có chắc chắn muốn lưu trữ mục công việc này không? Tất cả mục công việc đã lưu trữ có thể được khôi phục sau.", + "success": { + "label": "Lưu trữ thành công", + "message": "Mục đã lưu trữ của bạn có thể được tìm thấy trong phần lưu trữ của dự án." + }, + "failed": { + "message": "Không thể lưu trữ mục công việc. Vui lòng thử lại." + } + }, + "restore": { + "success": { + "title": "Khôi phục thành công", + "message": "Mục công việc của bạn có thể được tìm thấy trong mục công việc của dự án." + }, + "failed": { + "message": "Không thể khôi phục mục công việc. Vui lòng thử lại." + } + }, + "relation": { + "relates_to": "Liên quan đến", + "duplicate": "Trùng lặp với", + "blocked_by": "Bị chặn bởi", + "blocking": "Đang chặn" + }, + "copy_link": "Sao chép liên kết mục công việc", + "delete": { + "label": "Xóa mục công việc", + "error": "Đã xảy ra lỗi khi xóa mục công việc" + }, + "subscription": { + "actions": { + "subscribed": "Đã đăng ký mục công việc thành công", + "unsubscribed": "Đã hủy đăng ký mục công việc thành công" + } + }, + "select": { + "error": "Vui lòng chọn ít nhất một mục công việc", + "empty": "Chưa chọn mục công việc", + "add_selected": "Thêm mục công việc đã chọn" + }, + "open_in_full_screen": "Mở mục công việc trong chế độ toàn màn hình" + }, + "attachment": { + "error": "Không thể đính kèm tệp. Vui lòng tải lên lại.", + "only_one_file_allowed": "Chỉ có thể tải lên một tệp mỗi lần.", + "file_size_limit": "Kích thước tệp phải nhỏ hơn hoặc bằng {size}MB.", + "drag_and_drop": "Kéo và thả vào bất kỳ đâu để tải lên", + "delete": "Xóa tệp đính kèm" + }, + "label": { + "select": "Chọn nhãn", + "create": { + "success": "Đã tạo nhãn thành công", + "failed": "Tạo nhãn thất bại", + "already_exists": "Nhãn đã tồn tại", + "type": "Nhập để thêm nhãn mới" + } + }, + "sub_work_item": { + "update": { + "success": "Đã cập nhật mục công việc con thành công", + "error": "Đã xảy ra lỗi khi cập nhật mục công việc con" + }, + "remove": { + "success": "Đã xóa mục công việc con thành công", + "error": "Đã xảy ra lỗi khi xóa mục công việc con" + } + }, + "view": { + "label": "{count, plural, one {chế độ xem} other {chế độ xem}}", + "create": { + "label": "Tạo chế độ xem" + }, + "update": { + "label": "Cập nhật chế độ xem" + } + }, + "inbox_issue": { + "status": { + "pending": { + "title": "Đang chờ xử lý", + "description": "Đang chờ xử lý" + }, + "declined": { + "title": "Đã từ chối", + "description": "Đã từ chối" + }, + "snoozed": { + "title": "Đã tạm hoãn", + "description": "Còn lại {days, plural, one{# ngày} other{# ngày}}" + }, + "accepted": { + "title": "Đã chấp nhận", + "description": "Đã chấp nhận" + }, + "duplicate": { + "title": "Trùng lặp", + "description": "Trùng lặp" + } + }, + "modals": { + "decline": { + "title": "Từ chối mục công việc", + "content": "Bạn có chắc chắn muốn từ chối mục công việc {value} không?" + }, + "delete": { + "title": "Xóa mục công việc", + "content": "Bạn có chắc chắn muốn xóa mục công việc {value} không?", + "success": "Đã xóa mục công việc thành công" + } + }, + "errors": { + "snooze_permission": "Chỉ quản trị viên dự án mới có thể tạm hoãn/hủy tạm hoãn mục công việc", + "accept_permission": "Chỉ quản trị viên dự án mới có thể chấp nhận mục công việc", + "decline_permission": "Chỉ quản trị viên dự án mới có thể từ chối mục công việc" + }, + "actions": { + "accept": "Chấp nhận", + "decline": "Từ chối", + "snooze": "Tạm hoãn", + "unsnooze": "Hủy tạm hoãn", + "copy": "Sao chép liên kết mục công việc", + "delete": "Xóa", + "open": "Mở mục công việc", + "mark_as_duplicate": "Đánh dấu là trùng lặp", + "move": "Di chuyển {value} đến mục công việc dự án" + }, + "source": { + "in-app": "Trong ứng dụng" + }, + "order_by": { + "created_at": "Thời gian tạo", + "updated_at": "Thời gian cập nhật", + "id": "ID" + }, + "label": "Thu thập", + "page_label": "{workspace} - Thu thập", + "modal": { + "title": "Tạo mục công việc thu thập" + }, + "tabs": { + "open": "Chưa xử lý", + "closed": "Đã xử lý" + }, + "empty_state": { + "sidebar_open_tab": { + "title": "Không có mục công việc chưa xử lý", + "description": "Tìm mục công việc chưa xử lý tại đây. Tạo mục công việc mới." + }, + "sidebar_closed_tab": { + "title": "Không có mục công việc đã xử lý", + "description": "Tất cả mục công việc đã chấp nhận hoặc từ chối có thể được tìm thấy ở đây." + }, + "sidebar_filter": { + "title": "Không có mục công việc phù hợp", + "description": "Không có mục công việc trong thu thập phù hợp với bộ lọc của bạn. Tạo mục công việc mới." + }, + "detail": { + "title": "Chọn một mục công việc để xem chi tiết." + } + } + }, + "workspace_creation": { + "heading": "Tạo không gian làm việc của bạn", + "subheading": "Để bắt đầu với Plane, bạn cần tạo hoặc tham gia một không gian làm việc.", + "form": { + "name": { + "label": "Đặt tên cho không gian làm việc của bạn", + "placeholder": "Một tên quen thuộc và dễ nhận diện luôn là tốt nhất." + }, + "url": { + "label": "Thiết lập URL không gian làm việc của bạn", + "placeholder": "Nhập hoặc dán URL", + "edit_slug": "Bạn chỉ có thể chỉnh sửa phần định danh của URL" + }, + "organization_size": { + "label": "Có bao nhiêu người sẽ sử dụng không gian làm việc này?", + "placeholder": "Chọn một phạm vi" + } + }, + "errors": { + "creation_disabled": { + "title": "Chỉ quản trị viên hệ thống của bạn mới có thể tạo không gian làm việc", + "description": "Nếu bạn biết địa chỉ email của quản trị viên hệ thống, hãy nhấp vào nút bên dưới để liên hệ với họ.", + "request_button": "Yêu cầu quản trị viên hệ thống" + }, + "validation": { + "name_alphanumeric": "Tên không gian làm việc chỉ có thể chứa (' '), ('-'), ('_') và các ký tự chữ số.", + "name_length": "Tên giới hạn trong 80 ký tự.", + "url_alphanumeric": "URL chỉ có thể chứa ('-') và các ký tự chữ số.", + "url_length": "URL giới hạn trong 48 ký tự.", + "url_already_taken": "URL không gian làm việc đã được sử dụng!" + } + }, + "request_email": { + "subject": "Yêu cầu không gian làm việc mới", + "body": "Xin chào Quản trị viên hệ thống:\n\nVui lòng tạo một không gian làm việc mới có URL là [/workspace-name] cho [mục đích tạo không gian làm việc].\n\nCảm ơn,\n{firstName} {lastName}\n{email}" + }, + "button": { + "default": "Tạo không gian làm việc", + "loading": "Đang tạo không gian làm việc" + }, + "toast": { + "success": { + "title": "Thành công", + "message": "Đã tạo không gian làm việc thành công" + }, + "error": { + "title": "Lỗi", + "message": "Tạo không gian làm việc thất bại. Vui lòng thử lại." + } + } + }, + "workspace_dashboard": { + "empty_state": { + "general": { + "title": "Tổng quan về dự án, hoạt động và chỉ số", + "description": "Chào mừng đến với Plane, chúng tôi rất vui khi bạn ở đây. Tạo dự án đầu tiên của bạn và theo dõi mục công việc, trang này sẽ trở thành không gian giúp bạn tiến triển. Quản trị viên cũng sẽ thấy dự án giúp nhóm tiến triển.", + "primary_button": { + "text": "Xây dựng dự án đầu tiên của bạn", + "comic": { + "title": "Trong Plane, mọi thứ đều bắt đầu với dự án", + "description": "Dự án có thể là lộ trình sản phẩm, chiến dịch tiếp thị hoặc ra mắt xe mới." + } + } + } + } + }, + "workspace_analytics": { + "label": "Phân tích", + "page_label": "{workspace} - Phân tích", + "open_tasks": "Tổng nhiệm vụ đang mở", + "error": "Đã xảy ra lỗi khi truy xuất dữ liệu.", + "work_items_closed_in": "Mục công việc đã đóng trong", + "selected_projects": "Dự án đã chọn", + "total_members": "Tổng số thành viên", + "total_cycles": "Tổng số chu kỳ", + "total_modules": "Tổng số mô-đun", + "pending_work_items": { + "title": "Mục công việc đang chờ xử lý", + "empty_state": "Phân tích mục công việc đang chờ xử lý của đồng nghiệp sẽ hiển thị ở đây." + }, + "work_items_closed_in_a_year": { + "title": "Mục công việc đã đóng trong một năm", + "empty_state": "Đóng mục công việc để xem phân tích dưới dạng biểu đồ." + }, + "most_work_items_created": { + "title": "Tạo nhiều mục công việc nhất", + "empty_state": "Đồng nghiệp và số lượng mục công việc họ đã tạo sẽ hiển thị ở đây." + }, + "most_work_items_closed": { + "title": "Đóng nhiều mục công việc nhất", + "empty_state": "Đồng nghiệp và số lượng mục công việc họ đã đóng sẽ hiển thị ở đây." + }, + "tabs": { + "scope_and_demand": "Phạm vi và nhu cầu", + "custom": "Phân tích tùy chỉnh" + }, + "empty_state": { + "general": { + "title": "Theo dõi tiến độ, khối lượng công việc và phân công. Khám phá xu hướng, loại bỏ rào cản và đẩy nhanh công việc", + "description": "Xem phạm vi so với nhu cầu, ước tính và mở rộng phạm vi. Nhận hiệu suất của thành viên nhóm và nhóm, đảm bảo dự án của bạn đúng tiến độ.", + "primary_button": { + "text": "Bắt đầu dự án đầu tiên của bạn", + "comic": { + "title": "Phân tích hoạt động tốt nhất trong chu kỳ + mô-đun", + "description": "Đầu tiên, giới hạn mục công việc của bạn trong chu kỳ và nếu có thể, nhóm mục công việc kéo dài nhiều chu kỳ thành mô-đun. Xem cả hai trong thanh điều hướng bên trái." + } + } + } + } + }, + "workspace_projects": { + "label": "{count, plural, one {dự án} other {dự án}}", + "create": { + "label": "Thêm dự án" + }, + "network": { + "private": { + "title": "Riêng tư", + "description": "Chỉ truy cập bằng lời mời" + }, + "public": { + "title": "Công khai", + "description": "Bất kỳ ai trong không gian làm việc ngoại trừ khách đều có thể tham gia" + } + }, + "error": { + "permission": "Bạn không có quyền thực hiện thao tác này.", + "cycle_delete": "Xóa chu kỳ thất bại", + "module_delete": "Xóa mô-đun thất bại", + "issue_delete": "Xóa mục công việc thất bại" + }, + "state": { + "backlog": "Tồn đọng", + "unstarted": "Chưa bắt đầu", + "started": "Đang tiến hành", + "completed": "Đã hoàn thành", + "cancelled": "Đã hủy" + }, + "sort": { + "manual": "Thủ công", + "name": "Tên", + "created_at": "Ngày tạo", + "members_length": "Số lượng thành viên" + }, + "scope": { + "my_projects": "Dự án của tôi", + "archived_projects": "Đã lưu trữ" + }, + "common": { + "months_count": "{months, plural, one{# tháng} other{# tháng}}" + }, + "empty_state": { + "general": { + "title": "Không có dự án hoạt động", + "description": "Coi mỗi dự án như là cấp cha của công việc định hướng mục tiêu. Dự án là nơi chứa mục công việc, chu kỳ và mô-đun, cùng với đồng nghiệp giúp bạn đạt được mục tiêu. Tạo dự án mới hoặc lọc dự án đã lưu trữ.", + "primary_button": { + "text": "Bắt đầu dự án đầu tiên của bạn", + "comic": { + "title": "Trong Plane, mọi thứ đều bắt đầu với dự án", + "description": "Dự án có thể là lộ trình sản phẩm, chiến dịch tiếp thị hoặc ra mắt xe mới." + } + } + }, + "no_projects": { + "title": "Không có dự án", + "description": "Để tạo mục công việc hoặc quản lý công việc của bạn, bạn cần tạo dự án hoặc trở thành một phần của dự án.", + "primary_button": { + "text": "Bắt đầu dự án đầu tiên của bạn", + "comic": { + "title": "Trong Plane, mọi thứ đều bắt đầu với dự án", + "description": "Dự án có thể là lộ trình sản phẩm, chiến dịch tiếp thị hoặc ra mắt xe mới." + } + } + }, + "filter": { + "title": "Không có dự án phù hợp", + "description": "Không phát hiện dự án nào phù hợp với điều kiện tìm kiếm.\nTạo dự án mới." + }, + "search": { + "description": "Không phát hiện dự án nào phù hợp với điều kiện tìm kiếm.\nTạo dự án mới" + } + } + }, + "workspace_views": { + "add_view": "Thêm chế độ xem", + "empty_state": { + "all-issues": { + "title": "Không có mục công việc trong dự án", + "description": "Dự án đầu tiên hoàn thành! Bây giờ, hãy chia nhỏ công việc của bạn thành các mục công việc có thể theo dõi. Hãy bắt đầu nào!", + "primary_button": { + "text": "Tạo mục công việc mới" + } + }, + "assigned": { + "title": "Chưa có mục công việc", + "description": "Mục công việc được giao cho bạn có thể được theo dõi tại đây.", + "primary_button": { + "text": "Tạo mục công việc mới" + } + }, + "created": { + "title": "Chưa có mục công việc", + "description": "Tất cả mục công việc bạn tạo sẽ xuất hiện ở đây, theo dõi chúng trực tiếp tại đây.", + "primary_button": { + "text": "Tạo mục công việc mới" + } + }, + "subscribed": { + "title": "Chưa có mục công việc", + "description": "Đăng ký mục công việc bạn quan tâm, theo dõi tất cả chúng tại đây." + }, + "custom-view": { + "title": "Chưa có mục công việc", + "description": "Mục công việc phù hợp với bộ lọc, theo dõi tất cả chúng tại đây." + } + } + }, + "workspace_settings": { + "label": "Cài đặt không gian làm việc", + "page_label": "{workspace} - Cài đặt chung", + "key_created": "Đã tạo khóa", + "copy_key": "Sao chép và lưu khóa này trong Plane Pages. Bạn sẽ không thể thấy khóa này sau khi đóng. Tệp CSV chứa khóa đã được tải xuống.", + "token_copied": "Đã sao chép token vào bảng tạm.", + "settings": { + "general": { + "title": "Chung", + "upload_logo": "Tải lên logo", + "edit_logo": "Chỉnh sửa logo", + "name": "Tên không gian làm việc", + "company_size": "Quy mô công ty", + "url": "URL không gian làm việc", + "update_workspace": "Cập nhật không gian làm việc", + "delete_workspace": "Xóa không gian làm việc này", + "delete_workspace_description": "Khi xóa không gian làm việc, tất cả dữ liệu và tài nguyên trong không gian làm việc đó sẽ bị xóa vĩnh viễn và không thể khôi phục.", + "delete_btn": "Xóa không gian làm việc này", + "delete_modal": { + "title": "Bạn có chắc chắn muốn xóa không gian làm việc này không?", + "description": "Bạn hiện đang dùng thử gói trả phí của chúng tôi. Vui lòng hủy dùng thử trước khi tiếp tục.", + "dismiss": "Đóng", + "cancel": "Hủy dùng thử", + "success_title": "Đã xóa không gian làm việc.", + "success_message": "Sắp chuyển hướng đến trang hồ sơ của bạn.", + "error_title": "Thao tác thất bại.", + "error_message": "Vui lòng thử lại." + }, + "errors": { + "name": { + "required": "Tên là bắt buộc", + "max_length": "Tên không gian làm việc không nên vượt quá 80 ký tự" + }, + "company_size": { + "required": "Quy mô công ty là bắt buộc", + "select_a_range": "Chọn quy mô tổ chức" + } + } + }, + "members": { + "title": "Thành viên", + "add_member": "Thêm thành viên", + "pending_invites": "Lời mời đang chờ xử lý", + "invitations_sent_successfully": "Đã gửi lời mời thành công", + "leave_confirmation": "Bạn có chắc chắn muốn rời khỏi không gian làm việc này không? Bạn sẽ không thể truy cập không gian làm việc này nữa. Hành động này không thể hoàn tác.", + "details": { + "full_name": "Tên đầy đủ", + "display_name": "Tên hiển thị", + "email_address": "Địa chỉ email", + "account_type": "Loại tài khoản", + "authentication": "Xác thực", + "joining_date": "Ngày tham gia" + }, + "modal": { + "title": "Mời người cộng tác", + "description": "Mời người cộng tác trong không gian làm việc của bạn.", + "button": "Gửi lời mời", + "button_loading": "Đang gửi lời mời", + "placeholder": "name@company.com", + "errors": { + "required": "Chúng tôi cần một địa chỉ email để mời họ.", + "invalid": "Email không hợp lệ" + } + } + }, + "billing_and_plans": { + "title": "Thanh toán và Kế hoạch", + "current_plan": "Kế hoạch hiện tại", + "free_plan": "Bạn đang sử dụng kế hoạch miễn phí", + "view_plans": "Xem kế hoạch" + }, + "exports": { + "title": "Xuất", + "exporting": "Đang xuất", + "previous_exports": "Xuất trước đây", + "export_separate_files": "Xuất dữ liệu thành các tệp riêng biệt", + "modal": { + "title": "Xuất đến", + "toasts": { + "success": { + "title": "Xuất thành công", + "message": "Bạn có thể tải xuống {entity} đã xuất từ phần xuất trước đây" + }, + "error": { + "title": "Xuất thất bại", + "message": "Xuất không thành công. Vui lòng thử lại." + } + } + } + }, + "webhooks": { + "title": "Webhooks", + "add_webhook": "Thêm webhook", + "modal": { + "title": "Tạo webhook", + "details": "Chi tiết Webhook", + "payload": "URL tải", + "question": "Bạn muốn những sự kiện nào kích hoạt webhook này?", + "error": "URL là bắt buộc" + }, + "secret_key": { + "title": "Khóa bí mật", + "message": "Tạo token để đăng nhập tải webhook" + }, + "options": { + "all": "Gửi tất cả", + "individual": "Chọn từng sự kiện" + }, + "toasts": { + "created": { + "title": "Đã tạo Webhook", + "message": "Webhook đã được tạo thành công" + }, + "not_created": { + "title": "Chưa tạo Webhook", + "message": "Không thể tạo webhook" + }, + "updated": { + "title": "Đã cập nhật Webhook", + "message": "Webhook đã được cập nhật thành công" + }, + "not_updated": { + "title": "Chưa cập nhật Webhook", + "message": "Không thể cập nhật webhook" + }, + "removed": { + "title": "Đã xóa Webhook", + "message": "Webhook đã được xóa thành công" + }, + "not_removed": { + "title": "Chưa xóa Webhook", + "message": "Không thể xóa webhook" + }, + "secret_key_copied": { + "message": "Đã sao chép khóa bí mật vào bảng tạm." + }, + "secret_key_not_copied": { + "message": "Đã xảy ra lỗi khi sao chép khóa bí mật." + } + } + }, + "api_tokens": { + "title": "Token API", + "add_token": "Thêm token API", + "create_token": "Tạo token", + "never_expires": "Không bao giờ hết hạn", + "generate_token": "Tạo token", + "generating": "Đang tạo", + "delete": { + "title": "Xóa token API", + "description": "Bất kỳ ứng dụng nào sử dụng token này sẽ không thể truy cập dữ liệu Plane nữa. Hành động này không thể hoàn tác.", + "success": { + "title": "Thành công!", + "message": "Đã xóa token API thành công" + }, + "error": { + "title": "Lỗi!", + "message": "Không thể xóa token API" + } + } + } + }, + "empty_state": { + "api_tokens": { + "title": "Chưa tạo token API", + "description": "API Plane có thể được sử dụng để tích hợp dữ liệu Plane của bạn với bất kỳ hệ thống bên ngoài nào. Tạo token để bắt đầu." + }, + "webhooks": { + "title": "Chưa thêm webhook", + "description": "Tạo webhook để nhận cập nhật theo thời gian thực và tự động hóa hành động." + }, + "exports": { + "title": "Chưa có xuất dữ liệu", + "description": "Mỗi khi xuất, bạn sẽ có một bản sao ở đây để tham khảo." + }, + "imports": { + "title": "Chưa có nhập dữ liệu", + "description": "Tìm tất cả các lần nhập trước đây và tải xuống chúng tại đây." + } + } + }, + "profile": { + "label": "Hồ sơ", + "page_label": "Công việc của bạn", + "work": "Công việc", + "details": { + "joined_on": "Tham gia vào", + "time_zone": "Múi giờ" + }, + "stats": { + "workload": "Khối lượng công việc", + "overview": "Tổng quan", + "created": "Mục công việc đã tạo", + "assigned": "Mục công việc đã giao", + "subscribed": "Mục công việc đã đăng ký", + "state_distribution": { + "title": "Mục công việc theo trạng thái", + "empty": "Tạo mục công việc để xem phân loại theo trạng thái trong biểu đồ để phân tích tốt hơn." + }, + "priority_distribution": { + "title": "Mục công việc theo mức độ ưu tiên", + "empty": "Tạo mục công việc để xem phân loại theo mức độ ưu tiên trong biểu đồ để phân tích tốt hơn." + }, + "recent_activity": { + "title": "Hoạt động gần đây", + "empty": "Chúng tôi không tìm thấy dữ liệu. Vui lòng kiểm tra đầu vào của bạn", + "button": "Tải xuống hoạt động hôm nay", + "button_loading": "Đang tải xuống" + } + }, + "actions": { + "profile": "Hồ sơ", + "security": "Bảo mật", + "activity": "Hoạt động", + "appearance": "Giao diện", + "notifications": "Thông báo" + }, + "tabs": { + "summary": "Tóm tắt", + "assigned": "Đã giao", + "created": "Đã tạo", + "subscribed": "Đã đăng ký", + "activity": "Hoạt động" + }, + "empty_state": { + "activity": { + "title": "Chưa có hoạt động", + "description": "Bắt đầu bằng cách tạo mục công việc mới! Thêm chi tiết và thuộc tính cho nó. Khám phá thêm trong Plane để xem hoạt động của bạn." + }, + "assigned": { + "title": "Không có mục công việc nào được giao cho bạn", + "description": "Có thể theo dõi mục công việc được giao cho bạn từ đây." + }, + "created": { + "title": "Chưa có mục công việc", + "description": "Tất cả mục công việc bạn tạo sẽ xuất hiện ở đây, theo dõi chúng trực tiếp tại đây." + }, + "subscribed": { + "title": "Chưa có mục công việc", + "description": "Đăng ký mục công việc bạn quan tâm, theo dõi tất cả chúng tại đây." + } + } + }, + "project_settings": { + "general": { + "enter_project_id": "Nhập ID dự án", + "please_select_a_timezone": "Vui lòng chọn múi giờ", + "archive_project": { + "title": "Lưu trữ dự án", + "description": "Lưu trữ dự án sẽ hủy liệt kê dự án của bạn khỏi thanh điều hướng bên, nhưng bạn vẫn có thể truy cập nó từ trang dự án. Bạn có thể khôi phục hoặc xóa dự án bất cứ lúc nào.", + "button": "Lưu trữ dự án" + }, + "delete_project": { + "title": "Xóa dự án", + "description": "Khi xóa dự án, tất cả dữ liệu và tài nguyên trong dự án đó sẽ bị xóa vĩnh viễn và không thể khôi phục.", + "button": "Xóa dự án của tôi" + }, + "toast": { + "success": "Dự án đã được cập nhật thành công", + "error": "Không thể cập nhật dự án. Vui lòng thử lại." + } + }, + "members": { + "label": "Thành viên", + "project_lead": "Người phụ trách dự án", + "default_assignee": "Người nhận mặc định", + "guest_super_permissions": { + "title": "Cấp quyền cho người dùng khách xem tất cả mục công việc:", + "sub_heading": "Điều này sẽ cho phép khách xem tất cả mục công việc của dự án." + }, + "invite_members": { + "title": "Mời thành viên", + "sub_heading": "Mời thành viên tham gia dự án của bạn.", + "select_co_worker": "Chọn đồng nghiệp" + } + }, + "states": { + "describe_this_state_for_your_members": "Mô tả trạng thái này cho thành viên của bạn.", + "empty_state": { + "title": "Không có trạng thái trong nhóm {groupKey}", + "description": "Vui lòng tạo một trạng thái mới" + } + }, + "labels": { + "label_title": "Tiêu đề nhãn", + "label_title_is_required": "Tiêu đề nhãn là bắt buộc", + "label_max_char": "Tên nhãn không nên vượt quá 255 ký tự", + "toast": { + "error": "Đã xảy ra lỗi khi cập nhật nhãn" + } + }, + "estimates": { + "label": "Ước tính", + "title": "Bật ước tính cho dự án của tôi", + "description": "Chúng giúp bạn truyền đạt độ phức tạp và khối lượng công việc của nhóm.", + "no_estimate": "Không có ước tính", + "new": "Hệ thống ước tính mới", + "create": { + "custom": "Tùy chỉnh", + "start_from_scratch": "Bắt đầu từ đầu", + "choose_template": "Chọn mẫu", + "choose_estimate_system": "Chọn hệ thống ước tính", + "enter_estimate_point": "Nhập điểm ước tính", + "step": "Bước {step} của {total}", + "label": "Tạo ước tính" + }, + "toasts": { + "created": { + "success": { + "title": "Đã tạo điểm ước tính", + "message": "Điểm ước tính đã được tạo thành công" + }, + "error": { + "title": "Không thể tạo điểm ước tính", + "message": "Không thể tạo điểm ước tính mới, vui lòng thử lại" + } + }, + "updated": { + "success": { + "title": "Đã cập nhật ước tính", + "message": "Điểm ước tính đã được cập nhật trong dự án của bạn" + }, + "error": { + "title": "Không thể cập nhật ước tính", + "message": "Không thể cập nhật ước tính, vui lòng thử lại" + } + }, + "enabled": { + "success": { + "title": "Thành công!", + "message": "Đã bật ước tính" + } + }, + "disabled": { + "success": { + "title": "Thành công!", + "message": "Đã tắt ước tính" + }, + "error": { + "title": "Lỗi!", + "message": "Không thể tắt ước tính. Vui lòng thử lại" + } + } + }, + "validation": { + "min_length": "Điểm ước tính phải lớn hơn 0", + "unable_to_process": "Không thể xử lý yêu cầu của bạn, vui lòng thử lại", + "numeric": "Điểm ước tính phải là số", + "character": "Điểm ước tính phải là ký tự", + "empty": "Giá trị ước tính không được để trống", + "already_exists": "Giá trị ước tính này đã tồn tại", + "unsaved_changes": "Bạn có thay đổi chưa lưu. Vui lòng lưu trước khi nhấn 'xong'" + }, + "systems": { + "points": { + "label": "Điểm", + "fibonacci": "Fibonacci", + "linear": "Tuyến tính", + "squares": "Bình phương", + "custom": "Tùy chỉnh" + }, + "categories": { + "label": "Danh mục", + "t_shirt_sizes": "Kích cỡ áo", + "easy_to_hard": "Dễ đến khó", + "custom": "Tùy chỉnh" + }, + "time": { + "label": "Thời gian", + "hours": "Giờ" + } + } + }, + "automations": { + "label": "Tự động hóa", + "auto-archive": { + "title": "Tự động lưu trữ mục công việc đã đóng", + "description": "Plane sẽ tự động lưu trữ các mục công việc đã hoàn thành hoặc đã hủy.", + "duration": "Tự động lưu trữ đã đóng" + }, + "auto-close": { + "title": "Tự động đóng mục công việc", + "description": "Plane sẽ tự động đóng các mục công việc chưa hoàn thành hoặc hủy.", + "duration": "Tự động đóng không hoạt động", + "auto_close_status": "Trạng thái tự động đóng" + } + }, + "empty_state": { + "labels": { + "title": "Chưa có nhãn", + "description": "Tạo nhãn để giúp tổ chức và lọc mục công việc trong dự án của bạn." + }, + "estimates": { + "title": "Chưa có hệ thống ước tính", + "description": "Tạo một tập hợp ước tính để truyền đạt khối lượng công việc cho mỗi mục công việc.", + "primary_button": "Thêm hệ thống ước tính" + } + } + }, + "project_cycles": { + "add_cycle": "Thêm chu kỳ", + "more_details": "Thêm chi tiết", + "cycle": "Chu kỳ", + "update_cycle": "Cập nhật chu kỳ", + "create_cycle": "Tạo chu kỳ", + "no_matching_cycles": "Không có chu kỳ phù hợp", + "remove_filters_to_see_all_cycles": "Xóa bộ lọc để xem tất cả chu kỳ", + "remove_search_criteria_to_see_all_cycles": "Xóa tiêu chí tìm kiếm để xem tất cả chu kỳ", + "only_completed_cycles_can_be_archived": "Chỉ có thể lưu trữ chu kỳ đã hoàn thành", + "start_date": "Ngày bắt đầu", + "end_date": "Ngày kết thúc", + "in_your_timezone": "Trong múi giờ của bạn", + "transfer_work_items": "Chuyển {count} mục công việc", + "date_range": "Khoảng thời gian", + "add_date": "Thêm ngày", + "active_cycle": { + "label": "Chu kỳ hoạt động", + "progress": "Tiến độ", + "chart": "Biểu đồ burndown", + "priority_issue": "Mục công việc ưu tiên", + "assignees": "Người được giao", + "issue_burndown": "Burndown mục công việc", + "ideal": "Lý tưởng", + "current": "Hiện tại", + "labels": "Nhãn" + }, + "upcoming_cycle": { + "label": "Chu kỳ sắp tới" + }, + "completed_cycle": { + "label": "Chu kỳ đã hoàn thành" + }, + "status": { + "days_left": "Số ngày còn lại", + "completed": "Đã hoàn thành", + "yet_to_start": "Chưa bắt đầu", + "in_progress": "Đang tiến hành", + "draft": "Bản nháp" + }, + "action": { + "restore": { + "title": "Khôi phục chu kỳ", + "success": { + "title": "Đã khôi phục chu kỳ", + "description": "Chu kỳ đã được khôi phục." + }, + "failed": { + "title": "Khôi phục chu kỳ thất bại", + "description": "Không thể khôi phục chu kỳ. Vui lòng thử lại." + } + }, + "favorite": { + "loading": "Đang thêm chu kỳ vào mục yêu thích", + "success": { + "description": "Chu kỳ đã được thêm vào mục yêu thích.", + "title": "Thành công!" + }, + "failed": { + "description": "Không thể thêm chu kỳ vào mục yêu thích. Vui lòng thử lại.", + "title": "Lỗi!" + } + }, + "unfavorite": { + "loading": "Đang xóa chu kỳ khỏi mục yêu thích", + "success": { + "description": "Chu kỳ đã được xóa khỏi mục yêu thích.", + "title": "Thành công!" + }, + "failed": { + "description": "Không thể xóa chu kỳ khỏi mục yêu thích. Vui lòng thử lại.", + "title": "Lỗi!" + } + }, + "update": { + "loading": "Đang cập nhật chu kỳ", + "success": { + "description": "Chu kỳ đã được cập nhật thành công.", + "title": "Thành công!" + }, + "failed": { + "description": "Đã xảy ra lỗi khi cập nhật chu kỳ. Vui lòng thử lại.", + "title": "Lỗi!" + }, + "error": { + "already_exists": "Đã tồn tại chu kỳ trong khoảng thời gian đã cho, nếu bạn muốn tạo chu kỳ nháp, bạn có thể làm vậy bằng cách xóa cả hai ngày." + } + } + }, + "empty_state": { + "general": { + "title": "Nhóm và đặt khung thời gian cho công việc của bạn trong chu kỳ.", + "description": "Chia nhỏ công việc theo khung thời gian, đặt ngày từ thời hạn dự án và đạt được tiến độ cụ thể với tư cách là một nhóm.", + "primary_button": { + "text": "Thiết lập chu kỳ đầu tiên của bạn", + "comic": { + "title": "Chu kỳ là khung thời gian lặp lại.", + "description": "Sprint, iteration hoặc bất kỳ thuật ngữ nào khác bạn sử dụng để theo dõi công việc hàng tuần hoặc hai tuần một lần đều là một chu kỳ." + } + } + }, + "no_issues": { + "title": "Chưa thêm mục công việc vào chu kỳ", + "description": "Thêm hoặc tạo mục công việc bạn muốn đặt khung thời gian và giao trong chu kỳ này", + "primary_button": { + "text": "Tạo mục công việc mới" + }, + "secondary_button": { + "text": "Thêm mục công việc hiện có" + } + }, + "completed_no_issues": { + "title": "Không có mục công việc trong chu kỳ", + "description": "Không có mục công việc trong chu kỳ. Mục công việc đã được chuyển hoặc ẩn. Để xem mục công việc đã ẩn (nếu có), vui lòng cập nhật thuộc tính hiển thị của bạn tương ứng." + }, + "active": { + "title": "Không có chu kỳ hoạt động", + "description": "Chu kỳ hoạt động bao gồm bất kỳ khoảng thời gian nào có ngày hôm nay trong phạm vi của nó. Tìm tiến độ và chi tiết về chu kỳ hoạt động ở đây." + }, + "archived": { + "title": "Chưa có chu kỳ đã lưu trữ", + "description": "Để tổ chức dự án của bạn, hãy lưu trữ chu kỳ đã hoàn thành. Bạn có thể tìm thấy chúng ở đây sau khi lưu trữ." + } + } + }, + "project_issues": { + "empty_state": { + "no_issues": { + "title": "Tạo mục công việc và giao nó cho ai đó, thậm chí là chính bạn", + "description": "Xem mục công việc như công việc, nhiệm vụ hoặc công việc cần hoàn thành. Mục công việc và các mục công việc con của chúng thường dựa trên thời gian, được giao cho thành viên nhóm để thực hiện. Nhóm của bạn thúc đẩy dự án đạt được mục tiêu bằng cách tạo, giao và hoàn thành mục công việc.", + "primary_button": { + "text": "Tạo mục công việc đầu tiên của bạn", + "comic": { + "title": "Mục công việc là khối xây dựng cơ bản trong Plane.", + "description": "Thiết kế lại giao diện Plane, định vị lại thương hiệu công ty hoặc ra mắt hệ thống phun nhiên liệu mới đều là ví dụ về mục công việc có thể chứa các mục công việc con." + } + } + }, + "no_archived_issues": { + "title": "Chưa có mục công việc đã lưu trữ", + "description": "Thông qua phương thức thủ công hoặc tự động, bạn có thể lưu trữ mục công việc đã hoàn thành hoặc đã hủy. Bạn có thể tìm thấy chúng ở đây sau khi lưu trữ.", + "primary_button": { + "text": "Thiết lập tự động hóa" + } + }, + "issues_empty_filter": { + "title": "Không tìm thấy mục công việc phù hợp với bộ lọc", + "secondary_button": { + "text": "Xóa tất cả bộ lọc" + } + } + } + }, + "project_module": { + "add_module": "Thêm mô-đun", + "update_module": "Cập nhật mô-đun", + "create_module": "Tạo mô-đun", + "archive_module": "Lưu trữ mô-đun", + "restore_module": "Khôi phục mô-đun", + "delete_module": "Xóa mô-đun", + "empty_state": { + "general": { + "title": "Ánh xạ cột mốc dự án vào mô-đun, dễ dàng theo dõi công việc tổng hợp.", + "description": "Một nhóm mục công việc thuộc cấp cha trong cấu trúc logic tạo thành một mô-đun. Xem nó như một cách theo dõi công việc theo cột mốc dự án. Chúng có chu kỳ riêng và thời hạn cùng với các tính năng phân tích giúp bạn hiểu bạn đang ở đâu so với cột mốc.", + "primary_button": { + "text": "Xây dựng mô-đun đầu tiên của bạn", + "comic": { + "title": "Mô-đun giúp nhóm công việc theo cấu trúc phân cấp.", + "description": "Mô-đun giỏ hàng, mô-đun khung gầm và mô-đun kho đều là ví dụ tốt về nhóm như vậy." + } + } + }, + "no_issues": { + "title": "Không có mục công việc trong mô-đun", + "description": "Tạo hoặc thêm mục công việc bạn muốn hoàn thành như một phần của mô-đun này", + "primary_button": { + "text": "Tạo mục công việc mới" + }, + "secondary_button": { + "text": "Thêm mục công việc hiện có" + } + }, + "archived": { + "title": "Chưa có mô-đun đã lưu trữ", + "description": "Để tổ chức dự án của bạn, hãy lưu trữ mô-đun đã hoàn thành hoặc đã hủy. Bạn có thể tìm thấy chúng ở đây sau khi lưu trữ." + }, + "sidebar": { + "in_active": "Mô-đun này chưa được kích hoạt.", + "invalid_date": "Ngày không hợp lệ. Vui lòng nhập ngày hợp lệ." + } + }, + "quick_actions": { + "archive_module": "Lưu trữ mô-đun", + "archive_module_description": "Chỉ mô-đun đã hoàn thành hoặc đã hủy\ncó thể được lưu trữ.", + "delete_module": "Xóa mô-đun" + }, + "toast": { + "copy": { + "success": "Đã sao chép liên kết mô-đun vào bảng tạm" + }, + "delete": { + "success": "Đã xóa mô-đun thành công", + "error": "Xóa mô-đun thất bại" + } + } + }, + "project_views": { + "empty_state": { + "general": { + "title": "Lưu chế độ xem đã lọc cho dự án của bạn. Tạo bao nhiêu tùy ý", + "description": "Chế độ xem là bộ bộ lọc đã lưu mà bạn thường xuyên sử dụng hoặc muốn truy cập dễ dàng. Tất cả đồng nghiệp trong dự án có thể thấy chế độ xem của mọi người và chọn cái phù hợp nhất với nhu cầu của họ.", + "primary_button": { + "text": "Tạo chế độ xem đầu tiên của bạn", + "comic": { + "title": "Chế độ xem hoạt động dựa trên thuộc tính mục công việc.", + "description": "Bạn có thể tạo chế độ xem ở đây sử dụng bất kỳ số lượng thuộc tính nào làm bộ lọc theo nhu cầu của bạn." + } + } + }, + "filter": { + "title": "Không có chế độ xem phù hợp", + "description": "Không có chế độ xem phù hợp với tiêu chí tìm kiếm.\nTạo chế độ xem mới." + } + } + }, + "project_page": { + "empty_state": { + "general": { + "title": "Viết ghi chú, tài liệu hoặc cơ sở kiến thức đầy đủ. Để trợ lý AI Galileo của Plane giúp bạn bắt đầu", + "description": "Trang là không gian ghi lại suy nghĩ trong Plane. Ghi lại các ghi chú cuộc họp, định dạng dễ dàng, nhúng mục công việc, sử dụng thư viện thành phần để bố cục và lưu tất cả trong ngữ cảnh dự án. Để hoàn thành nhanh bất kỳ tài liệu nào, bạn có thể gọi AI Galileo của Plane thông qua phím tắt hoặc nhấp nút.", + "primary_button": { + "text": "Tạo trang đầu tiên của bạn" + } + }, + "private": { + "title": "Chưa có trang riêng tư", + "description": "Lưu ý riêng tư của bạn ở đây. Khi sẵn sàng chia sẻ, nhóm của bạn chỉ cách một cú nhấp chuột.", + "primary_button": { + "text": "Tạo trang đầu tiên của bạn" + } + }, + "public": { + "title": "Chưa có trang công khai", + "description": "Xem các trang được chia sẻ với mọi người trong dự án tại đây.", + "primary_button": { + "text": "Tạo trang đầu tiên của bạn" + } + }, + "archived": { + "title": "Chưa có trang đã lưu trữ", + "description": "Lưu trữ các trang không còn trong tầm nhìn của bạn. Truy cập chúng ở đây khi cần." + } + } + }, + "command_k": { + "empty_state": { + "search": { + "title": "Không tìm thấy kết quả" + } + } + }, + "issue_relation": { + "empty_state": { + "search": { + "title": "Không tìm thấy mục công việc phù hợp" + }, + "no_issues": { + "title": "Không tìm thấy mục công việc" + } + } + }, + "issue_comment": { + "empty_state": { + "general": { + "title": "Chưa có bình luận", + "description": "Bình luận có thể được sử dụng như không gian thảo luận và theo dõi cho mục công việc" + } + } + }, + "notification": { + "label": "Hộp thư đến", + "page_label": "{workspace} - Hộp thư đến", + "options": { + "mark_all_as_read": "Đánh dấu tất cả là đã đọc", + "mark_read": "Đánh dấu đã đọc", + "mark_unread": "Đánh dấu chưa đọc", + "refresh": "Làm mới", + "filters": "Bộ lọc hộp thư đến", + "show_unread": "Hiển thị chưa đọc", + "show_snoozed": "Hiển thị đã tạm hoãn", + "show_archived": "Hiển thị đã lưu trữ", + "mark_archive": "Lưu trữ", + "mark_unarchive": "Hủy lưu trữ", + "mark_snooze": "Tạm hoãn", + "mark_unsnooze": "Hủy tạm hoãn" + }, + "toasts": { + "read": "Thông báo đã được đánh dấu là đã đọc", + "unread": "Thông báo đã được đánh dấu là chưa đọc", + "archived": "Thông báo đã được đánh dấu là đã lưu trữ", + "unarchived": "Thông báo đã được đánh dấu là đã hủy lưu trữ", + "snoozed": "Thông báo đã được tạm hoãn", + "unsnoozed": "Thông báo đã được hủy tạm hoãn" + }, + "empty_state": { + "detail": { + "title": "Chọn để xem chi tiết." + }, + "all": { + "title": "Không có mục công việc được giao", + "description": "Xem cập nhật về mục công việc được giao cho bạn tại đây" + }, + "mentions": { + "title": "Không có mục công việc được giao", + "description": "Xem cập nhật về mục công việc được giao cho bạn tại đây" + } + }, + "tabs": { + "all": "Tất cả", + "mentions": "Đề cập" + }, + "filter": { + "assigned": "Được giao cho tôi", + "created": "Được tạo bởi tôi", + "subscribed": "Được đăng ký bởi tôi" + }, + "snooze": { + "1_day": "1 ngày", + "3_days": "3 ngày", + "5_days": "5 ngày", + "1_week": "1 tuần", + "2_weeks": "2 tuần", + "custom": "Tùy chỉnh" + } + }, + "active_cycle": { + "empty_state": { + "progress": { + "title": "Thêm mục công việc vào chu kỳ để xem tiến độ của nó" + }, + "chart": { + "title": "Thêm mục công việc vào chu kỳ để xem biểu đồ burndown." + }, + "priority_issue": { + "title": "Xem nhanh các mục công việc ưu tiên cao đang được xử lý trong chu kỳ." + }, + "assignee": { + "title": "Thêm người phụ trách cho mục công việc để xem phân tích công việc theo người phụ trách." + }, + "label": { + "title": "Thêm nhãn cho mục công việc để xem phân tích công việc theo nhãn." + } + } + }, + "disabled_project": { + "empty_state": { + "inbox": { + "title": "Dự án chưa bật tính năng thu thập.", + "description": "Tính năng thu thập giúp bạn quản lý các yêu cầu đến của dự án và thêm chúng như mục công việc trong quy trình làm việc. Bật tính năng thu thập từ cài đặt dự án để quản lý yêu cầu.", + "primary_button": { + "text": "Quản lý tính năng" + } + }, + "cycle": { + "title": "Dự án này chưa bật tính năng chu kỳ.", + "description": "Chia nhỏ công việc theo khung thời gian, đặt ngày từ thời hạn dự án, và đạt được tiến độ cụ thể với tư cách là một nhóm. Bật tính năng chu kỳ cho dự án của bạn để bắt đầu sử dụng.", + "primary_button": { + "text": "Quản lý tính năng" + } + }, + "module": { + "title": "Dự án chưa bật tính năng mô-đun.", + "description": "Mô-đun là khối xây dựng cơ bản của dự án. Bật mô-đun từ cài đặt dự án để bắt đầu sử dụng chúng.", + "primary_button": { + "text": "Quản lý tính năng" + } + }, + "page": { + "title": "Dự án chưa bật tính năng trang.", + "description": "Trang là khối xây dựng cơ bản của dự án. Bật trang từ cài đặt dự án để bắt đầu sử dụng chúng.", + "primary_button": { + "text": "Quản lý tính năng" + } + }, + "view": { + "title": "Dự án chưa bật tính năng chế độ xem.", + "description": "Chế độ xem là khối xây dựng cơ bản của dự án. Bật chế độ xem từ cài đặt dự án để bắt đầu sử dụng chúng.", + "primary_button": { + "text": "Quản lý tính năng" + } + } + } + }, + "workspace_draft_issues": { + "draft_an_issue": "Nháp một mục công việc", + "empty_state": { + "title": "Mục công việc viết dở và bình luận sắp ra mắt sẽ hiển thị ở đây.", + "description": "Để thử tính năng này, hãy bắt đầu thêm mục công việc và rời đi giữa chừng, hoặc tạo bản nháp đầu tiên của bạn bên dưới. 😉", + "primary_button": { + "text": "Tạo bản nháp đầu tiên của bạn" + } + }, + "delete_modal": { + "title": "Xóa bản nháp", + "description": "Bạn có chắc chắn muốn xóa bản nháp này không? Hành động này không thể hoàn tác." + }, + "toasts": { + "created": { + "success": "Đã tạo bản nháp", + "error": "Không thể tạo mục công việc. Vui lòng thử lại." + }, + "deleted": { + "success": "Đã xóa bản nháp" + } + } + }, + "stickies": { + "title": "Ghi chú của bạn", + "placeholder": "Nhấp vào đây để nhập", + "all": "Tất cả ghi chú", + "no-data": "Ghi lại một ý tưởng, nắm bắt một cảm hứng, hoặc ghi lại một suy nghĩ chợt nảy ra. Thêm ghi chú để bắt đầu.", + "add": "Thêm ghi chú", + "search_placeholder": "Tìm kiếm theo tiêu đề", + "delete": "Xóa ghi chú", + "delete_confirmation": "Bạn có chắc chắn muốn xóa ghi chú này không?", + "empty_state": { + "simple": "Ghi lại một ý tưởng, nắm bắt một cảm hứng, hoặc ghi lại một suy nghĩ chợt nảy ra. Thêm ghi chú để bắt đầu.", + "general": { + "title": "Ghi chú là ghi chú nhanh và việc cần làm mà bạn ghi lại ngay lập tức.", + "description": "Dễ dàng nắm bắt ý tưởng và sáng tạo của bạn bằng cách tạo ghi chú có thể truy cập từ mọi nơi, mọi lúc.", + "primary_button": { + "text": "Thêm ghi chú" + } + }, + "search": { + "title": "Điều này không khớp với bất kỳ ghi chú nào của bạn.", + "description": "Thử sử dụng các thuật ngữ khác, hoặc nếu bạn chắc chắn\ntìm kiếm là chính xác, hãy cho chúng tôi biết.", + "primary_button": { + "text": "Thêm ghi chú" + } + } + }, + "toasts": { + "errors": { + "wrong_name": "Tên ghi chú không thể vượt quá 100 ký tự.", + "already_exists": "Đã tồn tại một ghi chú không có mô tả" + }, + "created": { + "title": "Đã tạo ghi chú", + "message": "Ghi chú đã được tạo thành công" + }, + "not_created": { + "title": "Chưa tạo ghi chú", + "message": "Không thể tạo ghi chú" + }, + "updated": { + "title": "Đã cập nhật ghi chú", + "message": "Ghi chú đã được cập nhật thành công" + }, + "not_updated": { + "title": "Chưa cập nhật ghi chú", + "message": "Không thể cập nhật ghi chú" + }, + "removed": { + "title": "Đã xóa ghi chú", + "message": "Ghi chú đã được xóa thành công" + }, + "not_removed": { + "title": "Chưa xóa ghi chú", + "message": "Không thể xóa ghi chú" + } + } + }, + "role_details": { + "guest": { + "title": "Khách", + "description": "Thành viên bên ngoài của tổ chức có thể được mời với tư cách khách." + }, + "member": { + "title": "Thành viên", + "description": "Có thể đọc, viết, chỉnh sửa và xóa thực thể trong dự án, chu kỳ và mô-đun" + }, + "admin": { + "title": "Quản trị viên", + "description": "Tất cả quyền trong không gian làm việc đều được đặt là cho phép." + } + }, + "user_roles": { + "product_or_project_manager": "Quản lý sản phẩm/dự án", + "development_or_engineering": "Phát triển/Kỹ thuật", + "founder_or_executive": "Nhà sáng lập/Giám đốc điều hành", + "freelancer_or_consultant": "Freelancer/Tư vấn viên", + "marketing_or_growth": "Marketing/Tăng trưởng", + "sales_or_business_development": "Bán hàng/Phát triển kinh doanh", + "support_or_operations": "Hỗ trợ/Vận hành", + "student_or_professor": "Sinh viên/Giáo sư", + "human_resources": "Nhân sự", + "other": "Khác" + }, + "importer": { + "github": { + "title": "GitHub", + "description": "Nhập và đồng bộ mục công việc từ kho lưu trữ GitHub." + }, + "jira": { + "title": "Jira", + "description": "Nhập mục công việc và sử thi từ dự án và sử thi Jira." + } + }, + "exporter": { + "csv": { + "title": "CSV", + "description": "Xuất mục công việc thành tệp CSV.", + "short_description": "Xuất sang CSV" + }, + "excel": { + "title": "Excel", + "description": "Xuất mục công việc thành tệp Excel.", + "short_description": "Xuất sang Excel" + }, + "xlsx": { + "title": "Excel", + "description": "Xuất mục công việc thành tệp Excel.", + "short_description": "Xuất sang Excel" + }, + "json": { + "title": "JSON", + "description": "Xuất mục công việc thành tệp JSON.", + "short_description": "Xuất sang JSON" + } + }, + "default_global_view": { + "all_issues": "Tất cả mục công việc", + "assigned": "Đã giao", + "created": "Đã tạo", + "subscribed": "Đã đăng ký" + }, + "themes": { + "theme_options": { + "system_preference": { + "label": "Tùy chọn hệ thống" + }, + "light": { + "label": "Sáng" + }, + "dark": { + "label": "Tối" + }, + "light_contrast": { + "label": "Sáng tương phản cao" + }, + "dark_contrast": { + "label": "Tối tương phản cao" + }, + "custom": { + "label": "Chủ đề tùy chỉnh" + } + } + }, + "project_modules": { + "status": { + "backlog": "Tồn đọng", + "planned": "Đã lên kế hoạch", + "in_progress": "Đang tiến hành", + "paused": "Đã tạm dừng", + "completed": "Đã hoàn thành", + "cancelled": "Đã hủy" + }, + "layout": { + "list": "Bố cục danh sách", + "board": "Bố cục bảng", + "timeline": "Bố cục dòng thời gian" + }, + "order_by": { + "name": "Tên", + "progress": "Tiến độ", + "issues": "Số lượng mục công việc", + "due_date": "Ngày hết hạn", + "created_at": "Ngày tạo", + "manual": "Thủ công" + } + }, + + "cycle": { + "label": "{count, plural, one {chu kỳ} other {chu kỳ}}", + "no_cycle": "Không có chu kỳ" + }, + + "module": { + "label": "{count, plural, one {mô-đun} other {mô-đun}}", + "no_module": "Không có mô-đun" + }, + + "description_versions": { + "last_edited_by": "Chỉnh sửa lần cuối bởi", + "previously_edited_by": "Trước đây được chỉnh sửa bởi", + "edited_by": "Được chỉnh sửa bởi" + } +} diff --git a/packages/i18n/src/locales/zh-CN/translations.json b/packages/i18n/src/locales/zh-CN/translations.json index 5e2725127..633a18c94 100644 --- a/packages/i18n/src/locales/zh-CN/translations.json +++ b/packages/i18n/src/locales/zh-CN/translations.json @@ -350,7 +350,7 @@ "couldnt_remove_the_project_from_favorites": "无法从收藏中移除项目。请重试。", "add_to_favorites": "添加到收藏", "remove_from_favorites": "从收藏中移除", - "publish_settings": "发布设置", + "publish_project": "发布项目", "publish": "发布", "copy_link": "复制链接", "leave_project": "离开项目", @@ -501,6 +501,9 @@ "re_generate_key": "重新生成密钥", "export": "导出", "member": "{count, plural, other{# 成员}}", + "new_password_must_be_different_from_old_password": "新密码必须不同于旧密码", + "edited": "已编辑", + "bot": "机器人", "project_view": { "sort_by": { @@ -591,7 +594,7 @@ "default": "您还没有任何最近项目。" }, "filters": { - "all": "所有项目", + "all": "所有", "projects": "项目", "pages": "页面", "issues": "工作项" @@ -867,7 +870,8 @@ "deleting": "删除中", "pending": "待处理", "invite": "邀请", - "view": "查看" + "view": "查看", + "deactivated_user": "已停用用户" }, "chart": { @@ -1733,8 +1737,68 @@ } }, "estimates": { + "label": "估算", "title": "为我的项目启用估算", - "description": "它们有助于您传达团队的复杂性和工作量。" + "description": "它们有助于您传达团队的复杂性和工作量。", + "no_estimate": "无估算", + "new": "新估算系统", + "create": { + "custom": "自定义", + "start_from_scratch": "从头开始", + "choose_template": "选择模板", + "choose_estimate_system": "选择估算系统", + "enter_estimate_point": "输入估算点数", + "step": "步骤 {step} 共 {total}", + "label": "创建估算" + }, + "toasts": { + "created": { + "success": { + "title": "已创建估算点数", + "message": "估算点数创建成功" + }, + "error": { + "title": "无法创建估算点数", + "message": "无法创建新的估算点数,请重试" + } + }, + "updated": { + "success": { + "title": "已更新估算", + "message": "您项目中的估算点数已更新" + }, + "error": { + "title": "无法更新估算", + "message": "无法更新估算,请重试" + } + }, + "enabled": { + "success": { + "title": "成功!", + "message": "已启用估算" + } + }, + "disabled": { + "success": { + "title": "成功!", + "message": "已禁用估算" + }, + "error": { + "title": "错误!", + "message": "无法禁用估算。请重试" + } + } + }, + "validation": { + "min_length": "估算需要大于0。", + "unable_to_process": "我们无法处理您的请求,请重试。", + "numeric": "估算需要是数值。", + "character": "估算需要是字符值。", + "empty": "估算值不能为空。", + "already_exists": "估算值已存在。", + "unsaved_changes": "您有未保存的更改,请在点击完成前保存。", + "remove_empty": "估算不能为空。请在每个字段中输入值或删除没有值的字段。" + } }, "automations": { "label": "自动化", @@ -1774,6 +1838,12 @@ "remove_filters_to_see_all_cycles": "移除筛选器以查看所有周期", "remove_search_criteria_to_see_all_cycles": "移除搜索条件以查看所有周期", "only_completed_cycles_can_be_archived": "只能归档已完成的周期", + "start_date": "开始日期", + "end_date": "结束日期", + "in_your_timezone": "在您的时区", + "transfer_work_items": "转移 {count} 工作项", + "date_range": "日期范围", + "add_date": "添加日期", "active_cycle": { "label": "活动周期", "progress": "进度", @@ -2365,5 +2435,11 @@ "module": { "label": "{count, plural, one {模块} other {模块}}", "no_module": "无模块" + }, + + "description_versions": { + "last_edited_by": "最后编辑者", + "previously_edited_by": "之前编辑者", + "edited_by": "编辑者" } } diff --git a/packages/i18n/src/locales/zh-TW/translations.json b/packages/i18n/src/locales/zh-TW/translations.json index a59d78e4d..f5de553bd 100644 --- a/packages/i18n/src/locales/zh-TW/translations.json +++ b/packages/i18n/src/locales/zh-TW/translations.json @@ -350,7 +350,7 @@ "couldnt_remove_the_project_from_favorites": "無法從我的最愛移除專案。請再試一次。", "add_to_favorites": "加入我的最愛", "remove_from_favorites": "從我的最愛移除", - "publish_settings": "發布設定", + "publish_project": "發佈專案", "publish": "發布", "copy_link": "複製連結", "leave_project": "離開專案", @@ -501,6 +501,9 @@ "re_generate_key": "重新產生金鑰", "export": "匯出", "member": "{count, plural, one{# 位成員} other{# 位成員}}", + "new_password_must_be_different_from_old_password": "新密碼必須與舊密碼不同", + "edited": "已編輯", + "bot": "機器人", "project_view": { "sort_by": { @@ -591,7 +594,7 @@ "default": "您還沒有任何最近項目。" }, "filters": { - "all": "所有項目", + "all": "所有", "projects": "專案", "pages": "頁面", "issues": "工作事項" @@ -868,7 +871,8 @@ "deleting": "刪除中", "pending": "待處理", "invite": "邀請", - "view": "檢視" + "view": "檢視", + "deactivated_user": "已停用用戶" }, "chart": { @@ -1735,20 +1739,99 @@ } }, "estimates": { - "title": "為我的專案啟用評估", - "description": "它們可以協助您傳達團隊的複雜度和工作量。" + "label": "預估", + "title": "為我的專案啟用預估", + "description": "幫助你傳達團隊的複雜性和工作負荷。", + "no_estimate": "無預估", + "new": "新估算系統", + "create": { + "custom": "自訂", + "start_from_scratch": "從頭開始", + "choose_template": "選擇範本", + "choose_estimate_system": "選擇預估系統", + "enter_estimate_point": "輸入預估", + "step": "步驟 {step} 共 {total}", + "label": "建立預估" + }, + "toasts": { + "created": { + "success": { + "title": "預估已建立", + "message": "預估已成功建立" + }, + "error": { + "title": "預估建立失敗", + "message": "我們無法建立新的預估,請重試。" + } + }, + "updated": { + "success": { + "title": "預估已修改", + "message": "專案中的預估已更新。" + }, + "error": { + "title": "預估修改失敗", + "message": "我們無法修改預估,請重試" + } + }, + "enabled": { + "success": { + "title": "成功!", + "message": "預估已啟用。" + } + }, + "disabled": { + "success": { + "title": "成功!", + "message": "預估已停用。" + }, + "error": { + "title": "錯誤!", + "message": "無法停用預估。請重試" + } + } + }, + "validation": { + "min_length": "預估必須大於0。", + "unable_to_process": "我們無法處理你的請求,請重試。", + "numeric": "預估必須是數值。", + "character": "預估必須是字元值。", + "empty": "預估值不能為空。", + "already_exists": "預估值已存在。", + "unsaved_changes": "你有未儲存的變更。請在點擊完成前儲存", + "remove_empty": "預估不能為空。在每個欄位中輸入值或移除沒有值的欄位。" + }, + "systems": { + "points": { + "label": "點數", + "fibonacci": "費波那契數列", + "linear": "線性", + "squares": "平方數", + "custom": "自訂" + }, + "categories": { + "label": "類別", + "t_shirt_sizes": "T恤尺寸", + "easy_to_hard": "簡單到困難", + "custom": "自訂" + }, + "time": { + "label": "時間", + "hours": "小時" + } + } }, "automations": { "label": "自動化", "auto-archive": { - "title": "自動封存已關閉的工作事項", - "description": "Plane 將自動封存已完成或取消的工作事項。", - "duration": "自動封存已關閉" + "title": "自動封存已關閉的工作項目", + "description": "Plane將自動封存已完成或已取消的工作項目。", + "duration": "自動封存已關閉的工作項目" }, "auto-close": { - "title": "自動關閉工作事項", - "description": "Plane 將自動關閉尚未完成或取消的工作事項。", - "duration": "自動關閉閒置", + "title": "自動關閉工作項目", + "description": "Plane將自動關閉未完成或未取消的工作項目。", + "duration": "自動關閉非活動工作項目", "auto_close_status": "自動關閉狀態" } }, @@ -1776,6 +1859,12 @@ "remove_filters_to_see_all_cycles": "移除篩選器以檢視所有週期", "remove_search_criteria_to_see_all_cycles": "移除搜尋條件以檢視所有週期", "only_completed_cycles_can_be_archived": "只有已完成的週期可以封存", + "start_date": "開始日期", + "end_date": "結束日期", + "in_your_timezone": "在您的時區", + "transfer_work_items": "轉移 {count} 工作事項", + "date_range": "日期範圍", + "add_date": "新增日期", "active_cycle": { "label": "使用中的週期", "progress": "進度", @@ -2367,5 +2456,11 @@ "module": { "label": "{count, plural, one {模組} other {模組}}", "no_module": "無模組" + }, + + "description_versions": { + "last_edited_by": "最後編輯者", + "previously_edited_by": "先前編輯者", + "edited_by": "編輯者" } } diff --git a/packages/i18n/src/store/index.ts b/packages/i18n/src/store/index.ts index c711e0e63..ff4cee107 100644 --- a/packages/i18n/src/store/index.ts +++ b/packages/i18n/src/store/index.ts @@ -165,6 +165,16 @@ export class TranslationStore { return import("../locales/pl/translations.json"); case "ko": return import("../locales/ko/translations.json"); + case "pt-BR": + return import("../locales/pt-BR/translations.json"); + case "id": + return import("../locales/id/translations.json"); + case "ro": + return import("../locales/ro/translations.json"); + case "vi-VN": + return import("../locales/vi-VN/translations.json"); + case "tr-TR": + return import("../locales/tr-TR/translations.json"); default: throw new Error(`Unsupported language: ${language}`); } diff --git a/packages/i18n/src/types/language.ts b/packages/i18n/src/types/language.ts index 9e345064a..72ab64032 100644 --- a/packages/i18n/src/types/language.ts +++ b/packages/i18n/src/types/language.ts @@ -1,4 +1,23 @@ -export type TLanguage = "en" | "fr" | "es" | "ja" | "zh-CN" | "zh-TW" | "ru" | "it" | "cs" | "sk" | "de" | "ua" | "pl" | "ko"; +export type TLanguage = + | "en" + | "fr" + | "es" + | "ja" + | "zh-CN" + | "zh-TW" + | "ru" + | "it" + | "cs" + | "sk" + | "de" + | "ua" + | "pl" + | "ko" + | "pt-BR" + | "id" + | "ro" + | "vi-VN" + | "tr-TR"; export interface ILanguageOption { label: string; diff --git a/packages/logger/package.json b/packages/logger/package.json index 9d234d8de..bd5b95f4e 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -1,6 +1,6 @@ { "name": "@plane/logger", - "version": "0.25.3", + "version": "0.26.0", "license": "AGPL-3.0", "description": "Logger shared across multiple apps internally", "private": true, diff --git a/packages/propel/package.json b/packages/propel/package.json index 9350521f5..382739d03 100644 --- a/packages/propel/package.json +++ b/packages/propel/package.json @@ -1,6 +1,6 @@ { "name": "@plane/propel", - "version": "0.25.3", + "version": "0.26.0", "private": true, "license": "AGPL-3.0", "scripts": { diff --git a/packages/propel/src/charts/area-chart/root.tsx b/packages/propel/src/charts/area-chart/root.tsx index 710c5f70d..7d4e9e6ba 100644 --- a/packages/propel/src/charts/area-chart/root.tsx +++ b/packages/propel/src/charts/area-chart/root.tsx @@ -1,14 +1,14 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ "use client"; -import React, { useMemo } from "react"; -import { AreaChart as CoreAreaChart, Area, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; +import React, { useMemo, useState } from "react"; +import { Area, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis, Line, ComposedChart, CartesianGrid } from "recharts"; // plane imports -import { AXIS_LINE_CLASSNAME, LABEL_CLASSNAME } from "@plane/constants"; +import { AXIS_LABEL_CLASSNAME } from "@plane/constants"; import { TAreaChartProps } from "@plane/types"; // local components -import { CustomXAxisTick, CustomYAxisTick } from "../tick"; -import { CustomTooltip } from "../tooltip"; +import { getLegendProps } from "../components/legend"; +import { CustomXAxisTick, CustomYAxisTick } from "../components/tick"; +import { CustomTooltip } from "../components/tooltip"; export const AreaChart = React.memo((props: TAreaChartProps) => { const { @@ -16,107 +16,174 @@ export const AreaChart = React.memo((props: areas, xAxis, yAxis, - className = "w-full h-96", + className, + legend, + margin, tickCount = { x: undefined, y: 10, }, showTooltip = true, + comparisonLine, } = props; + // states + const [activeArea, setActiveArea] = useState(null); + const [activeLegend, setActiveLegend] = useState(null); // derived values const itemKeys = useMemo(() => areas.map((area) => area.key), [areas]); - const itemDotClassNames = useMemo( - () => areas.reduce((acc, area) => ({ ...acc, [area.key]: area.dotClassName }), {}), + const itemLabels: Record = useMemo( + () => areas.reduce((acc, area) => ({ ...acc, [area.key]: area.label }), {}), [areas] ); + const itemDotColors = useMemo(() => areas.reduce((acc, area) => ({ ...acc, [area.key]: area.fill }), {}), [areas]); const renderAreas = useMemo( () => areas.map((area) => ( setActiveArea(area.key)} + onMouseLeave={() => setActiveArea(null)} + className="[&_path]:transition-opacity [&_path]:duration-200" /> )), - [areas] + [activeLegend, areas] ); + // create comparison line data for straight line from origin to last point + const comparisonLineData = useMemo(() => { + if (!data || data.length === 0) return []; + // get the last data point + const lastPoint = data[data.length - 1]; + // for the y-value in the last point, use its yAxis key value + const lastYValue = lastPoint[yAxis.key] || 0; + // create data for a straight line that has points at each x-axis position + return data.map((item, index) => { + // calculate the y value for this point on the straight line + // using linear interpolation between (0,0) and (last_x, last_y) + const ratio = index / (data.length - 1); + const interpolatedValue = ratio * lastYValue; + + return { + [xAxis.key]: item[xAxis.key], + comparisonLine: interpolatedValue, + }; + }); + }, [data, xAxis.key]); + return (
- + } - tickLine={{ - stroke: "currentColor", - className: AXIS_LINE_CLASSNAME, - }} - axisLine={{ - stroke: "currentColor", - className: AXIS_LINE_CLASSNAME, - }} - label={{ - value: xAxis.label, - dy: 28, - className: LABEL_CLASSNAME, - }} + tickLine={false} + axisLine={false} + label={ + xAxis.label && { + value: xAxis.label, + dy: 28, + className: AXIS_LABEL_CLASSNAME, + } + } tickCount={tickCount.x} /> } tickCount={tickCount.y} allowDecimals={!!yAxis.allowDecimals} /> + {legend && ( + // @ts-expect-error recharts types are not up to date + itemLabels[value]} + onMouseEnter={(payload) => setActiveLegend(payload.value)} + onMouseLeave={() => setActiveLegend(null)} + {...getLegendProps(legend)} + /> + )} {showTooltip && ( ( )} /> )} {renderAreas} - + {comparisonLine && ( + + )} +
); diff --git a/packages/propel/src/charts/bar-chart/bar.tsx b/packages/propel/src/charts/bar-chart/bar.tsx index 339be704d..5cc9dac2f 100644 --- a/packages/propel/src/charts/bar-chart/bar.tsx +++ b/packages/propel/src/charts/bar-chart/bar.tsx @@ -15,10 +15,25 @@ const calculatePercentage = ( }; const MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT = 14; // Minimum height needed to show text inside -const BAR_BORDER_RADIUS = 2; // Border radius for each bar +const BAR_TOP_BORDER_RADIUS = 4; // Border radius for each bar +const BAR_BOTTOM_BORDER_RADIUS = 4; // Border radius for each bar export const CustomBar = React.memo((props: any) => { - const { fill, x, y, width, height, dataKey, stackKeys, payload, textClassName, showPercentage } = props; + const { + opacity, + fill, + x, + y, + width, + height, + dataKey, + stackKeys, + payload, + textClassName, + showPercentage, + showTopBorderRadius, + showBottomBorderRadius, + } = props; // Calculate text position const TEXT_PADDING_Y = Math.min(6, Math.abs(MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT - height / 2)); const textY = y + height - TEXT_PADDING_Y; // Position inside bar if tall enough @@ -34,24 +49,28 @@ export const CustomBar = React.memo((props: any) => { // bar percentage is a number !Number.isNaN(currentBarPercentage); + const topBorderRadius = showTopBorderRadius ? BAR_TOP_BORDER_RADIUS : 0; + const bottomBorderRadius = showBottomBorderRadius ? BAR_BOTTOM_BORDER_RADIUS : 0; + if (!height) return null; + return ( {showText && ( (props: TBarChartProps) => { @@ -18,19 +28,25 @@ export const BarChart = React.memo((props: T xAxis, yAxis, barSize = 40, - className = "w-full h-96", + className, + legend, + margin, tickCount = { x: undefined, y: 10, }, showTooltip = true, } = props; + // states + const [activeBar, setActiveBar] = useState(null); + const [activeLegend, setActiveLegend] = useState(null); // derived values const stackKeys = useMemo(() => bars.map((bar) => bar.key), [bars]); - const stackDotClassNames = useMemo( - () => bars.reduce((acc, bar) => ({ ...acc, [bar.key]: bar.dotClassName }), {}), + const stackLabels: Record = useMemo( + () => bars.reduce((acc, bar) => ({ ...acc, [bar.key]: bar.label }), {}), [bars] ); + const stackDotColors = useMemo(() => bars.reduce((acc, bar) => ({ ...acc, [bar.key]: bar.fill }), {}), [bars]); const renderBars = useMemo( () => @@ -39,18 +55,29 @@ export const BarChart = React.memo((props: T key={bar.key} dataKey={bar.key} stackId={bar.stackId} - fill={bar.fillClassName} - shape={(shapeProps: any) => ( - - )} + opacity={!!activeLegend && activeLegend !== bar.key ? 0.1 : 1} + shape={(shapeProps: any) => { + const showTopBorderRadius = bar.showTopBorderRadius?.(shapeProps.dataKey, shapeProps.payload); + const showBottomBorderRadius = bar.showBottomBorderRadius?.(shapeProps.dataKey, shapeProps.payload); + + return ( + + ); + }} + className="[&_path]:transition-opacity [&_path]:duration-200" + onMouseEnter={() => setActiveBar(bar.key)} + onMouseLeave={() => setActiveBar(null)} /> )), - [stackKeys, bars] + [activeLegend, stackKeys, bars] ); return ( @@ -58,60 +85,71 @@ export const BarChart = React.memo((props: T + } - tickLine={{ - stroke: "currentColor", - className: AXIS_LINE_CLASSNAME, - }} - axisLine={{ - stroke: "currentColor", - className: AXIS_LINE_CLASSNAME, - }} + tickLine={false} + axisLine={false} label={{ value: xAxis.label, dy: 28, - className: LABEL_CLASSNAME, + className: AXIS_LABEL_CLASSNAME, }} tickCount={tickCount.x} /> } tickCount={tickCount.y} allowDecimals={!!yAxis.allowDecimals} /> + {legend && ( + // @ts-expect-error recharts types are not up to date + setActiveLegend(payload.value)} + onMouseLeave={() => setActiveLegend(null)} + formatter={(value) => stackLabels[value]} + {...getLegendProps(legend)} + /> + )} {showTooltip && ( ( )} /> diff --git a/packages/propel/src/charts/components/legend.tsx b/packages/propel/src/charts/components/legend.tsx new file mode 100644 index 000000000..2be69c5cb --- /dev/null +++ b/packages/propel/src/charts/components/legend.tsx @@ -0,0 +1,77 @@ +import React from "react"; +import { LegendProps } from "recharts"; +// plane imports +import { TChartLegend } from "@plane/types"; +import { cn } from "@plane/utils"; + +export const getLegendProps = (args: TChartLegend): LegendProps => { + const { align, layout, verticalAlign } = args; + return { + layout, + align, + verticalAlign, + wrapperStyle: { + display: "flex", + overflow: "hidden", + ...(layout === "vertical" + ? { + top: 0, + alignItems: "center", + height: "100%", + } + : { + left: 0, + bottom: 0, + width: "100%", + justifyContent: "center", + }), + }, + content: , + }; +}; + +const CustomLegend = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & + Pick & + TChartLegend +>((props, ref) => { + const { formatter, layout, onClick, onMouseEnter, onMouseLeave, payload } = props; + + if (!payload?.length) return null; + + return ( +
+ {payload.map((item, index) => ( +
onClick?.(item, index, e)} + onMouseEnter={(e) => onMouseEnter?.(item, index, e)} + onMouseLeave={(e) => onMouseLeave?.(item, index, e)} + > +
+ {/* @ts-expect-error recharts types are not up to date */} + {formatter?.(item.value, { value: item.value }, index) ?? item.payload?.name} +
+ ))} +
+ ); +}); +CustomLegend.displayName = "CustomLegend"; diff --git a/packages/propel/src/charts/tick.tsx b/packages/propel/src/charts/components/tick.tsx similarity index 90% rename from packages/propel/src/charts/tick.tsx rename to packages/propel/src/charts/components/tick.tsx index c631d7d6e..e26e25ef3 100644 --- a/packages/propel/src/charts/tick.tsx +++ b/packages/propel/src/charts/components/tick.tsx @@ -2,7 +2,7 @@ import React from "react"; // Common classnames -const AXIS_TICK_CLASSNAME = "fill-custom-text-400 text-sm capitalize"; +const AXIS_TICK_CLASSNAME = "fill-custom-text-300 text-sm"; export const CustomXAxisTick = React.memo(({ x, y, payload }: any) => ( diff --git a/packages/propel/src/charts/components/tooltip.tsx b/packages/propel/src/charts/components/tooltip.tsx new file mode 100644 index 000000000..8931f7c71 --- /dev/null +++ b/packages/propel/src/charts/components/tooltip.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import { NameType, Payload, ValueType } from "recharts/types/component/DefaultTooltipContent"; +// plane imports +import { Card, ECardSpacing } from "@plane/ui"; +import { cn } from "@plane/utils"; + +type Props = { + active: boolean | undefined; + activeKey?: string | null; + label: string | undefined; + payload: Payload[] | undefined; + itemKeys: string[]; + itemLabels: Record; + itemDotColors: Record; +}; + +export const CustomTooltip = React.memo((props: Props) => { + const { active, activeKey, label, payload, itemKeys, itemLabels, itemDotColors } = props; + // derived values + const filteredPayload = payload?.filter((item) => item.dataKey && itemKeys.includes(`${item.dataKey}`)); + + if (!active || !filteredPayload || !filteredPayload.length) return null; + + return ( + +

+ {label} +

+ {filteredPayload.map((item) => { + if (!item.dataKey) return null; + + return ( +
+
+ {itemDotColors[item?.dataKey] && ( +
+ )} + {itemLabels[item?.dataKey]}: +
+ {item?.value} +
+ ); + })} + + ); +}); +CustomTooltip.displayName = "CustomTooltip"; diff --git a/packages/propel/src/charts/line-chart/root.tsx b/packages/propel/src/charts/line-chart/root.tsx index c689fe9ba..6812797b7 100644 --- a/packages/propel/src/charts/line-chart/root.tsx +++ b/packages/propel/src/charts/line-chart/root.tsx @@ -1,107 +1,154 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ "use client"; -import React, { useMemo } from "react"; -import { LineChart as CoreLineChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; +import React, { useMemo, useState } from "react"; +import { + CartesianGrid, + LineChart as CoreLineChart, + Legend, + Line, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; // plane imports -import { AXIS_LINE_CLASSNAME, LABEL_CLASSNAME } from "@plane/constants"; +import { AXIS_LABEL_CLASSNAME } from "@plane/constants"; import { TLineChartProps } from "@plane/types"; // local components -import { CustomXAxisTick, CustomYAxisTick } from "../tick"; -import { CustomTooltip } from "../tooltip"; +import { getLegendProps } from "../components/legend"; +import { CustomXAxisTick, CustomYAxisTick } from "../components/tick"; +import { CustomTooltip } from "../components/tooltip"; export const LineChart = React.memo((props: TLineChartProps) => { const { data, lines, + margin, xAxis, yAxis, - className = "w-full h-96", + className, tickCount = { x: undefined, y: 10, }, + legend, showTooltip = true, } = props; + // states + const [activeLine, setActiveLine] = useState(null); + const [activeLegend, setActiveLegend] = useState(null); // derived values const itemKeys = useMemo(() => lines.map((line) => line.key), [lines]); - const itemDotClassNames = useMemo( - () => lines.reduce((acc, line) => ({ ...acc, [line.key]: line.dotClassName }), {}), + const itemLabels: Record = useMemo( + () => lines.reduce((acc, line) => ({ ...acc, [line.key]: line.label }), {}), [lines] ); + const itemDotColors = useMemo(() => lines.reduce((acc, line) => ({ ...acc, [line.key]: line.stroke }), {}), [lines]); const renderLines = useMemo( () => lines.map((line) => ( - + setActiveLine(line.key)} + onMouseLeave={() => setActiveLine(null)} + /> )), - [lines] + [activeLegend, lines] ); return (
+ } - tickLine={{ - stroke: "currentColor", - className: AXIS_LINE_CLASSNAME, - }} - axisLine={{ - stroke: "currentColor", - className: AXIS_LINE_CLASSNAME, - }} - label={{ - value: xAxis.label, - dy: 28, - className: LABEL_CLASSNAME, - }} + tickLine={false} + axisLine={false} + label={ + xAxis.label && { + value: xAxis.label, + dy: 28, + className: AXIS_LABEL_CLASSNAME, + } + } tickCount={tickCount.x} /> } tickCount={tickCount.y} allowDecimals={!!yAxis.allowDecimals} /> + {legend && ( + // @ts-expect-error recharts types are not up to date + setActiveLegend(payload.value)} + onMouseLeave={() => setActiveLegend(null)} + formatter={(value) => itemLabels[value]} + {...getLegendProps(legend)} + /> + )} {showTooltip && ( ( )} /> diff --git a/packages/propel/src/charts/pie-chart/active-shape.tsx b/packages/propel/src/charts/pie-chart/active-shape.tsx new file mode 100644 index 000000000..61c14e281 --- /dev/null +++ b/packages/propel/src/charts/pie-chart/active-shape.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { Sector } from "recharts"; + +export const CustomActiveShape = React.memo((props: any) => { + const { cx, cy, cornerRadius, innerRadius, outerRadius, startAngle, endAngle, fill } = props; + + return ( + + + + + ); +}); diff --git a/packages/propel/src/charts/pie-chart/root.tsx b/packages/propel/src/charts/pie-chart/root.tsx index d9e2558ed..1110260b9 100644 --- a/packages/propel/src/charts/pie-chart/root.tsx +++ b/packages/propel/src/charts/pie-chart/root.tsx @@ -1,45 +1,145 @@ "use client"; -import React, { useMemo } from "react"; -import { Cell, PieChart as CorePieChart, Pie, ResponsiveContainer, Tooltip } from "recharts"; +import React, { useMemo, useState } from "react"; +import { Cell, PieChart as CorePieChart, Label, Legend, Pie, ResponsiveContainer, Tooltip } from "recharts"; // plane imports import { TPieChartProps } from "@plane/types"; // local components +import { getLegendProps } from "../components/legend"; +import { CustomActiveShape } from "./active-shape"; import { CustomPieChartTooltip } from "./tooltip"; export const PieChart = React.memo((props: TPieChartProps) => { - const { data, dataKey, cells, className = "w-full h-96", innerRadius, outerRadius, showTooltip = true } = props; + const { + data, + dataKey, + cells, + className, + innerRadius, + legend, + margin, + outerRadius, + showTooltip = true, + showLabel, + customLabel, + centerLabel, + cornerRadius, + paddingAngle, + tooltipLabel, + } = props; + // states + const [activeIndex, setActiveIndex] = useState(null); + const [activeLegend, setActiveLegend] = useState(null); const renderCells = useMemo( - () => cells.map((cell) => ), - [cells] + () => + cells.map((cell, index) => ( + setActiveIndex(index)} + onMouseLeave={() => setActiveIndex(null)} + /> + )), + [activeLegend, cells] ); return (
- + setActiveIndex(null)} + data={data} + dataKey={dataKey} + cx="50%" + cy="50%" + blendStroke + activeShape={} + innerRadius={innerRadius} + outerRadius={outerRadius} + cornerRadius={cornerRadius} + paddingAngle={paddingAngle} + labelLine={false} + label={ + showLabel + ? ({ payload, ...props }) => ( + + {customLabel?.(payload.count) ?? payload.count} + + ) + : undefined + } + > {renderCells} + {centerLabel && ( + + {legend && ( + // @ts-expect-error recharts types are not up to date + { + // @ts-expect-error recharts types are not up to date + const key: string | undefined = payload.payload?.key; + if (!key) return; + setActiveLegend(key); + setActiveIndex(null); + }} + onMouseLeave={() => setActiveLegend(null)} + {...getLegendProps(legend)} + /> + )} {showTooltip && ( { if (!active || !payload || !payload.length) return null; - const cellData = cells.find((c) => c.key === payload[0].name); + const cellData = cells.find((c) => c.key === payload[0].payload.key); if (!cellData) return null; - return ; + const label = tooltipLabel + ? typeof tooltipLabel === "function" + ? tooltipLabel(payload[0]?.payload?.payload) + : tooltipLabel + : dataKey; + return ; }} /> )} diff --git a/packages/propel/src/charts/pie-chart/tooltip.tsx b/packages/propel/src/charts/pie-chart/tooltip.tsx index 56c7fa34c..0a4a157de 100644 --- a/packages/propel/src/charts/pie-chart/tooltip.tsx +++ b/packages/propel/src/charts/pie-chart/tooltip.tsx @@ -2,27 +2,36 @@ import React from "react"; import { NameType, Payload, ValueType } from "recharts/types/component/DefaultTooltipContent"; // plane imports import { Card, ECardSpacing } from "@plane/ui"; -import { cn } from "@plane/utils"; type Props = { - dotClassName?: string; + dotColor?: string; label: string; payload: Payload[]; }; export const CustomPieChartTooltip = React.memo((props: Props) => { - const { dotClassName, label, payload } = props; + const { dotColor, label, payload } = props; return ( - -

+ +

{label}

{payload?.map((item) => (
-
- {item?.name}: - {item?.value} +
+
+ {item?.name}: +
+ {item?.value}
))} diff --git a/packages/propel/src/charts/tooltip.tsx b/packages/propel/src/charts/tooltip.tsx deleted file mode 100644 index e7f92a9cb..000000000 --- a/packages/propel/src/charts/tooltip.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from "react"; -import { NameType, Payload, ValueType } from "recharts/types/component/DefaultTooltipContent"; -// plane imports -import { Card, ECardSpacing } from "@plane/ui"; -import { cn } from "@plane/utils"; - -type Props = { - active: boolean | undefined; - label: string | undefined; - payload: Payload[] | undefined; - itemKeys: string[]; - itemDotClassNames: Record; -}; - -export const CustomTooltip = React.memo((props: Props) => { - const { active, label, payload, itemKeys, itemDotClassNames } = props; - // derived values - const filteredPayload = payload?.filter((item) => item.dataKey && itemKeys.includes(`${item.dataKey}`)); - - if (!active || !filteredPayload || !filteredPayload.length) return null; - return ( - -

- {label} -

- {filteredPayload.map((item) => { - if (!item.dataKey) return null; - return ( -
- {itemDotClassNames[item?.dataKey] && ( -
- )} - {item?.name}: - {item?.value} -
- ); - })} - - ); -}); -CustomTooltip.displayName = "CustomTooltip"; diff --git a/packages/propel/src/charts/tree-map/root.tsx b/packages/propel/src/charts/tree-map/root.tsx index 47ea21d72..7add4a6b6 100644 --- a/packages/propel/src/charts/tree-map/root.tsx +++ b/packages/propel/src/charts/tree-map/root.tsx @@ -31,6 +31,9 @@ export const TreeMapChart = React.memo((props: TreeMapChartProps) => { fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer", }} + wrapperStyle={{ + pointerEvents: "auto", + }} /> )} diff --git a/packages/services/package.json b/packages/services/package.json index 329b9ba91..952c23da6 100644 --- a/packages/services/package.json +++ b/packages/services/package.json @@ -1,6 +1,6 @@ { "name": "@plane/services", - "version": "0.25.3", + "version": "0.26.0", "license": "AGPL-3.0", "private": true, "main": "./src/index.ts", diff --git a/packages/shared-state/package.json b/packages/shared-state/package.json index 333cbf7d8..78ae22dde 100644 --- a/packages/shared-state/package.json +++ b/packages/shared-state/package.json @@ -1,6 +1,6 @@ { "name": "@plane/shared-state", - "version": "0.25.3", + "version": "0.26.0", "license": "AGPL-3.0", "description": "Shared state shared across multiple apps internally", "private": true, diff --git a/packages/tailwind-config/package.json b/packages/tailwind-config/package.json index 1c3da98ac..0f17d7936 100644 --- a/packages/tailwind-config/package.json +++ b/packages/tailwind-config/package.json @@ -1,6 +1,6 @@ { "name": "@plane/tailwind-config", - "version": "0.25.3", + "version": "0.26.0", "license": "AGPL-3.0", "description": "common tailwind configuration across monorepo", "main": "tailwind.config.js", diff --git a/packages/tailwind-config/tailwind.config.js b/packages/tailwind-config/tailwind.config.js index 3e34ca1f0..700831d12 100644 --- a/packages/tailwind-config/tailwind.config.js +++ b/packages/tailwind-config/tailwind.config.js @@ -27,6 +27,7 @@ module.exports = { theme: { extend: { boxShadow: { + "custom-shadow": "var(--color-shadow-custom)", "custom-shadow-2xs": "var(--color-shadow-2xs)", "custom-shadow-xs": "var(--color-shadow-xs)", "custom-shadow-sm": "var(--color-shadow-sm)", @@ -208,6 +209,28 @@ module.exports = { hover: "rgba(96, 100, 108, 0.25)", active: "rgba(96, 100, 108, 0.7)", }, + subscription: { + free: { + 200: convertToRGB("--color-subscription-free-200"), + 400: convertToRGB("--color-subscription-free-400"), + }, + one: { + 200: convertToRGB("--color-subscription-one-200"), + 400: convertToRGB("--color-subscription-one-400"), + }, + pro: { + 200: convertToRGB("--color-subscription-pro-200"), + 400: convertToRGB("--color-subscription-pro-400"), + }, + business: { + 200: convertToRGB("--color-subscription-business-200"), + 400: convertToRGB("--color-subscription-business-400"), + }, + enterprise: { + 200: convertToRGB("--color-subscription-enterprise-200"), + 400: convertToRGB("--color-subscription-enterprise-400"), + }, + }, }, onboarding: { background: { diff --git a/packages/types/package.json b/packages/types/package.json index a00a4d312..55861501a 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@plane/types", - "version": "0.25.3", + "version": "0.26.0", "license": "AGPL-3.0", "private": true, "types": "./src/index.d.ts", diff --git a/packages/types/src/charts.d.ts b/packages/types/src/charts.d.ts index 473c1077e..b1fc2997d 100644 --- a/packages/types/src/charts.d.ts +++ b/packages/types/src/charts.d.ts @@ -1,3 +1,16 @@ +export type TChartLegend = { + align: "left" | "center" | "right"; + verticalAlign: "top" | "middle" | "bottom"; + layout: "horizontal" | "vertical"; +}; + +export type TChartMargin = { + top?: number; + right?: number; + bottom?: number; + left?: number; +}; + export type TChartData = { // required key [key in K]: string | number; @@ -7,15 +20,19 @@ type TChartProps = { data: TChartData[]; xAxis: { key: keyof TChartData; - label: string; + label?: string; + strokeColor?: string; }; yAxis: { - key: keyof TChartData; - label: string; - domain?: [number, number]; allowDecimals?: boolean; + domain?: [number, number]; + key: keyof TChartData; + label?: string; + strokeColor?: string; }; className?: string; + legend?: TChartLegend; + margin?: TChartMargin; tickCount?: { x?: number; y?: number; @@ -25,11 +42,13 @@ type TChartProps = { export type TBarItem = { key: T; - fillClassName: string; + label: string; + fill: string | ((payload: any) => string); textClassName: string; - dotClassName?: string; showPercentage?: boolean; stackId: string; + showTopBorderRadius?: (barKey: string, payload: any) => boolean; + showBottomBorderRadius?: (barKey: string, payload: any) => boolean; }; export type TBarChartProps = TChartProps & { @@ -39,9 +58,13 @@ export type TBarChartProps = TChartProps = { key: T; - className?: string; + label: string; + dashedLine: boolean; + fill: string; + showDot: boolean; + smoothCurves: boolean; + stroke: string; style?: Record; - dotClassName?: string; }; export type TLineChartProps = TChartProps & { @@ -50,31 +73,50 @@ export type TLineChartProps = TChartProps = { key: T; + label: string; stackId: string; - className?: string; + fill: string; + fillOpacity: number; + showDot: boolean; + smoothCurves: boolean; + strokeColor: string; + strokeOpacity: number; style?: Record; - dotClassName?: string; }; export type TAreaChartProps = TChartProps & { areas: TAreaItem[]; + comparisonLine?: { + dashedLine: boolean; + strokeColor: string; + }; }; export type TCellItem = { key: T; - className?: string; - style?: Record; - dotClassName?: string; + fill: string; }; export type TPieChartProps = Pick< TChartProps, - "className" | "data" | "showTooltip" + "className" | "data" | "showTooltip" | "legend" | "margin" > & { dataKey: T; cells: TCellItem[]; - innerRadius?: number; - outerRadius?: number; + innerRadius?: number | string; + outerRadius?: number | string; + cornerRadius?: number; + paddingAngle?: number; + showLabel: boolean; + customLabel?: (value: any) => string; + centerLabel?: { + className?: string; + fill: string; + style?: React.CSSProperties; + text?: string | number; + }; + tooltipLabel?: string | ((payload: any) => string); + customLegend?: (props: any) => React.ReactNode; }; export type TreeMapItem = { diff --git a/packages/types/src/common.d.ts b/packages/types/src/common.d.ts index c45236a9f..b35e408d6 100644 --- a/packages/types/src/common.d.ts +++ b/packages/types/src/common.d.ts @@ -26,3 +26,11 @@ export type TLogoProps = { export type TNameDescriptionLoader = "submitting" | "submitted" | "saved"; export type TFetchStatus = "partial" | "complete" | undefined; + +export type ICustomSearchSelectOption = { + value: any; + query: string; + content: React.ReactNode; + disabled?: boolean; + tooltip?: string | React.ReactNode; +}; diff --git a/packages/types/src/description_version.d.ts b/packages/types/src/description_version.d.ts new file mode 100644 index 000000000..8b9816b01 --- /dev/null +++ b/packages/types/src/description_version.d.ts @@ -0,0 +1,29 @@ +export type TDescriptionVersion = { + created_at: string; + created_by: string | null; + id: string; + last_saved_at: string; + owned_by: string; + project: string; + updated_at: string; + updated_by: string | null; +}; + +export type TDescriptionVersionDetails = TDescriptionVersion & { + description_binary: string | null; + description_html: string | null; + description_json: object | null; + description_stripped: string | null; +}; + +export type TDescriptionVersionsListResponse = { + cursor: string; + next_cursor: string | null; + next_page_results: boolean; + page_count: number; + prev_cursor: string | null; + prev_page_results: boolean; + results: TDescriptionVersion[]; + total_pages: number; + total_results: number; +}; diff --git a/packages/types/src/enums.ts b/packages/types/src/enums.ts index 854c0c614..53138a1d7 100644 --- a/packages/types/src/enums.ts +++ b/packages/types/src/enums.ts @@ -6,6 +6,12 @@ export enum EUserPermissions { export type TUserPermissions = EUserPermissions.ADMIN | EUserPermissions.MEMBER | EUserPermissions.GUEST; +// project network +export enum EProjectNetwork { + PRIVATE = 0, + PUBLIC = 2, +} + // project pages export enum EPageAccess { PUBLIC = 0, diff --git a/packages/types/src/estimate.d.ts b/packages/types/src/estimate.d.ts index 145edf117..0de2019fa 100644 --- a/packages/types/src/estimate.d.ts +++ b/packages/types/src/estimate.d.ts @@ -14,10 +14,7 @@ export interface IEstimatePoint { updated_by: string | undefined; } -export type TEstimateSystemKeys = - | EEstimateSystem.POINTS - | EEstimateSystem.CATEGORIES - | EEstimateSystem.TIME; +export type TEstimateSystemKeys = EEstimateSystem.POINTS | EEstimateSystem.CATEGORIES | EEstimateSystem.TIME; export interface IEstimate { id: string | undefined; @@ -55,12 +52,14 @@ export type TEstimatePointsObject = { export type TTemplateValues = { title: string; + i18n_title: string; values: TEstimatePointsObject[]; hide?: boolean; }; export type TEstimateSystem = { name: string; + i18n_name: string; templates: Record; is_available: boolean; is_ee: boolean; @@ -82,6 +81,4 @@ export type TEstimateTypeErrorObject = { message: string | undefined; }; -export type TEstimateTypeError = - | Record - | undefined; +export type TEstimateTypeError = Record | undefined; diff --git a/packages/types/src/home.d.ts b/packages/types/src/home.d.ts index 56089bf46..f34c15380 100644 --- a/packages/types/src/home.d.ts +++ b/packages/types/src/home.d.ts @@ -35,6 +35,7 @@ export type TIssueEntityData = { sequence_id: number; project_id: string; project_identifier: string; + is_epic: boolean; }; export type TActivityEntityData = { diff --git a/packages/types/src/inbox.d.ts b/packages/types/src/inbox.d.ts index 69fb01f7c..e7065c6d0 100644 --- a/packages/types/src/inbox.d.ts +++ b/packages/types/src/inbox.d.ts @@ -1,6 +1,8 @@ +// plane constants +import { TInboxIssue, TInboxIssueStatus } from "@plane/constants"; +// plane types import { TPaginationInfo } from "./common"; import { TIssuePriorities } from "./issues"; -import { TIssue } from "./issues/base"; // filters export type TInboxIssueFilterMemberKeys = "assignees" | "created_by"; diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index bd4e593cc..b6af3b562 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -3,6 +3,7 @@ export * from "./workspace"; export * from "./cycle"; export * from "./dashboard"; export * from "./de-dupe"; +export * from "./description_version"; export * from "./project"; export * from "./state"; export * from "./issues"; @@ -41,3 +42,4 @@ export * from "./charts"; export * from "./home"; export * from "./stickies"; export * from "./utils"; +export * from "./payment"; diff --git a/packages/types/src/instance/auth.d.ts b/packages/types/src/instance/auth.d.ts index d71cfa0bb..31d3a2582 100644 --- a/packages/types/src/instance/auth.d.ts +++ b/packages/types/src/instance/auth.d.ts @@ -21,7 +21,8 @@ export type TInstanceGoogleAuthenticationConfigurationKeys = export type TInstanceGithubAuthenticationConfigurationKeys = | "GITHUB_CLIENT_ID" - | "GITHUB_CLIENT_SECRET"; + | "GITHUB_CLIENT_SECRET" + | "GITHUB_ORGANIZATION_ID"; export type TInstanceGitlabAuthenticationConfigurationKeys = | "GITLAB_HOST" diff --git a/packages/types/src/instance/base.d.ts b/packages/types/src/instance/base.d.ts index 33d0734ad..dc5ee5fc7 100644 --- a/packages/types/src/instance/base.d.ts +++ b/packages/types/src/instance/base.d.ts @@ -37,6 +37,7 @@ export interface IInstance { } export interface IInstanceConfig { + enable_signup: boolean; is_workspace_creation_disabled: boolean; is_google_enabled: boolean; is_github_enabled: boolean; @@ -72,9 +73,7 @@ export interface IInstanceAdmin { user_detail: IUserLite; } -export type TInstanceIntercomConfigurationKeys = - | "IS_INTERCOM_ENABLED" - | "INTERCOM_APP_ID"; +export type TInstanceIntercomConfigurationKeys = "IS_INTERCOM_ENABLED" | "INTERCOM_APP_ID"; export type TInstanceConfigurationKeys = | TInstanceAIConfigurationKeys diff --git a/packages/types/src/issues/activity/issue_activity.d.ts b/packages/types/src/issues/activity/issue_activity.d.ts index 7eccebdf3..7ed0c35d6 100644 --- a/packages/types/src/issues/activity/issue_activity.d.ts +++ b/packages/types/src/issues/activity/issue_activity.d.ts @@ -1,3 +1,6 @@ +// plane imports +import { EInboxIssueSource } from "@plane/constants"; +// local imports import { TIssueActivityWorkspaceDetail, TIssueActivityProjectDetail, @@ -31,7 +34,7 @@ export type TIssueActivity = { epoch: number; issue_comment: string | null; source_data: { - source: "IN_APP" | "FORM" | "EMAIL"; + source: EInboxIssueSource; source_email?: string; extra: { username?: string; diff --git a/packages/types/src/issues/activity/issue_comment.d.ts b/packages/types/src/issues/activity/issue_comment.d.ts index aef5134c6..e61b35585 100644 --- a/packages/types/src/issues/activity/issue_comment.d.ts +++ b/packages/types/src/issues/activity/issue_comment.d.ts @@ -5,7 +5,15 @@ import { TIssueActivityUserDetail, } from "./base"; import { EIssueCommentAccessSpecifier } from "../../enums"; +import { TFileSignedURLResponse } from "../../file"; +import { IUserLite } from "../../users"; +export type TCommentReaction = { + id: string; + reaction: string; + actor: string; + actor_detail: IUserLite; +}; export type TIssueComment = { id: string; workspace: string; @@ -17,6 +25,7 @@ export type TIssueComment = { actor: string; actor_detail: TIssueActivityUserDetail; created_at: string; + edited_at?: string | undefined; updated_at: string; created_by: string | undefined; updated_by: string | undefined; @@ -30,6 +39,23 @@ export type TIssueComment = { access: EIssueCommentAccessSpecifier; }; +export type TCommentsOperations = { + createComment: (data: Partial) => Promise | undefined>; + updateComment: (commentId: string, data: Partial) => Promise; + removeComment: (commentId: string) => Promise; + uploadCommentAsset: (blockId: string, file: File, commentId?: string) => Promise; + addCommentReaction: (commentId: string, reactionEmoji: string) => Promise; + deleteCommentReaction: (commentId: string, reactionEmoji: string, userReactions: TCommentReaction[]) => Promise; + react: (commentId: string, reactionEmoji: string, userReactions: string[]) => Promise; + reactionIds: (commentId: string) => + | { + [reaction: string]: string[]; + } + | undefined; + userReactions: (commentId: string) => string[] | undefined; + getReactionUsers: (reaction: string, reactionIds: Record) => string; +}; + export type TIssueCommentMap = { [issue_id: string]: TIssueComment; }; diff --git a/packages/types/src/issues/issue.d.ts b/packages/types/src/issues/issue.d.ts index e38810004..18a150c49 100644 --- a/packages/types/src/issues/issue.d.ts +++ b/packages/types/src/issues/issue.d.ts @@ -120,7 +120,7 @@ export type TBulkOperationsPayload = { export type TIssueDetailWidget = "sub-issues" | "relations" | "links" | "attachments"; -export type TIssueServiceType = EIssueServiceType.ISSUES | EIssueServiceType.EPICS; +export type TIssueServiceType = EIssueServiceType.ISSUES | EIssueServiceType.EPICS | EIssueServiceType.WORK_ITEMS; export interface IPublicIssue extends Pick< diff --git a/packages/types/src/payment.d.ts b/packages/types/src/payment.d.ts new file mode 100644 index 000000000..bdbab7f32 --- /dev/null +++ b/packages/types/src/payment.d.ts @@ -0,0 +1,36 @@ +import { EProductSubscriptionEnum } from "@plane/constants"; + +export type TBillingFrequency = "month" | "year"; + +export type IPaymentProductPrice = { + currency: string; + id: string; + product: string; + recurring: TBillingFrequency; + unit_amount: number; + workspace_amount: number; +}; + +export type TProductSubscriptionType = "FREE" | "ONE" | "PRO" | "BUSINESS" | "ENTERPRISE"; + +export type IPaymentProduct = { + description: string; + id: string; + name: string; + type: Omit; + payment_quantity: number; + prices: IPaymentProductPrice[]; + is_active: boolean; +}; + +export type TSubscriptionPrice = { + key: string; + id: string | undefined; + currency: string; + price: number; + recurring: TBillingFrequency; +}; + +export type TProductBillingFrequency = { + [key in EProductSubscriptionEnum]: TBillingFrequency | undefined; +}; diff --git a/packages/types/src/project/projects.d.ts b/packages/types/src/project/projects.d.ts index 40562d362..e1d9117a1 100644 --- a/packages/types/src/project/projects.d.ts +++ b/packages/types/src/project/projects.d.ts @@ -27,6 +27,7 @@ export interface IPartialProject { inbox_view: boolean; guest_view_all_features?: boolean; project_lead?: IUserLite | string | null; + network?: number; // Timestamps created_at?: Date; updated_at?: Date; @@ -50,7 +51,6 @@ export interface IProject extends IPartialProject { anchor?: string | null; is_favorite?: boolean; members?: string[]; - network?: number; timezone?: string; } diff --git a/packages/types/src/state.d.ts b/packages/types/src/state.d.ts index 120b216da..d28194dc9 100644 --- a/packages/types/src/state.d.ts +++ b/packages/types/src/state.d.ts @@ -24,3 +24,11 @@ export interface IStateLite { export interface IStateResponse { [key: string]: IState[]; } + +export type TStateOperationsCallbacks = { + createState: (data: Partial) => Promise; + updateState: (stateId: string, data: Partial) => Promise; + deleteState: (stateId: string) => Promise; + moveStatePosition: (stateId: string, data: Partial) => Promise; + markStateAsDefault: (stateId: string) => Promise; +}; diff --git a/packages/typescript-config/node-library.json b/packages/typescript-config/node-library.json new file mode 100644 index 000000000..afb41eff3 --- /dev/null +++ b/packages/typescript-config/node-library.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Node.js Library", + "extends": "./base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2020", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "sourceMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "build"] +} \ No newline at end of file diff --git a/packages/typescript-config/package.json b/packages/typescript-config/package.json index cac5df873..d31bfd2b0 100644 --- a/packages/typescript-config/package.json +++ b/packages/typescript-config/package.json @@ -1,11 +1,12 @@ { "name": "@plane/typescript-config", - "version": "0.25.3", + "version": "0.26.0", "license": "AGPL-3.0", "private": true, "files": [ "base.json", "nextjs.json", - "react-library.json" + "react-library.json", + "node-library.json" ] } diff --git a/packages/ui/package.json b/packages/ui/package.json index 607205c2a..45550f369 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -2,7 +2,7 @@ "name": "@plane/ui", "description": "UI components shared across multiple apps internally", "private": true, - "version": "0.25.3", + "version": "0.26.0", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", diff --git a/packages/ui/src/collapsible/collapsible-button.tsx b/packages/ui/src/collapsible/collapsible-button.tsx index 2a141aa41..b6198fa6c 100644 --- a/packages/ui/src/collapsible/collapsible-button.tsx +++ b/packages/ui/src/collapsible/collapsible-button.tsx @@ -1,10 +1,10 @@ import React, { FC } from "react"; -import { DropdownIcon } from "../icons"; import { cn } from "../../helpers"; +import { DropdownIcon } from "../icons"; type Props = { isOpen: boolean; - title: string; + title: React.ReactNode; hideChevron?: boolean; indicatorElement?: React.ReactNode; actionItemElement?: React.ReactNode; diff --git a/packages/ui/src/color-picker/color-picker.tsx b/packages/ui/src/color-picker/color-picker.tsx new file mode 100644 index 000000000..e53fdc10d --- /dev/null +++ b/packages/ui/src/color-picker/color-picker.tsx @@ -0,0 +1,38 @@ +import * as React from "react"; + +interface ColorPickerProps { + value: string; + onChange: (color: string) => void; + className?: string; +} + +export const ColorPicker: React.FC = (props) => { + const { value, onChange, className = "" } = props; + // refs + const inputRef = React.useRef(null); + + // handlers + const handleOnClick = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + inputRef.current?.click(); + }; + + return ( +
+
+ ); +}; diff --git a/packages/ui/src/color-picker/index.ts b/packages/ui/src/color-picker/index.ts new file mode 100644 index 000000000..6bad1d67e --- /dev/null +++ b/packages/ui/src/color-picker/index.ts @@ -0,0 +1 @@ +export * from "./color-picker"; diff --git a/packages/ui/src/dropdown/common/options.tsx b/packages/ui/src/dropdown/common/options.tsx index ae006a842..6be99a9d9 100644 --- a/packages/ui/src/dropdown/common/options.tsx +++ b/packages/ui/src/dropdown/common/options.tsx @@ -42,7 +42,7 @@ export const DropdownOptions: React.FC )} -
+
<> {options ? ( options.length > 0 ? ( @@ -50,6 +50,7 @@ export const DropdownOptions: React.FC cn( "flex w-full cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5", @@ -66,7 +67,7 @@ export const DropdownOptions: React.FC ( <> {renderItem ? ( - <>{renderItem({ value: keyExtractor(option), selected })} + <>{renderItem({ value: keyExtractor(option), selected, disabled: option.disabled })} ) : ( <> {option.value} diff --git a/packages/ui/src/dropdown/dropdown.d.ts b/packages/ui/src/dropdown/dropdown.d.ts index 74cd0e45d..dd441d0a8 100644 --- a/packages/ui/src/dropdown/dropdown.d.ts +++ b/packages/ui/src/dropdown/dropdown.d.ts @@ -27,7 +27,15 @@ export interface IDropdown { queryArray?: string[]; sortByKey?: string; firstItem?: (optionValue: string) => boolean; - renderItem?: ({ value, selected }: { value: string; selected: boolean }) => React.ReactNode; + renderItem?: ({ + value, + selected, + disabled, + }: { + value: string; + selected: boolean; + disabled?: boolean; + }) => React.ReactNode; loader?: React.ReactNode; disableSorting?: boolean; } @@ -35,7 +43,8 @@ export interface IDropdown { export interface TDropdownOption { data: any; value: string; - className?: ({ active, selected }: { active: boolean; selected: boolean }) => string; + className?: ({ active, selected }: { active: boolean; selected?: boolean }) => string; + disabled?: boolean; } export interface IMultiSelectDropdown extends IDropdown { @@ -82,7 +91,9 @@ export interface IDropdownOptions { handleClose?: () => void; keyExtractor: (option: TDropdownOption) => string; - renderItem: (({ value, selected }: { value: string; selected: boolean }) => React.ReactNode) | undefined; + renderItem: + | (({ value, selected, disabled }: { value: string; selected: boolean; disabled?: boolean }) => React.ReactNode) + | undefined; options: TDropdownOption[] | undefined; loader?: React.ReactNode; isMobile?: boolean; diff --git a/packages/ui/src/dropdowns/context-menu/root.tsx b/packages/ui/src/dropdowns/context-menu/root.tsx index e4265f100..61554d7bd 100644 --- a/packages/ui/src/dropdowns/context-menu/root.tsx +++ b/packages/ui/src/dropdowns/context-menu/root.tsx @@ -2,12 +2,12 @@ import React, { useEffect, useRef, useState } from "react"; import ReactDOM from "react-dom"; // plane helpers import { useOutsideClickDetector } from "@plane/hooks"; -// components -import { ContextMenuItem } from "./item"; // helpers import { cn } from "../../../helpers"; // hooks import { usePlatformOS } from "../../hooks/use-platform-os"; +// components +import { ContextMenuItem } from "./item"; export type TContextMenuItem = { key: string; diff --git a/packages/ui/src/dropdowns/custom-menu.tsx b/packages/ui/src/dropdowns/custom-menu.tsx index f21da4381..24c8a106a 100644 --- a/packages/ui/src/dropdowns/custom-menu.tsx +++ b/packages/ui/src/dropdowns/custom-menu.tsx @@ -1,14 +1,14 @@ +import { Menu } from "@headlessui/react"; +import { ChevronDown, MoreHorizontal } from "lucide-react"; import * as React from "react"; import ReactDOM from "react-dom"; -import { Menu } from "@headlessui/react"; import { usePopper } from "react-popper"; -import { ChevronDown, MoreHorizontal } from "lucide-react"; // plane helpers import { useOutsideClickDetector } from "@plane/hooks"; -// hooks -import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down"; // helpers import { cn } from "../../helpers"; +// hooks +import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down"; // types import { ICustomMenuDropdownProps, ICustomMenuItemProps } from "./helper"; diff --git a/packages/ui/src/dropdowns/custom-search-select.tsx b/packages/ui/src/dropdowns/custom-search-select.tsx index 4302c12fd..e592f0dc2 100644 --- a/packages/ui/src/dropdowns/custom-search-select.tsx +++ b/packages/ui/src/dropdowns/custom-search-select.tsx @@ -1,18 +1,15 @@ -import React, { useRef, useState } from "react"; -import { usePopper } from "react-popper"; import { Combobox } from "@headlessui/react"; import { Check, ChevronDown, Info, Search } from "lucide-react"; +import React, { useRef, useState } from "react"; import { createPortal } from "react-dom"; -// plane helpers +import { usePopper } from "react-popper"; +// plane imports import { useOutsideClickDetector } from "@plane/hooks"; -// hooks -import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down"; -// helpers +// local imports import { cn } from "../../helpers"; -// types -import { ICustomSearchSelectProps } from "./helper"; -// local components +import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down"; import { Tooltip } from "../tooltip"; +import { ICustomSearchSelectProps } from "./helper"; export const CustomSearchSelect = (props: ICustomSearchSelectProps) => { const { @@ -36,6 +33,7 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => { optionsClassName = "", value, tabIndex, + noResultsMessage = "No matches found", } = props; const [query, setQuery] = useState(""); @@ -201,7 +199,7 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => { )) ) : ( -

No matches found

+

{noResultsMessage}

) ) : (

Loading...

diff --git a/packages/ui/src/dropdowns/helper.tsx b/packages/ui/src/dropdowns/helper.tsx index a7ef61c3d..0e7587051 100644 --- a/packages/ui/src/dropdowns/helper.tsx +++ b/packages/ui/src/dropdowns/helper.tsx @@ -1,5 +1,6 @@ // FIXME: fix this!!! import { Placement } from "@blueprintjs/popover2"; +import { ICustomSearchSelectOption } from "@plane/types"; export interface IDropdownProps { customButtonClassName?: string; @@ -43,15 +44,8 @@ interface CustomSearchSelectProps { footerOption?: JSX.Element; onChange: any; onClose?: () => void; - options: - | { - value: any; - query: string; - content: React.ReactNode; - disabled?: boolean; - tooltip?: string | React.ReactNode; - }[] - | undefined; + noResultsMessage?: string; + options?: ICustomSearchSelectOption[]; } interface SingleValueProps { diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 19edba780..29bf6f248 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -31,3 +31,5 @@ export * from "./card"; export * from "./tag"; export * from "./tabs"; export * from "./calendar"; +export * from "./color-picker"; +export * from "./link"; diff --git a/packages/ui/src/link/block.tsx b/packages/ui/src/link/block.tsx new file mode 100644 index 000000000..f3a615124 --- /dev/null +++ b/packages/ui/src/link/block.tsx @@ -0,0 +1,69 @@ +import React, { FC } from "react"; +// plane utils +import { calculateTimeAgo, cn, getIconForLink } from "@plane/utils"; +// plane ui +import { TContextMenuItem } from "../dropdowns/context-menu/root"; +import { CustomMenu } from "../dropdowns/custom-menu"; + +export type TLinkItemBlockProps = { + title: string; + url: string; + createdAt?: Date | string; + menuItems?: TContextMenuItem[]; + onClick?: () => void; +}; + +export const LinkItemBlock: FC = (props) => { + // props + const { title, url, createdAt, menuItems, onClick } = props; + // icons + const Icon = getIconForLink(url); + return ( +
+
+ +
+
+
{title}
+ {createdAt &&
{calculateTimeAgo(createdAt)}
} +
+ {menuItems && ( +
+ + {menuItems.map((item) => ( + { + e.preventDefault(); + e.stopPropagation(); + item.action(); + }} + className={cn("flex items-center gap-2 w-full ", { + "text-custom-text-400": item.disabled, + })} + disabled={item.disabled} + > + {item.icon && } +
+
{item.title}
+ {item.description && ( +

+ {item.description} +

+ )} +
+
+ ))} +
+
+ )} +
+ ); +}; diff --git a/packages/ui/src/link/index.ts b/packages/ui/src/link/index.ts new file mode 100644 index 000000000..086dec913 --- /dev/null +++ b/packages/ui/src/link/index.ts @@ -0,0 +1 @@ +export * from "./block"; diff --git a/packages/utils/package.json b/packages/utils/package.json index bd25dae27..fc8600077 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,6 +1,6 @@ { "name": "@plane/utils", - "version": "0.25.3", + "version": "0.26.0", "description": "Helper functions shared across multiple apps internally", "license": "AGPL-3.0", "private": true, diff --git a/packages/utils/src/common.ts b/packages/utils/src/common.ts index fff5d9d8e..d2d02c299 100644 --- a/packages/utils/src/common.ts +++ b/packages/utils/src/common.ts @@ -1,5 +1,6 @@ import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; +import { CompleteOrEmpty } from "@plane/types"; // Support email can be configured by the application export const getSupportEmail = (defaultEmail: string = ""): string => defaultEmail; @@ -39,3 +40,21 @@ export const partitionValidIds = (ids: string[], validIds: string[]): { valid: s return { valid, invalid }; }; + +/** + * Checks if an object is complete (has properties) rather than empty. + * This helps TypeScript narrow the type from CompleteOrEmpty to T. + * + * @param obj The object to check, typed as CompleteOrEmpty + * @returns A boolean indicating if the object is complete (true) or empty (false) + */ +export const isComplete = (obj: CompleteOrEmpty): obj is T => { + // Check if object is not null or undefined + if (obj == null) return false; + + // Check if it's an object + if (typeof obj !== "object") return false; + + // Check if it has any own properties + return Object.keys(obj).length > 0; +}; diff --git a/packages/utils/src/get-icon-for-link.ts b/packages/utils/src/get-icon-for-link.ts new file mode 100644 index 000000000..0c703a81c --- /dev/null +++ b/packages/utils/src/get-icon-for-link.ts @@ -0,0 +1,64 @@ +import { + Github, + Linkedin, + Twitter, + Facebook, + Instagram, + Youtube, + Dribbble, + Figma, + FileText, + FileImage, + FileVideo, + FileAudio, + FileArchive, + FileSpreadsheet, + FileCode, + Mail, + Chrome, + Link2, +} from "lucide-react"; + +type IconMatcher = { + pattern: RegExp; + icon: typeof Github; +}; + +const SOCIAL_MEDIA_MATCHERS: IconMatcher[] = [ + { pattern: /github\.com/, icon: Github }, + { pattern: /linkedin\.com/, icon: Linkedin }, + { pattern: /(twitter\.com|x\.com)/, icon: Twitter }, + { pattern: /facebook\.com/, icon: Facebook }, + { pattern: /instagram\.com/, icon: Instagram }, + { pattern: /youtube\.com/, icon: Youtube }, + { pattern: /dribbble\.com/, icon: Dribbble }, +]; + +const PRODUCTIVITY_MATCHERS: IconMatcher[] = [ + { pattern: /figma\.com/, icon: Figma }, + { pattern: /(google\.com|docs\.|doc\.)/, icon: FileText }, +]; + +const FILE_TYPE_MATCHERS: IconMatcher[] = [ + { pattern: /\.(jpg|jpeg|png|gif|bmp|svg|webp)$/, icon: FileImage }, + { pattern: /\.(mp4|mov|avi|wmv|flv|mkv)$/, icon: FileVideo }, + { pattern: /\.(mp3|wav|ogg)$/, icon: FileAudio }, + { pattern: /\.(zip|rar|7z|tar|gz)$/, icon: FileArchive }, + { pattern: /\.(xls|xlsx|csv)$/, icon: FileSpreadsheet }, + { pattern: /\.(pdf|doc|docx|txt)$/, icon: FileText }, + { pattern: /\.(html|js|ts|jsx|tsx|css|scss)$/, icon: FileCode }, +]; + +const OTHER_MATCHERS: IconMatcher[] = [ + { pattern: /^mailto:/, icon: Mail }, + { pattern: /^http/, icon: Chrome }, +]; + +export const getIconForLink = (url: string) => { + const lowerUrl = url.toLowerCase(); + + const allMatchers = [...SOCIAL_MEDIA_MATCHERS, ...PRODUCTIVITY_MATCHERS, ...FILE_TYPE_MATCHERS, ...OTHER_MATCHERS]; + + const matchedIcon = allMatchers.find(({ pattern }) => pattern.test(lowerUrl)); + return matchedIcon?.icon ?? Link2; +}; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 57f10c5d4..765dce49d 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -12,3 +12,8 @@ export * from "./string"; export * from "./theme"; export * from "./workspace"; export * from "./work-item"; + +export * from "./get-icon-for-link"; + +export * from "./subscription"; + diff --git a/packages/utils/src/string.ts b/packages/utils/src/string.ts index 2fc52a254..19840df4d 100644 --- a/packages/utils/src/string.ts +++ b/packages/utils/src/string.ts @@ -55,13 +55,6 @@ export const createSimilarString = (str: string) => { return shuffled; }; -/** - * @description Copies full URL (origin + path) to clipboard - * @param {string} path - URL path to copy - * @returns {Promise} Promise that resolves when copying is complete - * @example - * await copyUrlToClipboard("issues/123") // copies "https://example.com/issues/123" - */ /** * @description Copies text to clipboard * @param {string} text - Text to copy @@ -86,8 +79,11 @@ export const copyTextToClipboard = async (text: string): Promise => { * await copyUrlToClipboard("issues/123") // copies "https://example.com/issues/123" */ export const copyUrlToClipboard = async (path: string) => { - const originUrl = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; - await copyTextToClipboard(`${originUrl}/${path}`); + // get origin or default to empty string if not in browser + const originUrl = typeof window !== "undefined" ? window.location.origin : ""; + // create URL object and ensure proper path formatting + const url = new URL(path, originUrl); + await copyTextToClipboard(url.toString()); }; /** diff --git a/packages/utils/src/subscription.ts b/packages/utils/src/subscription.ts new file mode 100644 index 000000000..207f387b7 --- /dev/null +++ b/packages/utils/src/subscription.ts @@ -0,0 +1,106 @@ +import orderBy from "lodash/orderBy"; +// plane imports +import { EProductSubscriptionEnum } from "@plane/constants"; +import { IPaymentProduct, TProductSubscriptionType, TSubscriptionPrice } from "@plane/types"; + +/** + * Calculates the yearly discount percentage when switching from monthly to yearly billing + * @param monthlyPrice - The monthly subscription price + * @param yearlyPricePerMonth - The monthly equivalent price when billed yearly + * @returns The discount percentage as a whole number (floored) + */ +export const calculateYearlyDiscount = (monthlyPrice: number, yearlyPricePerMonth: number): number => { + const monthlyCost = monthlyPrice * 12; + const yearlyCost = yearlyPricePerMonth * 12; + const amountSaved = monthlyCost - yearlyCost; + const discountPercentage = (amountSaved / monthlyCost) * 100; + return Math.floor(discountPercentage); +}; + +/** + * Gets the display name for a subscription plan variant + * @param planVariant - The subscription plan variant enum + * @returns The human-readable name of the plan + */ +export const getSubscriptionName = (planVariant: EProductSubscriptionEnum): string => { + switch (planVariant) { + case EProductSubscriptionEnum.FREE: + return "Free"; + case EProductSubscriptionEnum.ONE: + return "One"; + case EProductSubscriptionEnum.PRO: + return "Pro"; + case EProductSubscriptionEnum.BUSINESS: + return "Business"; + case EProductSubscriptionEnum.ENTERPRISE: + return "Enterprise"; + default: + return "--"; + } +}; + +/** + * Gets the base subscription name for upgrade/downgrade paths + * @param planVariant - The current subscription plan variant + * @param isSelfHosted - Whether the instance is self-hosted / community + * @returns The name of the base subscription plan + * + * @remarks + * - For self-hosted / community instances, the upgrade path differs from cloud instances + * - Returns the immediate lower tier subscription name + */ +export const getBaseSubscriptionName = (planVariant: TProductSubscriptionType, isSelfHosted: boolean): string => { + switch (planVariant) { + case EProductSubscriptionEnum.ONE: + return getSubscriptionName(EProductSubscriptionEnum.FREE); + case EProductSubscriptionEnum.PRO: + return isSelfHosted + ? getSubscriptionName(EProductSubscriptionEnum.ONE) + : getSubscriptionName(EProductSubscriptionEnum.FREE); + case EProductSubscriptionEnum.BUSINESS: + return getSubscriptionName(EProductSubscriptionEnum.PRO); + case EProductSubscriptionEnum.ENTERPRISE: + return getSubscriptionName(EProductSubscriptionEnum.BUSINESS); + default: + return "--"; + } +}; + +export type TSubscriptionPriceDetail = { + monthlyPriceDetails: TSubscriptionPrice; + yearlyPriceDetails: TSubscriptionPrice; +}; + +/** + * Gets the price details for a subscription product + * @param product - The payment product to get price details for + * @returns Array of price details for monthly and yearly plans + */ +export const getSubscriptionPriceDetails = (product: IPaymentProduct | undefined): TSubscriptionPriceDetail => { + const productPrices = product?.prices || []; + const monthlyPriceDetails = orderBy(productPrices, ["recurring"], ["desc"])?.find( + (price) => price.recurring === "month" + ); + const monthlyPriceAmount = Number(((monthlyPriceDetails?.unit_amount || 0) / 100).toFixed(2)); + const yearlyPriceDetails = orderBy(productPrices, ["recurring"], ["desc"])?.find( + (price) => price.recurring === "year" + ); + const yearlyPriceAmount = Number(((yearlyPriceDetails?.unit_amount || 0) / 1200).toFixed(2)); + + return { + monthlyPriceDetails: { + key: "monthly", + id: monthlyPriceDetails?.id, + currency: "$", + price: monthlyPriceAmount, + recurring: "month", + }, + yearlyPriceDetails: { + key: "yearly", + id: yearlyPriceDetails?.id, + currency: "$", + price: yearlyPriceAmount, + recurring: "year", + }, + }; +}; diff --git a/setup.sh b/setup.sh index 2376cb001..2dcaba80f 100755 --- a/setup.sh +++ b/setup.sh @@ -1,15 +1,89 @@ #!/bin/bash -cp ./.env.example ./.env -# Export for tr error in mac +# Plane Project Setup Script +# This script prepares the local development environment by setting up all necessary .env files +# https://github.com/makeplane/plane + +# Set colors for output messages +GREEN='\033[0;32m' +BLUE='\033[0;34m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +BOLD='\033[1m' +NC='\033[0m' # No Color + +# Print header +echo -e "${BOLD}${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo -e "${BOLD}${BLUE} Plane - Project Management Tool ${NC}" +echo -e "${BOLD}${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo -e "${BOLD}Setting up your development environment...${NC}\n" + +# Function to handle file copying with error checking +copy_env_file() { + local source=$1 + local destination=$2 + + if [ ! -f "$source" ]; then + echo -e "${RED}Error: Source file $source does not exist.${NC}" + return 1 + fi + + cp "$source" "$destination" + + if [ $? -eq 0 ]; then + echo -e "${GREEN}✓${NC} Copied $destination" + else + echo -e "${RED}✗${NC} Failed to copy $destination" + return 1 + fi +} + +# Export character encoding settings for macOS compatibility export LC_ALL=C export LC_CTYPE=C +echo -e "${YELLOW}Setting up environment files...${NC}" -cp ./web/.env.example ./web/.env -cp ./apiserver/.env.example ./apiserver/.env -cp ./space/.env.example ./space/.env -cp ./admin/.env.example ./admin/.env -cp ./live/.env.example ./live/.env +# Copy all environment example files +services=("" "web" "apiserver" "space" "admin" "live") +success=true -# Generate the SECRET_KEY that will be used by django -echo -e "\nSECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./apiserver/.env +for service in "${services[@]}"; do + prefix="./" + if [ "$service" != "" ]; then + prefix="./$service/" + fi + + copy_env_file "${prefix}.env.example" "${prefix}.env" || success=false +done + +# Generate SECRET_KEY for Django +if [ -f "./apiserver/.env" ]; then + echo -e "\n${YELLOW}Generating Django SECRET_KEY...${NC}" + SECRET_KEY=$(tr -dc 'a-z0-9' < /dev/urandom | head -c50) + + if [ -z "$SECRET_KEY" ]; then + echo -e "${RED}Error: Failed to generate SECRET_KEY.${NC}" + echo -e "${RED}Ensure 'tr' and 'head' commands are available on your system.${NC}" + success=false + else + echo -e "SECRET_KEY=\"$SECRET_KEY\"" >> ./apiserver/.env + echo -e "${GREEN}✓${NC} Added SECRET_KEY to apiserver/.env" + fi +else + echo -e "${RED}✗${NC} apiserver/.env not found. SECRET_KEY not added." + success=false +fi + +# Summary +echo -e "\n${YELLOW}Setup status:${NC}" +if [ "$success" = true ]; then + echo -e "${GREEN}✓${NC} Environment setup completed successfully!\n" + echo -e "${BOLD}Next steps:${NC}" + echo -e "1. Review the .env files in each folder if needed" + echo -e "2. Start the services with: ${BOLD}docker compose -f docker-compose-local.yml up -d${NC}" + echo -e "\n${GREEN}Happy coding! 🚀${NC}" +else + echo -e "${RED}✗${NC} Some issues occurred during setup. Please check the errors above.\n" + echo -e "For help, visit: ${BLUE}https://github.com/makeplane/plane${NC}" + exit 1 +fi diff --git a/space/core/components/account/oauth/oauth-options.tsx b/space/core/components/account/oauth/oauth-options.tsx index d514f1b68..153516b34 100644 --- a/space/core/components/account/oauth/oauth-options.tsx +++ b/space/core/components/account/oauth/oauth-options.tsx @@ -21,7 +21,7 @@ export const OAuthOptions: React.FC = observer(() => {
)} - {config?.is_github_enabled && } + {config?.is_github_enabled && } {config?.is_gitlab_enabled && }
diff --git a/space/core/components/editor/lite-text-read-only-editor.tsx b/space/core/components/editor/lite-text-read-only-editor.tsx index 5c8785e90..acb5cf14d 100644 --- a/space/core/components/editor/lite-text-read-only-editor.tsx +++ b/space/core/components/editor/lite-text-read-only-editor.tsx @@ -7,6 +7,8 @@ import { EditorMentionsRoot } from "@/components/editor"; // helpers import { cn } from "@/helpers/common.helper"; import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper"; +// store hooks +import { useMember } from "@/hooks/store"; type LiteTextReadOnlyEditorWrapperProps = MakeOptional< Omit, @@ -17,22 +19,29 @@ type LiteTextReadOnlyEditorWrapperProps = MakeOptional< }; export const LiteTextReadOnlyEditor = React.forwardRef( - ({ anchor, workspaceId, disabledExtensions, ...props }, ref) => ( - , - }} - {...props} - // overriding the customClassName to add relative class passed - containerClassName={cn(props.containerClassName, "relative p-2")} - /> - ) + ({ anchor, workspaceId, disabledExtensions, ...props }, ref) => { + const { getMemberById } = useMember(); + + return ( + , + getMentionedEntityDetails: (id: string) => ({ + display_name: getMemberById(id)?.member__display_name ?? "", + }), + }} + {...props} + // overriding the customClassName to add relative class passed + containerClassName={cn(props.containerClassName, "relative p-2")} + /> + ); + } ); LiteTextReadOnlyEditor.displayName = "LiteTextReadOnlyEditor"; diff --git a/space/core/components/editor/rich-text-editor.tsx b/space/core/components/editor/rich-text-editor.tsx index 682036f2a..00a9078ae 100644 --- a/space/core/components/editor/rich-text-editor.tsx +++ b/space/core/components/editor/rich-text-editor.tsx @@ -6,6 +6,8 @@ import { MakeOptional } from "@plane/types"; import { EditorMentionsRoot } from "@/components/editor"; // helpers import { getEditorFileHandlers } from "@/helpers/editor.helper"; +// store hooks +import { useMember } from "@/hooks/store"; interface RichTextEditorWrapperProps extends MakeOptional, "disabledExtensions"> { @@ -16,11 +18,14 @@ interface RichTextEditorWrapperProps export const RichTextEditor = forwardRef((props, ref) => { const { anchor, containerClassName, uploadFile, workspaceId, disabledExtensions, ...rest } = props; - + const { getMemberById } = useMember(); return ( , + getMentionedEntityDetails: (id: string) => ({ + display_name: getMemberById(id)?.member__display_name ?? "", + }), }} ref={ref} disabledExtensions={disabledExtensions ?? []} @@ -31,7 +36,8 @@ export const RichTextEditor = forwardRef ); }); diff --git a/space/core/components/editor/rich-text-read-only-editor.tsx b/space/core/components/editor/rich-text-read-only-editor.tsx index b989e1e41..f2d386629 100644 --- a/space/core/components/editor/rich-text-read-only-editor.tsx +++ b/space/core/components/editor/rich-text-read-only-editor.tsx @@ -7,6 +7,8 @@ import { EditorMentionsRoot } from "@/components/editor"; // helpers import { cn } from "@/helpers/common.helper"; import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper"; +// store hooks +import { useMember } from "@/hooks/store"; type RichTextReadOnlyEditorWrapperProps = MakeOptional< Omit, @@ -17,22 +19,29 @@ type RichTextReadOnlyEditorWrapperProps = MakeOptional< }; export const RichTextReadOnlyEditor = React.forwardRef( - ({ anchor, workspaceId, disabledExtensions, ...props }, ref) => ( - , - }} - {...props} - // overriding the customClassName to add relative class passed - containerClassName={cn("relative p-0 border-none", props.containerClassName)} - /> - ) + ({ anchor, workspaceId, disabledExtensions, ...props }, ref) => { + const { getMemberById } = useMember(); + + return ( + , + getMentionedEntityDetails: (id: string) => ({ + display_name: getMemberById(id)?.member__display_name ?? "", + }), + }} + {...props} + // overriding the customClassName to add relative class passed + containerClassName={cn("relative p-0 border-none", props.containerClassName)} + /> + ); + } ); RichTextReadOnlyEditor.displayName = "RichTextReadOnlyEditor"; diff --git a/space/google.d.ts b/space/google.d.ts deleted file mode 100644 index c37c83c94..000000000 --- a/space/google.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -// google.d.ts -interface GsiButtonConfiguration { - type: "standard" | "icon"; - theme?: "outline" | "filled_blue" | "filled_black"; - size?: "large" | "medium" | "small"; - text?: "signin_with" | "signup_with" | "continue_with" | "signup_with"; - shape?: "rectangular" | "pill" | "circle" | "square"; - logo_alignment?: "left" | "center"; - width?: number; - local?: string; -} diff --git a/space/instrumentation.ts b/space/instrumentation.ts deleted file mode 100644 index 7b89a972e..000000000 --- a/space/instrumentation.ts +++ /dev/null @@ -1,9 +0,0 @@ -export async function register() { - if (process.env.NEXT_RUNTIME === 'nodejs') { - await import('./sentry.server.config'); - } - - if (process.env.NEXT_RUNTIME === 'edge') { - await import('./sentry.edge.config'); - } -} diff --git a/space/next.config.js b/space/next.config.js index 58b6cfa0b..2d3e4e788 100644 --- a/space/next.config.js +++ b/space/next.config.js @@ -1,8 +1,4 @@ -// eslint-disable-next-line @typescript-eslint/no-var-requires -/* eslint-disable @typescript-eslint/no-var-requires */ /** @type {import('next').NextConfig} */ -require("dotenv").config({ path: ".env" }); -const { withSentryConfig } = require("@sentry/nextjs"); const nextConfig = { trailingSlash: true, @@ -27,45 +23,19 @@ const nextConfig = { ], unoptimized: true, }, + transpilePackages: [ + "@plane/constants", + "@plane/editor", + "@plane/hooks", + "@plane/i18n", + "@plane/logger", + "@plane/propel", + "@plane/services", + "@plane/shared-state", + "@plane/types", + "@plane/ui", + "@plane/utils", + ], }; -const sentryConfig = { - // For all available options, see: - // https://github.com/getsentry/sentry-webpack-plugin#options - - org: process.env.SENTRY_ORG_ID || "plane-hq", - project: process.env.SENTRY_PROJECT_ID || "plane-space", - authToken: process.env.SENTRY_AUTH_TOKEN, - // Only print logs for uploading source maps in CI - silent: true, - - // For all available options, see: - // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ - - // Upload a larger set of source maps for prettier stack traces (increases build time) - widenClientFileUpload: true, - - // Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers. - // This can increase your server load as well as your hosting bill. - // Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client- - // side errors will fail. - tunnelRoute: "/monitoring", - - // Hides source maps from generated client bundles - hideSourceMaps: true, - - // Automatically tree-shake Sentry logger statements to reduce bundle size - disableLogger: true, - - // Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.) - // See the following for more information: - // https://docs.sentry.io/product/crons/ - // https://vercel.com/docs/cron-jobs - automaticVercelMonitors: true, -}; - -if (parseInt(process.env.SENTRY_MONITORING_ENABLED || "0", 10)) { - module.exports = withSentryConfig(nextConfig, sentryConfig); -} else { - module.exports = nextConfig; -} +module.exports = nextConfig; diff --git a/space/package.json b/space/package.json index 8be06a5a8..c5a302664 100644 --- a/space/package.json +++ b/space/package.json @@ -1,6 +1,6 @@ { "name": "space", - "version": "0.25.3", + "version": "0.26.0", "private": true, "license": "AGPL-3.0", "scripts": { @@ -25,7 +25,6 @@ "@plane/types": "*", "@plane/ui": "*", "@plane/services": "*", - "@sentry/nextjs": "^8.54.0", "axios": "^1.8.3", "clsx": "^2.0.0", "date-fns": "^4.1.0", @@ -37,7 +36,7 @@ "mobx": "^6.10.0", "mobx-react": "^9.1.1", "mobx-utils": "^6.0.8", - "next": "^14.2.20", + "next": "^14.2.26", "next-themes": "^0.2.1", "nprogress": "^0.2.0", "react": "^18.3.1", diff --git a/space/sentry.client.config.ts b/space/sentry.client.config.ts deleted file mode 100644 index c81030622..000000000 --- a/space/sentry.client.config.ts +++ /dev/null @@ -1,31 +0,0 @@ -// This file configures the initialization of Sentry on the client. -// The config you add here will be used whenever a users loads a page in their browser. -// https://docs.sentry.io/platforms/javascript/guides/nextjs/ - -import * as Sentry from "@sentry/nextjs"; - -Sentry.init({ - dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, - environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT || "development", - - // Adjust this value in production, or use tracesSampler for greater control - tracesSampleRate: 1, - - // Setting this option to true will print useful information to the console while you're setting up Sentry. - debug: false, - - replaysOnErrorSampleRate: 1.0, - - // This sets the sample rate to be 10%. You may want this to be 100% while - // in development and sample at a lower rate in production - replaysSessionSampleRate: 0.1, - - // You can remove this option if you're not planning to use the Sentry Session Replay feature: - integrations: [ - Sentry.replayIntegration({ - // Additional Replay configuration goes in here, for example: - maskAllText: true, - blockAllMedia: true, - }), - ], -}); diff --git a/space/sentry.edge.config.ts b/space/sentry.edge.config.ts deleted file mode 100644 index 2dbc6e93a..000000000 --- a/space/sentry.edge.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on). -// The config you add here will be used whenever one of the edge features is loaded. -// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally. -// https://docs.sentry.io/platforms/javascript/guides/nextjs/ - -import * as Sentry from "@sentry/nextjs"; - -Sentry.init({ - dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, - environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT || "development", - - // Adjust this value in production, or use tracesSampler for greater control - tracesSampleRate: 1, - - // Setting this option to true will print useful information to the console while you're setting up Sentry. - debug: false, -}); diff --git a/space/sentry.properties b/space/sentry.properties deleted file mode 100644 index 1741152a3..000000000 --- a/space/sentry.properties +++ /dev/null @@ -1,3 +0,0 @@ -defaults.url=https://sentry.io/ -defaults.org=plane -defaults.project=plane-space diff --git a/space/sentry.server.config.ts b/space/sentry.server.config.ts deleted file mode 100644 index e578f1530..000000000 --- a/space/sentry.server.config.ts +++ /dev/null @@ -1,19 +0,0 @@ -// This file configures the initialization of Sentry on the server. -// The config you add here will be used whenever the server handles a request. -// https://docs.sentry.io/platforms/javascript/guides/nextjs/ - -import * as Sentry from "@sentry/nextjs"; - -Sentry.init({ - dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, - environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT || "development", - - // Adjust this value in production, or use tracesSampler for greater control - tracesSampleRate: 1, - - // Setting this option to true will print useful information to the console while you're setting up Sentry. - debug: false, - - // Uncomment the line below to enable Spotlight (https://spotlightjs.com) - // spotlight: process.env.NODE_ENV === 'development', -}); diff --git a/turbo.json b/turbo.json index 1113926ce..65f289b9c 100644 --- a/turbo.json +++ b/turbo.json @@ -18,13 +18,7 @@ "NEXT_PUBLIC_POSTHOG_KEY", "NEXT_PUBLIC_POSTHOG_HOST", "NEXT_PUBLIC_POSTHOG_DEBUG", - "NEXT_PUBLIC_SUPPORT_EMAIL", - "SENTRY_AUTH_TOKEN", - "SENTRY_ORG_ID", - "SENTRY_PROJECT_ID", - "NEXT_PUBLIC_SENTRY_ENVIRONMENT", - "NEXT_PUBLIC_SENTRY_DSN", - "SENTRY_MONITORING_ENABLED" + "NEXT_PUBLIC_SUPPORT_EMAIL" ], "tasks": { "build": { diff --git a/web/app/[workspaceSlug]/(projects)/extended-project-sidebar.tsx b/web/app/[workspaceSlug]/(projects)/extended-project-sidebar.tsx index c79a63237..b96f008ab 100644 --- a/web/app/[workspaceSlug]/(projects)/extended-project-sidebar.tsx +++ b/web/app/[workspaceSlug]/(projects)/extended-project-sidebar.tsx @@ -8,13 +8,12 @@ import { Plus, Search } from "lucide-react"; import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { setToast, TOAST_TYPE, Tooltip } from "@plane/ui"; -import { cn } from "@plane/utils"; +import { cn, copyUrlToClipboard } from "@plane/utils"; // components import { CreateProjectModal } from "@/components/project"; import { SidebarProjectsListItem } from "@/components/workspace"; // hooks import { orderJoinedProjects } from "@/helpers/project.helper"; -import { copyUrlToClipboard } from "@/helpers/string.helper"; import { useAppTheme, useProject, useUserPermissions } from "@/hooks/store"; import useExtendedSidebarOutsideClickDetector from "@/hooks/use-extended-sidebar-overview-outside-click"; import { TProject } from "@/plane-web/types"; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx index 381b567df..fbe8ed85d 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx @@ -26,7 +26,7 @@ const CycleDetailPage = observer(() => { const { getProjectById } = useProject(); // const { issuesFilter } = useIssues(EIssuesStoreType.CYCLE); // hooks - const { setValue, storedValue } = useLocalStorage("cycle_sidebar_collapsed", "false"); + const { setValue, storedValue } = useLocalStorage("cycle_sidebar_collapsed", false); useCyclesDetails({ workspaceSlug: workspaceSlug?.toString(), @@ -34,7 +34,7 @@ const CycleDetailPage = observer(() => { cycleId: cycleId.toString(), }); // derived values - const isSidebarCollapsed = storedValue ? (storedValue === "true" ? true : false) : false; + const isSidebarCollapsed = storedValue ? (storedValue === true ? true : false) : false; const cycle = cycleId ? getCycleById(cycleId.toString()) : undefined; const project = projectId ? getProjectById(projectId.toString()) : undefined; const pageTitle = project?.name && cycle?.name ? `${project?.name} - ${cycle?.name}` : undefined; @@ -42,7 +42,7 @@ const CycleDetailPage = observer(() => { /** * Toggles the sidebar */ - const toggleSidebar = () => setValue(`${!isSidebarCollapsed}`); + const toggleSidebar = () => setValue(!isSidebarCollapsed); // const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx index 508da58a2..ebe492584 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx @@ -1,11 +1,11 @@ "use client"; -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useParams } from "next/navigation"; // icons -import { ArrowRight, PanelRight } from "lucide-react"; +import { PanelRight } from "lucide-react"; // plane constants import { EIssueLayoutTypes, @@ -18,17 +18,22 @@ import { // i18n import { useTranslation } from "@plane/i18n"; // types -import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; +import { + ICustomSearchSelectOption, + IIssueDisplayFilterOptions, + IIssueDisplayProperties, + IIssueFilterOptions, +} from "@plane/types"; // ui -import { Breadcrumbs, Button, ContrastIcon, CustomMenu, Tooltip, Header } from "@plane/ui"; +import { Breadcrumbs, Button, ContrastIcon, Tooltip, Header, CustomSearchSelect } from "@plane/ui"; // components import { ProjectAnalyticsModal } from "@/components/analytics"; -import { BreadcrumbLink } from "@/components/common"; +import { BreadcrumbLink, SwitcherLabel } from "@/components/common"; +import { CycleQuickActions } from "@/components/cycles"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; // helpers import { cn } from "@/helpers/common.helper"; import { isIssueFilterActive } from "@/helpers/filter.helper"; -import { truncateText } from "@/helpers/string.helper"; // hooks import { useEventTracker, @@ -47,28 +52,9 @@ import { usePlatformOS } from "@/hooks/use-platform-os"; // plane web import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs"; -const CycleDropdownOption: React.FC<{ cycleId: string }> = ({ cycleId }) => { - // router - const { workspaceSlug, projectId } = useParams(); - // store hooks - const { getCycleById } = useCycle(); - // derived values - const cycle = getCycleById(cycleId); - // - - if (!cycle) return null; - - return ( - - - - {truncateText(cycle.name, 40)} - - - ); -}; - export const CycleIssuesHeader: React.FC = observer(() => { + // refs + const parentRef = useRef(null); // states const [analyticsModal, setAnalyticsModal] = useState(false); // router @@ -99,11 +85,11 @@ export const CycleIssuesHeader: React.FC = observer(() => { const activeLayout = issueFilters?.displayFilters?.layout; - const { setValue, storedValue } = useLocalStorage("cycle_sidebar_collapsed", "false"); + const { setValue, storedValue } = useLocalStorage("cycle_sidebar_collapsed", false); - const isSidebarCollapsed = storedValue ? (storedValue === "true" ? true : false) : false; + const isSidebarCollapsed = storedValue ? (storedValue === true ? true : false) : false; const toggleSidebar = () => { - setValue(`${!isSidebarCollapsed}`); + setValue(!isSidebarCollapsed); }; const handleLayoutChange = useCallback( @@ -159,7 +145,19 @@ export const CycleIssuesHeader: React.FC = observer(() => { EUserPermissionsLevel.PROJECT ); - const issuesCount = getGroupIssueCount(undefined, undefined, false); + const switcherOptions = currentProjectCycleIds + ?.map((id) => { + const _cycle = id === cycleId ? cycleDetails : getCycleById(id); + if (!_cycle) return; + return { + value: _cycle.id, + query: _cycle.name, + content: , + }; + }) + .filter((option) => option !== undefined) as ICustomSearchSelectOption[]; + + const workItemsCount = getGroupIssueCount(undefined, undefined, false); return ( <> @@ -201,39 +199,37 @@ export const CycleIssuesHeader: React.FC = observer(() => { { + router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${value}`); + }} label={ - <> - -
-

{cycleDetails?.name && cycleDetails.name}

- {issuesCount && issuesCount > 0 ? ( - 1 ? "work items" : "work item" - } in this cycle`} - position="bottom" - > - - {issuesCount} - - - ) : null} -
- +
+ + {workItemsCount && workItemsCount > 0 ? ( + 1 ? "work items" : "work item" + } in this cycle`} + position="bottom" + > + + {workItemsCount} + + + ) : null} +
} - className="ml-1.5 flex-shrink-0 truncate" - placement="bottom-start" - > - {currentProjectCycleIds?.map((cycleId) => )} - + /> } />
- +
{ )} +
-
diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx index 3c5eb7cc7..6bbbb29a0 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx @@ -1,11 +1,11 @@ "use client"; -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useParams } from "next/navigation"; // icons -import { ArrowRight, PanelRight } from "lucide-react"; +import { PanelRight } from "lucide-react"; // plane constants import { EIssueLayoutTypes, @@ -16,17 +16,22 @@ import { EUserPermissionsLevel, } from "@plane/constants"; // types -import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; +import { + ICustomSearchSelectOption, + IIssueDisplayFilterOptions, + IIssueDisplayProperties, + IIssueFilterOptions, +} from "@plane/types"; // ui -import { Breadcrumbs, Button, CustomMenu, DiceIcon, Tooltip, Header } from "@plane/ui"; +import { Breadcrumbs, Button, DiceIcon, Tooltip, Header, CustomSearchSelect } from "@plane/ui"; // components import { ProjectAnalyticsModal } from "@/components/analytics"; -import { BreadcrumbLink } from "@/components/common"; +import { BreadcrumbLink, SwitcherLabel } from "@/components/common"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; // helpers +import { ModuleQuickActions } from "@/components/modules"; import { cn } from "@/helpers/common.helper"; import { isIssueFilterActive } from "@/helpers/filter.helper"; -import { truncateText } from "@/helpers/string.helper"; // hooks import { useEventTracker, @@ -46,30 +51,9 @@ import { usePlatformOS } from "@/hooks/use-platform-os"; // plane web import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs"; -const ModuleDropdownOption: React.FC<{ moduleId: string }> = ({ moduleId }) => { - // router - const { workspaceSlug, projectId } = useParams(); - // store hooks - const { getModuleById } = useModule(); - // derived values - const moduleDetail = getModuleById(moduleId); - - if (!moduleDetail) return null; - - return ( - - - - {truncateText(moduleDetail.name, 40)} - - - ); -}; - export const ModuleIssuesHeader: React.FC = observer(() => { + // refs + const parentRef = useRef(null); // states const [analyticsModal, setAnalyticsModal] = useState(false); // router @@ -155,7 +139,19 @@ export const ModuleIssuesHeader: React.FC = observer(() => { EUserPermissionsLevel.PROJECT ); - const issuesCount = getGroupIssueCount(undefined, undefined, false); + const workItemsCount = getGroupIssueCount(undefined, undefined, false); + + const switcherOptions = projectModuleIds + ?.map((id) => { + const _module = id === moduleId ? moduleDetails : getModuleById(id); + if (!_module) return; + return { + value: _module.id, + query: _module.name, + content: , + }; + }) + .filter((option) => option !== undefined) as ICustomSearchSelectOption[]; return ( <> @@ -196,38 +192,36 @@ export const ModuleIssuesHeader: React.FC = observer(() => { - -
-

{moduleDetails?.name && moduleDetails.name}

- {issuesCount && issuesCount > 0 ? ( - 1 ? "work items" : "work item" - } in this module`} - position="bottom" - > - - {issuesCount} - - - ) : null} -
- +
+ + {workItemsCount && workItemsCount > 0 ? ( + 1 ? "work items" : "work item" + } in this module`} + position="bottom" + > + + {workItemsCount} + + + ) : null} +
} - className="ml-1.5 flex-shrink-0" - placement="bottom-start" - > - {projectModuleIds?.map((moduleId) => )} - + value={moduleId} + onChange={(value: string) => { + router.push(`/${workspaceSlug}/projects/${projectId}/modules/${value}`); + }} + /> } /> - +
{ )} + diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx index 2e45b9ba3..eacabf8f8 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx @@ -161,7 +161,6 @@ const PageDetailsPage = observer(() => { config={pageRootConfig} handlers={pageRootHandlers} page={page} - storeType={EPageStoreType.PROJECT} webhookConnectionParams={webhookConnectionParams} workspaceSlug={workspaceSlug?.toString() ?? ""} /> diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx index f8292f442..d939c6fe5 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx @@ -1,70 +1,63 @@ "use client"; - -import { useState } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { FileText } from "lucide-react"; // types -import { TLogoProps } from "@plane/types"; +import { ICustomSearchSelectOption } from "@plane/types"; // ui -import { Breadcrumbs, EmojiIconPicker, EmojiIconPickerTypes, TOAST_TYPE, Tooltip, setToast, Header } from "@plane/ui"; +import { Breadcrumbs, Header, CustomSearchSelect } from "@plane/ui"; // components -import { BreadcrumbLink, Logo } from "@/components/common"; -import { PageEditInformationPopover } from "@/components/pages"; +import { BreadcrumbLink, PageAccessIcon, SwitcherLabel } from "@/components/common"; +import { PageHeaderActions } from "@/components/pages/header/actions"; // helpers -import { convertHexEmojiToDecimal } from "@/helpers/emoji.helper"; import { getPageName } from "@/helpers/page.helper"; // hooks import { useProject } from "@/hooks/store"; -import { usePlatformOS } from "@/hooks/use-platform-os"; // plane web components +import { useAppRouter } from "@/hooks/use-app-router"; import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs"; import { PageDetailsHeaderExtraActions } from "@/plane-web/components/pages"; // plane web hooks -import { EPageStoreType, usePage } from "@/plane-web/hooks/store"; +import { EPageStoreType, usePage, usePageStore } from "@/plane-web/hooks/store"; export interface IPagesHeaderProps { showButton?: boolean; } +const storeType = EPageStoreType.PROJECT; + export const PageDetailsHeader = observer(() => { // router - const { workspaceSlug, pageId } = useParams(); - // state - const [isOpen, setIsOpen] = useState(false); + const router = useAppRouter(); + const { workspaceSlug, pageId, projectId } = useParams(); // store hooks const { currentProjectDetails, loader } = useProject(); + const { getPageById, getCurrentProjectPageIds } = usePageStore(storeType); const page = usePage({ pageId: pageId?.toString() ?? "", - storeType: EPageStoreType.PROJECT, + storeType, }); - if (!page) return null; // derived values - const { name, logo_props, updatePageLogo, isContentEditable } = page; - // use platform - const { isMobile } = usePlatformOS(); + const projectPageIds = getCurrentProjectPageIds(projectId?.toString()); - const handlePageLogoUpdate = async (data: TLogoProps) => { - if (data) { - updatePageLogo(data) - .then(() => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Success!", - message: "Logo Updated successfully.", - }); - }) - .catch(() => { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Something went wrong. Please try again.", - }); - }); - } - }; + const switcherOptions = projectPageIds + .map((id) => { + const _page = id === pageId ? page : getPageById(id); + if (!_page) return; + return { + value: _page.id, + query: _page.name, + content: ( +
+ + +
+ ), + }; + }) + .filter((option) => option !== undefined) as ICustomSearchSelectOption[]; - const pageTitle = getPageName(name); + if (!page) return null; return (
@@ -99,68 +92,26 @@ export const PageDetailsHeader = observer(() => { } /> -
-
-
- setIsOpen(val)} - className="flex items-center justify-center" - buttonClassName="flex items-center justify-center" - label={ - <> - {logo_props?.in_use ? ( - - ) : ( - - )} - - } - onChange={(val) => { - let logoValue = {}; - - if (val?.type === "emoji") - logoValue = { - value: convertHexEmojiToDecimal(val.value.unified), - url: val.value.imageUrl, - }; - else if (val?.type === "icon") logoValue = val.value; - - handlePageLogoUpdate({ - in_use: val?.type, - [val?.type]: logoValue, - }).finally(() => setIsOpen(false)); - }} - defaultIconColor={ - logo_props?.in_use && logo_props.in_use === "icon" ? logo_props?.icon?.color : undefined - } - defaultOpen={ - logo_props?.in_use && logo_props?.in_use === "emoji" - ? EmojiIconPickerTypes.EMOJI - : EmojiIconPickerTypes.ICON - } - disabled={!isContentEditable} - /> -
- -
- {pageTitle} -
-
-
-
- + type="component" + component={ + + } + onChange={(value: string) => { + router.push(`/${workspaceSlug}/projects/${projectId}/pages/${value}`); + }} + /> } />
- + ); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx index 3c6f18cf1..dacd61388 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx @@ -1,11 +1,22 @@ "use client"; // component +import { useParams } from "next/navigation"; +import useSWR from "swr"; import { AppHeader, ContentWrapper } from "@/components/core"; +// plane web hooks +import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store"; // local components import { PageDetailsHeader } from "./header"; export default function ProjectPageDetailsLayout({ children }: { children: React.ReactNode }) { + const { workspaceSlug, projectId } = useParams(); + const { fetchPagesList } = usePageStore(EPageStoreType.PROJECT); + // fetching pages list + useSWR( + workspaceSlug && projectId ? `PROJECT_PAGES_${projectId}` : null, + workspaceSlug && projectId ? () => fetchPagesList(workspaceSlug.toString(), projectId.toString()) : null + ); return ( <> } /> diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/members/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/members/page.tsx index 565e18754..9deaef126 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/members/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/members/page.tsx @@ -15,10 +15,12 @@ const MembersSettingsPage = observer(() => { const { workspaceUserInfo, allowPermissions } = useUserPermissions(); // derived values const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Members` : undefined; - const canPerformProjectMemberActions = allowPermissions( + const isProjectMemberOrAdmin = allowPermissions( [EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT ); + const isWorkspaceAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + const canPerformProjectMemberActions = isProjectMemberOrAdmin || isWorkspaceAdmin; if (workspaceUserInfo && !canPerformProjectMemberActions) { return ; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx index cc9a8fff6..3bc057479 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx @@ -2,7 +2,6 @@ import { useCallback, useRef } from "react"; import { observer } from "mobx-react"; -import Link from "next/link"; import { useParams } from "next/navigation"; import { Layers, Lock } from "lucide-react"; // plane constants @@ -16,17 +15,21 @@ import { EUserPermissionsLevel, } from "@plane/constants"; // types -import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; +import { + ICustomSearchSelectOption, + IIssueDisplayFilterOptions, + IIssueDisplayProperties, + IIssueFilterOptions, +} from "@plane/types"; // ui -import { Breadcrumbs, Button, CustomMenu, Tooltip, Header } from "@plane/ui"; +import { Breadcrumbs, Button, Tooltip, Header, CustomSearchSelect } from "@plane/ui"; // components -import { BreadcrumbLink, Logo } from "@/components/common"; +import { BreadcrumbLink, SwitcherLabel } from "@/components/common"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; // constants import { ViewQuickActions } from "@/components/views"; // helpers import { isIssueFilterActive } from "@/helpers/filter.helper"; -import { truncateText } from "@/helpers/string.helper"; // hooks import { useCommandPalette, @@ -40,6 +43,7 @@ import { useUserPermissions, } from "@/hooks/store"; // plane web +import { useAppRouter } from "@/hooks/use-app-router"; import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs"; export const ProjectViewIssuesHeader: React.FC = observer(() => { @@ -47,6 +51,7 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { const parentRef = useRef(null); // router const { workspaceSlug, projectId, viewId } = useParams(); + const router = useAppRouter(); // store hooks const { issuesFilter: { issueFilters, updateFilters }, @@ -143,6 +148,18 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { if (!viewDetails) return; + const switcherOptions = projectViewIds + ?.map((id) => { + const _view = id === viewId ? viewDetails : getViewById(id); + if (!_view) return; + return { + value: _view.id, + query: _view.name, + content: , + }; + }) + .filter((option) => option !== undefined) as ICustomSearchSelectOption[]; + return (
@@ -161,42 +178,14 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { - {viewDetails?.logo_props?.in_use ? ( - - ) : ( - - )} - {viewDetails?.name && truncateText(viewDetails.name, 40)} - - } - className="ml-1.5" - placement="bottom-start" - > - {projectViewIds?.map((viewId) => { - const view = getViewById(viewId); - - if (!view) return; - - return ( - - - {view?.logo_props?.in_use ? ( - - ) : ( - - )} - {truncateText(view.name, 40)} - - - ); - })} - + } + onChange={(value: string) => { + router.push(`/${workspaceSlug}/projects/${projectId}/views/${value}`); + }} + /> } /> @@ -210,17 +199,8 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { ) : ( <> )} - -
- -
- + {!viewDetails?.is_locked ? ( <> { ) : ( <> )} +
+ +
); diff --git a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/layout.tsx b/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/layout.tsx index 6dfe44ed6..e51106bfe 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/layout.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/layout.tsx @@ -3,7 +3,8 @@ import { FC, ReactNode } from "react"; import { observer } from "mobx-react"; // components -import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useParams, usePathname } from "next/navigation"; +import { EUserWorkspaceRoles, WORKSPACE_SETTINGS_ACCESS } from "@plane/constants"; import { NotAuthorizedView } from "@/components/auth-screens"; import { AppHeader } from "@/components/core"; // hooks @@ -21,17 +22,26 @@ export interface IWorkspaceSettingLayout { const WorkspaceSettingLayout: FC = observer((props) => { const { children } = props; - const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { workspaceUserInfo } = useUserPermissions(); + const pathname = usePathname(); + const [workspaceSlug, suffix, route] = pathname.replace(/^\/|\/$/g, "").split("/"); // Regex removes leading and trailing slashes // derived values - const isWorkspaceAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + const userWorkspaceRole = workspaceUserInfo?.[workspaceSlug.toString()]?.role; + const isAuthorized = + pathname && + workspaceSlug && + userWorkspaceRole && + WORKSPACE_SETTINGS_ACCESS[route ? `/${suffix}/${route}` : `/${suffix}`]?.includes( + userWorkspaceRole as EUserWorkspaceRoles + ); return ( <> } />
- {workspaceUserInfo && !isWorkspaceAdmin ? ( + {workspaceUserInfo && !isAuthorized ? ( ) : ( <> diff --git a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/mobile-header-tabs.tsx b/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/mobile-header-tabs.tsx index b699a1ff2..272862597 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/mobile-header-tabs.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/mobile-header-tabs.tsx @@ -20,7 +20,7 @@ export const MobileWorkspaceSettingsTabs = observer(() => {
{WORKSPACE_SETTINGS_LINKS.map( (item, index) => - shouldRenderSettingLink(item.key) && + shouldRenderSettingLink(workspaceSlug.toString(), item.key) && allowPermissions(item.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) && (
{
{WORKSPACE_SETTINGS_LINKS.map( (link) => - shouldRenderSettingLink(link.key) && + shouldRenderSettingLink(workspaceSlug.toString(), link.key) && allowPermissions(link.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) && ( { const { t } = useTranslation(); // store hooks const { captureEvent } = useEventTracker(); + const { config } = useInstance(); // hooks const { resolvedTheme } = useTheme(); // timer @@ -93,6 +94,9 @@ const ForgotPasswordPage = observer(() => { }); }; + // derived values + const enableSignUpConfig = config?.enable_signup ?? false; + const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo; return ( @@ -101,39 +105,41 @@ const ForgotPasswordPage = observer(() => {
Plane background pattern
-
-
-
+
+
+
Plane logo
-
- {t("auth.common.new_to_plane")} - captureEvent(NAVIGATE_TO_SIGNUP, {})} - className="font-semibold text-custom-primary-100 hover:underline" - > - {t("auth.common.create_account")} - -
+ {enableSignUpConfig && ( +
+ {t("auth.common.new_to_plane")} + captureEvent(NAVIGATE_TO_SIGNUP, {})} + className="font-semibold text-custom-primary-100 hover:underline" + > + {t("auth.common.create_account")} + +
+ )}
-
+
-
-

+
+

{t("auth.forgot_password.title")}

{t("auth.forgot_password.description")}

-

))}
diff --git a/web/core/components/pages/dropdowns/edit-information-popover.tsx b/web/core/components/pages/dropdowns/edit-information-popover.tsx deleted file mode 100644 index 9157c9f91..000000000 --- a/web/core/components/pages/dropdowns/edit-information-popover.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { observer } from "mobx-react"; -// helpers -import { calculateTimeAgoShort } from "@/helpers/date-time.helper"; -// store types -import { TPageInstance } from "@/store/pages/base-page"; - -type Props = { - page: TPageInstance; -}; - -export const PageEditInformationPopover: React.FC = observer((props) => { - const { page } = props; - - return ( -
- Edited {calculateTimeAgoShort(page.updated_at ?? "")} ago -
- ); -}); diff --git a/web/core/components/pages/dropdowns/index.ts b/web/core/components/pages/dropdowns/index.ts index 74ebad1d6..8ff5a89af 100644 --- a/web/core/components/pages/dropdowns/index.ts +++ b/web/core/components/pages/dropdowns/index.ts @@ -1,2 +1 @@ export * from "./actions"; -export * from "./edit-information-popover"; diff --git a/web/core/components/pages/editor/editor-body.tsx b/web/core/components/pages/editor/editor-body.tsx index 72533c3dc..5f1948183 100644 --- a/web/core/components/pages/editor/editor-body.tsx +++ b/web/core/components/pages/editor/editor-body.tsx @@ -1,6 +1,6 @@ import { Dispatch, SetStateAction, useCallback, useMemo } from "react"; import { observer } from "mobx-react"; -// document-editor +// plane imports import { CollaborativeDocumentEditorWithRef, EditorRefApi, @@ -10,19 +10,18 @@ import { TRealtimeConfig, TServerHandler, } from "@plane/editor"; -// plane types import { TSearchEntityRequestPayload, TSearchResponse, TWebhookConnectionQueryParams } from "@plane/types"; -// plane ui -import { Row } from "@plane/ui"; +import { ERowVariant, Row } from "@plane/ui"; +import { cn } from "@plane/utils"; // components import { EditorMentionsRoot } from "@/components/editor"; import { PageContentBrowser, PageContentLoader, PageEditorTitle } from "@/components/pages"; // helpers -import { cn, LIVE_BASE_PATH, LIVE_BASE_URL } from "@/helpers/common.helper"; +import { LIVE_BASE_PATH, LIVE_BASE_URL } from "@/helpers/common.helper"; import { generateRandomColor } from "@/helpers/string.helper"; // hooks import { useEditorMention } from "@/hooks/editor"; -import { useUser, useWorkspace } from "@/hooks/store"; +import { useUser, useWorkspace, useMember } from "@/hooks/store"; import { usePageFilters } from "@/hooks/use-page-filters"; // plane web components import { EditorAIMenu } from "@/plane-web/components/pages"; @@ -31,6 +30,8 @@ import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging"; import { useIssueEmbed } from "@/plane-web/hooks/use-issue-embed"; // store import { TPageInstance } from "@/store/pages/base-page"; +// local imports +import { PageEditorHeaderRoot } from "./header"; export type TEditorBodyConfig = { fileHandler: TFileHandler; @@ -42,13 +43,12 @@ export type TEditorBodyHandlers = { type Props = { config: TEditorBodyConfig; - editorRef: React.RefObject; editorReady: boolean; + editorForwardRef: React.RefObject; handleConnectionStatus: Dispatch>; - handleEditorReady: Dispatch>; + handleEditorReady: (status: boolean) => void; handlers: TEditorBodyHandlers; page: TPageInstance; - sidePeekVisible: boolean; webhookConnectionParams: TWebhookConnectionQueryParams; workspaceSlug: string; }; @@ -56,20 +56,21 @@ type Props = { export const PageEditorBody: React.FC = observer((props) => { const { config, - editorRef, + editorForwardRef, handleConnectionStatus, handleEditorReady, handlers, page, - sidePeekVisible, webhookConnectionParams, workspaceSlug, } = props; // store hooks const { data: currentUser } = useUser(); const { getWorkspaceBySlug } = useWorkspace(); + const { getUserDetails } = useMember(); + // derived values - const { id: pageId, name: pageTitle, isContentEditable, updateTitle } = page; + const { id: pageId, name: pageTitle, isContentEditable, updateTitle, editorRef } = page; const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id ?? ""; // issue-embed const { issueEmbedProps } = useIssueEmbed({ @@ -89,8 +90,9 @@ export const PageEditorBody: React.FC = observer((props) => { () => ({ fontSize, fontStyle, + wideLayout: isFullWidth, }), - [fontSize, fontStyle] + [fontSize, fontStyle, isFullWidth] ); const getAIMenu = useCallback( @@ -150,68 +152,74 @@ export const PageEditorBody: React.FC = observer((props) => { [currentUser?.display_name, currentUser?.id] ); - if (pageId === undefined || !realtimeConfig) return ; + const blockWidthClassName = cn( + "block bg-transparent w-full max-w-[720px] mx-auto transition-all duration-200 ease-in-out", + { + "max-w-[1152px]": isFullWidth, + } + ); + + if (pageId === undefined || !realtimeConfig) return ; return ( -
- -
-
- - { - const res = await fetchMentions(query); - if (!res) throw new Error("Failed in fetching mentions"); - return res; - }, - renderComponent: (props) => , - }} - embedHandler={{ - issue: issueEmbedProps, - }} - realtimeConfig={realtimeConfig} - serverHandler={serverHandler} - user={userConfig} - disabledExtensions={disabledExtensions} - aiHandler={{ - menu: getAIMenu, - }} - /> + +
+ {/* table of content */} +
+
+
+
+ +
+
+ +
+
+
+
+
+ + +
+
+ { + const res = await fetchMentions(query); + if (!res) throw new Error("Failed in fetching mentions"); + return res; + }, + renderComponent: (props) => , + getMentionedEntityDetails: (id: string) => ({ display_name: getUserDetails(id)?.display_name ?? "" }), + }} + embedHandler={{ + issue: issueEmbedProps, + }} + realtimeConfig={realtimeConfig} + serverHandler={serverHandler} + user={userConfig} + disabledExtensions={disabledExtensions} + aiHandler={{ + menu: getAIMenu, + }} + />
-
-
+
); }); diff --git a/web/core/components/pages/editor/header/extra-options.tsx b/web/core/components/pages/editor/header/extra-options.tsx deleted file mode 100644 index 0b81d1452..000000000 --- a/web/core/components/pages/editor/header/extra-options.tsx +++ /dev/null @@ -1,103 +0,0 @@ -"use client"; - -import { observer } from "mobx-react"; -// constants -import { IS_FAVORITE_MENU_OPEN } from "@plane/constants"; -// editor -import { EditorRefApi } from "@plane/editor"; -// plane hooks -import { useLocalStorage } from "@plane/hooks"; -// ui -import { ArchiveIcon, FavoriteStar, setToast, TOAST_TYPE, Tooltip } from "@plane/ui"; -// components -import { LockedComponent } from "@/components/icons/locked-component"; -import { PageInfoPopover, PageOptionsDropdown } from "@/components/pages"; -// helpers -import { renderFormattedDate } from "@/helpers/date-time.helper"; -// hooks -import useOnlineStatus from "@/hooks/use-online-status"; -// plane web hooks -import { EPageStoreType } from "@/plane-web/hooks/store"; -// store -import { TPageInstance } from "@/store/pages/base-page"; - -type Props = { - editorRef: EditorRefApi; - page: TPageInstance; - storeType: EPageStoreType; -}; - -export const PageExtraOptions: React.FC = observer((props) => { - const { editorRef, page, storeType } = props; - // derived values - const { - archived_at, - isContentEditable, - is_favorite, - is_locked, - canCurrentUserFavoritePage, - addToFavorites, - removePageFromFavorites, - } = page; - // use online status - const { isOnline } = useOnlineStatus(); - // local storage - const { setValue: toggleFavoriteMenu, storedValue: isFavoriteMenuOpen } = useLocalStorage( - IS_FAVORITE_MENU_OPEN, - false - ); - // favorite handler - const handleFavorite = () => { - if (is_favorite) { - removePageFromFavorites().then(() => - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Success!", - message: "Page removed from favorites.", - }) - ); - } else { - addToFavorites().then(() => { - if (!isFavoriteMenuOpen) toggleFavoriteMenu(true); - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Success!", - message: "Page added to favorites.", - }); - }); - } - }; - - return ( -
- {is_locked && } - {archived_at && ( -
- - Archived at {renderFormattedDate(archived_at)} -
- )} - {isContentEditable && !isOnline && ( - -
- - Offline -
-
- )} - {canCurrentUserFavoritePage && ( - - )} - - -
- ); -}); diff --git a/web/core/components/pages/editor/header/index.ts b/web/core/components/pages/editor/header/index.ts index d87f5d119..1efe34c51 100644 --- a/web/core/components/pages/editor/header/index.ts +++ b/web/core/components/pages/editor/header/index.ts @@ -1,7 +1 @@ -export * from "./color-dropdown"; -export * from "./extra-options"; -export * from "./info-popover"; -export * from "./options-dropdown"; export * from "./root"; -export * from "./mobile-root"; -export * from "./toolbar"; diff --git a/web/core/components/pages/editor/header/logo-picker.tsx b/web/core/components/pages/editor/header/logo-picker.tsx new file mode 100644 index 000000000..677046675 --- /dev/null +++ b/web/core/components/pages/editor/header/logo-picker.tsx @@ -0,0 +1,53 @@ +import { useState } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { EmojiIconPicker, EmojiIconPickerTypes } from "@plane/ui"; +import { cn } from "@plane/utils"; +// components +import { Logo } from "@/components/common"; +// store +import { TPageInstance } from "@/store/pages/base-page"; + +type Props = { + className?: string; + page: TPageInstance; +}; + +export const PageEditorHeaderLogoPicker: React.FC = observer((props) => { + const { className, page } = props; + // states + const [isLogoPickerOpen, setIsLogoPickerOpen] = useState(false); + // derived values + const { logo_props, isContentEditable, updatePageLogo } = page; + const isLogoSelected = !!logo_props?.in_use; + + return ( +
+ setIsLogoPickerOpen(val)} + className="flex items-center justify-center" + buttonClassName="flex items-center justify-center" + label={ +
+ {isLogoSelected && } +
+ } + onChange={updatePageLogo} + defaultIconColor={logo_props?.in_use && logo_props.in_use === "icon" ? logo_props?.icon?.color : undefined} + defaultOpen={ + logo_props?.in_use && logo_props?.in_use === "emoji" ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON + } + disabled={!isContentEditable} + /> +
+ ); +}); diff --git a/web/core/components/pages/editor/header/mobile-root.tsx b/web/core/components/pages/editor/header/mobile-root.tsx deleted file mode 100644 index 22f25e254..000000000 --- a/web/core/components/pages/editor/header/mobile-root.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { observer } from "mobx-react"; -import { EditorRefApi } from "@plane/editor"; -// components -import { Header, EHeaderVariant } from "@plane/ui"; -import { PageExtraOptions, PageSummaryPopover, PageToolbar } from "@/components/pages"; -// hooks -import { usePageFilters } from "@/hooks/use-page-filters"; -// plane web hooks -import { EPageStoreType } from "@/plane-web/hooks/store"; -// store -import { TPageInstance } from "@/store/pages/base-page"; - -type Props = { - editorRef: EditorRefApi; - page: TPageInstance; - setSidePeekVisible: (sidePeekState: boolean) => void; - sidePeekVisible: boolean; - storeType: EPageStoreType; -}; - -export const PageEditorMobileHeaderRoot: React.FC = observer((props) => { - const { editorRef, page, setSidePeekVisible, sidePeekVisible, storeType } = props; - // derived values - const { isContentEditable } = page; - // page filters - const { isFullWidth } = usePageFilters(); - - return ( - <> -
-
- -
- -
-
- {isContentEditable && editorRef && } -
- - ); -}); diff --git a/web/core/components/pages/editor/header/root.tsx b/web/core/components/pages/editor/header/root.tsx index 552ba6009..e8dd98e9a 100644 --- a/web/core/components/pages/editor/header/root.tsx +++ b/web/core/components/pages/editor/header/root.tsx @@ -1,71 +1,70 @@ +import { useState } from "react"; import { observer } from "mobx-react"; -import { EditorRefApi } from "@plane/editor"; -// components -import { Header, EHeaderVariant } from "@plane/ui"; -import { PageEditorMobileHeaderRoot, PageExtraOptions, PageSummaryPopover, PageToolbar } from "@/components/pages"; -// helpers -import { cn } from "@/helpers/common.helper"; -// hooks -import { usePageFilters } from "@/hooks/use-page-filters"; -// plane web hooks -import { EPageStoreType } from "@/plane-web/hooks/store"; +import { SmilePlus } from "lucide-react"; +// plane imports +import { EmojiIconPicker, EmojiIconPickerTypes } from "@plane/ui"; +import { cn } from "@plane/utils"; // store import { TPageInstance } from "@/store/pages/base-page"; +// local imports +import { PageEditorHeaderLogoPicker } from "./logo-picker"; type Props = { - editorReady: boolean; - editorRef: React.RefObject; page: TPageInstance; - setSidePeekVisible: (sidePeekState: boolean) => void; - sidePeekVisible: boolean; - storeType: EPageStoreType; }; export const PageEditorHeaderRoot: React.FC = observer((props) => { - const { editorReady, editorRef, page, setSidePeekVisible, sidePeekVisible, storeType } = props; + const { page } = props; + // states + const [isLogoPickerOpen, setIsLogoPickerOpen] = useState(false); // derived values - const { isContentEditable } = page; - // page filters - const { isFullWidth, isStickyToolbarEnabled } = usePageFilters(); - // derived values - const resolvedEditorRef = editorRef.current; - - if (!resolvedEditorRef) return null; + const { isContentEditable, logo_props, name, updatePageLogo } = page; + const isLogoSelected = !!logo_props?.in_use; + const isTitleEmpty = !name || name.trim() === ""; return ( <> -
- - {editorReady && ( -
- -
- )} - {isStickyToolbarEnabled && editorReady && isContentEditable && editorRef.current && ( - - )} -
- -
-
- +
+ {!isLogoSelected && ( +
+ setIsLogoPickerOpen(val)} + className="flex items-center justify-center" + buttonClassName="flex items-center justify-center" + label={ + + } + onChange={updatePageLogo} + defaultIconColor={ + logo_props?.in_use && logo_props.in_use === "icon" ? logo_props?.icon?.color : undefined + } + defaultOpen={ + logo_props?.in_use && logo_props?.in_use === "emoji" + ? EmojiIconPickerTypes.EMOJI + : EmojiIconPickerTypes.ICON + } + disabled={!isContentEditable} + /> +
+ )}
+ ); }); diff --git a/web/core/components/pages/editor/index.ts b/web/core/components/pages/editor/index.ts index 02f264301..0c3912e09 100644 --- a/web/core/components/pages/editor/index.ts +++ b/web/core/components/pages/editor/index.ts @@ -1,5 +1,5 @@ -export * from "./header"; -export * from "./summary"; export * from "./editor-body"; -export * from "./title"; export * from "./page-root"; +export * from "./summary"; +export * from "./title"; +export * from "./toolbar"; diff --git a/web/core/components/pages/editor/page-root.tsx b/web/core/components/pages/editor/page-root.tsx index c80a52d43..2f1595e33 100644 --- a/web/core/components/pages/editor/page-root.tsx +++ b/web/core/components/pages/editor/page-root.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { observer } from "mobx-react"; import { useSearchParams } from "next/navigation"; // editor @@ -7,7 +7,7 @@ import { EditorRefApi } from "@plane/editor"; import { TDocumentPayload, TPage, TPageVersion, TWebhookConnectionQueryParams } from "@plane/types"; // components import { - PageEditorHeaderRoot, + PageEditorToolbarRoot, PageEditorBody, PageVersionsOverlay, PagesVersionEditor, @@ -18,8 +18,6 @@ import { import { useAppRouter } from "@/hooks/use-app-router"; import { usePageFallback } from "@/hooks/use-page-fallback"; import { useQueryParams } from "@/hooks/use-query-params"; -// plane web hooks -import { EPageStoreType } from "@/plane-web/hooks/store"; // store import { TPageInstance } from "@/store/pages/base-page"; @@ -38,17 +36,15 @@ type TPageRootProps = { config: TPageRootConfig; handlers: TPageRootHandlers; page: TPageInstance; - storeType: EPageStoreType; webhookConnectionParams: TWebhookConnectionQueryParams; workspaceSlug: string; }; export const PageRoot = observer((props: TPageRootProps) => { - const { config, handlers, page, storeType, webhookConnectionParams, workspaceSlug } = props; + const { config, handlers, page, webhookConnectionParams, workspaceSlug } = props; // states const [editorReady, setEditorReady] = useState(false); const [hasConnectionFailed, setHasConnectionFailed] = useState(false); - const [sidePeekVisible, setSidePeekVisible] = useState(window.innerWidth >= 768); const [isVersionsOverlayOpen, setIsVersionsOverlayOpen] = useState(false); // refs const editorRef = useRef(null); @@ -57,7 +53,7 @@ export const PageRoot = observer((props: TPageRootProps) => { // search params const searchParams = useSearchParams(); // derived values - const { isContentEditable } = page; + const { isContentEditable, setEditorRef } = page; // page fallback usePageFallback({ editorRef, @@ -68,6 +64,22 @@ export const PageRoot = observer((props: TPageRootProps) => { // update query params const { updateQueryParams } = useQueryParams(); + const handleEditorReady = useCallback( + (status: boolean) => { + setEditorReady(status); + if (editorRef.current && !page.editorRef) { + setEditorRef(editorRef.current); + } + }, + [page.editorRef, setEditorRef] + ); + + useEffect(() => { + setTimeout(() => { + setEditorRef(editorRef.current); + }, 0); + }, [isContentEditable, setEditorRef]); + const version = searchParams.get("version"); useEffect(() => { if (!version) { @@ -90,6 +102,14 @@ export const PageRoot = observer((props: TPageRootProps) => { }; const currentVersionDescription = editorRef.current?.getDocument().html; + // reset editor ref on unmount + useEffect( + () => () => { + setEditorRef(null); + }, + [setEditorRef] + ); + return ( <> { pageId={page.id ?? ""} restoreEnabled={isContentEditable} /> - setSidePeekVisible(state)} - sidePeekVisible={sidePeekVisible} - storeType={storeType} - /> + diff --git a/web/core/components/pages/editor/summary/content-browser.tsx b/web/core/components/pages/editor/summary/content-browser.tsx index 16d818aae..e0ef27116 100644 --- a/web/core/components/pages/editor/summary/content-browser.tsx +++ b/web/core/components/pages/editor/summary/content-browser.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; // plane editor import { EditorRefApi, IMarking } from "@plane/editor"; // components @@ -7,10 +7,11 @@ import { OutlineHeading1, OutlineHeading2, OutlineHeading3 } from "./heading-com type Props = { editorRef: EditorRefApi | null; setSidePeekVisible?: (sidePeekState: boolean) => void; + showOutline?: boolean; }; export const PageContentBrowser: React.FC = (props) => { - const { editorRef, setSidePeekVisible } = props; + const { editorRef, setSidePeekVisible, showOutline = false } = props; // states const [headings, setHeadings] = useState([]); @@ -23,10 +24,13 @@ export const PageContentBrowser: React.FC = (props) => { }; }, [editorRef]); - const handleOnClick = (marking: IMarking) => { - editorRef?.scrollSummary(marking); - if (setSidePeekVisible) setSidePeekVisible(false); - }; + const handleOnClick = useCallback( + (marking: IMarking) => { + editorRef?.scrollSummary(marking); + setSidePeekVisible?.(false); + }, + [editorRef, setSidePeekVisible] + ); const HeadingComponent: { [key: number]: React.FC<{ marking: IMarking; onClick: () => void }>; @@ -37,24 +41,28 @@ export const PageContentBrowser: React.FC = (props) => { }; return ( -
-
- {headings && headings.length !== 0 ? ( - headings.map((marking) => { - const Component = HeadingComponent[marking.level]; - if (!Component) return null; - return ( - handleOnClick(marking)} - /> - ); - }) - ) : ( -

Headings will be displayed here for navigation

- )} -
+
+ {headings.map((marking) => { + const Component = HeadingComponent[marking.level]; + if (!Component) return null; + if (showOutline === true) + return ( +
+ ); + return ( + handleOnClick(marking)} + /> + ); + })}
); }; diff --git a/web/core/components/pages/editor/summary/heading-components.tsx b/web/core/components/pages/editor/summary/heading-components.tsx index 5ed1752de..c2e78dd67 100644 --- a/web/core/components/pages/editor/summary/heading-components.tsx +++ b/web/core/components/pages/editor/summary/heading-components.tsx @@ -1,36 +1,36 @@ -// document editor -import { IMarking } from "@plane/editor"; +// plane editor +import type { IMarking } from "@plane/editor"; -type HeadingProps = { +export type THeadingComponentProps = { marking: IMarking; onClick: (event: React.MouseEvent) => void; }; -export const OutlineHeading1 = ({ marking, onClick }: HeadingProps) => ( +export const OutlineHeading1 = ({ marking, onClick }: THeadingComponentProps) => ( ); -export const OutlineHeading2 = ({ marking, onClick }: HeadingProps) => ( +export const OutlineHeading2 = ({ marking, onClick }: THeadingComponentProps) => ( ); -export const OutlineHeading3 = ({ marking, onClick }: HeadingProps) => ( +export const OutlineHeading3 = ({ marking, onClick }: THeadingComponentProps) => ( diff --git a/web/core/components/pages/editor/summary/index.ts b/web/core/components/pages/editor/summary/index.ts index 3c4afb4d8..779b78548 100644 --- a/web/core/components/pages/editor/summary/index.ts +++ b/web/core/components/pages/editor/summary/index.ts @@ -1,2 +1 @@ export * from "./content-browser"; -export * from "./popover"; diff --git a/web/core/components/pages/editor/summary/popover.tsx b/web/core/components/pages/editor/summary/popover.tsx deleted file mode 100644 index 9acc4a7cc..000000000 --- a/web/core/components/pages/editor/summary/popover.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { useState } from "react"; -import { usePopper } from "react-popper"; -import { List } from "lucide-react"; -// document editor -import { EditorRefApi } from "@plane/editor"; -// helpers -import { cn } from "@/helpers/common.helper"; -// components -import { PageContentBrowser } from "./content-browser"; - -type Props = { - editorRef: EditorRefApi | null; - isFullWidth: boolean; - sidePeekVisible: boolean; - setSidePeekVisible: (sidePeekState: boolean) => void; -}; - -export const PageSummaryPopover: React.FC = (props) => { - const { editorRef, sidePeekVisible, setSidePeekVisible } = props; - // refs - const [referenceElement, setReferenceElement] = useState(null); - const [popperElement, setPopperElement] = useState(null); - // popper-js - const { styles: summaryPopoverStyles, attributes: summaryPopoverAttributes } = usePopper( - referenceElement, - popperElement, - { - placement: "bottom-start", - } - ); - - return ( -
- -
- {sidePeekVisible && ( -
- -
- )} -
-
- {!sidePeekVisible && ( -
- -
- )} -
-
- ); -}; diff --git a/web/core/components/pages/editor/title.tsx b/web/core/components/pages/editor/title.tsx index 9e85c33d3..5864ac5d9 100644 --- a/web/core/components/pages/editor/title.tsx +++ b/web/core/components/pages/editor/title.tsx @@ -13,7 +13,7 @@ import { getPageName } from "@/helpers/page.helper"; import { usePageFilters } from "@/hooks/use-page-filters"; type Props = { - editorRef: React.RefObject; + editorRef: EditorRefApi | null; readOnly: boolean; title: string | undefined; updateTitle: (title: string) => void; @@ -26,34 +26,34 @@ export const PageEditorTitle: React.FC = observer((props) => { // page filters const { fontSize } = usePageFilters(); // ui - const titleClassName = cn("bg-transparent tracking-[-2%] font-bold", { + const titleFontClassName = cn("tracking-[-2%] font-bold", { "text-[1.6rem] leading-[1.9rem]": fontSize === "small-font", "text-[2rem] leading-[2.375rem]": fontSize === "large-font", }); return ( -
+
{readOnly ? (
{getPageName(title)}
) : ( - <> +