Compare commits
8 Commits
d9a63f5f74
...
55318f14e5
| Author | SHA1 | Date |
|---|---|---|
|
|
55318f14e5 | |
|
|
561d1eeef5 | |
|
|
119d503d96 | |
|
|
ae262487ac | |
|
|
d7260bdfce | |
|
|
a7ab8ee123 | |
|
|
7ff7d83b07 | |
|
|
d28f83fe5e |
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}))
|
||||
|
|
@ -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}"))
|
||||
|
|
@ -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")},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -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")},
|
||||
),
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./layout-registry";
|
||||
export * from "./types";
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -106,7 +106,6 @@ const CreatedVsResolved = observer(function CreatedVsResolved() {
|
|||
areas={areas}
|
||||
xAxis={{
|
||||
key: "name",
|
||||
label: t("date"),
|
||||
}}
|
||||
yAxis={{
|
||||
key: "count",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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))]"
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ export {
|
|||
// NODE.DC runtime accent
|
||||
export {
|
||||
applyNodedcAccent,
|
||||
applyNodedcPassiveCardColor,
|
||||
applyNodedcPassiveCardSurfaceColor,
|
||||
getReadableNodedcTextRgb,
|
||||
nodedcAccentHexToRgb,
|
||||
normalizeNodedcAccentHex,
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue