Compare commits

...

8 Commits

Author SHA1 Message Date
DCCONSTRUCTIONS 55318f14e5 ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: Plane OIDC и план платформы 2026-05-04 17:16:47 +03:00
DCCONSTRUCTIONS 561d1eeef5 АРХ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: Plane OIDC mapping для существующего пользователя 2026-05-04 13:01:21 +03:00
DCCONSTRUCTIONS 119d503d96 UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: перенос режимов проекта в нижнюю панель 2026-05-01 14:16:28 +03:00
DCCONSTRUCTIONS ae262487ac UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: корректировка аналитики главной 2026-05-01 12:39:22 +03:00
DCCONSTRUCTIONS d7260bdfce UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: контроль цветов и реворк интерфейса проектов 2026-05-01 12:13:47 +03:00
DCCONSTRUCTIONS a7ab8ee123 UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: контроль цветов элементов, допил палитры и реворк основного UI 2026-05-01 02:29:39 +03:00
DCCONSTRUCTIONS 7ff7d83b07 UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: расширенный home layout и аналитические панели 2026-04-30 23:34:34 +03:00
DCCONSTRUCTIONS d28f83fe5e UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: быстрый выбор проектов на home 2026-04-29 22:37:30 +03:00
77 changed files with 5287 additions and 976 deletions

View File

@ -51,6 +51,17 @@ x-app-env: &app-env
WEB_URL: ${WEB_URL:-http://localhost:8090}
DEBUG: ${DEBUG:-0}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:8090}
ENABLE_SIGNUP: ${ENABLE_SIGNUP:-0}
ENABLE_EMAIL_PASSWORD: ${ENABLE_EMAIL_PASSWORD:-1}
ENABLE_MAGIC_LINK_LOGIN: ${ENABLE_MAGIC_LINK_LOGIN:-0}
PLANE_OIDC_ISSUER: ${PLANE_OIDC_ISSUER:-}
PLANE_OIDC_CLIENT_ID: ${PLANE_OIDC_CLIENT_ID:-}
PLANE_OIDC_CLIENT_SECRET: ${PLANE_OIDC_CLIENT_SECRET:-}
PLANE_OIDC_REDIRECT_URI: ${PLANE_OIDC_REDIRECT_URI:-}
PLANE_OIDC_SCOPE: ${PLANE_OIDC_SCOPE:-openid email profile groups}
PLANE_OIDC_REQUIRED_GROUPS: ${PLANE_OIDC_REQUIRED_GROUPS:-nodedc:superadmin,nodedc:taskmanager:admin,nodedc:taskmanager:user}
PLANE_OIDC_AUTO_LINK_EMAIL: ${PLANE_OIDC_AUTO_LINK_EMAIL:-0}
PLANE_OIDC_SYNC_PROFILE: ${PLANE_OIDC_SYNC_PROFILE:-1}
GUNICORN_WORKERS: 1
POSTHOG_API_KEY: ${POSTHOG_API_KEY:-}
POSTHOG_HOST: ${POSTHOG_HOST:-}
@ -113,6 +124,8 @@ services:
api:
image: nodedc/plane-backend:local
command: ./bin/docker-entrypoint-api.sh
extra_hosts:
- "auth.local.nodedc:host-gateway"
deploy:
replicas: ${API_REPLICAS:-1}
restart_policy:
@ -129,6 +142,8 @@ services:
worker:
image: nodedc/plane-backend:local
command: ./bin/docker-entrypoint-worker.sh
extra_hosts:
- "auth.local.nodedc:host-gateway"
deploy:
replicas: ${WORKER_REPLICAS:-1}
restart_policy:
@ -146,6 +161,8 @@ services:
beat-worker:
image: nodedc/plane-backend:local
command: ./bin/docker-entrypoint-beat.sh
extra_hosts:
- "auth.local.nodedc:host-gateway"
deploy:
replicas: ${BEAT_WORKER_REPLICAS:-1}
restart_policy:
@ -163,6 +180,8 @@ services:
migrator:
image: nodedc/plane-backend:local
command: ./bin/docker-entrypoint-migrator.sh
extra_hosts:
- "auth.local.nodedc:host-gateway"
deploy:
replicas: 1
restart_policy:

View File

@ -12,9 +12,9 @@ LIVE_REPLICAS=1
LISTEN_HTTP_PORT=8090
LISTEN_HTTPS_PORT=8443
WEB_URL=http://localhost:8090
WEB_URL=http://task.local.nodedc
DEBUG=0
CORS_ALLOWED_ORIGINS=http://localhost:8090
CORS_ALLOWED_ORIGINS=http://task.local.nodedc,http://localhost:8090
API_BASE_URL=http://api:8000
#DB SETTINGS
@ -89,3 +89,15 @@ LIVE_SERVER_SECRET_KEY=
DOCKERHUB_USER=makeplane
PULL_POLICY=if_not_present
CUSTOM_BUILD=false
# NODE.DC platform OIDC local dev
ENABLE_SIGNUP=0
ENABLE_EMAIL_PASSWORD=1
ENABLE_MAGIC_LINK_LOGIN=0
PLANE_OIDC_ISSUER=http://auth.local.nodedc/application/o/task-manager/
PLANE_OIDC_CLIENT_ID=nodedc-task-manager
PLANE_OIDC_CLIENT_SECRET=c510f7e389c95a610f34f7569c9ee7fbb744d214bc21e82734578d971e02e0aaa9812aeb83b33efdb76eb90c0a819b0a
PLANE_OIDC_REDIRECT_URI=http://task.local.nodedc/auth/oidc/callback
PLANE_OIDC_SCOPE=openid email profile groups
PLANE_OIDC_REQUIRED_GROUPS=nodedc:superadmin,nodedc:taskmanager:admin,nodedc:taskmanager:user
PLANE_OIDC_AUTO_LINK_EMAIL=1

View File

@ -21,6 +21,8 @@ from .views import (
MagicGenerateEndpoint,
MagicSignInEndpoint,
MagicSignUpEndpoint,
NodeDCOIDCCallbackEndpoint,
NodeDCOIDCInitiateEndpoint,
SignInAuthEndpoint,
SignOutAuthEndpoint,
SignUpAuthEndpoint,
@ -50,6 +52,9 @@ urlpatterns = [
# credentials
path("sign-in/", SignInAuthEndpoint.as_view(), name="sign-in"),
path("sign-up/", SignUpAuthEndpoint.as_view(), name="sign-up"),
path("oidc/login/", NodeDCOIDCInitiateEndpoint.as_view(), name="nodedc-oidc-login"),
path("oidc/callback/", NodeDCOIDCCallbackEndpoint.as_view(), name="nodedc-oidc-callback"),
path("oidc/callback", NodeDCOIDCCallbackEndpoint.as_view(), name="nodedc-oidc-callback-no-slash"),
path("spaces/sign-in/", SignInAuthSpaceEndpoint.as_view(), name="space-sign-in"),
path("spaces/sign-up/", SignUpAuthSpaceEndpoint.as_view(), name="space-sign-up"),
# signout

View File

@ -12,6 +12,7 @@ from .app.gitlab import GitLabCallbackEndpoint, GitLabOauthInitiateEndpoint
from .app.gitea import GiteaCallbackEndpoint, GiteaOauthInitiateEndpoint
from .app.google import GoogleCallbackEndpoint, GoogleOauthInitiateEndpoint
from .app.magic import MagicGenerateEndpoint, MagicSignInEndpoint, MagicSignUpEndpoint
from .app.oidc import NodeDCOIDCCallbackEndpoint, NodeDCOIDCInitiateEndpoint
from .app.signout import SignOutAuthEndpoint

View File

@ -0,0 +1,264 @@
import base64
import hashlib
import os
import secrets
from urllib.parse import urlencode
import jwt
import requests
from django.http import HttpResponseRedirect
from django.utils import timezone
from django.views import View
from plane.authentication.utils.host import base_host
from plane.authentication.utils.login import user_login
from plane.authentication.utils.redirection_path import get_redirection_path
from plane.db.models import ExternalIdentityLink, User
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path
OIDC_SESSION_KEY = "nodedc_oidc"
OIDC_PROVIDER = "authentik"
DEFAULT_REQUIRED_GROUPS = "nodedc:superadmin,nodedc:taskmanager:admin,nodedc:taskmanager:user"
class NodeDCOIDCInitiateEndpoint(View):
def get(self, request):
config = get_oidc_config()
next_path = validate_next_path(request.GET.get("next_path", ""))
discovery = load_discovery(config["issuer"])
state = secrets.token_urlsafe(32)
nonce = secrets.token_urlsafe(32)
code_verifier = secrets.token_urlsafe(64)
code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).decode().rstrip("=")
request.session[OIDC_SESSION_KEY] = {
"state": state,
"nonce": nonce,
"code_verifier": code_verifier,
"next_path": next_path,
}
request.session.save()
params = {
"response_type": "code",
"client_id": config["client_id"],
"redirect_uri": config["redirect_uri"],
"scope": config["scope"],
"state": state,
"nonce": nonce,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
}
if request.GET.get("prompt") == "login":
params["prompt"] = "login"
return HttpResponseRedirect(f"{discovery['authorization_endpoint']}?{urlencode(params)}")
class NodeDCOIDCCallbackEndpoint(View):
def get(self, request):
config = get_oidc_config()
oidc_session = request.session.get(OIDC_SESSION_KEY) or {}
next_path = oidc_session.get("next_path", "")
base_url = base_host(request=request, is_app=True)
if request.GET.get("error"):
return oidc_error_redirect(base_url, next_path, "oidc_provider_error")
state = request.GET.get("state")
code = request.GET.get("code")
if not state or state != oidc_session.get("state") or not code:
return oidc_error_redirect(base_url, next_path, "oidc_state_failed")
discovery = load_discovery(config["issuer"])
token_set = exchange_code(discovery, config, code, oidc_session.get("code_verifier"))
claims = verify_id_token(discovery, config, token_set["id_token"], oidc_session.get("nonce"))
groups = normalize_groups(claims.get("groups"))
if not has_required_group(groups):
return oidc_error_redirect(base_url, next_path, "oidc_access_denied")
user = resolve_linked_user(
claims=claims,
groups=groups,
auto_link=config["auto_link_email"],
sync_profile=config["sync_profile"],
)
if user is None or not user.is_active:
return oidc_error_redirect(base_url, next_path, "oidc_user_not_linked")
request.session.pop(OIDC_SESSION_KEY, None)
user_login(request=request, user=user, is_app=True)
path = next_path or get_redirection_path(user=user)
return HttpResponseRedirect(get_safe_redirect_url(base_url=base_url, next_path=path, params={}))
def get_oidc_config():
issuer = os.environ.get("PLANE_OIDC_ISSUER", "").strip()
client_id = os.environ.get("PLANE_OIDC_CLIENT_ID", "").strip()
client_secret = os.environ.get("PLANE_OIDC_CLIENT_SECRET", "").strip()
redirect_uri = os.environ.get("PLANE_OIDC_REDIRECT_URI", "").strip()
if not issuer or not client_id or not client_secret or not redirect_uri:
raise RuntimeError("Plane OIDC is not configured")
return {
"issuer": issuer.rstrip("/") + "/",
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": redirect_uri,
"scope": os.environ.get("PLANE_OIDC_SCOPE", "openid email profile groups"),
"auto_link_email": os.environ.get("PLANE_OIDC_AUTO_LINK_EMAIL", "0") == "1",
"sync_profile": os.environ.get("PLANE_OIDC_SYNC_PROFILE", "1") == "1",
}
def load_discovery(issuer):
response = requests.get(f"{issuer}.well-known/openid-configuration", timeout=10)
response.raise_for_status()
return response.json()
def exchange_code(discovery, config, code, code_verifier):
response = requests.post(
discovery["token_endpoint"],
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": config["redirect_uri"],
"code_verifier": code_verifier,
},
auth=(config["client_id"], config["client_secret"]),
timeout=10,
)
response.raise_for_status()
token_set = response.json()
if not token_set.get("id_token"):
raise RuntimeError("OIDC token response does not contain id_token")
return token_set
def verify_id_token(discovery, config, id_token, nonce):
jwks_client = jwt.PyJWKClient(discovery["jwks_uri"])
signing_key = jwks_client.get_signing_key_from_jwt(id_token)
claims = jwt.decode(
id_token,
signing_key.key,
algorithms=["RS256"],
audience=config["client_id"],
issuer=discovery.get("issuer", config["issuer"]),
)
if claims.get("nonce") != nonce:
raise RuntimeError("OIDC nonce validation failed")
return claims
def normalize_groups(groups):
if isinstance(groups, list):
return list(dict.fromkeys(group for group in groups if isinstance(group, str)))
if isinstance(groups, str) and groups:
return [groups]
return []
def has_required_group(groups):
required_groups = {
group.strip()
for group in os.environ.get("PLANE_OIDC_REQUIRED_GROUPS", DEFAULT_REQUIRED_GROUPS).split(",")
if group.strip()
}
return bool(required_groups.intersection(set(groups)))
def resolve_linked_user(claims, groups, auto_link, sync_profile):
subject = str(claims.get("sub") or "")
email = str(claims.get("email") or "").strip().lower()
if not subject:
return None
link = ExternalIdentityLink.objects.select_related("user").filter(
provider=OIDC_PROVIDER,
subject=subject,
status=ExternalIdentityLink.Status.ACTIVE,
).first()
if link is None and auto_link and email:
user = User.objects.filter(email__iexact=email, is_active=True).first()
if user:
link, _ = ExternalIdentityLink.objects.get_or_create(
provider=OIDC_PROVIDER,
subject=subject,
defaults={"user": user, "email": email, "groups": groups},
)
if link is None:
return None
link.email = email or link.email
link.groups = groups
link.last_login_at = timezone.now()
link.save(update_fields=["email", "groups", "last_login_at", "updated_at"])
user = link.user
user.last_login_medium = OIDC_PROVIDER
user.last_login_time = timezone.now()
update_fields = ["last_login_medium", "last_login_time", "updated_at"]
if sync_profile:
update_fields.extend(sync_user_profile_from_claims(user, claims))
user.save(update_fields=list(dict.fromkeys(update_fields)))
return user
def sync_user_profile_from_claims(user, claims):
updated_fields = []
display_name = first_string_claim(claims, "name", "preferred_username")
given_name = first_string_claim(claims, "given_name")
family_name = first_string_claim(claims, "family_name")
avatar_url = first_string_claim(claims, "picture", "avatar_url", "avatar")
if display_name and user.display_name != display_name:
user.display_name = display_name
updated_fields.append("display_name")
if not given_name and display_name:
name_parts = display_name.split(" ", 1)
given_name = name_parts[0]
family_name = family_name or (name_parts[1] if len(name_parts) > 1 else "")
if given_name and user.first_name != given_name:
user.first_name = given_name
updated_fields.append("first_name")
if family_name is not None and user.last_name != family_name:
user.last_name = family_name
updated_fields.append("last_name")
if avatar_url and user.avatar != avatar_url:
user.avatar = avatar_url
updated_fields.append("avatar")
return updated_fields
def first_string_claim(claims, *keys):
for key in keys:
value = claims.get(key)
if isinstance(value, str) and value:
return value
return None
def oidc_error_redirect(base_url, next_path, error_code):
return HttpResponseRedirect(get_safe_redirect_url(base_url=base_url, next_path=next_path, params={"error": error_code}))

View File

@ -0,0 +1,48 @@
from django.core.management import BaseCommand, CommandError
from plane.db.models import ExternalIdentityLink, User
class Command(BaseCommand):
help = "Link an existing Plane user to an Authentik OIDC subject"
def add_arguments(self, parser):
parser.add_argument("--email", required=True, help="Existing Plane user email")
parser.add_argument("--sub", required=True, help="Authentik OIDC subject")
parser.add_argument("--dry-run", action="store_true", help="Validate without writing")
def handle(self, *args, **options):
email = options["email"].strip().lower()
subject = options["sub"].strip()
dry_run = options["dry_run"]
if not email or not subject:
raise CommandError("--email and --sub are required")
user = User.objects.filter(email__iexact=email).first()
if user is None:
raise CommandError(f"Plane user not found: {email}")
existing_subject_link = ExternalIdentityLink.objects.filter(provider="authentik", subject=subject).first()
if existing_subject_link and existing_subject_link.user_id != user.id:
raise CommandError(f"Subject is already linked to another Plane user: {existing_subject_link.user.email}")
existing_user_link = ExternalIdentityLink.objects.filter(provider="authentik", user=user).exclude(subject=subject).first()
if existing_user_link:
raise CommandError(f"Plane user is already linked to another Authentik subject: {existing_user_link.subject}")
if dry_run:
self.stdout.write(self.style.SUCCESS(f"Dry run OK: {email} can be linked to {subject}"))
return
link, created = ExternalIdentityLink.objects.update_or_create(
provider="authentik",
subject=subject,
defaults={
"user": user,
"email": email,
"status": ExternalIdentityLink.Status.ACTIVE,
},
)
action = "created" if created else "updated"
self.stdout.write(self.style.SUCCESS(f"Authentik link {action}: {user.email} -> {link.subject}"))

View File

@ -0,0 +1,79 @@
import uuid
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("db", "0136_workspace_member_ban"),
]
operations = [
migrations.CreateModel(
name="ExternalIdentityLink",
fields=[
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="Last Modified At"),
),
("deleted_at", models.DateTimeField(blank=True, null=True)),
(
"id",
models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True, db_index=True),
),
("provider", models.CharField(max_length=64)),
("subject", models.CharField(max_length=255)),
("email", models.CharField(max_length=255)),
("groups", models.JSONField(default=list)),
(
"status",
models.CharField(
choices=[("active", "Active"), ("disabled", "Disabled")],
default="active",
max_length=32,
),
),
("last_login_at", models.DateTimeField(blank=True, null=True)),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="externalidentitylink_created_by",
to="db.user",
),
),
(
"updated_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="externalidentitylink_updated_by",
to="db.user",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="external_identity_links",
to="db.user",
),
),
],
options={
"verbose_name": "External Identity Link",
"verbose_name_plural": "External Identity Links",
"db_table": "external_identity_links",
"ordering": ("-created_at",),
"unique_together": {("provider", "subject")},
},
),
]

View File

@ -0,0 +1,14 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("db", "0137_external_identity_link"),
]
operations = [
migrations.AlterUniqueTogether(
name="externalidentitylink",
unique_together={("provider", "subject"), ("provider", "user")},
),
]

View File

@ -62,7 +62,7 @@ from .project import (
from .session import Session
from .social_connection import SocialLoginConnection
from .state import State, StateGroup, DEFAULT_STATES
from .user import Account, Profile, User, BotTypeEnum
from .user import Account, ExternalIdentityLink, Profile, User, BotTypeEnum
from .view import IssueView
from .webhook import Webhook, WebhookLog
from .voice_tasker import VoiceTaskSession, WorkspaceAICredential, WorkspaceAISettings

View File

@ -295,6 +295,31 @@ class Account(TimeAuditModel):
ordering = ("-created_at",)
class ExternalIdentityLink(TimeAuditModel):
class Status(models.TextChoices):
ACTIVE = "active", "Active"
DISABLED = "disabled", "Disabled"
id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True)
provider = models.CharField(max_length=64)
subject = models.CharField(max_length=255)
user = models.ForeignKey("db.User", on_delete=models.CASCADE, related_name="external_identity_links")
email = models.CharField(max_length=255)
groups = models.JSONField(default=list)
status = models.CharField(max_length=32, choices=Status.choices, default=Status.ACTIVE)
last_login_at = models.DateTimeField(blank=True, null=True)
class Meta:
unique_together = [
["provider", "subject"],
["provider", "user"],
]
verbose_name = "External Identity Link"
verbose_name_plural = "External Identity Links"
db_table = "external_identity_links"
ordering = ("-created_at",)
@receiver(post_save, sender=User)
def create_user_notification(sender, instance, created, **kwargs):
# create preferences

View File

@ -13,6 +13,7 @@ import { WorkItemsIcon } from "@plane/propel/icons";
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { HeaderFilters } from "@/components/issues/filters";
import { IssueDetailQuickActions } from "@/components/issues/issue-detail/issue-detail-quick-actions";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
@ -62,6 +63,14 @@ export const WorkItemDetailsHeader = observer(function WorkItemDetailsHeader() {
</Breadcrumbs>
</Header.LeftItem>
<Header.RightItem>
<div className="hidden items-center gap-2 md:flex">
<HeaderFilters
projectId={projectId.toString()}
currentProjectDetails={projectDetails}
workspaceSlug={workspaceSlug.toString()}
canUserCreateIssue={undefined}
/>
</div>
{projectId && issueId && (
<IssueDetailQuickActions
workspaceSlug={workspaceSlug?.toString()}

View File

@ -15,12 +15,15 @@ import { Breadcrumbs, Header } from "@plane/ui";
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
// hooks
import { useHome } from "@/hooks/store/use-home";
import { useUserProfile } from "@/hooks/store/user";
export const WorkspaceDashboardHeader = observer(function WorkspaceDashboardHeader() {
// plane hooks
const { t } = useTranslation();
// hooks
const { toggleWidgetSettings } = useHome();
const { data: userProfile } = useUserProfile();
const isCompactToolbar = userProfile?.theme?.nodedcCompactToolbar === true;
return (
<>
@ -36,17 +39,19 @@ export const WorkspaceDashboardHeader = observer(function WorkspaceDashboardHead
</Breadcrumbs>
</div>
</Header.LeftItem>
<Header.RightItem>
<Button
variant="secondary"
size="lg"
onClick={() => toggleWidgetSettings(true)}
className="nodedc-toolbar-pill my-auto mb-0"
prependIcon={<Shapes />}
>
<div className="hidden sm:hidden md:block">{t("home.manage_widgets")}</div>
</Button>
</Header.RightItem>
{isCompactToolbar && (
<Header.RightItem>
<Button
variant="secondary"
size="lg"
onClick={() => toggleWidgetSettings(true)}
className="nodedc-toolbar-pill my-auto mb-0"
prependIcon={<Shapes />}
>
<div className="hidden sm:hidden md:block">{t("home.manage_widgets")}</div>
</Button>
</Header.RightItem>
)}
</Header>
</>
);

View File

@ -7,8 +7,6 @@
*/
import { useMemo } from "react";
import Link from "next/link";
import { Menu } from "@headlessui/react";
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import useSWR from "swr";
@ -19,157 +17,31 @@ import {
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS,
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS,
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { InboxIcon, PlusIcon, ProjectIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import { cn, copyUrlToClipboard, joinUrlPath } from "@plane/utils";
import { TopNavPowerK } from "@/components/navigation";
import { joinUrlPath } from "@plane/utils";
import { openWorkspaceNotificationsModal } from "@/components/workspace-notifications/notifications-modal.utils";
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
import { useUser, useUserPermissions } from "@/hooks/store/user";
import { useUser, useUserPermissions, useUserProfile } from "@/hooks/store/user";
import {
usePersonalNavigationPreferences,
useWorkspaceNavigationPreferences,
} from "@/hooks/use-navigation-preferences";
import { SidebarProjectsListItem } from "@/components/workspace/sidebar/projects-list-item";
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root";
import { openWorkspaceNotificationsModal } from "@/components/workspace-notifications/notifications-modal.utils";
import { getSidebarNavigationItemIcon } from "@/plane-web/components/workspace/sidebar/helper";
type TToolbarItem = {
key: string;
href?: string;
labelTranslationKey: string;
active: boolean;
icon: React.ReactNode;
onClick?: () => void;
};
const ToolbarIconLink = ({ item }: { item: TToolbarItem }) => {
const { t } = useTranslation();
return (
<Tooltip tooltipContent={t(item.labelTranslationKey)} position="bottom">
<Link
href={item.href ?? "#"}
className="nodedc-toolbar-icon-button flex h-8 w-8 items-center justify-center"
data-active={item.active}
aria-label={t(item.labelTranslationKey)}
>
<span className="nodedc-toolbar-icon-active-dot">{item.icon}</span>
</Link>
</Tooltip>
);
};
const ToolbarIconButton = ({
label,
active = false,
children,
onClick,
disabled = false,
}: {
label: string;
active?: boolean;
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
}) => (
<Tooltip tooltipContent={label} position="bottom">
<button
type="button"
className="nodedc-toolbar-icon-button flex h-8 w-8 items-center justify-center"
data-active={active}
aria-label={label}
onClick={onClick}
disabled={disabled}
>
<span className="nodedc-toolbar-icon-active-dot">{children}</span>
</button>
</Tooltip>
);
const ProjectsToolbarMenu = observer(function ProjectsToolbarMenu() {
const { t } = useTranslation();
const pathname = usePathname();
const { workspaceSlug } = useParams();
const { joinedProjectIds } = useProject();
const { toggleCreateProjectModal } = useCommandPalette();
const handleCopyText = (projectId: string) =>
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/issues`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("link_copied"),
message: t("project_link_copied_to_clipboard"),
});
});
return (
<Menu as="div" className="relative">
<Menu.Button
type="button"
title={t("workspace_sidebar.projects.main")}
className="nodedc-toolbar-icon-button grid h-8 w-8 place-items-center"
aria-label={t("workspace_sidebar.projects.main")}
>
<span
className={`nodedc-toolbar-icon-active-dot ${
pathname.includes("/projects/")
? "bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))]"
: ""
}`}
>
<ProjectIcon className="size-4" />
</span>
</Menu.Button>
<Menu.Items className="absolute top-full -right-2 z-[170] mt-2 origin-top-right">
<div className="nodedc-glass-modal nodedc-glass-popup-surface flex max-h-[70vh] min-w-[26rem] flex-col overflow-hidden rounded-[1.5rem] border-0 p-2 shadow-none outline-none">
<div className="vertical-scrollbar flex scrollbar-sm max-h-[70vh] flex-col gap-0.5 overflow-y-auto pr-1">
{joinedProjectIds.map((projectId, index) => (
<SidebarProjectsListItem
key={projectId}
projectId={projectId}
handleCopyText={() => handleCopyText(projectId)}
projectListType="JOINED"
disableDrag
disableDrop
isLastChild={index === joinedProjectIds.length - 1}
renderInToolbarMenu
/>
))}
</div>
<div className="mt-2 border-t border-white/8 px-1 pt-2">
<Menu.Item>
<button
type="button"
className="flex w-full items-center gap-3 rounded-md px-2 py-2 text-left text-13 font-medium text-secondary transition-colors hover:bg-layer-transparent-hover hover:text-primary"
onClick={() => toggleCreateProjectModal(true)}
>
<span className="grid size-8 flex-shrink-0 place-items-center">
<PlusIcon className="size-4" />
</span>
<span>{t("create_project")}</span>
</button>
</Menu.Item>
</div>
</div>
</Menu.Items>
</Menu>
);
});
import {
DEFAULT_PROJECT_SHELL_TOOLBAR_LAYOUT,
PROJECT_SHELL_TOOLBAR_LAYOUTS,
type TProjectShellToolbarLayout,
type TToolbarItem,
} from "./top-toolbar";
export const ProjectShellTopToolbar = observer(function ProjectShellTopToolbar() {
const { t } = useTranslation();
const pathname = usePathname();
const { workspaceSlug } = useParams();
const { toggleCreateIssueModal } = useCommandPalette();
const { joinedProjectIds } = useProject();
const { data: currentUser } = useUser();
const { data: userProfile } = useUserProfile();
const { allowPermissions } = useUserPermissions();
const { unreadNotificationsCount, getUnreadNotificationsCount } = useWorkspaceNotifications();
const { preferences: personalPreferences } = usePersonalNavigationPreferences();
@ -186,7 +58,7 @@ export const ProjectShellTopToolbar = observer(function ProjectShellTopToolbar()
);
const isMentionsEnabled = unreadNotificationsCount.mention_unread_notifications_count > 0;
const totalNotifications = isMentionsEnabled
const notificationsCount = isMentionsEnabled
? unreadNotificationsCount.mention_unread_notifications_count
: unreadNotificationsCount.total_unread_notifications_count;
@ -249,62 +121,27 @@ export const ProjectShellTopToolbar = observer(function ProjectShellTopToolbar()
}).sort((a, b) => a.sort_order - b.sort_order),
[pathname, workspacePreferences, workspaceSlug]
);
const workspaceSlugValue = workspaceSlug?.toString();
const isWorkspaceHome = pathname === `/${workspaceSlugValue}` || pathname === `/${workspaceSlugValue}/`;
const toolbarLayout: TProjectShellToolbarLayout =
userProfile?.theme?.nodedcCompactToolbar === true ? "compact" : DEFAULT_PROJECT_SHELL_TOOLBAR_LAYOUT;
const ToolbarLayout = PROJECT_SHELL_TOOLBAR_LAYOUTS[toolbarLayout];
return (
<div
className={cn("z-20 w-full flex-shrink-0 px-4 pt-4 pb-3", {
"nodedc-home-top-toolbar": isWorkspaceHome,
})}
>
<div className="nodedc-glass-modal flex w-full flex-wrap items-center justify-between gap-4 rounded-[1.6rem] px-4 py-3">
<div className="flex min-w-0 items-center gap-3">
<div className="nodedc-toolbar-group relative flex items-center gap-1 overflow-visible">
<WorkspaceMenuRoot variant="toolbar" />
<TopNavPowerK variant="sidebar" />
<UserMenuRoot variant="toolbar" />
<Tooltip tooltipContent={t("notification.label")} position="bottom">
<button
type="button"
className="nodedc-toolbar-icon-button relative flex h-8 w-8 items-center justify-center"
data-active={false}
aria-label={t("notification.label")}
onClick={() => openWorkspaceNotificationsModal()}
>
<span className="nodedc-toolbar-icon-active-dot">
<InboxIcon className="size-4" />
</span>
{totalNotifications > 0 && (
<span className="absolute top-1.5 right-1.5 size-2 rounded-full bg-danger-primary" />
)}
</button>
</Tooltip>
<ToolbarIconButton
label={t("app_header.add_task")}
onClick={() => toggleCreateIssueModal(true)}
disabled={!canCreateIssue || joinedProjectIds.length === 0}
>
<PlusIcon className="size-4" />
</ToolbarIconButton>
</div>
</div>
<div className="flex min-w-0 items-center justify-end gap-3">
<div className="nodedc-toolbar-group flex items-center gap-1">
{primaryItems.map((item) => (
<ToolbarIconLink key={item.key} item={item} />
))}
</div>
<div className="nodedc-toolbar-group relative flex items-center gap-1 overflow-visible">
<ProjectsToolbarMenu />
{secondaryItems.map((item) => (
<ToolbarIconLink key={item.key} item={item} />
))}
</div>
</div>
</div>
</div>
<ToolbarLayout
canCreateIssue={canCreateIssue}
draftsItem={primaryItems.find((item) => item.key === "drafts")}
homeItem={primaryItems.find((item) => item.key === "home")}
isWorkspaceHome={isWorkspaceHome}
joinedProjectIdsCount={joinedProjectIds.length}
notificationsCount={notificationsCount}
primaryItems={primaryItems}
profileItem={primaryItems.find((item) => item.key === "your_work")}
secondaryItems={secondaryItems}
stickiesItem={primaryItems.find((item) => item.key === "stickies")}
onCreateIssue={() => toggleCreateIssueModal(true)}
onOpenNotifications={() => openWorkspaceNotificationsModal()}
/>
);
});

View File

@ -36,7 +36,6 @@ import {
DisplayFiltersSelection,
FiltersDropdown,
LayoutSelection,
MobileLayoutSelection,
} from "@/components/issues/issue-layouts/filters";
import { WorkItemFiltersToggle } from "@/components/work-item-filters/filters-toggle";
// hooks
@ -185,32 +184,17 @@ export const CycleIssuesHeader = observer(function CycleIssuesHeader() {
</Header.LeftItem>
<Header.RightItem className="items-center">
<div className="hidden items-center gap-2 md:flex">
<div className="hidden @4xl:flex">
<LayoutSelection
layouts={[
EIssueLayoutTypes.LIST,
EIssueLayoutTypes.KANBAN,
EIssueLayoutTypes.CALENDAR,
EIssueLayoutTypes.SPREADSHEET,
EIssueLayoutTypes.GANTT,
]}
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
/>
</div>
<div className="flex @4xl:hidden">
<MobileLayoutSelection
layouts={[
EIssueLayoutTypes.LIST,
EIssueLayoutTypes.KANBAN,
EIssueLayoutTypes.CALENDAR,
EIssueLayoutTypes.SPREADSHEET,
EIssueLayoutTypes.GANTT,
]}
onChange={(layout) => handleLayoutChange(layout)}
activeLayout={activeLayout}
/>
</div>
<LayoutSelection
layouts={[
EIssueLayoutTypes.LIST,
EIssueLayoutTypes.KANBAN,
EIssueLayoutTypes.CALENDAR,
EIssueLayoutTypes.SPREADSHEET,
EIssueLayoutTypes.GANTT,
]}
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
/>
<WorkItemFiltersToggle entityType={EIssuesStoreType.CYCLE} entityId={cycleId} />
<FiltersDropdown
title={t("common.display")}

View File

@ -18,7 +18,7 @@ import { WorkItemsModal } from "@/components/analytics/work-items/modal";
import {
DisplayFiltersSelection,
FiltersDropdown,
MobileLayoutSelection,
LayoutSelection,
} from "@/components/issues/issue-layouts/filters";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
@ -93,9 +93,15 @@ export const CycleIssuesMobileHeader = observer(function CycleIssuesMobileHeader
cycleDetails={cycleDetails ?? undefined}
/>
<div className="flex justify-evenly border-b border-subtle bg-surface-1 py-2 md:hidden">
<MobileLayoutSelection
activeLayout={activeLayout}
layouts={[EIssueLayoutTypes.LIST, EIssueLayoutTypes.KANBAN, EIssueLayoutTypes.CALENDAR]}
<LayoutSelection
selectedLayout={activeLayout}
layouts={[
EIssueLayoutTypes.LIST,
EIssueLayoutTypes.KANBAN,
EIssueLayoutTypes.CALENDAR,
EIssueLayoutTypes.SPREADSHEET,
EIssueLayoutTypes.GANTT,
]}
onChange={handleLayoutChange}
/>
<div className="flex flex-grow items-center justify-center border-l border-subtle text-13 text-secondary">

View File

@ -18,7 +18,7 @@ import { WorkItemsModal } from "@/components/analytics/work-items/modal";
import {
DisplayFiltersSelection,
FiltersDropdown,
MobileLayoutSelection,
LayoutSelection,
} from "@/components/issues/issue-layouts/filters";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
@ -69,9 +69,16 @@ export const ProjectIssuesMobileHeader = observer(function ProjectIssuesMobileHe
projectDetails={currentProjectDetails ?? undefined}
/>
<div className="z-[13] flex justify-evenly border-b border-subtle bg-surface-1 py-2 md:hidden">
<MobileLayoutSelection
layouts={[EIssueLayoutTypes.LIST, EIssueLayoutTypes.KANBAN, EIssueLayoutTypes.CALENDAR]}
<LayoutSelection
layouts={[
EIssueLayoutTypes.LIST,
EIssueLayoutTypes.KANBAN,
EIssueLayoutTypes.CALENDAR,
EIssueLayoutTypes.SPREADSHEET,
EIssueLayoutTypes.GANTT,
]}
onChange={handleLayoutChange}
selectedLayout={activeLayout}
/>
<div className="flex flex-grow items-center justify-center border-l border-subtle text-13 text-secondary">
<FiltersDropdown

View File

@ -32,7 +32,6 @@ import {
DisplayFiltersSelection,
FiltersDropdown,
LayoutSelection,
MobileLayoutSelection,
} from "@/components/issues/issue-layouts/filters";
import { ModuleQuickActions } from "@/components/modules";
import { WorkItemFiltersToggle } from "@/components/work-item-filters/filters-toggle";
@ -179,32 +178,17 @@ export const ModuleIssuesHeader = observer(function ModuleIssuesHeader() {
</Header.LeftItem>
<Header.RightItem className="items-center">
<div className="hidden gap-2 md:flex">
<div className="hidden @4xl:flex">
<LayoutSelection
layouts={[
EIssueLayoutTypes.LIST,
EIssueLayoutTypes.KANBAN,
EIssueLayoutTypes.CALENDAR,
EIssueLayoutTypes.SPREADSHEET,
EIssueLayoutTypes.GANTT,
]}
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
/>
</div>
<div className="flex @4xl:hidden">
<MobileLayoutSelection
layouts={[
EIssueLayoutTypes.LIST,
EIssueLayoutTypes.KANBAN,
EIssueLayoutTypes.CALENDAR,
EIssueLayoutTypes.SPREADSHEET,
EIssueLayoutTypes.GANTT,
]}
onChange={(layout) => handleLayoutChange(layout)}
activeLayout={activeLayout}
/>
</div>
<LayoutSelection
layouts={[
EIssueLayoutTypes.LIST,
EIssueLayoutTypes.KANBAN,
EIssueLayoutTypes.CALENDAR,
EIssueLayoutTypes.SPREADSHEET,
EIssueLayoutTypes.GANTT,
]}
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
/>
{moduleId && <WorkItemFiltersToggle entityType={EIssuesStoreType.MODULE} entityId={moduleId} />}
<FiltersDropdown
title="Display"

View File

@ -18,7 +18,7 @@ import { WorkItemsModal } from "@/components/analytics/work-items/modal";
import {
DisplayFiltersSelection,
FiltersDropdown,
MobileLayoutSelection,
LayoutSelection,
} from "@/components/issues/issue-layouts/filters";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
@ -75,9 +75,15 @@ export const ModuleIssuesMobileHeader = observer(function ModuleIssuesMobileHead
projectDetails={currentProjectDetails}
/>
<div className="flex justify-evenly border-b border-subtle bg-surface-1 py-2">
<MobileLayoutSelection
activeLayout={activeLayout}
layouts={[EIssueLayoutTypes.LIST, EIssueLayoutTypes.KANBAN, EIssueLayoutTypes.CALENDAR]}
<LayoutSelection
selectedLayout={activeLayout}
layouts={[
EIssueLayoutTypes.LIST,
EIssueLayoutTypes.KANBAN,
EIssueLayoutTypes.CALENDAR,
EIssueLayoutTypes.SPREADSHEET,
EIssueLayoutTypes.GANTT,
]}
onChange={handleLayoutChange}
/>
<div className="flex flex-grow items-center justify-center border-l border-subtle text-13 text-secondary">

View File

@ -21,6 +21,7 @@ import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
import { ExpandedToolbarBreadcrumbs } from "@/plane-web/components/breadcrumbs/expanded-toolbar-breadcrumbs";
import { ProjectFeatureBreadcrumb } from "@/plane-web/components/breadcrumbs/project-feature";
import { PageDetailsHeaderExtraActions } from "@/plane-web/components/pages";
import { EPageStoreType, usePage, usePageStore } from "@/plane-web/hooks/store";
@ -68,7 +69,7 @@ export const PageDetailsHeader = observer(function PageDetailsHeader() {
<Header>
<Header.LeftItem>
<div>
<Breadcrumbs isLoading={loader === "init-loader"}>
<ExpandedToolbarBreadcrumbs isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<ProjectFeatureBreadcrumb
workspaceSlug={workspaceSlug?.toString()}
@ -94,7 +95,7 @@ export const PageDetailsHeader = observer(function PageDetailsHeader() {
/>
}
/>
</Breadcrumbs>
</ExpandedToolbarBreadcrumbs>
</div>
</Header.LeftItem>
<Header.RightItem>

View File

@ -14,13 +14,14 @@ import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TPage } from "@plane/types";
// plane ui
import { Breadcrumbs, Header } from "@plane/ui";
import { Header } from "@plane/ui";
// components
import { AppHeaderPrimaryActionButton } from "@/components/core/app-header/primary-action-button";
// hooks
import { useProject } from "@/hooks/store/use-project";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
import { ExpandedToolbarBreadcrumbs } from "@/plane-web/components/breadcrumbs/expanded-toolbar-breadcrumbs";
import { ProjectFeatureBreadcrumb } from "@/plane-web/components/breadcrumbs/project-feature";
import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store";
@ -62,7 +63,7 @@ export const PagesListHeader = observer(function PagesListHeader() {
return (
<Header>
<Header.LeftItem>
<Breadcrumbs isLoading={loader === "init-loader"}>
<ExpandedToolbarBreadcrumbs isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<ProjectFeatureBreadcrumb
workspaceSlug={workspaceSlug?.toString()}
@ -70,7 +71,7 @@ export const PagesListHeader = observer(function PagesListHeader() {
featureKey={EProjectFeatureKey.PAGES}
isLast
/>
</Breadcrumbs>
</ExpandedToolbarBreadcrumbs>
</Header.LeftItem>
{canCurrentUserCreatePage && (
<Header.RightItem>

View File

@ -0,0 +1,77 @@
"use client";
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useTranslation } from "@plane/i18n";
import { PlusIcon } from "@plane/propel/icons";
import { cn } from "@plane/utils";
// components
import { TopNavPowerK } from "@/components/navigation";
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root";
import { ProjectsToolbarMenu } from "./projects-toolbar-menu";
import { ToolbarIconButton, ToolbarIconLink, ToolbarNotificationsButton } from "./toolbar-controls";
// types
import type { TProjectShellToolbarLayoutProps } from "./types";
export const CompactProjectShellToolbarLayout = ({
canCreateIssue,
isWorkspaceHome,
joinedProjectIdsCount,
notificationsCount,
primaryItems,
secondaryItems,
onCreateIssue,
onOpenNotifications,
}: TProjectShellToolbarLayoutProps) => {
const { t } = useTranslation();
return (
<div
className={cn("z-20 w-full flex-shrink-0 px-4 pt-4 pb-3", {
"nodedc-home-top-toolbar": isWorkspaceHome,
})}
>
<div className="nodedc-glass-modal flex w-full flex-wrap items-center justify-between gap-4 rounded-[1.6rem] px-4 py-3">
<div className="flex min-w-0 items-center gap-3">
<div className="nodedc-toolbar-group relative flex items-center gap-1 overflow-visible">
<WorkspaceMenuRoot variant="toolbar" />
<TopNavPowerK variant="sidebar" />
<UserMenuRoot variant="toolbar" />
<ToolbarNotificationsButton
label={t("notification.label")}
notificationsCount={notificationsCount}
onClick={onOpenNotifications}
/>
<ToolbarIconButton
label={t("app_header.add_task")}
onClick={onCreateIssue}
disabled={!canCreateIssue || joinedProjectIdsCount === 0}
>
<PlusIcon className="size-4" />
</ToolbarIconButton>
</div>
</div>
<div className="flex min-w-0 items-center justify-end gap-3">
<div className="nodedc-toolbar-group flex items-center gap-1">
{primaryItems.map((item) => (
<ToolbarIconLink key={item.key} item={item} />
))}
</div>
<div className="nodedc-toolbar-group relative flex items-center gap-1 overflow-visible">
<ProjectsToolbarMenu />
{secondaryItems.map((item) => (
<ToolbarIconLink key={item.key} item={item} />
))}
</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,91 @@
"use client";
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useTranslation } from "@plane/i18n";
import { Shapes } from "lucide-react";
import { cn } from "@plane/utils";
// components
import { TopNavPowerK } from "@/components/navigation";
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root";
import { useHome } from "@/hooks/store/use-home";
import { ProjectsToolbarMenu } from "./projects-toolbar-menu";
import { ExpandedToolbarLink, ExpandedToolbarToolButton, ToolbarNotificationsButton } from "./toolbar-controls";
// types
import type { TProjectShellToolbarLayoutProps } from "./types";
export const ExpandedProjectShellToolbarLayout = ({
draftsItem,
homeItem,
isWorkspaceHome,
notificationsCount,
profileItem,
stickiesItem,
onOpenNotifications,
}: TProjectShellToolbarLayoutProps) => {
const { t } = useTranslation();
const { toggleWidgetSettings } = useHome();
return (
<div
className={cn("nodedc-expanded-toolbar-shell w-full flex-shrink-0 px-5 pt-4 pb-3", {
"nodedc-home-top-toolbar": isWorkspaceHome,
})}
>
<div className="nodedc-expanded-toolbar">
<div className="nodedc-expanded-toolbar-top">
<div className="nodedc-expanded-toolbar-left">
<img src="/nodedc-logo.svg" alt="NODE DC" className="nodedc-expanded-brand-logo" />
</div>
<div className="nodedc-expanded-toolbar-center">
<WorkspaceMenuRoot variant="expanded-toolbar" />
<div className="nodedc-expanded-nav-group">
<ExpandedToolbarLink item={homeItem} label="Главная" />
<ProjectsToolbarMenu variant="expanded" />
<ExpandedToolbarLink item={stickiesItem} label="Стикеры" />
<ExpandedToolbarLink item={draftsItem} label="Черновики" />
</div>
</div>
<div className="nodedc-expanded-toolbar-right">
<div className="nodedc-expanded-user-group">
<ExpandedToolbarLink item={profileItem} label="Профиль" />
<ToolbarNotificationsButton
label={t("notification.label")}
notificationsCount={notificationsCount}
onClick={onOpenNotifications}
variant="expanded"
/>
<UserMenuRoot variant="expanded-toolbar" />
</div>
</div>
</div>
<div className="nodedc-expanded-toolbar-tools-row">
<div className="nodedc-expanded-breadcrumbs-slot" data-nodedc-expanded-breadcrumbs-slot />
{!isWorkspaceHome && (
<div className="nodedc-expanded-main-tool-cluster">
<TopNavPowerK variant="expanded-toolbar" />
<div className="nodedc-expanded-header-filters-slot" data-nodedc-expanded-header-filters-slot />
</div>
)}
<div className="nodedc-expanded-action-tool-cluster">
<div className="nodedc-expanded-tool-slot" data-nodedc-voice-task-toolbar-slot />
{isWorkspaceHome && (
<ExpandedToolbarToolButton label={t("home.manage_widgets")} onClick={() => toggleWidgetSettings(true)}>
<Shapes className="size-4" />
</ExpandedToolbarToolButton>
)}
<div className="nodedc-expanded-primary-action-slot" data-nodedc-expanded-primary-action-slot />
</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,2 @@
export * from "./layout-registry";
export * from "./types";

View File

@ -0,0 +1,22 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { ComponentType } from "react";
// components
import { CompactProjectShellToolbarLayout } from "./compact-layout";
import { ExpandedProjectShellToolbarLayout } from "./expanded-layout";
// types
import type { TProjectShellToolbarLayout, TProjectShellToolbarLayoutProps } from "./types";
export const DEFAULT_PROJECT_SHELL_TOOLBAR_LAYOUT: TProjectShellToolbarLayout = "expanded";
export const PROJECT_SHELL_TOOLBAR_LAYOUTS: Record<
TProjectShellToolbarLayout,
ComponentType<TProjectShellToolbarLayoutProps>
> = {
compact: CompactProjectShellToolbarLayout,
expanded: ExpandedProjectShellToolbarLayout,
};

View File

@ -0,0 +1,102 @@
"use client";
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Menu } from "@headlessui/react";
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import { useTranslation } from "@plane/i18n";
import { PlusIcon, ProjectIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { cn, copyUrlToClipboard } from "@plane/utils";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
// components
import { SidebarProjectsListItem } from "@/components/workspace/sidebar/projects-list-item";
export const ProjectsToolbarMenu = observer(function ProjectsToolbarMenu({
variant = "compact",
}: {
variant?: "compact" | "expanded";
}) {
const { t } = useTranslation();
const pathname = usePathname();
const { workspaceSlug } = useParams();
const { joinedProjectIds } = useProject();
const { toggleCreateProjectModal } = useCommandPalette();
const handleCopyText = (projectId: string) =>
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/issues`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("link_copied"),
message: t("project_link_copied_to_clipboard"),
});
});
return (
<Menu as="div" className="relative">
<Menu.Button
type="button"
title={t("workspace_sidebar.projects.main")}
className={cn(
variant === "expanded"
? "nodedc-expanded-nav-button"
: "nodedc-toolbar-icon-button grid h-8 w-8 place-items-center"
)}
data-active={pathname.includes("/projects/")}
aria-label={t("workspace_sidebar.projects.main")}
>
{variant === "expanded" ? null : (
<span className="nodedc-toolbar-icon-active-dot">
<ProjectIcon className="size-4" />
</span>
)}
{variant === "expanded" && <span>Проекты</span>}
</Menu.Button>
<Menu.Items
className={cn(
"absolute top-full z-[170] mt-2",
variant === "expanded" ? "left-0 origin-top-left" : "-right-2 origin-top-right"
)}
>
<div className="nodedc-glass-modal nodedc-glass-popup-surface flex max-h-[70vh] min-w-[26rem] flex-col overflow-hidden rounded-[1.5rem] border-0 p-2 shadow-none outline-none">
<div className="vertical-scrollbar flex scrollbar-sm max-h-[70vh] flex-col gap-0.5 overflow-y-auto pr-1">
{joinedProjectIds.map((projectId, index) => (
<SidebarProjectsListItem
key={projectId}
projectId={projectId}
handleCopyText={() => handleCopyText(projectId)}
projectListType="JOINED"
disableDrag
disableDrop
isLastChild={index === joinedProjectIds.length - 1}
renderInToolbarMenu
/>
))}
</div>
<div className="mt-2 border-t border-white/8 px-1 pt-2">
<Menu.Item>
<button
type="button"
className="flex w-full items-center gap-3 rounded-md px-2 py-2 text-left text-13 font-medium text-secondary transition-colors hover:bg-layer-transparent-hover hover:text-primary"
onClick={() => toggleCreateProjectModal(true)}
>
<span className="grid size-8 flex-shrink-0 place-items-center">
<PlusIcon className="size-4" />
</span>
<span>{t("create_project")}</span>
</button>
</Menu.Item>
</div>
</div>
</Menu.Items>
</Menu>
);
});

View File

@ -0,0 +1,118 @@
"use client";
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import Link from "next/link";
import type { ReactNode } from "react";
import { useTranslation } from "@plane/i18n";
import { InboxIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { cn } from "@plane/utils";
// types
import type { TToolbarItem } from "./types";
export const ToolbarIconLink = ({ item }: { item: TToolbarItem }) => {
const { t } = useTranslation();
return (
<Tooltip tooltipContent={t(item.labelTranslationKey)} position="bottom">
<Link
href={item.href ?? "#"}
className="nodedc-toolbar-icon-button flex h-8 w-8 items-center justify-center"
data-active={item.active}
aria-label={t(item.labelTranslationKey)}
>
<span className="nodedc-toolbar-icon-active-dot">{item.icon}</span>
</Link>
</Tooltip>
);
};
export const ToolbarIconButton = ({
label,
active = false,
children,
onClick,
disabled = false,
}: {
label: string;
active?: boolean;
children: ReactNode;
onClick?: () => void;
disabled?: boolean;
}) => (
<Tooltip tooltipContent={label} position="bottom">
<button
type="button"
className="nodedc-toolbar-icon-button flex h-8 w-8 items-center justify-center"
data-active={active}
aria-label={label}
onClick={onClick}
disabled={disabled}
>
<span className="nodedc-toolbar-icon-active-dot">{children}</span>
</button>
</Tooltip>
);
export const ToolbarNotificationsButton = ({
label,
notificationsCount,
onClick,
variant = "compact",
}: {
label: string;
notificationsCount: number;
onClick: () => void;
variant?: "compact" | "expanded";
}) => (
<Tooltip tooltipContent={label} position="bottom">
<button
type="button"
className={cn(
"nodedc-toolbar-icon-button relative flex items-center justify-center",
variant === "expanded" ? "nodedc-expanded-notification-button" : "h-8 w-8"
)}
data-active={false}
aria-label={label}
onClick={onClick}
>
<span className="nodedc-toolbar-icon-active-dot">
<InboxIcon className={variant === "expanded" ? "size-5" : "size-4"} />
</span>
{notificationsCount > 0 && (
<span className="nodedc-toolbar-notification-dot absolute top-1.5 right-1.5 size-2 rounded-full bg-danger-primary" />
)}
</button>
</Tooltip>
);
export const ExpandedToolbarLink = ({ item, label }: { item?: TToolbarItem; label: string }) => {
if (!item?.href) return null;
return (
<Link href={item.href} className="nodedc-expanded-nav-button" data-active={item.active}>
<span>{label}</span>
</Link>
);
};
export const ExpandedToolbarToolButton = ({
label,
children,
onClick,
}: {
label: string;
children: ReactNode;
onClick?: () => void;
}) => (
<Tooltip tooltipContent={label} position="bottom">
<button type="button" className="nodedc-expanded-tool-button" aria-label={label} onClick={onClick}>
{children}
</button>
</Tooltip>
);

View File

@ -0,0 +1,33 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { ReactNode } from "react";
export type TProjectShellToolbarLayout = "compact" | "expanded";
export type TToolbarItem = {
key: string;
href?: string;
labelTranslationKey: string;
active: boolean;
icon: ReactNode;
onClick?: () => void;
};
export type TProjectShellToolbarLayoutProps = {
canCreateIssue: boolean;
draftsItem?: TToolbarItem;
homeItem?: TToolbarItem;
isWorkspaceHome: boolean;
joinedProjectIdsCount: number;
notificationsCount: number;
primaryItems: TToolbarItem[];
profileItem?: TToolbarItem;
secondaryItems: TToolbarItem[];
stickiesItem?: TToolbarItem;
onCreateIssue: () => void;
onOpenNotifications: () => void;
};

View File

@ -5,12 +5,11 @@
*/
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { createRoot } from "react-dom/client";
import { HydratedRouter } from "react-router/dom";
startTransition(() => {
hydrateRoot(
document,
createRoot(document).render(
<StrictMode>
<HydratedRouter />
</StrictMode>

View File

@ -8,7 +8,7 @@ import type { CSSProperties, ReactNode } from "react";
import Script from "next/script";
import { Links, Meta, Outlet, Scripts } from "react-router";
import type { LinksFunction } from "react-router";
import { ThemeProvider, useTheme } from "next-themes";
import { ThemeProvider } from "next-themes";
// plane imports
import { SITE_DESCRIPTION, SITE_NAME } from "@plane/constants";
import { cn } from "@plane/utils";
@ -24,7 +24,6 @@ import globalStyles from "@/styles/globals.css?url";
import type { Route } from "./+types/root";
import designConfig from "../design.config.json";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
// local
import { CustomErrorComponent } from "./error";
import { AppProvider } from "./provider";
@ -54,7 +53,8 @@ const toRelativeLuminance = (rgb: readonly number[]) => {
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const getReadableTextRgb = (rgb: readonly number[]) => (toRelativeLuminance(rgb) > 0.52 ? DARK_TEXT_RGB : LIGHT_TEXT_RGB);
const getReadableTextRgb = (rgb: readonly number[]) =>
toRelativeLuminance(rgb) > 0.52 ? DARK_TEXT_RGB : LIGHT_TEXT_RGB;
const accentRgb = designConfig.nodedc.accent_rgb as [number, number, number];
const activeCardRgb = designConfig.nodedc.active_card_rgb as [number, number, number];
@ -68,10 +68,13 @@ const onPassiveCardRgb = getReadableTextRgb(passiveCardRgb);
const designConfigStyle = {
"--nodedc-accent-rgb": formatRgbTuple(accentRgb),
"--nodedc-card-passive-rgb": formatRgbTuple(passiveCardRgb),
"--nodedc-card-passive-surface-rgb": formatRgbTuple(passiveCardRgb),
"--nodedc-presence-dot-border-rgb": formatRgbTuple(passiveCardRgb),
"--nodedc-card-active-rgb": formatRgbTuple(activeCardRgb),
"--nodedc-on-accent-rgb": formatRgbTuple(onAccentRgb),
"--nodedc-on-card-active-rgb": formatRgbTuple(onActiveCardRgb),
"--nodedc-on-card-passive-rgb": formatRgbTuple(onPassiveCardRgb),
"--nodedc-on-card-passive-surface-rgb": formatRgbTuple(onPassiveCardRgb),
"--brand-default": formatCssRgb(accentRgb),
"--brand-300": formatCssRgb(blendRgb(accentRgb, 255, 0.35)),
"--brand-700": formatCssRgb(blendRgb(accentRgb, 0, 0.25)),
@ -188,16 +191,7 @@ export default function Root() {
}
export function HydrateFallback() {
const { resolvedTheme } = useTheme();
// if we are on the server or the theme is not resolved, return an empty div
if (typeof window === "undefined" || resolvedTheme === undefined) return <div />;
return (
<div className="relative flex h-screen w-full items-center justify-center bg-canvas">
<LogoSpinner />
</div>
);
return null;
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {

View File

@ -6,18 +6,28 @@
// local components
import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences";
import { useUserProfile } from "@/hooks/store/user";
import { ProjectBreadcrumb } from "./project";
type TCommonProjectBreadcrumbProps = {
workspaceSlug: string;
projectId: string;
shouldTruncate?: boolean;
};
export function CommonProjectBreadcrumbs(props: TCommonProjectBreadcrumbProps) {
const { workspaceSlug, projectId } = props;
const { workspaceSlug, projectId, shouldTruncate } = props;
// preferences
const { preferences: projectPreferences } = useProjectNavigationPreferences();
const { data: userProfile } = useUserProfile();
const shouldUseCompactToolbar = userProfile?.theme?.nodedcCompactToolbar === true;
if (projectPreferences.navigationMode === "TABBED") return null;
return <ProjectBreadcrumb workspaceSlug={workspaceSlug} projectId={projectId} />;
return (
<ProjectBreadcrumb
workspaceSlug={workspaceSlug}
projectId={projectId}
shouldTruncate={shouldTruncate ?? shouldUseCompactToolbar}
/>
);
}

View File

@ -0,0 +1,59 @@
"use client";
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useState } from "react";
import type { ReactNode } from "react";
import { createPortal } from "react-dom";
import { observer } from "mobx-react";
import { Breadcrumbs } from "@plane/ui";
import { useUserProfile } from "@/hooks/store/user";
type TExpandedToolbarBreadcrumbsProps = {
children: ReactNode;
isLoading?: boolean;
onBack?: () => void;
};
export const ExpandedToolbarBreadcrumbs = observer(function ExpandedToolbarBreadcrumbs(
props: TExpandedToolbarBreadcrumbsProps
) {
const { children, isLoading = false, onBack } = props;
const { data: userProfile } = useUserProfile();
const [target, setTarget] = useState<HTMLElement | null>(null);
const isCompactToolbar = userProfile?.theme?.nodedcCompactToolbar === true;
useEffect(() => {
if (isCompactToolbar || typeof document === "undefined") {
setTarget(null);
return;
}
const animationFrame = window.requestAnimationFrame(() => {
setTarget(document.querySelector<HTMLElement>("[data-nodedc-expanded-breadcrumbs-slot]"));
});
return () => window.cancelAnimationFrame(animationFrame);
}, [isCompactToolbar]);
const content = (
<div className="flex min-w-0 items-center gap-2.5 overflow-visible">
<Breadcrumbs
onBack={onBack}
isLoading={isLoading}
className={isCompactToolbar ? "flex-grow-0" : "nodedc-expanded-breadcrumbs flex-grow-0"}
>
{children}
</Breadcrumbs>
</div>
);
if (!isCompactToolbar && target) return createPortal(content, target);
return content;
});

View File

@ -19,10 +19,11 @@ import { BreadcrumbNavigationSearchDropdown } from "@plane/ui";
type TProjectBreadcrumbProps = {
workspaceSlug: string;
projectId: string;
shouldTruncate?: boolean;
};
export const ProjectBreadcrumb = observer(function ProjectBreadcrumb(props: TProjectBreadcrumbProps) {
const { workspaceSlug, projectId } = props;
const { workspaceSlug, projectId, shouldTruncate = true } = props;
// router
const router = useAppRouter();
// store hooks
@ -69,7 +70,7 @@ export const ProjectBreadcrumb = observer(function ProjectBreadcrumb(props: TPro
title={currentProjectDetails?.name}
icon={renderIcon(currentProjectDetails)}
openOnLabelClick
shouldTruncate
shouldTruncate={shouldTruncate}
/>
);
});

View File

@ -21,7 +21,7 @@ import { useTranslation } from "@plane/i18n";
import { NewTabIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { EIssuesStoreType } from "@plane/types";
import { Breadcrumbs, Header } from "@plane/ui";
import { Header } from "@plane/ui";
import { CountChip } from "@/components/common/count-chip";
import { AppHeaderPrimaryActionButton } from "@/components/core/app-header/primary-action-button";
// constants
@ -31,11 +31,12 @@ import { HeaderFilters } from "@/components/issues/filters";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useIssues } from "@/hooks/store/use-issues";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useUserPermissions, useUserProfile } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
import { ExpandedToolbarBreadcrumbs } from "@/plane-web/components/breadcrumbs/expanded-toolbar-breadcrumbs";
import { ProjectFeatureBreadcrumb } from "@/plane-web/components/breadcrumbs/project-feature";
export const IssuesHeader = observer(function IssuesHeader() {
@ -53,40 +54,46 @@ export const IssuesHeader = observer(function IssuesHeader() {
const { toggleCreateIssueModal } = useCommandPalette();
const { allowPermissions } = useUserPermissions();
const { data: userProfile } = useUserProfile();
const { isMobile } = usePlatformOS();
const SPACE_APP_URL = (SPACE_BASE_URL.trim() === "" ? window.location.origin : SPACE_BASE_URL) + SPACE_BASE_PATH;
const publishedURL = `${SPACE_APP_URL}/issues/${currentProjectDetails?.anchor}`;
const issuesCount = getGroupIssueCount(undefined, undefined, false);
const isCompactToolbar = userProfile?.theme?.nodedcCompactToolbar === true;
const canUserCreateIssue = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT
);
const breadcrumbsContent = (
<>
<ExpandedToolbarBreadcrumbs onBack={() => router.back()} isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<ProjectFeatureBreadcrumb
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
featureKey={EProjectFeatureKey.WORK_ITEMS}
isLast
/>
</ExpandedToolbarBreadcrumbs>
{isCompactToolbar && issuesCount && issuesCount > 0 ? (
<Tooltip
isMobile={isMobile}
tooltipContent={t("issues_header.count_tooltip", { count: issuesCount })}
position="bottom"
>
<CountChip count={issuesCount} />
</Tooltip>
) : null}
</>
);
return (
<Header>
<Header.LeftItem className="nodedc-bottom-dock-left">
<div className="flex min-w-0 items-center gap-2.5 overflow-hidden">
<Breadcrumbs onBack={() => router.back()} isLoading={loader === "init-loader"} className="flex-grow-0">
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<ProjectFeatureBreadcrumb
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
featureKey={EProjectFeatureKey.WORK_ITEMS}
isLast
/>
</Breadcrumbs>
{issuesCount && issuesCount > 0 ? (
<Tooltip
isMobile={isMobile}
tooltipContent={t("issues_header.count_tooltip", { count: issuesCount })}
position="bottom"
>
<CountChip count={issuesCount} />
</Tooltip>
) : null}
</div>
{breadcrumbsContent}
{currentProjectDetails?.anchor ? (
<a
href={publishedURL}

View File

@ -128,11 +128,13 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
const stateOptions = canEditTargetIssue
? projectStateIds.map((stateId) => getStateById(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-[rgb(var(--nodedc-on-card-passive-surface-rgb))]";
const subtleTextClasses = isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]/70" : "text-[#B3B3B8]";
const pillBackgroundClasses = isActive
? "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-[rgb(var(--nodedc-on-card-passive-rgb))]";
const cornerActionButtonClasses = 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",
isActive
@ -141,7 +143,9 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
);
const assigneeButtonClasses = cn(
"flex h-7 min-w-7 items-center justify-center rounded-full border-0 bg-transparent p-0 shadow-none outline-none transition-colors",
isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-white"
isActive
? "text-[rgb(var(--nodedc-on-card-active-rgb))]"
: "text-[rgb(var(--nodedc-on-card-passive-surface-rgb))]"
);
const dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none");
const checkerBlocksTotal = issue.checker_blocks_count ?? 0;

View File

@ -10,13 +10,14 @@ import { useParams } from "next/navigation";
import { RefreshCcw } from "lucide-react";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Breadcrumbs, Header } from "@plane/ui";
import { Header } from "@plane/ui";
import { AppHeaderPrimaryActionButton } from "@/components/core/app-header/primary-action-button";
import { FiltersToggle } from "@/components/rich-filters/filters-toggle";
import { useProject } from "@/hooks/store/use-project";
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
import { useUserPermissions } from "@/hooks/store/user";
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
import { ExpandedToolbarBreadcrumbs } from "@/plane-web/components/breadcrumbs/expanded-toolbar-breadcrumbs";
import { ProjectFeatureBreadcrumb } from "@/plane-web/components/breadcrumbs/project-feature";
import { useExternalContoursFilter } from "./filters/provider";
import { ExternalContourCreateModalRoot } from "./create-modal";
@ -39,7 +40,7 @@ export const ProjectExternalContoursHeader = observer(function ProjectExternalCo
<Header>
<Header.LeftItem className="nodedc-bottom-dock-left">
<div className="flex min-w-0 flex-grow items-center gap-4 overflow-hidden">
<Breadcrumbs isLoading={currentProjectDetailsLoader === "init-loader"}>
<ExpandedToolbarBreadcrumbs isLoading={currentProjectDetailsLoader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<ProjectFeatureBreadcrumb
workspaceSlug={workspaceSlug?.toString()}
@ -47,7 +48,7 @@ export const ProjectExternalContoursHeader = observer(function ProjectExternalCo
featureKey="external_contours"
isLast
/>
</Breadcrumbs>
</ExpandedToolbarBreadcrumbs>
{(loader === "mutation-loading" || loader === "issue-loading") && (
<div className="flex items-center gap-1.5 text-tertiary">

View File

@ -11,7 +11,7 @@ import { RefreshCcw } from "lucide-react";
// ui
import { EProjectFeatureKey, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Breadcrumbs, Header } from "@plane/ui";
import { Header } from "@plane/ui";
// components
import { AppHeaderPrimaryActionButton } from "@/components/core/app-header/primary-action-button";
import { FiltersRoot } from "@/components/inbox/inbox-filter";
@ -22,6 +22,7 @@ import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { useUserPermissions } from "@/hooks/store/user";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
import { ExpandedToolbarBreadcrumbs } from "@/plane-web/components/breadcrumbs/expanded-toolbar-breadcrumbs";
import { ProjectFeatureBreadcrumb } from "@/plane-web/components/breadcrumbs/project-feature";
export const ProjectInboxHeader = observer(function ProjectInboxHeader() {
@ -46,7 +47,7 @@ export const ProjectInboxHeader = observer(function ProjectInboxHeader() {
<Header>
<Header.LeftItem className="nodedc-bottom-dock-left">
<div className="flex min-w-0 flex-grow items-center gap-4 overflow-hidden">
<Breadcrumbs isLoading={currentProjectDetailsLoader === "init-loader"}>
<ExpandedToolbarBreadcrumbs isLoading={currentProjectDetailsLoader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<ProjectFeatureBreadcrumb
workspaceSlug={workspaceSlug?.toString()}
@ -54,7 +55,7 @@ export const ProjectInboxHeader = observer(function ProjectInboxHeader() {
featureKey={EProjectFeatureKey.INTAKE}
isLast
/>
</Breadcrumbs>
</ExpandedToolbarBreadcrumbs>
{loader === "pagination-loading" && (
<div className="flex items-center gap-1.5 text-tertiary">

View File

@ -106,7 +106,6 @@ const CreatedVsResolved = observer(function CreatedVsResolved() {
areas={areas}
xAxis={{
key: "name",
label: t("date"),
}}
yAxis={{
key: "count",

View File

@ -210,15 +210,15 @@ const PriorityChart = observer(function PriorityChart(props: Props) {
bars={bars}
barSize={chart_model === EChartModels.STACKED ? 72 : 86}
margin={{
top: 12,
top: isPeekView ? 32 : 12,
right: 16,
bottom: 34,
bottom: isPeekView ? 28 : 34,
left: 8,
}}
xAxis={{
key: "name",
label: xAxisLabel,
dy: 30,
dy: isPeekView ? 24 : 30,
}}
yAxis={{
key: "count",

View File

@ -7,10 +7,12 @@
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
// plane imports
import { Row } from "@plane/ui";
// components
import { cn } from "@plane/utils";
import { useUserProfile } from "@/hooks/store/user";
import { ExtendedAppHeader } from "@/plane-web/components/common/extended-app-header";
export interface AppHeaderProps {
@ -24,6 +26,18 @@ export const AppHeader = observer(function AppHeader(props: AppHeaderProps) {
const { header, mobileHeader, className, rowClassName } = props;
const containerRef = useRef<HTMLDivElement>(null);
const [dockStyle, setDockStyle] = useState<CSSProperties | undefined>(undefined);
const pathname = usePathname();
const { workspaceSlug } = useParams();
const { data: userProfile } = useUserProfile();
const isCompactToolbar = userProfile?.theme?.nodedcCompactToolbar === true;
const workspaceSlugValue = workspaceSlug?.toString();
const isWorkspaceHome =
!!workspaceSlugValue && (pathname === `/${workspaceSlugValue}` || pathname === `/${workspaceSlugValue}/`);
const effectiveDockStyle = isCompactToolbar
? dockStyle
: {
left: typeof dockStyle?.left === "number" ? dockStyle.left : 0,
};
useEffect(() => {
if (typeof window === "undefined") return;
@ -41,10 +55,7 @@ export const AppHeader = observer(function AppHeader(props: AppHeaderProps) {
width,
});
document.documentElement.style.setProperty(
"--nodedc-bottom-dock-offset",
`${Math.max(height, 0)}px`
);
document.documentElement.style.setProperty("--nodedc-bottom-dock-offset", `${Math.max(height, 0)}px`);
};
updateDockBounds();
@ -61,7 +72,16 @@ export const AppHeader = observer(function AppHeader(props: AppHeaderProps) {
}, []);
return (
<div ref={containerRef} className={cn("fixed right-0 bottom-0 z-[18]", className)} style={dockStyle}>
<div
ref={containerRef}
className={cn(
"fixed bottom-0 z-[18]",
isCompactToolbar ? "nodedc-app-header-compact right-0" : "nodedc-app-header-expanded",
className
)}
data-nodedc-footer-scrim={isWorkspaceHome ? "false" : "true"}
style={effectiveDockStyle}
>
<Row
className={cn(
"nodedc-bottom-dock flex h-[var(--nodedc-bottom-dock-height)] w-full items-center gap-2",
@ -71,7 +91,7 @@ export const AppHeader = observer(function AppHeader(props: AppHeaderProps) {
<ExtendedAppHeader header={header} />
<div className="nodedc-bottom-dock-voice-slot" data-nodedc-voice-task-dock-slot />
</Row>
{mobileHeader && mobileHeader}
{mobileHeader ?? null}
</div>
);
});

View File

@ -4,22 +4,65 @@
* See the LICENSE file for details.
*/
import { useEffect, useState } from "react";
import type { ComponentProps } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { PlusIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { cn } from "@plane/utils";
import { useUserProfile } from "@/hooks/store/user";
type TPrimaryActionButtonProps = ComponentProps<typeof Button>;
export const AppHeaderPrimaryActionButton = (props: TPrimaryActionButtonProps) => {
const { children, className, ...buttonProps } = props;
const { children, className, disabled, onClick, ...buttonProps } = props;
const { t } = useTranslation();
const { data: userProfile } = useUserProfile();
const [expandedToolbarTarget, setExpandedToolbarTarget] = useState<HTMLElement | null>(null);
const isCompactToolbar = userProfile?.theme?.nodedcCompactToolbar === true;
useEffect(() => {
if (isCompactToolbar || typeof document === "undefined") {
setExpandedToolbarTarget(null);
return;
}
const animationFrame = window.requestAnimationFrame(() => {
setExpandedToolbarTarget(document.querySelector<HTMLElement>("[data-nodedc-expanded-primary-action-slot]"));
});
return () => window.cancelAnimationFrame(animationFrame);
}, [isCompactToolbar]);
if (!isCompactToolbar) {
if (!expandedToolbarTarget) return null;
return createPortal(
<Tooltip tooltipContent={typeof children === "string" ? children : t("app_header.add_task")} position="bottom">
<button
type="button"
className="nodedc-expanded-tool-button"
aria-label={typeof children === "string" ? children : t("app_header.add_task")}
disabled={disabled}
onClick={onClick}
data-ph-element={(buttonProps as { "data-ph-element"?: string })["data-ph-element"]}
>
<PlusIcon className="size-4" />
</button>
</Tooltip>,
expandedToolbarTarget
);
}
return (
<Button
variant="primary"
size="lg"
className={cn("nodedc-toolbar-primary nodedc-toolbar-primary-wide", className)}
disabled={disabled}
onClick={onClick}
{...buttonProps}
>
{children ?? t("app_header.add_task")}

View File

@ -24,7 +24,7 @@ import { WorkspaceService } from "@/services/workspace.service";
import { HomeCardShell } from "./home-card-shell";
import { HomeGanttPreview } from "./home-gantt-preview";
import { HomeRecentIssueDecks } from "./home-recent-issue-decks";
import { HomeActivityTrendCard, HomeOperationsCard, HomeRhythmRecentOverview } from "./home-project-insights";
import { HomeAnalyticsBottomRow, HomeAnalyticsRail, HomeIndividualAnalyticsPanel } from "./home-project-insights";
import { HomeProjectStack } from "./home-project-stack";
import { aggregateProjectAnalytics, type THomeProjectData } from "./home.utils";
import { StickiesWidget } from "../stickies/widget";
@ -184,7 +184,7 @@ export const DashboardWidgets = observer(function DashboardWidgets(props: Dashbo
/>
) : null;
const sideWidgetCards = [
const bottomWidgetCards = [
isQuickLinksEnabled ? (
<HomeCardShell key="quick_links" className="overflow-hidden" contentClassName="p-5">
<DashboardQuickLinks workspaceSlug={workspaceSlugValue} />
@ -213,7 +213,7 @@ export const DashboardWidgets = observer(function DashboardWidgets(props: Dashbo
workspaceName={currentWorkspace?.name}
/>
<div className="nodedc-home-dashboard-grid grid xl:grid-cols-[minmax(320px,360px)_minmax(0,1fr)] xl:items-stretch">
<div className="nodedc-home-dashboard-grid grid xl:grid-cols-[minmax(320px,360px)_minmax(0,1fr)_minmax(320px,360px)] xl:items-stretch">
<div className="flex min-w-0">
<HomeProjectStack
className="h-full"
@ -231,33 +231,18 @@ export const DashboardWidgets = observer(function DashboardWidgets(props: Dashbo
analytics={selectedProjectAnalytics}
workspaceSlug={workspaceSlugValue}
/>
<HomeRhythmRecentOverview
project={selectedProject}
analytics={selectedProjectAnalytics}
analyticsCollection={analyticsCollection}
recents={workspaceRecents}
recentActivitySlot={recentActivityCard}
locale={currentLocale}
/>
<HomeIndividualAnalyticsPanel project={selectedProject} locale={currentLocale} />
</div>
<HomeAnalyticsRail
project={selectedProject}
analytics={selectedProjectAnalytics}
analyticsCollection={analyticsCollection}
recents={workspaceRecents}
locale={currentLocale}
/>
</div>
<div className="nodedc-home-lower-grid grid xl:grid-cols-[minmax(320px,360px)_minmax(0,1fr)]">
<HomeOperationsCard
project={selectedProject}
analytics={selectedProjectAnalytics}
analyticsCollection={analyticsCollection}
recents={workspaceRecents}
locale={currentLocale}
/>
<HomeActivityTrendCard
project={selectedProject}
analytics={selectedProjectAnalytics}
analyticsCollection={analyticsCollection}
recents={workspaceRecents}
locale={currentLocale}
/>
</div>
<HomeAnalyticsBottomRow recentActivitySlot={recentActivityCard} />
{isProjectLatestIssuesEnabled && (
<HomeRecentIssueDecks project={selectedProject} workspaceSlug={workspaceSlugValue} />
@ -268,11 +253,12 @@ export const DashboardWidgets = observer(function DashboardWidgets(props: Dashbo
{hasSecondaryWidgets && (
<div
className={cn("grid gap-5", {
"md:grid-cols-2": sideWidgetCards.length > 1,
className={cn("nodedc-home-bottom-widgets grid gap-5", {
"md:grid-cols-2": bottomWidgetCards.length === 2,
"xl:grid-cols-3": bottomWidgetCards.length >= 3,
})}
>
{sideWidgetCards}
{bottomWidgetCards}
</div>
)}
</div>

View File

@ -4,9 +4,14 @@
* See the LICENSE file for details.
*/
import { type ReactNode, useId, useMemo } from "react";
import { type ReactNode, useEffect, useId, useMemo } from "react";
import { observer } from "mobx-react";
import { Activity, CheckCircle2, Layers3, UsersRound } from "lucide-react";
import type { TActivityEntityData, TProjectAnalyticsCount } from "@plane/types";
import { type TActivityEntityData, type TProjectAnalyticsCount } from "@plane/types";
import CreatedVsResolved from "@/components/analytics/work-items/created-vs-resolved";
import CustomizedInsights from "@/components/analytics/work-items/customized-insights";
import WorkItemsInsightTable from "@/components/analytics/work-items/workitems-insight-table";
import { useAnalytics } from "@/hooks/store/use-analytics";
import {
aggregateProjectAnalytics,
getActivityProjectId,
@ -294,14 +299,8 @@ export function HomeActivityTrendCard(props: HomeProjectInsightsProps) {
}
export function HomeRhythmCard(props: HomeProjectInsightsProps) {
const {
completedIssues,
metricCards,
openIssues,
project,
recentTouchpoints,
totalIssues,
} = useHomeProjectInsightData(props);
const { completedIssues, metricCards, openIssues, project, recentTouchpoints, totalIssues } =
useHomeProjectInsightData(props);
return (
<section className="nodedc-home-subpanel nodedc-home-rhythm-card space-y-4 p-5">
@ -368,9 +367,7 @@ export function HomeRhythmCard(props: HomeProjectInsightsProps) {
}
export function HomeOperationsCard(props: HomeProjectInsightsProps) {
const {
progressRows,
} = useHomeProjectInsightData(props);
const { progressRows } = useHomeProjectInsightData(props);
return (
<section className="nodedc-home-subpanel nodedc-home-operations-card space-y-4 p-5">
@ -437,6 +434,196 @@ export function HomeRhythmRecentOverview(props: HomeProjectInsightsProps) {
);
}
function HomeActivityMiniCard(props: HomeProjectInsightsProps) {
const { activitySeries, chart, chartId, project, recentTouchpoints } = useHomeProjectInsightData(props);
return (
<section className="nodedc-home-subpanel nodedc-home-activity-mini p-5">
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-semibold tracking-[0.2em] text-placeholder uppercase">
{project?.identifier ?? "Workspace"}
</div>
<div className="text-15 mt-2 font-semibold text-primary">Активность</div>
<div className="mt-1 text-12 text-secondary">Касания за последние 7 дней.</div>
</div>
<div className="nodedc-home-focus-chip nodedc-home-corner-badge">{recentTouchpoints}</div>
</div>
<div className="nodedc-home-activity-mini-chart">
<svg viewBox={`0 0 ${chart.width} ${chart.height}`} className="h-full w-full" preserveAspectRatio="none">
<defs>
<linearGradient id={`${chartId}-mini-fill`} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="rgba(var(--nodedc-accent-rgb),0.3)" />
<stop offset="100%" stopColor="rgba(var(--nodedc-accent-rgb),0.02)" />
</linearGradient>
</defs>
{[0.25, 0.5, 0.75].map((position) => {
const y = chart.height - chart.paddingY - position * (chart.height - chart.paddingY * 2);
return <line key={position} x1={12} x2={chart.width - 12} y1={y} y2={y} stroke="rgba(255,255,255,0.07)" />;
})}
<path d={chart.areaPath} fill={`url(#${chartId}-mini-fill)`} />
<path
d={chart.linePath}
fill="none"
stroke="rgb(var(--nodedc-accent-rgb))"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="5"
/>
{activitySeries.map((activityPoint, index) => {
const point = chart.points[index];
if (!point) return null;
return (
<circle
key={activityPoint.key}
cx={point.x}
cy={point.y}
fill="rgb(var(--nodedc-accent-rgb))"
r={activityPoint.value > 0 ? 4 : 2.2}
/>
);
})}
</svg>
</div>
</section>
);
}
export const HomeAnalyticsRail = observer(function HomeAnalyticsRail(props: HomeProjectInsightsProps) {
const { project } = props;
const { completionRate, completedIssues, openIssues, recentTouchpoints, totalIssues } =
useHomeProjectInsightData(props);
const { updateIsEpic, updateIsPeekView, updateSelectedCycle, updateSelectedModule, updateSelectedProjects } =
useAnalytics();
useEffect(() => {
updateIsPeekView(true);
updateIsEpic(false);
updateSelectedCycle("");
updateSelectedModule("");
updateSelectedProjects(project?.id ? [project.id] : []);
return () => {
updateSelectedProjects([]);
updateSelectedCycle("");
updateSelectedModule("");
updateIsPeekView(false);
updateIsEpic(false);
};
}, [project?.id, updateIsEpic, updateIsPeekView, updateSelectedCycle, updateSelectedModule, updateSelectedProjects]);
return (
<aside className="nodedc-home-analytics-rail" aria-label="Аналитика проекта">
<section className="nodedc-home-subpanel nodedc-home-analytics-intro p-5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-semibold tracking-[0.22em] text-placeholder uppercase">
{project?.identifier ?? "Workspace"}
</div>
<div className="mt-2 text-16 font-semibold text-primary">Аналитика проекта</div>
</div>
<div className="nodedc-home-focus-chip nodedc-home-corner-badge">{completionRate}%</div>
</div>
<div className="nodedc-home-analytics-stat-grid mt-4">
<div className="nodedc-home-analytics-stat">
<span>Всего</span>
<strong>{totalIssues}</strong>
</div>
<div className="nodedc-home-analytics-stat">
<span>Открыто</span>
<strong>{openIssues}</strong>
</div>
<div className="nodedc-home-analytics-stat">
<span>Закрыто</span>
<strong>{completedIssues}</strong>
</div>
<div className="nodedc-home-analytics-stat">
<span>Касания</span>
<strong>{recentTouchpoints}</strong>
</div>
</div>
</section>
<HomeActivityMiniCard {...props} />
<HomeOperationsCard {...props} />
<CreatedVsResolved />
</aside>
);
});
export const HomeIndividualAnalyticsPanel = observer(function HomeIndividualAnalyticsPanel(
props: Pick<HomeProjectInsightsProps, "project" | "locale">
) {
const { updateIsEpic, updateIsPeekView, updateSelectedCycle, updateSelectedModule, updateSelectedProjects } =
useAnalytics();
useEffect(() => {
updateIsPeekView(true);
updateIsEpic(false);
updateSelectedCycle("");
updateSelectedModule("");
updateSelectedProjects(props.project?.id ? [props.project.id] : []);
return () => {
updateSelectedProjects([]);
updateSelectedCycle("");
updateSelectedModule("");
updateIsPeekView(false);
updateIsEpic(false);
};
}, [
props.project?.id,
updateIsEpic,
updateIsPeekView,
updateSelectedCycle,
updateSelectedModule,
updateSelectedProjects,
]);
return (
<section className="nodedc-home-individual-analytics" aria-label="Индивидуальные аналитические данные">
<CustomizedInsights peekView />
</section>
);
});
export const HomeAnalyticsBottomRow = observer(function HomeAnalyticsBottomRow({
recentActivitySlot,
}: {
recentActivitySlot?: ReactNode;
}) {
return (
<section className="nodedc-home-analytics-bottom-row" aria-label="Назначения и последние действия">
<div className="nodedc-home-assignee-analytics">
<WorkItemsInsightTable />
</div>
<div className="nodedc-home-subpanel nodedc-home-analytics-recents p-5">
{recentActivitySlot ? (
<div className="h-full min-h-[22rem]">{recentActivitySlot}</div>
) : (
<div className="flex h-full min-h-[22rem] flex-col justify-between">
<div>
<div className="flex items-center gap-3">
<div className="grid size-11 place-items-center rounded-full bg-black text-[rgb(var(--nodedc-card-active-rgb))]">
<Layers3 className="size-5" />
</div>
<div>
<div className="text-15 font-semibold text-primary">Последние действия</div>
<div className="text-12 text-secondary">Виджет recent activity отключен в настройках.</div>
</div>
</div>
</div>
</div>
)}
</div>
</section>
);
});
export function HomeOperationsOverview(props: HomeProjectInsightsProps) {
return (
<div className="grid gap-4">

View File

@ -43,11 +43,16 @@ export function HomeProjectStack(props: HomeProjectStackProps) {
} = props;
const activeProject = projects.find((project: THomeProjectData) => project.id === selectedProjectId);
const orderedProjects = activeProject
const selectedProject = activeProject ?? projects[0];
const selectedProjectIndex = Math.max(
projects.findIndex((project: THomeProjectData) => project.id === selectedProject?.id),
0
);
const stackProjects = activeProject
? [activeProject, ...projects.filter((project: THomeProjectData) => project.id !== activeProject.id)]
: projects;
const visibleProjects = orderedProjects.slice(0, STACK_VISIBLE_LIMIT);
const visibleProjects = stackProjects.slice(0, STACK_VISIBLE_LIMIT);
const activityCountByProject = (recents ?? []).reduce<Record<string, number>>((acc, activity) => {
const projectId = getActivityProjectId(activity);
if (!projectId) return acc;
@ -56,8 +61,6 @@ export function HomeProjectStack(props: HomeProjectStackProps) {
return acc;
}, {});
const selectedProject =
orderedProjects.find((project: THomeProjectData) => project.id === selectedProjectId) ?? orderedProjects[0];
const stackHeight =
visibleProjects.length > 0 ? ACTIVE_CARD_HEIGHT + (visibleProjects.length - 1) * STACK_OFFSET : 228;
const isHorizontal = orientation === "horizontal";
@ -191,19 +194,7 @@ export function HomeProjectStack(props: HomeProjectStackProps) {
</button>
</div>
{isHorizontal ? (
<div className="nodedc-home-project-deck-scroller">
<div className="nodedc-home-project-deck-row flex items-start px-1 py-2">
{visibleProjects.map((project: THomeProjectData, index: number) => renderProjectCard(project, index, true))}
</div>
</div>
) : (
<div className="relative" style={{ height: `${stackHeight}px` }}>
{visibleProjects.map((project: THomeProjectData, index: number) => renderProjectCard(project, index, false))}
</div>
)}
<div className="nodedc-home-project-quick-section mt-4 rounded-[24px] bg-black/10 p-4 xl:mt-auto">
<div className="nodedc-home-project-quick-section mb-4 rounded-[24px] bg-black/10 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-13 font-semibold text-primary">Быстрый выбор</div>
@ -216,9 +207,11 @@ export function HomeProjectStack(props: HomeProjectStackProps) {
</div>
<div className="nodedc-home-project-quick-list">
{orderedProjects.map((project: THomeProjectData) => {
{projects.map((project: THomeProjectData, index: number) => {
const analytics = analyticsMap[project.id];
const isActive = project.id === selectedProject?.id;
const completionRate = getCompletionRate(analytics);
const distanceFromActive = Math.min(Math.abs(index - selectedProjectIndex), 2);
return (
<button
@ -226,6 +219,7 @@ export function HomeProjectStack(props: HomeProjectStackProps) {
type="button"
className="nodedc-home-project-quick-button"
data-active={isActive}
data-distance={distanceFromActive}
aria-label={
isActive ? `Открыть рабочую область проекта ${project.name}` : `Выбрать проект ${project.name}`
}
@ -240,45 +234,54 @@ export function HomeProjectStack(props: HomeProjectStackProps) {
>
<span className="nodedc-home-project-quick-main">
<span className="nodedc-home-project-quick-logo">
<Logo logo={project.logo_props} size={14} />
<FolderOpenDot className="size-4" />
</span>
<span className="truncate">{project.identifier}</span>
</span>
<span className="nodedc-home-project-quick-metric">
<span className="nodedc-home-project-quick-dot" aria-hidden="true" />
<span>{getCompletionRate(analytics)}%</span>
<span className="nodedc-home-project-quick-name truncate">{project.name}</span>
</span>
<span className="nodedc-home-project-quick-rate">{completionRate}%</span>
</button>
);
})}
</div>
</div>
{selectedProject && (
<div className="nodedc-home-project-focus-grid mt-4 grid grid-cols-2 gap-3 md:grid-cols-3">
<div className="nodedc-home-project-focus-item px-3 py-2">
<div className="text-[11px] tracking-[0.18em] text-placeholder uppercase">Фокус</div>
<div className="nodedc-home-project-focus-value mt-1 text-13 font-semibold text-primary">
{selectedProject.identifier}
</div>
</div>
<div className="nodedc-home-project-focus-item px-3 py-2">
<div className="flex items-center gap-1 text-[11px] tracking-[0.18em] text-placeholder uppercase">
<UsersRound className="size-3.5" />
<span>Команда</span>
</div>
<div className="nodedc-home-project-focus-value mt-1 text-13 font-semibold text-primary">
{analyticsMap[selectedProject.id]?.total_members ?? 0}
</div>
</div>
<div className="nodedc-home-project-focus-item px-3 py-2">
<div className="text-[11px] tracking-[0.18em] text-placeholder uppercase">Контур</div>
<div className="nodedc-home-project-focus-value mt-1 text-13 font-semibold text-primary">
{activityCountByProject[selectedProject.id] ?? 0} касаний
</div>
{isHorizontal ? (
<div className="nodedc-home-project-deck-scroller">
<div className="nodedc-home-project-deck-row flex items-start px-1 py-2">
{visibleProjects.map((project: THomeProjectData, index: number) => renderProjectCard(project, index, true))}
</div>
</div>
) : (
<div className="relative" style={{ height: `${stackHeight}px` }}>
{visibleProjects.map((project: THomeProjectData, index: number) => renderProjectCard(project, index, false))}
</div>
)}
{selectedProject && (
<div className="nodedc-home-project-focus-grid mt-4 grid grid-cols-2 gap-3 md:grid-cols-3">
<div className="nodedc-home-project-focus-item px-3 py-2">
<div className="text-[11px] tracking-[0.18em] text-placeholder uppercase">Фокус</div>
<div className="nodedc-home-project-focus-value mt-1 text-13 font-semibold text-primary">
{selectedProject.identifier}
</div>
</div>
)}
</div>
<div className="nodedc-home-project-focus-item px-3 py-2">
<div className="flex items-center gap-1 text-[11px] tracking-[0.18em] text-placeholder uppercase">
<UsersRound className="size-3.5" />
<span>Команда</span>
</div>
<div className="nodedc-home-project-focus-value mt-1 text-13 font-semibold text-primary">
{analyticsMap[selectedProject.id]?.total_members ?? 0}
</div>
</div>
<div className="nodedc-home-project-focus-item px-3 py-2">
<div className="text-[11px] tracking-[0.18em] text-placeholder uppercase">Контур</div>
<div className="nodedc-home-project-focus-value mt-1 text-13 font-semibold text-primary">
{activityCountByProject[selectedProject.id] ?? 0} касаний
</div>
</div>
</div>
)}
</section>
);
}

View File

@ -4,27 +4,26 @@
* See the LICENSE file for details.
*/
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { observer } from "mobx-react";
import { ChartNoAxesColumn, SlidersHorizontal } from "lucide-react";
import { SlidersHorizontal } from "lucide-react";
// plane imports
import { EIssueFilterType, ISSUE_STORE_TO_FILTERS_MAP } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import type { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types";
import { EIssueLayoutTypes, EIssuesStoreType } from "@plane/types";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { useUserProfile } from "@/hooks/store/user";
// plane web imports
import type { TProject } from "@/plane-web/types";
// local imports
import { WorkItemsModal } from "../analytics/work-items/modal";
import { WorkItemFiltersToggle } from "../work-item-filters/filters-toggle";
import {
DisplayFiltersSelection,
FiltersDropdown,
LayoutSelection,
MobileLayoutSelection,
} from "./issue-layouts/filters";
type Props = {
@ -47,20 +46,38 @@ export const HeaderFilters = observer(function HeaderFilters(props: Props) {
currentProjectDetails,
projectId,
workspaceSlug,
canUserCreateIssue,
storeType = EIssuesStoreType.PROJECT,
} = props;
// i18n
const { t } = useTranslation();
// states
const [analyticsModal, setAnalyticsModal] = useState(false);
const [expandedToolbarTarget, setExpandedToolbarTarget] = useState<HTMLElement | null>(null);
// store hooks
const { data: userProfile } = useUserProfile();
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(storeType);
// derived values
const activeLayout = issueFilters?.displayFilters?.layout;
const layoutDisplayFiltersOptions = ISSUE_STORE_TO_FILTERS_MAP[storeType]?.layoutOptions[activeLayout];
const isCompactToolbar = userProfile?.theme?.nodedcCompactToolbar === true;
useEffect(() => {
if (isCompactToolbar || typeof document === "undefined") {
setExpandedToolbarTarget(null);
return;
}
let animationFrame = 0;
const resolveTarget = () => {
setExpandedToolbarTarget(document.querySelector<HTMLElement>("[data-nodedc-expanded-header-filters-slot]"));
};
animationFrame = window.requestAnimationFrame(resolveTarget);
return () => window.cancelAnimationFrame(animationFrame);
}, [isCompactToolbar]);
const handleLayoutChange = useCallback(
(layout: EIssueLayoutTypes) => {
@ -86,64 +103,76 @@ export const HeaderFilters = observer(function HeaderFilters(props: Props) {
[workspaceSlug, projectId, updateFilters]
);
const dockLayoutSelection = (
<div className="nodedc-project-layout-controls pointer-events-auto flex">
<LayoutSelection layouts={LAYOUTS} onChange={(layout) => handleLayoutChange(layout)} selectedLayout={activeLayout} />
</div>
);
const headerTools = (
<>
<WorkItemFiltersToggle entityType={storeType} entityId={projectId} />
<FiltersDropdown
menuButton={<SlidersHorizontal className="size-4" />}
menuButtonWrapperClassName="nodedc-expanded-tool-button"
miniIcon={<SlidersHorizontal className="size-3.5" />}
title={t("common.display")}
placement="bottom-end"
>
<DisplayFiltersSelection
layoutDisplayFiltersOptions={layoutDisplayFiltersOptions}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
displayProperties={issueFilters?.displayProperties ?? {}}
handleDisplayPropertiesUpdate={handleDisplayProperties}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
isEpic={storeType === EIssuesStoreType.EPIC}
/>
</FiltersDropdown>
</>
);
const expandedToolbarControls =
!isCompactToolbar && expandedToolbarTarget
? createPortal(
<div className="nodedc-expanded-header-filters">
{dockLayoutSelection}
{headerTools}
</div>,
expandedToolbarTarget
)
: null;
return (
<>
<WorkItemsModal
isOpen={analyticsModal}
onClose={() => setAnalyticsModal(false)}
projectDetails={currentProjectDetails ?? undefined}
isEpic={storeType === EIssuesStoreType.EPIC}
/>
<div className="pointer-events-none absolute top-1/2 left-1/2 z-[1] flex -translate-x-1/2 -translate-y-1/2 items-center">
<div className="pointer-events-auto hidden @4xl:flex">
<LayoutSelection
layouts={LAYOUTS}
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
/>
</div>
<div className="pointer-events-auto flex @4xl:hidden">
<MobileLayoutSelection
layouts={LAYOUTS}
onChange={(layout) => handleLayoutChange(layout)}
activeLayout={activeLayout}
/>
</div>
</div>
<div className="nodedc-top-toolbar-cluster flex items-center gap-2">
<WorkItemFiltersToggle entityType={storeType} entityId={projectId} />
<FiltersDropdown
miniIcon={<SlidersHorizontal className="size-3.5" />}
title={t("common.display")}
placement="bottom-end"
>
<DisplayFiltersSelection
layoutDisplayFiltersOptions={layoutDisplayFiltersOptions}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
displayProperties={issueFilters?.displayProperties ?? {}}
handleDisplayPropertiesUpdate={handleDisplayProperties}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
isEpic={storeType === EIssuesStoreType.EPIC}
/>
</FiltersDropdown>
{canUserCreateIssue ? (
<Button
className="nodedc-toolbar-pill nodedc-toolbar-pill-wide hidden md:inline-flex"
onClick={() => setAnalyticsModal(true)}
variant="secondary"
size="lg"
>
<div className="hidden @4xl:flex">{t("common.analytics")}</div>
<div className="flex @4xl:hidden">
<ChartNoAxesColumn className="size-3.5" />
</div>
</Button>
) : (
<></>
)}
</div>
{expandedToolbarControls}
{!isCompactToolbar && expandedToolbarTarget ? null : (
<>
<div className="pointer-events-none absolute top-1/2 left-1/2 z-[1] flex -translate-x-1/2 -translate-y-1/2 items-center">
{dockLayoutSelection}
</div>
<div className="nodedc-top-toolbar-cluster flex items-center gap-2">
<WorkItemFiltersToggle entityType={storeType} entityId={projectId} />
<FiltersDropdown
miniIcon={<SlidersHorizontal className="size-3.5" />}
title={t("common.display")}
placement="bottom-end"
>
<DisplayFiltersSelection
layoutDisplayFiltersOptions={layoutDisplayFiltersOptions}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
displayProperties={issueFilters?.displayProperties ?? {}}
handleDisplayPropertiesUpdate={handleDisplayProperties}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
isEpic={storeType === EIssuesStoreType.EPIC}
/>
</FiltersDropdown>
</div>
</>
)}
</>
);
});

View File

@ -122,6 +122,8 @@ export function FiltersDropdown(props: Props) {
ref={setReferenceElement}
className={menuButtonWrapperClassName}
disabled={disabled}
data-active={isOpen}
aria-pressed={isOpen}
tabIndex={tabIndex}
onClick={toggleDropdown}
>
@ -162,6 +164,7 @@ export function FiltersDropdown(props: Props) {
variant="secondary"
tabIndex={-1}
className="nodedc-toolbar-pill nodedc-toolbar-pill-wide"
data-active={isOpen}
size="lg"
>
{miniIcon || title}

View File

@ -296,10 +296,10 @@ export const BaseKanBanRoot = observer(function BaseKanBanRoot(props: IBaseKanBa
</div>
<IssueLayoutHOC layout={EIssueLayoutTypes.KANBAN}>
<div
className={`horizontal-scrollbar relative flex scrollbar-lg h-full w-full bg-surface-2 ${sub_group_by ? "vertical-scrollbar overflow-y-auto" : "overflow-x-auto overflow-y-hidden"}`}
className={`nodedc-kanban-scroll-container horizontal-scrollbar relative flex scrollbar-lg h-full w-full bg-transparent ${sub_group_by ? "vertical-scrollbar overflow-y-auto" : "overflow-x-auto overflow-y-hidden"}`}
ref={scrollableContainerRef}
>
<div className="relative h-full w-max min-w-full bg-surface-2">
<div className="relative h-full w-max min-w-full bg-transparent">
<div className="h-full w-max">
<KanBanView
issuesMap={issueMap}

View File

@ -173,7 +173,7 @@ export const KanBan = observer(function KanBan(props: IKanBan) {
} `}
>
{sub_group_by === null && (
<div className="sticky top-0 z-[2] w-full flex-shrink-0 bg-surface-2 py-1">
<div className="sticky top-0 z-[2] w-full flex-shrink-0 bg-transparent py-1">
<HeaderGroupByCard
sub_group_by={sub_group_by}
group_by={group_by}

View File

@ -340,7 +340,7 @@ export const KanbanGroup = observer(function KanbanGroup(props: IKanbanGroup) {
</div>
{shouldShowQuickAdd && (
<div className="nodedc-bottom-dock-sticky-offset sticky z-[2] w-full bg-surface-2 py-0.5">
<div className="nodedc-bottom-dock-sticky-offset sticky z-[2] w-full bg-transparent py-0.5">
<QuickAddIssueRoot
layout={EIssueLayoutTypes.KANBAN}
QuickAddButton={KanbanQuickAddIssueButton}

View File

@ -323,7 +323,7 @@ export const KanBanSwimLanes = observer(function KanBanSwimLanes(props: IKanBanS
return (
<div className="relative">
<Row className="sticky top-0 z-[4] h-[50px] bg-surface-2">
<Row className="sticky top-0 z-[4] h-[50px] bg-transparent">
<SubGroupSwimlaneHeader
getGroupIssueCount={getGroupIssueCount}
group_by={group_by}

View File

@ -35,6 +35,12 @@ import { IssueStats } from "@/plane-web/components/issues/issue-layouts/issue-st
// types
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
import { calculateIdentifierWidth } from "../utils";
import {
applyNodedcListPropertiesWidth,
NODEDC_LIST_PROPERTIES_WIDTH_CSS_VAR,
NODEDC_LIST_PROPERTIES_WIDTH_DEFAULT,
persistNodedcListPropertiesWidth,
} from "./list-properties-width";
import type { TRenderQuickActions } from "./list-view-types";
interface IssueBlockProps {
@ -157,6 +163,44 @@ export const IssueBlock = observer(function IssueBlock(props: IssueBlockProps) {
}
};
const handlePropertiesResizePointerDown = (event: React.PointerEvent<HTMLSpanElement>) => {
if (event.button !== 0 || typeof window === "undefined") return;
event.preventDefault();
event.stopPropagation();
const root = document.documentElement;
const computedWidth = Number.parseFloat(
getComputedStyle(root).getPropertyValue(NODEDC_LIST_PROPERTIES_WIDTH_CSS_VAR)
);
const initialWidth = Number.isFinite(computedWidth) ? computedWidth : NODEDC_LIST_PROPERTIES_WIDTH_DEFAULT;
const initialClientX = event.clientX;
let latestWidth = initialWidth;
const previousCursor = document.body.style.cursor;
const previousUserSelect = document.body.style.userSelect;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
const handlePointerMove = (moveEvent: PointerEvent) => {
moveEvent.preventDefault();
latestWidth = applyNodedcListPropertiesWidth(initialWidth - (moveEvent.clientX - initialClientX));
};
const handlePointerUp = () => {
persistNodedcListPropertiesWidth(latestWidth);
document.body.style.cursor = previousCursor;
document.body.style.userSelect = previousUserSelect;
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
};
// Calculate width for: projectIdentifier + "-" + dynamic sequence number digits
// Use next_work_item_sequence from backend (static value from project endpoint)
const maxSequenceId = currentProjectNextSequenceId ?? 1;
@ -184,7 +228,7 @@ export const IssueBlock = observer(function IssueBlock(props: IssueBlockProps) {
<Row
ref={issueRef}
className={cn(
"group/list-block relative flex min-h-11 flex-col gap-3 bg-layer-transparent py-3 text-13 transition-colors hover:bg-layer-transparent-hover",
"nodedc-list-work-item-row group/list-block relative flex min-h-11 flex-col gap-3 bg-layer-transparent py-3 text-13 transition-colors hover:bg-layer-transparent-hover",
{
"border-accent-strong": getIsIssuePeeked(issue.id) && peekIssue?.nestingLevel === nestingLevel,
"border-strong-1": isIssueActive,
@ -207,8 +251,19 @@ export const IssueBlock = observer(function IssueBlock(props: IssueBlockProps) {
}
}}
>
<div className="flex w-full gap-2 truncate">
<div className="flex flex-grow items-center gap-0.5 truncate">
<span
className="nodedc-list-properties-resize-handle"
role="separator"
aria-orientation="vertical"
aria-label="Resize list properties panel"
onPointerDown={handlePropertiesResizePointerDown}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
/>
<div className="nodedc-list-work-item-main flex w-full gap-2 truncate">
<div className="flex min-w-0 flex-grow items-center gap-0.5 truncate">
<div className="flex items-center gap-1" style={isSubIssue ? { marginLeft } : {}}>
{/* select checkbox */}
{projectId && canSelectIssues && !isEpic && (
@ -282,7 +337,9 @@ export const IssueBlock = observer(function IssueBlock(props: IssueBlockProps) {
disabled={isCurrentBlockDragging}
renderByDefault={false}
>
<p className="cursor-pointer truncate text-body-xs-medium text-primary">{issue.name}</p>
<p className="nodedc-list-work-item-title cursor-pointer truncate text-body-xs-medium text-primary">
{issue.name}
</p>
</Tooltip>
{isEpic && displayProperties && (
<WithDisplayPropertiesHOC
@ -308,11 +365,11 @@ export const IssueBlock = observer(function IssueBlock(props: IssueBlockProps) {
</div>
)}
</div>
<div className="flex flex-shrink-0 items-center gap-2">
<div className="nodedc-list-work-item-side flex flex-shrink-0 items-center gap-2">
{!issue?.tempId ? (
<>
<IssueProperties
className={`relative flex flex-wrap ${isSidebarCollapsed ? "md:flex-shrink-0 md:flex-grow" : "lg:flex-shrink-0 lg:flex-grow"} items-center gap-2 whitespace-nowrap`}
className={`nodedc-list-work-item-properties relative flex flex-wrap ${isSidebarCollapsed ? "md:flex-shrink-0 md:flex-grow" : "lg:flex-shrink-0 lg:flex-grow"} items-center gap-2 whitespace-nowrap`}
issue={issue}
isReadOnly={!canEditIssueProperties}
updateIssue={updateIssue}

View File

@ -34,6 +34,7 @@ import { useBulkOperationStatus } from "@/plane-web/hooks/use-bulk-operation-sta
import type { GroupDropLocation } from "../utils";
import { getGroupByColumns, isWorkspaceLevel, isSubGrouped } from "../utils";
import { ListGroup } from "./list-group";
import { applyStoredNodedcListPropertiesWidth } from "./list-properties-width";
import type { TRenderQuickActions } from "./list-view-types";
export interface IList {
@ -107,6 +108,17 @@ export const List = observer(function List(props: IList) {
);
}, [containerRef]);
useEffect(() => {
if (typeof window === "undefined") return;
const syncPropertiesWidth = () => applyStoredNodedcListPropertiesWidth();
syncPropertiesWidth();
window.addEventListener("resize", syncPropertiesWidth);
return () => window.removeEventListener("resize", syncPropertiesWidth);
}, []);
if (!groups) return null;
const getGroupIndex = (groupId: string | undefined) => groups.findIndex(({ id }) => id === groupId);

View File

@ -252,6 +252,12 @@ export const ListGroup = observer(function ListGroup(props: Props) {
const isGroupByCreatedBy = group_by === "created_by";
const shouldExpand = (!!groupIssueCount && isExpanded) || !group_by;
const shouldShowQuickAdd =
enableIssueQuickAdd &&
!disableIssueCreation &&
!isGroupByCreatedBy &&
!isCompletedCycle &&
!isWorkflowIssueCreationDisabled;
return validateEmptyIssueGroups(groupIssueCount) ? (
<div
@ -295,51 +301,49 @@ export const ListGroup = observer(function ListGroup(props: Props) {
isDraggingOverColumn={isDraggingOverColumn}
isEpic={isEpic}
/>
{groupIssueIds && (
<IssueBlocksList
issueIds={groupIssueIds}
groupId={group.id}
issuesMap={issuesMap}
updateIssue={updateIssue}
quickActions={quickActions}
displayProperties={displayProperties}
canEditProperties={canEditProperties}
containerRef={containerRef}
isDragAllowed={isDragAllowed}
canDropOverIssue={!canOverlayBeVisible}
selectionHelpers={selectionHelpers}
isEpic={isEpic}
/>
)}
{shouldLoadMore &&
(group_by ? (
<>{loadMore}</>
) : (
<>
{Array.from({ length: 2 }).map((_, index) => (
<ListLoaderItemRow key={index} />
))}
<ListLoaderItemRow ref={setIntersectionElement} />
</>
))}
{enableIssueQuickAdd &&
!disableIssueCreation &&
!isGroupByCreatedBy &&
!isCompletedCycle &&
!isWorkflowIssueCreationDisabled && (
<div className="nodedc-bottom-dock-sticky-offset sticky z-[1] w-full flex-shrink-0">
<QuickAddIssueRoot
layout={EIssueLayoutTypes.LIST}
QuickAddButton={ListQuickAddIssueButton}
prePopulatedData={prePopulateQuickAddData(group_by, group.id)}
containerClassName="border-b border-t border-subtle bg-surface-1 "
quickAddCallback={quickAddCallback}
isEpic={isEpic}
/>
</div>
<div className={cn({ "nodedc-bottom-dock-aware-padding": shouldShowQuickAdd })}>
{groupIssueIds && (
<IssueBlocksList
issueIds={groupIssueIds}
groupId={group.id}
issuesMap={issuesMap}
updateIssue={updateIssue}
quickActions={quickActions}
displayProperties={displayProperties}
canEditProperties={canEditProperties}
containerRef={containerRef}
isDragAllowed={isDragAllowed}
canDropOverIssue={!canOverlayBeVisible}
selectionHelpers={selectionHelpers}
isEpic={isEpic}
/>
)}
{shouldLoadMore &&
(group_by ? (
<>{loadMore}</>
) : (
<>
{Array.from({ length: 2 }).map((_, index) => (
<ListLoaderItemRow key={index} />
))}
<ListLoaderItemRow ref={setIntersectionElement} />
</>
))}
</div>
{shouldShowQuickAdd && (
<div className="nodedc-list-quick-add-sticky nodedc-bottom-dock-sticky-offset sticky z-[2] w-full flex-shrink-0 bg-transparent py-0.5">
<QuickAddIssueRoot
layout={EIssueLayoutTypes.LIST}
QuickAddButton={ListQuickAddIssueButton}
prePopulatedData={prePopulateQuickAddData(group_by, group.id)}
containerClassName="border-b border-t border-subtle bg-surface-1 "
quickAddCallback={quickAddCallback}
isEpic={isEpic}
/>
</div>
)}
</div>
)}
</div>

View File

@ -0,0 +1,46 @@
export const NODEDC_LIST_PROPERTIES_WIDTH_STORAGE_KEY = "nodedc_project_list_properties_width";
export const NODEDC_LIST_PROPERTIES_WIDTH_CSS_VAR = "--nodedc-list-properties-panel-width";
export const NODEDC_LIST_PROPERTIES_WIDTH_DEFAULT = 1024;
export const NODEDC_LIST_PROPERTIES_WIDTH_MIN = 760;
export const NODEDC_LIST_PROPERTIES_WIDTH_MAX = 1280;
const getRuntimeMaxWidth = () => {
if (typeof window === "undefined") return NODEDC_LIST_PROPERTIES_WIDTH_MAX;
return Math.max(
NODEDC_LIST_PROPERTIES_WIDTH_MIN,
Math.min(NODEDC_LIST_PROPERTIES_WIDTH_MAX, window.innerWidth - 420)
);
};
export const clampNodedcListPropertiesWidth = (width: number) => {
const maxWidth = getRuntimeMaxWidth();
const normalizedWidth = Number.isFinite(width) ? width : NODEDC_LIST_PROPERTIES_WIDTH_DEFAULT;
return Math.min(Math.max(normalizedWidth, NODEDC_LIST_PROPERTIES_WIDTH_MIN), maxWidth);
};
export const applyNodedcListPropertiesWidth = (width: number) => {
if (typeof document === "undefined") return width;
const clampedWidth = clampNodedcListPropertiesWidth(width);
document.documentElement.style.setProperty(NODEDC_LIST_PROPERTIES_WIDTH_CSS_VAR, `${clampedWidth}px`);
return clampedWidth;
};
export const applyStoredNodedcListPropertiesWidth = () => {
if (typeof window === "undefined") return NODEDC_LIST_PROPERTIES_WIDTH_DEFAULT;
const storedWidth = Number(window.localStorage.getItem(NODEDC_LIST_PROPERTIES_WIDTH_STORAGE_KEY));
return applyNodedcListPropertiesWidth(
Number.isFinite(storedWidth) && storedWidth > 0 ? storedWidth : NODEDC_LIST_PROPERTIES_WIDTH_DEFAULT
);
};
export const persistNodedcListPropertiesWidth = (width: number) => {
if (typeof window === "undefined") return;
window.localStorage.setItem(NODEDC_LIST_PROPERTIES_WIDTH_STORAGE_KEY, String(clampNodedcListPropertiesWidth(width)));
};

View File

@ -59,7 +59,7 @@ export interface IIssueProperties {
}
export const IssueProperties = observer(function IssueProperties(props: IIssueProperties) {
const { issue, updateIssue, displayProperties, isReadOnly, className, isEpic = false } = props;
const { issue, updateIssue, displayProperties, isReadOnly, className, activeLayout, isEpic = false } = props;
// i18n
const { t } = useTranslation();
// store hooks
@ -175,6 +175,15 @@ export const IssueProperties = observer(function IssueProperties(props: IIssuePr
const redirectToIssueDetail = () => router.push(`${workItemLink}#sub-issues`);
if (!displayProperties || !issue.project_id) return null;
const isListLayout = activeLayout === "List";
const propertySlotClassName = (slot: string, ...classNames: (string | false | null | undefined)[]) =>
cn(
"h-5",
isListLayout && "nodedc-list-property-slot",
isListLayout && `nodedc-list-property-${slot}`,
...classNames
);
const listIconControlClassName = isListLayout ? "nodedc-list-icon-control" : undefined;
// date range is enabled only when both dates are available and both dates are enabled
const isDateRangeEnabled: boolean = Boolean(
@ -196,7 +205,11 @@ export const IssueProperties = observer(function IssueProperties(props: IIssuePr
{/* basic properties */}
{/* state */}
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="state">
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<div
className={propertySlotClassName("state")}
onFocus={handleEventPropagation}
onClick={handleEventPropagation}
>
<StateDropdown
buttonContainerClassName="truncate max-w-40"
value={issue.state_id}
@ -212,12 +225,17 @@ export const IssueProperties = observer(function IssueProperties(props: IIssuePr
{/* priority */}
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="priority">
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<div
className={propertySlotClassName("priority", isListLayout && "nodedc-list-property-icon-only")}
onFocus={handleEventPropagation}
onClick={handleEventPropagation}
>
<PriorityDropdown
value={issue?.priority}
onChange={handlePriority}
disabled={isReadOnly}
buttonVariant="border-without-text"
buttonContainerClassName={listIconControlClassName}
renderByDefault={isMobile}
showTooltip
/>
@ -230,7 +248,11 @@ export const IssueProperties = observer(function IssueProperties(props: IIssuePr
displayPropertyKey={["start_date", "due_date"]}
shouldRenderProperty={() => isDateRangeEnabled}
>
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<div
className={propertySlotClassName("date-range")}
onFocus={handleEventPropagation}
onClick={handleEventPropagation}
>
<DateRangeDropdown
value={{
from: getDate(issue.start_date) || undefined,
@ -255,6 +277,7 @@ export const IssueProperties = observer(function IssueProperties(props: IIssuePr
showTooltip
renderPlaceholder={false}
customTooltipHeading="Date Range"
buttonContainerClassName={isListLayout ? "nodedc-list-valued-date-control" : undefined}
/>
</div>
</WithDisplayPropertiesHOC>
@ -265,7 +288,14 @@ export const IssueProperties = observer(function IssueProperties(props: IIssuePr
displayPropertyKey="start_date"
shouldRenderProperty={() => !isDateRangeEnabled}
>
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<div
className={propertySlotClassName(
"start-date",
isListLayout && (issue.start_date ? "nodedc-list-property-valued-date" : "nodedc-list-property-icon-only")
)}
onFocus={handleEventPropagation}
onClick={handleEventPropagation}
>
<DateDropdown
value={issue.start_date ?? null}
rangePreview={{
@ -277,6 +307,7 @@ export const IssueProperties = observer(function IssueProperties(props: IIssuePr
placeholder={t("common.order_by.start_date")}
icon={<StartDatePropertyIcon className="h-3 w-3 flex-shrink-0" />}
buttonVariant={issue.start_date ? "border-with-text" : "border-without-text"}
buttonContainerClassName={issue.start_date ? undefined : listIconControlClassName}
optionsClassName="z-10"
disabled={isReadOnly}
renderByDefault={isMobile}
@ -292,7 +323,14 @@ export const IssueProperties = observer(function IssueProperties(props: IIssuePr
displayPropertyKey="due_date"
shouldRenderProperty={() => !isDateRangeEnabled}
>
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<div
className={propertySlotClassName(
"due-date",
isListLayout && (issue.target_date ? "nodedc-list-property-valued-date" : "nodedc-list-property-icon-only")
)}
onFocus={handleEventPropagation}
onClick={handleEventPropagation}
>
<DateDropdown
value={issue?.target_date ?? null}
rangePreview={{
@ -304,6 +342,7 @@ export const IssueProperties = observer(function IssueProperties(props: IIssuePr
placeholder={t("common.order_by.due_date")}
icon={<DueDatePropertyIcon className="h-3 w-3 shrink-0" />}
buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"}
buttonContainerClassName={issue.target_date ? undefined : listIconControlClassName}
buttonClassName={
shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group) ? "text-danger-primary" : ""
}
@ -319,7 +358,11 @@ export const IssueProperties = observer(function IssueProperties(props: IIssuePr
{/* assignee */}
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="assignee">
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<div
className={propertySlotClassName("assignee")}
onFocus={handleEventPropagation}
onClick={handleEventPropagation}
>
<MemberDropdown
projectId={issue?.project_id}
value={issue?.assignee_ids}
@ -343,7 +386,11 @@ export const IssueProperties = observer(function IssueProperties(props: IIssuePr
{/* modules */}
{projectDetails?.module_view && (
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="modules">
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<div
className={propertySlotClassName("module")}
onFocus={handleEventPropagation}
onClick={handleEventPropagation}
>
<ModuleDropdown
buttonContainerClassName="truncate max-w-40"
projectId={issue?.project_id}
@ -363,7 +410,11 @@ export const IssueProperties = observer(function IssueProperties(props: IIssuePr
{/* cycles */}
{projectDetails?.cycle_view && (
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="cycle">
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<div
className={propertySlotClassName("cycle")}
onFocus={handleEventPropagation}
onClick={handleEventPropagation}
>
<CycleDropdown
buttonContainerClassName="truncate max-w-40"
projectId={issue?.project_id}
@ -384,7 +435,11 @@ export const IssueProperties = observer(function IssueProperties(props: IIssuePr
{/* estimates */}
{projectId && areEstimateEnabledByProjectId(projectId?.toString()) && (
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="estimate">
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<div
className={propertySlotClassName("estimate")}
onFocus={handleEventPropagation}
onClick={handleEventPropagation}
>
<EstimateDropdown
value={issue.estimate_point ?? undefined}
onChange={handleEstimate}
@ -421,6 +476,7 @@ export const IssueProperties = observer(function IssueProperties(props: IIssuePr
}}
className={cn(
"flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded-sm border-[0.5px] border-strong px-2.5 py-1",
isListLayout && "nodedc-list-property-slot nodedc-list-property-sub-issues",
{
"cursor-pointer hover:bg-layer-1": subIssueCount,
}
@ -446,7 +502,10 @@ export const IssueProperties = observer(function IssueProperties(props: IIssuePr
renderByDefault={false}
>
<div
className="flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded-sm border-[0.5px] border-strong px-2.5 py-1"
className={cn(
"flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded-sm border-[0.5px] border-strong px-2.5 py-1",
isListLayout && "nodedc-list-property-slot nodedc-list-property-attachments"
)}
onFocus={handleEventPropagation}
onClick={handleEventPropagation}
>
@ -469,7 +528,10 @@ export const IssueProperties = observer(function IssueProperties(props: IIssuePr
renderByDefault={false}
>
<div
className="flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded-sm border-[0.5px] border-strong px-2.5 py-1"
className={cn(
"flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded-sm border-[0.5px] border-strong px-2.5 py-1",
isListLayout && "nodedc-list-property-slot nodedc-list-property-links"
)}
onFocus={handleEventPropagation}
onClick={handleEventPropagation}
>
@ -484,16 +546,22 @@ export const IssueProperties = observer(function IssueProperties(props: IIssuePr
{/* label */}
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="labels">
<IssuePropertyLabels
projectId={issue?.project_id || null}
value={issue?.label_ids || []}
defaultOptions={defaultLabelOptions}
onChange={handleLabel}
disabled={isReadOnly}
renderByDefault={isMobile}
hideDropdownArrow
maxRender={3}
/>
<div
className={propertySlotClassName("labels")}
onFocus={handleEventPropagation}
onClick={handleEventPropagation}
>
<IssuePropertyLabels
projectId={issue?.project_id || null}
value={issue?.label_ids || []}
defaultOptions={defaultLabelOptions}
onChange={handleLabel}
disabled={isReadOnly}
renderByDefault={isMobile}
hideDropdownArrow
maxRender={3}
/>
</div>
</WithDisplayPropertiesHOC>
</div>
);

View File

@ -26,10 +26,10 @@ type TNodedcWorkItemCardProps = {
export const getNodedcWorkItemCardAppearance = (isActive: boolean) => ({
surfaceClassName: isActive
? "bg-[rgb(var(--nodedc-card-active-rgb))] text-[rgb(var(--nodedc-on-card-active-rgb))]"
: "bg-[rgb(var(--nodedc-card-passive-rgb))] text-white",
: "bg-[rgb(var(--nodedc-card-passive-surface-rgb))] text-[rgb(var(--nodedc-on-card-passive-surface-rgb))]",
foregroundClasses: isActive
? "text-[rgb(var(--nodedc-on-card-active-rgb))]"
: "text-[rgb(var(--nodedc-on-card-passive-rgb))]",
: "text-[rgb(var(--nodedc-on-card-passive-surface-rgb))]",
subtleTextClasses: isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]/70" : "text-[#B3B3B8]",
pillBackgroundClasses: isActive
? "bg-black/10 text-[rgb(var(--nodedc-on-card-active-rgb))]"

View File

@ -24,12 +24,14 @@ import { useAppRouter } from "@/hooks/use-app-router";
import { useExpandableSearch } from "@/hooks/use-expandable-search";
type TTopNavPowerKProps = {
variant?: "top-navigation" | "sidebar";
variant?: "top-navigation" | "sidebar" | "expanded-toolbar";
};
export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
const { variant = "top-navigation" } = props;
const { t } = useTranslation();
const isWideSearch = variant === "top-navigation" || variant === "expanded-toolbar";
const isExpandedToolbar = variant === "expanded-toolbar";
// router
const router = useAppRouter();
const params = useParams();
@ -44,6 +46,7 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
left: number;
top: number;
width: number;
listMaxHeight: number;
} | null>(null);
const sidebarSearchPortalRef = useRef<HTMLDivElement>(null);
@ -131,13 +134,21 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
const rect = sidebarSearchButtonRef.current.getBoundingClientRect();
const width = 320;
const viewportPadding = 16;
const left = Math.min(rect.left, window.innerWidth - width - viewportPadding);
const top = rect.bottom + 10;
const topSafetyOffset = 88;
const inputHeight = 32;
const inputToListGap = 12;
const dockGap = 18;
const availableAboveDock = Math.max(260, rect.top - topSafetyOffset - inputHeight - inputToListGap - dockGap);
const listMaxHeight = Math.min(window.innerHeight * 0.7, availableAboveDock);
const panelHeight = inputHeight + inputToListGap + listMaxHeight;
const left = Math.max(viewportPadding, Math.min(rect.left, window.innerWidth - width - viewportPadding));
const top = Math.max(topSafetyOffset, rect.top - panelHeight - dockGap);
setSidebarSearchPosition({
left,
top,
width,
listMaxHeight,
});
}, [variant]);
@ -287,44 +298,68 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
return (
<div ref={containerRef} className="relative">
{variant === "top-navigation" ? (
<div
className={cn("relative z-30 flex w-[364px] items-center transition-all duration-300 ease-in-out", {
"w-[554px]": isOpen,
})}
>
<div
className={cn(
"flex h-7 w-full items-center rounded-lg border border-subtle-1 bg-layer-2 p-2 transition-colors duration-200",
{
"bg-layer-1": isOpen,
}
)}
onClick={() => inputRef.current?.focus()}
role="button"
>
<SearchIcon className="mr-2 size-3.5 shrink-0 text-placeholder" />
<input
ref={inputRef}
type="text"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
if (!isOpen) openPanel();
{isWideSearch ? (
isExpandedToolbar ? (
<div className="nodedc-expanded-search-control" data-open={isOpen}>
<button
type="button"
className="nodedc-expanded-tool-button nodedc-expanded-search-trigger"
data-active={isOpen}
aria-label="Поиск"
aria-pressed={isOpen}
onClick={() => {
if (isOpen) {
closePanel();
return;
}
openPanel();
requestAnimationFrame(() => inputRef.current?.focus());
}}
onMouseDown={handleMouseDown}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
placeholder={t("power_k.search_menu.quick_command_placeholder")}
className="placeholder-text-placeholder min-w-0 flex-1 bg-transparent text-13 text-primary outline-none"
/>
{searchTerm && (
<button type="button" onClick={handleClear} className="ml-2 shrink-0">
<CloseIcon className="size-3.5 text-placeholder hover:text-primary" />
</button>
)}
>
<SearchIcon className="size-4 shrink-0" />
</button>
</div>
</div>
) : (
<div
className={cn("relative z-30 flex w-[364px] items-center transition-all duration-300 ease-in-out", {
"w-[554px]": isOpen,
})}
>
<div
className={cn(
"flex h-7 w-full items-center rounded-lg border border-subtle-1 bg-layer-2 p-2 transition-colors duration-200",
{
"bg-layer-1": isOpen,
}
)}
onClick={() => inputRef.current?.focus()}
role="button"
>
<span className="mr-2">
<SearchIcon className="size-3.5 shrink-0 text-placeholder" />
</span>
<input
ref={inputRef}
type="text"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
if (!isOpen) openPanel();
}}
onMouseDown={handleMouseDown}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
placeholder={t("power_k.search_menu.quick_command_placeholder")}
className="placeholder-text-placeholder min-w-0 flex-1 bg-transparent text-13 text-primary outline-none"
/>
{searchTerm && (
<button type="button" onClick={handleClear} className="ml-2 shrink-0">
<CloseIcon className="size-3.5 text-placeholder hover:text-primary" />
</button>
)}
</div>
</div>
)
) : (
<div className="relative z-30 h-8 w-8">
<button
@ -347,15 +382,55 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
</button>
</div>
)}
{variant === "top-navigation" && (
{isWideSearch && isExpandedToolbar && (
<div
className={cn(
"nodedc-expanded-search-results nodedc-glass-modal nodedc-glass-popup-surface absolute z-20 flex flex-col overflow-hidden px-0 pt-3 transition-all duration-300 ease-in-out",
{
"max-h-[80vh] opacity-100": isOpen,
"h-0 w-0 opacity-0": !isOpen,
}
)}
>
{isOpen && (
<>
<div className="nodedc-expanded-search-floating-input mx-3 mb-3 flex h-11 items-center rounded-full px-4">
<SearchIcon className="mr-2 size-3.5 shrink-0 text-placeholder" />
<input
ref={inputRef}
type="text"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
if (!isOpen) openPanel();
}}
onMouseDown={handleMouseDown}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
placeholder={t("power_k.search_menu.quick_command_placeholder")}
className="placeholder-text-placeholder min-w-0 flex-1 bg-transparent text-13 text-primary outline-none"
autoFocus
/>
{searchTerm && (
<button type="button" onClick={handleClear} className="ml-2 shrink-0 text-placeholder hover:text-primary">
<CloseIcon className="size-3.5" />
</button>
)}
</div>
{searchCommandContent}
</>
)}
</div>
)}
{isWideSearch && !isExpandedToolbar && (
<div
className={cn(
"absolute z-20 flex flex-col overflow-hidden px-0 transition-all duration-300 ease-in-out",
{
"max-h-[80vh] w-[574px] opacity-100": isOpen,
"max-h-[80vh] opacity-100": isOpen,
"w-[574px]": isOpen,
"h-0 w-0 opacity-0": !isOpen,
"-top-[6px] left-1/2 -translate-x-1/2 rounded-md border border-subtle bg-surface-1 shadow-lg pt-10":
true,
"-top-[6px] left-1/2 -translate-x-1/2 rounded-md border border-subtle bg-surface-1 shadow-lg pt-10": true,
}
)}
>
@ -399,7 +474,10 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
</button>
)}
</div>
<div className="nodedc-glass-modal nodedc-glass-popup-surface absolute top-full left-0 mt-3 flex max-h-[70vh] w-full flex-col overflow-hidden rounded-[1.5rem] pt-3">
<div
className="nodedc-glass-modal nodedc-glass-popup-surface absolute top-full left-0 mt-3 flex w-full flex-col overflow-hidden rounded-[1.5rem] pt-3"
style={{ maxHeight: `${sidebarSearchPosition.listMaxHeight}px` }}
>
<div className="px-4 pb-2">
<div className="text-[13px] font-medium text-secondary">
{t("power_k.search_menu.quick_access_title")}

View File

@ -17,7 +17,7 @@ export const PresenceDot = (props: Props) => {
<span
aria-label="Пользователь онлайн"
className={cn(
"pointer-events-none absolute right-0 bottom-0 h-3 w-3 rounded-full border-2 border-[rgb(var(--nodedc-card-passive-rgb))] bg-[#B8FF4D] shadow-[0_0_0_1px_rgba(0,0,0,0.22)]",
"pointer-events-none absolute right-0 bottom-0 h-3 w-3 rounded-full border-2 border-[rgb(var(--nodedc-presence-dot-border-rgb))] bg-[#B8FF4D] shadow-[0_0_0_1px_rgba(0,0,0,0.22)]",
className
)}
/>

View File

@ -72,7 +72,7 @@ export const FiltersToggle = observer(function FiltersToggle<P extends TFilterPr
icon={showFilterRowChangesPill ? FilterAppliedIcon : FilterIcon}
onClick={handleToggleFilter}
className={buttonClassName}
data-active={showFilterRowChangesPill}
data-active={showFilterRowChangesPill || isFilterRowVisible}
iconClassName={cn("translate-y-[3px]", iconClassName)}
/>
);

View File

@ -4,25 +4,12 @@
* 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";
import { applyNodedcAccent } 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";
import { NODEDC_DEFAULT_ACCENT_HEX } from "../../../../../../helpers/nodedc-design";
// local imports
import { ProfileSettingsNodedcColorControl } from "./passive-card-color";
const ACCENT_PRESET_COLORS = [
"#EF4444",
@ -42,242 +29,19 @@ const ACCENT_PRESET_COLORS = [
"#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>
}
/>
);
});
export const ProfileSettingsAccentColor = () => (
<ProfileSettingsNodedcColorControl
themeKey="nodedcAccent"
defaultColor={NODEDC_DEFAULT_ACCENT_HEX}
title="Акцентный цвет"
description="Локальная настройка пользователя. Меняет цвет кнопок, активных элементов, шкал и выделений без перезапуска системы."
inputName="nodedcAccent"
paletteLabel="Открыть палитру акцентного цвета"
saveSuccessMessage="Акцентный цвет обновлен."
saveErrorMessage="Не удалось сохранить акцентный цвет."
resetSuccessMessage="Акцентный цвет возвращен к дизайн-конфигу."
resetErrorMessage="Не удалось сбросить акцентный цвет."
presetColors={ACCENT_PRESET_COLORS}
applyColor={applyNodedcAccent}
/>
);

View File

@ -9,6 +9,8 @@ import { observer } from "mobx-react";
import { ThemeSwitcher } from "@/plane-web/components/preferences/theme-switcher";
// local imports
import { ProfileSettingsAccentColor } from "./accent-color";
import { ProfileSettingsPassiveCardColor, ProfileSettingsPassiveCardSurfaceColor } from "./passive-card-color";
import { ProfileSettingsToolbarLayout } from "./toolbar-layout";
export const ProfileSettingsDefaultPreferencesList = observer(function ProfileSettingsDefaultPreferencesList() {
return (
@ -21,6 +23,9 @@ export const ProfileSettingsDefaultPreferencesList = observer(function ProfileSe
}}
/>
<ProfileSettingsAccentColor />
<ProfileSettingsPassiveCardSurfaceColor />
<ProfileSettingsPassiveCardColor />
<ProfileSettingsToolbarLayout />
</div>
);
});

View File

@ -0,0 +1,573 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { BookmarkPlus, Pipette, Trash2 } from "lucide-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 {
applyNodedcPassiveCardColor,
applyNodedcPassiveCardSurfaceColor,
getReadableNodedcTextRgb,
nodedcAccentHexToRgb,
cn,
normalizeNodedcAccentHex,
} from "@plane/utils";
// helpers
import {
NODEDC_DEFAULT_PASSIVE_CARD_HEX,
NODEDC_DEFAULT_PASSIVE_CARD_SURFACE_HEX,
} from "../../../../../../helpers/nodedc-design";
// components
import { SettingsControlItem } from "@/components/settings/control-item";
// hooks
import { useUserProfile } from "@/hooks/store/user";
const PASSIVE_CARD_PRESET_COLORS = [
"#050505",
"#101113",
"#1A1B1E",
"#242529",
"#2A2B2E",
"#34363B",
"#42454B",
"#5A5F67",
"#1F2937",
"#243447",
"#312E81",
"#3B1D4A",
"#3F1D2F",
"#3C2F1D",
"#4B5563",
"#7C7F85",
];
const COLOR_TEMPLATE_STORAGE_KEY = "nodedc:color-template-swatches:v1";
const COLOR_TEMPLATE_LIMIT = 24;
const COLOR_PICKER_PANEL_WIDTH = 336;
const COLOR_PICKER_PANEL_ESTIMATED_HEIGHT = 430;
const COLOR_PICKER_PANEL_GAP = 12;
const COLOR_PICKER_VIEWPORT_MARGIN = 16;
type TEyeDropperResult = {
sRGBHex: string;
};
type TEyeDropperConstructor = new () => {
open: () => Promise<TEyeDropperResult>;
};
declare global {
interface Window {
EyeDropper?: TEyeDropperConstructor;
}
}
const clampNumber = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
const normalizeTemplateColorList = (colors: unknown[]): string[] =>
Array.from(
new Set(
colors
.map((color) => (typeof color === "string" ? normalizeNodedcAccentHex(color) : undefined))
.filter((color): color is string => !!color)
)
).slice(0, COLOR_TEMPLATE_LIMIT);
const readTemplateColors = (): string[] => {
if (typeof window === "undefined") return [];
try {
const parsedValue = JSON.parse(window.localStorage.getItem(COLOR_TEMPLATE_STORAGE_KEY) || "[]");
return Array.isArray(parsedValue) ? normalizeTemplateColorList(parsedValue) : [];
} catch (_error) {
return [];
}
};
const writeTemplateColors = (colors: string[]) => {
if (typeof window === "undefined") return;
window.localStorage.setItem(COLOR_TEMPLATE_STORAGE_KEY, JSON.stringify(normalizeTemplateColorList(colors)));
};
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(" ")})`;
};
type TNodedcColorThemeKey = "nodedcAccent" | "nodedcPassiveCard" | "nodedcPassiveCardSurface";
type TProfileSettingsNodedcColorControlProps = {
themeKey: TNodedcColorThemeKey;
defaultColor: string;
title: string;
description: string;
inputName: string;
paletteLabel: string;
saveSuccessMessage: string;
saveErrorMessage: string;
resetSuccessMessage: string;
resetErrorMessage: string;
presetColors: string[];
applyColor: (hex: string | null | undefined) => boolean;
};
export const ProfileSettingsNodedcColorControl = observer(function ProfileSettingsNodedcColorControl(
props: TProfileSettingsNodedcColorControlProps
) {
const {
themeKey,
defaultColor,
title,
description,
inputName,
paletteLabel,
saveSuccessMessage,
saveErrorMessage,
resetSuccessMessage,
resetErrorMessage,
presetColors,
applyColor,
} = props;
const { data: userProfile, updateUserTheme } = useUserProfile();
const [draftColor, setDraftColor] = useState(defaultColor);
const [isBrowser, setIsBrowser] = useState(false);
const [isPaletteOpen, setIsPaletteOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [panelPosition, setPanelPosition] = useState({ left: 0, top: 0 });
const [templateColors, setTemplateColors] = useState<string[]>([]);
const paletteButtonRef = useRef<HTMLButtonElement | null>(null);
const palettePanelRef = useRef<HTMLDivElement | null>(null);
const savedColor = useMemo(
() => normalizeNodedcAccentHex(userProfile?.theme?.[themeKey]) || defaultColor,
[defaultColor, themeKey, userProfile?.theme]
);
const normalizedDraftColor = normalizeNodedcAccentHex(draftColor);
const isDraftValid = !!normalizedDraftColor;
const isDirty = normalizedDraftColor !== savedColor;
useEffect(() => {
setIsBrowser(true);
setTemplateColors(readTemplateColors());
}, []);
useEffect(() => {
setDraftColor(savedColor);
}, [savedColor]);
const updatePalettePosition = useCallback(() => {
if (typeof window === "undefined" || !paletteButtonRef.current) return;
const buttonRect = paletteButtonRef.current.getBoundingClientRect();
const panelHeight = palettePanelRef.current?.offsetHeight || COLOR_PICKER_PANEL_ESTIMATED_HEIGHT;
const maxLeft = window.innerWidth - COLOR_PICKER_PANEL_WIDTH - COLOR_PICKER_VIEWPORT_MARGIN;
const left = clampNumber(
buttonRect.left - COLOR_PICKER_PANEL_GAP,
COLOR_PICKER_VIEWPORT_MARGIN,
Math.max(COLOR_PICKER_VIEWPORT_MARGIN, maxLeft)
);
const topAbove = buttonRect.top - panelHeight - COLOR_PICKER_PANEL_GAP;
const topBelow = buttonRect.bottom + COLOR_PICKER_PANEL_GAP;
const maxTop = window.innerHeight - panelHeight - COLOR_PICKER_VIEWPORT_MARGIN;
const top =
topAbove >= COLOR_PICKER_VIEWPORT_MARGIN
? topAbove
: clampNumber(topBelow, COLOR_PICKER_VIEWPORT_MARGIN, Math.max(COLOR_PICKER_VIEWPORT_MARGIN, maxTop));
setPanelPosition({ left, top });
}, []);
useEffect(() => {
if (!isPaletteOpen) return;
updatePalettePosition();
const animationFrame = window.requestAnimationFrame(updatePalettePosition);
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (paletteButtonRef.current?.contains(target) || palettePanelRef.current?.contains(target)) return;
setIsPaletteOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setIsPaletteOpen(false);
};
window.addEventListener("resize", updatePalettePosition);
window.addEventListener("scroll", updatePalettePosition, true);
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
window.cancelAnimationFrame(animationFrame);
window.removeEventListener("resize", updatePalettePosition);
window.removeEventListener("scroll", updatePalettePosition, true);
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [isPaletteOpen, updatePalettePosition]);
const handleColorChange = (value: string) => {
const nextValue = value.startsWith("#") ? value : `#${value}`;
setDraftColor(nextValue);
const normalizedValue = normalizeNodedcAccentHex(nextValue);
if (normalizedValue) applyColor(normalizedValue);
};
const handleColorPickerChange = (color: ColorResult) => {
handleColorChange(color.hex);
};
const handlePickColorFromScreen = async () => {
if (typeof window === "undefined" || !window.EyeDropper) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Недоступно",
message: "Пипетка не поддерживается этим браузером.",
});
return;
}
try {
const result = await new window.EyeDropper().open();
handleColorChange(result.sRGBHex);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return;
setToast({
type: TOAST_TYPE.ERROR,
title: "Ошибка",
message: "Не удалось взять цвет пипеткой.",
});
}
};
const handleSaveTemplateColor = () => {
if (!normalizedDraftColor) return;
const nextTemplateColors = normalizeTemplateColorList([
normalizedDraftColor,
...templateColors.filter((color) => color !== normalizedDraftColor),
]);
setTemplateColors(nextTemplateColors);
writeTemplateColors(nextTemplateColors);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Сохранено",
message: "Цвет добавлен в шаблоны.",
});
};
const handleRemoveTemplateColor = (color: string) => {
const nextTemplateColors = templateColors.filter((templateColor) => templateColor !== color);
setTemplateColors(nextTemplateColors);
writeTemplateColors(nextTemplateColors);
};
const handleSave = async () => {
if (!normalizedDraftColor) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Ошибка",
message: "Введите корректный HEX-цвет.",
});
return;
}
try {
setIsSaving(true);
applyColor(normalizedDraftColor);
await updateUserTheme({ [themeKey]: normalizedDraftColor });
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Сохранено",
message: saveSuccessMessage,
});
} catch (_error) {
applyColor(savedColor);
setDraftColor(savedColor);
setToast({
type: TOAST_TYPE.ERROR,
title: "Ошибка",
message: saveErrorMessage,
});
} finally {
setIsSaving(false);
}
};
const handleReset = async () => {
try {
setIsSaving(true);
setDraftColor(defaultColor);
applyColor(defaultColor);
await updateUserTheme({ [themeKey]: undefined });
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Сброшено",
message: resetSuccessMessage,
});
} catch (_error) {
applyColor(savedColor);
setDraftColor(savedColor);
setToast({
type: TOAST_TYPE.ERROR,
title: "Ошибка",
message: resetErrorMessage,
});
} finally {
setIsSaving(false);
}
};
const renderColorSwatch = (color: string, options?: { isTemplate?: boolean }) => {
const isSelected = color === normalizedDraftColor;
return (
<div key={color} className={cn("relative grid size-7 place-items-center", options?.isTemplate && "group")}>
<button
type="button"
className="grid size-7 place-items-center rounded-full transition-transform hover:scale-105 focus-visible:scale-105"
onClick={() => handleColorChange(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>
{options?.isTemplate && (
<button
type="button"
className="absolute -top-1 -right-1 grid size-4 place-items-center rounded-full bg-black/70 text-white opacity-0 transition-opacity group-hover:opacity-100 hover:bg-black focus-visible:opacity-100"
onClick={() => handleRemoveTemplateColor(color)}
aria-label={`Удалить шаблон ${color}`}
title="Удалить шаблон"
>
<Trash2 className="size-2.5" />
</button>
)}
</div>
);
};
const colorPickerPanel =
isBrowser && isPaletteOpen
? createPortal(
<div
ref={palettePanelRef}
className="nodedc-accent-picker-panel fixed z-[9999] max-h-[calc(100vh-2rem)] overflow-y-auto rounded-[1.75rem] p-4 shadow-[0_28px_80px_rgba(0,0,0,0.46)]"
style={{
left: panelPosition.left,
top: panelPosition.top,
width: COLOR_PICKER_PANEL_WIDTH,
}}
>
<div className="mb-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<button
type="button"
className="grid size-9 place-items-center rounded-full bg-white/7 text-white transition-colors hover:bg-white/12 disabled:cursor-not-allowed disabled:opacity-45"
onClick={handlePickColorFromScreen}
aria-label="Взять цвет пипеткой"
title="Пипетка"
>
<Pipette className="size-4" />
</button>
<button
type="button"
className="grid size-9 place-items-center rounded-full bg-white/7 text-white transition-colors hover:bg-white/12 disabled:cursor-not-allowed disabled:opacity-45"
onClick={handleSaveTemplateColor}
disabled={!isDraftValid}
aria-label="Сохранить цвет как шаблон"
title="Сохранить как шаблон"
>
<BookmarkPlus className="size-4" />
</button>
</div>
<div className="flex min-w-0 items-center gap-2 rounded-full bg-white/7 py-1 pr-3 pl-1 text-11 font-medium text-primary">
<span
className="size-5 shrink-0 rounded-full shadow-[0_0_0_1px_rgba(255,255,255,0.2)]"
style={{ backgroundColor: normalizedDraftColor || defaultColor }}
/>
<span className="truncate uppercase">{normalizedDraftColor || defaultColor}</span>
</div>
</div>
<ColorPicker.ChromePicker
className="nodedc-accent-chrome-picker"
color={normalizedDraftColor || defaultColor}
disableAlpha
onChange={handleColorPickerChange}
styles={CHROME_PICKER_STYLES}
/>
{templateColors.length > 0 && (
<div className="mt-4 grid grid-cols-8 gap-2">
{templateColors.map((color) => renderColorSwatch(color, { isTemplate: true }))}
</div>
)}
<div
className={cn(
"grid grid-cols-8 gap-2",
templateColors.length > 0 ? "mt-3 border-t border-white/8 pt-3" : "mt-4"
)}
>
{presetColors.map((color) => renderColorSwatch(color))}
</div>
</div>,
document.body
)
: null;
return (
<SettingsControlItem
title={title}
description={description}
control={
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative w-40">
<button
ref={paletteButtonRef}
type="button"
className="absolute top-1/2 left-3 z-20 grid size-5 -translate-y-1/2 place-items-center rounded-full transition-transform outline-none hover:scale-105 focus-visible:scale-105"
onClick={() => setIsPaletteOpen((isOpen) => !isOpen)}
aria-label={paletteLabel}
aria-expanded={isPaletteOpen}
>
<span
className="size-4 rounded-full shadow-[0_0_0_1px_rgba(255,255,255,0.24)]"
style={{ backgroundColor: normalizedDraftColor || defaultColor }}
/>
</button>
{colorPickerPanel}
<input
name={inputName}
value={draftColor}
onChange={(event) => handleColorChange(event.target.value)}
placeholder={defaultColor}
className="nodedc-settings-input h-11 min-h-11 w-full pr-4 pl-10 text-13 font-medium uppercase"
style={{
color: normalizedDraftColor ? getReadableColor(normalizedDraftColor) : 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={savedColor === defaultColor || isSaving}
>
Сбросить
</button>
</div>
</div>
}
/>
);
});
export const ProfileSettingsPassiveCardSurfaceColor = () => (
<ProfileSettingsNodedcColorControl
themeKey="nodedcPassiveCardSurface"
defaultColor={NODEDC_DEFAULT_PASSIVE_CARD_SURFACE_HEX}
title="Фон пассивных карточек"
description="Локальная настройка пользователя. Меняет фон невыделенных карточек во внутреннем и внешнем контурах без перезапуска системы."
inputName="nodedcPassiveCardSurface"
paletteLabel="Открыть палитру фона пассивных карточек"
saveSuccessMessage="Фон пассивных карточек обновлен."
saveErrorMessage="Не удалось сохранить фон пассивных карточек."
resetSuccessMessage="Фон пассивных карточек возвращен к дизайн-конфигу."
resetErrorMessage="Не удалось сбросить фон пассивных карточек."
presetColors={PASSIVE_CARD_PRESET_COLORS}
applyColor={applyNodedcPassiveCardSurfaceColor}
/>
);
export const ProfileSettingsPassiveCardColor = () => (
<ProfileSettingsNodedcColorControl
themeKey="nodedcPassiveCard"
defaultColor={NODEDC_DEFAULT_PASSIVE_CARD_HEX}
title="Цвет дат на карточках"
description="Локальная настройка пользователя. Меняет цвет плашек дат и служебных бейджей на карточках."
inputName="nodedcPassiveCard"
paletteLabel="Открыть палитру цвета дат на карточках"
saveSuccessMessage="Цвет дат на карточках обновлен."
saveErrorMessage="Не удалось сохранить цвет дат на карточках."
resetSuccessMessage="Цвет дат на карточках возвращен к дизайн-конфигу."
resetErrorMessage="Не удалось сбросить цвет дат на карточках."
presetColors={PASSIVE_CARD_PRESET_COLORS}
applyColor={applyNodedcPassiveCardColor}
/>
);

View File

@ -0,0 +1,84 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { cn } from "@plane/utils";
// components
import { SettingsControlItem } from "@/components/settings/control-item";
// hooks
import { useUserProfile } from "@/hooks/store/user";
export const ProfileSettingsToolbarLayout = observer(function ProfileSettingsToolbarLayout() {
const { data: userProfile, updateUserTheme } = useUserProfile();
const [isSaving, setIsSaving] = useState(false);
const isCompactToolbar = userProfile?.theme?.nodedcCompactToolbar === true;
const handleToggle = async () => {
const nextValue = !isCompactToolbar;
try {
setIsSaving(true);
await updateUserTheme({ nodedcCompactToolbar: nextValue });
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Сохранено",
message: nextValue ? "Компактная панель инструментов включена." : "Расширенная панель инструментов включена.",
});
} catch (_error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Ошибка",
message: "Не удалось обновить режим панели инструментов.",
});
} finally {
setIsSaving(false);
}
};
return (
<SettingsControlItem
title="Панель инструментов"
description="Локальная настройка пользователя. Компактный режим оставляет текущую короткую верхнюю панель, расширенный режим показывает основные разделы текстовыми кнопками."
control={
<button
type="button"
className={cn(
"flex w-full items-center justify-between gap-4 rounded-[1.25rem] bg-white/5 px-4 py-3 text-left transition sm:w-[28rem]",
"hover:bg-white/8 focus-visible:bg-white/8",
isSaving && "cursor-wait opacity-70"
)}
onClick={handleToggle}
disabled={isSaving}
>
<span className="flex min-w-0 items-center gap-3">
<span
className={cn(
"grid size-4 flex-shrink-0 place-items-center rounded-full transition",
isCompactToolbar ? "bg-[rgb(var(--nodedc-accent-rgb))]" : "bg-white/10"
)}
>
{isCompactToolbar && <span className="size-1.5 rounded-full bg-[rgb(var(--nodedc-on-accent-rgb))]" />}
</span>
<span className="min-w-0">
<span className="block text-13 font-semibold text-primary">Компактный режим</span>
<span className="mt-0.5 block text-12 leading-5 text-tertiary">
{isCompactToolbar
? "Все основные действия собраны в короткие иконки."
: "Основные разделы вынесены в расширенную верхнюю навигацию."}
</span>
</span>
</span>
<span className="hidden flex-shrink-0 rounded-full bg-white/6 px-3 py-1 text-11 font-semibold text-secondary sm:block">
{isCompactToolbar ? "Компактно" : "Расширенно"}
</span>
</button>
}
/>
);
});

View File

@ -866,7 +866,10 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
if (typeof document === "undefined") return;
const updateDockSlot = () => {
setDockSlot(document.querySelector("[data-nodedc-voice-task-dock-slot]"));
setDockSlot(
document.querySelector("[data-nodedc-voice-task-toolbar-slot]") ??
document.querySelector("[data-nodedc-voice-task-dock-slot]")
);
};
updateDockSlot();

View File

@ -24,13 +24,13 @@ export const WorkspaceLogo = observer(function WorkspaceLogo(props: Props) {
className={cn(
`relative grid h-6 w-6 flex-shrink-0 place-items-center uppercase ${
!props.logo && "rounded-md bg-accent-primary text-on-color"
} ${props.classNames ? props.classNames : ""}`
} ${props.logo && "rounded-md"} ${props.classNames ? props.classNames : ""}`
)}
>
{props.logo && props.logo !== "" ? (
<img
src={getFileURL(props.logo)}
className="absolute top-0 left-0 h-full w-full rounded-md object-cover"
className="absolute top-0 left-0 h-full w-full rounded-[inherit] object-cover"
alt={t("aria_labels.projects_sidebar.workspace_logo")}
/>
) : (

View File

@ -6,8 +6,9 @@
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { Settings, UserPlus } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import type { MouseEvent } from "react";
import { Archive, BarChart3, Layers3, Settings, UserPlus } from "lucide-react";
import { Menu } from "@headlessui/react";
// plane imports
import { EUserPermissions } from "@plane/constants";
@ -31,8 +32,18 @@ const SidebarDropdownItem = observer(function SidebarDropdownItem(props: TProps)
const { workspace, activeWorkspace, handleItemClick, handleWorkspaceNavigation, handleClose } = props;
// router
const { workspaceSlug } = useParams();
const router = useRouter();
// hooks
const { t } = useTranslation();
const canOpenWorkspaceSettings = [EUserPermissions.ADMIN, EUserPermissions.MEMBER].includes(workspace?.role);
const canInviteMembers = [EUserPermissions.ADMIN].includes(workspace?.role);
const handleWorkspaceAction = (e: MouseEvent<HTMLButtonElement>, action: () => void) => {
e.preventDefault();
e.stopPropagation();
action();
handleClose();
};
return (
<Link
@ -92,36 +103,56 @@ const SidebarDropdownItem = observer(function SidebarDropdownItem(props: TProps)
</div>
{workspace.id === activeWorkspace?.id && (
<>
<div className="mt-2 mb-1 grid grid-cols-2 gap-3">
{[EUserPermissions.ADMIN, EUserPermissions.MEMBER].includes(workspace?.role) && (
<div className="mt-2 mb-1 flex flex-col gap-1.5">
{canOpenWorkspaceSettings && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openWorkspaceSettingsModal("general");
handleClose();
}}
className="flex min-w-0 flex-1 items-center justify-center gap-1.5 rounded-[1.25rem] border-0 bg-white/[0.05] px-5 py-2.5 text-secondary shadow-none outline-none transition-colors hover:bg-white/[0.09] hover:text-primary"
onClick={(e) => handleWorkspaceAction(e, () => openWorkspaceSettingsModal("general"))}
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-[1.1rem] border-0 bg-white/[0.05] px-3.5 py-2.5 text-secondary shadow-none outline-none transition-colors hover:bg-white/[0.09] hover:text-primary"
>
<Settings className="my-auto h-4 w-4 flex-shrink-0" />
<span className="my-auto text-13 font-medium whitespace-nowrap">{t("settings")}</span>
</button>
)}
{[EUserPermissions.ADMIN].includes(workspace?.role) && (
<Link
href={`/${workspace.slug}/settings/members`}
onClick={(e) => {
e.stopPropagation();
handleClose();
}}
className="flex min-w-0 flex-1 items-center justify-center gap-1.5 rounded-[1.25rem] border-0 bg-white/[0.05] px-5 py-2.5 text-secondary shadow-none outline-none transition-colors hover:bg-white/[0.09] hover:text-primary"
{canInviteMembers && (
<button
type="button"
onClick={(e) => handleWorkspaceAction(e, () => openWorkspaceSettingsModal("members"))}
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-[1.1rem] border-0 bg-white/[0.05] px-3.5 py-2.5 text-secondary shadow-none outline-none transition-colors hover:bg-white/[0.09] hover:text-primary"
>
<UserPlus className="my-auto h-4 w-4 flex-shrink-0" />
<span className="my-auto text-13 font-medium whitespace-nowrap">
{t("project_settings.members.invite_members.title")}
</span>
</Link>
</button>
)}
{canOpenWorkspaceSettings && (
<button
type="button"
onClick={(e) => handleWorkspaceAction(e, () => router.push(`/${workspace.slug}/analytics/`))}
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-[1.1rem] border-0 bg-white/[0.05] px-3.5 py-2.5 text-secondary shadow-none outline-none transition-colors hover:bg-white/[0.09] hover:text-primary"
>
<BarChart3 className="my-auto h-4 w-4 flex-shrink-0" />
<span className="my-auto text-13 font-medium whitespace-nowrap">Analytics</span>
</button>
)}
<button
type="button"
onClick={(e) => handleWorkspaceAction(e, () => router.push(`/${workspace.slug}/workspace-views/all-issues/`))}
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-[1.1rem] border-0 bg-white/[0.05] px-3.5 py-2.5 text-secondary shadow-none outline-none transition-colors hover:bg-white/[0.09] hover:text-primary"
>
<Layers3 className="my-auto h-4 w-4 flex-shrink-0" />
<span className="my-auto text-13 font-medium whitespace-nowrap">Представления</span>
</button>
{canOpenWorkspaceSettings && (
<button
type="button"
onClick={(e) => handleWorkspaceAction(e, () => router.push(`/${workspace.slug}/projects/archives`))}
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-[1.1rem] border-0 bg-white/[0.05] px-3.5 py-2.5 text-secondary shadow-none outline-none transition-colors hover:bg-white/[0.09] hover:text-primary"
>
<Archive className="my-auto h-4 w-4 flex-shrink-0" />
<span className="my-auto text-13 font-medium whitespace-nowrap">{t("archives")}</span>
</button>
)}
</div>
</>

View File

@ -22,7 +22,7 @@ import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useUser } from "@/hooks/store/user";
type TUserMenuRootProps = {
variant?: "default" | "sidebar-utility" | "toolbar";
variant?: "default" | "sidebar-utility" | "toolbar" | "expanded-toolbar";
};
export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootProps) {
@ -43,6 +43,7 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootP
const isSidebarUtilityVariant = variant === "sidebar-utility";
const isToolbarVariant = variant === "toolbar";
const isExpandedToolbarVariant = variant === "expanded-toolbar";
const handleSignOut = () => {
signOut().catch(() =>
@ -137,16 +138,18 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootP
className="flex items-center"
buttonAsChild
button={
isToolbarVariant ? (
isToolbarVariant || isExpandedToolbarVariant ? (
<button
type="button"
aria-label={t("profile")}
className="flex size-8 items-center justify-center rounded-full border-0 bg-white/[0.04] backdrop-blur-[18px] transition-all hover:bg-white/[0.07]"
className={`flex items-center justify-center overflow-hidden rounded-full border-0 bg-white/[0.04] backdrop-blur-[18px] transition-all hover:bg-white/[0.07] ${
isExpandedToolbarVariant ? "nodedc-expanded-user-avatar-button size-12" : "size-8"
}`}
>
<Avatar
name={currentUser?.display_name}
src={getFileURL(currentUser?.avatar_url ?? "")}
size={18}
size={isExpandedToolbarVariant ? 48 : 18}
shape="circle"
/>
</button>

View File

@ -31,7 +31,7 @@ import { WorkspaceLogo } from "../logo";
import SidebarDropdownItem from "./dropdown-item";
type WorkspaceMenuRootProps = {
variant: "sidebar" | "top-navigation" | "sidebar-panel" | "toolbar";
variant: "sidebar" | "top-navigation" | "sidebar-panel" | "toolbar" | "expanded-toolbar";
};
type WorkspaceMenuStateSyncProps = {
@ -46,7 +46,12 @@ function WorkspaceMenuStateSync(props: WorkspaceMenuStateSyncProps) {
const { open, variant, sidebarPanelButtonRef, onSidebarDropdownToggle, onSidebarPanelPositionChange } = props;
const updateSidebarPanelMenuPosition = useCallback(() => {
if (!["sidebar-panel", "toolbar"].includes(variant) || !sidebarPanelButtonRef.current || typeof window === "undefined") return;
if (
!["sidebar-panel", "toolbar", "expanded-toolbar"].includes(variant) ||
!sidebarPanelButtonRef.current ||
typeof window === "undefined"
)
return;
const rect = sidebarPanelButtonRef.current.getBoundingClientRect();
const width = 480;
@ -64,7 +69,7 @@ function WorkspaceMenuStateSync(props: WorkspaceMenuStateSyncProps) {
}, [onSidebarDropdownToggle, open]);
useLayoutEffect(() => {
if (!open || !["sidebar-panel", "toolbar"].includes(variant)) {
if (!open || !["sidebar-panel", "toolbar", "expanded-toolbar"].includes(variant)) {
onSidebarPanelPositionChange(null);
return;
}
@ -133,7 +138,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
"w-full justify-center text-center": variant === "sidebar",
"flex-grow justify-stretch text-left": variant === "top-navigation",
"w-full max-w-none justify-stretch text-left": variant === "sidebar-panel",
"w-fit max-w-none justify-center text-center": variant === "toolbar",
"w-fit max-w-none justify-center text-center": ["toolbar", "expanded-toolbar"].includes(variant),
})}
>
{({ open, close }: { open: boolean; close: () => void }) => {
@ -221,11 +226,12 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
/>
</Menu.Button>
)}
{variant === "toolbar" && (
{["toolbar", "expanded-toolbar"].includes(variant) && (
<Menu.Button
ref={sidebarPanelButtonRef}
className={cn(
"flex size-8 items-center justify-center rounded-full bg-white/[0.04] backdrop-blur-[18px] transition-all hover:bg-white/[0.07] focus:outline-none",
"flex items-center justify-center rounded-full bg-white/[0.04] backdrop-blur-[18px] transition-all hover:bg-white/[0.07] focus:outline-none",
variant === "expanded-toolbar" ? "size-12" : "size-8",
{
"bg-white/[0.08]": open,
}
@ -235,7 +241,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
<WorkspaceLogo
logo={activeWorkspace?.logo_url}
name={activeWorkspace?.name}
classNames="size-8 rounded-[0.9rem]"
classNames={variant === "expanded-toolbar" ? "size-12 rounded-full" : "size-8 rounded-[0.9rem]"}
/>
</Menu.Button>
)}
@ -247,15 +253,15 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
"z-21 mt-1 flex min-w-[30rem] origin-top-left flex-col divide-y overflow-hidden outline-none",
{
"fixed divide-subtle rounded-md border-[0.5px] border-strong bg-surface-1 shadow-raised-200":
!["sidebar-panel", "toolbar"].includes(variant),
!["sidebar-panel", "toolbar", "expanded-toolbar"].includes(variant),
"top-11 left-14": variant === "sidebar",
"top-10 left-4": variant === "top-navigation",
"nodedc-glass-modal nodedc-glass-popup-surface rounded-[1.5rem] divide-white/10":
["sidebar-panel", "toolbar"].includes(variant),
["sidebar-panel", "toolbar", "expanded-toolbar"].includes(variant),
}
)}
style={
["sidebar-panel", "toolbar"].includes(variant) && sidebarPanelMenuPosition
["sidebar-panel", "toolbar", "expanded-toolbar"].includes(variant) && sidebarPanelMenuPosition
? {
position: "fixed",
left: `${sidebarPanelMenuPosition.left}px`,
@ -270,8 +276,8 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
className={cn(
"sticky top-0 z-21 h-full w-full flex-shrink-0 truncate px-4 pt-3 pb-1 text-left text-13 font-medium text-placeholder",
{
"rounded-md bg-surface-1": !["sidebar-panel", "toolbar"].includes(variant),
"bg-transparent": ["sidebar-panel", "toolbar"].includes(variant),
"rounded-md bg-surface-1": !["sidebar-panel", "toolbar", "expanded-toolbar"].includes(variant),
"bg-transparent": ["sidebar-panel", "toolbar", "expanded-toolbar"].includes(variant),
}
)}
>
@ -343,7 +349,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
</Menu.Items>
);
if (["sidebar-panel", "toolbar"].includes(variant)) {
if (["sidebar-panel", "toolbar", "expanded-toolbar"].includes(variant)) {
if (!open || !sidebarPanelMenuPosition || typeof document === "undefined") return null;
return createPortal(menuItems, document.body);
}

View File

@ -8,5 +8,8 @@ import { rgbToNodedcAccentHex } from "@plane/utils";
import designConfig from "../../design.config.json";
const defaultAccentRgb = designConfig.nodedc.accent_rgb as [number, number, number];
const defaultPassiveCardRgb = designConfig.nodedc.passive_card_rgb as [number, number, number];
export const NODEDC_DEFAULT_ACCENT_HEX = rgbToNodedcAccentHex(defaultAccentRgb);
export const NODEDC_DEFAULT_PASSIVE_CARD_HEX = rgbToNodedcAccentHex(defaultPassiveCardRgb);
export const NODEDC_DEFAULT_PASSIVE_CARD_SURFACE_HEX = rgbToNodedcAccentHex(defaultPassiveCardRgb);

View File

@ -12,8 +12,18 @@ import { useTheme } from "next-themes";
import type { TLanguage } from "@plane/i18n";
import { DEFAULT_LANGUAGE, useTranslation } from "@plane/i18n";
// helpers
import { applyCustomTheme, applyNodedcAccent, clearCustomTheme } from "@plane/utils";
import { NODEDC_DEFAULT_ACCENT_HEX } from "@/helpers/nodedc-design";
import {
applyCustomTheme,
applyNodedcAccent,
applyNodedcPassiveCardColor,
applyNodedcPassiveCardSurfaceColor,
clearCustomTheme,
} from "@plane/utils";
import {
NODEDC_DEFAULT_ACCENT_HEX,
NODEDC_DEFAULT_PASSIVE_CARD_HEX,
NODEDC_DEFAULT_PASSIVE_CARD_SURFACE_HEX,
} from "../../helpers/nodedc-design";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useRouterParams } from "@/hooks/store/use-router-params";
@ -113,6 +123,18 @@ function StoreWrapper(props: TStoreWrapper) {
applyNodedcAccent(userProfile?.theme?.nodedcAccent || NODEDC_DEFAULT_ACCENT_HEX);
}, [userProfile?.id, userProfile?.theme?.nodedcAccent]);
useEffect(() => {
if (!userProfile?.id) return;
applyNodedcPassiveCardColor(userProfile?.theme?.nodedcPassiveCard || NODEDC_DEFAULT_PASSIVE_CARD_HEX);
}, [userProfile?.id, userProfile?.theme?.nodedcPassiveCard]);
useEffect(() => {
if (!userProfile?.id) return;
applyNodedcPassiveCardSurfaceColor(
userProfile?.theme?.nodedcPassiveCardSurface || NODEDC_DEFAULT_PASSIVE_CARD_SURFACE_HEX
);
}, [userProfile?.id, userProfile?.theme?.nodedcPassiveCardSurface]);
useEffect(() => {
if (!userProfile?.id) return;
changeLanguage((userProfile?.language as TLanguage) || DEFAULT_LANGUAGE);

View File

@ -46,6 +46,9 @@ export class ProfileStore implements IUserProfileStore {
background: undefined,
darkPalette: false,
nodedcAccent: undefined,
nodedcPassiveCard: undefined,
nodedcPassiveCardSurface: undefined,
nodedcCompactToolbar: undefined,
},
onboarding_step: {
workspace_join: false,

View File

@ -0,0 +1 @@
<svg id="nodedc-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220.82 54.55"><defs><style>.cls-1{fill:#e2e1e1;}.cls-2{fill:#dbdbdb;stroke:#dbdbdb;stroke-miterlimit:10;stroke-width:0.75px;}</style></defs><path class="cls-1" d="M52.8,23.61,46.92,33.76,41.05,23.61H52.8m18-10.39H23.06L46.92,54.55Z"/><polygon class="cls-1" points="31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"/><path class="cls-2" d="M116.35,18.49V1h1.27l10.34,15V1h1.33V18.49H128l-10.34-15v15Z"/><path class="cls-2" d="M140.43,18.64c-4.79,0-8.16-3.72-8.16-8.89S135.64.86,140.43.86s8.17,3.72,8.17,8.89S145.25,18.64,140.43,18.64Zm0-1.25c4,0,6.79-3.17,6.79-7.64s-2.77-7.64-6.79-7.64-6.77,3.17-6.77,7.64S136.44,17.39,140.43,17.39Z"/><path class="cls-2" d="M151.6,18.49V1h5.1c5.54,0,8.79,3.42,8.79,8.74s-3.25,8.74-8.79,8.74ZM153,17.24h3.75c4.77,0,7.42-2.92,7.42-7.49s-2.65-7.49-7.42-7.49H153Z"/><path class="cls-2" d="M168.49,1h10.77V2.26h-9.42V8.93h7.89v1.25h-7.89v7.06h9.74v1.25H168.49Z"/><path class="cls-2" d="M188.88,18.49V1H194c5.54,0,8.79,3.42,8.79,8.74s-3.25,8.74-8.79,8.74Zm1.35-1.25H194c4.77,0,7.41-2.92,7.41-7.49S198.75,2.26,194,2.26h-3.75Z"/><path class="cls-2" d="M205.15,9.75c0-5.24,3.19-8.89,8.11-8.89a6.8,6.8,0,0,1,7.1,5.52h-1.43a5.54,5.54,0,0,0-5.74-4.27c-4.05,0-6.64,3.17-6.64,7.64s2.54,7.64,6.59,7.64a5.46,5.46,0,0,0,5.74-4.29h1.43c-.75,3.52-3.4,5.54-7.15,5.54C208.27,18.64,205.15,15.05,205.15,9.75Z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,9 @@ export type TUserProfile = {
theme: {
theme: string | undefined;
nodedcAccent?: string | undefined;
nodedcPassiveCard?: string | undefined;
nodedcPassiveCardSurface?: string | undefined;
nodedcCompactToolbar?: boolean | undefined;
};
onboarding_step: {

View File

@ -70,6 +70,9 @@ export type TUserProfile = {
background: string | undefined;
darkPalette: boolean | undefined;
nodedcAccent?: string | undefined;
nodedcPassiveCard?: string | undefined;
nodedcPassiveCardSurface?: string | undefined;
nodedcCompactToolbar?: boolean | undefined;
};
onboarding_step: TOnboardingSteps;
is_onboarded: boolean;
@ -109,6 +112,9 @@ export interface IUserTheme {
background?: string | undefined;
darkPalette?: boolean | undefined;
nodedcAccent?: string | undefined;
nodedcPassiveCard?: string | undefined;
nodedcPassiveCardSurface?: string | undefined;
nodedcCompactToolbar?: boolean | undefined;
}
export interface IUserMemberLite extends IUserLite {

View File

@ -30,6 +30,8 @@ export {
// NODE.DC runtime accent
export {
applyNodedcAccent,
applyNodedcPassiveCardColor,
applyNodedcPassiveCardSurfaceColor,
getReadableNodedcTextRgb,
nodedcAccentHexToRgb,
normalizeNodedcAccentHex,

View File

@ -100,3 +100,33 @@ export const applyNodedcAccent = (hex: string | null | undefined): boolean => {
return true;
};
export const applyNodedcPassiveCardColor = (hex: string | null | undefined): boolean => {
if (typeof document === "undefined") return false;
const passiveCardRgb = nodedcAccentHexToRgb(hex);
if (!passiveCardRgb) return false;
const root = document.documentElement;
const onPassiveCardRgb = getReadableNodedcTextRgb(passiveCardRgb);
root.style.setProperty("--nodedc-card-passive-rgb", formatRgbTuple(passiveCardRgb));
root.style.setProperty("--nodedc-on-card-passive-rgb", formatRgbTuple(onPassiveCardRgb));
return true;
};
export const applyNodedcPassiveCardSurfaceColor = (hex: string | null | undefined): boolean => {
if (typeof document === "undefined") return false;
const passiveCardSurfaceRgb = nodedcAccentHexToRgb(hex);
if (!passiveCardSurfaceRgb) return false;
const root = document.documentElement;
const onPassiveCardSurfaceRgb = getReadableNodedcTextRgb(passiveCardSurfaceRgb);
root.style.setProperty("--nodedc-card-passive-surface-rgb", formatRgbTuple(passiveCardSurfaceRgb));
root.style.setProperty("--nodedc-on-card-passive-surface-rgb", formatRgbTuple(onPassiveCardSurfaceRgb));
return true;
};

File diff suppressed because it is too large Load Diff