Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 57646a27e0 |
+1
-1
@@ -4,7 +4,7 @@ Thank you for showing an interest in contributing to Plane! All kinds of contrib
|
||||
|
||||
## Submitting an issue
|
||||
|
||||
Before submitting a new issue, please search the [issues](https://github.com/makeplane/plane/issues) tab. Maybe an issue or discussion already exists and might inform you of workarounds. Otherwise, you can give new information.
|
||||
Before submitting a new issue, please search the [issues](https://github.com/makeplane/plane/issues) tab. Maybe an issue or discussion already exists and might inform you of workarounds. Otherwise, you can give new informplaneation.
|
||||
|
||||
While we want to fix all the [issues](https://github.com/makeplane/plane/issues), before fixing a bug we need to be able to reproduce and confirm it. Please provide us with a minimal reproduction scenario using a repository or [Gist](https://gist.github.com/). Having a live, reproducible scenario gives us the information without asking questions back & forth with additional questions like:
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"version": "0.23.0",
|
||||
"version": "0.22.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo run develop",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"name": "plane-api",
|
||||
"version": "0.23.0"
|
||||
"version": "0.22.0"
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ urlpatterns = [
|
||||
),
|
||||
# updated v2 paginated issues
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/v2/issues/",
|
||||
"workspaces/<str:slug>/v2/issues/",
|
||||
IssuePaginatedViewSet.as_view({"get": "list"}),
|
||||
name="project-issues-paginated",
|
||||
),
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import random
|
||||
import string
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
@@ -23,7 +19,6 @@ from plane.app.serializers import (
|
||||
EstimateReadSerializer,
|
||||
)
|
||||
from plane.utils.cache import invalidate_cache
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
|
||||
|
||||
def generate_random_name(length=10):
|
||||
@@ -254,66 +249,11 @@ class EstimatePointEndpoint(BaseViewSet):
|
||||
)
|
||||
# update all the issues with the new estimate
|
||||
if new_estimate_id:
|
||||
issues = Issue.objects.filter(
|
||||
_ = Issue.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
estimate_point_id=estimate_point_id,
|
||||
)
|
||||
for issue in issues:
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
requested_data=json.dumps(
|
||||
{
|
||||
"estimate_point": (
|
||||
str(new_estimate_id)
|
||||
if new_estimate_id
|
||||
else None
|
||||
),
|
||||
}
|
||||
),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=issue.id,
|
||||
project_id=str(project_id),
|
||||
current_instance=json.dumps(
|
||||
{
|
||||
"estimate_point": (
|
||||
str(issue.estimate_point_id)
|
||||
if issue.estimate_point_id
|
||||
else None
|
||||
),
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
)
|
||||
issues.update(estimate_point_id=new_estimate_id)
|
||||
else:
|
||||
issues = Issue.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
estimate_point_id=estimate_point_id,
|
||||
)
|
||||
for issue in issues:
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
requested_data=json.dumps(
|
||||
{
|
||||
"estimate_point": None,
|
||||
}
|
||||
),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=issue.id,
|
||||
project_id=str(project_id),
|
||||
current_instance=json.dumps(
|
||||
{
|
||||
"estimate_point": (
|
||||
str(issue.estimate_point_id)
|
||||
if issue.estimate_point_id
|
||||
else None
|
||||
),
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
)
|
||||
).update(estimate_point_id=new_estimate_id)
|
||||
|
||||
# delete the estimate point
|
||||
old_estimate_point = EstimatePoint.objects.filter(
|
||||
|
||||
@@ -741,12 +741,17 @@ class DeletedIssuesListViewSet(BaseAPIView):
|
||||
class IssuePaginatedViewSet(BaseViewSet):
|
||||
def get_queryset(self):
|
||||
workspace_slug = self.kwargs.get("slug")
|
||||
project_id = self.kwargs.get("project_id")
|
||||
|
||||
# getting the project_id from the request params
|
||||
project_id = self.request.GET.get("project_id", None)
|
||||
|
||||
issue_queryset = Issue.issue_objects.filter(
|
||||
workspace__slug=workspace_slug, project_id=project_id
|
||||
workspace__slug=workspace_slug
|
||||
)
|
||||
|
||||
if project_id:
|
||||
issue_queryset = issue_queryset.filter(project_id=project_id)
|
||||
|
||||
return (
|
||||
issue_queryset.select_related(
|
||||
"workspace", "project", "state", "parent"
|
||||
@@ -788,8 +793,8 @@ class IssuePaginatedViewSet(BaseViewSet):
|
||||
|
||||
return paginated_data
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def list(self, request, slug, project_id):
|
||||
def list(self, request, slug):
|
||||
project_id = self.request.GET.get("project_id", None)
|
||||
cursor = request.GET.get("cursor", None)
|
||||
is_description_required = request.GET.get("description", False)
|
||||
updated_at = request.GET.get("updated_at__gt", None)
|
||||
@@ -828,26 +833,14 @@ class IssuePaginatedViewSet(BaseViewSet):
|
||||
required_fields.append("description_html")
|
||||
|
||||
# querying issues
|
||||
base_queryset = Issue.issue_objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
base_queryset = Issue.issue_objects.filter(workspace__slug=slug)
|
||||
|
||||
if project_id:
|
||||
base_queryset = base_queryset.filter(project_id=project_id)
|
||||
|
||||
base_queryset = base_queryset.order_by("updated_at")
|
||||
queryset = self.get_queryset().order_by("updated_at")
|
||||
|
||||
# validation for guest user
|
||||
project = Project.objects.get(pk=project_id, workspace__slug=slug)
|
||||
project_member = ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
member=request.user,
|
||||
role=5,
|
||||
is_active=True,
|
||||
)
|
||||
if project_member.exists() and not project.guest_view_all_features:
|
||||
base_queryset = base_queryset.filter(created_by=request.user)
|
||||
queryset = queryset.filter(created_by=request.user)
|
||||
|
||||
# filtering issues by greater then updated_at given by the user
|
||||
if updated_at:
|
||||
base_queryset = base_queryset.filter(updated_at__gt=updated_at)
|
||||
|
||||
@@ -413,20 +413,9 @@ class ProjectViewSet(BaseViewSet):
|
||||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def partial_update(self, request, slug, pk=None):
|
||||
try:
|
||||
if not ProjectMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
project_id=pk,
|
||||
role=20,
|
||||
is_active=True,
|
||||
).exists():
|
||||
return Response(
|
||||
{"error": "You don't have the required permissions."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
project = Project.objects.get(pk=pk)
|
||||
|
||||
@@ -74,6 +74,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
|
||||
# Get all active workspace members
|
||||
workspace_members = self.get_queryset()
|
||||
|
||||
if workspace_member.role > 5:
|
||||
serializer = WorkspaceMemberAdminSerializer(
|
||||
workspace_members,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
from django.utils import timezone
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
|
||||
# Third party imports
|
||||
@@ -19,25 +18,17 @@ def soft_delete_related_objects(
|
||||
for field in related_fields:
|
||||
if field.one_to_many or field.one_to_one:
|
||||
try:
|
||||
# Check if the field has CASCADE on delete
|
||||
if (
|
||||
hasattr(field.remote_field, "on_delete")
|
||||
and field.remote_field.on_delete == models.CASCADE
|
||||
):
|
||||
if field.one_to_many:
|
||||
related_objects = getattr(instance, field.name).all()
|
||||
elif field.one_to_one:
|
||||
related_object = getattr(instance, field.name)
|
||||
related_objects = (
|
||||
[related_object]
|
||||
if related_object is not None
|
||||
else []
|
||||
)
|
||||
|
||||
for obj in related_objects:
|
||||
if obj:
|
||||
obj.deleted_at = timezone.now()
|
||||
obj.save(using=using)
|
||||
if field.one_to_many:
|
||||
related_objects = getattr(instance, field.name).all()
|
||||
elif field.one_to_one:
|
||||
related_object = getattr(instance, field.name)
|
||||
related_objects = (
|
||||
[related_object] if related_object is not None else []
|
||||
)
|
||||
for obj in related_objects:
|
||||
if obj:
|
||||
obj.deleted_at = timezone.now()
|
||||
obj.save(using=using)
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
|
||||
@@ -163,7 +154,8 @@ def hard_delete():
|
||||
if hasattr(model, "deleted_at"):
|
||||
# Get all instances where 'deleted_at' is greater than 30 days ago
|
||||
_ = model.all_objects.filter(
|
||||
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
|
||||
deleted_at__lt=timezone.now()
|
||||
- timezone.timedelta(days=days)
|
||||
).delete()
|
||||
|
||||
return
|
||||
|
||||
@@ -465,7 +465,7 @@ def track_estimate_points(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor_id=actor_id,
|
||||
verb="removed" if new_estimate is None else "updated",
|
||||
verb="updated",
|
||||
old_identifier=(
|
||||
current_instance.get("estimate_point")
|
||||
if current_instance.get("estimate_point") is not None
|
||||
@@ -1700,12 +1700,16 @@ def issue_activity(
|
||||
event=(
|
||||
"issue_comment"
|
||||
if activity.field == "comment"
|
||||
else "inbox_issue" if inbox else "issue"
|
||||
else "inbox_issue"
|
||||
if inbox
|
||||
else "issue"
|
||||
),
|
||||
event_id=(
|
||||
activity.issue_comment_id
|
||||
if activity.field == "comment"
|
||||
else inbox if inbox else activity.issue_id
|
||||
else inbox
|
||||
if inbox
|
||||
else activity.issue_id
|
||||
),
|
||||
verb=activity.verb,
|
||||
field=(
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from opentelemetry import trace
|
||||
|
||||
# Module imports
|
||||
from plane.license.models import Instance
|
||||
from plane.db.models import (
|
||||
User,
|
||||
Workspace,
|
||||
Project,
|
||||
Issue,
|
||||
Module,
|
||||
Cycle,
|
||||
CycleIssue,
|
||||
ModuleIssue,
|
||||
Page,
|
||||
WorkspaceMember,
|
||||
)
|
||||
|
||||
|
||||
@shared_task
|
||||
def instance_traces():
|
||||
# Get the tracer
|
||||
tracer = trace.get_tracer(__name__)
|
||||
|
||||
# Check if the instance is registered
|
||||
instance = Instance.objects.first()
|
||||
|
||||
# If instance is None then return
|
||||
if instance is None:
|
||||
return
|
||||
|
||||
if instance.is_telemetry_enabled:
|
||||
# Instance details
|
||||
with tracer.start_as_current_span("instance_details") as span:
|
||||
# Count of all models
|
||||
workspace_count = Workspace.objects.count()
|
||||
user_count = User.objects.count()
|
||||
project_count = Project.objects.count()
|
||||
issue_count = Issue.objects.count()
|
||||
module_count = Module.objects.count()
|
||||
cycle_count = Cycle.objects.count()
|
||||
cycle_issue_count = CycleIssue.objects.count()
|
||||
module_issue_count = ModuleIssue.objects.count()
|
||||
page_count = Page.objects.count()
|
||||
|
||||
# Set span attributes
|
||||
span.set_attribute("instance_id", instance.instance_id)
|
||||
span.set_attribute("instance_name", instance.instance_name)
|
||||
span.set_attribute("current_version", instance.current_version)
|
||||
span.set_attribute("latest_version", instance.latest_version)
|
||||
span.set_attribute(
|
||||
"is_telemetry_enabled", instance.is_telemetry_enabled
|
||||
)
|
||||
span.set_attribute("user_count", user_count)
|
||||
span.set_attribute("workspace_count", workspace_count)
|
||||
span.set_attribute("project_count", project_count)
|
||||
span.set_attribute("issue_count", issue_count)
|
||||
span.set_attribute("module_count", module_count)
|
||||
span.set_attribute("cycle_count", cycle_count)
|
||||
span.set_attribute("cycle_issue_count", cycle_issue_count)
|
||||
span.set_attribute("module_issue_count", module_issue_count)
|
||||
span.set_attribute("page_count", page_count)
|
||||
|
||||
# Workspace details
|
||||
for workspace in Workspace.objects.all():
|
||||
# Count of all models
|
||||
project_count = Project.objects.filter(workspace=workspace).count()
|
||||
issue_count = Issue.objects.filter(workspace=workspace).count()
|
||||
module_count = Module.objects.filter(workspace=workspace).count()
|
||||
cycle_count = Cycle.objects.filter(workspace=workspace).count()
|
||||
cycle_issue_count = CycleIssue.objects.filter(
|
||||
workspace=workspace
|
||||
).count()
|
||||
module_issue_count = ModuleIssue.objects.filter(
|
||||
workspace=workspace
|
||||
).count()
|
||||
page_count = Page.objects.filter(workspace=workspace).count()
|
||||
member_count = WorkspaceMember.objects.filter(
|
||||
workspace=workspace
|
||||
).count()
|
||||
|
||||
# Set span attributes
|
||||
with tracer.start_as_current_span("workspace_details") as span:
|
||||
span.set_attribute("instance_id", instance.instance_id)
|
||||
span.set_attribute("workspace_id", str(workspace.id))
|
||||
span.set_attribute("workspace_slug", workspace.slug)
|
||||
span.set_attribute("project_count", project_count)
|
||||
span.set_attribute("issue_count", issue_count)
|
||||
span.set_attribute("module_count", module_count)
|
||||
span.set_attribute("cycle_count", cycle_count)
|
||||
span.set_attribute("cycle_issue_count", cycle_issue_count)
|
||||
span.set_attribute("module_issue_count", module_issue_count)
|
||||
span.set_attribute("page_count", page_count)
|
||||
span.set_attribute("member_count", member_count)
|
||||
|
||||
return
|
||||
@@ -9,10 +9,7 @@ from django.conf import settings
|
||||
|
||||
# Module imports
|
||||
from plane.license.models import Instance
|
||||
from plane.db.models import (
|
||||
User,
|
||||
)
|
||||
from plane.license.bgtasks.tracer import instance_traces
|
||||
from plane.db.models import User
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -24,24 +21,16 @@ class Command(BaseCommand):
|
||||
"machine_signature", type=str, help="Machine signature"
|
||||
)
|
||||
|
||||
def read_package_json(self):
|
||||
with open("package.json", "r") as file:
|
||||
# Load JSON content from the file
|
||||
data = json.load(file)
|
||||
|
||||
payload = {
|
||||
"instance_key": settings.INSTANCE_KEY,
|
||||
"version": data.get("version", 0.1),
|
||||
"user_count": User.objects.filter(is_bot=False).count(),
|
||||
}
|
||||
return payload
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Check if the instance is registered
|
||||
instance = Instance.objects.first()
|
||||
|
||||
# If instance is None then register this instance
|
||||
if instance is None:
|
||||
with open("package.json", "r") as file:
|
||||
# Load JSON content from the file
|
||||
data = json.load(file)
|
||||
|
||||
machine_signature = options.get(
|
||||
"machine_signature", "machine-signature"
|
||||
)
|
||||
@@ -49,7 +38,12 @@ class Command(BaseCommand):
|
||||
if not machine_signature:
|
||||
raise CommandError("Machine signature is required")
|
||||
|
||||
payload = self.read_package_json()
|
||||
payload = {
|
||||
"instance_key": settings.INSTANCE_KEY,
|
||||
"version": data.get("version", 0.1),
|
||||
"machine_signature": machine_signature,
|
||||
"user_count": User.objects.filter(is_bot=False).count(),
|
||||
}
|
||||
|
||||
instance = Instance.objects.create(
|
||||
instance_name="Plane Community Edition",
|
||||
@@ -66,15 +60,4 @@ class Command(BaseCommand):
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS("Instance already registered")
|
||||
)
|
||||
payload = self.read_package_json()
|
||||
# Update the instance details
|
||||
instance.last_checked_at = timezone.now()
|
||||
instance.user_count = payload.get("user_count", 0)
|
||||
instance.current_version = payload.get("version")
|
||||
instance.latest_version = payload.get("version")
|
||||
instance.save()
|
||||
|
||||
# Call the instance traces task
|
||||
instance_traces.delay()
|
||||
|
||||
return
|
||||
return
|
||||
|
||||
@@ -16,17 +16,6 @@ from sentry_sdk.integrations.django import DjangoIntegration
|
||||
from sentry_sdk.integrations.redis import RedisIntegration
|
||||
from corsheaders.defaults import default_headers
|
||||
|
||||
# OpenTelemetry
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
|
||||
OTLPSpanExporter,
|
||||
)
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
from opentelemetry.instrumentation.django import DjangoInstrumentor
|
||||
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# Secret Key
|
||||
@@ -35,19 +24,6 @@ SECRET_KEY = os.environ.get("SECRET_KEY", get_random_secret_key())
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = int(os.environ.get("DEBUG", "0"))
|
||||
|
||||
# Initialize Django instrumentation
|
||||
DjangoInstrumentor().instrument()
|
||||
# Configure the tracer provider
|
||||
service_name = os.environ.get("SERVICE_NAME", "plane-ce-api")
|
||||
resource = Resource.create({"service.name": service_name})
|
||||
trace.set_tracer_provider(TracerProvider(resource=resource))
|
||||
# Configure the OTLP exporter
|
||||
otel_endpoint = os.environ.get("OTLP_ENDPOINT", "https://telemetry.plane.so")
|
||||
otlp_exporter = OTLPSpanExporter(endpoint=otel_endpoint)
|
||||
span_processor = BatchSpanProcessor(otlp_exporter)
|
||||
trace.get_tracer_provider().add_span_processor(span_processor)
|
||||
|
||||
|
||||
# Allowed Hosts
|
||||
ALLOWED_HOSTS = ["*"]
|
||||
|
||||
@@ -303,7 +279,6 @@ CELERY_IMPORTS = (
|
||||
"plane.bgtasks.file_asset_task",
|
||||
"plane.bgtasks.email_notification_task",
|
||||
"plane.bgtasks.api_logs_task",
|
||||
"plane.license.bgtasks.tracer",
|
||||
# management tasks
|
||||
"plane.bgtasks.dummy_data_task",
|
||||
)
|
||||
|
||||
@@ -62,8 +62,3 @@ zxcvbn==4.4.28
|
||||
pytz==2024.1
|
||||
# jwt
|
||||
PyJWT==2.8.0
|
||||
# OpenTelemetry
|
||||
opentelemetry-api==1.27.0
|
||||
opentelemetry-sdk==1.27.0
|
||||
opentelemetry-instrumentation-django==0.48b0
|
||||
opentelemetry-exporter-otlp==1.27.0
|
||||
@@ -280,7 +280,7 @@ function download() {
|
||||
function startServices() {
|
||||
/bin/bash -c "$COMPOSE_CMD -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH up -d --pull if_not_present --quiet-pull"
|
||||
|
||||
local migrator_container_id=$(docker container ls -aq -f "name=$SERVICE_FOLDER-migrator")
|
||||
local migrator_container_id=$(docker ps --format "{{.ID}} {{.Names}}" | grep -E "${SERVICE_FOLDER}(-migrator|_migrator)" | awk '{print $1}')
|
||||
if [ -n "$migrator_container_id" ]; then
|
||||
local idx=0
|
||||
while docker inspect --format='{{.State.Status}}' $migrator_container_id | grep -q "running"; do
|
||||
@@ -308,7 +308,7 @@ function startServices() {
|
||||
fi
|
||||
fi
|
||||
|
||||
local api_container_id=$(docker container ls -q -f "name=$SERVICE_FOLDER-api")
|
||||
local api_container_id=$(docker ps --format "{{.ID}} {{.Names}}" | grep -E "${SERVICE_FOLDER}(-api|_api)" | awk '{print $1}')
|
||||
local idx2=0
|
||||
while ! docker logs $api_container_id 2>&1 | grep -m 1 -i "Application startup complete" | grep -q ".";
|
||||
do
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "live",
|
||||
"version": "0.23.0",
|
||||
"version": "0.22.0",
|
||||
"description": "",
|
||||
"main": "./src/server.ts",
|
||||
"private": true,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"repository": "https://github.com/makeplane/plane.git",
|
||||
"version": "0.23.0",
|
||||
"version": "0.22.0",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/constants",
|
||||
"version": "0.23.0",
|
||||
"version": "0.22.0",
|
||||
"private": true,
|
||||
"main": "./index.ts"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/editor",
|
||||
"version": "0.23.0",
|
||||
"version": "0.22.0",
|
||||
"description": "Core Editor that powers Plane",
|
||||
"private": true,
|
||||
"main": "./dist/index.mjs",
|
||||
|
||||
@@ -71,17 +71,6 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
const containerRect = useRef<DOMRect | null>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
const updateAttributesSafely = useCallback(
|
||||
(attributes: Partial<ImageAttributes>, errorMessage: string) => {
|
||||
try {
|
||||
updateAttributes(attributes);
|
||||
} catch (error) {
|
||||
console.error(`${errorMessage}:`, error);
|
||||
}
|
||||
},
|
||||
[updateAttributes]
|
||||
);
|
||||
|
||||
const handleImageLoad = useCallback(() => {
|
||||
const img = imageRef.current;
|
||||
if (!img) return;
|
||||
@@ -116,25 +105,17 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
};
|
||||
|
||||
setSize(initialComputedSize);
|
||||
updateAttributesSafely(
|
||||
initialComputedSize,
|
||||
"Failed to update attributes while initializing an image for the first time:"
|
||||
);
|
||||
updateAttributes(initialComputedSize);
|
||||
} else {
|
||||
// as the aspect ratio in not stored for old images, we need to update the attrs
|
||||
if (!aspectRatio) {
|
||||
setSize((prevSize) => {
|
||||
const newSize = { ...prevSize, aspectRatio };
|
||||
updateAttributesSafely(
|
||||
newSize,
|
||||
"Failed to update attributes while initializing images with width but no aspect ratio:"
|
||||
);
|
||||
return newSize;
|
||||
});
|
||||
}
|
||||
setSize((prevSize) => {
|
||||
const newSize = { ...prevSize, aspectRatio };
|
||||
updateAttributes(newSize);
|
||||
return newSize;
|
||||
});
|
||||
}
|
||||
setInitialResizeComplete(true);
|
||||
}, [width, updateAttributes, editorContainer, aspectRatio]);
|
||||
}, [width, updateAttributes, editorContainer]);
|
||||
|
||||
// for real time resizing
|
||||
useLayoutEffect(() => {
|
||||
@@ -161,7 +142,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
|
||||
const handleResizeEnd = useCallback(() => {
|
||||
setIsResizing(false);
|
||||
updateAttributesSafely(size, "Failed to update attributes at the end of resizing:");
|
||||
updateAttributes(size);
|
||||
}, [size, updateAttributes]);
|
||||
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
|
||||
|
||||
@@ -5,7 +5,9 @@ import { ImageIcon } from "lucide-react";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
// hooks
|
||||
import { useUploader, useDropZone, uploadFirstImageAndInsertRemaining } from "@/hooks/use-file-upload";
|
||||
import { useUploader, useDropZone } from "@/hooks/use-file-upload";
|
||||
// plugins
|
||||
import { isFileValid } from "@/plugins/image";
|
||||
// extensions
|
||||
import { getImageComponentImageFileMap, ImageAttributes } from "@/extensions/custom-image";
|
||||
|
||||
@@ -72,11 +74,7 @@ export const CustomImageUploader = (props: {
|
||||
);
|
||||
// hooks
|
||||
const { uploading: isImageBeingUploaded, uploadFile } = useUploader({ onUpload, editor, loadImageFromFileSystem });
|
||||
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({
|
||||
uploader: uploadFile,
|
||||
editor,
|
||||
pos: getPos(),
|
||||
});
|
||||
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({ uploader: uploadFile });
|
||||
|
||||
// the meta data of the image component
|
||||
const meta = useMemo(
|
||||
@@ -84,6 +82,9 @@ export const CustomImageUploader = (props: {
|
||||
[imageComponentImageFileMap, imageEntityId]
|
||||
);
|
||||
|
||||
// if the image component is dropped, we check if it has an existing file
|
||||
const existingFile = useMemo(() => (meta && meta.event === "drop" ? meta.file : undefined), [meta]);
|
||||
|
||||
// after the image component is mounted we start the upload process based on
|
||||
// it's uploaded
|
||||
useEffect(() => {
|
||||
@@ -99,20 +100,27 @@ export const CustomImageUploader = (props: {
|
||||
}
|
||||
}, [meta, uploadFile, imageComponentImageFileMap]);
|
||||
|
||||
// check if the image is dropped and set the local image as the existing file
|
||||
useEffect(() => {
|
||||
if (existingFile) {
|
||||
uploadFile(existingFile);
|
||||
}
|
||||
}, [existingFile, uploadFile]);
|
||||
|
||||
const onFileChange = useCallback(
|
||||
async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
e.preventDefault();
|
||||
const fileList = e.target.files;
|
||||
if (!fileList) {
|
||||
return;
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (isFileValid(file)) {
|
||||
uploadFile(file);
|
||||
}
|
||||
}
|
||||
await uploadFirstImageAndInsertRemaining(editor, fileList, getPos(), uploadFile);
|
||||
},
|
||||
[uploadFile, editor, getPos]
|
||||
[uploadFile]
|
||||
);
|
||||
|
||||
const getDisplayMessage = useCallback(() => {
|
||||
const isUploading = isImageBeingUploaded;
|
||||
const isUploading = isImageBeingUploaded || existingFile;
|
||||
if (failedToLoadImage) {
|
||||
return "Error loading image";
|
||||
}
|
||||
@@ -126,14 +134,13 @@ export const CustomImageUploader = (props: {
|
||||
}
|
||||
|
||||
return "Add an image";
|
||||
}, [draggedInside, failedToLoadImage, isImageBeingUploaded]);
|
||||
}, [draggedInside, failedToLoadImage, existingFile, isImageBeingUploaded]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"image-upload-component flex items-center justify-start gap-2 py-3 px-2 rounded-lg text-custom-text-300 hover:text-custom-text-200 bg-custom-background-90 hover:bg-custom-background-80 border border-dashed border-custom-border-300 transition-all duration-200 ease-in-out cursor-default",
|
||||
"image-upload-component flex items-center justify-start gap-2 py-3 px-2 rounded-lg text-custom-text-300 hover:text-custom-text-200 bg-custom-background-90 hover:bg-custom-background-80 border border-dashed border-custom-border-300 cursor-pointer transition-all duration-200 ease-in-out",
|
||||
{
|
||||
"hover:text-custom-text-200 cursor-pointer": editor.isEditable,
|
||||
"bg-custom-background-80 text-custom-text-200": draggedInside,
|
||||
"text-custom-primary-200 bg-custom-primary-100/10 hover:bg-custom-primary-100/10 hover:text-custom-primary-200 border-custom-primary-200/10":
|
||||
selected,
|
||||
@@ -146,7 +153,7 @@ export const CustomImageUploader = (props: {
|
||||
onDragLeave={onDragLeave}
|
||||
contentEditable={false}
|
||||
onClick={() => {
|
||||
if (!failedToLoadImage && editor.isEditable) {
|
||||
if (!failedToLoadImage) {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
@@ -160,7 +167,6 @@ export const CustomImageUploader = (props: {
|
||||
type="file"
|
||||
accept=".jpg,.jpeg,.png,.webp"
|
||||
onChange={onFileChange}
|
||||
multiple
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -21,7 +21,7 @@ export const DropHandlerExtension = () =>
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
const pos = view.state.selection.from;
|
||||
insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" });
|
||||
insertImages({ editor, files: imageFiles, initialPos: pos, event: "drop" });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export const DropHandlerExtension = () =>
|
||||
|
||||
if (coordinates) {
|
||||
const pos = coordinates.pos;
|
||||
insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" });
|
||||
insertImages({ editor, files: imageFiles, initialPos: pos, event: "drop" });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export const DropHandlerExtension = () =>
|
||||
},
|
||||
});
|
||||
|
||||
export const insertImagesSafely = async ({
|
||||
const insertImages = async ({
|
||||
editor,
|
||||
files,
|
||||
initialPos,
|
||||
@@ -72,6 +72,13 @@ export const insertImagesSafely = async ({
|
||||
const docSize = editor.state.doc.content.size;
|
||||
pos = Math.min(pos, docSize);
|
||||
|
||||
// Check if the position has a non-empty node
|
||||
const nodeAtPos = editor.state.doc.nodeAt(pos);
|
||||
if (nodeAtPos && nodeAtPos.content.size > 0) {
|
||||
// Move to the end of the current node
|
||||
pos += nodeAtPos.nodeSize;
|
||||
}
|
||||
|
||||
try {
|
||||
// Insert the image at the current position
|
||||
editor.commands.insertImageComponent({ file, pos, event });
|
||||
|
||||
@@ -42,7 +42,7 @@ export const SideMenuExtension = (props: Props) => {
|
||||
ai: aiEnabled,
|
||||
dragDrop: dragDropEnabled,
|
||||
},
|
||||
scrollThreshold: { up: 200, down: 100 },
|
||||
scrollThreshold: { up: 300, down: 100 },
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
@@ -126,7 +126,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
forwardedRef,
|
||||
() => ({
|
||||
clearEditor: (emitUpdate = false) => {
|
||||
editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
|
||||
editorRef.current?.commands.clearContent(emitUpdate);
|
||||
},
|
||||
setEditorValue: (content: string) => {
|
||||
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { DragEvent, useCallback, useEffect, useState } from "react";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { isFileValid } from "@/plugins/image";
|
||||
import { insertImagesSafely } from "@/extensions/drop";
|
||||
|
||||
export const useUploader = ({
|
||||
onUpload,
|
||||
@@ -64,15 +63,7 @@ export const useUploader = ({
|
||||
return { uploading, uploadFile };
|
||||
};
|
||||
|
||||
export const useDropZone = ({
|
||||
uploader,
|
||||
editor,
|
||||
pos,
|
||||
}: {
|
||||
uploader: (file: File) => Promise<void>;
|
||||
editor: Editor;
|
||||
pos: number;
|
||||
}) => {
|
||||
export const useDropZone = ({ uploader }: { uploader: (file: File) => void }) => {
|
||||
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||
const [draggedInside, setDraggedInside] = useState<boolean>(false);
|
||||
|
||||
@@ -95,16 +86,40 @@ export const useDropZone = ({
|
||||
}, []);
|
||||
|
||||
const onDrop = useCallback(
|
||||
async (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
(e: DragEvent<HTMLDivElement>) => {
|
||||
setDraggedInside(false);
|
||||
if (e.dataTransfer.files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileList = e.dataTransfer.files;
|
||||
await uploadFirstImageAndInsertRemaining(editor, fileList, pos, uploader);
|
||||
|
||||
const files: File[] = [];
|
||||
|
||||
for (let i = 0; i < fileList.length; i += 1) {
|
||||
const item = fileList.item(i);
|
||||
if (item) {
|
||||
files.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (files.some((file) => file.type.indexOf("image") === -1)) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const filteredFiles = files.filter((f) => f.type.indexOf("image") !== -1);
|
||||
|
||||
const file = filteredFiles.length > 0 ? filteredFiles[0] : undefined;
|
||||
|
||||
if (file) {
|
||||
uploader(file);
|
||||
} else {
|
||||
console.error("No file found");
|
||||
}
|
||||
},
|
||||
[uploader, editor, pos]
|
||||
[uploader]
|
||||
);
|
||||
|
||||
const onDragEnter = () => {
|
||||
@@ -128,40 +143,3 @@ function trimFileName(fileName: string, maxLength = 100) {
|
||||
|
||||
return fileName;
|
||||
}
|
||||
|
||||
// Upload the first image and insert the remaining images for uploading multiple image
|
||||
// post insertion of image-component
|
||||
export async function uploadFirstImageAndInsertRemaining(
|
||||
editor: Editor,
|
||||
fileList: FileList,
|
||||
pos: number,
|
||||
uploaderFn: (file: File) => Promise<void>
|
||||
) {
|
||||
const filteredFiles: File[] = [];
|
||||
for (let i = 0; i < fileList.length; i += 1) {
|
||||
const item = fileList.item(i);
|
||||
if (item && item.type.indexOf("image") !== -1 && isFileValid(item)) {
|
||||
filteredFiles.push(item);
|
||||
}
|
||||
}
|
||||
if (filteredFiles.length !== fileList.length) {
|
||||
console.warn("Some files were not images and have been ignored.");
|
||||
}
|
||||
if (filteredFiles.length === 0) {
|
||||
console.error("No image files found to upload");
|
||||
return;
|
||||
}
|
||||
|
||||
// Upload the first image
|
||||
const firstFile = filteredFiles[0];
|
||||
uploaderFn(firstFile);
|
||||
|
||||
// Insert the remaining images
|
||||
const remainingFiles = filteredFiles.slice(1);
|
||||
|
||||
if (remainingFiles.length > 0) {
|
||||
const docSize = editor.state.doc.content.size;
|
||||
const posOfNextImageToBeInserted = Math.min(pos + 1, docSize);
|
||||
insertImagesSafely({ editor, files: remainingFiles, initialPos: posOfNextImageToBeInserted, event: "drop" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,8 +70,8 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
|
||||
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
clearEditor: (emitUpdate = false) => {
|
||||
editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
|
||||
clearEditor: () => {
|
||||
editorRef.current?.commands.clearContent();
|
||||
},
|
||||
setEditorValue: (content: string) => {
|
||||
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
|
||||
|
||||
@@ -233,46 +233,14 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp
|
||||
dragHandleElement.addEventListener("click", (e) => handleClick(e, view));
|
||||
dragHandleElement.addEventListener("contextmenu", (e) => handleClick(e, view));
|
||||
|
||||
const isScrollable = (node: HTMLElement | SVGElement) => {
|
||||
if (!(node instanceof HTMLElement || node instanceof SVGElement)) {
|
||||
return false;
|
||||
}
|
||||
const style = getComputedStyle(node);
|
||||
return ["overflow", "overflow-y"].some((propertyName) => {
|
||||
const value = style.getPropertyValue(propertyName);
|
||||
return value === "auto" || value === "scroll";
|
||||
});
|
||||
};
|
||||
|
||||
const getScrollParent = (node: HTMLElement | SVGElement) => {
|
||||
let currentParent = node.parentElement;
|
||||
while (currentParent) {
|
||||
if (isScrollable(currentParent)) {
|
||||
return currentParent;
|
||||
}
|
||||
currentParent = currentParent.parentElement;
|
||||
}
|
||||
return document.scrollingElement || document.documentElement;
|
||||
};
|
||||
|
||||
const maxScrollSpeed = 100;
|
||||
|
||||
dragHandleElement.addEventListener("drag", (e) => {
|
||||
hideDragHandle();
|
||||
const scrollableParent = getScrollParent(dragHandleElement);
|
||||
if (!scrollableParent) return;
|
||||
const scrollThreshold = options.scrollThreshold;
|
||||
|
||||
if (e.clientY < scrollThreshold.up) {
|
||||
const overflow = scrollThreshold.up - e.clientY;
|
||||
const ratio = Math.min(overflow / scrollThreshold.up, 1);
|
||||
const scrollAmount = -maxScrollSpeed * ratio;
|
||||
scrollableParent.scrollBy({ top: scrollAmount });
|
||||
} else if (window.innerHeight - e.clientY < scrollThreshold.down) {
|
||||
const overflow = e.clientY - (window.innerHeight - scrollThreshold.down);
|
||||
const ratio = Math.min(overflow / scrollThreshold.down, 1);
|
||||
const scrollAmount = maxScrollSpeed * ratio;
|
||||
scrollableParent.scrollBy({ top: scrollAmount });
|
||||
const frameRenderer = document.querySelector(".frame-renderer");
|
||||
if (!frameRenderer) return;
|
||||
if (e.clientY < options.scrollThreshold.up) {
|
||||
frameRenderer.scrollBy({ top: -70, behavior: "smooth" });
|
||||
} else if (window.innerHeight - e.clientY < options.scrollThreshold.down) {
|
||||
frameRenderer.scrollBy({ top: 70, behavior: "smooth" });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -17,8 +17,6 @@ export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImag
|
||||
});
|
||||
|
||||
transactions.forEach((transaction) => {
|
||||
// if the transaction has meta of skipImageDeletion get to true, then return (like while clearing the editor content programatically)
|
||||
if (transaction.getMeta("skipImageDeletion")) return;
|
||||
// transaction could be a selection
|
||||
if (!transaction.docChanged) return;
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
}
|
||||
|
||||
&.sans-serif {
|
||||
--font-style: "Inter", sans-serif;
|
||||
--font-style: sans-serif;
|
||||
}
|
||||
|
||||
&.serif {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@plane/eslint-config",
|
||||
"private": true,
|
||||
"version": "0.23.0",
|
||||
"version": "0.22.0",
|
||||
"files": [
|
||||
"library.js",
|
||||
"next.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/helpers",
|
||||
"version": "0.23.0",
|
||||
"version": "0.22.0",
|
||||
"description": "Helper functions shared across multiple apps internally",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tailwind-config-custom",
|
||||
"version": "0.23.0",
|
||||
"version": "0.22.0",
|
||||
"description": "common tailwind configuration across monorepo",
|
||||
"main": "index.js",
|
||||
"private": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/types",
|
||||
"version": "0.23.0",
|
||||
"version": "0.22.0",
|
||||
"private": true,
|
||||
"types": "./src/index.d.ts",
|
||||
"main": "./src/index.d.ts"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/typescript-config",
|
||||
"version": "0.23.0",
|
||||
"version": "0.22.0",
|
||||
"private": true,
|
||||
"files": [
|
||||
"base.json",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@plane/ui",
|
||||
"description": "UI components shared across multiple apps internally",
|
||||
"private": true,
|
||||
"version": "0.23.0",
|
||||
"version": "0.22.0",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { Check, ChevronDown, Search } from "lucide-react";
|
||||
import { createPortal } from "react-dom";
|
||||
// plane helpers
|
||||
import { useOutsideClickDetector } from "@plane/helpers";
|
||||
@@ -11,8 +11,6 @@ import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
|
||||
import { cn } from "../../helpers";
|
||||
// types
|
||||
import { ICustomSearchSelectProps } from "./helper";
|
||||
// local components
|
||||
import { Tooltip } from "../tooltip";
|
||||
|
||||
export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
|
||||
const {
|
||||
@@ -97,10 +95,11 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={`flex w-full items-center justify-between gap-1 text-xs ${disabled
|
||||
? "cursor-not-allowed text-custom-text-200"
|
||||
: "cursor-pointer hover:bg-custom-background-80"
|
||||
} ${customButtonClassName}`}
|
||||
className={`flex w-full items-center justify-between gap-1 text-xs ${
|
||||
disabled
|
||||
? "cursor-not-allowed text-custom-text-200"
|
||||
: "cursor-pointer hover:bg-custom-background-80"
|
||||
} ${customButtonClassName}`}
|
||||
onClick={toggleDropdown}
|
||||
>
|
||||
{customButton}
|
||||
@@ -171,32 +170,17 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
|
||||
"w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none",
|
||||
{
|
||||
"bg-custom-background-80": active,
|
||||
"text-custom-text-400 opacity-60 cursor-not-allowed": option.disabled,
|
||||
}
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
if (!multiple) closeDropdown();
|
||||
}}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span className="flex-grow truncate">{option.content}</span>
|
||||
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||
{option.tooltip && (
|
||||
<>
|
||||
{
|
||||
typeof option.tooltip === "string" ? (
|
||||
<Tooltip tooltipContent={option.tooltip}>
|
||||
<Info className="h-3.5 w-3.5 flex-shrink-0 cursor-pointer text-custom-text-200" />
|
||||
</Tooltip>
|
||||
) : (
|
||||
option.tooltip
|
||||
)
|
||||
}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
|
||||
@@ -44,14 +44,12 @@ interface CustomSearchSelectProps {
|
||||
onChange: any;
|
||||
onClose?: () => void;
|
||||
options:
|
||||
| {
|
||||
value: any;
|
||||
query: string;
|
||||
content: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
tooltip?: string | React.ReactNode;
|
||||
}[]
|
||||
| undefined;
|
||||
| {
|
||||
value: any;
|
||||
query: string;
|
||||
content: React.ReactNode;
|
||||
}[]
|
||||
| undefined;
|
||||
}
|
||||
|
||||
interface SingleValueProps {
|
||||
|
||||
@@ -4,9 +4,6 @@ export enum EModalPosition {
|
||||
}
|
||||
|
||||
export enum EModalWidth {
|
||||
SM = "sm:max-w-sm",
|
||||
MD = "sm:max-w-md",
|
||||
LG = "sm:max-w-lg",
|
||||
XL = "sm:max-w-xl",
|
||||
XXL = "sm:max-w-2xl",
|
||||
XXXL = "sm:max-w-3xl",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "space",
|
||||
"version": "0.23.0",
|
||||
"version": "0.22.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo run develop",
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ export const CycleIssuesMobileHeader = () => {
|
||||
const { getCycleById } = useCycle();
|
||||
const layouts = [
|
||||
{ key: "list", title: "List", icon: List },
|
||||
{ key: "kanban", title: "Board", icon: Kanban },
|
||||
{ key: "kanban", title: "Kanban", icon: Kanban },
|
||||
{ key: "calendar", title: "Calendar", icon: Calendar },
|
||||
];
|
||||
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ import { useIssues, useLabel, useMember, useProject, useProjectState } from "@/h
|
||||
export const ProjectIssuesMobileHeader = observer(() => {
|
||||
const layouts = [
|
||||
{ key: "list", title: "List", icon: List },
|
||||
{ key: "kanban", title: "Board", icon: Kanban },
|
||||
{ key: "kanban", title: "Kanban", icon: Kanban },
|
||||
{ key: "calendar", title: "Calendar", icon: Calendar },
|
||||
];
|
||||
const [analyticsModal, setAnalyticsModal] = useState(false);
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ export const ModuleIssuesMobileHeader = observer(() => {
|
||||
const { getModuleById } = useModule();
|
||||
const layouts = [
|
||||
{ key: "list", title: "List", icon: List },
|
||||
{ key: "kanban", title: "Board", icon: Kanban },
|
||||
{ key: "kanban", title: "Kanban", icon: Kanban },
|
||||
{ key: "calendar", title: "Calendar", icon: Calendar },
|
||||
];
|
||||
const { workspaceSlug, projectId, moduleId } = useParams() as {
|
||||
|
||||
@@ -52,8 +52,13 @@ const ProfileAppearancePage = observer(() => {
|
||||
const applyThemeChange = (theme: Partial<IUserTheme>) => {
|
||||
setTheme(theme?.theme || "system");
|
||||
|
||||
if (theme?.theme === "custom" && theme?.palette) {
|
||||
applyTheme(theme?.palette !== ",,,," ? theme?.palette : "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5", false);
|
||||
const customThemeElement = window.document?.querySelector<HTMLElement>("[data-theme='custom']");
|
||||
if (theme?.theme === "custom" && theme?.palette && customThemeElement) {
|
||||
applyTheme(
|
||||
theme?.palette !== ",,,," ? theme?.palette : "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5",
|
||||
false,
|
||||
customThemeElement
|
||||
);
|
||||
} else unsetCustomCssVariables();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
import { Control } from "react-hook-form";
|
||||
// types
|
||||
import { TBulkIssueProperties, TIssue } from "@plane/types";
|
||||
import { TIssue } from "@plane/types";
|
||||
|
||||
export type TIssueFields = TIssue & TBulkIssueProperties;
|
||||
|
||||
export type TIssueTypeDropdownVariant = "xs" | "sm";
|
||||
|
||||
export type TIssueTypeSelectProps<T extends Partial<TIssueFields>> = {
|
||||
control: Control<T>;
|
||||
type TIssueTypeSelectProps = {
|
||||
control: Control<TIssue>;
|
||||
projectId: string | null;
|
||||
disabled?: boolean;
|
||||
variant?: TIssueTypeDropdownVariant;
|
||||
placeholder?: string;
|
||||
isRequired?: boolean;
|
||||
renderChevron?: boolean;
|
||||
dropDownContainerClassName?: string;
|
||||
showMandatoryFieldInfo?: boolean; // Show info about mandatory fields
|
||||
handleFormChange?: () => void;
|
||||
handleFormChange: () => void;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const IssueTypeSelect = <T extends Partial<TIssueFields>>(props: TIssueTypeSelectProps<T>) => <></>;
|
||||
export const IssueTypeSelect: React.FC<TIssueTypeSelectProps> = () => <></>;
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from "./lite-text-editor";
|
||||
export * from "./pdf";
|
||||
export * from "./rich-text-editor";
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Document, Font, Page, PageProps } from "@react-pdf/renderer";
|
||||
import { Html } from "react-pdf-html";
|
||||
// constants
|
||||
import { EDITOR_PDF_DOCUMENT_STYLESHEET } from "@/constants/editor";
|
||||
|
||||
Font.register({
|
||||
family: "Inter",
|
||||
fonts: [
|
||||
{ src: "/fonts/inter/thin.ttf", fontWeight: "thin" },
|
||||
{ src: "/fonts/inter/thin.ttf", fontWeight: "thin", fontStyle: "italic" },
|
||||
{ src: "/fonts/inter/ultralight.ttf", fontWeight: "ultralight" },
|
||||
{ src: "/fonts/inter/ultralight.ttf", fontWeight: "ultralight", fontStyle: "italic" },
|
||||
{ src: "/fonts/inter/light.ttf", fontWeight: "light" },
|
||||
{ src: "/fonts/inter/light.ttf", fontWeight: "light", fontStyle: "italic" },
|
||||
{ src: "/fonts/inter/regular.ttf", fontWeight: "normal" },
|
||||
{ src: "/fonts/inter/regular.ttf", fontWeight: "normal", fontStyle: "italic" },
|
||||
{ src: "/fonts/inter/medium.ttf", fontWeight: "medium" },
|
||||
{ src: "/fonts/inter/medium.ttf", fontWeight: "medium", fontStyle: "italic" },
|
||||
{ src: "/fonts/inter/semibold.ttf", fontWeight: "semibold" },
|
||||
{ src: "/fonts/inter/semibold.ttf", fontWeight: "semibold", fontStyle: "italic" },
|
||||
{ src: "/fonts/inter/bold.ttf", fontWeight: "bold" },
|
||||
{ src: "/fonts/inter/bold.ttf", fontWeight: "bold", fontStyle: "italic" },
|
||||
{ src: "/fonts/inter/extrabold.ttf", fontWeight: "ultrabold" },
|
||||
{ src: "/fonts/inter/extrabold.ttf", fontWeight: "ultrabold", fontStyle: "italic" },
|
||||
{ src: "/fonts/inter/heavy.ttf", fontWeight: "heavy" },
|
||||
{ src: "/fonts/inter/heavy.ttf", fontWeight: "heavy", fontStyle: "italic" },
|
||||
],
|
||||
});
|
||||
|
||||
type Props = {
|
||||
content: string;
|
||||
pageFormat: PageProps["size"];
|
||||
};
|
||||
|
||||
export const PDFDocument: React.FC<Props> = (props) => {
|
||||
const { content, pageFormat } = props;
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page
|
||||
size={pageFormat}
|
||||
style={{
|
||||
backgroundColor: "#ffffff",
|
||||
padding: 64,
|
||||
}}
|
||||
>
|
||||
<Html stylesheet={EDITOR_PDF_DOCUMENT_STYLESHEET}>{content}</Html>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./document";
|
||||
@@ -280,7 +280,6 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
projectId={projectId}
|
||||
disabled={!!data?.sourceIssueId}
|
||||
handleFormChange={handleFormChange}
|
||||
renderChevron
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { ArchiveRestoreIcon, ArrowUpToLine, Clipboard, Copy, History, Link, Lock, LockOpen } from "lucide-react";
|
||||
import { ArchiveRestoreIcon, Clipboard, Copy, History, Link, Lock, LockOpen } from "lucide-react";
|
||||
// document editor
|
||||
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
|
||||
// ui
|
||||
import { ArchiveIcon, CustomMenu, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { ExportPageModal } from "@/components/pages";
|
||||
// helpers
|
||||
import { copyTextToClipboard, copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
@@ -30,7 +27,6 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||
const router = useRouter();
|
||||
// store values
|
||||
const {
|
||||
name,
|
||||
archived_at,
|
||||
is_locked,
|
||||
id,
|
||||
@@ -42,8 +38,6 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||
canCurrentUserLockPage,
|
||||
restore,
|
||||
} = page;
|
||||
// states
|
||||
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
||||
// store hooks
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// page filters
|
||||
@@ -163,41 +157,26 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||
icon: History,
|
||||
shouldRender: true,
|
||||
},
|
||||
{
|
||||
key: "export",
|
||||
action: () => setIsExportModalOpen(true),
|
||||
label: "Export",
|
||||
icon: ArrowUpToLine,
|
||||
shouldRender: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<ExportPageModal
|
||||
editorRef={editorRef}
|
||||
isOpen={isExportModalOpen}
|
||||
onClose={() => setIsExportModalOpen(false)}
|
||||
pageTitle={name ?? ""}
|
||||
/>
|
||||
<CustomMenu maxHeight="lg" placement="bottom-start" verticalEllipsis closeOnSelect>
|
||||
<CustomMenu.MenuItem
|
||||
className="hidden md:flex w-full items-center justify-between gap-2"
|
||||
onClick={() => handleFullWidth(!isFullWidth)}
|
||||
>
|
||||
Full width
|
||||
<ToggleSwitch value={isFullWidth} onChange={() => {}} />
|
||||
</CustomMenu.MenuItem>
|
||||
{MENU_ITEMS.map((item) => {
|
||||
if (!item.shouldRender) return null;
|
||||
return (
|
||||
<CustomMenu.MenuItem key={item.key} onClick={item.action} className="flex items-center gap-2">
|
||||
<item.icon className="h-3 w-3" />
|
||||
{item.label}
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
</>
|
||||
<CustomMenu maxHeight="lg" placement="bottom-start" verticalEllipsis closeOnSelect>
|
||||
<CustomMenu.MenuItem
|
||||
className="hidden md:flex w-full items-center justify-between gap-2"
|
||||
onClick={() => handleFullWidth(!isFullWidth)}
|
||||
>
|
||||
Full width
|
||||
<ToggleSwitch value={isFullWidth} onChange={() => {}} />
|
||||
</CustomMenu.MenuItem>
|
||||
{MENU_ITEMS.map((item) => {
|
||||
if (!item.shouldRender) return null;
|
||||
return (
|
||||
<CustomMenu.MenuItem key={item.key} onClick={item.action} className="flex items-center gap-2">
|
||||
<item.icon className="h-3 w-3" />
|
||||
{item.label}
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CSSProperties, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// editor
|
||||
import { EditorRefApi } from "@plane/editor";
|
||||
@@ -23,21 +23,27 @@ export const PageEditorTitle: React.FC<Props> = observer((props) => {
|
||||
// states
|
||||
const [isLengthVisible, setIsLengthVisible] = useState(false);
|
||||
// page filters
|
||||
const { fontSize } = usePageFilters();
|
||||
const { fontSize, fontStyle } = usePageFilters();
|
||||
// ui
|
||||
const titleClassName = cn("bg-transparent tracking-[-2%] font-semibold", {
|
||||
"text-[1.6rem] leading-[1.8rem]": fontSize === "small-font",
|
||||
"text-[2rem] leading-[2.25rem]": fontSize === "large-font",
|
||||
});
|
||||
const titleStyle: CSSProperties = {
|
||||
fontFamily: fontStyle,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{readOnly ? (
|
||||
<h6 className={cn(titleClassName, "break-words")}>{title}</h6>
|
||||
<h6 className={cn(titleClassName, "break-words")} style={titleStyle}>
|
||||
{title}
|
||||
</h6>
|
||||
) : (
|
||||
<>
|
||||
<TextArea
|
||||
className={cn(titleClassName, "w-full outline-none p-0 border-none resize-none rounded-none")}
|
||||
style={titleStyle}
|
||||
placeholder="Untitled"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { PageProps, pdf } from "@react-pdf/renderer";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// plane editor
|
||||
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
|
||||
// plane ui
|
||||
import { Button, CustomSelect, EModalPosition, EModalWidth, ModalCore, setToast, TOAST_TYPE } from "@plane/ui";
|
||||
// components
|
||||
import { PDFDocument } from "@/components/editor";
|
||||
// helpers
|
||||
import {
|
||||
replaceCustomComponentsFromHTMLContent,
|
||||
replaceCustomComponentsFromMarkdownContent,
|
||||
} from "@/helpers/editor.helper";
|
||||
|
||||
type Props = {
|
||||
editorRef: EditorRefApi | EditorReadOnlyRefApi | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
pageTitle: string;
|
||||
};
|
||||
|
||||
type TExportFormats = "pdf" | "markdown";
|
||||
type TPageFormats = Exclude<PageProps["size"], undefined>;
|
||||
type TContentVariety = "everything" | "no-assets";
|
||||
|
||||
type TFormValues = {
|
||||
export_format: TExportFormats;
|
||||
page_format: TPageFormats;
|
||||
content_variety: TContentVariety;
|
||||
};
|
||||
|
||||
const EXPORT_FORMATS: {
|
||||
key: TExportFormats;
|
||||
label: string;
|
||||
}[] = [
|
||||
{
|
||||
key: "pdf",
|
||||
label: "PDF",
|
||||
},
|
||||
{
|
||||
key: "markdown",
|
||||
label: "Markdown",
|
||||
},
|
||||
];
|
||||
|
||||
const PAGE_FORMATS: {
|
||||
key: TPageFormats;
|
||||
label: string;
|
||||
}[] = [
|
||||
{
|
||||
key: "A4",
|
||||
label: "A4",
|
||||
},
|
||||
{
|
||||
key: "A3",
|
||||
label: "A3",
|
||||
},
|
||||
{
|
||||
key: "A2",
|
||||
label: "A2",
|
||||
},
|
||||
{
|
||||
key: "LETTER",
|
||||
label: "Letter",
|
||||
},
|
||||
{
|
||||
key: "LEGAL",
|
||||
label: "Legal",
|
||||
},
|
||||
{
|
||||
key: "TABLOID",
|
||||
label: "Tabloid",
|
||||
},
|
||||
];
|
||||
|
||||
const CONTENT_VARIETY: {
|
||||
key: TContentVariety;
|
||||
label: string;
|
||||
}[] = [
|
||||
{
|
||||
key: "everything",
|
||||
label: "Everything",
|
||||
},
|
||||
{
|
||||
key: "no-assets",
|
||||
label: "No images",
|
||||
},
|
||||
];
|
||||
|
||||
const defaultValues: TFormValues = {
|
||||
export_format: "pdf",
|
||||
page_format: "A4",
|
||||
content_variety: "everything",
|
||||
};
|
||||
|
||||
export const ExportPageModal: React.FC<Props> = (props) => {
|
||||
const { editorRef, isOpen, onClose, pageTitle } = props;
|
||||
// states
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
// form info
|
||||
const { control, reset, watch } = useForm<TFormValues>({
|
||||
defaultValues,
|
||||
});
|
||||
// derived values
|
||||
const selectedExportFormat = watch("export_format");
|
||||
const selectedPageFormat = watch("page_format");
|
||||
const selectedContentVariety = watch("content_variety");
|
||||
const isPDFSelected = selectedExportFormat === "pdf";
|
||||
const fileName = pageTitle
|
||||
?.toLowerCase()
|
||||
?.replace(/[^a-z0-9-_]/g, "-")
|
||||
.replace(/-+/g, "-");
|
||||
// handle modal close
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setTimeout(() => {
|
||||
reset();
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const initiateDownload = (blob: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(url);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// handle export as a PDF
|
||||
const handleExportAsPDF = async () => {
|
||||
try {
|
||||
const pageContent = `<h1 class="page-title">${pageTitle}</h1>${editorRef?.getDocument().html ?? "<p></p>"}`;
|
||||
const parsedPageContent = await replaceCustomComponentsFromHTMLContent({
|
||||
htmlContent: pageContent,
|
||||
noAssets: selectedContentVariety === "no-assets",
|
||||
});
|
||||
|
||||
const blob = await pdf(<PDFDocument content={parsedPageContent} pageFormat={selectedPageFormat} />).toBlob();
|
||||
initiateDownload(blob, `${fileName}-${selectedPageFormat.toString().toLowerCase()}.pdf`);
|
||||
} catch (error) {
|
||||
throw new Error(`Error in exporting as a PDF: ${error}`);
|
||||
}
|
||||
};
|
||||
// handle export as markdown
|
||||
const handleExportAsMarkdown = async () => {
|
||||
try {
|
||||
const markdownContent = editorRef?.getMarkDown() ?? "";
|
||||
const parsedMarkdownContent = replaceCustomComponentsFromMarkdownContent({
|
||||
markdownContent,
|
||||
noAssets: selectedContentVariety === "no-assets",
|
||||
});
|
||||
|
||||
const blob = new Blob([parsedMarkdownContent], { type: "text/markdown" });
|
||||
initiateDownload(blob, `${fileName}.md`);
|
||||
} catch (error) {
|
||||
throw new Error(`Error in exporting as markdown: ${error}`);
|
||||
}
|
||||
};
|
||||
// handle export
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
if (selectedExportFormat === "pdf") {
|
||||
await handleExportAsPDF();
|
||||
}
|
||||
if (selectedExportFormat === "markdown") {
|
||||
await handleExportAsMarkdown();
|
||||
}
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Page exported successfully.",
|
||||
});
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Page could not be exported. Please try again later.",
|
||||
});
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.SM}>
|
||||
<div>
|
||||
<div className="p-5 space-y-5">
|
||||
<h3 className="text-xl font-medium text-custom-text-200">Export page</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h6 className="flex-shrink-0 text-sm text-custom-text-200">Export format</h6>
|
||||
<Controller
|
||||
control={control}
|
||||
name="export_format"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<CustomSelect
|
||||
label={EXPORT_FORMATS.find((format) => format.key === value)?.label}
|
||||
buttonClassName="border-none"
|
||||
value={value}
|
||||
onChange={(val: TExportFormats) => onChange(val)}
|
||||
className="flex-shrink-0"
|
||||
placement="bottom-end"
|
||||
>
|
||||
{EXPORT_FORMATS.map((format) => (
|
||||
<CustomSelect.Option key={format.key} value={format.key}>
|
||||
{format.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h6 className="flex-shrink-0 text-sm text-custom-text-200">Include content</h6>
|
||||
<Controller
|
||||
control={control}
|
||||
name="content_variety"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<CustomSelect
|
||||
label={CONTENT_VARIETY.find((variety) => variety.key === value)?.label}
|
||||
buttonClassName="border-none"
|
||||
value={value}
|
||||
onChange={(val: TContentVariety) => onChange(val)}
|
||||
className="flex-shrink-0"
|
||||
placement="bottom-end"
|
||||
>
|
||||
{CONTENT_VARIETY.map((variety) => (
|
||||
<CustomSelect.Option key={variety.key} value={variety.key}>
|
||||
{variety.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{isPDFSelected && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h6 className="flex-shrink-0 text-sm text-custom-text-200">Page format</h6>
|
||||
<Controller
|
||||
control={control}
|
||||
name="page_format"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<CustomSelect
|
||||
label={PAGE_FORMATS.find((format) => format.key === value)?.label}
|
||||
buttonClassName="border-none"
|
||||
value={value}
|
||||
onChange={(val: TPageFormats) => onChange(val)}
|
||||
className="flex-shrink-0"
|
||||
placement="bottom-end"
|
||||
>
|
||||
{PAGE_FORMATS.map((format) => (
|
||||
<CustomSelect.Option key={format.key.toString()} value={format.key}>
|
||||
{format.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" loading={isExporting} onClick={handleExport}>
|
||||
{isExporting ? "Exporting" : "Export"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalCore>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from "./create-page-modal";
|
||||
export * from "./delete-page-modal";
|
||||
export * from "./export-page-modal";
|
||||
export * from "./page-form";
|
||||
|
||||
@@ -182,7 +182,7 @@ export const SidebarFavoritesMenu = observer(() => {
|
||||
.map((fav, index) => (
|
||||
<Tooltip
|
||||
key={fav.id}
|
||||
tooltipContent={fav?.entity_data ? fav.entity_data?.name : fav?.name}
|
||||
tooltipContent={fav.entity_data ? fav.entity_data.name : fav.name}
|
||||
position="right"
|
||||
className="ml-2"
|
||||
disabled={!sidebarCollapsed}
|
||||
|
||||
@@ -40,12 +40,12 @@ export const CYCLE_VIEW_LAYOUTS: {
|
||||
{
|
||||
key: "board",
|
||||
icon: LayoutGrid,
|
||||
title: "Gallery layout",
|
||||
title: "Grid layout",
|
||||
},
|
||||
{
|
||||
key: "gantt",
|
||||
icon: GanttChartSquare,
|
||||
title: "Timeline layout",
|
||||
title: "Gantt layout",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Styles, StyleSheet } from "@react-pdf/renderer";
|
||||
import {
|
||||
Bold,
|
||||
CaseSensitive,
|
||||
@@ -24,8 +23,6 @@ import {
|
||||
import { TEditorCommands, TEditorFontStyle } from "@plane/editor";
|
||||
// ui
|
||||
import { MonospaceIcon, SansSerifIcon, SerifIcon } from "@plane/ui";
|
||||
// helpers
|
||||
import { convertRemToPixel } from "@/helpers/common.helper";
|
||||
|
||||
type TEditorTypes = "lite" | "document";
|
||||
|
||||
@@ -134,179 +131,3 @@ export const EDITOR_FONT_STYLES: {
|
||||
icon: MonospaceIcon,
|
||||
},
|
||||
];
|
||||
|
||||
const EDITOR_PDF_FONT_FAMILY_STYLES: Styles = {
|
||||
"*:not(.courier, .courier-bold)": {
|
||||
fontFamily: "Inter",
|
||||
},
|
||||
".courier": {
|
||||
fontFamily: "Courier",
|
||||
},
|
||||
".courier-bold": {
|
||||
fontFamily: "Courier-Bold",
|
||||
},
|
||||
};
|
||||
|
||||
const EDITOR_PDF_TYPOGRAPHY_STYLES: Styles = {
|
||||
// page title
|
||||
"h1.page-title": {
|
||||
fontSize: convertRemToPixel(1.6),
|
||||
fontWeight: "bold",
|
||||
marginTop: 0,
|
||||
marginBottom: convertRemToPixel(2),
|
||||
},
|
||||
// headings
|
||||
"h1:not(.page-title)": {
|
||||
fontSize: convertRemToPixel(1.4),
|
||||
fontWeight: "semibold",
|
||||
marginTop: convertRemToPixel(2),
|
||||
marginBottom: convertRemToPixel(0.25),
|
||||
},
|
||||
h2: {
|
||||
fontSize: convertRemToPixel(1.2),
|
||||
fontWeight: "semibold",
|
||||
marginTop: convertRemToPixel(1.4),
|
||||
marginBottom: convertRemToPixel(0.0625),
|
||||
},
|
||||
h3: {
|
||||
fontSize: convertRemToPixel(1.1),
|
||||
fontWeight: "semibold",
|
||||
marginTop: convertRemToPixel(1),
|
||||
marginBottom: convertRemToPixel(0.0625),
|
||||
},
|
||||
h4: {
|
||||
fontSize: convertRemToPixel(1),
|
||||
fontWeight: "semibold",
|
||||
marginTop: convertRemToPixel(1),
|
||||
marginBottom: convertRemToPixel(0.0625),
|
||||
},
|
||||
h5: {
|
||||
fontSize: convertRemToPixel(0.9),
|
||||
fontWeight: "semibold",
|
||||
marginTop: convertRemToPixel(1),
|
||||
marginBottom: convertRemToPixel(0.0625),
|
||||
},
|
||||
h6: {
|
||||
fontSize: convertRemToPixel(0.8),
|
||||
fontWeight: "semibold",
|
||||
marginTop: convertRemToPixel(1),
|
||||
marginBottom: convertRemToPixel(0.0625),
|
||||
},
|
||||
// paragraph
|
||||
"p:not(table p)": {
|
||||
fontSize: convertRemToPixel(0.8),
|
||||
},
|
||||
"p:not(ol p, ul p)": {
|
||||
marginTop: convertRemToPixel(0.25),
|
||||
marginBottom: convertRemToPixel(0.0625),
|
||||
},
|
||||
};
|
||||
|
||||
const EDITOR_PDF_LIST_STYLES: Styles = {
|
||||
"ul, ol": {
|
||||
fontSize: convertRemToPixel(0.8),
|
||||
marginHorizontal: -20,
|
||||
},
|
||||
"ol p, ul p": {
|
||||
marginVertical: 0,
|
||||
},
|
||||
"ol li, ul li": {
|
||||
marginTop: convertRemToPixel(0.45),
|
||||
},
|
||||
"ul ul, ul ol, ol ol, ol ul": {
|
||||
marginVertical: 0,
|
||||
},
|
||||
"ul[data-type='taskList']": {
|
||||
position: "relative",
|
||||
},
|
||||
"div.input-checkbox": {
|
||||
position: "absolute",
|
||||
top: convertRemToPixel(0.15),
|
||||
left: -convertRemToPixel(1.2),
|
||||
height: convertRemToPixel(0.75),
|
||||
width: convertRemToPixel(0.75),
|
||||
borderWidth: "1.5px",
|
||||
borderStyle: "solid",
|
||||
borderRadius: convertRemToPixel(0.125),
|
||||
},
|
||||
"div.input-checkbox:not(.checked)": {
|
||||
backgroundColor: "#ffffff",
|
||||
borderColor: "#171717",
|
||||
},
|
||||
"div.input-checkbox.checked": {
|
||||
backgroundColor: "#3f76ff",
|
||||
borderColor: "#3f76ff",
|
||||
},
|
||||
"ul li[data-checked='true'] p": {
|
||||
color: "#a3a3a3",
|
||||
},
|
||||
};
|
||||
|
||||
const EDITOR_PDF_CODE_STYLES: Styles = {
|
||||
// code block
|
||||
"[data-node-type='code-block']": {
|
||||
marginVertical: convertRemToPixel(0.5),
|
||||
padding: convertRemToPixel(1),
|
||||
borderRadius: convertRemToPixel(0.5),
|
||||
backgroundColor: "#f7f7f7",
|
||||
fontSize: convertRemToPixel(0.7),
|
||||
},
|
||||
// inline code block
|
||||
"[data-node-type='inline-code-block']": {
|
||||
margin: 0,
|
||||
paddingVertical: convertRemToPixel(0.25 / 4 + 0.25 / 8),
|
||||
paddingHorizontal: convertRemToPixel(0.375),
|
||||
border: "0.5px solid #e5e5e5",
|
||||
borderRadius: convertRemToPixel(0.25),
|
||||
backgroundColor: "#e8e8e8",
|
||||
color: "#f97316",
|
||||
fontSize: convertRemToPixel(0.7),
|
||||
},
|
||||
};
|
||||
|
||||
export const EDITOR_PDF_DOCUMENT_STYLESHEET = StyleSheet.create({
|
||||
...EDITOR_PDF_FONT_FAMILY_STYLES,
|
||||
...EDITOR_PDF_TYPOGRAPHY_STYLES,
|
||||
...EDITOR_PDF_LIST_STYLES,
|
||||
...EDITOR_PDF_CODE_STYLES,
|
||||
// quote block
|
||||
blockquote: {
|
||||
borderLeft: "3px solid gray",
|
||||
paddingLeft: convertRemToPixel(1),
|
||||
marginTop: convertRemToPixel(0.625),
|
||||
marginBottom: 0,
|
||||
marginHorizontal: 0,
|
||||
},
|
||||
// image
|
||||
img: {
|
||||
marginVertical: 0,
|
||||
borderRadius: convertRemToPixel(0.375),
|
||||
},
|
||||
// divider
|
||||
"div[data-type='horizontalRule']": {
|
||||
marginVertical: convertRemToPixel(1),
|
||||
height: 1,
|
||||
width: "100%",
|
||||
backgroundColor: "gray",
|
||||
},
|
||||
// mention block
|
||||
"[data-node-type='mention-block']": {
|
||||
margin: 0,
|
||||
color: "#3f76ff",
|
||||
backgroundColor: "#3f76ff33",
|
||||
paddingHorizontal: convertRemToPixel(0.375),
|
||||
},
|
||||
// table
|
||||
table: {
|
||||
marginTop: convertRemToPixel(0.5),
|
||||
marginBottom: convertRemToPixel(1),
|
||||
marginHorizontal: 0,
|
||||
},
|
||||
"table td": {
|
||||
padding: convertRemToPixel(0.625),
|
||||
border: "1px solid #e5e5e5",
|
||||
},
|
||||
"table p": {
|
||||
fontSize: convertRemToPixel(0.7),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -156,24 +156,24 @@ export const ISSUE_EXTRA_OPTIONS: {
|
||||
];
|
||||
|
||||
export const ISSUE_LAYOUT_MAP = {
|
||||
[EIssueLayoutTypes.LIST]: { key: EIssueLayoutTypes.LIST, title: "List layout", label: "List", icon: List },
|
||||
[EIssueLayoutTypes.KANBAN]: { key: EIssueLayoutTypes.KANBAN, title: "Board layout", label: "Board", icon: Kanban },
|
||||
[EIssueLayoutTypes.LIST]: { key: EIssueLayoutTypes.LIST, title: "List Layout", label: "List", icon: List },
|
||||
[EIssueLayoutTypes.KANBAN]: { key: EIssueLayoutTypes.KANBAN, title: "Kanban Layout", label: "Kanban", icon: Kanban },
|
||||
[EIssueLayoutTypes.CALENDAR]: {
|
||||
key: EIssueLayoutTypes.CALENDAR,
|
||||
title: "Calendar layout",
|
||||
title: "Calendar Layout",
|
||||
label: "Calendar",
|
||||
icon: Calendar,
|
||||
},
|
||||
[EIssueLayoutTypes.SPREADSHEET]: {
|
||||
key: EIssueLayoutTypes.SPREADSHEET,
|
||||
title: "Table layout",
|
||||
label: "Table",
|
||||
title: "Spreadsheet Layout",
|
||||
label: "Spreadsheet",
|
||||
icon: Sheet,
|
||||
},
|
||||
[EIssueLayoutTypes.GANTT]: {
|
||||
key: EIssueLayoutTypes.GANTT,
|
||||
title: "Timeline layout",
|
||||
label: "Timeline",
|
||||
title: "Gantt Chart Layout",
|
||||
label: "Gantt",
|
||||
icon: GanttChartSquare,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -62,12 +62,12 @@ export const MODULE_VIEW_LAYOUTS: { key: TModuleLayoutOptions; icon: any; title:
|
||||
{
|
||||
key: "board",
|
||||
icon: LayoutGrid,
|
||||
title: "Gallery layout",
|
||||
title: "Grid layout",
|
||||
},
|
||||
{
|
||||
key: "gantt",
|
||||
icon: GanttChartSquare,
|
||||
title: "Timeline layout",
|
||||
title: "Gantt layout",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useProject, usePage, useProjectView, useCycle, useModule } from "@/hook
|
||||
export const useFavoriteItemDetails = (workspaceSlug: string, favorite: IFavorite) => {
|
||||
const favoriteItemId = favorite?.entity_data?.id;
|
||||
const favoriteItemLogoProps = favorite?.entity_data?.logo_props;
|
||||
const favoriteItemName = favorite?.entity_data?.name || favorite?.name;
|
||||
const favoriteItemName = favorite?.entity_data.name || favorite?.name;
|
||||
const favoriteItemEntityType = favorite?.entity_type;
|
||||
|
||||
// store hooks
|
||||
|
||||
@@ -21,6 +21,8 @@ const StoreWrapper: FC<TStoreWrapper> = observer((props) => {
|
||||
const { setQuery } = useRouterParams();
|
||||
const { sidebarCollapsed, toggleSidebar } = useAppTheme();
|
||||
const { data: userProfile } = useUserProfile();
|
||||
// states
|
||||
const [dom, setDom] = useState<HTMLElement | null>(null);
|
||||
|
||||
/**
|
||||
* Sidebar collapsed fetching from local storage
|
||||
@@ -42,14 +44,36 @@ const StoreWrapper: FC<TStoreWrapper> = observer((props) => {
|
||||
const currentThemePalette = userProfile?.theme?.palette;
|
||||
if (currentTheme) {
|
||||
setTheme(currentTheme);
|
||||
if (currentTheme === "custom" && currentThemePalette) {
|
||||
if (currentTheme === "custom" && currentThemePalette && dom) {
|
||||
applyTheme(
|
||||
currentThemePalette !== ",,,," ? currentThemePalette : "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5",
|
||||
false
|
||||
false,
|
||||
dom
|
||||
);
|
||||
} else unsetCustomCssVariables();
|
||||
}
|
||||
}, [userProfile?.theme?.theme, userProfile?.theme?.palette, setTheme]);
|
||||
}, [userProfile?.theme?.theme, userProfile?.theme?.palette, setTheme, dom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dom) return;
|
||||
|
||||
const observer = new MutationObserver((mutationsList, observer) => {
|
||||
for (const mutation of mutationsList) {
|
||||
if (mutation.type === "childList") {
|
||||
const customThemeElement = window.document?.querySelector<HTMLElement>("[data-theme='custom']");
|
||||
if (customThemeElement) {
|
||||
setDom(customThemeElement);
|
||||
observer.disconnect();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [dom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!params) return;
|
||||
|
||||
@@ -55,7 +55,14 @@ export class IssueService extends APIService {
|
||||
queries?: any,
|
||||
config = {}
|
||||
): Promise<TIssuesResponse> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/v2/issues/`, { params: queries }, config)
|
||||
queries.project_id = projectId;
|
||||
return this.get(
|
||||
`/api/workspaces/${workspaceSlug}/v2/issues/`,
|
||||
{
|
||||
params: queries,
|
||||
},
|
||||
config
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
|
||||
@@ -37,5 +37,3 @@ export const debounce = (func: any, wait: number, immediate: boolean = false) =>
|
||||
};
|
||||
|
||||
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
|
||||
|
||||
export const convertRemToPixel = (rem: number): number => rem * 0.9 * 16;
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
// helpers
|
||||
import { getBase64Image } from "@/helpers/file.helper";
|
||||
|
||||
/**
|
||||
* @description function to replace all the custom components from the html component to make it pdf compatible
|
||||
* @param props
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export const replaceCustomComponentsFromHTMLContent = async (props: {
|
||||
htmlContent: string;
|
||||
noAssets?: boolean;
|
||||
}): Promise<string> => {
|
||||
const { htmlContent, noAssets = false } = props;
|
||||
// create a DOM parser
|
||||
const parser = new DOMParser();
|
||||
// parse the HTML string into a DOM document
|
||||
const doc = parser.parseFromString(htmlContent, "text/html");
|
||||
// replace all mention-component elements
|
||||
const mentionComponents = doc.querySelectorAll("mention-component");
|
||||
mentionComponents.forEach((component) => {
|
||||
// get the user label from the component (or use any other attribute)
|
||||
const label = component.getAttribute("label") || "user";
|
||||
// create a span element to replace the mention-component
|
||||
const span = doc.createElement("span");
|
||||
span.setAttribute("data-node-type", "mention-block");
|
||||
span.textContent = `@${label}`;
|
||||
// replace the mention-component with the anchor element
|
||||
component.replaceWith(span);
|
||||
});
|
||||
// handle code inside pre elements
|
||||
const preElements = doc.querySelectorAll("pre");
|
||||
preElements.forEach((preElement) => {
|
||||
const codeElement = preElement.querySelector("code");
|
||||
if (codeElement) {
|
||||
// create a div element with the required attributes for code blocks
|
||||
const div = doc.createElement("div");
|
||||
div.setAttribute("data-node-type", "code-block");
|
||||
div.setAttribute("class", "courier");
|
||||
// transfer the content from the code block
|
||||
div.innerHTML = codeElement.innerHTML.replace(/\n/g, "<br>") || "";
|
||||
// replace the pre element with the new div
|
||||
preElement.replaceWith(div);
|
||||
}
|
||||
});
|
||||
// handle inline code elements (not inside pre tags)
|
||||
const inlineCodeElements = doc.querySelectorAll("code");
|
||||
inlineCodeElements.forEach((codeElement) => {
|
||||
// check if the code element is inside a pre element
|
||||
if (!codeElement.closest("pre")) {
|
||||
// create a span element with the required attributes for inline code blocks
|
||||
const span = doc.createElement("span");
|
||||
span.setAttribute("data-node-type", "inline-code-block");
|
||||
span.setAttribute("class", "courier-bold");
|
||||
// transfer the code content
|
||||
span.textContent = codeElement.textContent || "";
|
||||
// replace the standalone code element with the new span
|
||||
codeElement.replaceWith(span);
|
||||
}
|
||||
});
|
||||
// handle image-component elements
|
||||
const imageComponents = doc.querySelectorAll("image-component");
|
||||
if (noAssets) {
|
||||
// if no assets is enabled, remove the image component elements
|
||||
imageComponents.forEach((component) => component.remove());
|
||||
// remove default img elements
|
||||
const imageElements = doc.querySelectorAll("img");
|
||||
imageElements.forEach((img) => img.remove());
|
||||
} else {
|
||||
// if no assets is not enabled, replace the image component elements with img elements
|
||||
imageComponents.forEach((component) => {
|
||||
// get the image src from the component
|
||||
const src = component.getAttribute("src") ?? "";
|
||||
const height = component.getAttribute("height") ?? "";
|
||||
const width = component.getAttribute("width") ?? "";
|
||||
// create an img element to replace the image-component
|
||||
const img = doc.createElement("img");
|
||||
img.src = src;
|
||||
img.style.height = height;
|
||||
img.style.width = width;
|
||||
// replace the image-component with the img element
|
||||
component.replaceWith(img);
|
||||
});
|
||||
}
|
||||
// convert all images to base64
|
||||
const imgElements = doc.querySelectorAll("img");
|
||||
await Promise.all(
|
||||
Array.from(imgElements).map(async (img) => {
|
||||
// get the image src from the img element
|
||||
const src = img.getAttribute("src");
|
||||
if (src) {
|
||||
try {
|
||||
const base64Image = await getBase64Image(src);
|
||||
img.src = base64Image;
|
||||
} catch (error) {
|
||||
// log the error if the image conversion fails
|
||||
console.error("Failed to convert image to base64:", error);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
// replace all checkbox elements
|
||||
const checkboxComponents = doc.querySelectorAll("input[type='checkbox']");
|
||||
checkboxComponents.forEach((component) => {
|
||||
// get the checked status from the element
|
||||
const checked = component.getAttribute("checked");
|
||||
// create a div element to replace the input element
|
||||
const div = doc.createElement("div");
|
||||
div.classList.value = "input-checkbox";
|
||||
// add the checked class if the checkbox is checked
|
||||
if (checked === "checked" || checked === "true") div.classList.add("checked");
|
||||
// replace the input element with the div element
|
||||
component.replaceWith(div);
|
||||
});
|
||||
// remove all issue-embed-component elements
|
||||
const issueEmbedComponents = doc.querySelectorAll("issue-embed-component");
|
||||
issueEmbedComponents.forEach((component) => component.remove());
|
||||
// serialize the document back into a string
|
||||
let serializedDoc = doc.body.innerHTML;
|
||||
// remove null colors from table elements
|
||||
serializedDoc = serializedDoc.replace(/background-color: null/g, "").replace(/color: null/g, "");
|
||||
return serializedDoc;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description function to replace all the custom components from the markdown content
|
||||
* @param props
|
||||
* @returns {string}
|
||||
*/
|
||||
export const replaceCustomComponentsFromMarkdownContent = (props: {
|
||||
markdownContent: string;
|
||||
noAssets?: boolean;
|
||||
}): string => {
|
||||
const { markdownContent, noAssets = false } = props;
|
||||
let parsedMarkdownContent = markdownContent;
|
||||
// replace the matched mention components with [label](redirect_uri)
|
||||
const mentionRegex = /<mention-component[^>]*label="([^"]+)"[^>]*redirect_uri="([^"]+)"[^>]*><\/mention-component>/g;
|
||||
const originUrl = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
|
||||
parsedMarkdownContent = parsedMarkdownContent.replace(
|
||||
mentionRegex,
|
||||
(_match, label, redirectUri) => `[${label}](${originUrl}/${redirectUri})`
|
||||
);
|
||||
// replace the matched image components with <img src={src} >
|
||||
const imageComponentRegex = /<image-component[^>]*src="([^"]+)"[^>]*>[^]*<\/image-component>/g;
|
||||
const imgTagRegex = /<img[^>]*src="([^"]+)"[^>]*\/?>/g;
|
||||
if (noAssets) {
|
||||
// remove all image components
|
||||
parsedMarkdownContent = parsedMarkdownContent.replace(imageComponentRegex, "").replace(imgTagRegex, "");
|
||||
} else {
|
||||
// replace the matched image components with <img src={src} >
|
||||
parsedMarkdownContent = parsedMarkdownContent.replace(imageComponentRegex, (_match, src) => `<img src="${src}" >`);
|
||||
}
|
||||
// remove all issue-embed components
|
||||
const issueEmbedRegex = /<issue-embed-component[^>]*>[^]*<\/issue-embed-component>/g;
|
||||
parsedMarkdownContent = parsedMarkdownContent.replace(issueEmbedRegex, "");
|
||||
return parsedMarkdownContent;
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
/**
|
||||
* @description encode image via URL to base64
|
||||
* @param {string} url
|
||||
* @returns
|
||||
*/
|
||||
export const getBase64Image = async (url: string): Promise<string> => {
|
||||
if (!url || typeof url !== "string") {
|
||||
throw new Error("Invalid URL provided");
|
||||
}
|
||||
|
||||
// Try to create a URL object to validate the URL
|
||||
try {
|
||||
new URL(url);
|
||||
} catch (error) {
|
||||
throw new Error("Invalid URL format");
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
// check if the response is OK
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch image: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onloadend = () => {
|
||||
if (reader.result) {
|
||||
resolve(reader.result as string);
|
||||
} else {
|
||||
reject(new Error("Failed to convert image to base64."));
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
reject(new Error("Failed to read the image file."));
|
||||
};
|
||||
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
};
|
||||
@@ -270,7 +270,7 @@ export const isCommentEmpty = (comment: string | undefined): boolean => {
|
||||
export const checkURLValidity = (url: string): boolean => {
|
||||
if (!url) return false;
|
||||
// regex to match valid URLs (with or without http/https)
|
||||
const urlPattern = /^(https?:\/\/)?([\w.-]+\.[a-z]{2,6})(\/[\w\-.~:/?#[\]@!$&'()*+,;=%]*)?$/i;
|
||||
const urlPattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z]{2,6})(\/[\w.-]*)*\/?(\?[=&\w.-]*)?$/i;
|
||||
// test if the URL matches the pattern
|
||||
return urlPattern.test(url);
|
||||
};
|
||||
|
||||
@@ -59,9 +59,8 @@ const calculateShades = (hexValue: string): TShades => {
|
||||
return shades as TShades;
|
||||
};
|
||||
|
||||
export const applyTheme = (palette: string, isDarkPalette: boolean) => {
|
||||
export const applyTheme = (palette: string, isDarkPalette: boolean, dom: HTMLElement | null) => {
|
||||
if (!palette) return;
|
||||
const themeElement = document?.querySelector("html");
|
||||
// palette: [bg, text, primary, sidebarBg, sidebarText]
|
||||
const values: string[] = palette.split(",");
|
||||
values.push(isDarkPalette ? "dark" : "light");
|
||||
@@ -81,27 +80,27 @@ export const applyTheme = (palette: string, isDarkPalette: boolean) => {
|
||||
const sidebarBackgroundRgbValues = `${sidebarBackgroundShades[shade].r}, ${sidebarBackgroundShades[shade].g}, ${sidebarBackgroundShades[shade].b}`;
|
||||
const sidebarTextRgbValues = `${sidebarTextShades[shade].r}, ${sidebarTextShades[shade].g}, ${sidebarTextShades[shade].b}`;
|
||||
|
||||
themeElement?.style.setProperty(`--color-background-${shade}`, bgRgbValues);
|
||||
themeElement?.style.setProperty(`--color-text-${shade}`, textRgbValues);
|
||||
themeElement?.style.setProperty(`--color-primary-${shade}`, primaryRgbValues);
|
||||
themeElement?.style.setProperty(`--color-sidebar-background-${shade}`, sidebarBackgroundRgbValues);
|
||||
themeElement?.style.setProperty(`--color-sidebar-text-${shade}`, sidebarTextRgbValues);
|
||||
dom?.style.setProperty(`--color-background-${shade}`, bgRgbValues);
|
||||
dom?.style.setProperty(`--color-text-${shade}`, textRgbValues);
|
||||
dom?.style.setProperty(`--color-primary-${shade}`, primaryRgbValues);
|
||||
dom?.style.setProperty(`--color-sidebar-background-${shade}`, sidebarBackgroundRgbValues);
|
||||
dom?.style.setProperty(`--color-sidebar-text-${shade}`, sidebarTextRgbValues);
|
||||
|
||||
if (i >= 100 && i <= 400) {
|
||||
const borderShade = i === 100 ? 70 : i === 200 ? 80 : i === 300 ? 90 : 100;
|
||||
|
||||
themeElement?.style.setProperty(
|
||||
dom?.style.setProperty(
|
||||
`--color-border-${shade}`,
|
||||
`${bgShades[borderShade].r}, ${bgShades[borderShade].g}, ${bgShades[borderShade].b}`
|
||||
);
|
||||
themeElement?.style.setProperty(
|
||||
dom?.style.setProperty(
|
||||
`--color-sidebar-border-${shade}`,
|
||||
`${sidebarBackgroundShades[borderShade].r}, ${sidebarBackgroundShades[borderShade].g}, ${sidebarBackgroundShades[borderShade].b}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
themeElement?.style.setProperty("--color-scheme", values[5]);
|
||||
dom?.style.setProperty("--color-scheme", values[5]);
|
||||
};
|
||||
|
||||
export const unsetCustomCssVariables = () => {
|
||||
|
||||
+1
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "0.23.0",
|
||||
"version": "0.22.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo run develop",
|
||||
@@ -32,7 +32,6 @@
|
||||
"@plane/types": "*",
|
||||
"@plane/ui": "*",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@react-pdf/renderer": "^3.4.5",
|
||||
"@sentry/nextjs": "^8.32.0",
|
||||
"@sqlite.org/sqlite-wasm": "^3.46.0-build2",
|
||||
"axios": "^1.7.4",
|
||||
@@ -58,7 +57,6 @@
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-hook-form": "7.51.5",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-pdf-html": "^2.1.2",
|
||||
"react-popper": "^2.3.0",
|
||||
"sharp": "^0.32.1",
|
||||
"smooth-scroll-into-view-if-needed": "^2.0.2",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user