mirror of
https://github.com/makeplane/plane.git
synced 2025-12-25 16:19:43 +01:00
Merge branch 'preview' of github.com:makeplane/plane into chore-page-sort-order
This commit is contained in:
@@ -3,13 +3,7 @@ import random
|
||||
from rest_framework import serializers
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
ProjectIdentifier,
|
||||
WorkspaceMember,
|
||||
State,
|
||||
Estimate,
|
||||
)
|
||||
from plane.db.models import Project, ProjectIdentifier, WorkspaceMember, State, Estimate
|
||||
|
||||
from plane.utils.content_validator import (
|
||||
validate_html_content,
|
||||
@@ -123,6 +117,7 @@ class ProjectCreateSerializer(BaseSerializer):
|
||||
|
||||
def create(self, validated_data):
|
||||
identifier = validated_data.get("identifier", "").strip().upper()
|
||||
|
||||
if identifier == "":
|
||||
raise serializers.ValidationError(detail="Project Identifier is required")
|
||||
|
||||
|
||||
@@ -210,7 +210,9 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
|
||||
"""
|
||||
try:
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
serializer = ProjectCreateSerializer(data={**request.data}, context={"workspace_id": workspace.id})
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from plane.db.models import (
|
||||
ProjectIdentifier,
|
||||
DeployBoard,
|
||||
ProjectPublicMember,
|
||||
IssueSequence
|
||||
IssueSequence,
|
||||
)
|
||||
from plane.utils.content_validator import (
|
||||
validate_html_content,
|
||||
|
||||
@@ -15,9 +15,10 @@ from django.utils import timezone
|
||||
from django.db.models import Prefetch
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import ExporterHistory, Issue, IssueRelation
|
||||
from plane.db.models import ExporterHistory, Issue, IssueComment, IssueRelation, IssueSubscriber
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.utils.exporters import Exporter, IssueExportSchema
|
||||
from plane.utils.porters.exporter import DataExporter
|
||||
from plane.utils.porters.serializers.issue import IssueExportSerializer
|
||||
|
||||
|
||||
def create_zip_file(files: List[tuple[str, str | bytes]]) -> io.BytesIO:
|
||||
@@ -159,10 +160,16 @@ def issue_export_task(
|
||||
"labels",
|
||||
"issue_cycle__cycle",
|
||||
"issue_module__module",
|
||||
"issue_comments",
|
||||
"assignees",
|
||||
"issue_subscribers",
|
||||
"issue_link",
|
||||
Prefetch(
|
||||
"issue_subscribers",
|
||||
queryset=IssueSubscriber.objects.select_related("subscriber"),
|
||||
),
|
||||
Prefetch(
|
||||
"issue_comments",
|
||||
queryset=IssueComment.objects.select_related("actor").order_by("created_at"),
|
||||
),
|
||||
Prefetch(
|
||||
"issue_relation",
|
||||
queryset=IssueRelation.objects.select_related("related_issue", "related_issue__project"),
|
||||
@@ -180,11 +187,7 @@ def issue_export_task(
|
||||
|
||||
# Create exporter for the specified format
|
||||
try:
|
||||
exporter = Exporter(
|
||||
format_type=provider,
|
||||
schema_class=IssueExportSchema,
|
||||
options={"list_joiner": ", "},
|
||||
)
|
||||
exporter = DataExporter(IssueExportSerializer, format_type=provider)
|
||||
except ValueError as e:
|
||||
# Invalid format type
|
||||
exporter_instance = ExporterHistory.objects.get(token=token_id)
|
||||
|
||||
@@ -116,6 +116,11 @@ class Project(BaseModel):
|
||||
external_source = models.CharField(max_length=255, null=True, blank=True)
|
||||
external_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Track if timezone is provided, if so, don't override it with the workspace timezone when saving
|
||||
self.is_timezone_provided = kwargs.get("timezone") is not None
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def cover_image_url(self):
|
||||
# Return cover image url
|
||||
@@ -155,7 +160,15 @@ class Project(BaseModel):
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
from plane.db.models import Workspace
|
||||
|
||||
self.identifier = self.identifier.strip().upper()
|
||||
is_creating = self._state.adding
|
||||
|
||||
if is_creating and not self.is_timezone_provided:
|
||||
workspace = Workspace.objects.get(id=self.workspace_id)
|
||||
self.timezone = workspace.timezone
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
|
||||
@@ -147,6 +147,11 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||
return self.cover_image
|
||||
return None
|
||||
|
||||
@property
|
||||
def full_name(self):
|
||||
"""Return user's full name (first + last)."""
|
||||
return f"{self.first_name} {self.last_name}".strip()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.email = self.email.lower().strip()
|
||||
self.mobile_number = self.mobile_number
|
||||
|
||||
15
apps/api/plane/utils/porters/__init__.py
Normal file
15
apps/api/plane/utils/porters/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from .formatters import BaseFormatter, CSVFormatter, JSONFormatter, XLSXFormatter
|
||||
from .exporter import DataExporter
|
||||
from .serializers import IssueExportSerializer
|
||||
|
||||
__all__ = [
|
||||
# Formatters
|
||||
"BaseFormatter",
|
||||
"CSVFormatter",
|
||||
"JSONFormatter",
|
||||
"XLSXFormatter",
|
||||
# Exporters
|
||||
"DataExporter",
|
||||
# Export Serializers
|
||||
"IssueExportSerializer",
|
||||
]
|
||||
103
apps/api/plane/utils/porters/exporter.py
Normal file
103
apps/api/plane/utils/porters/exporter.py
Normal file
@@ -0,0 +1,103 @@
|
||||
from typing import Dict, List, Union
|
||||
from .formatters import BaseFormatter, CSVFormatter, JSONFormatter, XLSXFormatter
|
||||
|
||||
|
||||
class DataExporter:
|
||||
"""
|
||||
Export data using DRF serializers with built-in format support.
|
||||
|
||||
Usage:
|
||||
# New simplified interface
|
||||
exporter = DataExporter(BookSerializer, format_type='csv')
|
||||
filename, content = exporter.export('books_export', queryset)
|
||||
|
||||
# Legacy interface (still supported)
|
||||
exporter = DataExporter(BookSerializer)
|
||||
csv_string = exporter.to_string(queryset, CSVFormatter())
|
||||
"""
|
||||
|
||||
# Available formatters
|
||||
FORMATTERS = {
|
||||
"csv": CSVFormatter,
|
||||
"json": JSONFormatter,
|
||||
"xlsx": XLSXFormatter,
|
||||
}
|
||||
|
||||
def __init__(self, serializer_class, format_type: str = None, **serializer_kwargs):
|
||||
"""
|
||||
Initialize exporter with serializer and optional format type.
|
||||
|
||||
Args:
|
||||
serializer_class: DRF serializer class to use for data serialization
|
||||
format_type: Optional format type (csv, json, xlsx). If provided, enables export() method.
|
||||
**serializer_kwargs: Additional kwargs to pass to serializer
|
||||
"""
|
||||
self.serializer_class = serializer_class
|
||||
self.serializer_kwargs = serializer_kwargs
|
||||
self.format_type = format_type
|
||||
self.formatter = None
|
||||
|
||||
if format_type:
|
||||
if format_type not in self.FORMATTERS:
|
||||
raise ValueError(f"Unsupported format: {format_type}. Available: {list(self.FORMATTERS.keys())}")
|
||||
# Create formatter with default options
|
||||
self.formatter = self._create_formatter(format_type)
|
||||
|
||||
def _create_formatter(self, format_type: str) -> BaseFormatter:
|
||||
"""Create formatter instance with appropriate options."""
|
||||
formatter_class = self.FORMATTERS[format_type]
|
||||
|
||||
# Apply format-specific options
|
||||
if format_type == "xlsx":
|
||||
return formatter_class(list_joiner=", ")
|
||||
else:
|
||||
return formatter_class()
|
||||
|
||||
def serialize(self, queryset) -> List[Dict]:
|
||||
"""QuerySet → list of dicts"""
|
||||
serializer = self.serializer_class(
|
||||
queryset,
|
||||
many=True,
|
||||
**self.serializer_kwargs
|
||||
)
|
||||
return serializer.data
|
||||
|
||||
def export(self, filename: str, queryset) -> tuple[str, Union[str, bytes]]:
|
||||
"""
|
||||
Export queryset to file with configured format.
|
||||
|
||||
Args:
|
||||
filename: Base filename (without extension)
|
||||
queryset: Django QuerySet to export
|
||||
|
||||
Returns:
|
||||
Tuple of (filename_with_extension, content)
|
||||
|
||||
Raises:
|
||||
ValueError: If format_type was not provided during initialization
|
||||
"""
|
||||
if not self.formatter:
|
||||
raise ValueError("format_type must be provided during initialization to use export() method")
|
||||
|
||||
data = self.serialize(queryset)
|
||||
content = self.formatter.encode(data)
|
||||
full_filename = f"{filename}.{self.formatter.extension}"
|
||||
|
||||
return full_filename, content
|
||||
|
||||
def to_string(self, queryset, formatter: BaseFormatter) -> Union[str, bytes]:
|
||||
"""Export to formatted string (legacy interface)"""
|
||||
data = self.serialize(queryset)
|
||||
return formatter.encode(data)
|
||||
|
||||
def to_file(self, queryset, filepath: str, formatter: BaseFormatter) -> str:
|
||||
"""Export to file (legacy interface)"""
|
||||
content = self.to_string(queryset, formatter)
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
return filepath
|
||||
|
||||
@classmethod
|
||||
def get_available_formats(cls) -> List[str]:
|
||||
"""Get list of available export formats."""
|
||||
return list(cls.FORMATTERS.keys())
|
||||
265
apps/api/plane/utils/porters/formatters.py
Normal file
265
apps/api/plane/utils/porters/formatters.py
Normal file
@@ -0,0 +1,265 @@
|
||||
"""
|
||||
Import/Export System with Pluggable Formatters
|
||||
|
||||
Exporter: QuerySet → Serializer → Formatter → File/String
|
||||
Importer: File/String → Formatter → Serializer → Models
|
||||
"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
from abc import ABC, abstractmethod
|
||||
from io import BytesIO, StringIO
|
||||
from typing import Any, Dict, List, Union
|
||||
|
||||
from openpyxl import Workbook, load_workbook
|
||||
|
||||
|
||||
class BaseFormatter(ABC):
|
||||
@abstractmethod
|
||||
def encode(self, data: List[Dict]) -> Union[str, bytes]:
|
||||
"""Data → formatted string/bytes"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def decode(self, content: Union[str, bytes]) -> List[Dict]:
|
||||
"""Formatted string/bytes → data"""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def extension(self) -> str:
|
||||
pass
|
||||
|
||||
|
||||
class JSONFormatter(BaseFormatter):
|
||||
def __init__(self, indent: int = 2):
|
||||
self.indent = indent
|
||||
|
||||
def encode(self, data: List[Dict]) -> str:
|
||||
return json.dumps(data, indent=self.indent, default=str)
|
||||
|
||||
def decode(self, content: str) -> List[Dict]:
|
||||
return json.loads(content)
|
||||
|
||||
@property
|
||||
def extension(self) -> str:
|
||||
return "json"
|
||||
|
||||
|
||||
class CSVFormatter(BaseFormatter):
|
||||
def __init__(self, flatten: bool = True, delimiter: str = ",", prettify_headers: bool = True):
|
||||
"""
|
||||
Args:
|
||||
flatten: Whether to flatten nested dicts.
|
||||
delimiter: CSV delimiter character.
|
||||
prettify_headers: If True, transforms 'created_by_name' → 'Created By Name'.
|
||||
"""
|
||||
self.flatten = flatten
|
||||
self.delimiter = delimiter
|
||||
self.prettify_headers = prettify_headers
|
||||
|
||||
def _prettify_header(self, header: str) -> str:
|
||||
"""Transform 'created_by_name' → 'Created By Name'"""
|
||||
return header.replace("_", " ").title()
|
||||
|
||||
def _normalize_header(self, header: str) -> str:
|
||||
"""Transform 'Display Name' → 'display_name' (reverse of prettify)"""
|
||||
return header.strip().lower().replace(" ", "_")
|
||||
|
||||
def _flatten(self, row: Dict, parent_key: str = "") -> Dict:
|
||||
items = {}
|
||||
for key, value in row.items():
|
||||
new_key = f"{parent_key}__{key}" if parent_key else key
|
||||
if isinstance(value, dict):
|
||||
items.update(self._flatten(value, new_key))
|
||||
elif isinstance(value, list):
|
||||
items[new_key] = json.dumps(value)
|
||||
else:
|
||||
items[new_key] = value
|
||||
return items
|
||||
|
||||
def _unflatten(self, row: Dict) -> Dict:
|
||||
result = {}
|
||||
for key, value in row.items():
|
||||
parts = key.split("__")
|
||||
current = result
|
||||
for part in parts[:-1]:
|
||||
current = current.setdefault(part, {})
|
||||
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
if isinstance(parsed, (list, dict)):
|
||||
value = parsed
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
current[parts[-1]] = value
|
||||
return result
|
||||
|
||||
def encode(self, data: List[Dict]) -> str:
|
||||
if not data:
|
||||
return ""
|
||||
|
||||
if self.flatten:
|
||||
data = [self._flatten(row) for row in data]
|
||||
|
||||
# Collect all unique field names in order
|
||||
fieldnames = []
|
||||
for row in data:
|
||||
for key in row.keys():
|
||||
if key not in fieldnames:
|
||||
fieldnames.append(key)
|
||||
|
||||
output = StringIO()
|
||||
|
||||
if self.prettify_headers:
|
||||
# Create header mapping: original_key → Pretty Header
|
||||
header_map = {key: self._prettify_header(key) for key in fieldnames}
|
||||
pretty_headers = [header_map[key] for key in fieldnames]
|
||||
|
||||
# Write pretty headers manually, then write data rows
|
||||
writer = csv.writer(output, delimiter=self.delimiter)
|
||||
writer.writerow(pretty_headers)
|
||||
|
||||
# Write data rows in the same field order
|
||||
for row in data:
|
||||
writer.writerow([row.get(key, "") for key in fieldnames])
|
||||
else:
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames, delimiter=self.delimiter)
|
||||
writer.writeheader()
|
||||
writer.writerows(data)
|
||||
|
||||
return output.getvalue()
|
||||
|
||||
def decode(self, content: str, normalize_headers: bool = True) -> List[Dict]:
|
||||
"""
|
||||
Decode CSV content to list of dicts.
|
||||
|
||||
Args:
|
||||
content: CSV string
|
||||
normalize_headers: If True, converts 'Display Name' → 'display_name'
|
||||
"""
|
||||
rows = list(csv.DictReader(StringIO(content), delimiter=self.delimiter))
|
||||
|
||||
# Normalize headers: 'Email' → 'email', 'Display Name' → 'display_name'
|
||||
if normalize_headers:
|
||||
rows = [{self._normalize_header(k): v for k, v in row.items()} for row in rows]
|
||||
|
||||
if self.flatten:
|
||||
rows = [self._unflatten(row) for row in rows]
|
||||
|
||||
return rows
|
||||
|
||||
@property
|
||||
def extension(self) -> str:
|
||||
return "csv"
|
||||
|
||||
|
||||
class XLSXFormatter(BaseFormatter):
|
||||
"""Formatter for XLSX (Excel) files using openpyxl."""
|
||||
|
||||
def __init__(self, prettify_headers: bool = True, list_joiner: str = ", "):
|
||||
"""
|
||||
Args:
|
||||
prettify_headers: If True, transforms 'created_by_name' → 'Created By Name'.
|
||||
list_joiner: String to join list values (default: ", ").
|
||||
"""
|
||||
self.prettify_headers = prettify_headers
|
||||
self.list_joiner = list_joiner
|
||||
|
||||
def _prettify_header(self, header: str) -> str:
|
||||
"""Transform 'created_by_name' → 'Created By Name'"""
|
||||
return header.replace("_", " ").title()
|
||||
|
||||
def _normalize_header(self, header: str) -> str:
|
||||
"""Transform 'Display Name' → 'display_name' (reverse of prettify)"""
|
||||
return header.strip().lower().replace(" ", "_")
|
||||
|
||||
def _format_value(self, value: Any) -> Any:
|
||||
"""Format a value for XLSX cell."""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, list):
|
||||
return self.list_joiner.join(str(v) for v in value)
|
||||
if isinstance(value, dict):
|
||||
return json.dumps(value)
|
||||
return value
|
||||
|
||||
def encode(self, data: List[Dict]) -> bytes:
|
||||
"""Encode data to XLSX bytes."""
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
|
||||
if not data:
|
||||
# Return empty workbook
|
||||
output = BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
return output.getvalue()
|
||||
|
||||
# Collect all unique field names in order
|
||||
fieldnames = []
|
||||
for row in data:
|
||||
for key in row.keys():
|
||||
if key not in fieldnames:
|
||||
fieldnames.append(key)
|
||||
|
||||
# Write header row
|
||||
if self.prettify_headers:
|
||||
headers = [self._prettify_header(key) for key in fieldnames]
|
||||
else:
|
||||
headers = fieldnames
|
||||
ws.append(headers)
|
||||
|
||||
# Write data rows
|
||||
for row in data:
|
||||
ws.append([self._format_value(row.get(key, "")) for key in fieldnames])
|
||||
|
||||
output = BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
return output.getvalue()
|
||||
|
||||
def decode(self, content: bytes, normalize_headers: bool = True) -> List[Dict]:
|
||||
"""
|
||||
Decode XLSX bytes to list of dicts.
|
||||
|
||||
Args:
|
||||
content: XLSX file bytes
|
||||
normalize_headers: If True, converts 'Display Name' → 'display_name'
|
||||
"""
|
||||
wb = load_workbook(filename=BytesIO(content), read_only=True, data_only=True)
|
||||
ws = wb.active
|
||||
|
||||
rows = list(ws.iter_rows(values_only=True))
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
# First row is headers
|
||||
headers = list(rows[0])
|
||||
if normalize_headers:
|
||||
headers = [self._normalize_header(str(h)) if h else "" for h in headers]
|
||||
|
||||
# Convert remaining rows to dicts
|
||||
result = []
|
||||
for row in rows[1:]:
|
||||
row_dict = {}
|
||||
for i, value in enumerate(row):
|
||||
if i < len(headers) and headers[i]:
|
||||
# Try to parse JSON strings back to lists/dicts
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
if isinstance(parsed, (list, dict)):
|
||||
value = parsed
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
row_dict[headers[i]] = value
|
||||
result.append(row_dict)
|
||||
|
||||
return result
|
||||
|
||||
@property
|
||||
def extension(self) -> str:
|
||||
return "xlsx"
|
||||
6
apps/api/plane/utils/porters/serializers/__init__.py
Normal file
6
apps/api/plane/utils/porters/serializers/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from .issue import IssueExportSerializer
|
||||
|
||||
__all__ = [
|
||||
# Export Serializers
|
||||
"IssueExportSerializer",
|
||||
]
|
||||
141
apps/api/plane/utils/porters/serializers/issue.py
Normal file
141
apps/api/plane/utils/porters/serializers/issue.py
Normal file
@@ -0,0 +1,141 @@
|
||||
# Third party imports
|
||||
from rest_framework import serializers
|
||||
|
||||
# Module imports
|
||||
from plane.app.serializers import IssueSerializer
|
||||
|
||||
|
||||
class IssueExportSerializer(IssueSerializer):
|
||||
"""
|
||||
Export-optimized serializer that extends IssueSerializer with human-readable fields.
|
||||
|
||||
Converts UUIDs to readable values for CSV/JSON export.
|
||||
"""
|
||||
|
||||
identifier = serializers.SerializerMethodField()
|
||||
project_name = serializers.CharField(source='project.name', read_only=True, default="")
|
||||
project_identifier = serializers.CharField(source='project.identifier', read_only=True, default="")
|
||||
state_name = serializers.CharField(source='state.name', read_only=True, default="")
|
||||
created_by_name = serializers.CharField(source='created_by.full_name', read_only=True, default="")
|
||||
|
||||
assignees = serializers.SerializerMethodField()
|
||||
parent = serializers.SerializerMethodField()
|
||||
labels = serializers.SerializerMethodField()
|
||||
cycles = serializers.SerializerMethodField()
|
||||
modules = serializers.SerializerMethodField()
|
||||
comments = serializers.SerializerMethodField()
|
||||
estimate = serializers.SerializerMethodField()
|
||||
links = serializers.SerializerMethodField()
|
||||
relations = serializers.SerializerMethodField()
|
||||
subscribers = serializers.SerializerMethodField()
|
||||
|
||||
class Meta(IssueSerializer.Meta):
|
||||
fields = [
|
||||
"project_name",
|
||||
"project_identifier",
|
||||
"parent",
|
||||
"identifier",
|
||||
"sequence_id",
|
||||
"name",
|
||||
"state_name",
|
||||
"priority",
|
||||
"assignees",
|
||||
"subscribers",
|
||||
"created_by_name",
|
||||
"start_date",
|
||||
"target_date",
|
||||
"completed_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"archived_at",
|
||||
"estimate",
|
||||
"labels",
|
||||
"cycles",
|
||||
"modules",
|
||||
"links",
|
||||
"relations",
|
||||
"comments",
|
||||
"sub_issues_count",
|
||||
"link_count",
|
||||
"attachment_count",
|
||||
"is_draft",
|
||||
]
|
||||
|
||||
def get_identifier(self, obj):
|
||||
return f"{obj.project.identifier}-{obj.sequence_id}"
|
||||
|
||||
def get_assignees(self, obj):
|
||||
return [u.full_name for u in obj.assignees.all() if u.is_active]
|
||||
|
||||
def get_subscribers(self, obj):
|
||||
"""Return list of subscriber names."""
|
||||
return [sub.subscriber.full_name for sub in obj.issue_subscribers.all() if sub.subscriber]
|
||||
|
||||
def get_parent(self, obj):
|
||||
if not obj.parent:
|
||||
return ""
|
||||
return f"{obj.parent.project.identifier}-{obj.parent.sequence_id}"
|
||||
|
||||
def get_labels(self, obj):
|
||||
return [
|
||||
il.label.name
|
||||
for il in obj.label_issue.all()
|
||||
if il.deleted_at is None
|
||||
]
|
||||
|
||||
def get_cycles(self, obj):
|
||||
return [ic.cycle.name for ic in obj.issue_cycle.all()]
|
||||
|
||||
def get_modules(self, obj):
|
||||
return [im.module.name for im in obj.issue_module.all()]
|
||||
|
||||
def get_estimate(self, obj):
|
||||
"""Return estimate point value."""
|
||||
if obj.estimate_point:
|
||||
return obj.estimate_point.value if hasattr(obj.estimate_point, 'value') else str(obj.estimate_point)
|
||||
return ""
|
||||
|
||||
def get_links(self, obj):
|
||||
"""Return list of issue links with titles."""
|
||||
return [
|
||||
{
|
||||
"url": link.url,
|
||||
"title": link.title if link.title else link.url,
|
||||
}
|
||||
for link in obj.issue_link.all()
|
||||
]
|
||||
|
||||
def get_relations(self, obj):
|
||||
"""Return list of related issues."""
|
||||
relations = []
|
||||
|
||||
# Outgoing relations (this issue relates to others)
|
||||
for rel in obj.issue_relation.all():
|
||||
if rel.related_issue:
|
||||
relations.append({
|
||||
"type": rel.relation_type if hasattr(rel, 'relation_type') else "related",
|
||||
"issue": f"{rel.related_issue.project.identifier}-{rel.related_issue.sequence_id}",
|
||||
"direction": "outgoing"
|
||||
})
|
||||
|
||||
# Incoming relations (other issues relate to this one)
|
||||
for rel in obj.issue_related.all():
|
||||
if rel.issue:
|
||||
relations.append({
|
||||
"type": rel.relation_type if hasattr(rel, 'relation_type') else "related",
|
||||
"issue": f"{rel.issue.project.identifier}-{rel.issue.sequence_id}",
|
||||
"direction": "incoming"
|
||||
})
|
||||
|
||||
return relations
|
||||
|
||||
def get_comments(self, obj):
|
||||
"""Return list of comments with author and timestamp."""
|
||||
return [
|
||||
{
|
||||
"comment": comment.comment_stripped if hasattr(comment, 'comment_stripped') else comment.comment_html,
|
||||
"created_by": comment.actor.full_name if comment.actor else "",
|
||||
"created_at": comment.created_at.strftime("%Y-%m-%d %H:%M:%S") if comment.created_at else "",
|
||||
}
|
||||
for comment in obj.issue_comments.all()
|
||||
]
|
||||
@@ -32,13 +32,9 @@ export class ProfileStore implements IProfileStore {
|
||||
last_workspace_id: undefined,
|
||||
theme: {
|
||||
theme: undefined,
|
||||
text: undefined,
|
||||
palette: undefined,
|
||||
primary: undefined,
|
||||
background: undefined,
|
||||
darkPalette: undefined,
|
||||
sidebarText: undefined,
|
||||
sidebarBackground: undefined,
|
||||
},
|
||||
onboarding_step: {
|
||||
workspace_join: false,
|
||||
|
||||
@@ -67,7 +67,7 @@ function AnalyticsPage({ params }: Route.ComponentProps) {
|
||||
{workspaceProjectIds && (
|
||||
<>
|
||||
{workspaceProjectIds.length > 0 || loader === "init-loader" ? (
|
||||
<div className="flex h-full overflow-hidden bg-surface-1 ">
|
||||
<div className="flex h-full overflow-hidden ">
|
||||
<Tabs value={selectedTab} onValueChange={handleTabChange} className="w-full h-full">
|
||||
<div className={"flex flex-col w-full h-full"}>
|
||||
<div
|
||||
@@ -75,7 +75,7 @@ function AnalyticsPage({ params }: Route.ComponentProps) {
|
||||
"px-6 py-2 border-b border-subtle flex items-center gap-4 overflow-hidden w-full justify-between"
|
||||
)}
|
||||
>
|
||||
<Tabs.List className={"my-2 overflow-x-auto flex w-fit"}>
|
||||
<Tabs.List background="layer-2" className={"my-2 overflow-x-auto flex w-fit"}>
|
||||
{ANALYTICS_TABS.map((tab) => (
|
||||
<Tabs.Trigger
|
||||
key={tab.key}
|
||||
@@ -83,6 +83,11 @@ function AnalyticsPage({ params }: Route.ComponentProps) {
|
||||
disabled={tab.isDisabled}
|
||||
size="md"
|
||||
className="px-3"
|
||||
onClick={() => {
|
||||
if (!tab.isDisabled) {
|
||||
handleTabChange(tab.key);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</Tabs.Trigger>
|
||||
|
||||
@@ -42,7 +42,7 @@ const WorkspaceMembersSettingsPage = observer(function WorkspaceMembersSettingsP
|
||||
const {
|
||||
workspace: { workspaceMemberIds, inviteMembersToWorkspace, filtersStore },
|
||||
} = useMember();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { currentWorkspace, mutateWorkspaceMembersActivity } = useWorkspace();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// derived values
|
||||
@@ -55,6 +55,7 @@ const WorkspaceMembersSettingsPage = observer(function WorkspaceMembersSettingsP
|
||||
const handleWorkspaceInvite = async (data: IWorkspaceBulkInviteFormData) => {
|
||||
try {
|
||||
await inviteMembersToWorkspace(workspaceSlug, data);
|
||||
void mutateWorkspaceMembersActivity(workspaceSlug);
|
||||
|
||||
setInviteModal(false);
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { SettingsHeading } from "@/components/settings/heading";
|
||||
// hooks
|
||||
import { useUserProfile } from "@/hooks/store/user";
|
||||
|
||||
function ProfileAppearancePage() {
|
||||
const ProfileAppearancePage = observer(() => {
|
||||
const { t } = useTranslation();
|
||||
// hooks
|
||||
const { data: userProfile } = useUserProfile();
|
||||
@@ -34,6 +34,6 @@ function ProfileAppearancePage() {
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default observer(ProfileAppearancePage);
|
||||
export default ProfileAppearancePage;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTheme } from "next-themes";
|
||||
// plane imports
|
||||
@@ -6,9 +6,7 @@ import type { I_THEME_OPTION } from "@plane/constants";
|
||||
import { THEME_OPTIONS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
import type { IUserTheme } from "@plane/types";
|
||||
// components
|
||||
import { applyTheme, unsetCustomCssVariables } from "@plane/utils";
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { CustomThemeSelector } from "@/components/core/theme/custom-theme-selector";
|
||||
@@ -19,46 +17,36 @@ import { ProfileSettingContentWrapper } from "@/components/profile/profile-setti
|
||||
import { useUserProfile } from "@/hooks/store/user";
|
||||
|
||||
function ProfileAppearancePage() {
|
||||
const { t } = useTranslation();
|
||||
const { setTheme } = useTheme();
|
||||
// states
|
||||
const [currentTheme, setCurrentTheme] = useState<I_THEME_OPTION | null>(null);
|
||||
// hooks
|
||||
// store hooks
|
||||
const { data: userProfile, updateUserTheme } = useUserProfile();
|
||||
|
||||
useEffect(() => {
|
||||
if (userProfile?.theme?.theme) {
|
||||
const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile?.theme?.theme);
|
||||
if (userThemeOption) {
|
||||
setCurrentTheme(userThemeOption);
|
||||
}
|
||||
}
|
||||
// theme
|
||||
const { setTheme } = useTheme();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const currentTheme = useMemo(() => {
|
||||
const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile?.theme?.theme);
|
||||
return userThemeOption || null;
|
||||
}, [userProfile?.theme?.theme]);
|
||||
|
||||
const handleThemeChange = (themeOption: I_THEME_OPTION) => {
|
||||
applyThemeChange({ theme: themeOption.value });
|
||||
|
||||
const updateCurrentUserThemePromise = updateUserTheme({ theme: themeOption.value });
|
||||
setPromiseToast(updateCurrentUserThemePromise, {
|
||||
loading: "Updating theme...",
|
||||
success: {
|
||||
title: "Success!",
|
||||
message: () => "Theme updated successfully!",
|
||||
},
|
||||
error: {
|
||||
title: "Error!",
|
||||
message: () => "Failed to Update the theme",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const applyThemeChange = (theme: Partial<IUserTheme>) => {
|
||||
setTheme(theme?.theme || "system");
|
||||
|
||||
if (theme?.theme === "custom" && theme?.palette) {
|
||||
applyTheme(theme?.palette !== ",,,," ? theme?.palette : "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5", false);
|
||||
} else unsetCustomCssVariables();
|
||||
};
|
||||
const handleThemeChange = useCallback(
|
||||
(themeOption: I_THEME_OPTION) => {
|
||||
setTheme(themeOption.value);
|
||||
const updateCurrentUserThemePromise = updateUserTheme({ theme: themeOption.value });
|
||||
setPromiseToast(updateCurrentUserThemePromise, {
|
||||
loading: "Updating theme...",
|
||||
success: {
|
||||
title: "Success!",
|
||||
message: () => "Theme updated successfully.",
|
||||
},
|
||||
error: {
|
||||
title: "Error!",
|
||||
message: () => "Failed to update the theme.",
|
||||
},
|
||||
});
|
||||
},
|
||||
[updateUserTheme]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -75,7 +63,7 @@ function ProfileAppearancePage() {
|
||||
<ThemeSwitch value={currentTheme} onChange={handleThemeChange} />
|
||||
</div>
|
||||
</div>
|
||||
{userProfile?.theme?.theme === "custom" && <CustomThemeSelector applyThemeChange={applyThemeChange} />}
|
||||
{userProfile?.theme?.theme === "custom" && <CustomThemeSelector />}
|
||||
</ProfileSettingContentWrapper>
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<div id="context-menu-portal" />
|
||||
<div id="editor-portal" />
|
||||
<AppProvider>
|
||||
<div className={cn("h-screen w-full overflow-hidden bg-canvas relative flex flex-col", "app-container")}>
|
||||
<div className={cn("h-screen w-full overflow-hidden relative flex flex-col", "app-container")}>
|
||||
<main className="w-full h-full overflow-hidden relative">{children}</main>
|
||||
</div>
|
||||
</AppProvider>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTheme } from "next-themes";
|
||||
// plane imports
|
||||
@@ -6,8 +6,6 @@ import type { I_THEME_OPTION } from "@plane/constants";
|
||||
import { THEME_OPTIONS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
import type { IUserTheme } from "@plane/types";
|
||||
import { applyTheme, unsetCustomCssVariables } from "@plane/utils";
|
||||
// components
|
||||
import { CustomThemeSelector } from "@/components/core/theme/custom-theme-selector";
|
||||
import { ThemeSwitch } from "@/components/core/theme/theme-switch";
|
||||
@@ -23,48 +21,22 @@ export const ThemeSwitcher = observer(function ThemeSwitcher(props: {
|
||||
description: string;
|
||||
};
|
||||
}) {
|
||||
// hooks
|
||||
const { setTheme } = useTheme();
|
||||
// store hooks
|
||||
const { data: userProfile, updateUserTheme } = useUserProfile();
|
||||
|
||||
// states
|
||||
const [currentTheme, setCurrentTheme] = useState<I_THEME_OPTION | null>(null);
|
||||
|
||||
// theme
|
||||
const { setTheme } = useTheme();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
// initialize theme
|
||||
useEffect(() => {
|
||||
if (!userProfile?.theme?.theme) return;
|
||||
|
||||
const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile.theme.theme);
|
||||
|
||||
if (userThemeOption) {
|
||||
setCurrentTheme(userThemeOption);
|
||||
}
|
||||
// derived values
|
||||
const currentTheme = useMemo(() => {
|
||||
const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile?.theme?.theme);
|
||||
return userThemeOption || null;
|
||||
}, [userProfile?.theme?.theme]);
|
||||
|
||||
// handlers
|
||||
const applyThemeChange = useCallback(
|
||||
(theme: Partial<IUserTheme>) => {
|
||||
const themeValue = theme?.theme || "system";
|
||||
setTheme(themeValue);
|
||||
|
||||
if (theme?.theme === "custom" && theme?.palette) {
|
||||
const defaultPalette = "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5";
|
||||
const palette = theme.palette !== ",,,," ? theme.palette : defaultPalette;
|
||||
applyTheme(palette, false);
|
||||
} else {
|
||||
unsetCustomCssVariables();
|
||||
}
|
||||
},
|
||||
[setTheme]
|
||||
);
|
||||
|
||||
const handleThemeChange = useCallback(
|
||||
async (themeOption: I_THEME_OPTION) => {
|
||||
(themeOption: I_THEME_OPTION) => {
|
||||
try {
|
||||
applyThemeChange({ theme: themeOption.value });
|
||||
|
||||
setTheme(themeOption.value);
|
||||
const updatePromise = updateUserTheme({ theme: themeOption.value });
|
||||
setPromiseToast(updatePromise, {
|
||||
loading: "Updating theme...",
|
||||
@@ -81,7 +53,7 @@ export const ThemeSwitcher = observer(function ThemeSwitcher(props: {
|
||||
console.error("Error updating theme:", error);
|
||||
}
|
||||
},
|
||||
[applyThemeChange, updateUserTheme]
|
||||
[updateUserTheme]
|
||||
);
|
||||
|
||||
if (!userProfile) return null;
|
||||
@@ -92,12 +64,12 @@ export const ThemeSwitcher = observer(function ThemeSwitcher(props: {
|
||||
title={t(props.option.title)}
|
||||
description={t(props.option.description)}
|
||||
control={
|
||||
<div className="">
|
||||
<div>
|
||||
<ThemeSwitch value={currentTheme} onChange={handleThemeChange} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{userProfile.theme?.theme === "custom" && <CustomThemeSelector applyThemeChange={applyThemeChange} />}
|
||||
{userProfile.theme?.theme === "custom" && <CustomThemeSelector />}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -21,4 +21,8 @@ export class UserPermissionStore extends BaseUserPermissionStore implements IUse
|
||||
(workspaceSlug: string, projectId?: string): EUserPermissions | undefined =>
|
||||
this.getProjectRole(workspaceSlug, projectId)
|
||||
);
|
||||
|
||||
fetchWorkspaceLevelProjectEntities = (workspaceSlug: string, projectId: string): void => {
|
||||
void this.store.projectRoot.project.fetchProjectDetails(workspaceSlug, projectId);
|
||||
};
|
||||
}
|
||||
|
||||
18
apps/web/ce/store/workspace/index.ts
Normal file
18
apps/web/ce/store/workspace/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// store
|
||||
import { BaseWorkspaceRootStore } from "@/store/workspace";
|
||||
import type { RootStore } from "@/plane-web/store/root.store";
|
||||
|
||||
export class WorkspaceRootStore extends BaseWorkspaceRootStore {
|
||||
constructor(_rootStore: RootStore) {
|
||||
super(_rootStore);
|
||||
}
|
||||
|
||||
// actions
|
||||
/**
|
||||
* Mutate workspace members activity
|
||||
* @param workspaceSlug
|
||||
*/
|
||||
mutateWorkspaceMembersActivity = async (_workspaceSlug: string) => {
|
||||
// No-op in default/CE version
|
||||
};
|
||||
}
|
||||
@@ -65,15 +65,15 @@ const ProjectInsights = observer(function ProjectInsights() {
|
||||
{projectInsightsData && (
|
||||
<Suspense fallback={<ProjectInsightsLoader />}>
|
||||
<RadarChart
|
||||
className="h-[350px] w-full lg:w-3/5"
|
||||
className="h-[350px] w-full lg:w-3/5 text-accent-primary"
|
||||
data={projectInsightsData}
|
||||
dataKey="key"
|
||||
radars={[
|
||||
{
|
||||
key: "count",
|
||||
name: "Count",
|
||||
fill: "rgba(var(--color-primary-300))",
|
||||
stroke: "rgba(var(--color-primary-300))",
|
||||
fill: "var(--color-brand-default)",
|
||||
stroke: "var(--color-brand-default)",
|
||||
fillOpacity: 0.6,
|
||||
dot: {
|
||||
r: 4,
|
||||
|
||||
135
apps/web/core/components/core/theme/config-handler.tsx
Normal file
135
apps/web/core/components/core/theme/config-handler.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import type { UseFormGetValues, UseFormSetValue } from "react-hook-form";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
|
||||
import type { IUserTheme } from "@plane/types";
|
||||
|
||||
type Props = {
|
||||
getValues: UseFormGetValues<IUserTheme>;
|
||||
handleUpdateTheme: (formData: IUserTheme) => Promise<void>;
|
||||
setValue: UseFormSetValue<IUserTheme>;
|
||||
};
|
||||
|
||||
export const CustomThemeConfigHandler = observer(function CustomThemeConfigHandler(props: Props) {
|
||||
const { getValues, handleUpdateTheme, setValue } = props;
|
||||
// refs
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleDownloadConfig = () => {
|
||||
try {
|
||||
const currentValues = getValues();
|
||||
const config = {
|
||||
version: "1.0",
|
||||
themeName: "Custom Theme",
|
||||
primary: currentValues.primary,
|
||||
background: currentValues.background,
|
||||
darkPalette: currentValues.darkPalette,
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(config, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `plane-theme-${Date.now()}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("success"),
|
||||
message: "Theme configuration downloaded successfully.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to download config:", error);
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("error"),
|
||||
message: "Failed to download theme configuration.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadConfig = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const config = JSON.parse(text) as IUserTheme;
|
||||
|
||||
// Validate required fields
|
||||
if (!config.primary || !config.background) {
|
||||
throw new Error("Missing required fields: primary and background");
|
||||
}
|
||||
|
||||
// Validate hex color format
|
||||
const hexPattern = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/;
|
||||
if (!hexPattern.test(config.primary)) {
|
||||
throw new Error("Invalid brand color hex format");
|
||||
}
|
||||
if (!hexPattern.test(config.background)) {
|
||||
throw new Error("Invalid neutral color hex format");
|
||||
}
|
||||
|
||||
// Validate theme mode
|
||||
const themeMode = config.darkPalette ?? false;
|
||||
if (typeof themeMode !== "boolean") {
|
||||
throw new Error("Invalid theme mode. Must be a boolean");
|
||||
}
|
||||
|
||||
// Apply the configuration to form
|
||||
const formData: IUserTheme = {
|
||||
theme: "custom",
|
||||
primary: config.primary,
|
||||
background: config.background,
|
||||
darkPalette: themeMode,
|
||||
};
|
||||
|
||||
// Update form values
|
||||
setValue("primary", formData.primary);
|
||||
setValue("background", formData.background);
|
||||
setValue("darkPalette", formData.darkPalette);
|
||||
setValue("theme", "custom");
|
||||
|
||||
// Apply the theme
|
||||
await handleUpdateTheme(formData);
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("success"),
|
||||
message: "Theme configuration imported successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to upload config:", error);
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("error"),
|
||||
message: error instanceof Error ? error.message : "Failed to import theme configuration",
|
||||
});
|
||||
} finally {
|
||||
// Reset file input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<input ref={fileInputRef} type="file" accept=".json" onChange={handleUploadConfig} className="hidden" />
|
||||
<Button variant="secondary" type="button" onClick={() => fileInputRef.current?.click()}>
|
||||
Import config
|
||||
</Button>
|
||||
<Button variant="secondary" type="button" onClick={handleDownloadConfig}>
|
||||
Download config
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,126 +1,95 @@
|
||||
import { useMemo } from "react";
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// types
|
||||
import { PROFILE_SETTINGS_TRACKER_ELEMENTS, PROFILE_SETTINGS_TRACKER_EVENTS } from "@plane/constants";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IUserTheme } from "@plane/types";
|
||||
// ui
|
||||
import { InputColorPicker } from "@plane/ui";
|
||||
import { InputColorPicker, ToggleSwitch } from "@plane/ui";
|
||||
import { applyCustomTheme } from "@plane/utils";
|
||||
// hooks
|
||||
import { captureElementAndEvent } from "@/helpers/event-tracker.helper";
|
||||
import { useUserProfile } from "@/hooks/store/user";
|
||||
// local imports
|
||||
import { CustomThemeConfigHandler } from "./config-handler";
|
||||
|
||||
type TCustomThemeSelector = {
|
||||
applyThemeChange: (theme: Partial<IUserTheme>) => void;
|
||||
};
|
||||
|
||||
export const CustomThemeSelector = observer(function CustomThemeSelector(props: TCustomThemeSelector) {
|
||||
const { applyThemeChange } = props;
|
||||
// hooks
|
||||
export const CustomThemeSelector = observer(function CustomThemeSelector() {
|
||||
// store hooks
|
||||
const { data: userProfile, updateUserTheme } = useUserProfile();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
control,
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
watch,
|
||||
} = useForm<IUserTheme>({
|
||||
defaultValues: {
|
||||
background: userProfile?.theme?.background !== "" ? userProfile?.theme?.background : "#0d101b",
|
||||
text: userProfile?.theme?.text !== "" ? userProfile?.theme?.text : "#c5c5c5",
|
||||
primary: userProfile?.theme?.primary !== "" ? userProfile?.theme?.primary : "#3f76ff",
|
||||
sidebarBackground:
|
||||
userProfile?.theme?.sidebarBackground !== "" ? userProfile?.theme?.sidebarBackground : "#0d101b",
|
||||
sidebarText: userProfile?.theme?.sidebarText !== "" ? userProfile?.theme?.sidebarText : "#c5c5c5",
|
||||
darkPalette: userProfile?.theme?.darkPalette || false,
|
||||
palette: userProfile?.theme?.palette !== "" ? userProfile?.theme?.palette : "",
|
||||
},
|
||||
});
|
||||
|
||||
const inputRules = useMemo(
|
||||
() => ({
|
||||
minLength: {
|
||||
value: 7,
|
||||
message: t("enter_a_valid_hex_code_of_6_characters"),
|
||||
},
|
||||
maxLength: {
|
||||
value: 7,
|
||||
message: t("enter_a_valid_hex_code_of_6_characters"),
|
||||
},
|
||||
pattern: {
|
||||
value: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,
|
||||
message: t("enter_a_valid_hex_code_of_6_characters"),
|
||||
},
|
||||
}),
|
||||
[t] // Empty dependency array since these rules never change
|
||||
);
|
||||
// Loading state for async palette generation
|
||||
const [isLoadingPalette, setIsLoadingPalette] = useState(false);
|
||||
|
||||
const handleUpdateTheme = async (formData: Partial<IUserTheme>) => {
|
||||
const payload: IUserTheme = {
|
||||
background: formData.background,
|
||||
text: formData.text,
|
||||
primary: formData.primary,
|
||||
sidebarBackground: formData.sidebarBackground,
|
||||
sidebarText: formData.sidebarText,
|
||||
darkPalette: false,
|
||||
palette: `${formData.background},${formData.text},${formData.primary},${formData.sidebarBackground},${formData.sidebarText}`,
|
||||
// Load saved theme from userProfile (fallback to defaults)
|
||||
const getSavedTheme = (): IUserTheme => {
|
||||
if (userProfile?.theme) {
|
||||
const theme = userProfile.theme;
|
||||
if (theme.primary && theme.background && theme.darkPalette !== undefined) {
|
||||
return {
|
||||
theme: "custom",
|
||||
primary: theme.primary,
|
||||
background: theme.background,
|
||||
darkPalette: theme.darkPalette,
|
||||
};
|
||||
}
|
||||
}
|
||||
// Fallback to defaults
|
||||
return {
|
||||
theme: "custom",
|
||||
primary: "#3f76ff",
|
||||
background: "#1a1a1a",
|
||||
darkPalette: false,
|
||||
};
|
||||
applyThemeChange(payload);
|
||||
|
||||
const updateCurrentUserThemePromise = updateUserTheme(payload);
|
||||
setPromiseToast(updateCurrentUserThemePromise, {
|
||||
loading: t("updating_theme"),
|
||||
success: {
|
||||
title: t("success"),
|
||||
message: () => t("theme_updated_successfully"),
|
||||
},
|
||||
error: {
|
||||
title: t("error"),
|
||||
message: () => t("failed_to_update_the_theme"),
|
||||
},
|
||||
});
|
||||
updateCurrentUserThemePromise
|
||||
.then(() => {
|
||||
captureElementAndEvent({
|
||||
element: {
|
||||
elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.THEME_DROPDOWN,
|
||||
},
|
||||
event: {
|
||||
eventName: PROFILE_SETTINGS_TRACKER_EVENTS.theme_updated,
|
||||
payload: {
|
||||
theme: payload.theme,
|
||||
},
|
||||
state: "SUCCESS",
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
captureElementAndEvent({
|
||||
element: {
|
||||
elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.THEME_DROPDOWN,
|
||||
},
|
||||
event: {
|
||||
eventName: PROFILE_SETTINGS_TRACKER_EVENTS.theme_updated,
|
||||
payload: {
|
||||
theme: payload.theme,
|
||||
},
|
||||
state: "ERROR",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
const handleValueChange = (val: string | undefined, onChange: any) => {
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit,
|
||||
getValues,
|
||||
watch,
|
||||
setValue,
|
||||
} = useForm<IUserTheme>({
|
||||
defaultValues: getSavedTheme(),
|
||||
});
|
||||
|
||||
const handleUpdateTheme = async (formData: IUserTheme) => {
|
||||
if (!formData.primary || !formData.background || formData.darkPalette === undefined) return;
|
||||
|
||||
try {
|
||||
setIsLoadingPalette(true);
|
||||
applyCustomTheme(formData.primary, formData.background, formData.darkPalette ? "dark" : "light");
|
||||
// Save to profile endpoint
|
||||
await updateUserTheme({
|
||||
theme: "custom",
|
||||
primary: formData.primary,
|
||||
background: formData.background,
|
||||
darkPalette: formData.darkPalette,
|
||||
});
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("success"),
|
||||
message: t("theme_updated_successfully"),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to apply theme:", error);
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("error"),
|
||||
message: t("failed_to_update_the_theme"),
|
||||
});
|
||||
} finally {
|
||||
setIsLoadingPalette(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleValueChange = (val: string | undefined, onChange: (...args: unknown[]) => void) => {
|
||||
let hex = val;
|
||||
// prepend a hashtag if it doesn't exist
|
||||
if (val && val[0] !== "#") hex = `#${val}`;
|
||||
|
||||
onChange(hex);
|
||||
};
|
||||
|
||||
@@ -128,146 +97,98 @@ export const CustomThemeSelector = observer(function CustomThemeSelector(props:
|
||||
<form onSubmit={handleSubmit(handleUpdateTheme)}>
|
||||
<div className="space-y-5">
|
||||
<h3 className="text-16 font-semibold text-primary">{t("customize_your_theme")}</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-2 md:grid-cols-3">
|
||||
{/* Color Inputs */}
|
||||
<div className="grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-2">
|
||||
{/* Brand Color */}
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<h3 className="text-left text-13 font-medium text-secondary">{t("background_color")}</h3>
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
control={control}
|
||||
name="background"
|
||||
rules={{ ...inputRules, required: t("background_color_is_required") }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<InputColorPicker
|
||||
name="background"
|
||||
value={value}
|
||||
onChange={(val) => handleValueChange(val, onChange)}
|
||||
placeholder="#0d101b"
|
||||
className="w-full placeholder:text-placeholder/60"
|
||||
style={{
|
||||
backgroundColor: watch("background"),
|
||||
color: watch("text"),
|
||||
}}
|
||||
hasError={Boolean(errors?.background)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.background && <p className="mt-1 text-11 text-red-500">{errors.background.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<h3 className="text-left text-13 font-medium text-secondary">{t("text_color")}</h3>
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
control={control}
|
||||
name="text"
|
||||
rules={{ ...inputRules, required: t("text_color_is_required") }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<InputColorPicker
|
||||
name="text"
|
||||
value={value}
|
||||
onChange={(val) => handleValueChange(val, onChange)}
|
||||
placeholder="#c5c5c5"
|
||||
className="w-full placeholder:text-placeholder/60"
|
||||
style={{
|
||||
backgroundColor: watch("text"),
|
||||
color: watch("background"),
|
||||
}}
|
||||
hasError={Boolean(errors?.text)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.text && <p className="mt-1 text-11 text-red-500">{errors.text.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<h3 className="text-left text-13 font-medium text-secondary">{t("primary_color")}</h3>
|
||||
<h3 className="text-left text-13 font-medium text-secondary">Brand color</h3>
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
control={control}
|
||||
name="primary"
|
||||
rules={{ ...inputRules, required: t("primary_color_is_required") }}
|
||||
rules={{
|
||||
required: "Brand color is required",
|
||||
pattern: {
|
||||
value: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,
|
||||
message: "Enter a valid hex code",
|
||||
},
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<InputColorPicker
|
||||
name="primary"
|
||||
value={value}
|
||||
onChange={(val) => handleValueChange(val, onChange)}
|
||||
placeholder="#3f76ff"
|
||||
className="w-full placeholder:text-placeholder/60"
|
||||
className="w-full placeholder:text-placeholder"
|
||||
style={{
|
||||
backgroundColor: watch("primary"),
|
||||
color: watch("text"),
|
||||
backgroundColor: value,
|
||||
color: "#ffffff",
|
||||
}}
|
||||
hasError={Boolean(errors?.primary)}
|
||||
hasError={false}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.primary && <p className="mt-1 text-11 text-red-500">{errors.primary.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Neutral Color */}
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<h3 className="text-left text-13 font-medium text-secondary">{t("sidebar_background_color")}</h3>
|
||||
<h3 className="text-left text-13 font-medium text-secondary">Neutral color</h3>
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
control={control}
|
||||
name="sidebarBackground"
|
||||
rules={{ ...inputRules, required: t("sidebar_background_color_is_required") }}
|
||||
name="background"
|
||||
rules={{
|
||||
required: "Neutral color is required",
|
||||
pattern: {
|
||||
value: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,
|
||||
message: "Enter a valid hex code",
|
||||
},
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<InputColorPicker
|
||||
name="sidebarBackground"
|
||||
name="background"
|
||||
value={value}
|
||||
onChange={(val) => handleValueChange(val, onChange)}
|
||||
placeholder="#0d101b"
|
||||
className="w-full placeholder:text-placeholder/60"
|
||||
placeholder="#1a1a1a"
|
||||
className="w-full placeholder:text-placeholder"
|
||||
style={{
|
||||
backgroundColor: watch("sidebarBackground"),
|
||||
color: watch("sidebarText"),
|
||||
backgroundColor: value,
|
||||
color: "#ffffff",
|
||||
}}
|
||||
hasError={Boolean(errors?.sidebarBackground)}
|
||||
hasError={false}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.sidebarBackground && (
|
||||
<p className="mt-1 text-11 text-red-500">{errors.sidebarBackground.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<h3 className="text-left text-13 font-medium text-secondary">{t("sidebar_text_color")}</h3>
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
control={control}
|
||||
name="sidebarText"
|
||||
rules={{ ...inputRules, required: t("sidebar_text_color_is_required") }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<InputColorPicker
|
||||
name="sidebarText"
|
||||
value={value}
|
||||
onChange={(val) => handleValueChange(val, onChange)}
|
||||
placeholder="#c5c5c5"
|
||||
className="w-full placeholder:text-placeholder/60"
|
||||
style={{
|
||||
backgroundColor: watch("sidebarText"),
|
||||
color: watch("sidebarBackground"),
|
||||
}}
|
||||
hasError={Boolean(errors?.sidebarText)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.sidebarText && <p className="mt-1 text-11 text-red-500">{errors.sidebarText.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button variant="primary" type="submit" loading={isSubmitting}>
|
||||
{isSubmitting ? t("creating_theme") : t("set_theme")}
|
||||
</Button>
|
||||
|
||||
<div className="mt-5 flex flex-col gap-4 sm:flex-row sm:justify-between sm:items-center">
|
||||
{/* Import/Export Section */}
|
||||
<CustomThemeConfigHandler getValues={getValues} handleUpdateTheme={handleUpdateTheme} setValue={setValue} />
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Theme Mode Toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="darkPalette"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<ToggleSwitch value={!!value} onChange={onChange} size="sm" />
|
||||
)}
|
||||
/>
|
||||
<span className="text-12 text-tertiary">{watch("darkPalette") ? "Dark mode" : "Light mode"}</span>
|
||||
</div>
|
||||
{/* Save Theme Button */}
|
||||
<Button variant="primary" size="lg" type="submit" loading={isSubmitting || isLoadingPalette}>
|
||||
{isSubmitting ? t("creating_theme") : isLoadingPalette ? "Generating..." : t("set_theme")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,9 @@ type Props = {
|
||||
|
||||
export function ThemeSwitch(props: Props) {
|
||||
const { value, onChange } = props;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
@@ -48,6 +50,7 @@ export function ThemeSwitch(props: Props) {
|
||||
)
|
||||
}
|
||||
onChange={onChange}
|
||||
placement="bottom-end"
|
||||
input
|
||||
>
|
||||
{THEME_OPTIONS.map((themeOption) => (
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Button } from "@plane/propel/button";
|
||||
import type { IProject } from "@plane/types";
|
||||
// ui
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
|
||||
@@ -24,7 +23,6 @@ export function JoinProjectModal(props: TJoinProjectModalProps) {
|
||||
const [isJoiningLoading, setIsJoiningLoading] = useState(false);
|
||||
// store hooks
|
||||
const { joinProject } = useUserPermissions();
|
||||
const { fetchProjectDetails } = useProject();
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
|
||||
@@ -34,7 +32,6 @@ export function JoinProjectModal(props: TJoinProjectModalProps) {
|
||||
joinProject(workspaceSlug, project.id)
|
||||
.then(() => {
|
||||
router.push(`/${workspaceSlug}/projects/${project.id}/issues`);
|
||||
fetchProjectDetails(workspaceSlug, project.id);
|
||||
handleClose();
|
||||
})
|
||||
.finally(() => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ConfirmWorkspaceMemberRemove } from "@/components/workspace/confirm-wor
|
||||
import { captureClick } from "@/helpers/event-tracker.helper";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
|
||||
type Props = {
|
||||
invitationId: string;
|
||||
@@ -31,6 +32,7 @@ export const WorkspaceInvitationsListItem = observer(function WorkspaceInvitatio
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { allowPermissions, workspaceInfoBySlug } = useUserPermissions();
|
||||
const { mutateWorkspaceMembersActivity } = useWorkspace();
|
||||
const {
|
||||
workspace: { updateMemberInvitation, deleteMemberInvitation, getWorkspaceInvitationDetails },
|
||||
} = useMember();
|
||||
@@ -50,36 +52,36 @@ export const WorkspaceInvitationsListItem = observer(function WorkspaceInvitatio
|
||||
);
|
||||
|
||||
const handleRemoveInvitation = async () => {
|
||||
if (!workspaceSlug || !invitationDetails) return;
|
||||
try {
|
||||
if (!workspaceSlug || !invitationDetails) return;
|
||||
|
||||
await deleteMemberInvitation(workspaceSlug.toString(), invitationDetails.id)
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Invitation removed successfully.",
|
||||
});
|
||||
})
|
||||
.catch((err) =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: err?.error || "Something went wrong. Please try again.",
|
||||
})
|
||||
);
|
||||
await deleteMemberInvitation(workspaceSlug.toString(), invitationDetails.id);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Invitation removed successfully.",
|
||||
});
|
||||
void mutateWorkspaceMembersActivity(workspaceSlug);
|
||||
} catch (err: unknown) {
|
||||
const error = err as { error?: string };
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: error?.error || "Something went wrong. Please try again.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!invitationDetails || !currentWorkspaceMemberInfo) return null;
|
||||
|
||||
const handleCopyText = () => {
|
||||
const handleCopyText = async () => {
|
||||
try {
|
||||
const inviteLink = new URL(invitationDetails.invite_link, window.location.origin).href;
|
||||
copyTextToClipboard(inviteLink).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("common.link_copied"),
|
||||
message: t("entity.link_copied_to_clipboard", { entity: t("common.invite") }),
|
||||
});
|
||||
await copyTextToClipboard(inviteLink);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("common.link_copied"),
|
||||
message: t("entity.link_copied_to_clipboard", { entity: t("common.invite") }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error generating invite link:", error);
|
||||
@@ -89,7 +91,7 @@ export const WorkspaceInvitationsListItem = observer(function WorkspaceInvitatio
|
||||
const MENU_ITEMS: TContextMenuItem[] = [
|
||||
{
|
||||
key: "copy-link",
|
||||
action: handleCopyText,
|
||||
action: () => void handleCopyText(),
|
||||
title: t("common.actions.copy_link"),
|
||||
icon: LinkIcon,
|
||||
shouldRender: !!invitationDetails.invite_link,
|
||||
@@ -157,7 +159,8 @@ export const WorkspaceInvitationsListItem = observer(function WorkspaceInvitatio
|
||||
|
||||
updateMemberInvitation(workspaceSlug.toString(), invitationDetails.id, {
|
||||
role: value,
|
||||
}).catch((error) => {
|
||||
}).catch((err: unknown) => {
|
||||
const error = err as { error?: string };
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
@@ -169,7 +172,11 @@ export const WorkspaceInvitationsListItem = observer(function WorkspaceInvitatio
|
||||
placement="bottom-end"
|
||||
>
|
||||
{Object.keys(ROLE).map((key) => {
|
||||
if (currentWorkspaceRole && currentWorkspaceRole !== 20 && currentWorkspaceRole < parseInt(key))
|
||||
if (
|
||||
currentWorkspaceRole &&
|
||||
Number(currentWorkspaceRole) !== 20 &&
|
||||
Number(currentWorkspaceRole) < parseInt(key)
|
||||
)
|
||||
return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -16,6 +16,7 @@ import { getFileURL } from "@plane/utils";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
// plane web constants
|
||||
|
||||
export interface RowData {
|
||||
@@ -45,7 +46,7 @@ export function NameColumn(props: NameProps) {
|
||||
|
||||
return (
|
||||
<Disclosure>
|
||||
{({}) => (
|
||||
{() => (
|
||||
<div className="relative group">
|
||||
<div className="flex items-center gap-x-4 gap-y-2 w-72 justify-between">
|
||||
<div className="flex items-center gap-x-2 gap-y-2 flex-1">
|
||||
@@ -83,8 +84,16 @@ export function NameColumn(props: NameProps) {
|
||||
buttonClassName="outline-none origin-center rotate-90 size-8 aspect-square flex-shrink-0 grid place-items-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
render={() => (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex items-center gap-x-3 cursor-pointer"
|
||||
onClick={() => setRemoveMemberModal(rowData)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setRemoveMemberModal(rowData);
|
||||
}
|
||||
}}
|
||||
data-ph-element={MEMBER_TRACKER_ELEMENTS.WORKSPACE_MEMBER_TABLE_CONTEXT_MENU}
|
||||
>
|
||||
<Trash2 className="size-3.5 align-middle" /> {id === currentUser?.id ? "Leave " : "Remove "}
|
||||
@@ -112,6 +121,7 @@ export const AccountTypeColumn = observer(function AccountTypeColumn(props: Acco
|
||||
const {
|
||||
workspace: { updateMember },
|
||||
} = useMember();
|
||||
const { mutateWorkspaceMembersActivity } = useWorkspace();
|
||||
const { data: currentUser } = useUser();
|
||||
|
||||
// derived values
|
||||
@@ -139,22 +149,24 @@ export const AccountTypeColumn = observer(function AccountTypeColumn(props: Acco
|
||||
rules={{ required: "Role is required." }}
|
||||
render={({ field: { value } }) => (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
onChange={(value: EUserPermissions) => {
|
||||
value={value as EUserPermissions}
|
||||
onChange={async (value: EUserPermissions) => {
|
||||
if (!workspaceSlug) return;
|
||||
updateMember(workspaceSlug.toString(), rowData.member.id, {
|
||||
role: value as unknown as EUserPermissions, // Cast value to unknown first, then to EUserPermissions
|
||||
}).catch((err) => {
|
||||
console.log(err, "err");
|
||||
const error = err.error;
|
||||
const errorString = Array.isArray(error) ? error[0] : error;
|
||||
try {
|
||||
await updateMember(workspaceSlug.toString(), rowData.member.id, {
|
||||
role: value as unknown as EUserPermissions,
|
||||
});
|
||||
void mutateWorkspaceMembersActivity(workspaceSlug);
|
||||
} catch (err: unknown) {
|
||||
const error = err as { error?: string | string[] };
|
||||
const errorString = Array.isArray(error?.error) ? error.error[0] : error?.error;
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: errorString ?? "An error occurred while updating member role. Please try again.",
|
||||
});
|
||||
});
|
||||
}
|
||||
}}
|
||||
label={
|
||||
<div className="flex ">
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Table } from "@plane/ui";
|
||||
// components
|
||||
import { MembersLayoutLoader } from "@/components/ui/loader/layouts/members-layout-loader";
|
||||
import { ConfirmWorkspaceMemberRemove } from "@/components/workspace/confirm-workspace-member-remove";
|
||||
import type { RowData } from "@/components/workspace/settings/member-columns";
|
||||
// helpers
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
// hooks
|
||||
@@ -34,7 +35,7 @@ export const WorkspaceMembersListItem = observer(function WorkspaceMembersListIt
|
||||
workspace: { removeMemberFromWorkspace },
|
||||
} = useMember();
|
||||
const { leaveWorkspace } = useUserPermissions();
|
||||
const { getWorkspaceRedirectionUrl } = useWorkspace();
|
||||
const { getWorkspaceRedirectionUrl, mutateWorkspaceMembersActivity } = useWorkspace();
|
||||
const { fetchCurrentUserSettings } = useUserSettings();
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
@@ -42,43 +43,48 @@ export const WorkspaceMembersListItem = observer(function WorkspaceMembersListIt
|
||||
const handleLeaveWorkspace = async () => {
|
||||
if (!workspaceSlug || !currentUser) return;
|
||||
|
||||
await leaveWorkspace(workspaceSlug.toString())
|
||||
.then(async () => {
|
||||
await fetchCurrentUserSettings();
|
||||
router.push(getWorkspaceRedirectionUrl());
|
||||
captureSuccess({
|
||||
eventName: MEMBER_TRACKER_EVENTS.workspace.leave,
|
||||
payload: {
|
||||
workspace: workspaceSlug,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((err: any) => {
|
||||
captureError({
|
||||
eventName: MEMBER_TRACKER_EVENTS.workspace.leave,
|
||||
payload: {
|
||||
workspace: workspaceSlug,
|
||||
},
|
||||
error: err,
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: err?.error || t("something_went_wrong_please_try_again"),
|
||||
});
|
||||
try {
|
||||
await leaveWorkspace(workspaceSlug.toString());
|
||||
await fetchCurrentUserSettings();
|
||||
router.push(getWorkspaceRedirectionUrl());
|
||||
captureSuccess({
|
||||
eventName: MEMBER_TRACKER_EVENTS.workspace.leave,
|
||||
payload: {
|
||||
workspace: workspaceSlug,
|
||||
},
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const error = err as { error?: string };
|
||||
const errorForCapture: Error | string = err instanceof Error ? err : String(err);
|
||||
captureError({
|
||||
eventName: MEMBER_TRACKER_EVENTS.workspace.leave,
|
||||
payload: {
|
||||
workspace: workspaceSlug,
|
||||
},
|
||||
error: errorForCapture,
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: error?.error || t("something_went_wrong_please_try_again"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (memberId: string) => {
|
||||
if (!workspaceSlug || !memberId) return;
|
||||
|
||||
await removeMemberFromWorkspace(workspaceSlug.toString(), memberId).catch((err) =>
|
||||
try {
|
||||
await removeMemberFromWorkspace(workspaceSlug.toString(), memberId);
|
||||
void mutateWorkspaceMembersActivity(workspaceSlug);
|
||||
} catch (err: unknown) {
|
||||
const error = err as { error?: string };
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: err?.error || t("something_went_wrong_please_try_again"),
|
||||
})
|
||||
);
|
||||
message: error?.error || t("something_went_wrong_please_try_again"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (memberId: string) => {
|
||||
@@ -109,9 +115,11 @@ export const WorkspaceMembersListItem = observer(function WorkspaceMembersListIt
|
||||
onSubmit={() => handleRemove(removeMemberModal.member.id)}
|
||||
/>
|
||||
)}
|
||||
<Table
|
||||
<Table<RowData>
|
||||
columns={columns ?? []}
|
||||
data={(memberDetails?.filter((member): member is IWorkspaceMember => member !== null) ?? []) as any}
|
||||
data={
|
||||
(memberDetails?.filter((member): member is IWorkspaceMember => member !== null) ?? []) as unknown as RowData[]
|
||||
}
|
||||
keyExtractor={(rowData) => rowData?.member.id ?? ""}
|
||||
tHeadClassName="border-b border-subtle"
|
||||
thClassName="text-left font-medium divide-x-0 text-placeholder"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { IJiraMetadata } from "@plane/types";
|
||||
import type { EUserPermissions, IJiraMetadata } from "@plane/types";
|
||||
|
||||
const paramsToKey = (params: any) => {
|
||||
const {
|
||||
@@ -70,6 +70,9 @@ export const WORKSPACE_INVITATION = (invitationId: string) => `WORKSPACE_INVITAT
|
||||
export const WORKSPACE_MEMBER_ME_INFORMATION = (workspaceSlug: string) =>
|
||||
`WORKSPACE_MEMBER_ME_INFORMATION_${workspaceSlug.toUpperCase()}`;
|
||||
|
||||
export const WORKSPACE_MEMBER_ACTIVITY = (workspaceSlug: string) =>
|
||||
`WORKSPACE_MEMBER_ACTIVITY_${workspaceSlug.toUpperCase()}`;
|
||||
|
||||
export const WORKSPACE_PROJECTS_ROLES_INFORMATION = (workspaceSlug: string) =>
|
||||
`WORKSPACE_PROJECTS_ROLES_INFORMATION_${workspaceSlug.toUpperCase()}`;
|
||||
|
||||
@@ -154,29 +157,41 @@ export const PROJECT_DETAILS = (workspaceSlug: string, projectId: string) =>
|
||||
export const PROJECT_ME_INFORMATION = (workspaceSlug: string, projectId: string) =>
|
||||
`PROJECT_ME_INFORMATION_${projectId.toString().toUpperCase()}`;
|
||||
|
||||
export const PROJECT_LABELS = (workspaceSlug: string, projectId: string) =>
|
||||
`PROJECT_LABELS_${projectId.toString().toUpperCase()}`;
|
||||
export const PROJECT_LABELS = (projectId: string, projectRole: EUserPermissions | undefined) =>
|
||||
`PROJECT_LABELS_${projectId.toString().toUpperCase()}_${projectRole}`;
|
||||
|
||||
export const PROJECT_MEMBERS = (workspaceSlug: string, projectId: string) =>
|
||||
`PROJECT_MEMBERS_${projectId.toString().toUpperCase()}`;
|
||||
export const PROJECT_MEMBERS = (projectId: string, projectRole: EUserPermissions | undefined) =>
|
||||
`PROJECT_MEMBERS_${projectId.toString().toUpperCase()}_${projectRole}`;
|
||||
|
||||
export const PROJECT_STATES = (workspaceSlug: string, projectId: string) =>
|
||||
`PROJECT_STATES_${projectId.toString().toUpperCase()}`;
|
||||
export const PROJECT_STATES = (projectId: string, projectRole: EUserPermissions | undefined) =>
|
||||
`PROJECT_STATES_${projectId.toString().toUpperCase()}_${projectRole}`;
|
||||
|
||||
export const PROJECT_INTAKE_STATE = (workspaceSlug: string, projectId: string) =>
|
||||
`PROJECT_INTAKE_STATE_${projectId.toString().toUpperCase()}`;
|
||||
export const PROJECT_INTAKE_STATE = (projectId: string, projectRole: EUserPermissions | undefined) =>
|
||||
`PROJECT_INTAKE_STATE_${projectId.toString().toUpperCase()}_${projectRole}`;
|
||||
|
||||
export const PROJECT_ESTIMATES = (workspaceSlug: string, projectId: string) =>
|
||||
`PROJECT_ESTIMATES_${projectId.toString().toUpperCase()}`;
|
||||
export const PROJECT_ESTIMATES = (projectId: string, projectRole: EUserPermissions | undefined) =>
|
||||
`PROJECT_ESTIMATES_${projectId.toString().toUpperCase()}_${projectRole}`;
|
||||
|
||||
export const PROJECT_ALL_CYCLES = (workspaceSlug: string, projectId: string) =>
|
||||
`PROJECT_ALL_CYCLES_${projectId.toString().toUpperCase()}`;
|
||||
export const PROJECT_ALL_CYCLES = (projectId: string, projectRole: EUserPermissions | undefined) =>
|
||||
`PROJECT_ALL_CYCLES_${projectId.toString().toUpperCase()}_${projectRole}`;
|
||||
|
||||
export const PROJECT_MODULES = (workspaceSlug: string, projectId: string) =>
|
||||
`PROJECT_MODULES_${projectId.toString().toUpperCase()}`;
|
||||
export const PROJECT_MODULES = (projectId: string, projectRole: EUserPermissions | undefined) =>
|
||||
`PROJECT_MODULES_${projectId.toString().toUpperCase()}_${projectRole}`;
|
||||
|
||||
export const PROJECT_VIEWS = (workspaceSlug: string, projectId: string) =>
|
||||
`PROJECT_VIEWS_${projectId.toString().toUpperCase()}`;
|
||||
export const PROJECT_VIEWS = (projectId: string, projectRole: EUserPermissions | undefined) =>
|
||||
`PROJECT_VIEWS_${projectId.toString().toUpperCase()}_${projectRole}`;
|
||||
|
||||
export const PROJECT_MEMBER_PREFERENCES = (workspaceSlug: string, projectId: string) =>
|
||||
`PROJECT_MEMBER_PREFERENCES_${projectId.toString().toUpperCase()}`;
|
||||
export const PROJECT_MEMBER_PREFERENCES = (projectId: string, projectRole: EUserPermissions | undefined) =>
|
||||
`PROJECT_MEMBER_PREFERENCES_${projectId.toString().toUpperCase()}_${projectRole}`;
|
||||
|
||||
export const PROJECT_WORKFLOWS = (projectId: string, projectRole: EUserPermissions | undefined) =>
|
||||
`PROJECT_WORKFLOWS_${projectId.toString().toUpperCase()}_${projectRole}`;
|
||||
|
||||
export const EPICS_PROPERTIES_AND_OPTIONS = (projectId: string, projectRole: EUserPermissions | undefined) =>
|
||||
`EPICS_PROPERTIES_AND_OPTIONS_${projectId.toString().toUpperCase()}_${projectRole}`;
|
||||
|
||||
export const WORK_ITEM_TYPES_PROPERTIES_AND_OPTIONS = (projectId: string, projectRole: EUserPermissions | undefined) =>
|
||||
`WORK_ITEM_TYPES_PROPERTIES_AND_OPTIONS_${projectId.toString().toUpperCase()}_${projectRole}`;
|
||||
|
||||
export const PROJECT_MILESTONES = (projectId: string, projectRole: EUserPermissions | undefined) =>
|
||||
`PROJECT_MILESTONES_${projectId.toString().toUpperCase()}_${projectRole}`;
|
||||
|
||||
@@ -44,7 +44,7 @@ export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IP
|
||||
// states
|
||||
const [isJoiningProject, setIsJoiningProject] = useState(false);
|
||||
// store hooks
|
||||
const { fetchUserProjectInfo, allowPermissions } = useUserPermissions();
|
||||
const { fetchUserProjectInfo, allowPermissions, getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
|
||||
const { fetchProjectDetails } = useProject();
|
||||
const { joinProject } = useUserPermissions();
|
||||
const { fetchAllCycles } = useCycle();
|
||||
@@ -65,8 +65,8 @@ export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IP
|
||||
workspaceSlug,
|
||||
projectId
|
||||
);
|
||||
const currentProjectRole = getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, projectId);
|
||||
const isWorkspaceAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE, workspaceSlug);
|
||||
|
||||
// Initialize module timeline chart
|
||||
useEffect(() => {
|
||||
initGantt();
|
||||
@@ -82,50 +82,50 @@ export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IP
|
||||
useSWR(PROJECT_ME_INFORMATION(workspaceSlug, projectId), () => fetchUserProjectInfo(workspaceSlug, projectId));
|
||||
// fetching project member preferences
|
||||
useSWR(
|
||||
currentUserData?.id ? PROJECT_MEMBER_PREFERENCES(workspaceSlug, projectId) : null,
|
||||
currentUserData?.id ? PROJECT_MEMBER_PREFERENCES(projectId, currentProjectRole) : null,
|
||||
currentUserData?.id ? () => fetchProjectMemberPreferences(workspaceSlug, projectId, currentUserData.id) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
// fetching project labels
|
||||
useSWR(PROJECT_LABELS(workspaceSlug, projectId), () => fetchProjectLabels(workspaceSlug, projectId), {
|
||||
useSWR(PROJECT_LABELS(projectId, currentProjectRole), () => fetchProjectLabels(workspaceSlug, projectId), {
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
// fetching project members
|
||||
useSWR(PROJECT_MEMBERS(workspaceSlug, projectId), () => fetchProjectMembers(workspaceSlug, projectId), {
|
||||
useSWR(PROJECT_MEMBERS(projectId, currentProjectRole), () => fetchProjectMembers(workspaceSlug, projectId), {
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
// fetching project states
|
||||
useSWR(PROJECT_STATES(workspaceSlug, projectId), () => fetchProjectStates(workspaceSlug, projectId), {
|
||||
useSWR(PROJECT_STATES(projectId, currentProjectRole), () => fetchProjectStates(workspaceSlug, projectId), {
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
// fetching project intake state
|
||||
useSWR(PROJECT_INTAKE_STATE(workspaceSlug, projectId), () => fetchProjectIntakeState(workspaceSlug, projectId), {
|
||||
useSWR(PROJECT_INTAKE_STATE(projectId, currentProjectRole), () => fetchProjectIntakeState(workspaceSlug, projectId), {
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
// fetching project estimates
|
||||
useSWR(PROJECT_ESTIMATES(workspaceSlug, projectId), () => getProjectEstimates(workspaceSlug, projectId), {
|
||||
useSWR(PROJECT_ESTIMATES(projectId, currentProjectRole), () => getProjectEstimates(workspaceSlug, projectId), {
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
// fetching project cycles
|
||||
useSWR(PROJECT_ALL_CYCLES(workspaceSlug, projectId), () => fetchAllCycles(workspaceSlug, projectId), {
|
||||
useSWR(PROJECT_ALL_CYCLES(projectId, currentProjectRole), () => fetchAllCycles(workspaceSlug, projectId), {
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
// fetching project modules
|
||||
useSWR(
|
||||
PROJECT_MODULES(workspaceSlug, projectId),
|
||||
PROJECT_MODULES(projectId, currentProjectRole),
|
||||
async () => {
|
||||
await Promise.all([fetchModulesSlim(workspaceSlug, projectId), fetchModules(workspaceSlug, projectId)]);
|
||||
},
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
// fetching project views
|
||||
useSWR(PROJECT_VIEWS(workspaceSlug, projectId), () => fetchViews(workspaceSlug, projectId), {
|
||||
useSWR(PROJECT_VIEWS(projectId, currentProjectRole), () => fetchViews(workspaceSlug, projectId), {
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
@@ -133,9 +133,7 @@ export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IP
|
||||
// handle join project
|
||||
const handleJoinProject = () => {
|
||||
setIsJoiningProject(true);
|
||||
joinProject(workspaceSlug, projectId)
|
||||
.then(() => fetchProjectDetails(workspaceSlug, projectId))
|
||||
.finally(() => setIsJoiningProject(false));
|
||||
joinProject(workspaceSlug, projectId).finally(() => setIsJoiningProject(false));
|
||||
};
|
||||
|
||||
const isProjectLoading = (isParentLoading || isProjectDetailsLoading) && !projectDetailsError;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTheme } from "next-themes";
|
||||
import type { TLanguage } from "@plane/i18n";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// helpers
|
||||
import { applyTheme, unsetCustomCssVariables } from "@plane/utils";
|
||||
import { applyCustomTheme, clearCustomTheme } from "@plane/utils";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useRouterParams } from "@/hooks/store/use-router-params";
|
||||
@@ -16,7 +16,7 @@ type TStoreWrapper = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const StoreWrapper = observer(function StoreWrapper(props: TStoreWrapper) {
|
||||
function StoreWrapper(props: TStoreWrapper) {
|
||||
const { children } = props;
|
||||
// theme
|
||||
const { setTheme } = useTheme();
|
||||
@@ -38,22 +38,25 @@ const StoreWrapper = observer(function StoreWrapper(props: TStoreWrapper) {
|
||||
}, [sidebarCollapsed, setTheme, toggleSidebar]);
|
||||
|
||||
/**
|
||||
* Setting up the theme of the user by fetching it from local storage
|
||||
* Setting up the theme of the user by fetching it from profile
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!userProfile?.theme?.theme) return;
|
||||
const currentTheme = userProfile?.theme?.theme || "system";
|
||||
const currentThemePalette = userProfile?.theme?.palette;
|
||||
const theme = userProfile?.theme;
|
||||
|
||||
if (currentTheme) {
|
||||
setTheme(currentTheme);
|
||||
if (currentTheme === "custom" && currentThemePalette) {
|
||||
applyTheme(
|
||||
currentThemePalette !== ",,,," ? currentThemePalette : "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5",
|
||||
false
|
||||
);
|
||||
} else unsetCustomCssVariables();
|
||||
if (currentTheme === "custom") {
|
||||
// New 2-color palette system
|
||||
if (theme.primary && theme.background && theme.darkPalette !== undefined) {
|
||||
applyCustomTheme(theme.primary, theme.background, theme.darkPalette ? "dark" : "light");
|
||||
}
|
||||
} else {
|
||||
clearCustomTheme();
|
||||
}
|
||||
}
|
||||
}, [userProfile?.theme?.theme, userProfile?.theme?.palette, setTheme]);
|
||||
}, [userProfile?.theme, setTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userProfile?.language) return;
|
||||
@@ -66,6 +69,6 @@ const StoreWrapper = observer(function StoreWrapper(props: TStoreWrapper) {
|
||||
}, [params, setQuery]);
|
||||
|
||||
return <>{children}</>;
|
||||
});
|
||||
}
|
||||
|
||||
export default StoreWrapper;
|
||||
export default observer(StoreWrapper);
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { IPowerKStore } from "@/plane-web/store/power-k.store";
|
||||
import type { RootStore } from "@/plane-web/store/root.store";
|
||||
import type { IStateStore } from "@/plane-web/store/state.store";
|
||||
import { StateStore } from "@/plane-web/store/state.store";
|
||||
import { WorkspaceRootStore } from "@/plane-web/store/workspace";
|
||||
// stores
|
||||
import type { ICycleStore } from "./cycle.store";
|
||||
import { CycleStore } from "./cycle.store";
|
||||
@@ -61,7 +62,6 @@ import { ThemeStore } from "./theme.store";
|
||||
import type { IUserStore } from "./user";
|
||||
import { UserStore } from "./user";
|
||||
import type { IWorkspaceRootStore } from "./workspace";
|
||||
import { WorkspaceRootStore } from "./workspace";
|
||||
|
||||
enableStaticRendering(typeof window === "undefined");
|
||||
|
||||
@@ -102,7 +102,7 @@ export class CoreRootStore {
|
||||
this.instance = new InstanceStore();
|
||||
this.user = new UserStore(this as unknown as RootStore);
|
||||
this.theme = new ThemeStore();
|
||||
this.workspaceRoot = new WorkspaceRootStore(this);
|
||||
this.workspaceRoot = new WorkspaceRootStore(this as unknown as RootStore);
|
||||
this.projectRoot = new ProjectRootStore(this);
|
||||
this.memberRoot = new MemberRootStore(this as unknown as RootStore);
|
||||
this.cycle = new CycleStore(this);
|
||||
@@ -136,7 +136,7 @@ export class CoreRootStore {
|
||||
this.commandPalette = new CommandPaletteStore();
|
||||
this.instance = new InstanceStore();
|
||||
this.user = new UserStore(this as unknown as RootStore);
|
||||
this.workspaceRoot = new WorkspaceRootStore(this);
|
||||
this.workspaceRoot = new WorkspaceRootStore(this as unknown as RootStore);
|
||||
this.projectRoot = new ProjectRootStore(this);
|
||||
this.memberRoot = new MemberRootStore(this as unknown as RootStore);
|
||||
this.cycle = new CycleStore(this);
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface IBaseUserPermissionStore {
|
||||
workspaceSlug: string,
|
||||
projectId?: string
|
||||
) => EUserPermissions | undefined;
|
||||
fetchWorkspaceLevelProjectEntities: (workspaceSlug: string, projectId: string) => void;
|
||||
allowPermissions: (
|
||||
allowPermissions: ETempUserRole[],
|
||||
level: TUserPermissionsLevel,
|
||||
@@ -148,6 +149,15 @@ export abstract class BaseUserPermissionStore implements IBaseUserPermissionStor
|
||||
projectId?: string
|
||||
) => EUserPermissions | undefined;
|
||||
|
||||
/**
|
||||
* @description Fetches project-level entities that are not automatically loaded by the project wrapper.
|
||||
* This is used when joining a project to ensure all necessary workspace-level project data is available.
|
||||
* @param { string } workspaceSlug
|
||||
* @param { string } projectId
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
abstract fetchWorkspaceLevelProjectEntities: (workspaceSlug: string, projectId: string) => void;
|
||||
|
||||
/**
|
||||
* @description Returns whether the user has the permission to access a page
|
||||
* @param { string } page
|
||||
@@ -309,6 +319,7 @@ export abstract class BaseUserPermissionStore implements IBaseUserPermissionStor
|
||||
runInAction(() => {
|
||||
set(this.workspaceProjectsPermissions, [workspaceSlug, projectId], projectMemberRole);
|
||||
});
|
||||
void this.fetchWorkspaceLevelProjectEntities(workspaceSlug, projectId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error user joining the project", error);
|
||||
|
||||
@@ -36,13 +36,9 @@ export class ProfileStore implements IUserProfileStore {
|
||||
last_workspace_id: undefined,
|
||||
theme: {
|
||||
theme: undefined,
|
||||
text: undefined,
|
||||
palette: undefined,
|
||||
primary: undefined,
|
||||
background: undefined,
|
||||
darkPalette: undefined,
|
||||
sidebarText: undefined,
|
||||
sidebarBackground: undefined,
|
||||
darkPalette: false,
|
||||
},
|
||||
onboarding_step: {
|
||||
workspace_join: false,
|
||||
@@ -219,12 +215,14 @@ export class ProfileStore implements IUserProfileStore {
|
||||
const currentProfileTheme = cloneDeep(this.data.theme);
|
||||
try {
|
||||
runInAction(() => {
|
||||
Object.keys(data).forEach((key: string) => {
|
||||
const userKey: keyof IUserTheme = key as keyof IUserTheme;
|
||||
if (this.data.theme) set(this.data.theme, userKey, data[userKey]);
|
||||
Object.keys(data).forEach((key) => {
|
||||
const dataKey = key as keyof IUserTheme;
|
||||
if (this.data.theme) set(this.data.theme, dataKey, data[dataKey]);
|
||||
});
|
||||
});
|
||||
const userProfile = await this.userService.updateCurrentUserProfile({ theme: this.data.theme });
|
||||
const userProfile = await this.userService.updateCurrentUserProfile({
|
||||
theme: this.data.theme,
|
||||
});
|
||||
return userProfile;
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
|
||||
@@ -45,13 +45,14 @@ export interface IWorkspaceRootStore {
|
||||
data: Array<{ key: string; is_pinned: boolean; sort_order: number }>
|
||||
) => Promise<void>;
|
||||
getNavigationPreferences: (workspaceSlug: string) => IWorkspaceSidebarNavigation | undefined;
|
||||
mutateWorkspaceMembersActivity: (workspaceSlug: string) => Promise<void>;
|
||||
// sub-stores
|
||||
webhook: IWebhookStore;
|
||||
apiToken: IApiTokenStore;
|
||||
home: IHomeStore;
|
||||
}
|
||||
|
||||
export class WorkspaceRootStore implements IWorkspaceRootStore {
|
||||
export abstract class BaseWorkspaceRootStore implements IWorkspaceRootStore {
|
||||
loader: boolean = false;
|
||||
// observables
|
||||
workspaces: Record<string, IWorkspace> = {};
|
||||
@@ -205,7 +206,7 @@ export class WorkspaceRootStore implements IWorkspaceRootStore {
|
||||
* @param {string} workspaceSlug
|
||||
* @param {string} logoURL
|
||||
*/
|
||||
updateWorkspaceLogo = async (workspaceSlug: string, logoURL: string) => {
|
||||
updateWorkspaceLogo = (workspaceSlug: string, logoURL: string) => {
|
||||
const workspaceId = this.getWorkspaceBySlug(workspaceSlug)?.id;
|
||||
if (!workspaceId) {
|
||||
throw new Error("Workspace not found");
|
||||
@@ -219,15 +220,19 @@ export class WorkspaceRootStore implements IWorkspaceRootStore {
|
||||
* delete workspace using the workspace slug
|
||||
* @param workspaceSlug
|
||||
*/
|
||||
deleteWorkspace = async (workspaceSlug: string) =>
|
||||
await this.workspaceService.deleteWorkspace(workspaceSlug).then(() => {
|
||||
deleteWorkspace = async (workspaceSlug: string) => {
|
||||
try {
|
||||
await this.workspaceService.deleteWorkspace(workspaceSlug);
|
||||
const updatedWorkspacesList = this.workspaces;
|
||||
const workspaceId = this.getWorkspaceBySlug(workspaceSlug)?.id;
|
||||
delete updatedWorkspacesList[`${workspaceId}`];
|
||||
runInAction(() => {
|
||||
this.workspaces = updatedWorkspacesList;
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to delete workspace:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSidebarNavigationPreferences = async (workspaceSlug: string) => {
|
||||
try {
|
||||
@@ -309,4 +314,10 @@ export class WorkspaceRootStore implements IWorkspaceRootStore {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Mutate workspace members activity
|
||||
* @param workspaceSlug
|
||||
*/
|
||||
abstract mutateWorkspaceMembersActivity(workspaceSlug: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ const BAR_TOP_BORDER_RADIUS = 4; // Border radius for the top of bars
|
||||
const BAR_BOTTOM_BORDER_RADIUS = 4; // Border radius for the bottom of bars
|
||||
const DEFAULT_LOLLIPOP_LINE_WIDTH = 2; // Width of lollipop stick
|
||||
const DEFAULT_LOLLIPOP_CIRCLE_RADIUS = 8; // Radius of lollipop circle
|
||||
const DEFAULT_BAR_FILL_COLOR = "#000000"; // Default color when fill is a function - black
|
||||
|
||||
// Types
|
||||
interface TShapeProps {
|
||||
@@ -66,7 +67,7 @@ function PercentageText({
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<text x={x} y={y} textAnchor="middle" className={cn("text-11 font-medium", className)} fill="currentColor">
|
||||
<text x={x} y={y} textAnchor="middle" className={cn("text-xs font-medium", className)} fill="currentColor">
|
||||
{percentage}%
|
||||
</text>
|
||||
);
|
||||
@@ -109,9 +110,12 @@ const CustomBar = React.memo(function CustomBar(props: TBarProps) {
|
||||
<g>
|
||||
<path
|
||||
d={getBarPath(x, y, width, height, topBorderRadius, bottomBorderRadius)}
|
||||
className="transition-opacity duration-200"
|
||||
fill={fill}
|
||||
opacity={opacity}
|
||||
style={{
|
||||
transition: "opacity 200ms",
|
||||
fill: fill,
|
||||
}}
|
||||
/>
|
||||
{showText && (
|
||||
<PercentageText x={x + width / 2} y={textY} percentage={currentBarPercentage} className={textClassName} />
|
||||
@@ -172,11 +176,13 @@ const createShapeVariant =
|
||||
);
|
||||
};
|
||||
|
||||
export { DEFAULT_BAR_FILL_COLOR };
|
||||
|
||||
export const barShapeVariants: Record<
|
||||
TBarChartShapeVariant,
|
||||
(props: TShapeProps, bar: TBarItem<string>, stackKeys: string[]) => React.ReactNode
|
||||
> = {
|
||||
bar: createShapeVariant(CustomBar), // Standard bar with rounded-sm corners
|
||||
bar: createShapeVariant(CustomBar), // Standard bar with rounded corners
|
||||
lollipop: createShapeVariant(CustomBarLollipop), // Line with circle at top
|
||||
"lollipop-dotted": createShapeVariant(CustomBarLollipop, { dotted: true }), // Dotted line lollipop variant
|
||||
};
|
||||
|
||||
@@ -17,9 +17,7 @@ import type { TBarChartProps } from "@plane/types";
|
||||
import { getLegendProps } from "../components/legend";
|
||||
import { CustomXAxisTick, CustomYAxisTick } from "../components/tick";
|
||||
import { CustomTooltip } from "../components/tooltip";
|
||||
import { barShapeVariants } from "./bar";
|
||||
|
||||
const DEFAULT_BAR_FILL_COLOR = "#000000";
|
||||
import { barShapeVariants, DEFAULT_BAR_FILL_COLOR } from "./bar";
|
||||
|
||||
export const BarChart = React.memo(function BarChart<K extends string, T extends string>(props: TBarChartProps<K, T>) {
|
||||
const {
|
||||
@@ -129,7 +127,7 @@ export const BarChart = React.memo(function BarChart<K extends string, T extends
|
||||
barSize={barSize}
|
||||
className="recharts-wrapper"
|
||||
>
|
||||
<CartesianGrid stroke="--alpha(var(--border-color-subtle) / 80%)" vertical={false} />
|
||||
<CartesianGrid stroke="rgba(var(--color-border-100), 0.8)" vertical={false} />
|
||||
<XAxis
|
||||
dataKey={xAxis.key}
|
||||
tick={(props) => {
|
||||
@@ -176,8 +174,8 @@ export const BarChart = React.memo(function BarChart<K extends string, T extends
|
||||
{showTooltip && (
|
||||
<Tooltip
|
||||
cursor={{
|
||||
fill: "currentColor",
|
||||
className: "text-custom-background-90/80 cursor-pointer",
|
||||
fill: "var(--color-alpha-black-300)",
|
||||
className: "bg-layer-1 cursor-pointer",
|
||||
}}
|
||||
wrapperStyle={{
|
||||
pointerEvents: "auto",
|
||||
|
||||
@@ -77,7 +77,7 @@ export const PieChart = React.memo(function PieChart<K extends string, T extends
|
||||
showLabel
|
||||
? ({ payload, ...props }) => (
|
||||
<text
|
||||
className="text-13 font-medium transition-opacity duration-200"
|
||||
className="text-sm font-medium transition-opacity duration-200"
|
||||
cx={props.cx}
|
||||
cy={props.cy}
|
||||
x={props.x}
|
||||
@@ -123,7 +123,7 @@ export const PieChart = React.memo(function PieChart<K extends string, T extends
|
||||
<Tooltip
|
||||
cursor={{
|
||||
fill: "currentColor",
|
||||
className: "text-custom-background-90/80 cursor-pointer",
|
||||
className: "bg-layer-1-hover cursor-pointer",
|
||||
}}
|
||||
wrapperStyle={{
|
||||
pointerEvents: "none",
|
||||
|
||||
@@ -17,7 +17,7 @@ export const TreeMapChart = React.memo(function TreeMapChart(props: TreeMapChart
|
||||
nameKey="name"
|
||||
dataKey="value"
|
||||
stroke="currentColor"
|
||||
className="text-custom-background-100 bg-surface-1"
|
||||
className="bg-layer-1 cursor-pointer"
|
||||
content={<CustomTreeMapContent />}
|
||||
animationEasing="ease-out"
|
||||
isUpdateAnimationActive={isAnimationActive}
|
||||
@@ -29,7 +29,7 @@ export const TreeMapChart = React.memo(function TreeMapChart(props: TreeMapChart
|
||||
content={({ active, payload }) => <TreeMapTooltip active={active} payload={payload} />}
|
||||
cursor={{
|
||||
fill: "currentColor",
|
||||
className: "text-custom-background-90/80 cursor-pointer",
|
||||
className: "bg-layer-1 cursor-pointer",
|
||||
}}
|
||||
wrapperStyle={{
|
||||
pointerEvents: "auto",
|
||||
|
||||
@@ -2,11 +2,15 @@ import * as React from "react";
|
||||
import { Tabs as TabsPrimitive } from "@base-ui-components/react/tabs";
|
||||
import { cn } from "../utils/classname";
|
||||
|
||||
type BackgroundVariant = "layer-1" | "layer-2" | "layer-3" | "layer-transparent";
|
||||
|
||||
type TabsCompound = React.ForwardRefExoticComponent<
|
||||
React.ComponentProps<typeof TabsPrimitive.Root> & React.RefAttributes<React.ElementRef<typeof TabsPrimitive.Root>>
|
||||
> & {
|
||||
List: React.ForwardRefExoticComponent<
|
||||
React.ComponentProps<typeof TabsPrimitive.List> & React.RefAttributes<React.ElementRef<typeof TabsPrimitive.List>>
|
||||
React.ComponentProps<typeof TabsPrimitive.List> & {
|
||||
background?: BackgroundVariant;
|
||||
} & React.RefAttributes<React.ElementRef<typeof TabsPrimitive.List>>
|
||||
>;
|
||||
Trigger: React.ForwardRefExoticComponent<
|
||||
React.ComponentProps<typeof TabsPrimitive.Tab> & { size?: "sm" | "md" | "lg" } & React.RefAttributes<
|
||||
@@ -34,14 +38,26 @@ const TabsRoot = React.forwardRef(function TabsRoot(
|
||||
});
|
||||
|
||||
const TabsList = React.forwardRef(function TabsList(
|
||||
{ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>,
|
||||
{
|
||||
className,
|
||||
background = "layer-1",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> & {
|
||||
background?: BackgroundVariant;
|
||||
},
|
||||
ref: React.ForwardedRef<React.ElementRef<typeof TabsPrimitive.List>>
|
||||
) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-1.5 rounded-md text-13 p-0.5 bg-layer-1/60 relative overflow-auto",
|
||||
"flex w-full items-center justify-between gap-1.5 rounded-md text-13 p-0.5 relative overflow-auto",
|
||||
{
|
||||
"bg-layer-1": background === "layer-1",
|
||||
"bg-layer-2": background === "layer-2",
|
||||
"bg-layer-3": background === "layer-3",
|
||||
"bg-layer-transparent": background === "layer-transparent",
|
||||
},
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
}
|
||||
|
||||
body {
|
||||
@apply font-body text-primary;
|
||||
@apply font-body bg-canvas text-primary;
|
||||
}
|
||||
|
||||
/* emoji icon picker */
|
||||
|
||||
@@ -59,14 +59,10 @@ export type TUserProfile = {
|
||||
role: string | undefined;
|
||||
last_workspace_id: string | undefined;
|
||||
theme: {
|
||||
text: string | undefined;
|
||||
theme: string | undefined;
|
||||
palette: string | undefined;
|
||||
primary: string | undefined;
|
||||
background: string | undefined;
|
||||
darkPalette: boolean | undefined;
|
||||
sidebarText: string | undefined;
|
||||
sidebarBackground: string | undefined;
|
||||
};
|
||||
onboarding_step: TOnboardingSteps;
|
||||
is_onboarded: boolean;
|
||||
@@ -101,14 +97,10 @@ export interface IUserSettings {
|
||||
}
|
||||
|
||||
export interface IUserTheme {
|
||||
text: string | undefined;
|
||||
theme: string | undefined;
|
||||
palette: string | undefined;
|
||||
primary: string | undefined;
|
||||
background: string | undefined;
|
||||
darkPalette: boolean | undefined;
|
||||
sidebarText: string | undefined;
|
||||
sidebarBackground: string | undefined;
|
||||
theme: string | undefined; // 'light', 'dark', 'custom', etc.
|
||||
primary?: string | undefined;
|
||||
background?: string | undefined;
|
||||
darkPalette?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface IUserMemberLite extends IUserLite {
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"dependencies": {
|
||||
"@plane/constants": "workspace:*",
|
||||
"@plane/types": "workspace:*",
|
||||
"chroma-js": "^3.2.0",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"hast": "^1.0.0",
|
||||
@@ -44,6 +45,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/chroma-js": "^3.1.2",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/lodash-es": "catalog:",
|
||||
"@types/mdast": "^4.0.4",
|
||||
|
||||
@@ -28,6 +28,7 @@ export * from "./string";
|
||||
export * from "./subscription";
|
||||
export * from "./tab-indices";
|
||||
export * from "./theme";
|
||||
export { resolveGeneralTheme } from "./theme-legacy";
|
||||
export * from "./url";
|
||||
export * from "./work-item-filters";
|
||||
export * from "./work-item";
|
||||
|
||||
21
packages/utils/src/theme-legacy.ts
Normal file
21
packages/utils/src/theme-legacy.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Legacy Theme System
|
||||
*
|
||||
* This file contains the old 5-color theme system for backward compatibility.
|
||||
*
|
||||
* @deprecated Most functions in this file are deprecated
|
||||
* New code should use the OKLCH-based theme system from ./theme/ instead
|
||||
*
|
||||
* Functions:
|
||||
* - applyTheme: OLD 5-color theme system (background, text, primary, sidebarBg, sidebarText)
|
||||
* - unsetCustomCssVariables: Clears both old AND new theme variables (updated for OKLCH)
|
||||
* - resolveGeneralTheme: Utility to resolve theme mode (still useful)
|
||||
* - migrateLegacyTheme: Converts old 5-color theme to new 2-color system
|
||||
*
|
||||
* For new implementations:
|
||||
* - Use: import { applyCustomTheme, clearCustomTheme } from '@plane/utils/theme'
|
||||
* - See: packages/utils/src/theme/theme-application.ts
|
||||
*/
|
||||
|
||||
export const resolveGeneralTheme = (resolvedTheme: string | undefined) =>
|
||||
resolvedTheme?.includes("light") ? "light" : resolvedTheme?.includes("dark") ? "dark" : "system";
|
||||
@@ -1,125 +0,0 @@
|
||||
// local imports
|
||||
import type { TRgb } from "./color";
|
||||
import { hexToRgb } from "./color";
|
||||
|
||||
type TShades = {
|
||||
10: TRgb;
|
||||
20: TRgb;
|
||||
30: TRgb;
|
||||
40: TRgb;
|
||||
50: TRgb;
|
||||
60: TRgb;
|
||||
70: TRgb;
|
||||
80: TRgb;
|
||||
90: TRgb;
|
||||
100: TRgb;
|
||||
200: TRgb;
|
||||
300: TRgb;
|
||||
400: TRgb;
|
||||
500: TRgb;
|
||||
600: TRgb;
|
||||
700: TRgb;
|
||||
800: TRgb;
|
||||
900: TRgb;
|
||||
};
|
||||
|
||||
const calculateShades = (hexValue: string): TShades => {
|
||||
const shades: Partial<TShades> = {};
|
||||
const { r, g, b } = hexToRgb(hexValue);
|
||||
|
||||
const convertHexToSpecificShade = (shade: number): TRgb => {
|
||||
if (shade <= 100) {
|
||||
const decimalValue = (100 - shade) / 100;
|
||||
|
||||
const newR = Math.floor(r + (255 - r) * decimalValue);
|
||||
const newG = Math.floor(g + (255 - g) * decimalValue);
|
||||
const newB = Math.floor(b + (255 - b) * decimalValue);
|
||||
|
||||
return {
|
||||
r: newR,
|
||||
g: newG,
|
||||
b: newB,
|
||||
};
|
||||
} else {
|
||||
const decimalValue = 1 - Math.ceil((shade - 100) / 100) / 10;
|
||||
|
||||
const newR = Math.ceil(r * decimalValue);
|
||||
const newG = Math.ceil(g * decimalValue);
|
||||
const newB = Math.ceil(b * decimalValue);
|
||||
|
||||
return {
|
||||
r: newR,
|
||||
g: newG,
|
||||
b: newB,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 10; i <= 900; i >= 100 ? (i += 100) : (i += 10))
|
||||
shades[i as keyof TShades] = convertHexToSpecificShade(i);
|
||||
|
||||
return shades as TShades;
|
||||
};
|
||||
|
||||
export const applyTheme = (palette: string, isDarkPalette: boolean) => {
|
||||
if (!palette) return;
|
||||
const themeElement = document?.querySelector("html");
|
||||
// palette: [bg, text, primary, sidebarBg, sidebarText]
|
||||
const values: string[] = palette.split(",");
|
||||
values.push(isDarkPalette ? "dark" : "light");
|
||||
|
||||
const bgShades = calculateShades(values[0]);
|
||||
const textShades = calculateShades(values[1]);
|
||||
const primaryShades = calculateShades(values[2]);
|
||||
const sidebarBackgroundShades = calculateShades(values[3]);
|
||||
const sidebarTextShades = calculateShades(values[4]);
|
||||
|
||||
for (let i = 10; i <= 900; i >= 100 ? (i += 100) : (i += 10)) {
|
||||
const shade = i as keyof TShades;
|
||||
|
||||
const bgRgbValues = `${bgShades[shade].r}, ${bgShades[shade].g}, ${bgShades[shade].b}`;
|
||||
const textRgbValues = `${textShades[shade].r}, ${textShades[shade].g}, ${textShades[shade].b}`;
|
||||
const primaryRgbValues = `${primaryShades[shade].r}, ${primaryShades[shade].g}, ${primaryShades[shade].b}`;
|
||||
const sidebarBackgroundRgbValues = `${sidebarBackgroundShades[shade].r}, ${sidebarBackgroundShades[shade].g}, ${sidebarBackgroundShades[shade].b}`;
|
||||
const sidebarTextRgbValues = `${sidebarTextShades[shade].r}, ${sidebarTextShades[shade].g}, ${sidebarTextShades[shade].b}`;
|
||||
|
||||
themeElement?.style.setProperty(`--color-background-${shade}`, bgRgbValues);
|
||||
themeElement?.style.setProperty(`--color-text-${shade}`, textRgbValues);
|
||||
themeElement?.style.setProperty(`--color-primary-${shade}`, primaryRgbValues);
|
||||
themeElement?.style.setProperty(`--color-sidebar-background-${shade}`, sidebarBackgroundRgbValues);
|
||||
themeElement?.style.setProperty(`--color-sidebar-text-${shade}`, sidebarTextRgbValues);
|
||||
|
||||
if (i >= 100 && i <= 400) {
|
||||
const borderShade = i === 100 ? 70 : i === 200 ? 80 : i === 300 ? 90 : 100;
|
||||
|
||||
themeElement?.style.setProperty(
|
||||
`--color-border-${shade}`,
|
||||
`${bgShades[borderShade].r}, ${bgShades[borderShade].g}, ${bgShades[borderShade].b}`
|
||||
);
|
||||
themeElement?.style.setProperty(
|
||||
`--color-sidebar-border-${shade}`,
|
||||
`${sidebarBackgroundShades[borderShade].r}, ${sidebarBackgroundShades[borderShade].g}, ${sidebarBackgroundShades[borderShade].b}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
themeElement?.style.setProperty("--color-scheme", values[5]);
|
||||
};
|
||||
|
||||
export const unsetCustomCssVariables = () => {
|
||||
for (let i = 10; i <= 900; i >= 100 ? (i += 100) : (i += 10)) {
|
||||
const dom = document.querySelector<HTMLElement>("[data-theme='custom']");
|
||||
|
||||
dom?.style.removeProperty(`--color-background-${i}`);
|
||||
dom?.style.removeProperty(`--color-text-${i}`);
|
||||
dom?.style.removeProperty(`--color-border-${i}`);
|
||||
dom?.style.removeProperty(`--color-primary-${i}`);
|
||||
dom?.style.removeProperty(`--color-sidebar-background-${i}`);
|
||||
dom?.style.removeProperty(`--color-sidebar-text-${i}`);
|
||||
dom?.style.removeProperty(`--color-sidebar-border-${i}`);
|
||||
dom?.style.removeProperty("--color-scheme");
|
||||
}
|
||||
};
|
||||
|
||||
export const resolveGeneralTheme = (resolvedTheme: string | undefined) =>
|
||||
resolvedTheme?.includes("light") ? "light" : resolvedTheme?.includes("dark") ? "dark" : "system";
|
||||
138
packages/utils/src/theme/color-conversion.ts
Normal file
138
packages/utils/src/theme/color-conversion.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Color Conversion Utilities
|
||||
* Provides hex/RGB/HSL/OKLCH conversions using chroma-js
|
||||
*/
|
||||
|
||||
import chroma from "chroma-js";
|
||||
import { validateAndAdjustOKLCH } from "./color-validation";
|
||||
|
||||
/**
|
||||
* RGB color interface
|
||||
*/
|
||||
export interface RGB {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* OKLCH color interface (modern perceptual color space)
|
||||
* L = Lightness (0-1)
|
||||
* C = Chroma/Saturation
|
||||
* H = Hue (0-360 degrees)
|
||||
*/
|
||||
export interface OKLCH {
|
||||
l: number;
|
||||
c: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex color to OKLCH color space
|
||||
* Uses chroma-js for accurate conversion
|
||||
*/
|
||||
export function hexToOKLCH(hex: string): OKLCH {
|
||||
try {
|
||||
const cleanHex = hex.replace("#", "");
|
||||
const color = chroma(`#${cleanHex}`);
|
||||
const [l, c, h] = color.oklch();
|
||||
|
||||
// Validate and adjust if needed
|
||||
return validateAndAdjustOKLCH({ l, c: c || 0, h: h || 0 });
|
||||
} catch (error) {
|
||||
console.error("Error converting hex to OKLCH:", error);
|
||||
// Return a safe default (mid-gray)
|
||||
return { l: 0.5, c: 0, h: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert OKLCH to CSS string format
|
||||
* Example: oklch(0.5840 0.1200 250.00)
|
||||
*/
|
||||
export function oklchToCSS(oklch: OKLCH, alpha?: number): string {
|
||||
const { l, c, h } = oklch;
|
||||
return `oklch(${l.toFixed(4)} ${c.toFixed(4)} ${h.toFixed(2)}${alpha ? ` / ${alpha.toFixed(2)}%` : ""})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex color to OKLCH CSS string
|
||||
* Combines hexToOKLCH and oklchToCSS
|
||||
*/
|
||||
export function hexToOKLCHString(hex: string): string {
|
||||
const oklch = hexToOKLCH(hex);
|
||||
return oklchToCSS(oklch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse OKLCH CSS string to OKLCH object
|
||||
* Example: "oklch(0.5840 0.1200 250.00)" -> { l: 0.5840, c: 0.1200, h: 250.00 }
|
||||
*/
|
||||
export function parseOKLCH(oklchString: string): OKLCH | null {
|
||||
const match = oklchString.match(/oklch\(([\d.]+)\s+([\d.]+)\s+([\d.]+)\)/);
|
||||
if (match) {
|
||||
return {
|
||||
l: parseFloat(match[1]),
|
||||
c: parseFloat(match[2]),
|
||||
h: parseFloat(match[3]),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex color to RGB object
|
||||
* Legacy function for backward compatibility
|
||||
*/
|
||||
export function hexToRgb(hex: string): RGB {
|
||||
const cleanHex = hex.replace("#", "");
|
||||
|
||||
const r = parseInt(cleanHex.substring(0, 2), 16);
|
||||
const g = parseInt(cleanHex.substring(2, 4), 16);
|
||||
const b = parseInt(cleanHex.substring(4, 6), 16);
|
||||
|
||||
return { r, g, b };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert RGB to hex color
|
||||
* Legacy function for backward compatibility
|
||||
*/
|
||||
export function rgbToHex(rgb: RGB): string {
|
||||
const { r, g, b } = rgb;
|
||||
const toHex = (n: number) => {
|
||||
const hex = Math.round(Math.max(0, Math.min(255, n))).toString(16);
|
||||
return hex.length === 1 ? "0" + hex : hex;
|
||||
};
|
||||
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex to chroma-js HSL
|
||||
* Returns [hue (0-360), saturation (0-1), lightness (0-1)]
|
||||
*/
|
||||
export function hexToHSL(hex: string): [number, number, number] {
|
||||
try {
|
||||
const cleanHex = hex.replace("#", "");
|
||||
const color = chroma(`#${cleanHex}`);
|
||||
return color.hsl();
|
||||
} catch (error) {
|
||||
console.error("Error converting hex to HSL:", error);
|
||||
return [0, 0, 0.5]; // Safe default
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a color is grayscale (has no saturation)
|
||||
*/
|
||||
export function isGrayscale(hex: string): boolean {
|
||||
try {
|
||||
const cleanHex = hex.replace("#", "");
|
||||
const color = chroma(`#${cleanHex}`);
|
||||
const [, s] = color.hsl();
|
||||
return isNaN(s) || s < 0.01; // NaN hue or very low saturation
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
91
packages/utils/src/theme/color-validation.ts
Normal file
91
packages/utils/src/theme/color-validation.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Color Validation Utilities
|
||||
* Validates and adjusts color inputs for palette generation
|
||||
*/
|
||||
|
||||
/**
|
||||
* Validate hex color format
|
||||
* Accepts formats: #RGB, #RRGGBB, RGB, RRGGBB
|
||||
*/
|
||||
export function validateHexColor(hex: string): boolean {
|
||||
if (!hex) return false;
|
||||
|
||||
const cleanHex = hex.replace("#", "");
|
||||
const hexRegex = /^[0-9A-Fa-f]{3}$|^[0-9A-Fa-f]{6}$/;
|
||||
|
||||
return hexRegex.test(cleanHex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize hex color to 6-digit format without #
|
||||
* Converts #RGB to RRGGBB format
|
||||
*/
|
||||
export function normalizeHexColor(hex: string): string {
|
||||
const cleanHex = hex.replace("#", "").toUpperCase();
|
||||
|
||||
// Expand 3-digit hex to 6-digit
|
||||
if (cleanHex.length === 3) {
|
||||
return cleanHex
|
||||
.split("")
|
||||
.map((char) => char + char)
|
||||
.join("");
|
||||
}
|
||||
|
||||
return cleanHex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and adjust OKLCH color for better visibility
|
||||
* Ensures the color is not too extreme (too light or too dark)
|
||||
*/
|
||||
export function validateAndAdjustOKLCH(oklch: { l: number; c: number; h: number }): {
|
||||
l: number;
|
||||
c: number;
|
||||
h: number;
|
||||
} {
|
||||
let { l, c, h } = oklch;
|
||||
|
||||
// Adjust lightness if too extreme
|
||||
if (l > 0.95) {
|
||||
l = 0.9; // Too light - darken slightly
|
||||
} else if (l < 0.1) {
|
||||
l = 0.15; // Too dark - lighten slightly
|
||||
}
|
||||
|
||||
// Ensure minimum chroma for color distinction (not pure gray)
|
||||
if (c < 0.001) {
|
||||
c = 0.002;
|
||||
}
|
||||
|
||||
// Clamp chroma to reasonable range
|
||||
c = Math.max(0.001, Math.min(0.37, c));
|
||||
|
||||
// Normalize hue to 0-360 range
|
||||
h = ((h % 360) + 360) % 360;
|
||||
|
||||
return { l, c, h };
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust lightness for dark mode with improved algorithm
|
||||
* Applies different scaling based on original lightness
|
||||
*/
|
||||
export function adjustLightnessForDarkMode(lightness: number, offset: number): number {
|
||||
// Apply offset (negative to make darker)
|
||||
let adjusted = lightness + offset;
|
||||
|
||||
// Enhanced clamping with better distribution
|
||||
// Keep very light colors from becoming too dark
|
||||
if (lightness > 0.9) {
|
||||
// For very light colors, apply less offset
|
||||
adjusted = lightness + offset * 0.6;
|
||||
} else if (lightness < 0.25) {
|
||||
// For already dark colors, apply more offset to ensure they stay very dark
|
||||
adjusted = lightness + offset * 1.2;
|
||||
}
|
||||
|
||||
// Clamp to valid range (0.1 to 0.95)
|
||||
adjusted = Math.max(0.1, Math.min(0.95, adjusted));
|
||||
|
||||
return adjusted;
|
||||
}
|
||||
114
packages/utils/src/theme/constants.ts
Normal file
114
packages/utils/src/theme/constants.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Theme System Constants
|
||||
* Defines shade stops, default configurations, and color modes
|
||||
*/
|
||||
|
||||
/**
|
||||
* Alpha mapping for 14-shade palette system
|
||||
*/
|
||||
export const ALPHA_MAPPING = {
|
||||
100: 0.05,
|
||||
200: 0.1,
|
||||
300: 0.15,
|
||||
400: 0.2,
|
||||
500: 0.3,
|
||||
600: 0.4,
|
||||
700: 0.5,
|
||||
800: 0.6,
|
||||
900: 0.7,
|
||||
1000: 0.8,
|
||||
1100: 0.9,
|
||||
1200: 0.95,
|
||||
};
|
||||
|
||||
/**
|
||||
* All shade stops for 14-shade palette system
|
||||
* 50 = white, 1000 = black
|
||||
* Extended range: 50-1000 for more granular control
|
||||
*/
|
||||
export const SHADE_STOPS = [50, 100, 200, 300, 400, 500, 600, 700, 750, 800, 850, 900, 950, 1000] as const;
|
||||
|
||||
/**
|
||||
* Default stop where user input color is anchored
|
||||
* This is now dynamically calculated based on the input color's lightness
|
||||
* This constant serves as a fallback only
|
||||
*/
|
||||
export const DEFAULT_VALUE_STOP = 500;
|
||||
|
||||
/**
|
||||
* Baseline lightness values for each stop (in OKLCH L scale 0-1)
|
||||
* Used to determine which stop best matches an input color
|
||||
* Based on perceptually uniform distribution
|
||||
*/
|
||||
export const BASELINE_LIGHTNESS_MAP: Record<number, number> = {
|
||||
50: 0.98, // Near white
|
||||
100: 0.95, // Lightest
|
||||
200: 0.88, // Very light
|
||||
300: 0.78, // Light
|
||||
400: 0.68, // Light-medium
|
||||
500: 0.58, // Medium (typical input)
|
||||
600: 0.48, // Medium-dark
|
||||
700: 0.38, // Dark
|
||||
750: 0.28, // Very dark
|
||||
800: 0.18, // Darkest
|
||||
850: 0.12, // Near black
|
||||
900: 0.08, // Almost black
|
||||
950: 0.04, // Nearly black
|
||||
1000: 0.02, // Black
|
||||
};
|
||||
|
||||
/**
|
||||
* Default hue shift for brand colors (in degrees)
|
||||
* Adds visual interest by shifting hue at extremes
|
||||
*/
|
||||
export const DEFAULT_HUE_SHIFT_BRAND = 10;
|
||||
|
||||
/**
|
||||
* Default hue shift for neutral colors (in degrees)
|
||||
* No shift to keep neutrals truly neutral
|
||||
*/
|
||||
export const DEFAULT_HUE_SHIFT_NEUTRAL = 0;
|
||||
|
||||
/**
|
||||
* Default minimum lightness for light mode (0-100 scale)
|
||||
*/
|
||||
export const DEFAULT_LIGHT_MODE_LIGHTNESS_MIN = 0;
|
||||
|
||||
/**
|
||||
* Default maximum lightness for light mode (0-100 scale)
|
||||
*/
|
||||
export const DEFAULT_LIGHT_MODE_LIGHTNESS_MAX = 100;
|
||||
|
||||
/**
|
||||
* Default minimum lightness for dark mode (0-100 scale)
|
||||
*/
|
||||
export const DEFAULT_DARK_MODE_LIGHTNESS_MIN = 10;
|
||||
|
||||
/**
|
||||
* Default maximum lightness for dark mode (0-100 scale)
|
||||
*/
|
||||
export const DEFAULT_DARK_MODE_LIGHTNESS_MAX = 80;
|
||||
|
||||
/**
|
||||
* Color generation modes
|
||||
* - perceived: HSLuv-based perceptually uniform lightness (recommended)
|
||||
* - linear: Direct HSL manipulation
|
||||
*/
|
||||
export type ColorMode = "perceived" | "linear";
|
||||
|
||||
/**
|
||||
* Default color generation mode
|
||||
*/
|
||||
export const DEFAULT_COLOR_MODE: ColorMode = "perceived";
|
||||
|
||||
/**
|
||||
* Saturation curve types
|
||||
* - ease-in-out: Increase saturation at extremes (recommended for brand colors)
|
||||
* - linear: Maintain constant saturation (recommended for neutrals)
|
||||
*/
|
||||
export type SaturationCurve = "ease-in-out" | "linear";
|
||||
|
||||
/**
|
||||
* Default saturation curve
|
||||
*/
|
||||
export const DEFAULT_SATURATION_CURVE: SaturationCurve = "ease-in-out";
|
||||
53
packages/utils/src/theme/index.ts
Normal file
53
packages/utils/src/theme/index.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Theme System Public API
|
||||
* Exports all theme-related utilities for use across Plane apps
|
||||
*/
|
||||
|
||||
// Palette generation
|
||||
export {
|
||||
calculateDynamicValueStop,
|
||||
generateColorPalette,
|
||||
generateThemePalettes,
|
||||
type ColorPalette,
|
||||
type PaletteOptions,
|
||||
} from "./palette-generator";
|
||||
|
||||
// Theme application
|
||||
export { applyCustomTheme, clearCustomTheme } from "./theme-application";
|
||||
|
||||
// Color conversion utilities
|
||||
export {
|
||||
hexToHSL,
|
||||
hexToOKLCH,
|
||||
hexToOKLCHString,
|
||||
// hexToRgb,
|
||||
isGrayscale,
|
||||
oklchToCSS,
|
||||
parseOKLCH,
|
||||
// rgbToHex,
|
||||
type OKLCH,
|
||||
type RGB,
|
||||
} from "./color-conversion";
|
||||
|
||||
// Color validation
|
||||
export { normalizeHexColor, validateHexColor } from "./color-validation";
|
||||
|
||||
// Theme inversion (dark mode)
|
||||
export { getBrandMapping, getNeutralMapping, invertPalette } from "./theme-inversion";
|
||||
|
||||
// Constants
|
||||
export {
|
||||
BASELINE_LIGHTNESS_MAP,
|
||||
type ColorMode,
|
||||
DEFAULT_COLOR_MODE,
|
||||
DEFAULT_HUE_SHIFT_BRAND,
|
||||
DEFAULT_HUE_SHIFT_NEUTRAL,
|
||||
DEFAULT_LIGHT_MODE_LIGHTNESS_MAX,
|
||||
DEFAULT_LIGHT_MODE_LIGHTNESS_MIN,
|
||||
DEFAULT_DARK_MODE_LIGHTNESS_MAX,
|
||||
DEFAULT_DARK_MODE_LIGHTNESS_MIN,
|
||||
DEFAULT_SATURATION_CURVE,
|
||||
DEFAULT_VALUE_STOP,
|
||||
type SaturationCurve,
|
||||
SHADE_STOPS,
|
||||
} from "./constants";
|
||||
214
packages/utils/src/theme/palette-generator.ts
Normal file
214
packages/utils/src/theme/palette-generator.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Palette Generator
|
||||
* Generates 14-shade color palettes directly in OKLCH color space
|
||||
* Keeps C (chroma) and H (hue) constant, only varies L (lightness)
|
||||
* Inspired by tints.dev but optimized for OKLCH
|
||||
*/
|
||||
|
||||
import type { OKLCH } from "./color-conversion";
|
||||
import { hexToOKLCH, oklchToCSS } from "./color-conversion";
|
||||
import { normalizeHexColor, validateHexColor } from "./color-validation";
|
||||
import {
|
||||
BASELINE_LIGHTNESS_MAP,
|
||||
DEFAULT_LIGHT_MODE_LIGHTNESS_MIN,
|
||||
DEFAULT_LIGHT_MODE_LIGHTNESS_MAX,
|
||||
DEFAULT_DARK_MODE_LIGHTNESS_MIN,
|
||||
DEFAULT_DARK_MODE_LIGHTNESS_MAX,
|
||||
DEFAULT_VALUE_STOP,
|
||||
SHADE_STOPS,
|
||||
} from "./constants";
|
||||
|
||||
/**
|
||||
* Type representing valid shade stop values
|
||||
*/
|
||||
export type ShadeStop = (typeof SHADE_STOPS)[number];
|
||||
|
||||
/**
|
||||
* 14-shade color palette
|
||||
* Keys: 50, 100, 200, 300, 400, 500, 600, 700, 750, 800, 850, 900, 950, 1000
|
||||
* Values: OKLCH CSS strings (e.g., "oklch(0.5840 0.1200 250.00)")
|
||||
*/
|
||||
export interface ColorPalette {
|
||||
50: string;
|
||||
100: string;
|
||||
200: string;
|
||||
300: string;
|
||||
400: string;
|
||||
500: string;
|
||||
600: string;
|
||||
700: string;
|
||||
750: string;
|
||||
800: string;
|
||||
850: string;
|
||||
900: string;
|
||||
950: string;
|
||||
1000: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Palette generation options
|
||||
*/
|
||||
export interface PaletteOptions {
|
||||
/** Minimum lightness (0-1) for darkest shade */
|
||||
lightnessMin?: number;
|
||||
/** Maximum lightness (0-1) for lightest shade */
|
||||
lightnessMax?: number;
|
||||
/** Stop where the input color is anchored (default: auto-calculated) */
|
||||
valueStop?: number | "auto";
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the appropriate stop value based on a color's OKLCH lightness
|
||||
* Inspired by tints.dev's calculateStopFromColor but simplified for OKLCH
|
||||
*
|
||||
* @param oklch - OKLCH color object
|
||||
* @returns The nearest available stop value (50, 100, 200, etc.)
|
||||
*/
|
||||
export function calculateDynamicValueStop(oklch: OKLCH): number {
|
||||
const { l: lightness } = oklch;
|
||||
|
||||
// Find the stop whose baseline lightness is closest to the input color's lightness
|
||||
let closestStop = DEFAULT_VALUE_STOP;
|
||||
let smallestDiff = Infinity;
|
||||
|
||||
for (const stop of SHADE_STOPS) {
|
||||
const baselineLightness = BASELINE_LIGHTNESS_MAP[stop];
|
||||
const diff = Math.abs(baselineLightness - lightness);
|
||||
|
||||
if (diff < smallestDiff) {
|
||||
smallestDiff = diff;
|
||||
closestStop = stop;
|
||||
}
|
||||
}
|
||||
|
||||
return closestStop;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a number is a valid shade stop
|
||||
* @param value - Number to check
|
||||
* @returns True if value is a valid shade stop
|
||||
*/
|
||||
function isValidShadeStop(value: number): value is ShadeStop {
|
||||
return (SHADE_STOPS as readonly number[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a 14-shade color palette from a base hex color
|
||||
* Works directly in OKLCH space, keeping C and H constant, only varying L
|
||||
*
|
||||
* @param baseColor - Hex color (with or without #)
|
||||
* @param mode - "light" or "dark"
|
||||
* @param options - Palette generation options
|
||||
* @returns ColorPalette with 14 OKLCH CSS strings
|
||||
*/
|
||||
export function generateColorPalette(
|
||||
baseColor: string,
|
||||
mode: "light" | "dark",
|
||||
options: PaletteOptions = {}
|
||||
): ColorPalette {
|
||||
// Validate and normalize input
|
||||
if (!validateHexColor(baseColor)) {
|
||||
throw new Error(`Invalid hex color: ${baseColor}`);
|
||||
}
|
||||
|
||||
const normalizedHex = normalizeHexColor(baseColor);
|
||||
|
||||
// Convert to OKLCH
|
||||
const inputOKLCH = hexToOKLCH(normalizedHex);
|
||||
const { l: inputL, c: inputC, h: inputH } = inputOKLCH;
|
||||
|
||||
const DEFAULT_LIGHTNESS_MIN = mode === "light" ? DEFAULT_LIGHT_MODE_LIGHTNESS_MIN : DEFAULT_DARK_MODE_LIGHTNESS_MIN;
|
||||
const DEFAULT_LIGHTNESS_MAX = mode === "light" ? DEFAULT_LIGHT_MODE_LIGHTNESS_MAX : DEFAULT_DARK_MODE_LIGHTNESS_MAX;
|
||||
|
||||
// Extract options with defaults
|
||||
const {
|
||||
lightnessMin = DEFAULT_LIGHTNESS_MIN / 100, // Convert to 0-1 scale
|
||||
lightnessMax = DEFAULT_LIGHTNESS_MAX / 100, // Convert to 0-1 scale
|
||||
valueStop = options.valueStop ?? DEFAULT_VALUE_STOP,
|
||||
} = options;
|
||||
|
||||
// Calculate or use provided valueStop
|
||||
const anchorStop = valueStop === "auto" ? calculateDynamicValueStop(inputOKLCH) : valueStop;
|
||||
|
||||
// Validate valueStop if provided manually
|
||||
if (typeof anchorStop === "number" && !isValidShadeStop(anchorStop)) {
|
||||
throw new Error(`Invalid valueStop: ${anchorStop}. Must be one of ${SHADE_STOPS.join(", ")}`);
|
||||
}
|
||||
|
||||
// Create lightness distribution with three anchor points
|
||||
const distributionAnchors = [
|
||||
{ stop: SHADE_STOPS[0], lightness: lightnessMax }, // Lightest
|
||||
{ stop: anchorStop, lightness: inputL }, // Input color
|
||||
{ stop: SHADE_STOPS[SHADE_STOPS.length - 1], lightness: lightnessMin }, // Darkest
|
||||
];
|
||||
|
||||
// Generate palette by interpolating lightness for each stop
|
||||
const palette: Partial<ColorPalette> = {};
|
||||
|
||||
SHADE_STOPS.forEach((stop) => {
|
||||
let targetLightness: number;
|
||||
|
||||
// Check if this is an anchor point
|
||||
const anchor = distributionAnchors.find((a) => a.stop === stop);
|
||||
if (anchor) {
|
||||
targetLightness = anchor.lightness;
|
||||
} else {
|
||||
// Interpolate between anchor points
|
||||
let leftAnchor, rightAnchor;
|
||||
|
||||
if (stop < anchorStop) {
|
||||
leftAnchor = distributionAnchors[0]; // stop 50
|
||||
rightAnchor = distributionAnchors[1]; // anchorStop
|
||||
} else {
|
||||
leftAnchor = distributionAnchors[1]; // anchorStop
|
||||
rightAnchor = distributionAnchors[2]; // stop 1000
|
||||
}
|
||||
|
||||
// Linear interpolation
|
||||
const range = rightAnchor.stop - leftAnchor.stop;
|
||||
const position = stop - leftAnchor.stop;
|
||||
const ratio = position / range;
|
||||
targetLightness = leftAnchor.lightness + (rightAnchor.lightness - leftAnchor.lightness) * ratio;
|
||||
}
|
||||
|
||||
// Create OKLCH color with constant C and H, only varying L
|
||||
const shadeOKLCH: OKLCH = {
|
||||
l: Math.max(0, Math.min(1, targetLightness)), // Clamp to 0-1
|
||||
c: inputC, // Keep chroma constant
|
||||
h: inputH, // Keep hue constant
|
||||
};
|
||||
|
||||
// Convert to CSS string
|
||||
const key = stop as keyof ColorPalette;
|
||||
palette[key] = oklchToCSS(shadeOKLCH);
|
||||
});
|
||||
|
||||
return palette as ColorPalette;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate both brand and neutral palettes for a custom theme
|
||||
* Optimized for Plane's 2-color theme system
|
||||
* Uses auto-calculated value stops for better color matching
|
||||
*
|
||||
* @param brandColor - Brand accent color (hex)
|
||||
* @param neutralColor - Neutral/background color (hex)
|
||||
* @returns Object with brandPalette and neutralPalette
|
||||
*/
|
||||
export function generateThemePalettes(
|
||||
brandColor: string,
|
||||
neutralColor: string,
|
||||
mode: "light" | "dark"
|
||||
): {
|
||||
brandPalette: ColorPalette;
|
||||
neutralPalette: ColorPalette;
|
||||
} {
|
||||
// Brand palette - auto-calculate value stop based on color lightness
|
||||
const brandPalette = generateColorPalette(brandColor, mode);
|
||||
|
||||
// Neutral palette - auto-calculate value stop based on color lightness
|
||||
const neutralPalette = generateColorPalette(neutralColor, mode);
|
||||
|
||||
return { brandPalette, neutralPalette };
|
||||
}
|
||||
112
packages/utils/src/theme/theme-application.ts
Normal file
112
packages/utils/src/theme/theme-application.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Theme Application Utilities
|
||||
* Applies generated palettes to CSS variables for Plane's theme system
|
||||
*/
|
||||
|
||||
import { hexToOKLCH, oklchToCSS } from "./color-conversion";
|
||||
import { ALPHA_MAPPING } from "./constants";
|
||||
import { generateThemePalettes } from "./palette-generator";
|
||||
import { getBrandMapping, getNeutralMapping, invertPalette } from "./theme-inversion";
|
||||
|
||||
/**
|
||||
* Apply custom theme using 2-color palette system
|
||||
* Generates full palettes from brand and neutral colors
|
||||
* and maps them to CSS variables
|
||||
*
|
||||
* @param brandColor - Brand accent color (hex with or without #)
|
||||
* @param neutralColor - Neutral/background color (hex with or without #)
|
||||
* @param mode - 'light' or 'dark' theme mode
|
||||
*/
|
||||
export function applyCustomTheme(brandColor: string, neutralColor: string, mode: "light" | "dark"): void {
|
||||
if (!brandColor || !neutralColor) {
|
||||
console.warn("applyCustomTheme: brandColor and neutralColor are required");
|
||||
return;
|
||||
}
|
||||
|
||||
const themeElement = document?.querySelector("html");
|
||||
if (!themeElement) {
|
||||
console.warn("applyCustomTheme: html element not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate palettes directly in OKLCH color space
|
||||
const { brandPalette, neutralPalette } = generateThemePalettes(brandColor, neutralColor, mode);
|
||||
const neutralOKLCH = hexToOKLCH(neutralColor);
|
||||
const brandOKLCH = hexToOKLCH(brandColor);
|
||||
|
||||
// For dark mode, invert the palettes
|
||||
const activeBrandPalette = mode === "dark" ? invertPalette(brandPalette) : brandPalette;
|
||||
const activeNeutralPalette = mode === "dark" ? invertPalette(neutralPalette) : neutralPalette;
|
||||
|
||||
// Get CSS variable mappings
|
||||
const neutralMapping = getNeutralMapping(activeNeutralPalette);
|
||||
const brandMapping = getBrandMapping(activeBrandPalette);
|
||||
|
||||
// Apply base palette colors
|
||||
// This updates the source palette variables that semantic colors reference
|
||||
Object.entries(neutralMapping).forEach(([key, value]) => {
|
||||
themeElement.style.setProperty(`--color-neutral-${key}`, value);
|
||||
});
|
||||
|
||||
Object.entries(brandMapping).forEach(([key, value]) => {
|
||||
themeElement.style.setProperty(`--color-brand-${key}`, value);
|
||||
});
|
||||
|
||||
Object.entries(ALPHA_MAPPING).forEach(([key, value]) => {
|
||||
themeElement.style.setProperty(`--color-alpha-white-${key}`, oklchToCSS(neutralOKLCH, value * 100));
|
||||
themeElement.style.setProperty(`--color-alpha-black-${key}`, oklchToCSS(neutralOKLCH, value * 100));
|
||||
});
|
||||
|
||||
const isBrandColorDark = brandOKLCH.l < 0.2;
|
||||
const whiteInOKLCH = { l: 1, c: 0, h: 0 };
|
||||
const blackInOKLCH = { l: 0, c: 0, h: 0 };
|
||||
themeElement.style.setProperty(`--text-color-on-color`, oklchToCSS(isBrandColorDark ? whiteInOKLCH : blackInOKLCH));
|
||||
themeElement.style.setProperty(
|
||||
`--text-color-icon-on-color`,
|
||||
oklchToCSS(isBrandColorDark ? blackInOKLCH : whiteInOKLCH)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear custom theme CSS variables
|
||||
* Removes base palette color overrides
|
||||
*/
|
||||
export function clearCustomTheme(): void {
|
||||
const themeElement = document?.querySelector("html");
|
||||
if (!themeElement) return;
|
||||
|
||||
// Clear neutral base palette colors
|
||||
const neutralKeys = [
|
||||
"white",
|
||||
"100",
|
||||
"200",
|
||||
"300",
|
||||
"400",
|
||||
"500",
|
||||
"600",
|
||||
"700",
|
||||
"800",
|
||||
"900",
|
||||
"1000",
|
||||
"1100",
|
||||
"1200",
|
||||
"black",
|
||||
];
|
||||
neutralKeys.forEach((key) => {
|
||||
themeElement.style.removeProperty(`--color-neutral-${key}`);
|
||||
});
|
||||
|
||||
// Clear brand base palette colors
|
||||
const brandKeys = ["100", "200", "300", "400", "500", "600", "700", "800", "900", "1000", "1100", "1200", "default"];
|
||||
brandKeys.forEach((key) => {
|
||||
themeElement.style.removeProperty(`--color-brand-${key}`);
|
||||
});
|
||||
|
||||
Object.keys(ALPHA_MAPPING).forEach((key) => {
|
||||
themeElement.style.removeProperty(`--color-alpha-white-${key}`);
|
||||
themeElement.style.removeProperty(`--color-alpha-black-${key}`);
|
||||
});
|
||||
|
||||
themeElement.style.removeProperty(`--text-color-on-color`);
|
||||
themeElement.style.removeProperty(`--text-color-icon-on-color`);
|
||||
}
|
||||
93
packages/utils/src/theme/theme-inversion.ts
Normal file
93
packages/utils/src/theme/theme-inversion.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Theme Inversion Utilities
|
||||
* Handles dark mode palette inversion and mapping
|
||||
*/
|
||||
|
||||
import { DEFAULT_VALUE_STOP } from "./constants";
|
||||
import type { ColorPalette } from "./palette-generator";
|
||||
|
||||
/**
|
||||
* Invert a color palette for dark mode
|
||||
* Maps each shade to its opposite (50↔1250, 100↔1200, 200↔1100, etc.)
|
||||
* Shades around the middle are preserved for smooth transitions
|
||||
*
|
||||
* @param palette - 14-shade color palette to invert
|
||||
* @returns Inverted palette with swapped shades
|
||||
*/
|
||||
export function invertPalette(palette: ColorPalette): ColorPalette {
|
||||
return {
|
||||
50: palette[1000],
|
||||
100: palette[950],
|
||||
200: palette[900],
|
||||
300: palette[850],
|
||||
400: palette[800],
|
||||
500: palette[750],
|
||||
600: palette[700],
|
||||
700: palette[600],
|
||||
750: palette[500],
|
||||
800: palette[400],
|
||||
850: palette[300],
|
||||
900: palette[200],
|
||||
950: palette[100],
|
||||
1000: palette[50],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSS variable mapping for a theme mode
|
||||
* Maps 14-shade palette to Plane's CSS variable system
|
||||
*
|
||||
* For light mode:
|
||||
* - Uses lighter shades for backgrounds (50-100-200)
|
||||
* - Uses darker shades for text (900, 950, 1000)
|
||||
*
|
||||
* For dark mode:
|
||||
* - Uses inverted palette
|
||||
* - Shifts mapping to lighter shades to avoid cave-like darkness
|
||||
*
|
||||
* @param palette - 14-shade palette (already inverted for dark mode)
|
||||
* @returns Mapping object for neutral color CSS variables
|
||||
*/
|
||||
export function getNeutralMapping(palette: ColorPalette): Record<string, string> {
|
||||
return {
|
||||
white: palette["50"],
|
||||
"100": palette["100"],
|
||||
"200": palette["200"],
|
||||
"300": palette["300"],
|
||||
"400": palette["400"],
|
||||
"500": palette["500"],
|
||||
"600": palette["600"],
|
||||
"700": palette["700"],
|
||||
"800": palette["750"],
|
||||
"900": palette["800"],
|
||||
"1000": palette["850"],
|
||||
"1100": palette["900"],
|
||||
"1200": palette["950"],
|
||||
black: palette["1000"],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSS variable mapping for brand colors
|
||||
* Brand colors use active palette (already inverted for dark mode)
|
||||
*
|
||||
* @param palette - 14-shade brand palette
|
||||
* @returns Mapping object for brand color CSS variables
|
||||
*/
|
||||
export function getBrandMapping(palette: ColorPalette): Record<string, string> {
|
||||
return {
|
||||
"100": palette["100"],
|
||||
"200": palette["200"],
|
||||
"300": palette["300"],
|
||||
"400": palette["400"],
|
||||
"500": palette["500"],
|
||||
"600": palette["600"],
|
||||
"700": palette["700"],
|
||||
"800": palette["750"],
|
||||
"900": palette["800"],
|
||||
"1000": palette["850"],
|
||||
"1100": palette["900"],
|
||||
"1200": palette["950"],
|
||||
default: palette[DEFAULT_VALUE_STOP], // Default brand color (middle-ish)
|
||||
};
|
||||
}
|
||||
32
pnpm-lock.yaml
generated
32
pnpm-lock.yaml
generated
@@ -1457,6 +1457,9 @@ importers:
|
||||
'@plane/types':
|
||||
specifier: workspace:*
|
||||
version: link:../types
|
||||
chroma-js:
|
||||
specifier: ^3.2.0
|
||||
version: 3.2.0
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
@@ -1509,6 +1512,9 @@ importers:
|
||||
'@plane/typescript-config':
|
||||
specifier: workspace:*
|
||||
version: link:../typescript-config
|
||||
'@types/chroma-js':
|
||||
specifier: ^3.1.2
|
||||
version: 3.1.2
|
||||
'@types/hast':
|
||||
specifier: ^3.0.4
|
||||
version: 3.0.4
|
||||
@@ -1817,8 +1823,8 @@ packages:
|
||||
resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==}
|
||||
engines: {node: '>=0.1.90'}
|
||||
|
||||
'@csstools/color-helpers@5.1.0':
|
||||
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
|
||||
'@csstools/color-helpers@5.0.2':
|
||||
resolution: {integrity: sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@csstools/css-calc@2.1.4':
|
||||
@@ -1828,8 +1834,8 @@ packages:
|
||||
'@csstools/css-parser-algorithms': ^3.0.5
|
||||
'@csstools/css-tokenizer': ^3.0.4
|
||||
|
||||
'@csstools/css-color-parser@3.1.0':
|
||||
resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==}
|
||||
'@csstools/css-color-parser@3.0.10':
|
||||
resolution: {integrity: sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@csstools/css-parser-algorithms': ^3.0.5
|
||||
@@ -3975,6 +3981,9 @@ packages:
|
||||
'@types/chai@5.2.2':
|
||||
resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==}
|
||||
|
||||
'@types/chroma-js@3.1.2':
|
||||
resolution: {integrity: sha512-YBTQqArPN8A0niHXCwrO1z5x++a+6l0mLBykncUpr23oIPW7L4h39s6gokdK/bDrPmSh8+TjMmrhBPnyiaWPmQ==}
|
||||
|
||||
'@types/compression@1.8.1':
|
||||
resolution: {integrity: sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==}
|
||||
|
||||
@@ -4919,6 +4928,9 @@ packages:
|
||||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||
engines: {node: '>= 8.10.0'}
|
||||
|
||||
chroma-js@3.2.0:
|
||||
resolution: {integrity: sha512-os/OippSlX1RlWWr+QDPcGUZs0uoqr32urfxESG9U93lhUfbnlyckte84Q8P1UQY/qth983AS1JONKmLS4T0nw==}
|
||||
|
||||
chromatic@11.29.0:
|
||||
resolution: {integrity: sha512-yisBlntp9hHVj19lIQdpTlcYIXuU9H/DbFuu6tyWHmj6hWT2EtukCCcxYXL78XdQt1vm2GfIrtgtKpj/Rzmo4A==}
|
||||
hasBin: true
|
||||
@@ -9478,7 +9490,7 @@ snapshots:
|
||||
'@asamuzakjp/css-color@3.2.0':
|
||||
dependencies:
|
||||
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-color-parser': 3.0.10(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-tokenizer': 3.0.4
|
||||
lru-cache: 10.4.3
|
||||
@@ -9926,7 +9938,7 @@ snapshots:
|
||||
|
||||
'@colors/colors@1.6.0': {}
|
||||
|
||||
'@csstools/color-helpers@5.1.0':
|
||||
'@csstools/color-helpers@5.0.2':
|
||||
optional: true
|
||||
|
||||
'@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
|
||||
@@ -9935,9 +9947,9 @@ snapshots:
|
||||
'@csstools/css-tokenizer': 3.0.4
|
||||
optional: true
|
||||
|
||||
'@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
|
||||
'@csstools/css-color-parser@3.0.10(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
|
||||
dependencies:
|
||||
'@csstools/color-helpers': 5.1.0
|
||||
'@csstools/color-helpers': 5.0.2
|
||||
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-tokenizer': 3.0.4
|
||||
@@ -12312,6 +12324,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/deep-eql': 4.0.2
|
||||
|
||||
'@types/chroma-js@3.1.2': {}
|
||||
|
||||
'@types/compression@1.8.1':
|
||||
dependencies:
|
||||
'@types/express': 4.17.23
|
||||
@@ -13395,6 +13409,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
chroma-js@3.2.0: {}
|
||||
|
||||
chromatic@11.29.0: {}
|
||||
|
||||
chrome-trace-event@1.0.4: {}
|
||||
|
||||
Reference in New Issue
Block a user