Compare commits
5 Commits
4dbb7b500c
...
d9a63f5f74
| Author | SHA1 | Date |
|---|---|---|
|
|
d9a63f5f74 | |
|
|
52a05ba607 | |
|
|
a7e0a63426 | |
|
|
aa348d5d64 | |
|
|
5f9d9c418e |
34
AGENTS.md
34
AGENTS.md
|
|
@ -83,6 +83,13 @@ UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: переименован
|
||||||
|
|
||||||
Карточка задачи должна разделять постановку, план этапов и фактическую реализацию.
|
Карточка задачи должна разделять постановку, план этапов и фактическую реализацию.
|
||||||
|
|
||||||
|
Каноническая структура карточки:
|
||||||
|
- заголовок карточки
|
||||||
|
- основное описание карточки
|
||||||
|
- при необходимости текстовый блок `Текущая архитектура`
|
||||||
|
- для каждого этапа: текстовый блок этапа, затем чекер этапа
|
||||||
|
- после реализации этапа: текстовый блок `Реализация этапа N`
|
||||||
|
|
||||||
Заголовок карточки:
|
Заголовок карточки:
|
||||||
- передает основную суть задачи
|
- передает основную суть задачи
|
||||||
- должен быть коротким, читаемым в списке и без служебного шума
|
- должен быть коротким, читаемым в списке и без служебного шума
|
||||||
|
|
@ -93,20 +100,39 @@ UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: переименован
|
||||||
- не превращается в журнал работ и не дублирует чекеры
|
- не превращается в журнал работ и не дублирует чекеры
|
||||||
- остается читаемым входом в задачу после нескольких итераций
|
- остается читаемым входом в задачу после нескольких итераций
|
||||||
|
|
||||||
Подэлементы-чекеры:
|
Текстовый блок `Текущая архитектура`:
|
||||||
- создаются через `Добавить подэлемент` как отдельный чекер на каждый смысловой этап
|
- создается через `Добавить подэлемент`, если задача опирается на уже существующее поведение
|
||||||
- заголовок чекера должен называться как этап, например `Этап 1. Backend enforcement лимитов`
|
- фиксирует, что уже реализовано, что частично реализовано, а чего в системе еще нет
|
||||||
|
- отделяет существующий baseline от будущего плана, чтобы не планировать повторную реализацию уже готовых частей
|
||||||
|
- не должен содержать рабочие чекеры или журнал выполнения
|
||||||
|
|
||||||
|
Текстовый блок этапа:
|
||||||
|
- создается через `Добавить подэлемент` перед чекером соответствующего этапа
|
||||||
|
- заголовок должен называться как этап, например `Этап 1. Backend enforcement лимитов`
|
||||||
|
- тело блока описывает цель этапа, текущий статус, границы scope, зависимости и важные ограничения
|
||||||
|
- если этап пока только планируется, в теле явно указывается `Статус: не реализовано`, `Статус: частично реализовано` или `Статус: backlog`
|
||||||
|
- длинные архитектурные пояснения живут здесь, а не внутри пунктов чекера
|
||||||
|
|
||||||
|
Чекер этапа:
|
||||||
|
- создается через `Добавить подэлемент` сразу после текстового блока этапа
|
||||||
|
- заголовок чекера должен называться `Чекер этапа N. <название этапа>`
|
||||||
- пункты внутри чекера должны быть короткими проверяемыми действиями по этапу
|
- пункты внутри чекера должны быть короткими проверяемыми действиями по этапу
|
||||||
- пункт закрывается только после реализации и проверки, а не по намерению
|
- пункт закрывается только после реализации и проверки, а не по намерению
|
||||||
- чекеры используются как рабочий план, а не как место для длинных объяснений
|
- чекеры используются как рабочий план, а не как место для длинных объяснений
|
||||||
|
|
||||||
Текстовые блоки фактической реализации:
|
Текстовые блоки фактической реализации:
|
||||||
- создаются через `Добавить подэлемент` под соответствующим чекером этапа
|
- создаются через `Добавить подэлемент` под соответствующим чекером этапа только после фактической реализации
|
||||||
- заголовок текстового блока должен явно связывать его с этапом, например `Реализация этапа 1`
|
- заголовок текстового блока должен явно связывать его с этапом, например `Реализация этапа 1`
|
||||||
- блок фиксирует, что реально сделано, какие файлы/модули затронуты, какие проверки прошли и какие ограничения остались
|
- блок фиксирует, что реально сделано, какие файлы/модули затронуты, какие проверки прошли и какие ограничения остались
|
||||||
- важные нюансы для дальнейшего масштабирования записываются именно сюда, а не теряются в чате
|
- важные нюансы для дальнейшего масштабирования записываются именно сюда, а не теряются в чате
|
||||||
- после каждого осмысленного этапа соответствующий текстовый блок обновляется
|
- после каждого осмысленного этапа соответствующий текстовый блок обновляется
|
||||||
|
|
||||||
|
Декомпозиция задач:
|
||||||
|
- одна продуктовая или архитектурная тема должна жить в одной карточке, если этапы не имеют самостоятельного независимого scope
|
||||||
|
- не создавать пачку top-level карточек для этапов одной большой задачи; этапы ведутся текстовыми блоками и чекерами внутри основной карточки
|
||||||
|
- отдельная top-level карточка допустима только когда задача имеет отдельный результат, владельца, проверку и может выполняться независимо от родительского контекста
|
||||||
|
- если несколько старых карточек описывают один контекст, их нужно объединить в одну актуальную карточку и удалить или закрыть дубли
|
||||||
|
|
||||||
Статус карточки:
|
Статус карточки:
|
||||||
- `В работе` ставится только когда задача реально взята в исполнение
|
- `В работе` ставится только когда задача реально взята в исполнение
|
||||||
- `Готово` ставится после проверки результата и закрытия рабочих чекеров
|
- `Готово` ставится после проверки результата и закрытия рабочих чекеров
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from .state import StateLiteSerializer
|
||||||
from .user import UserLiteSerializer
|
from .user import UserLiteSerializer
|
||||||
from plane.app.serializers.issue import LabelSerializer
|
from plane.app.serializers.issue import LabelSerializer
|
||||||
from plane.db.models import FileAsset, IntakeIssue, Issue, IssueActivity, IssueComment, Label, Notification, Project
|
from plane.db.models import FileAsset, IntakeIssue, Issue, IssueActivity, IssueComment, Label, Notification, Project
|
||||||
|
from plane.utils.issue_checker_progress import build_issue_checker_progress
|
||||||
|
|
||||||
|
|
||||||
class ExternalContourIssuePayloadSerializer(serializers.Serializer):
|
class ExternalContourIssuePayloadSerializer(serializers.Serializer):
|
||||||
|
|
@ -86,6 +87,9 @@ class ExternalContourIssueSerializer(BaseSerializer):
|
||||||
label_ids = serializers.SerializerMethodField()
|
label_ids = serializers.SerializerMethodField()
|
||||||
project_detail = ProjectLiteSerializer(source="project", read_only=True)
|
project_detail = ProjectLiteSerializer(source="project", read_only=True)
|
||||||
state_detail = StateLiteSerializer(source="state", read_only=True)
|
state_detail = StateLiteSerializer(source="state", read_only=True)
|
||||||
|
checker_blocks_count = serializers.SerializerMethodField()
|
||||||
|
checker_items_count = serializers.SerializerMethodField()
|
||||||
|
checker_items_completed_count = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Issue
|
model = Issue
|
||||||
|
|
@ -105,6 +109,9 @@ class ExternalContourIssueSerializer(BaseSerializer):
|
||||||
"label_details",
|
"label_details",
|
||||||
"assignee_ids",
|
"assignee_ids",
|
||||||
"assignee_details",
|
"assignee_details",
|
||||||
|
"checker_blocks_count",
|
||||||
|
"checker_items_count",
|
||||||
|
"checker_items_completed_count",
|
||||||
"state_detail",
|
"state_detail",
|
||||||
"project_detail",
|
"project_detail",
|
||||||
"created_by_detail",
|
"created_by_detail",
|
||||||
|
|
@ -126,6 +133,15 @@ class ExternalContourIssueSerializer(BaseSerializer):
|
||||||
for label_bridge in obj.label_issue.all()
|
for label_bridge in obj.label_issue.all()
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def get_checker_blocks_count(self, obj):
|
||||||
|
return build_issue_checker_progress(obj.detail_layout).get("checker_blocks_count", 0)
|
||||||
|
|
||||||
|
def get_checker_items_count(self, obj):
|
||||||
|
return build_issue_checker_progress(obj.detail_layout).get("checker_items_count", 0)
|
||||||
|
|
||||||
|
def get_checker_items_completed_count(self, obj):
|
||||||
|
return build_issue_checker_progress(obj.detail_layout).get("checker_items_completed_count", 0)
|
||||||
|
|
||||||
|
|
||||||
class ExternalContourMirroredAttachmentSerializer(BaseSerializer):
|
class ExternalContourMirroredAttachmentSerializer(BaseSerializer):
|
||||||
uploaded_by = serializers.SerializerMethodField()
|
uploaded_by = serializers.SerializerMethodField()
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ from plane.utils.content_validator import (
|
||||||
validate_binary_data,
|
validate_binary_data,
|
||||||
)
|
)
|
||||||
from plane.utils.date_utils import set_default_issue_start_date
|
from plane.utils.date_utils import set_default_issue_start_date
|
||||||
|
from plane.utils.issue_checker_progress import build_issue_checker_progress
|
||||||
|
|
||||||
|
|
||||||
class IssueFlatSerializer(BaseSerializer):
|
class IssueFlatSerializer(BaseSerializer):
|
||||||
|
|
@ -773,6 +774,9 @@ class IssueSerializer(DynamicBaseSerializer):
|
||||||
|
|
||||||
# Count items
|
# Count items
|
||||||
sub_issues_count = serializers.IntegerField(read_only=True)
|
sub_issues_count = serializers.IntegerField(read_only=True)
|
||||||
|
checker_blocks_count = serializers.SerializerMethodField()
|
||||||
|
checker_items_count = serializers.SerializerMethodField()
|
||||||
|
checker_items_completed_count = serializers.SerializerMethodField()
|
||||||
attachment_count = serializers.IntegerField(read_only=True)
|
attachment_count = serializers.IntegerField(read_only=True)
|
||||||
link_count = serializers.IntegerField(read_only=True)
|
link_count = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
|
|
@ -796,6 +800,9 @@ class IssueSerializer(DynamicBaseSerializer):
|
||||||
"label_ids",
|
"label_ids",
|
||||||
"assignee_ids",
|
"assignee_ids",
|
||||||
"sub_issues_count",
|
"sub_issues_count",
|
||||||
|
"checker_blocks_count",
|
||||||
|
"checker_items_count",
|
||||||
|
"checker_items_completed_count",
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
"created_by",
|
"created_by",
|
||||||
|
|
@ -809,6 +816,15 @@ class IssueSerializer(DynamicBaseSerializer):
|
||||||
]
|
]
|
||||||
read_only_fields = fields
|
read_only_fields = fields
|
||||||
|
|
||||||
|
def get_checker_blocks_count(self, obj):
|
||||||
|
return build_issue_checker_progress(obj.detail_layout).get("checker_blocks_count", 0)
|
||||||
|
|
||||||
|
def get_checker_items_count(self, obj):
|
||||||
|
return build_issue_checker_progress(obj.detail_layout).get("checker_items_count", 0)
|
||||||
|
|
||||||
|
def get_checker_items_completed_count(self, obj):
|
||||||
|
return build_issue_checker_progress(obj.detail_layout).get("checker_items_completed_count", 0)
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
if (
|
if (
|
||||||
data.get("state_id")
|
data.get("state_id")
|
||||||
|
|
@ -868,6 +884,7 @@ class IssueListDetailSerializer(serializers.Serializer):
|
||||||
"label_ids": self.get_label_ids(instance),
|
"label_ids": self.get_label_ids(instance),
|
||||||
"assignee_ids": self.get_assignee_ids(instance),
|
"assignee_ids": self.get_assignee_ids(instance),
|
||||||
"sub_issues_count": instance.sub_issues_count,
|
"sub_issues_count": instance.sub_issues_count,
|
||||||
|
**build_issue_checker_progress(instance.detail_layout),
|
||||||
"attachment_count": instance.attachment_count,
|
"attachment_count": instance.attachment_count,
|
||||||
"link_count": instance.link_count,
|
"link_count": instance.link_count,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ from plane.utils.grouper import (
|
||||||
)
|
)
|
||||||
from plane.utils.host import base_host
|
from plane.utils.host import base_host
|
||||||
from plane.utils.issue_filters import issue_filters
|
from plane.utils.issue_filters import issue_filters
|
||||||
|
from plane.utils.issue_checker_progress import attach_issue_checker_progress
|
||||||
from plane.utils.order_queryset import order_issue_queryset
|
from plane.utils.order_queryset import order_issue_queryset
|
||||||
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
|
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
|
||||||
from plane.utils.timezone_converter import user_timezone_converter
|
from plane.utils.timezone_converter import user_timezone_converter
|
||||||
|
|
@ -190,6 +191,7 @@ class IssueListEndpoint(BaseAPIView):
|
||||||
"label_ids",
|
"label_ids",
|
||||||
"assignee_ids",
|
"assignee_ids",
|
||||||
"sub_issues_count",
|
"sub_issues_count",
|
||||||
|
"detail_layout",
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
"created_by",
|
"created_by",
|
||||||
|
|
@ -205,6 +207,7 @@ class IssueListEndpoint(BaseAPIView):
|
||||||
)
|
)
|
||||||
datetime_fields = ["created_at", "updated_at"]
|
datetime_fields = ["created_at", "updated_at"]
|
||||||
issues = user_timezone_converter(issues, datetime_fields, request.user.user_timezone)
|
issues = user_timezone_converter(issues, datetime_fields, request.user.user_timezone)
|
||||||
|
issues = attach_issue_checker_progress(issues)
|
||||||
return Response(issues, status=status.HTTP_200_OK)
|
return Response(issues, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -470,6 +473,7 @@ class IssueViewSet(BaseViewSet):
|
||||||
"label_ids",
|
"label_ids",
|
||||||
"assignee_ids",
|
"assignee_ids",
|
||||||
"sub_issues_count",
|
"sub_issues_count",
|
||||||
|
"detail_layout",
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
"created_by",
|
"created_by",
|
||||||
|
|
@ -486,6 +490,7 @@ class IssueViewSet(BaseViewSet):
|
||||||
)
|
)
|
||||||
datetime_fields = ["created_at", "updated_at"]
|
datetime_fields = ["created_at", "updated_at"]
|
||||||
issue = user_timezone_converter(issue, datetime_fields, request.user.user_timezone)
|
issue = user_timezone_converter(issue, datetime_fields, request.user.user_timezone)
|
||||||
|
issue = attach_issue_checker_progress([issue])[0] if issue else issue
|
||||||
# Send the model activity
|
# Send the model activity
|
||||||
model_activity.delay(
|
model_activity.delay(
|
||||||
model_name="issue",
|
model_name="issue",
|
||||||
|
|
@ -893,6 +898,7 @@ class IssuePaginatedViewSet(BaseViewSet):
|
||||||
# converting the datetime fields in paginated data
|
# converting the datetime fields in paginated data
|
||||||
datetime_fields = ["created_at", "updated_at"]
|
datetime_fields = ["created_at", "updated_at"]
|
||||||
paginated_data = user_timezone_converter(paginated_data, datetime_fields, timezone)
|
paginated_data = user_timezone_converter(paginated_data, datetime_fields, timezone)
|
||||||
|
paginated_data = attach_issue_checker_progress(paginated_data)
|
||||||
|
|
||||||
return paginated_data
|
return paginated_data
|
||||||
|
|
||||||
|
|
@ -930,6 +936,7 @@ class IssuePaginatedViewSet(BaseViewSet):
|
||||||
"link_count",
|
"link_count",
|
||||||
"attachment_count",
|
"attachment_count",
|
||||||
"sub_issues_count",
|
"sub_issues_count",
|
||||||
|
"detail_layout",
|
||||||
]
|
]
|
||||||
|
|
||||||
if str(is_description_required).lower() == "true":
|
if str(is_description_required).lower() == "true":
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ from plane.db.models import (
|
||||||
ModuleIssue,
|
ModuleIssue,
|
||||||
IssueLabel,
|
IssueLabel,
|
||||||
)
|
)
|
||||||
|
from plane.utils.issue_checker_progress import attach_issue_checker_progress
|
||||||
from typing import Optional, Dict, Tuple, Any, Union, List
|
from typing import Optional, Dict, Tuple, Any, Union, List
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -118,6 +119,7 @@ def issue_on_results(
|
||||||
"parent_id",
|
"parent_id",
|
||||||
"cycle_id",
|
"cycle_id",
|
||||||
"sub_issues_count",
|
"sub_issues_count",
|
||||||
|
"detail_layout",
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
"created_by",
|
"created_by",
|
||||||
|
|
@ -141,7 +143,7 @@ def issue_on_results(
|
||||||
original_list.append(sub_group_by)
|
original_list.append(sub_group_by)
|
||||||
|
|
||||||
required_fields.extend(original_list)
|
required_fields.extend(original_list)
|
||||||
return list(issues.values(*required_fields))
|
return attach_issue_checker_progress(issues.values(*required_fields))
|
||||||
|
|
||||||
|
|
||||||
def issue_group_values(
|
def issue_group_values(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
# See the LICENSE file for details.
|
||||||
|
|
||||||
|
STRUCTURED_BLOCKS_KEY = "nodedc_structured_blocks"
|
||||||
|
|
||||||
|
|
||||||
|
def build_issue_checker_progress(detail_layout):
|
||||||
|
blocks = detail_layout.get(STRUCTURED_BLOCKS_KEY) if isinstance(detail_layout, dict) else []
|
||||||
|
|
||||||
|
checker_blocks_count = 0
|
||||||
|
checker_items_count = 0
|
||||||
|
checker_items_completed_count = 0
|
||||||
|
|
||||||
|
if not isinstance(blocks, list):
|
||||||
|
blocks = []
|
||||||
|
|
||||||
|
for block in blocks:
|
||||||
|
if not isinstance(block, dict) or block.get("type") != "checker":
|
||||||
|
continue
|
||||||
|
|
||||||
|
checker_blocks_count += 1
|
||||||
|
items = block.get("items")
|
||||||
|
|
||||||
|
if not isinstance(items, list):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
checker_items_count += 1
|
||||||
|
if item.get("checked"):
|
||||||
|
checker_items_completed_count += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"checker_blocks_count": checker_blocks_count,
|
||||||
|
"checker_items_count": checker_items_count,
|
||||||
|
"checker_items_completed_count": checker_items_completed_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def attach_issue_checker_progress(items):
|
||||||
|
prepared_items = list(items)
|
||||||
|
|
||||||
|
for item in prepared_items:
|
||||||
|
detail_layout = item.pop("detail_layout", None)
|
||||||
|
item.update(build_issue_checker_progress(detail_layout))
|
||||||
|
|
||||||
|
return prepared_items
|
||||||
|
|
@ -78,6 +78,14 @@ const designConfigStyle = {
|
||||||
"--bg-accent-primary": formatCssRgb(accentRgb),
|
"--bg-accent-primary": formatCssRgb(accentRgb),
|
||||||
"--bg-accent-primary-hover": formatCssRgb(accentHoverRgb),
|
"--bg-accent-primary-hover": formatCssRgb(accentHoverRgb),
|
||||||
"--bg-accent-primary-active": formatCssRgb(accentActiveRgb),
|
"--bg-accent-primary-active": formatCssRgb(accentActiveRgb),
|
||||||
|
"--txt-accent-primary": formatCssRgb(accentRgb),
|
||||||
|
"--txt-accent-secondary": formatCssRgb(blendRgb(accentRgb, 0, 0.25)),
|
||||||
|
"--txt-icon-accent-primary": formatCssRgb(accentRgb),
|
||||||
|
"--text-color-accent-primary": formatCssRgb(accentRgb),
|
||||||
|
"--text-color-accent-secondary": formatCssRgb(blendRgb(accentRgb, 0, 0.25)),
|
||||||
|
"--text-color-icon-accent-primary": formatCssRgb(accentRgb),
|
||||||
|
"--stroke-accent-primary": formatCssRgb(accentRgb),
|
||||||
|
"--fill-accent-primary": formatCssRgb(accentRgb),
|
||||||
"--txt-on-color": formatCssRgb(onAccentRgb),
|
"--txt-on-color": formatCssRgb(onAccentRgb),
|
||||||
"--txt-icon-on-color": formatCssRgb(onAccentRgb),
|
"--txt-icon-on-color": formatCssRgb(onAccentRgb),
|
||||||
} as CSSProperties;
|
} as CSSProperties;
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import { useProjectState } from "@/hooks/store/use-project-state";
|
||||||
import { useUserPermissions } from "@/hooks/store/user";
|
import { useUserPermissions } from "@/hooks/store/user";
|
||||||
import { IssueArchiveService } from "@/services/issue/issue_archive.service";
|
import { IssueArchiveService } from "@/services/issue/issue_archive.service";
|
||||||
import { IssueService } from "@/services/issue/issue.service";
|
import { IssueService } from "@/services/issue/issue.service";
|
||||||
|
import { NodedcWorkItemProgress } from "@/components/issues/issue-layouts/shared/nodedc-work-item-card";
|
||||||
import { ExternalContourDeleteModal } from "./delete-modal";
|
import { ExternalContourDeleteModal } from "./delete-modal";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -128,7 +129,7 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
|
||||||
? projectStateIds.map((stateId) => getStateById(stateId)).filter((state): state is IState => !!state)
|
? projectStateIds.map((stateId) => getStateById(stateId)).filter((state): state is IState => !!state)
|
||||||
: sourceStateIds.map((stateId) => sourceStateMap[stateId]).filter((state): state is IState => !!state);
|
: sourceStateIds.map((stateId) => sourceStateMap[stateId]).filter((state): state is IState => !!state);
|
||||||
const foregroundClasses = isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-white";
|
const foregroundClasses = isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-white";
|
||||||
const subtleTextClasses = isActive ? "text-[#2F4721]" : "text-[#B3B3B8]";
|
const subtleTextClasses = isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]/70" : "text-[#B3B3B8]";
|
||||||
const pillBackgroundClasses = isActive
|
const pillBackgroundClasses = isActive
|
||||||
? "bg-black/10 text-[rgb(var(--nodedc-on-card-active-rgb))]"
|
? "bg-black/10 text-[rgb(var(--nodedc-on-card-active-rgb))]"
|
||||||
: "bg-[rgb(var(--nodedc-card-passive-rgb))] text-white";
|
: "bg-[rgb(var(--nodedc-card-passive-rgb))] text-white";
|
||||||
|
|
@ -143,6 +144,10 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
|
||||||
isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-white"
|
isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-white"
|
||||||
);
|
);
|
||||||
const dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none");
|
const dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none");
|
||||||
|
const checkerBlocksTotal = issue.checker_blocks_count ?? 0;
|
||||||
|
const checkerItemsTotal = issue.checker_items_count ?? 0;
|
||||||
|
const checkerItemsCompleted = issue.checker_items_completed_count ?? 0;
|
||||||
|
const hasCheckerProgress = checkerBlocksTotal > 0;
|
||||||
const canArchive = canEditTargetIssue && !!selectedState && ARCHIVABLE_STATE_GROUPS.includes(selectedState.group);
|
const canArchive = canEditTargetIssue && !!selectedState && ARCHIVABLE_STATE_GROUPS.includes(selectedState.group);
|
||||||
|
|
||||||
if (!issue) return null;
|
if (!issue) return null;
|
||||||
|
|
@ -330,14 +335,14 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
buttonClassName="h-12 w-12"
|
buttonClassName="h-12 w-12"
|
||||||
menuClassName="min-w-[18rem]"
|
menuClassName="nodedc-work-item-action-menu"
|
||||||
onOpenChange={(isOpen) => {
|
onOpenChange={(isOpen) => {
|
||||||
if (isOpen) void ensureSourceOptions();
|
if (isOpen) void ensureSourceOptions();
|
||||||
}}
|
}}
|
||||||
items={[]}
|
items={[]}
|
||||||
menuContent={({ closeDropdown }) => (
|
menuContent={({ closeDropdown }) => (
|
||||||
<div className="max-h-[calc(100vh-2rem)] space-y-2 overflow-y-auto" onClick={stopCardPropagation}>
|
<div className="nodedc-work-item-action-grid" onClick={stopCardPropagation}>
|
||||||
<div className="space-y-1">
|
<div className="nodedc-work-item-action-section space-y-1">
|
||||||
<div className="px-2 text-[10px] font-semibold tracking-[0.16em] text-tertiary uppercase">
|
<div className="px-2 text-[10px] font-semibold tracking-[0.16em] text-tertiary uppercase">
|
||||||
Приоритет
|
Приоритет
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -358,7 +363,7 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1 border-t border-white/8 pt-2">
|
<div className="nodedc-work-item-action-section space-y-1">
|
||||||
<div className="px-2 text-[10px] font-semibold tracking-[0.16em] text-tertiary uppercase">
|
<div className="px-2 text-[10px] font-semibold tracking-[0.16em] text-tertiary uppercase">
|
||||||
Статус
|
Статус
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -388,7 +393,7 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1 border-t border-white/8 pt-2">
|
<div className="nodedc-work-item-action-section space-y-1">
|
||||||
<div className="px-2 text-[10px] font-semibold tracking-[0.16em] text-tertiary uppercase">
|
<div className="px-2 text-[10px] font-semibold tracking-[0.16em] text-tertiary uppercase">
|
||||||
Быстрые действия
|
Быстрые действия
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -459,8 +464,26 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 items-center justify-start px-1 pt-7 pb-4 text-left">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 items-center pb-4",
|
||||||
|
hasCheckerProgress ? "justify-center px-0.5 pt-4 text-left" : "justify-start px-1 pt-7 text-left"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{hasCheckerProgress ? (
|
||||||
|
<div className="flex w-full flex-col items-stretch gap-3">
|
||||||
|
<NodedcWorkItemProgress
|
||||||
|
completedCount={checkerItemsCompleted}
|
||||||
|
totalCount={checkerItemsTotal}
|
||||||
|
isActive={isActive}
|
||||||
|
/>
|
||||||
|
<div className="line-clamp-4 w-full text-left text-[15px] leading-5 font-medium">
|
||||||
|
{issue.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="line-clamp-5 max-w-full text-[15px] leading-5 font-medium">{issue.name}</div>
|
<div className="line-clamp-5 max-w-full text-[15px] leading-5 font-medium">{issue.name}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-3" onClick={stopCardPropagation}>
|
<div className="flex items-center justify-between gap-3" onClick={stopCardPropagation}>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ import AnalyticsSectionWrapper from "../analytics-section-wrapper";
|
||||||
import { ChartLoader } from "../loaders";
|
import { ChartLoader } from "../loaders";
|
||||||
|
|
||||||
const analyticsService = new AnalyticsService();
|
const analyticsService = new AnalyticsService();
|
||||||
|
const NODEDC_ANALYTICS_ACCENT = "rgb(var(--nodedc-accent-rgb))";
|
||||||
|
const NODEDC_ANALYTICS_ACCENT_SOFT = "rgb(var(--nodedc-accent-rgb) / 0.18)";
|
||||||
const CreatedVsResolved = observer(function CreatedVsResolved() {
|
const CreatedVsResolved = observer(function CreatedVsResolved() {
|
||||||
const {
|
const {
|
||||||
selectedDuration,
|
selectedDuration,
|
||||||
|
|
@ -66,12 +68,12 @@ const CreatedVsResolved = observer(function CreatedVsResolved() {
|
||||||
{
|
{
|
||||||
key: "completed_issues",
|
key: "completed_issues",
|
||||||
label: "Решено",
|
label: "Решено",
|
||||||
fill: "rgba(195, 255, 102, 0.18)",
|
fill: NODEDC_ANALYTICS_ACCENT_SOFT,
|
||||||
fillOpacity: 1,
|
fillOpacity: 1,
|
||||||
stackId: "bar-one",
|
stackId: "bar-one",
|
||||||
showDot: false,
|
showDot: false,
|
||||||
smoothCurves: true,
|
smoothCurves: true,
|
||||||
strokeColor: "#C3FF66",
|
strokeColor: NODEDC_ANALYTICS_ACCENT,
|
||||||
strokeOpacity: 1,
|
strokeOpacity: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ interface ParamsProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NODEDC_ANALYTICS_COLORS = [
|
export const NODEDC_ANALYTICS_COLORS = [
|
||||||
"#C3FF66",
|
"rgb(var(--nodedc-accent-rgb))",
|
||||||
"#F5F7FB",
|
"#F5F7FB",
|
||||||
"#7C7F85",
|
"#7C7F85",
|
||||||
"#050505",
|
"#050505",
|
||||||
|
|
@ -29,12 +29,12 @@ const STATE_GROUP_COLORS: Record<TStateGroups, string> = {
|
||||||
backlog: "#050505",
|
backlog: "#050505",
|
||||||
unstarted: "#7C7F85",
|
unstarted: "#7C7F85",
|
||||||
started: "#FFFFFF",
|
started: "#FFFFFF",
|
||||||
completed: "#C3FF66",
|
completed: "rgb(var(--nodedc-accent-rgb))",
|
||||||
cancelled: "#050505",
|
cancelled: "#050505",
|
||||||
};
|
};
|
||||||
|
|
||||||
const PRIORITY_COLORS: Record<string, string> = {
|
const PRIORITY_COLORS: Record<string, string> = {
|
||||||
urgent: "#C3FF66",
|
urgent: "rgb(var(--nodedc-accent-rgb))",
|
||||||
high: "#F5F7FB",
|
high: "#F5F7FB",
|
||||||
medium: "#7C7F85",
|
medium: "#7C7F85",
|
||||||
low: "#2A2B2E",
|
low: "#2A2B2E",
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,14 @@ const getTimelineBounds = (items: TGanttTimelinePreviewItem[], settings: TRangeS
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTimelineTicks = (startDate: Date, endDate: Date, dayWidth: number, stepDays: number, locale: string) => {
|
const getTimelineTicks = (
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date,
|
||||||
|
dayWidth: number,
|
||||||
|
stepDays: number,
|
||||||
|
locale: string,
|
||||||
|
today: Date
|
||||||
|
) => {
|
||||||
const formatter = new Intl.DateTimeFormat(locale || "ru-RU", {
|
const formatter = new Intl.DateTimeFormat(locale || "ru-RU", {
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
month: "short",
|
month: "short",
|
||||||
|
|
@ -178,6 +185,7 @@ const getTimelineTicks = (startDate: Date, endDate: Date, dayWidth: number, step
|
||||||
const date = addDays(startDate, index * stepDays);
|
const date = addDays(startDate, index * stepDays);
|
||||||
return {
|
return {
|
||||||
id: date.toISOString(),
|
id: date.toISOString(),
|
||||||
|
isToday: startOfDay(date).getTime() === startOfDay(today).getTime(),
|
||||||
label: formatter.format(date).toUpperCase(),
|
label: formatter.format(date).toUpperCase(),
|
||||||
left: getDaysBetween(startDate, date) * dayWidth,
|
left: getDaysBetween(startDate, date) * dayWidth,
|
||||||
};
|
};
|
||||||
|
|
@ -320,8 +328,7 @@ export function GanttTimelinePreview(props: TGanttTimelinePreviewProps) {
|
||||||
blocks,
|
blocks,
|
||||||
canvasWidth,
|
canvasWidth,
|
||||||
gridLines: getTimelineGridLines(startDate, endDate, settings.dayWidth),
|
gridLines: getTimelineGridLines(startDate, endDate, settings.dayWidth),
|
||||||
ticks: getTimelineTicks(startDate, endDate, settings.dayWidth, settings.tickStepDays, locale),
|
ticks: getTimelineTicks(startDate, endDate, settings.dayWidth, settings.tickStepDays, locale, today),
|
||||||
todayLabel: getShortDateLabel(today, locale),
|
|
||||||
timelineWidth,
|
timelineWidth,
|
||||||
todayLeft: getDaysBetween(startDate, today) * settings.dayWidth,
|
todayLeft: getDaysBetween(startDate, today) * settings.dayWidth,
|
||||||
};
|
};
|
||||||
|
|
@ -557,13 +564,17 @@ export function GanttTimelinePreview(props: TGanttTimelinePreviewProps) {
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{timeline.ticks.map((tick) => (
|
{timeline.ticks.map((tick) => (
|
||||||
<div key={tick.id} className="nodedc-home-gantt-grid-label" style={{ left: `${tick.left}px` }}>
|
<div
|
||||||
{tick.label}
|
key={tick.id}
|
||||||
|
className={cn("nodedc-home-gantt-grid-label", {
|
||||||
|
"nodedc-home-gantt-grid-label-today": tick.isToday,
|
||||||
|
})}
|
||||||
|
style={{ left: `${tick.left}px` }}
|
||||||
|
>
|
||||||
|
<span>{tick.label}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="nodedc-home-gantt-today-marker" style={{ left: `${timeline.todayLeft}px` }}>
|
<div className="nodedc-home-gantt-today-marker" style={{ left: `${timeline.todayLeft}px` }} />
|
||||||
<span className="nodedc-home-gantt-today-pill">Сегодня · {timeline.todayLabel}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedPreviewItemId && selectedPreviewItem && (
|
{selectedPreviewItemId && selectedPreviewItem && (
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,11 @@ import { useMember } from "@/hooks/store/use-member";
|
||||||
import { useProject } from "@/hooks/store/use-project";
|
import { useProject } from "@/hooks/store/use-project";
|
||||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||||
import { NodedcWorkItemCard, getNodedcWorkItemCardAppearance } from "../shared/nodedc-work-item-card";
|
import {
|
||||||
|
NodedcWorkItemCard,
|
||||||
|
NodedcWorkItemProgress,
|
||||||
|
getNodedcWorkItemCardAppearance,
|
||||||
|
} from "../shared/nodedc-work-item-card";
|
||||||
import type { TRenderQuickActions } from "../list/list-view-types";
|
import type { TRenderQuickActions } from "../list/list-view-types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -78,6 +82,10 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
|
||||||
const creatorId = creatorDetails?.id || issue.created_by;
|
const creatorId = creatorDetails?.id || issue.created_by;
|
||||||
const workspaceSlug = routerWorkspaceSlug?.toString();
|
const workspaceSlug = routerWorkspaceSlug?.toString();
|
||||||
const dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none");
|
const dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none");
|
||||||
|
const checkerBlocksTotal = issue.checker_blocks_count ?? 0;
|
||||||
|
const checkerItemsTotal = issue.checker_items_count ?? 0;
|
||||||
|
const checkerItemsCompleted = issue.checker_items_completed_count ?? 0;
|
||||||
|
const hasCheckerProgress = checkerBlocksTotal > 0;
|
||||||
const cornerControlClasses = cn(
|
const cornerControlClasses = cn(
|
||||||
"flex h-12 w-12 -translate-x-0.5 -translate-y-0.5 items-center justify-center rounded-full border bg-transparent shadow-none ring-0 transition-colors outline-none",
|
"flex h-12 w-12 -translate-x-0.5 -translate-y-0.5 items-center justify-center rounded-full border bg-transparent shadow-none ring-0 transition-colors outline-none",
|
||||||
isActive
|
isActive
|
||||||
|
|
@ -96,7 +104,7 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
|
||||||
};
|
};
|
||||||
const menuContentBefore = ({ closeDropdown }: { closeDropdown: () => void }) => (
|
const menuContentBefore = ({ closeDropdown }: { closeDropdown: () => void }) => (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-1">
|
<div className="nodedc-work-item-action-section space-y-1">
|
||||||
<div className="px-2 text-[10px] font-semibold tracking-[0.16em] text-tertiary uppercase">Приоритет</div>
|
<div className="px-2 text-[10px] font-semibold tracking-[0.16em] text-tertiary uppercase">Приоритет</div>
|
||||||
{priorityOptions.map((priority) => (
|
{priorityOptions.map((priority) => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -115,7 +123,7 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1 border-t border-white/8 pt-2">
|
<div className="nodedc-work-item-action-section space-y-1">
|
||||||
<div className="px-2 text-[10px] font-semibold tracking-[0.16em] text-tertiary uppercase">Статус</div>
|
<div className="px-2 text-[10px] font-semibold tracking-[0.16em] text-tertiary uppercase">Статус</div>
|
||||||
{stateOptions.map((state) => (
|
{stateOptions.map((state) => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -138,12 +146,6 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-white/8 pt-2">
|
|
||||||
<div className="px-2 text-[10px] font-semibold tracking-[0.16em] text-tertiary uppercase">
|
|
||||||
Быстрые действия
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
const dateButton = (
|
const dateButton = (
|
||||||
|
|
@ -204,7 +206,12 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
|
||||||
|
|
||||||
<div className="min-w-0 pr-[162px] pl-[58px] pt-1">
|
<div className="min-w-0 pr-[162px] pl-[58px] pt-1">
|
||||||
<div className="truncate text-body-sm-medium leading-5">{creatorName}</div>
|
<div className="truncate text-body-sm-medium leading-5">{creatorName}</div>
|
||||||
<div className={cn("truncate text-[10px] leading-3.5 font-medium", isActive ? "text-[#2F4721]" : "text-[#B3B3B8]")}>
|
<div
|
||||||
|
className={cn(
|
||||||
|
"truncate text-[10px] leading-3.5 font-medium",
|
||||||
|
isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]/70" : "text-[#B3B3B8]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
{sourceContourName}
|
{sourceContourName}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -214,7 +221,7 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
|
||||||
issue,
|
issue,
|
||||||
parentRef: cardRef,
|
parentRef: cardRef,
|
||||||
customActionButton,
|
customActionButton,
|
||||||
menuClassName: "min-w-[18rem]",
|
menuClassName: "nodedc-work-item-action-menu",
|
||||||
menuContentBefore,
|
menuContentBefore,
|
||||||
placement: "bottom-end",
|
placement: "bottom-end",
|
||||||
})}
|
})}
|
||||||
|
|
@ -222,7 +229,16 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const title = (
|
const title = hasCheckerProgress ? (
|
||||||
|
<div className="flex w-full flex-col items-stretch gap-3">
|
||||||
|
<NodedcWorkItemProgress
|
||||||
|
completedCount={checkerItemsCompleted}
|
||||||
|
totalCount={checkerItemsTotal}
|
||||||
|
isActive={isActive}
|
||||||
|
/>
|
||||||
|
<div className="line-clamp-4 w-full text-left text-[15px] leading-5 font-medium">{issue.name}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="line-clamp-5 max-w-full text-[15px] leading-5 font-medium">{issue.name}</div>
|
<div className="line-clamp-5 max-w-full text-[15px] leading-5 font-medium">{issue.name}</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -262,7 +278,9 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
|
||||||
contentClassName="min-h-[220px] px-1"
|
contentClassName="min-h-[220px] px-1"
|
||||||
header={header}
|
header={header}
|
||||||
title={title}
|
title={title}
|
||||||
titleContainerClassName="justify-start px-1 pt-7 pb-4 text-left"
|
titleContainerClassName={cn(
|
||||||
|
hasCheckerProgress ? "justify-center px-0.5 pt-4 pb-4 text-left" : "justify-start px-1 pt-7 pb-4 text-left"
|
||||||
|
)}
|
||||||
titleClassName="line-clamp-none w-full text-[15px] leading-5 font-medium"
|
titleClassName="line-clamp-none w-full text-[15px] leading-5 font-medium"
|
||||||
footer={footer}
|
footer={footer}
|
||||||
footerClassName="pointer-events-auto items-end gap-3"
|
footerClassName="pointer-events-auto items-end gap-3"
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ import { useParams } from "next/navigation";
|
||||||
import { ARCHIVABLE_STATE_GROUPS, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
import { ARCHIVABLE_STATE_GROUPS, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||||
import type { TIssue } from "@plane/types";
|
import type { TIssue } from "@plane/types";
|
||||||
import { EIssuesStoreType } from "@plane/types";
|
import { EIssuesStoreType } from "@plane/types";
|
||||||
import { ActionDropdown, ContextMenu } from "@plane/ui";
|
import { ActionDropdown, ContextMenu, type TContextMenuItem } from "@plane/ui";
|
||||||
|
import { cn } from "@plane/utils";
|
||||||
// hooks
|
// hooks
|
||||||
import { useIssues } from "@/hooks/store/use-issues";
|
import { useIssues } from "@/hooks/store/use-issues";
|
||||||
import { useProject } from "@/hooks/store/use-project";
|
import { useProject } from "@/hooks/store/use-project";
|
||||||
|
|
@ -102,6 +103,56 @@ export const ProjectIssueQuickActions = observer(function ProjectIssueQuickActio
|
||||||
};
|
};
|
||||||
|
|
||||||
const MENU_ITEMS = useProjectIssueMenuItems(menuItemProps);
|
const MENU_ITEMS = useProjectIssueMenuItems(menuItemProps);
|
||||||
|
const shouldUseNodedcActionGrid = menuClassName?.includes("nodedc-work-item-action-menu") ?? false;
|
||||||
|
const DROPDOWN_MENU_ITEMS = shouldUseNodedcActionGrid
|
||||||
|
? MENU_ITEMS.filter((item) => item.key !== "open-in-new-tab")
|
||||||
|
: MENU_ITEMS;
|
||||||
|
|
||||||
|
const renderNodedcMenuItem = (item: TContextMenuItem, closeDropdown: () => void) => {
|
||||||
|
if (item.shouldRender === false) return null;
|
||||||
|
|
||||||
|
const Icon = item.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 rounded-[0.9rem] px-2.5 py-2 text-left text-12 text-secondary transition-colors",
|
||||||
|
{
|
||||||
|
"cursor-not-allowed text-placeholder": item.disabled,
|
||||||
|
"hover:bg-white/6": !item.disabled,
|
||||||
|
},
|
||||||
|
item.className
|
||||||
|
)}
|
||||||
|
disabled={item.disabled}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
if (item.disabled) return;
|
||||||
|
|
||||||
|
item.action();
|
||||||
|
closeDropdown();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Icon && <Icon className={cn("h-3.5 w-3.5 shrink-0", item.iconClassName)} />}
|
||||||
|
<span className="min-w-0 truncate">{item.title}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderNodedcMenuContent = ({ closeDropdown }: { closeDropdown: () => void }) => (
|
||||||
|
<div className="nodedc-work-item-action-grid">
|
||||||
|
{typeof menuContentBefore === "function" ? menuContentBefore({ closeDropdown }) : menuContentBefore}
|
||||||
|
<div className="nodedc-work-item-action-section space-y-1">
|
||||||
|
<div className="px-2 text-[10px] font-semibold tracking-[0.16em] text-tertiary uppercase">
|
||||||
|
Быстрые действия
|
||||||
|
</div>
|
||||||
|
{DROPDOWN_MENU_ITEMS.map((item) => renderNodedcMenuItem(item, closeDropdown))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const CONTEXT_MENU_ITEMS = MENU_ITEMS.map(function CONTEXT_MENU_ITEMS(item) {
|
const CONTEXT_MENU_ITEMS = MENU_ITEMS.map(function CONTEXT_MENU_ITEMS(item) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -153,9 +204,10 @@ export const ProjectIssueQuickActions = observer(function ProjectIssueQuickActio
|
||||||
<ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} />
|
<ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} />
|
||||||
<ActionDropdown
|
<ActionDropdown
|
||||||
button={customActionButton}
|
button={customActionButton}
|
||||||
items={MENU_ITEMS}
|
items={shouldUseNodedcActionGrid ? [] : MENU_ITEMS}
|
||||||
menuClassName={menuClassName}
|
menuClassName={menuClassName}
|
||||||
menuContentBefore={menuContentBefore}
|
menuContent={shouldUseNodedcActionGrid ? renderNodedcMenuContent : undefined}
|
||||||
|
menuContentBefore={shouldUseNodedcActionGrid ? undefined : menuContentBefore}
|
||||||
placement={placements}
|
placement={placements}
|
||||||
portalElement={portalElement}
|
portalElement={portalElement}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -30,13 +30,65 @@ export const getNodedcWorkItemCardAppearance = (isActive: boolean) => ({
|
||||||
foregroundClasses: isActive
|
foregroundClasses: isActive
|
||||||
? "text-[rgb(var(--nodedc-on-card-active-rgb))]"
|
? "text-[rgb(var(--nodedc-on-card-active-rgb))]"
|
||||||
: "text-[rgb(var(--nodedc-on-card-passive-rgb))]",
|
: "text-[rgb(var(--nodedc-on-card-passive-rgb))]",
|
||||||
subtleTextClasses: isActive ? "text-[#2F4721]" : "text-[#B3B3B8]",
|
subtleTextClasses: isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]/70" : "text-[#B3B3B8]",
|
||||||
pillBackgroundClasses: isActive
|
pillBackgroundClasses: isActive
|
||||||
? "bg-black/10 text-[rgb(var(--nodedc-on-card-active-rgb))]"
|
? "bg-black/10 text-[rgb(var(--nodedc-on-card-active-rgb))]"
|
||||||
: "bg-[rgb(var(--nodedc-card-passive-rgb))] text-[rgb(var(--nodedc-on-card-passive-rgb))]",
|
: "bg-[rgb(var(--nodedc-card-passive-rgb))] text-[rgb(var(--nodedc-on-card-passive-rgb))]",
|
||||||
iconBubbleClasses: isActive ? "bg-black text-[rgb(var(--nodedc-card-active-rgb))]" : "bg-[#111214] text-white",
|
iconBubbleClasses: isActive ? "bg-black text-[rgb(var(--nodedc-card-active-rgb))]" : "bg-[#111214] text-white",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type TNodedcWorkItemProgressProps = {
|
||||||
|
completedCount?: number | null;
|
||||||
|
totalCount?: number | null;
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NodedcWorkItemProgress = (props: TNodedcWorkItemProgressProps) => {
|
||||||
|
const { completedCount, totalCount, isActive } = props;
|
||||||
|
const total = Math.max(totalCount ?? 0, 0);
|
||||||
|
const completed = Math.min(Math.max(completedCount ?? 0, 0), total);
|
||||||
|
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||||
|
const segments = [0, 1, 2].map((segmentIndex) => {
|
||||||
|
const segmentStart = segmentIndex * (100 / 3);
|
||||||
|
const segmentEnd = (segmentIndex + 1) * (100 / 3);
|
||||||
|
const filled = Math.min(Math.max(((percentage - segmentStart) / (segmentEnd - segmentStart)) * 100, 0), 100);
|
||||||
|
|
||||||
|
return filled;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full pt-5">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"absolute -top-1 right-6 translate-x-1/2 text-center text-[8px] leading-[10px] font-medium",
|
||||||
|
isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]/70" : "text-[#B3B3B8]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{percentage}%
|
||||||
|
</span>
|
||||||
|
<div className="flex h-[2.5px] w-full gap-[2.5px]">
|
||||||
|
{segments.map((segmentFill, segmentIndex) => (
|
||||||
|
<div
|
||||||
|
key={segmentIndex}
|
||||||
|
className={cn(
|
||||||
|
"h-full flex-1 overflow-hidden rounded-full",
|
||||||
|
isActive ? "bg-[rgb(var(--nodedc-on-card-active-rgb))]/20" : "bg-white/16"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-full rounded-full",
|
||||||
|
isActive ? "bg-[rgb(var(--nodedc-on-card-active-rgb))]" : "bg-[rgb(var(--nodedc-accent-rgb))]"
|
||||||
|
)}
|
||||||
|
style={{ width: `${segmentFill}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const NodedcWorkItemCard = ({
|
export const NodedcWorkItemCard = ({
|
||||||
isActive,
|
isActive,
|
||||||
priority,
|
priority,
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ export const PROFILE_STATE_GROUP_COLORS: Partial<Record<TStateGroups, string>> =
|
||||||
backlog: "#050505",
|
backlog: "#050505",
|
||||||
unstarted: "#7C7F85",
|
unstarted: "#7C7F85",
|
||||||
started: "#F5F7FB",
|
started: "#F5F7FB",
|
||||||
completed: "#C3FF66",
|
completed: "rgb(var(--nodedc-accent-rgb))",
|
||||||
cancelled: "#050505",
|
cancelled: "#050505",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -23,7 +23,7 @@ export const PROFILE_STATE_GROUP_LABELS: Partial<Record<TStateGroups, string>> =
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PROFILE_PRIORITY_COLORS: Record<string, string> = {
|
export const PROFILE_PRIORITY_COLORS: Record<string, string> = {
|
||||||
urgent: "#C3FF66",
|
urgent: "rgb(var(--nodedc-accent-rgb))",
|
||||||
high: "#F5F7FB",
|
high: "#F5F7FB",
|
||||||
medium: "#7C7F85",
|
medium: "#7C7F85",
|
||||||
low: "#2A2B2E",
|
low: "#2A2B2E",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,283 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* See the LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Fragment, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Popover, Transition } from "@headlessui/react";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import * as ColorPicker from "react-color";
|
||||||
|
import type { ColorResult } from "react-color";
|
||||||
|
// plane imports
|
||||||
|
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||||
|
import {
|
||||||
|
applyNodedcAccent,
|
||||||
|
getReadableNodedcTextRgb,
|
||||||
|
nodedcAccentHexToRgb,
|
||||||
|
normalizeNodedcAccentHex,
|
||||||
|
} from "@plane/utils";
|
||||||
|
// helpers
|
||||||
|
import { NODEDC_DEFAULT_ACCENT_HEX } from "@/helpers/nodedc-design";
|
||||||
|
// components
|
||||||
|
import { SettingsControlItem } from "@/components/settings/control-item";
|
||||||
|
// hooks
|
||||||
|
import { useUserProfile } from "@/hooks/store/user";
|
||||||
|
|
||||||
|
const ACCENT_PRESET_COLORS = [
|
||||||
|
"#EF4444",
|
||||||
|
"#F97316",
|
||||||
|
"#FACC15",
|
||||||
|
"#8B5E34",
|
||||||
|
"#C3FF66",
|
||||||
|
"#5FBF2A",
|
||||||
|
"#A855F7",
|
||||||
|
"#7C3AED",
|
||||||
|
"#3B82F6",
|
||||||
|
"#2DD4BF",
|
||||||
|
"#86EFAC",
|
||||||
|
"#050505",
|
||||||
|
"#2A2B2E",
|
||||||
|
"#7C7F85",
|
||||||
|
"#F5F7FB",
|
||||||
|
];
|
||||||
|
|
||||||
|
const CHROME_PICKER_STYLES = {
|
||||||
|
default: {
|
||||||
|
picker: {
|
||||||
|
width: "100%",
|
||||||
|
background: "transparent",
|
||||||
|
borderRadius: 0,
|
||||||
|
boxShadow: "none",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
},
|
||||||
|
saturation: {
|
||||||
|
borderRadius: "1.35rem",
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
Saturation: {
|
||||||
|
borderRadius: "1.35rem",
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
padding: "0.9rem 0 0",
|
||||||
|
},
|
||||||
|
controls: {
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.75rem",
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
width: "2rem",
|
||||||
|
},
|
||||||
|
swatch: {
|
||||||
|
borderRadius: "999px",
|
||||||
|
boxShadow: "0 0 0 1px rgba(255,255,255,0.14)",
|
||||||
|
},
|
||||||
|
hue: {
|
||||||
|
borderRadius: "999px",
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
Hue: {
|
||||||
|
borderRadius: "999px",
|
||||||
|
},
|
||||||
|
alpha: {
|
||||||
|
display: "none",
|
||||||
|
},
|
||||||
|
Alpha: {
|
||||||
|
display: "none",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const getReadableColor = (hex: string) => {
|
||||||
|
const rgb = nodedcAccentHexToRgb(hex);
|
||||||
|
if (!rgb) return undefined;
|
||||||
|
return `rgb(${getReadableNodedcTextRgb(rgb).join(" ")})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProfileSettingsAccentColor = observer(function ProfileSettingsAccentColor() {
|
||||||
|
const { data: userProfile, updateUserTheme } = useUserProfile();
|
||||||
|
const [draftAccent, setDraftAccent] = useState(NODEDC_DEFAULT_ACCENT_HEX);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
const savedAccent = useMemo(
|
||||||
|
() => normalizeNodedcAccentHex(userProfile?.theme?.nodedcAccent) || NODEDC_DEFAULT_ACCENT_HEX,
|
||||||
|
[userProfile?.theme?.nodedcAccent]
|
||||||
|
);
|
||||||
|
const normalizedDraftAccent = normalizeNodedcAccentHex(draftAccent);
|
||||||
|
const isDraftValid = !!normalizedDraftAccent;
|
||||||
|
const isDirty = normalizedDraftAccent !== savedAccent;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraftAccent(savedAccent);
|
||||||
|
}, [savedAccent]);
|
||||||
|
|
||||||
|
const handleAccentChange = (value: string) => {
|
||||||
|
const nextValue = value.startsWith("#") ? value : `#${value}`;
|
||||||
|
setDraftAccent(nextValue);
|
||||||
|
|
||||||
|
const normalizedValue = normalizeNodedcAccentHex(nextValue);
|
||||||
|
if (normalizedValue) applyNodedcAccent(normalizedValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleColorPickerChange = (color: ColorResult) => {
|
||||||
|
handleAccentChange(color.hex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!normalizedDraftAccent) {
|
||||||
|
setToast({
|
||||||
|
type: TOAST_TYPE.ERROR,
|
||||||
|
title: "Ошибка",
|
||||||
|
message: "Введите корректный HEX-цвет.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSaving(true);
|
||||||
|
applyNodedcAccent(normalizedDraftAccent);
|
||||||
|
await updateUserTheme({ nodedcAccent: normalizedDraftAccent });
|
||||||
|
setToast({
|
||||||
|
type: TOAST_TYPE.SUCCESS,
|
||||||
|
title: "Сохранено",
|
||||||
|
message: "Акцентный цвет обновлен.",
|
||||||
|
});
|
||||||
|
} catch (_error) {
|
||||||
|
applyNodedcAccent(savedAccent);
|
||||||
|
setDraftAccent(savedAccent);
|
||||||
|
setToast({
|
||||||
|
type: TOAST_TYPE.ERROR,
|
||||||
|
title: "Ошибка",
|
||||||
|
message: "Не удалось сохранить акцентный цвет.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = async () => {
|
||||||
|
try {
|
||||||
|
setIsSaving(true);
|
||||||
|
setDraftAccent(NODEDC_DEFAULT_ACCENT_HEX);
|
||||||
|
applyNodedcAccent(NODEDC_DEFAULT_ACCENT_HEX);
|
||||||
|
await updateUserTheme({ nodedcAccent: undefined });
|
||||||
|
setToast({
|
||||||
|
type: TOAST_TYPE.SUCCESS,
|
||||||
|
title: "Сброшено",
|
||||||
|
message: "Акцентный цвет возвращен к дизайн-конфигу.",
|
||||||
|
});
|
||||||
|
} catch (_error) {
|
||||||
|
applyNodedcAccent(savedAccent);
|
||||||
|
setDraftAccent(savedAccent);
|
||||||
|
setToast({
|
||||||
|
type: TOAST_TYPE.ERROR,
|
||||||
|
title: "Ошибка",
|
||||||
|
message: "Не удалось сбросить акцентный цвет.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsControlItem
|
||||||
|
title="Акцентный цвет"
|
||||||
|
description="Локальная настройка пользователя. Меняет цвет кнопок, активных элементов, шкал и выделений без перезапуска системы."
|
||||||
|
control={
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<div className="relative w-40">
|
||||||
|
<Popover as="div" className="absolute top-1/2 left-3 z-20 -translate-y-1/2">
|
||||||
|
{() => (
|
||||||
|
<>
|
||||||
|
<Popover.Button
|
||||||
|
type="button"
|
||||||
|
className="grid size-5 place-items-center rounded-full outline-none transition-transform hover:scale-105 focus-visible:scale-105"
|
||||||
|
aria-label="Открыть палитру акцентного цвета"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="size-4 rounded-full shadow-[0_0_0_1px_rgba(255,255,255,0.24)]"
|
||||||
|
style={{ backgroundColor: normalizedDraftAccent || NODEDC_DEFAULT_ACCENT_HEX }}
|
||||||
|
/>
|
||||||
|
</Popover.Button>
|
||||||
|
<Transition
|
||||||
|
as={Fragment}
|
||||||
|
enter="transition ease-out duration-150"
|
||||||
|
enterFrom="opacity-0 translate-y-1 scale-95"
|
||||||
|
enterTo="opacity-100 translate-y-0 scale-100"
|
||||||
|
leave="transition ease-in duration-100"
|
||||||
|
leaveFrom="opacity-100 translate-y-0 scale-100"
|
||||||
|
leaveTo="opacity-0 translate-y-1 scale-95"
|
||||||
|
>
|
||||||
|
<Popover.Panel className="nodedc-accent-picker-panel absolute top-full left-0 z-[90] mt-4 w-[21rem] origin-top-left rounded-[1.75rem] p-4 shadow-[0_28px_80px_rgba(0,0,0,0.46)]">
|
||||||
|
<ColorPicker.ChromePicker
|
||||||
|
className="nodedc-accent-chrome-picker"
|
||||||
|
color={normalizedDraftAccent || NODEDC_DEFAULT_ACCENT_HEX}
|
||||||
|
disableAlpha
|
||||||
|
onChange={handleColorPickerChange}
|
||||||
|
styles={CHROME_PICKER_STYLES}
|
||||||
|
/>
|
||||||
|
<div className="mt-4 grid grid-cols-8 gap-2">
|
||||||
|
{ACCENT_PRESET_COLORS.map((color) => {
|
||||||
|
const isSelected = color === normalizedDraftAccent;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
type="button"
|
||||||
|
className="grid size-7 place-items-center rounded-full transition-transform hover:scale-105 focus-visible:scale-105"
|
||||||
|
onClick={() => handleAccentChange(color)}
|
||||||
|
aria-label={`Выбрать цвет ${color}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="size-5 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: color,
|
||||||
|
boxShadow: isSelected
|
||||||
|
? `0 0 0 2px rgba(245,247,251,0.92), 0 0 0 5px ${color}`
|
||||||
|
: "0 0 0 1px rgba(255,255,255,0.16)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Popover.Panel>
|
||||||
|
</Transition>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Popover>
|
||||||
|
<input
|
||||||
|
name="nodedcAccent"
|
||||||
|
value={draftAccent}
|
||||||
|
onChange={(event) => handleAccentChange(event.target.value)}
|
||||||
|
placeholder={NODEDC_DEFAULT_ACCENT_HEX}
|
||||||
|
className="nodedc-settings-input h-11 min-h-11 w-full pl-10 pr-4 text-13 font-medium uppercase"
|
||||||
|
style={{
|
||||||
|
color: normalizedDraftAccent ? getReadableColor(normalizedDraftAccent) : undefined,
|
||||||
|
}}
|
||||||
|
aria-invalid={!isDraftValid}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="nodedc-settings-primary-button min-w-[7rem] text-13 font-medium disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!isDraftValid || !isDirty || isSaving}
|
||||||
|
>
|
||||||
|
Применить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="nodedc-settings-secondary-button min-w-[6rem] text-13 font-medium disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={savedAccent === NODEDC_DEFAULT_ACCENT_HEX || isSaving}
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
// components
|
// components
|
||||||
import { ThemeSwitcher } from "@/plane-web/components/preferences/theme-switcher";
|
import { ThemeSwitcher } from "@/plane-web/components/preferences/theme-switcher";
|
||||||
|
// local imports
|
||||||
|
import { ProfileSettingsAccentColor } from "./accent-color";
|
||||||
|
|
||||||
export const ProfileSettingsDefaultPreferencesList = observer(function ProfileSettingsDefaultPreferencesList() {
|
export const ProfileSettingsDefaultPreferencesList = observer(function ProfileSettingsDefaultPreferencesList() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -18,6 +20,7 @@ export const ProfileSettingsDefaultPreferencesList = observer(function ProfileSe
|
||||||
description: "select_or_customize_your_interface_color_scheme",
|
description: "select_or_customize_your_interface_color_scheme",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<ProfileSettingsAccentColor />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* See the LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { rgbToNodedcAccentHex } from "@plane/utils";
|
||||||
|
import designConfig from "../../design.config.json";
|
||||||
|
|
||||||
|
const defaultAccentRgb = designConfig.nodedc.accent_rgb as [number, number, number];
|
||||||
|
|
||||||
|
export const NODEDC_DEFAULT_ACCENT_HEX = rgbToNodedcAccentHex(defaultAccentRgb);
|
||||||
|
|
@ -12,7 +12,8 @@ import { useTheme } from "next-themes";
|
||||||
import type { TLanguage } from "@plane/i18n";
|
import type { TLanguage } from "@plane/i18n";
|
||||||
import { DEFAULT_LANGUAGE, useTranslation } from "@plane/i18n";
|
import { DEFAULT_LANGUAGE, useTranslation } from "@plane/i18n";
|
||||||
// helpers
|
// helpers
|
||||||
import { applyCustomTheme, clearCustomTheme } from "@plane/utils";
|
import { applyCustomTheme, applyNodedcAccent, clearCustomTheme } from "@plane/utils";
|
||||||
|
import { NODEDC_DEFAULT_ACCENT_HEX } from "@/helpers/nodedc-design";
|
||||||
// hooks
|
// hooks
|
||||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||||
import { useRouterParams } from "@/hooks/store/use-router-params";
|
import { useRouterParams } from "@/hooks/store/use-router-params";
|
||||||
|
|
@ -107,6 +108,11 @@ function StoreWrapper(props: TStoreWrapper) {
|
||||||
previousThemeRef.current = currentTheme;
|
previousThemeRef.current = currentTheme;
|
||||||
}, [userProfile?.theme]);
|
}, [userProfile?.theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userProfile?.id) return;
|
||||||
|
applyNodedcAccent(userProfile?.theme?.nodedcAccent || NODEDC_DEFAULT_ACCENT_HEX);
|
||||||
|
}, [userProfile?.id, userProfile?.theme?.nodedcAccent]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userProfile?.id) return;
|
if (!userProfile?.id) return;
|
||||||
changeLanguage((userProfile?.language as TLanguage) || DEFAULT_LANGUAGE);
|
changeLanguage((userProfile?.language as TLanguage) || DEFAULT_LANGUAGE);
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ export class ProfileStore implements IUserProfileStore {
|
||||||
primary: undefined,
|
primary: undefined,
|
||||||
background: undefined,
|
background: undefined,
|
||||||
darkPalette: false,
|
darkPalette: false,
|
||||||
|
nodedcAccent: undefined,
|
||||||
},
|
},
|
||||||
onboarding_step: {
|
onboarding_step: {
|
||||||
workspace_join: false,
|
workspace_join: false,
|
||||||
|
|
|
||||||
|
|
@ -347,6 +347,37 @@
|
||||||
0 6px 18px rgba(0, 0, 0, 0.2);
|
0 6px 18px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodedc-tall-action-menu > div {
|
||||||
|
max-height: calc(100vh - 24px) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-work-item-action-menu {
|
||||||
|
width: min(calc(100vw - 24px), 46rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-work-item-action-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-work-item-action-section {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.nodedc-work-item-action-menu {
|
||||||
|
width: min(calc(100vw - 24px), 18rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-work-item-action-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
max-height: calc(100vh - 24px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.nodedc-bottom-dock {
|
.nodedc-bottom-dock {
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.024) 0%, rgba(255, 255, 255, 0.008) 100%), rgba(7, 7, 10, 0.72) !important;
|
linear-gradient(180deg, rgba(255, 255, 255, 0.024) 0%, rgba(255, 255, 255, 0.008) 100%), rgba(7, 7, 10, 0.72) !important;
|
||||||
|
|
@ -1254,6 +1285,45 @@
|
||||||
color: var(--text-color-primary) !important;
|
color: var(--text-color-primary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodedc-accent-picker-panel {
|
||||||
|
border: 0 !important;
|
||||||
|
outline: none !important;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.075) 0%, rgba(255, 255, 255, 0.026) 100%),
|
||||||
|
rgba(8, 9, 12, 0.78) !important;
|
||||||
|
-webkit-backdrop-filter: blur(28px);
|
||||||
|
backdrop-filter: blur(28px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-accent-chrome-picker,
|
||||||
|
.nodedc-accent-chrome-picker * {
|
||||||
|
font-family: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-accent-chrome-picker input {
|
||||||
|
min-height: 2rem !important;
|
||||||
|
border: 0 !important;
|
||||||
|
outline: none !important;
|
||||||
|
border-radius: 0.85rem !important;
|
||||||
|
background: rgba(255, 255, 255, 0.07) !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
color: var(--text-color-primary) !important;
|
||||||
|
font-size: 0.75rem !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-accent-chrome-picker label {
|
||||||
|
color: var(--text-color-secondary) !important;
|
||||||
|
font-size: 0.62rem !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
letter-spacing: 0.08em !important;
|
||||||
|
text-transform: uppercase !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-accent-chrome-picker .flexbox-fix {
|
||||||
|
border: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.nodedc-settings-danger-button {
|
.nodedc-settings-danger-button {
|
||||||
min-height: 2.75rem;
|
min-height: 2.75rem;
|
||||||
border: 0 !important;
|
border: 0 !important;
|
||||||
|
|
@ -1972,7 +2042,7 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 1.7rem;
|
border-radius: 1.7rem;
|
||||||
background: #474747 !important;
|
background: #050506 !important;
|
||||||
padding: 1.6rem 1.35rem;
|
padding: 1.6rem 1.35rem;
|
||||||
padding-right: max(1.35rem, calc(100% - var(--nodedc-home-title-width)));
|
padding-right: max(1.35rem, calc(100% - var(--nodedc-home-title-width)));
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035) !important;
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035) !important;
|
||||||
|
|
@ -2493,13 +2563,33 @@
|
||||||
.nodedc-home-gantt-grid-label {
|
.nodedc-home-gantt-grid-label {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
padding-left: 0.75rem;
|
|
||||||
color: var(--text-color-placeholder);
|
color: var(--text-color-placeholder);
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-home-gantt-grid-label span {
|
||||||
|
display: inline-flex;
|
||||||
|
height: 1.36rem;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding-inline: 0.48rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-home-gantt-grid-label-today {
|
||||||
|
color: rgb(var(--nodedc-on-card-active-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-home-gantt-grid-label-today span {
|
||||||
|
background: rgb(var(--nodedc-card-active-rgb));
|
||||||
|
color: rgb(var(--nodedc-on-card-active-rgb));
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.42),
|
||||||
|
0 12px 24px rgba(0, 0, 0, 0.24);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodedc-home-gantt-today-marker {
|
.nodedc-home-gantt-today-marker {
|
||||||
|
|
@ -2539,28 +2629,6 @@
|
||||||
content: "";
|
content: "";
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodedc-home-gantt-today-pill {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.15rem;
|
|
||||||
left: 0;
|
|
||||||
display: inline-flex;
|
|
||||||
height: 1.75rem;
|
|
||||||
align-items: center;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgb(var(--nodedc-card-active-rgb));
|
|
||||||
padding-inline: 0.72rem;
|
|
||||||
color: rgb(var(--nodedc-on-card-active-rgb));
|
|
||||||
font-size: 0.67rem;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0;
|
|
||||||
text-transform: uppercase;
|
|
||||||
white-space: nowrap;
|
|
||||||
box-shadow:
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.42),
|
|
||||||
0 12px 24px rgba(0, 0, 0, 0.24);
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodedc-home-gantt-row {
|
.nodedc-home-gantt-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -2680,7 +2748,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodedc-home-gantt-grid-label {
|
.nodedc-home-gantt-grid-label {
|
||||||
padding-left: 0.35rem;
|
|
||||||
font-size: 0.6rem;
|
font-size: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ export type TUserProfile = {
|
||||||
|
|
||||||
theme: {
|
theme: {
|
||||||
theme: string | undefined;
|
theme: string | undefined;
|
||||||
|
nodedcAccent?: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
onboarding_step: {
|
onboarding_step: {
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,9 @@ export type TBaseIssue = {
|
||||||
estimate_point: string | null;
|
estimate_point: string | null;
|
||||||
|
|
||||||
sub_issues_count: number;
|
sub_issues_count: number;
|
||||||
|
checker_blocks_count?: number | null;
|
||||||
|
checker_items_count?: number | null;
|
||||||
|
checker_items_completed_count?: number | null;
|
||||||
attachment_count: number;
|
attachment_count: number;
|
||||||
link_count: number;
|
link_count: number;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ export type TUserProfile = {
|
||||||
primary: string | undefined;
|
primary: string | undefined;
|
||||||
background: string | undefined;
|
background: string | undefined;
|
||||||
darkPalette: boolean | undefined;
|
darkPalette: boolean | undefined;
|
||||||
|
nodedcAccent?: string | undefined;
|
||||||
};
|
};
|
||||||
onboarding_step: TOnboardingSteps;
|
onboarding_step: TOnboardingSteps;
|
||||||
is_onboarded: boolean;
|
is_onboarded: boolean;
|
||||||
|
|
@ -107,6 +108,7 @@ export interface IUserTheme {
|
||||||
primary?: string | undefined;
|
primary?: string | undefined;
|
||||||
background?: string | undefined;
|
background?: string | undefined;
|
||||||
darkPalette?: boolean | undefined;
|
darkPalette?: boolean | undefined;
|
||||||
|
nodedcAccent?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IUserMemberLite extends IUserLite {
|
export interface IUserMemberLite extends IUserLite {
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,15 @@ export {
|
||||||
type DarknessDetectionMethod,
|
type DarknessDetectionMethod,
|
||||||
} from "./theme-application";
|
} from "./theme-application";
|
||||||
|
|
||||||
|
// NODE.DC runtime accent
|
||||||
|
export {
|
||||||
|
applyNodedcAccent,
|
||||||
|
getReadableNodedcTextRgb,
|
||||||
|
nodedcAccentHexToRgb,
|
||||||
|
normalizeNodedcAccentHex,
|
||||||
|
rgbToNodedcAccentHex,
|
||||||
|
} from "./nodedc-accent";
|
||||||
|
|
||||||
// Color conversion utilities
|
// Color conversion utilities
|
||||||
export {
|
export {
|
||||||
hexToHSL,
|
hexToHSL,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* See the LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { normalizeHexColor, validateHexColor } from "./color-validation";
|
||||||
|
|
||||||
|
type TRgbTuple = [number, number, number];
|
||||||
|
|
||||||
|
const DARK_TEXT_RGB: TRgbTuple = [11, 17, 23];
|
||||||
|
const LIGHT_TEXT_RGB: TRgbTuple = [245, 247, 251];
|
||||||
|
|
||||||
|
const clampRgbChannel = (value: number) => Math.min(Math.max(Math.round(value), 0), 255);
|
||||||
|
|
||||||
|
const formatRgbTuple = (rgb: readonly number[]) => rgb.map(clampRgbChannel).join(" ");
|
||||||
|
const formatCssRgb = (rgb: readonly number[]) => `rgb(${formatRgbTuple(rgb)})`;
|
||||||
|
|
||||||
|
const blendRgb = (rgb: readonly number[], target: number, ratio: number): TRgbTuple =>
|
||||||
|
rgb.map((channel) => clampRgbChannel(channel * (1 - ratio) + target * ratio)) as TRgbTuple;
|
||||||
|
|
||||||
|
const toRelativeLuminance = (rgb: readonly number[]) => {
|
||||||
|
const [r, g, b] = rgb.map((channel) => {
|
||||||
|
const normalized = channel / 255;
|
||||||
|
return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
|
||||||
|
});
|
||||||
|
|
||||||
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeNodedcAccentHex = (hex: string | null | undefined): string | undefined => {
|
||||||
|
if (!hex || !validateHexColor(hex)) return undefined;
|
||||||
|
return `#${normalizeHexColor(hex)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const nodedcAccentHexToRgb = (hex: string | null | undefined): TRgbTuple | undefined => {
|
||||||
|
const normalizedHex = normalizeNodedcAccentHex(hex);
|
||||||
|
if (!normalizedHex) return undefined;
|
||||||
|
|
||||||
|
const cleanHex = normalizedHex.replace("#", "");
|
||||||
|
return [0, 2, 4].map((start) => parseInt(cleanHex.slice(start, start + 2), 16)) as TRgbTuple;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rgbToNodedcAccentHex = (rgb: readonly number[]): string =>
|
||||||
|
`#${rgb
|
||||||
|
.map((channel) => clampRgbChannel(channel).toString(16).padStart(2, "0"))
|
||||||
|
.join("")
|
||||||
|
.toUpperCase()}`;
|
||||||
|
|
||||||
|
export const getReadableNodedcTextRgb = (rgb: readonly number[]): TRgbTuple =>
|
||||||
|
toRelativeLuminance(rgb) > 0.52 ? DARK_TEXT_RGB : LIGHT_TEXT_RGB;
|
||||||
|
|
||||||
|
export const applyNodedcAccent = (hex: string | null | undefined): boolean => {
|
||||||
|
if (typeof document === "undefined") return false;
|
||||||
|
|
||||||
|
const accentRgb = nodedcAccentHexToRgb(hex);
|
||||||
|
if (!accentRgb) return false;
|
||||||
|
|
||||||
|
const root = document.documentElement;
|
||||||
|
const onAccentRgb = getReadableNodedcTextRgb(accentRgb);
|
||||||
|
|
||||||
|
const brandScale: Record<string, TRgbTuple> = {
|
||||||
|
"100": blendRgb(accentRgb, 255, 0.9),
|
||||||
|
"200": blendRgb(accentRgb, 255, 0.8),
|
||||||
|
"300": blendRgb(accentRgb, 255, 0.65),
|
||||||
|
"400": blendRgb(accentRgb, 255, 0.45),
|
||||||
|
"500": blendRgb(accentRgb, 255, 0.25),
|
||||||
|
"600": blendRgb(accentRgb, 255, 0.1),
|
||||||
|
"700": blendRgb(accentRgb, 0, 0.25),
|
||||||
|
"800": blendRgb(accentRgb, 0, 0.35),
|
||||||
|
"900": blendRgb(accentRgb, 0, 0.45),
|
||||||
|
"1000": blendRgb(accentRgb, 0, 0.6),
|
||||||
|
"1100": blendRgb(accentRgb, 0, 0.72),
|
||||||
|
"1200": blendRgb(accentRgb, 0, 0.82),
|
||||||
|
};
|
||||||
|
|
||||||
|
root.style.setProperty("--nodedc-accent-rgb", formatRgbTuple(accentRgb));
|
||||||
|
root.style.setProperty("--nodedc-card-active-rgb", formatRgbTuple(accentRgb));
|
||||||
|
root.style.setProperty("--nodedc-on-accent-rgb", formatRgbTuple(onAccentRgb));
|
||||||
|
root.style.setProperty("--nodedc-on-card-active-rgb", formatRgbTuple(onAccentRgb));
|
||||||
|
|
||||||
|
Object.entries(brandScale).forEach(([key, value]) => {
|
||||||
|
root.style.setProperty(`--brand-${key}`, formatCssRgb(value));
|
||||||
|
});
|
||||||
|
|
||||||
|
root.style.setProperty("--brand-default", formatCssRgb(accentRgb));
|
||||||
|
root.style.setProperty("--bg-accent-primary", formatCssRgb(accentRgb));
|
||||||
|
root.style.setProperty("--bg-accent-primary-hover", formatCssRgb(blendRgb(accentRgb, 255, 0.18)));
|
||||||
|
root.style.setProperty("--bg-accent-primary-active", formatCssRgb(blendRgb(accentRgb, 0, 0.1)));
|
||||||
|
root.style.setProperty("--txt-accent-primary", formatCssRgb(accentRgb));
|
||||||
|
root.style.setProperty("--txt-accent-secondary", formatCssRgb(blendRgb(accentRgb, 0, 0.25)));
|
||||||
|
root.style.setProperty("--txt-icon-accent-primary", formatCssRgb(accentRgb));
|
||||||
|
root.style.setProperty("--text-color-accent-primary", formatCssRgb(accentRgb));
|
||||||
|
root.style.setProperty("--text-color-accent-secondary", formatCssRgb(blendRgb(accentRgb, 0, 0.25)));
|
||||||
|
root.style.setProperty("--text-color-icon-accent-primary", formatCssRgb(accentRgb));
|
||||||
|
root.style.setProperty("--stroke-accent-primary", formatCssRgb(accentRgb));
|
||||||
|
root.style.setProperty("--fill-accent-primary", formatCssRgb(accentRgb));
|
||||||
|
root.style.setProperty("--txt-on-color", formatCssRgb(onAccentRgb));
|
||||||
|
root.style.setProperty("--txt-icon-on-color", formatCssRgb(onAccentRgb));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue