Compare commits

..

5 Commits

26 changed files with 844 additions and 71 deletions

View File

@ -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 карточка допустима только когда задача имеет отдельный результат, владельца, проверку и может выполняться независимо от родительского контекста
- если несколько старых карточек описывают один контекст, их нужно объединить в одну актуальную карточку и удалить или закрыть дубли
Статус карточки: Статус карточки:
- `В работе` ставится только когда задача реально взята в исполнение - `В работе` ставится только когда задача реально взята в исполнение
- `Готово` ставится после проверки результата и закрытия рабочих чекеров - `Готово` ставится после проверки результата и закрытия рабочих чекеров

View File

@ -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()

View File

@ -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,
} }

View File

@ -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":

View File

@ -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(

View File

@ -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

View File

@ -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;

View File

@ -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}>

View File

@ -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,
}, },
{ {

View File

@ -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",

View File

@ -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 && (

View File

@ -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"

View File

@ -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}
/> />

View File

@ -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,

View File

@ -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",

View File

@ -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>
}
/>
);
});

View File

@ -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>
); );
}); });

View File

@ -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);

View File

@ -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);

View File

@ -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,

View File

@ -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;
} }

View File

@ -13,6 +13,7 @@ export type TUserProfile = {
theme: { theme: {
theme: string | undefined; theme: string | undefined;
nodedcAccent?: string | undefined;
}; };
onboarding_step: { onboarding_step: {

View File

@ -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;

View File

@ -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 {

View File

@ -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,

View File

@ -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;
};