Compare commits
29 Commits
feat-ui-kit
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 22836ea03e | |||
| 13cc8b0e96 | |||
| 9addcde553 | |||
| 26a9b7fced | |||
| 62dd80874f | |||
| 9ae1ce0a9a | |||
| 00cc338c07 | |||
| 4432be15e4 | |||
| 20893c6017 | |||
| 95f43a7bb6 | |||
| fd7eedc343 | |||
| d2c9b437f4 | |||
| 2f57d0e138 | |||
| 59ddc02a31 | |||
| 3ac20741d9 | |||
| 8ea0772a1b | |||
| bddad8932b | |||
| 8acea7f599 | |||
| a908bf9edd | |||
| 79fff4744a | |||
| 369d927321 | |||
| 996d11de12 | |||
| 0345336d90 | |||
| 75d14e7c3a | |||
| 76fdb81249 | |||
| 88669af141 | |||
| 71dcbd938e | |||
| 4060412b18 | |||
| 9d715683f7 |
@@ -35,7 +35,6 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "7.51.5",
|
||||
"swr": "^2.2.4",
|
||||
"tailwindcss": "3.3.2",
|
||||
"uuid": "^9.0.1",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
|
||||
@@ -132,6 +132,18 @@ class CycleViewSet(BaseViewSet):
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"issue_cycle__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group__in=["backlog", "unstarted", "started"],
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
status=Case(
|
||||
When(
|
||||
@@ -214,6 +226,7 @@ class CycleViewSet(BaseViewSet):
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
"completed_issues",
|
||||
"pending_issues",
|
||||
"assignee_ids",
|
||||
"status",
|
||||
"version",
|
||||
@@ -245,6 +258,7 @@ class CycleViewSet(BaseViewSet):
|
||||
# meta fields
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
"pending_issues",
|
||||
"completed_issues",
|
||||
"assignee_ids",
|
||||
"status",
|
||||
|
||||
@@ -53,10 +53,10 @@ from .. import BaseAPIView
|
||||
def dashboard_overview_stats(self, request, slug):
|
||||
assigned_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
(Q(assignees__in=[request.user]) & Q(issue_assignee__deleted_at__isnull=True)),
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__member=request.user,
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user],
|
||||
)
|
||||
.filter(
|
||||
Q(
|
||||
@@ -133,10 +133,13 @@ def dashboard_overview_stats(self, request, slug):
|
||||
|
||||
completed_issues_count = (
|
||||
Issue.issue_objects.filter(
|
||||
(
|
||||
Q(assignees__in=[request.user])
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__member=request.user,
|
||||
assignees__in=[request.user],
|
||||
state__group="completed",
|
||||
)
|
||||
.filter(
|
||||
@@ -176,10 +179,13 @@ def dashboard_assigned_issues(self, request, slug):
|
||||
# get all the assigned issues
|
||||
assigned_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
(
|
||||
Q(assignees__in=[request.user])
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
assignees__in=[request.user],
|
||||
)
|
||||
.filter(**filters)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
|
||||
@@ -39,9 +39,9 @@ class WorkspaceStickyViewSet(BaseViewSet):
|
||||
)
|
||||
def list(self, request, slug):
|
||||
query = request.query_params.get("query", False)
|
||||
stickies = self.get_queryset()
|
||||
stickies = self.get_queryset().order_by("-sort_order")
|
||||
if query:
|
||||
stickies = stickies.filter(name__icontains=query)
|
||||
stickies = stickies.filter(description_stripped__icontains=query)
|
||||
|
||||
return self.paginate(
|
||||
request=request,
|
||||
@@ -49,7 +49,7 @@ class WorkspaceStickyViewSet(BaseViewSet):
|
||||
on_results=lambda stickies: StickySerializer(stickies, many=True).data,
|
||||
default_per_page=20,
|
||||
)
|
||||
|
||||
|
||||
@allow_permission(allowed_roles=[], creator=True, model=Sticky, level="WORKSPACE")
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
@@ -375,8 +375,11 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
||||
|
||||
state_distribution = (
|
||||
Issue.issue_objects.filter(
|
||||
(
|
||||
Q(assignees__in=[user_id])
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
workspace__slug=slug,
|
||||
assignees__in=[user_id],
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
@@ -391,8 +394,11 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
||||
|
||||
priority_distribution = (
|
||||
Issue.issue_objects.filter(
|
||||
(
|
||||
Q(assignees__in=[user_id])
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
workspace__slug=slug,
|
||||
assignees__in=[user_id],
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
@@ -426,8 +432,11 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
||||
|
||||
assigned_issues_count = (
|
||||
Issue.issue_objects.filter(
|
||||
(
|
||||
Q(assignees__in=[user_id])
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
workspace__slug=slug,
|
||||
assignees__in=[user_id],
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
@@ -438,8 +447,11 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
||||
pending_issues_count = (
|
||||
Issue.issue_objects.filter(
|
||||
~Q(state__group__in=["completed", "cancelled"]),
|
||||
(
|
||||
Q(assignees__in=[user_id])
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
workspace__slug=slug,
|
||||
assignees__in=[user_id],
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
@@ -449,8 +461,11 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
||||
|
||||
completed_issues_count = (
|
||||
Issue.issue_objects.filter(
|
||||
(
|
||||
Q(assignees__in=[user_id])
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
workspace__slug=slug,
|
||||
assignees__in=[user_id],
|
||||
state__group="completed",
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
|
||||
@@ -5,6 +5,9 @@ from django.db import models
|
||||
# Module imports
|
||||
from .base import BaseModel
|
||||
|
||||
# Third party imports
|
||||
from plane.utils.html_processor import strip_tags
|
||||
|
||||
|
||||
class Sticky(BaseModel):
|
||||
name = models.TextField(null=True, blank=True)
|
||||
@@ -33,6 +36,12 @@ class Sticky(BaseModel):
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
None
|
||||
if (self.description_html == "" or self.description_html is None)
|
||||
else strip_tags(self.description_html)
|
||||
)
|
||||
if self._state.adding:
|
||||
# Get the maximum sequence value from the database
|
||||
last_id = Sticky.objects.filter(workspace=self.workspace).aggregate(
|
||||
|
||||
@@ -290,11 +290,12 @@ class InstanceAdminSignInEndpoint(View):
|
||||
# Fetch the user
|
||||
user = User.objects.filter(email=email).first()
|
||||
|
||||
# is_active
|
||||
if not user.is_active:
|
||||
# Error out if the user is not present
|
||||
if not user:
|
||||
exc = AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DEACTIVATED"],
|
||||
error_message="ADMIN_USER_DEACTIVATED",
|
||||
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DOES_NOT_EXIST"],
|
||||
error_message="ADMIN_USER_DOES_NOT_EXIST",
|
||||
payload={"email": email},
|
||||
)
|
||||
url = urljoin(
|
||||
base_host(request=request, is_admin=True),
|
||||
@@ -302,12 +303,11 @@ class InstanceAdminSignInEndpoint(View):
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
# Error out if the user is not present
|
||||
if not user:
|
||||
# is_active
|
||||
if not user.is_active:
|
||||
exc = AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DOES_NOT_EXIST"],
|
||||
error_message="ADMIN_USER_DOES_NOT_EXIST",
|
||||
payload={"email": email},
|
||||
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DEACTIVATED"],
|
||||
error_message="ADMIN_USER_DEACTIVATED",
|
||||
)
|
||||
url = urljoin(
|
||||
base_host(request=request, is_admin=True),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# base requirements
|
||||
|
||||
# django
|
||||
Django==4.2.17
|
||||
Django==4.2.18
|
||||
# rest framework
|
||||
djangorestframework==3.15.2
|
||||
# postgres
|
||||
|
||||
+6
-6
@@ -16,10 +16,10 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@hocuspocus/extension-database": "^2.11.3",
|
||||
"@hocuspocus/extension-logger": "^2.11.3",
|
||||
"@hocuspocus/extension-redis": "^2.13.5",
|
||||
"@hocuspocus/server": "^2.11.3",
|
||||
"@hocuspocus/extension-database": "^2.15.0",
|
||||
"@hocuspocus/extension-logger": "^2.15.0",
|
||||
"@hocuspocus/extension-redis": "^2.15.0",
|
||||
"@hocuspocus/server": "^2.15.0",
|
||||
"@plane/constants": "*",
|
||||
"@plane/editor": "*",
|
||||
"@plane/types": "*",
|
||||
@@ -40,9 +40,9 @@
|
||||
"pino-http": "^10.3.0",
|
||||
"pino-pretty": "^11.2.2",
|
||||
"uuid": "^10.0.0",
|
||||
"y-prosemirror": "^1.2.9",
|
||||
"y-prosemirror": "^1.2.15",
|
||||
"y-protocols": "^1.0.6",
|
||||
"yjs": "^13.6.14"
|
||||
"yjs": "^13.6.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.25.6",
|
||||
|
||||
@@ -13,3 +13,4 @@ export * from "./state";
|
||||
export * from "./swr";
|
||||
export * from "./user";
|
||||
export * from "./workspace";
|
||||
export * from "./stickies";
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const STICKIES_PER_PAGE = 30;
|
||||
@@ -12,14 +12,12 @@
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"import": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs"
|
||||
"import": "./dist/index.mjs"
|
||||
},
|
||||
"./lib": {
|
||||
"require": "./dist/lib.js",
|
||||
"types": "./dist/lib.d.mts",
|
||||
"import": "./dist/lib.mjs",
|
||||
"module": "./dist/lib.mjs"
|
||||
"import": "./dist/lib.mjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
@@ -36,7 +34,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.26.4",
|
||||
"@hocuspocus/provider": "^2.13.5",
|
||||
"@hocuspocus/provider": "^2.15.0",
|
||||
"@plane/types": "*",
|
||||
"@plane/ui": "*",
|
||||
"@plane/utils": "*",
|
||||
@@ -67,12 +65,12 @@
|
||||
"prosemirror-codemark": "^0.4.2",
|
||||
"prosemirror-utils": "^1.2.2",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-markdown": "^0.8.9",
|
||||
"tiptap-markdown": "^0.8.10",
|
||||
"uuid": "^10.0.0",
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"y-prosemirror": "^1.2.5",
|
||||
"y-prosemirror": "^1.2.15",
|
||||
"y-protocols": "^1.0.6",
|
||||
"yjs": "^13.6.15"
|
||||
"yjs": "^13.6.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "*",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import { Extensions } from "@tiptap/core";
|
||||
import { AnyExtension } from "@tiptap/core";
|
||||
import { SlashCommands } from "@/extensions";
|
||||
// plane editor types
|
||||
import { TIssueEmbedConfig } from "@/plane-editor/types";
|
||||
@@ -13,15 +14,24 @@ type Props = {
|
||||
userDetails: TUserDetails;
|
||||
};
|
||||
|
||||
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
|
||||
const { disabledExtensions } = _props;
|
||||
const extensions: Extensions = disabledExtensions?.includes("slash-commands")
|
||||
? []
|
||||
: [
|
||||
SlashCommands({
|
||||
disabledExtensions,
|
||||
}),
|
||||
];
|
||||
|
||||
return extensions;
|
||||
type ExtensionConfig = {
|
||||
isEnabled: (disabledExtensions: TExtensions[]) => boolean;
|
||||
getExtension: (props: Props) => AnyExtension;
|
||||
};
|
||||
|
||||
const extensionRegistry: ExtensionConfig[] = [
|
||||
{
|
||||
isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"),
|
||||
getExtension: () => SlashCommands({}),
|
||||
},
|
||||
];
|
||||
|
||||
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
|
||||
const { disabledExtensions = [] } = _props;
|
||||
|
||||
const documentExtensions = extensionRegistry
|
||||
.filter((config) => config.isEnabled(disabledExtensions))
|
||||
.map((config) => config.getExtension(_props));
|
||||
|
||||
return documentExtensions;
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { TSlashCommandAdditionalOption } from "@/extensions";
|
||||
import { TExtensions } from "@/types";
|
||||
|
||||
type Props = {
|
||||
disabledExtensions: TExtensions[];
|
||||
disabledExtensions?: TExtensions[];
|
||||
};
|
||||
|
||||
export const coreEditorAdditionalSlashCommandOptions = (props: Props): TSlashCommandAdditionalOption[] => {
|
||||
|
||||
@@ -202,8 +202,7 @@ export const ImageItem = (editor: Editor): EditorMenuItem<"image"> => ({
|
||||
key: "image",
|
||||
name: "Image",
|
||||
isActive: () => editor?.isActive("image") || editor?.isActive("imageComponent"),
|
||||
command: ({ savedSelection }) =>
|
||||
insertImage({ editor, event: "insert", pos: savedSelection?.from ?? editor.state.selection.from }),
|
||||
command: () => insertImage({ editor, event: "insert", pos: editor.state.selection.from }),
|
||||
icon: ImageIcon,
|
||||
});
|
||||
|
||||
|
||||
@@ -39,11 +39,11 @@ import {
|
||||
setText,
|
||||
} from "@/helpers/editor-commands";
|
||||
// types
|
||||
import { CommandProps, ISlashCommandItem, TExtensions, TSlashCommandSectionKeys } from "@/types";
|
||||
import { CommandProps, ISlashCommandItem, TSlashCommandSectionKeys } from "@/types";
|
||||
// plane editor extensions
|
||||
import { coreEditorAdditionalSlashCommandOptions } from "@/plane-editor/extensions";
|
||||
// local types
|
||||
import { TSlashCommandAdditionalOption } from "./root";
|
||||
import { TExtensionProps } from "./root";
|
||||
|
||||
export type TSlashCommandSection = {
|
||||
key: TSlashCommandSectionKeys;
|
||||
@@ -51,13 +51,8 @@ export type TSlashCommandSection = {
|
||||
items: ISlashCommandItem[];
|
||||
};
|
||||
|
||||
type TArgs = {
|
||||
additionalOptions?: TSlashCommandAdditionalOption[];
|
||||
disabledExtensions: TExtensions[];
|
||||
};
|
||||
|
||||
export const getSlashCommandFilteredSections =
|
||||
(args: TArgs) =>
|
||||
(args: TExtensionProps) =>
|
||||
({ query }: { query: string }): TSlashCommandSection[] => {
|
||||
const { additionalOptions, disabledExtensions } = args;
|
||||
const SLASH_COMMAND_SECTIONS: TSlashCommandSection[] = [
|
||||
|
||||
@@ -103,9 +103,9 @@ const renderItems = () => {
|
||||
};
|
||||
};
|
||||
|
||||
type TExtensionProps = {
|
||||
export type TExtensionProps = {
|
||||
additionalOptions?: TSlashCommandAdditionalOption[];
|
||||
disabledExtensions: TExtensions[];
|
||||
disabledExtensions?: TExtensions[];
|
||||
};
|
||||
|
||||
export const SlashCommands = (props: TExtensionProps) =>
|
||||
|
||||
@@ -1,27 +1,21 @@
|
||||
import { MutableRefObject } from "react";
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
import { Editor } from "@tiptap/react";
|
||||
|
||||
export const insertContentAtSavedSelection = (
|
||||
editorRef: MutableRefObject<Editor | null>,
|
||||
content: string,
|
||||
savedSelection: Selection
|
||||
) => {
|
||||
if (!editorRef.current || editorRef.current.isDestroyed) {
|
||||
export const insertContentAtSavedSelection = (editor: Editor, content: string) => {
|
||||
if (!editor || editor.isDestroyed) {
|
||||
console.error("Editor reference is not available or has been destroyed.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!savedSelection) {
|
||||
if (!editor.state.selection) {
|
||||
console.error("Saved selection is invalid.");
|
||||
return;
|
||||
}
|
||||
|
||||
const docSize = editorRef.current.state.doc.content.size;
|
||||
const safePosition = Math.max(0, Math.min(savedSelection.anchor, docSize));
|
||||
const docSize = editor.state.doc.content.size;
|
||||
const safePosition = Math.max(0, Math.min(editor.state.selection.anchor, docSize));
|
||||
|
||||
try {
|
||||
editorRef.current.chain().focus().insertContentAt(safePosition, content).run();
|
||||
editor.chain().focus().insertContentAt(safePosition, content).run();
|
||||
} catch (error) {
|
||||
console.error("An error occurred while inserting content at saved selection:", error);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useImperativeHandle, useRef, MutableRefObject, useState, useEffect } from "react";
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import { DOMSerializer } from "@tiptap/pm/model";
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
import { EditorProps } from "@tiptap/pm/view";
|
||||
import { useEditor as useTiptapEditor, Editor, Extensions } from "@tiptap/react";
|
||||
import { useEditor as useTiptapEditor, Extensions } from "@tiptap/react";
|
||||
import { useImperativeHandle, MutableRefObject, useEffect } from "react";
|
||||
import * as Y from "yjs";
|
||||
// components
|
||||
import { EditorMenuItem, getEditorMenuItems } from "@/components/menus";
|
||||
import { getEditorMenuItems } from "@/components/menus";
|
||||
// extensions
|
||||
import { CoreEditorExtensions } from "@/extensions";
|
||||
// helpers
|
||||
@@ -71,14 +70,12 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
provider,
|
||||
autofocus = false,
|
||||
} = props;
|
||||
// states
|
||||
const [savedSelection, setSavedSelection] = useState<Selection | null>(null);
|
||||
// refs
|
||||
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||
const savedSelectionRef = useRef(savedSelection);
|
||||
|
||||
const editor = useTiptapEditor(
|
||||
{
|
||||
editable,
|
||||
immediatelyRender: false,
|
||||
shouldRerenderOnTransaction: false,
|
||||
autofocus,
|
||||
editorProps: {
|
||||
...CoreEditorProps({
|
||||
@@ -100,8 +97,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
],
|
||||
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
|
||||
onCreate: () => handleEditorReady?.(true),
|
||||
onTransaction: ({ editor }) => {
|
||||
setSavedSelection(editor.state.selection);
|
||||
onTransaction: () => {
|
||||
onTransaction?.();
|
||||
},
|
||||
onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()),
|
||||
@@ -110,23 +106,17 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
[editable]
|
||||
);
|
||||
|
||||
// Update the ref whenever savedSelection changes
|
||||
useEffect(() => {
|
||||
savedSelectionRef.current = savedSelection;
|
||||
}, [savedSelection]);
|
||||
|
||||
// Effect for syncing SWR data
|
||||
useEffect(() => {
|
||||
// value is null when intentionally passed where syncing is not yet
|
||||
// supported and value is undefined when the data from swr is not populated
|
||||
if (value === null || value === undefined) return;
|
||||
if (value == null) return;
|
||||
if (editor && !editor.isDestroyed && !editor.storage.imageComponent.uploadInProgress) {
|
||||
try {
|
||||
editor.commands.setContent(value, false, { preserveWhitespace: "full" });
|
||||
const currentSavedSelection = savedSelectionRef.current;
|
||||
if (currentSavedSelection) {
|
||||
if (editor.state.selection) {
|
||||
const docLength = editor.state.doc.content.size;
|
||||
const relativePosition = Math.min(currentSavedSelection.from, docLength - 1);
|
||||
const relativePosition = Math.min(editor.state.selection.from, docLength - 1);
|
||||
editor.commands.setTextSelection(relativePosition);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -138,46 +128,40 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
useImperativeHandle(
|
||||
forwardedRef,
|
||||
() => ({
|
||||
blur: () => editorRef.current?.commands.blur(),
|
||||
blur: () => editor.commands.blur(),
|
||||
scrollToNodeViaDOMCoordinates(behavior?: ScrollBehavior, pos?: number) {
|
||||
const resolvedPos = pos ?? savedSelection?.from;
|
||||
if (!editorRef.current || !resolvedPos) return;
|
||||
scrollToNodeViaDOMCoordinates(editorRef.current, resolvedPos, behavior);
|
||||
const resolvedPos = pos ?? editor.state.selection.from;
|
||||
if (!editor || !resolvedPos) return;
|
||||
scrollToNodeViaDOMCoordinates(editor, resolvedPos, behavior);
|
||||
},
|
||||
getCurrentCursorPosition: () => savedSelection?.from,
|
||||
getCurrentCursorPosition: () => editor.state.selection.from,
|
||||
clearEditor: (emitUpdate = false) => {
|
||||
editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
|
||||
editor?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
|
||||
},
|
||||
setEditorValue: (content: string) => {
|
||||
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
|
||||
editor?.commands.setContent(content, false, { preserveWhitespace: "full" });
|
||||
},
|
||||
setEditorValueAtCursorPosition: (content: string) => {
|
||||
if (savedSelection) {
|
||||
insertContentAtSavedSelection(editorRef, content, savedSelection);
|
||||
if (editor.state.selection) {
|
||||
insertContentAtSavedSelection(editor, content);
|
||||
}
|
||||
},
|
||||
executeMenuItemCommand: (props) => {
|
||||
const { itemKey } = props;
|
||||
const editorItems = getEditorMenuItems(editorRef.current);
|
||||
const editorItems = getEditorMenuItems(editor);
|
||||
|
||||
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
|
||||
|
||||
const item = getEditorMenuItem(itemKey);
|
||||
if (item) {
|
||||
if (item.key === "image") {
|
||||
(item as EditorMenuItem<"image">).command({
|
||||
savedSelection: savedSelectionRef.current,
|
||||
});
|
||||
} else {
|
||||
item.command(props);
|
||||
}
|
||||
item.command(props);
|
||||
} else {
|
||||
console.warn(`No command found for item: ${itemKey}`);
|
||||
}
|
||||
},
|
||||
isMenuItemActive: (props) => {
|
||||
const { itemKey } = props;
|
||||
const editorItems = getEditorMenuItems(editorRef.current);
|
||||
const editorItems = getEditorMenuItems(editor);
|
||||
|
||||
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
|
||||
const item = getEditorMenuItem(itemKey);
|
||||
@@ -187,20 +171,20 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
},
|
||||
onHeadingChange: (callback: (headings: IMarking[]) => void) => {
|
||||
// Subscribe to update event emitted from headers extension
|
||||
editorRef.current?.on("update", () => {
|
||||
callback(editorRef.current?.storage.headingList.headings);
|
||||
editor?.on("update", () => {
|
||||
callback(editor?.storage.headingList.headings);
|
||||
});
|
||||
// Return a function to unsubscribe to the continuous transactions of
|
||||
// the editor on unmounting the component that has subscribed to this
|
||||
// method
|
||||
return () => {
|
||||
editorRef.current?.off("update");
|
||||
editor?.off("update");
|
||||
};
|
||||
},
|
||||
getHeadings: () => editorRef?.current?.storage.headingList.headings,
|
||||
getHeadings: () => editor?.storage.headingList.headings,
|
||||
onStateChange: (callback: () => void) => {
|
||||
// Subscribe to editor state changes
|
||||
editorRef.current?.on("transaction", () => {
|
||||
editor?.on("transaction", () => {
|
||||
callback();
|
||||
});
|
||||
|
||||
@@ -208,17 +192,17 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
// the editor on unmounting the component that has subscribed to this
|
||||
// method
|
||||
return () => {
|
||||
editorRef.current?.off("transaction");
|
||||
editor?.off("transaction");
|
||||
};
|
||||
},
|
||||
getMarkDown: (): string => {
|
||||
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
|
||||
const markdownOutput = editor?.storage.markdown.getMarkdown();
|
||||
return markdownOutput;
|
||||
},
|
||||
getDocument: () => {
|
||||
const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null;
|
||||
const documentHTML = editorRef.current?.getHTML() ?? "<p></p>";
|
||||
const documentJSON = editorRef.current?.getJSON() ?? null;
|
||||
const documentHTML = editor?.getHTML() ?? "<p></p>";
|
||||
const documentJSON = editor.getJSON() ?? null;
|
||||
|
||||
return {
|
||||
binary: documentBinary,
|
||||
@@ -227,19 +211,19 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
};
|
||||
},
|
||||
scrollSummary: (marking: IMarking): void => {
|
||||
if (!editorRef.current) return;
|
||||
scrollSummary(editorRef.current, marking);
|
||||
if (!editor) return;
|
||||
scrollSummary(editor, marking);
|
||||
},
|
||||
isEditorReadyToDiscard: () => editorRef.current?.storage.imageComponent.uploadInProgress === false,
|
||||
isEditorReadyToDiscard: () => editor?.storage.imageComponent.uploadInProgress === false,
|
||||
setFocusAtPosition: (position: number) => {
|
||||
if (!editorRef.current || editorRef.current.isDestroyed) {
|
||||
if (!editor || editor.isDestroyed) {
|
||||
console.error("Editor reference is not available or has been destroyed.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const docSize = editorRef.current.state.doc.content.size;
|
||||
const docSize = editor.state.doc.content.size;
|
||||
const safePosition = Math.max(0, Math.min(position, docSize));
|
||||
editorRef.current
|
||||
editor
|
||||
.chain()
|
||||
.insertContentAt(safePosition, [{ type: "paragraph" }])
|
||||
.focus()
|
||||
@@ -249,17 +233,17 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
}
|
||||
},
|
||||
getSelectedText: () => {
|
||||
if (!editorRef.current) return null;
|
||||
if (!editor) return null;
|
||||
|
||||
const { state } = editorRef.current;
|
||||
const { state } = editor;
|
||||
const { from, to, empty } = state.selection;
|
||||
|
||||
if (empty) return null;
|
||||
|
||||
const nodesArray: string[] = [];
|
||||
state.doc.nodesBetween(from, to, (node, _pos, parent) => {
|
||||
if (parent === state.doc && editorRef.current) {
|
||||
const serializer = DOMSerializer.fromSchema(editorRef.current?.schema);
|
||||
if (parent === state.doc && editor) {
|
||||
const serializer = DOMSerializer.fromSchema(editor.schema);
|
||||
const dom = serializer.serializeNode(node);
|
||||
const tempDiv = document.createElement("div");
|
||||
tempDiv.appendChild(dom);
|
||||
@@ -270,28 +254,21 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
return selection;
|
||||
},
|
||||
insertText: (contentHTML, insertOnNextLine) => {
|
||||
if (!editorRef.current) return;
|
||||
// get selection
|
||||
const { from, to, empty } = editorRef.current.state.selection;
|
||||
if (!editor) return;
|
||||
const { from, to, empty } = editor.state.selection;
|
||||
if (empty) return;
|
||||
if (insertOnNextLine) {
|
||||
// move cursor to the end of the selection and insert a new line
|
||||
editorRef.current
|
||||
.chain()
|
||||
.focus()
|
||||
.setTextSelection(to)
|
||||
.insertContent("<br />")
|
||||
.insertContent(contentHTML)
|
||||
.run();
|
||||
editor.chain().focus().setTextSelection(to).insertContent("<br />").insertContent(contentHTML).run();
|
||||
} else {
|
||||
// replace selected text with the content provided
|
||||
editorRef.current.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run();
|
||||
editor.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run();
|
||||
}
|
||||
},
|
||||
getDocumentInfo: () => ({
|
||||
characters: editorRef?.current?.storage?.characterCount?.characters?.() ?? 0,
|
||||
paragraphs: getParagraphCount(editorRef?.current?.state),
|
||||
words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0,
|
||||
characters: editor?.storage?.characterCount?.characters?.() ?? 0,
|
||||
paragraphs: getParagraphCount(editor?.state),
|
||||
words: editor?.storage?.characterCount?.words?.() ?? 0,
|
||||
}),
|
||||
setProviderDocument: (value) => {
|
||||
const document = provider?.document;
|
||||
@@ -301,16 +278,12 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
emitRealTimeUpdate: (message: TDocumentEventsServer) => provider?.sendStateless(message),
|
||||
listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) },
|
||||
}),
|
||||
[editorRef, savedSelection]
|
||||
[editor]
|
||||
);
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// the editorRef is used to access the editor instance from outside the hook
|
||||
// and should only be used after editor is initialized
|
||||
editorRef.current = editor;
|
||||
|
||||
return editor;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useImperativeHandle, useRef, MutableRefObject, useEffect } from "react";
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import { EditorProps } from "@tiptap/pm/view";
|
||||
import { useEditor as useCustomEditor, Editor, Extensions } from "@tiptap/react";
|
||||
import { useEditor as useTiptapEditor, Extensions } from "@tiptap/react";
|
||||
import { useImperativeHandle, MutableRefObject, useEffect } from "react";
|
||||
import * as Y from "yjs";
|
||||
// extensions
|
||||
import { CoreReadOnlyEditorExtensions } from "@/extensions";
|
||||
@@ -11,13 +11,7 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node";
|
||||
// props
|
||||
import { CoreReadOnlyEditorProps } from "@/props";
|
||||
// types
|
||||
import type {
|
||||
EditorReadOnlyRefApi,
|
||||
TExtensions,
|
||||
TDocumentEventsServer,
|
||||
TFileHandler,
|
||||
TReadOnlyMentionHandler,
|
||||
} from "@/types";
|
||||
import type { EditorReadOnlyRefApi, TExtensions, TFileHandler, TReadOnlyMentionHandler } from "@/types";
|
||||
|
||||
interface CustomReadOnlyEditorProps {
|
||||
disabledExtensions: TExtensions[];
|
||||
@@ -46,8 +40,10 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
|
||||
provider,
|
||||
} = props;
|
||||
|
||||
const editor = useCustomEditor({
|
||||
const editor = useTiptapEditor({
|
||||
editable: false,
|
||||
immediatelyRender: true,
|
||||
shouldRerenderOnTransaction: false,
|
||||
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
|
||||
editorProps: {
|
||||
...CoreReadOnlyEditorProps({
|
||||
@@ -77,23 +73,21 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
|
||||
if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue, false, { preserveWhitespace: "full" });
|
||||
}, [editor, initialValue]);
|
||||
|
||||
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
clearEditor: (emitUpdate = false) => {
|
||||
editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
|
||||
editor?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
|
||||
},
|
||||
setEditorValue: (content: string) => {
|
||||
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
|
||||
editor?.commands.setContent(content, false, { preserveWhitespace: "full" });
|
||||
},
|
||||
getMarkDown: (): string => {
|
||||
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
|
||||
const markdownOutput = editor?.storage.markdown.getMarkdown();
|
||||
return markdownOutput;
|
||||
},
|
||||
getDocument: () => {
|
||||
const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null;
|
||||
const documentHTML = editorRef.current?.getHTML() ?? "<p></p>";
|
||||
const documentJSON = editorRef.current?.getJSON() ?? null;
|
||||
const documentHTML = editor?.getHTML() ?? "<p></p>";
|
||||
const documentJSON = editor?.getJSON() ?? null;
|
||||
|
||||
return {
|
||||
binary: documentBinary,
|
||||
@@ -102,35 +96,22 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
|
||||
};
|
||||
},
|
||||
scrollSummary: (marking: IMarking): void => {
|
||||
if (!editorRef.current) return;
|
||||
scrollSummary(editorRef.current, marking);
|
||||
if (!editor) return;
|
||||
scrollSummary(editor, marking);
|
||||
},
|
||||
getDocumentInfo: () => ({
|
||||
characters: editorRef?.current?.storage?.characterCount?.characters?.() ?? 0,
|
||||
paragraphs: getParagraphCount(editorRef?.current?.state),
|
||||
words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0,
|
||||
}),
|
||||
onHeadingChange: (callback: (headings: IMarking[]) => void) => {
|
||||
// Subscribe to update event emitted from headers extension
|
||||
editorRef.current?.on("update", () => {
|
||||
callback(editorRef.current?.storage.headingList.headings);
|
||||
});
|
||||
// Return a function to unsubscribe to the continuous transactions of
|
||||
// the editor on unmounting the component that has subscribed to this
|
||||
// method
|
||||
return () => {
|
||||
editorRef.current?.off("update");
|
||||
getDocumentInfo: () => {
|
||||
if (!editor) return;
|
||||
return {
|
||||
characters: editor.storage?.characterCount?.characters?.() ?? 0,
|
||||
paragraphs: getParagraphCount(editor.state),
|
||||
words: editor.storage?.characterCount?.words?.() ?? 0,
|
||||
};
|
||||
},
|
||||
emitRealTimeUpdate: (message: TDocumentEventsServer) => provider?.sendStateless(message),
|
||||
listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) },
|
||||
getHeadings: () => editorRef?.current?.storage.headingList.headings,
|
||||
}));
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
editorRef.current = editor;
|
||||
return editor;
|
||||
};
|
||||
|
||||
@@ -86,10 +86,6 @@ export type EditorReadOnlyRefApi = {
|
||||
paragraphs: number;
|
||||
words: number;
|
||||
};
|
||||
onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void;
|
||||
getHeadings: () => IMarking[];
|
||||
emitRealTimeUpdate: (action: TDocumentEventsServer) => void;
|
||||
listenToRealTimeUpdate: () => TDocumentEventEmitter | undefined;
|
||||
};
|
||||
|
||||
export interface EditorRefApi extends EditorReadOnlyRefApi {
|
||||
@@ -105,6 +101,10 @@ export interface EditorRefApi extends EditorReadOnlyRefApi {
|
||||
getSelectedText: () => string | null;
|
||||
insertText: (contentHTML: string, insertOnNextLine?: boolean) => void;
|
||||
setProviderDocument: (value: Uint8Array) => void;
|
||||
onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void;
|
||||
getHeadings: () => IMarking[];
|
||||
emitRealTimeUpdate: (action: TDocumentEventsServer) => void;
|
||||
listenToRealTimeUpdate: () => TDocumentEventEmitter | undefined;
|
||||
}
|
||||
|
||||
// editor props
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
.ProseMirror p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: rgb(var(--color-text-400));
|
||||
color: var(--color-placeholder);
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
@@ -34,7 +34,7 @@
|
||||
.ProseMirror p.is-empty::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: rgb(var(--color-text-400));
|
||||
color: var(--color-placeholder);
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
@@ -145,7 +145,7 @@ ul[data-type="taskList"] li > label input[type="checkbox"] {
|
||||
position: relative;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-color: rgb(var(--color-background-100));
|
||||
background-color: transparent;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
width: 0.8rem;
|
||||
@@ -192,7 +192,7 @@ ul[data-type="taskList"] li > div {
|
||||
|
||||
ul[data-type="taskList"] li[data-checked="true"] {
|
||||
& > div > p.editor-paragraph-block {
|
||||
color: rgb(var(--color-text-400));
|
||||
color: var(--color-placeholder);
|
||||
}
|
||||
|
||||
[data-text-color] {
|
||||
|
||||
@@ -1,42 +1,6 @@
|
||||
:root {
|
||||
/* text colors */
|
||||
--editor-colors-gray-text: #5c5e63;
|
||||
--editor-colors-peach-text: #ff5b59;
|
||||
--editor-colors-pink-text: #f65385;
|
||||
--editor-colors-orange-text: #fd9038;
|
||||
--editor-colors-green-text: #0fc27b;
|
||||
--editor-colors-light-blue-text: #17bee9;
|
||||
--editor-colors-dark-blue-text: #266df0;
|
||||
--editor-colors-purple-text: #9162f9;
|
||||
/* end text colors */
|
||||
}
|
||||
|
||||
/* text background colors */
|
||||
[data-theme="light"],
|
||||
[data-theme="light-contrast"] {
|
||||
--editor-colors-gray-background: #d6d6d8;
|
||||
--editor-colors-peach-background: #ffd5d7;
|
||||
--editor-colors-pink-background: #fdd4e3;
|
||||
--editor-colors-orange-background: #ffe3cd;
|
||||
--editor-colors-green-background: #c3f0de;
|
||||
--editor-colors-light-blue-background: #c5eff9;
|
||||
--editor-colors-dark-blue-background: #c9dafb;
|
||||
--editor-colors-purple-background: #e3d8fd;
|
||||
}
|
||||
[data-theme="dark"],
|
||||
[data-theme="dark-contrast"] {
|
||||
--editor-colors-gray-background: #404144;
|
||||
--editor-colors-peach-background: #593032;
|
||||
--editor-colors-pink-background: #562e3d;
|
||||
--editor-colors-orange-background: #583e2a;
|
||||
--editor-colors-green-background: #1d4a3b;
|
||||
--editor-colors-light-blue-background: #1f495c;
|
||||
--editor-colors-dark-blue-background: #223558;
|
||||
--editor-colors-purple-background: #3d325a;
|
||||
}
|
||||
/* end text background colors */
|
||||
|
||||
.editor-container {
|
||||
--color-placeholder: rgba(var(--color-text-100), 0.5);
|
||||
|
||||
/* font sizes and line heights */
|
||||
&.large-font {
|
||||
--font-size-h1: 1.75rem;
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
|
||||
},
|
||||
"dependencies": {
|
||||
"@plane/utils": "*"
|
||||
"@plane/utils": "*",
|
||||
"intl-messageformat": "^10.7.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "*",
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import React, { createContext, useEffect } from "react";
|
||||
import { Language, languages } from "../config";
|
||||
import { TranslationStore } from "./store";
|
||||
|
||||
// Create the store instance
|
||||
const translationStore = new TranslationStore();
|
||||
|
||||
// Create Context
|
||||
export const TranslationContext = createContext<TranslationStore>(translationStore);
|
||||
|
||||
export const TranslationProvider = observer(({ children }: { children: React.ReactNode }) => {
|
||||
// Handle storage events for cross-tab synchronization
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (event: StorageEvent) => {
|
||||
if (event.key === "userLanguage" && event.newValue) {
|
||||
const newLang = event.newValue as Language;
|
||||
if (languages.includes(newLang)) {
|
||||
translationStore.setLanguage(newLang);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("storage", handleStorageChange);
|
||||
return () => window.removeEventListener("storage", handleStorageChange);
|
||||
}, []);
|
||||
|
||||
return <TranslationContext.Provider value={translationStore}>{children}</TranslationContext.Provider>;
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { makeObservable, observable } from "mobx";
|
||||
import { Language, fallbackLng, languages, translations } from "../config";
|
||||
|
||||
export class TranslationStore {
|
||||
currentLocale: Language = fallbackLng;
|
||||
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
currentLocale: observable.ref,
|
||||
});
|
||||
this.initializeLanguage();
|
||||
}
|
||||
|
||||
get availableLanguages() {
|
||||
return languages;
|
||||
}
|
||||
|
||||
t(key: string) {
|
||||
return translations[this.currentLocale]?.[key] || translations[fallbackLng][key] || key;
|
||||
}
|
||||
|
||||
setLanguage(lng: Language) {
|
||||
try {
|
||||
localStorage.setItem("userLanguage", lng);
|
||||
this.currentLocale = lng;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
initializeLanguage() {
|
||||
if (typeof window === "undefined") return;
|
||||
const savedLocale = localStorage.getItem("userLanguage") as Language;
|
||||
if (savedLocale && languages.includes(savedLocale)) {
|
||||
this.setLanguage(savedLocale);
|
||||
} else {
|
||||
const browserLang = navigator.language.split("-")[0] as Language;
|
||||
const newLocale = languages.includes(browserLang as Language) ? (browserLang as Language) : fallbackLng;
|
||||
this.setLanguage(newLocale);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import en from "../locales/en/translations.json";
|
||||
import fr from "../locales/fr/translations.json";
|
||||
import es from "../locales/es/translations.json";
|
||||
import ja from "../locales/ja/translations.json";
|
||||
|
||||
export type Language = (typeof languages)[number];
|
||||
export type Translations = {
|
||||
[key: string]: {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const fallbackLng = "en";
|
||||
export const languages = ["en", "fr", "es", "ja"] as const;
|
||||
export const translations: Translations = {
|
||||
en,
|
||||
fr,
|
||||
es,
|
||||
ja,
|
||||
};
|
||||
|
||||
export const SUPPORTED_LANGUAGES = [
|
||||
{
|
||||
label: "English",
|
||||
value: "en",
|
||||
},
|
||||
{
|
||||
label: "French",
|
||||
value: "fr",
|
||||
},
|
||||
{
|
||||
label: "Spanish",
|
||||
value: "es",
|
||||
},
|
||||
{
|
||||
label: "Japanese",
|
||||
value: "ja",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./language";
|
||||
@@ -0,0 +1,13 @@
|
||||
import { TLanguage, ILanguageOption } from "../types";
|
||||
|
||||
export const FALLBACK_LANGUAGE: TLanguage = "en";
|
||||
|
||||
export const SUPPORTED_LANGUAGES: ILanguageOption[] = [
|
||||
{ label: "English", value: "en" },
|
||||
{ label: "Français", value: "fr" },
|
||||
{ label: "Español", value: "es" },
|
||||
{ label: "日本語", value: "ja" },
|
||||
{ label: "中文", value: "zh-CN" },
|
||||
];
|
||||
|
||||
export const STORAGE_KEY = "userLanguage";
|
||||
@@ -0,0 +1,19 @@
|
||||
import { observer } from "mobx-react";
|
||||
import React, { createContext } from "react";
|
||||
// store
|
||||
import { TranslationStore } from "../store";
|
||||
|
||||
export const TranslationContext = createContext<TranslationStore | null>(null);
|
||||
|
||||
interface TranslationProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the translation store to the application
|
||||
*/
|
||||
export const TranslationProvider: React.FC<TranslationProviderProps> = observer(({ children }) => {
|
||||
const [store] = React.useState(() => new TranslationStore());
|
||||
|
||||
return <TranslationContext.Provider value={store}>{children}</TranslationContext.Provider>;
|
||||
});
|
||||
@@ -1,17 +1,35 @@
|
||||
import { useContext } from "react";
|
||||
import { TranslationContext } from "../components";
|
||||
import { Language } from "../config";
|
||||
import { useContext } from 'react';
|
||||
// context
|
||||
import { TranslationContext } from '../context';
|
||||
// types
|
||||
import { ILanguageOption, TLanguage } from '../types';
|
||||
|
||||
export function useTranslation() {
|
||||
export type TTranslationStore = {
|
||||
t: (key: string, params?: Record<string, any>) => string;
|
||||
currentLocale: TLanguage;
|
||||
changeLanguage: (lng: TLanguage) => void;
|
||||
languages: ILanguageOption[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides the translation store to the application
|
||||
* @returns {TTranslationStore}
|
||||
* @returns {(key: string, params?: Record<string, any>) => string} t: method to translate the key with params
|
||||
* @returns {TLanguage} currentLocale - current locale language
|
||||
* @returns {(lng: TLanguage) => void} changeLanguage - method to change the language
|
||||
* @returns {ILanguageOption[]} languages - available languages
|
||||
* @throws {Error} if the TranslationProvider is not used
|
||||
*/
|
||||
export function useTranslation(): TTranslationStore {
|
||||
const store = useContext(TranslationContext);
|
||||
if (!store) {
|
||||
throw new Error("useTranslation must be used within a TranslationProvider");
|
||||
throw new Error('useTranslation must be used within a TranslationProvider');
|
||||
}
|
||||
|
||||
return {
|
||||
t: (key: string) => store.t(key),
|
||||
t: store.t.bind(store),
|
||||
currentLocale: store.currentLocale,
|
||||
changeLanguage: (lng: Language) => store.setLanguage(lng),
|
||||
changeLanguage: (lng: TLanguage) => store.setLanguage(lng),
|
||||
languages: store.availableLanguages,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./config";
|
||||
export * from "./components";
|
||||
export * from "./constants";
|
||||
export * from "./context";
|
||||
export * from "./hooks";
|
||||
export * from "./types";
|
||||
|
||||
@@ -19,12 +19,12 @@
|
||||
"remove_members": "Supprimer des membres",
|
||||
"add": "Ajouter",
|
||||
"remove": "Supprimer",
|
||||
"add_new": "Ajouter nouveau",
|
||||
"add_new": "Ajouter un nouveau",
|
||||
"remove_selected": "Supprimer la sélection",
|
||||
"first_name": "Prénom",
|
||||
"last_name": "Nom de famille",
|
||||
"email": "Email",
|
||||
"display_name": "Nom d'affichage",
|
||||
"display_name": "Pseudonyme",
|
||||
"role": "Rôle",
|
||||
"timezone": "Fuseau horaire",
|
||||
"avatar": "Avatar",
|
||||
@@ -32,7 +32,7 @@
|
||||
"password": "Mot de passe",
|
||||
"change_cover": "Modifier la couverture",
|
||||
"language": "Langue",
|
||||
"saving": "Enregistrement...",
|
||||
"saving": "Enregistrement en cours...",
|
||||
"save_changes": "Enregistrer les modifications",
|
||||
"deactivate_account": "Désactiver le compte",
|
||||
"deactivate_account_description": "Lors de la désactivation d'un compte, toutes les données et ressources de ce compte seront définitivement supprimées et ne pourront pas être récupérées.",
|
||||
@@ -43,14 +43,14 @@
|
||||
"activity": "Activité",
|
||||
"appearance": "Apparence",
|
||||
"notifications": "Notifications",
|
||||
"workspaces": "Workspaces",
|
||||
"create_workspace": "Créer un workspace",
|
||||
"workspaces": "Espaces de travail",
|
||||
"create_workspace": "Créer un espace de travail",
|
||||
"invitations": "Invitations",
|
||||
"summary": "Résumé",
|
||||
"assigned": "Assigné",
|
||||
"created": "Créé",
|
||||
"subscribed": "Souscrit",
|
||||
"you_do_not_have_the_permission_to_access_this_page": "Vous n'avez pas les permissions pour accéder à cette page.",
|
||||
"you_do_not_have_the_permission_to_access_this_page": "Vous n'avez pas les permissions d'accéder à cette page.",
|
||||
"failed_to_sign_out_please_try_again": "Impossible de se déconnecter. Veuillez réessayer.",
|
||||
"password_changed_successfully": "Mot de passe changé avec succès.",
|
||||
"something_went_wrong_please_try_again": "Quelque chose s'est mal passé. Veuillez réessayer.",
|
||||
@@ -62,7 +62,7 @@
|
||||
"this_field_is_required": "Ce champ est requis",
|
||||
"passwords_dont_match": "Les mots de passe ne correspondent pas",
|
||||
"please_enter_your_password": "Veuillez entrer votre mot de passe.",
|
||||
"password_length_should_me_more_than_8_characters": "La longueur du mot de passe doit être supérieure à 8 caractères.",
|
||||
"password_length_should_me_more_than_8_characters": "La longueur du mot de passe doit faire au moins 8 caractères.",
|
||||
"password_is_weak": "Le mot de passe est faible.",
|
||||
"password_is_strong": "Le mot de passe est fort.",
|
||||
"load_more": "Charger plus",
|
||||
@@ -106,8 +106,8 @@
|
||||
"comments_description": "Me notifier lorsqu'un utilisateur commente un problème",
|
||||
"mentions": "Mention",
|
||||
"mentions_description": "Me notifier uniquement lorsqu'un utilisateur mentionne un problème",
|
||||
"create_your_workspace": "Créer votre workspace",
|
||||
"only_your_instance_admin_can_create_workspaces": "Seuls les administrateurs de votre instance peuvent créer des workspaces",
|
||||
"create_your_workspace": "Créer votre espace de travail",
|
||||
"only_your_instance_admin_can_create_workspaces": "Seuls les administrateurs de votre instance peuvent créer des espaces de travail",
|
||||
"only_your_instance_admin_can_create_workspaces_description": "Si vous connaissez l'adresse email de votre administrateur d'instance, cliquez sur le bouton ci-dessous pour les contacter.",
|
||||
"go_back": "Retour",
|
||||
"request_instance_admin": "Demander à l'administrateur de l'instance",
|
||||
@@ -135,7 +135,7 @@
|
||||
"old_password": "Mot de passe actuel",
|
||||
"general_settings": "Paramètres généraux",
|
||||
"sign_out": "Déconnexion",
|
||||
"signing_out": "Déconnexion",
|
||||
"signing_out": "Déconnexion en cours",
|
||||
"active_cycles": "Cycles actifs",
|
||||
"active_cycles_description": "Surveillez les cycles dans les projets, suivez les issues de haute priorité et zoomez sur les cycles qui nécessitent attention.",
|
||||
"on_demand_snapshots_of_all_your_cycles": "Captures instantanées sur demande de tous vos cycles",
|
||||
@@ -190,17 +190,17 @@
|
||||
"project_created_successfully_description": "Projet créé avec succès. Vous pouvez maintenant ajouter des issues à ce projet.",
|
||||
"project_cover_image_alt": "Image de couverture du projet",
|
||||
"name_is_required": "Le nom est requis",
|
||||
"title_should_be_less_than_255_characters": "Le titre doit être inférieur à 255 caractères",
|
||||
"title_should_be_less_than_255_characters": "Le titre ne doit pas dépasser 255 caractères",
|
||||
"project_name": "Nom du projet",
|
||||
"project_id_must_be_at_least_1_character": "Le projet ID doit être au moins de 1 caractère",
|
||||
"project_id_must_be_at_most_5_characters": "Le projet ID doit être au plus de 5 caractères",
|
||||
"project_id_must_be_at_least_1_character": "L'ID du projet doit être au moins de 1 caractère",
|
||||
"project_id_must_be_at_most_5_characters": "L'ID du projet doit être au plus de 5 caractères",
|
||||
"project_id": "ID du projet",
|
||||
"project_id_tooltip_content": "Aide à identifier les issues du projet de manière unique. Max 5 caractères.",
|
||||
"description_placeholder": "Description...",
|
||||
"only_alphanumeric_non_latin_characters_allowed": "Seuls les caractères alphanumériques et non latins sont autorisés.",
|
||||
"project_id_is_required": "Le projet ID est requis",
|
||||
"project_id_is_required": "L'ID du projet est requis",
|
||||
"select_network": "Sélectionner le réseau",
|
||||
"lead": "Lead",
|
||||
"lead": "Responsable",
|
||||
"private": "Privé",
|
||||
"public": "Public",
|
||||
"accessible_only_by_invite": "Accessible uniquement par invitation",
|
||||
@@ -245,11 +245,11 @@
|
||||
"contact_sales": "Contacter les ventes",
|
||||
"hyper_mode": "Mode hyper",
|
||||
"keyboard_shortcuts": "Raccourcis clavier",
|
||||
"whats_new": "Nouveautés?",
|
||||
"whats_new": "Nouveautés ?",
|
||||
"version": "Version",
|
||||
"we_are_having_trouble_fetching_the_updates": "Nous avons des difficultés à récupérer les mises à jour.",
|
||||
"our_changelogs": "nos changelogs",
|
||||
"for_the_latest_updates": "pour les dernières mises à jour.",
|
||||
"our_changelogs": "Notre journal de modifications",
|
||||
"for_the_latest_updates": "Pour les dernières mises à jour.",
|
||||
"please_visit": "Veuillez visiter",
|
||||
"docs": "Documentation",
|
||||
"full_changelog": "Journal complet",
|
||||
@@ -316,5 +316,5 @@
|
||||
"remove_parent_issue": "Supprimer le problème parent",
|
||||
"add_parent": "Ajouter un parent",
|
||||
"loading_members": "Chargement des membres...",
|
||||
"inbox": "boîte de réception"
|
||||
"inbox": "Boîte de réception"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
{
|
||||
"submit": "提交",
|
||||
"cancel": "取消",
|
||||
"loading": "加载中",
|
||||
"error": "错误",
|
||||
"success": "成功",
|
||||
"warning": "警告",
|
||||
"info": "信息",
|
||||
"close": "关闭",
|
||||
"yes": "是",
|
||||
"no": "否",
|
||||
"ok": "确定",
|
||||
"name": "名称",
|
||||
"description": "描述",
|
||||
"search": "搜索",
|
||||
"add_member": "添加成员",
|
||||
"remove_member": "移除成员",
|
||||
"add_members": "添加成员",
|
||||
"remove_members": "移除成员",
|
||||
"add": "添加",
|
||||
"remove": "移除",
|
||||
"add_new": "新增",
|
||||
"remove_selected": "移除选中项",
|
||||
"first_name": "名字",
|
||||
"last_name": "姓氏",
|
||||
"email": "电子邮件",
|
||||
"display_name": "显示名称",
|
||||
"role": "角色",
|
||||
"timezone": "时区",
|
||||
"avatar": "头像",
|
||||
"cover_image": "封面图片",
|
||||
"password": "密码",
|
||||
"change_cover": "更换封面",
|
||||
"language": "语言",
|
||||
"saving": "保存中...",
|
||||
"save_changes": "保存更改",
|
||||
"deactivate_account": "停用账户",
|
||||
"deactivate_account_description": "停用账户后,该账户内的所有数据和资源将被永久删除,且无法恢复。",
|
||||
"profile_settings": "个人资料设置",
|
||||
"your_account": "账户",
|
||||
"profile": "个人资料",
|
||||
"security": "安全",
|
||||
"activity": "活动",
|
||||
"appearance": "外观",
|
||||
"notifications": "通知",
|
||||
"workspaces": "工作区",
|
||||
"create_workspace": "创建工作区",
|
||||
"invitations": "邀请",
|
||||
"summary": "摘要",
|
||||
"assigned": "已分配",
|
||||
"created": "已创建",
|
||||
"subscribed": "已订阅",
|
||||
"you_do_not_have_the_permission_to_access_this_page": "您没有权限访问此页面。",
|
||||
"failed_to_sign_out_please_try_again": "登出失败,请重试。",
|
||||
"password_changed_successfully": "密码已成功更改。",
|
||||
"something_went_wrong_please_try_again": "出错了,请重试。",
|
||||
"change_password": "更改密码",
|
||||
"passwords_dont_match": "密码不匹配",
|
||||
"current_password": "当前密码",
|
||||
"new_password": "新密码",
|
||||
"confirm_password": "确认密码",
|
||||
"this_field_is_required": "此字段为必填项",
|
||||
"changing_password": "正在更改密码",
|
||||
"please_enter_your_password": "请输入您的密码。",
|
||||
"password_length_should_me_more_than_8_characters": "密码长度应大于8个字符。",
|
||||
"password_is_weak": "密码强度较弱。",
|
||||
"password_is_strong": "密码强度较强。",
|
||||
"load_more": "加载更多",
|
||||
"select_or_customize_your_interface_color_scheme": "选择或自定义界面配色方案。",
|
||||
"theme": "主题",
|
||||
"system_preference": "系统偏好",
|
||||
"light": "浅色",
|
||||
"dark": "深色",
|
||||
"light_contrast": "浅色高对比",
|
||||
"dark_contrast": "深色高对比",
|
||||
"custom": "自定义主题",
|
||||
"select_your_theme": "选择主题",
|
||||
"customize_your_theme": "自定义主题",
|
||||
"background_color": "背景颜色",
|
||||
"text_color": "文字颜色",
|
||||
"primary_color": "主色调",
|
||||
"sidebar_background_color": "侧边栏背景颜色",
|
||||
"sidebar_text_color": "侧边栏文字颜色",
|
||||
"set_theme": "设置主题",
|
||||
"enter_a_valid_hex_code_of_6_characters": "请输入6位有效的十六进制代码",
|
||||
"background_color_is_required": "背景颜色为必填项",
|
||||
"text_color_is_required": "文字颜色为必填项",
|
||||
"primary_color_is_required": "主色调为必填项",
|
||||
"sidebar_background_color_is_required": "侧边栏背景颜色为必填项",
|
||||
"sidebar_text_color_is_required": "侧边栏文字颜色为必填项",
|
||||
"updating_theme": "正在更新主题",
|
||||
"theme_updated_successfully": "主题已成功更新",
|
||||
"failed_to_update_the_theme": "主题更新失败",
|
||||
"email_notifications": "电子邮件通知",
|
||||
"stay_in_the_loop_on_issues_you_are_subscribed_to_enable_this_to_get_notified": "启用此功能以接收您订阅问题的通知。",
|
||||
"email_notification_setting_updated_successfully": "电子邮件通知设置已成功更新",
|
||||
"failed_to_update_email_notification_setting": "电子邮件通知设置更新失败",
|
||||
"notify_me_when": "在以下情况下通知我",
|
||||
"property_changes": "属性更改",
|
||||
"property_changes_description": "当负责人、优先级、估算等属性更改时通知。",
|
||||
"state_change": "状态更改",
|
||||
"state_change_description": "当问题状态更改时通知",
|
||||
"issue_completed": "问题完成",
|
||||
"issue_completed_description": "仅在问题完成时通知",
|
||||
"comments": "评论",
|
||||
"comments_description": "当有人在问题中添加评论时通知",
|
||||
"mentions": "提及",
|
||||
"mentions_description": "仅在评论或描述中提及您时通知",
|
||||
"create_your_workspace": "创建工作区",
|
||||
"only_your_instance_admin_can_create_workspaces": "只有实例管理员可以创建工作区",
|
||||
"only_your_instance_admin_can_create_workspaces_description": "如果您知道实例管理员的电子邮件地址,请点击以下按钮联系他们。",
|
||||
"go_back": "返回",
|
||||
"request_instance_admin": "请求实例管理员",
|
||||
"plane_logo": "Plane 徽标",
|
||||
"workspace_creation_disabled": "工作区创建已禁用",
|
||||
"workspace_request_subject": "新工作区请求",
|
||||
"workspace_request_body": "实例管理员:\n\n请在 URL [/workspace-name] 处创建一个新的工作区。[工作区创建目的]\n\n谢谢。\n{{firstName}} {{lastName}}\n{{email}}",
|
||||
"creating_workspace": "正在创建工作区",
|
||||
"workspace_created_successfully": "工作区已成功创建",
|
||||
"create_workspace_page": "创建工作区页面",
|
||||
"workspace_could_not_be_created_please_try_again": "无法创建工作区,请重试。",
|
||||
"workspace_could_not_be_created_please_try_again_description": "创建工作区时出错,请重试。",
|
||||
"this_is_a_required_field": "此字段为必填项。",
|
||||
"name_your_workspace": "为工作区命名",
|
||||
"workspaces_names_can_contain_only_space_dash_and_alphanumeric_characters": "工作区名称只能包含空格、破折号和字母数字字符。",
|
||||
"limit_your_name_to_80_characters": "名称长度限制为80个字符。",
|
||||
"set_your_workspace_url": "设置工作区URL",
|
||||
"limit_your_url_to_48_characters": "URL长度限制为48个字符。",
|
||||
"how_many_people_will_use_this_workspace": "有多少人将使用此工作区?",
|
||||
"how_many_people_will_use_this_workspace_description": "这将帮助确定购买的席位数量。",
|
||||
"select_a_range": "选择一个范围",
|
||||
"urls_can_contain_only_dash_and_alphanumeric_characters": "URL只能包含破折号和字母数字字符。",
|
||||
"something_familiar_and_recognizable_is_always_best": "熟悉且易于识别的名称总是最佳选择。",
|
||||
"workspace_url_is_already_taken": "工作区URL已被占用!",
|
||||
"old_password": "旧密码",
|
||||
"general_settings": "常规设置",
|
||||
"sign_out": "登出",
|
||||
"signing_out": "正在登出",
|
||||
"active_cycles": "活跃周期",
|
||||
"active_cycles_description": "监控所有项目的周期,跟踪高优先级问题,并关注需要关注的周期。",
|
||||
"on_demand_snapshots_of_all_your_cycles": "所有周期的按需快照",
|
||||
"upgrade": "升级",
|
||||
"10000_feet_view": "所有活跃周期的概览",
|
||||
"10000_feet_view_description": "放大查看所有项目的周期,而不是单独查看每个项目的周期。",
|
||||
"get_snapshot_of_each_active_cycle": "获取每个活跃周期的快照",
|
||||
"get_snapshot_of_each_active_cycle_description": "跟踪所有活跃周期的高级指标,查看进度并了解范围与截止日期的关系。",
|
||||
"compare_burndowns": "比较燃尽图",
|
||||
"compare_burndowns_description": "监控每个团队的表现,查看每个周期的燃尽报告。",
|
||||
"quickly_see_make_or_break_issues": "快速查看关键问题",
|
||||
"quickly_see_make_or_break_issues_description": "预览每个周期中与截止日期相关的高优先级问题。一键查看所有周期的详细信息。",
|
||||
"zoom_into_cycles_that_need_attention": "关注需要关注的周期",
|
||||
"zoom_into_cycles_that_need_attention_description": "一键调查未达预期的周期状态。",
|
||||
"stay_ahead_of_blockers": "提前解决阻碍",
|
||||
"stay_ahead_of_blockers_description": "发现项目间的挑战,查看其他视图中不明显的周期依赖关系。",
|
||||
"analytics": "分析",
|
||||
"workspace_invites": "工作区邀请",
|
||||
"workspace_settings": "工作区设置",
|
||||
"enter_god_mode": "进入上帝模式",
|
||||
"workspace_logo": "工作区徽标",
|
||||
"new_issue": "新建问题",
|
||||
"home": "首页",
|
||||
"your_work": "您的工作",
|
||||
"drafts": "草稿",
|
||||
"projects": "项目",
|
||||
"views": "视图",
|
||||
"workspace": "工作区",
|
||||
"archives": "归档",
|
||||
"settings": "设置",
|
||||
"failed_to_move_favorite": "移动收藏失败",
|
||||
"your_favorites": "您的收藏",
|
||||
"no_favorites_yet": "尚无收藏",
|
||||
"create_folder": "创建文件夹",
|
||||
"new_folder": "新建文件夹",
|
||||
"favorite_updated_successfully": "收藏已成功更新",
|
||||
"favorite_created_successfully": "收藏已成功创建",
|
||||
"folder_already_exists": "文件夹已存在",
|
||||
"folder_name_cannot_be_empty": "文件夹名称不能为空",
|
||||
"something_went_wrong": "出错了",
|
||||
"failed_to_reorder_favorite": "重新排序收藏失败",
|
||||
"favorite_removed_successfully": "收藏已成功移除",
|
||||
"failed_to_create_favorite": "创建收藏失败",
|
||||
"failed_to_rename_favorite": "重命名收藏失败",
|
||||
"project_link_copied_to_clipboard": "项目链接已复制到剪贴板",
|
||||
"link_copied": "链接已复制",
|
||||
"your_projects": "您的项目",
|
||||
"add_project": "添加项目",
|
||||
"create_project": "创建项目",
|
||||
"failed_to_remove_project_from_favorites": "无法从收藏中移除项目,请重试。",
|
||||
"project_created_successfully": "项目已成功创建",
|
||||
"project_created_successfully_description": "项目已成功创建。您现在可以开始添加问题。",
|
||||
"project_cover_image_alt": "项目封面图片",
|
||||
"name_is_required": "名称为必填项",
|
||||
"title_should_be_less_than_255_characters": "标题应少于255个字符",
|
||||
"project_name": "项目名称",
|
||||
"project_id_must_be_at_least_1_character": "项目ID至少为1个字符",
|
||||
"project_id_must_be_at_most_5_characters": "项目ID最多为5个字符",
|
||||
"project_id": "项目ID",
|
||||
"project_id_tooltip_content": "用于唯一标识项目中的问题。最多5个字符。",
|
||||
"description_placeholder": "描述...",
|
||||
"only_alphanumeric_non_latin_characters_allowed": "仅允许字母数字和非拉丁字符。",
|
||||
"project_id_is_required": "项目ID为必填项",
|
||||
"select_network": "选择网络",
|
||||
"lead": "负责人",
|
||||
"private": "私有",
|
||||
"public": "公开",
|
||||
"accessible_only_by_invite": "仅限邀请访问",
|
||||
"anyone_in_the_workspace_except_guests_can_join": "除访客外,工作区中的任何人都可以加入",
|
||||
"creating": "创建中",
|
||||
"creating_project": "正在创建项目",
|
||||
"adding_project_to_favorites": "正在将项目添加到收藏",
|
||||
"project_added_to_favorites": "项目已添加到收藏",
|
||||
"couldnt_add_the_project_to_favorites": "无法将项目添加到收藏,请重试。",
|
||||
"removing_project_from_favorites": "正在从收藏中移除项目",
|
||||
"project_removed_from_favorites": "项目已从收藏中移除",
|
||||
"couldnt_remove_the_project_from_favorites": "无法从收藏中移除项目,请重试。",
|
||||
"add_to_favorites": "添加到收藏",
|
||||
"remove_from_favorites": "从收藏中移除",
|
||||
"publish_settings": "发布设置",
|
||||
"publish": "发布",
|
||||
"copy_link": "复制链接",
|
||||
"leave_project": "离开项目",
|
||||
"join_the_project_to_rearrange": "加入项目以重新排序",
|
||||
"drag_to_rearrange": "拖动以重新排序",
|
||||
"congrats": "恭喜!",
|
||||
"project": "项目",
|
||||
"open_project": "打开项目",
|
||||
"issues": "问题",
|
||||
"cycles": "周期",
|
||||
"modules": "模块",
|
||||
"pages": "页面",
|
||||
"intake": "接收",
|
||||
"time_tracking": "时间跟踪",
|
||||
"work_management": "工作管理",
|
||||
"projects_and_issues": "项目与问题",
|
||||
"projects_and_issues_description": "在此项目上启用或禁用。",
|
||||
"cycles_description": "按时间段对项目工作进行时间盒管理,并调整频率。",
|
||||
"modules_description": "将工作分组为类似子项目的设置,拥有自己的负责人和分配人。",
|
||||
"views_description": "保存排序、过滤和显示选项以供以后使用或共享。",
|
||||
"pages_description": "写下一些内容。",
|
||||
"intake_description": "启用此功能以接收您订阅问题的通知。",
|
||||
"time_tracking_description": "跟踪问题和项目所花费的时间。",
|
||||
"work_management_description": "轻松管理工作和项目。",
|
||||
"documentation": "文档",
|
||||
"message_support": "联系支持",
|
||||
"contact_sales": "联系销售",
|
||||
"hyper_mode": "超速模式",
|
||||
"keyboard_shortcuts": "键盘快捷键",
|
||||
"whats_new": "新功能",
|
||||
"version": "版本",
|
||||
"we_are_having_trouble_fetching_the_updates": "获取更新时出现问题。",
|
||||
"our_changelogs": "我们的更新日志",
|
||||
"for_the_latest_updates": "获取最新更新,请访问",
|
||||
"please_visit": "请访问",
|
||||
"docs": "文档",
|
||||
"full_changelog": "完整更新日志",
|
||||
"support": "支持",
|
||||
"discord": "Discord",
|
||||
"powered_by_plane_pages": "由 Plane Pages 提供支持",
|
||||
"please_select_at_least_one_invitation": "请至少选择一个邀请。",
|
||||
"please_select_at_least_one_invitation_description": "请至少选择一个邀请以加入工作区。",
|
||||
"we_see_that_someone_has_invited_you_to_join_a_workspace": "我们发现有人邀请您加入一个工作区",
|
||||
"join_a_workspace": "加入工作区",
|
||||
"we_see_that_someone_has_invited_you_to_join_a_workspace_description": "我们发现有人邀请您加入一个工作区",
|
||||
"join_a_workspace_description": "加入工作区",
|
||||
"accept_and_join": "接受并加入",
|
||||
"go_home": "返回首页",
|
||||
"no_pending_invites": "没有待处理的邀请",
|
||||
"you_can_see_here_if_someone_invites_you_to_a_workspace": "如果有人邀请您加入工作区,您可以在此处查看",
|
||||
"back_to_home": "返回首页",
|
||||
"workspace_name": "工作区名称",
|
||||
"deactivate_your_account": "停用您的账户",
|
||||
"deactivate_your_account_description": "停用后,您将无法再分配问题,工作区的计费也将停止。要重新激活账户,您需要使用此电子邮件地址收到工作区邀请。",
|
||||
"deactivating": "正在停用",
|
||||
"confirm": "确认",
|
||||
"draft_created": "草稿已创建",
|
||||
"issue_created_successfully": "问题已成功创建",
|
||||
"draft_creation_failed": "草稿创建失败",
|
||||
"issue_creation_failed": "问题创建失败",
|
||||
"draft_issue": "草稿问题",
|
||||
"issue_updated_successfully": "问题已成功更新",
|
||||
"issue_could_not_be_updated": "无法更新问题",
|
||||
"create_a_draft": "创建草稿",
|
||||
"save_to_drafts": "保存为草稿",
|
||||
"save": "保存",
|
||||
"updating": "正在更新",
|
||||
"create_new_issue": "创建新问题",
|
||||
"editor_is_not_ready_to_discard_changes": "编辑器尚未准备好丢弃更改",
|
||||
"failed_to_move_issue_to_project": "无法将问题移动到项目",
|
||||
"create_more": "创建更多",
|
||||
"add_to_project": "添加到项目",
|
||||
"discard": "丢弃",
|
||||
"duplicate_issue_found": "发现重复问题",
|
||||
"duplicate_issues_found": "发现重复问题",
|
||||
"no_matching_results": "没有匹配的结果",
|
||||
"title_is_required": "标题为必填项",
|
||||
"title": "标题",
|
||||
"state": "状态",
|
||||
"priority": "优先级",
|
||||
"none": "无",
|
||||
"urgent": "紧急",
|
||||
"high": "高",
|
||||
"medium": "中",
|
||||
"low": "低",
|
||||
"members": "成员",
|
||||
"assignee": "分配人",
|
||||
"assignees": "分配人",
|
||||
"you": "您",
|
||||
"labels": "标签",
|
||||
"create_new_label": "创建新标签",
|
||||
"start_date": "开始日期",
|
||||
"due_date": "截止日期",
|
||||
"cycle": "周期",
|
||||
"estimate": "估算",
|
||||
"change_parent_issue": "更改父问题",
|
||||
"remove_parent_issue": "移除父问题",
|
||||
"add_parent": "添加父问题",
|
||||
"loading_members": "正在加载成员...",
|
||||
"inbox": "收件箱"
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import IntlMessageFormat from "intl-messageformat";
|
||||
import get from "lodash/get";
|
||||
import { makeAutoObservable } from "mobx";
|
||||
// constants
|
||||
import { FALLBACK_LANGUAGE, SUPPORTED_LANGUAGES, STORAGE_KEY } from "../constants";
|
||||
// types
|
||||
import { TLanguage, ILanguageOption, ITranslations } from "../types";
|
||||
|
||||
/**
|
||||
* Mobx store class for handling translations and language changes in the application
|
||||
* Provides methods to translate keys with params and change the language
|
||||
* Uses IntlMessageFormat to format the translations
|
||||
*/
|
||||
export class TranslationStore {
|
||||
// List of translations for each language
|
||||
private translations: ITranslations = {};
|
||||
// Cache for IntlMessageFormat instances
|
||||
private messageCache: Map<string, IntlMessageFormat> = new Map();
|
||||
// Current language
|
||||
currentLocale: TLanguage = FALLBACK_LANGUAGE;
|
||||
|
||||
/**
|
||||
* Constructor for the TranslationStore class
|
||||
*/
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
this.initializeLanguage();
|
||||
this.loadTranslations();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads translations from JSON files and initializes the message cache
|
||||
*/
|
||||
private async loadTranslations() {
|
||||
try {
|
||||
// dynamic import of translations
|
||||
const translations = {
|
||||
en: (await import("../locales/en/translations.json")).default,
|
||||
fr: (await import("../locales/fr/translations.json")).default,
|
||||
es: (await import("../locales/es/translations.json")).default,
|
||||
ja: (await import("../locales/ja/translations.json")).default,
|
||||
"zh-CN": (await import("../locales/zh-CN/translations.json")).default,
|
||||
};
|
||||
this.translations = translations;
|
||||
this.messageCache.clear(); // Clear cache when translations change
|
||||
} catch (error) {
|
||||
console.error("Failed to load translations:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Initializes the language based on the local storage or browser language */
|
||||
private initializeLanguage() {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const savedLocale = localStorage.getItem(STORAGE_KEY) as TLanguage;
|
||||
if (this.isValidLanguage(savedLocale)) {
|
||||
this.setLanguage(savedLocale);
|
||||
return;
|
||||
}
|
||||
|
||||
const browserLang = this.getBrowserLanguage();
|
||||
this.setLanguage(browserLang);
|
||||
}
|
||||
|
||||
/** Checks if the language is valid based on the supported languages */
|
||||
private isValidLanguage(lang: string | null): lang is TLanguage {
|
||||
return lang !== null && this.availableLanguages.some((l) => l.value === lang);
|
||||
}
|
||||
|
||||
/** Checks if a language code is similar to any supported language */
|
||||
private findSimilarLanguage(lang: string): TLanguage | null {
|
||||
// Convert to lowercase for case-insensitive comparison
|
||||
const normalizedLang = lang.toLowerCase();
|
||||
|
||||
// Find a supported language that includes or is included in the browser language
|
||||
const similarLang = this.availableLanguages.find(
|
||||
(l) => normalizedLang.includes(l.value.toLowerCase()) || l.value.toLowerCase().includes(normalizedLang)
|
||||
);
|
||||
|
||||
return similarLang ? similarLang.value : null;
|
||||
}
|
||||
|
||||
/** Gets the browser language based on the navigator.language */
|
||||
private getBrowserLanguage(): TLanguage {
|
||||
const browserLang = navigator.language;
|
||||
|
||||
// Check exact match first
|
||||
if (this.isValidLanguage(browserLang)) {
|
||||
return browserLang;
|
||||
}
|
||||
|
||||
// Check base language without region code
|
||||
const baseLang = browserLang.split("-")[0];
|
||||
if (this.isValidLanguage(baseLang)) {
|
||||
return baseLang as TLanguage;
|
||||
}
|
||||
|
||||
// Try to find a similar language
|
||||
const similarLang = this.findSimilarLanguage(browserLang) || this.findSimilarLanguage(baseLang);
|
||||
|
||||
return similarLang || FALLBACK_LANGUAGE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the cache key for the given key and locale
|
||||
* @param key - the key to get the cache key for
|
||||
* @param locale - the locale to get the cache key for
|
||||
* @returns the cache key for the given key and locale
|
||||
*/
|
||||
private getCacheKey(key: string, locale: TLanguage): string {
|
||||
return `${locale}:${key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the IntlMessageFormat instance for the given key and locale
|
||||
* Returns cached instance if available
|
||||
* Throws an error if the key is not found in the translations
|
||||
*/
|
||||
private getMessageInstance(key: string, locale: TLanguage): IntlMessageFormat | null {
|
||||
const cacheKey = this.getCacheKey(key, locale);
|
||||
|
||||
// Check if the cache already has the key
|
||||
if (this.messageCache.has(cacheKey)) {
|
||||
return this.messageCache.get(cacheKey) || null;
|
||||
}
|
||||
|
||||
// Get the message from the translations
|
||||
const message = get(this.translations[locale], key);
|
||||
if (!message) return null;
|
||||
|
||||
try {
|
||||
const formatter = new IntlMessageFormat(message as any, locale);
|
||||
this.messageCache.set(cacheKey, formatter);
|
||||
return formatter;
|
||||
} catch (error) {
|
||||
console.error(`Failed to create message formatter for key "${key}":`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates a key with params using the current locale
|
||||
* Falls back to the default language if the translation is not found
|
||||
* Returns the key itself if the translation is not found
|
||||
* @param key - The key to translate
|
||||
* @param params - The params to format the translation with
|
||||
* @returns The translated string
|
||||
*/
|
||||
t(key: string, params?: Record<string, any>): string {
|
||||
try {
|
||||
// Try current locale
|
||||
let formatter = this.getMessageInstance(key, this.currentLocale);
|
||||
|
||||
// Fallback to default language if necessary
|
||||
if (!formatter && this.currentLocale !== FALLBACK_LANGUAGE) {
|
||||
formatter = this.getMessageInstance(key, FALLBACK_LANGUAGE);
|
||||
}
|
||||
|
||||
// If we have a formatter, use it
|
||||
if (formatter) {
|
||||
return formatter.format(params || {}) as string;
|
||||
}
|
||||
|
||||
// Last resort: return the key itself
|
||||
return key;
|
||||
} catch (error) {
|
||||
console.error(`Translation error for key "${key}":`, error);
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the current language and updates the translations
|
||||
* @param lng - The new language
|
||||
*/
|
||||
setLanguage(lng: TLanguage): void {
|
||||
try {
|
||||
if (!this.isValidLanguage(lng)) {
|
||||
throw new Error(`Invalid language: ${lng}`);
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(STORAGE_KEY, lng);
|
||||
}
|
||||
this.currentLocale = lng;
|
||||
this.messageCache.clear(); // Clear cache when language changes
|
||||
if (typeof window !== "undefined") {
|
||||
document.documentElement.lang = lng;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to set language:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the available language options for the dropdown
|
||||
* @returns An array of language options
|
||||
*/
|
||||
get availableLanguages(): ILanguageOption[] {
|
||||
return SUPPORTED_LANGUAGES;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./language";
|
||||
export * from "./translation";
|
||||
@@ -0,0 +1,6 @@
|
||||
export type TLanguage = "en" | "fr" | "es" | "ja" | "zh-CN";
|
||||
|
||||
export interface ILanguageOption {
|
||||
label: string;
|
||||
value: TLanguage;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface ITranslation {
|
||||
[key: string]: string | ITranslation;
|
||||
}
|
||||
|
||||
export interface ITranslations {
|
||||
[locale: string]: ITranslation;
|
||||
}
|
||||
@@ -22,7 +22,6 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^2.8.8",
|
||||
"prettier-plugin-tailwindcss": "^0.3.0",
|
||||
"tailwindcss": "^3.2.7",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.6"
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+2
-3
@@ -104,6 +104,7 @@ export interface ICycle extends TProgressSnapshot {
|
||||
project_detail: IProjectDetails;
|
||||
progress: any[];
|
||||
version: number;
|
||||
pending_issues: number;
|
||||
}
|
||||
|
||||
export interface CycleIssueResponse {
|
||||
@@ -120,9 +121,7 @@ export interface CycleIssueResponse {
|
||||
sub_issues_count: number;
|
||||
}
|
||||
|
||||
export type SelectCycleType =
|
||||
| (ICycle & { actionType: "edit" | "delete" | "create-issue" })
|
||||
| undefined;
|
||||
export type SelectCycleType = (ICycle & { actionType: "edit" | "delete" | "create-issue" }) | undefined;
|
||||
|
||||
export type CycleDateCheckData = {
|
||||
start_date: string;
|
||||
|
||||
Vendored
+12
-4
@@ -1,8 +1,16 @@
|
||||
import { TLogoProps } from "./common";
|
||||
|
||||
export type TSticky = {
|
||||
created_at?: string | undefined;
|
||||
created_by?: string | undefined;
|
||||
background_color?: string | null | undefined;
|
||||
description?: object | undefined;
|
||||
description_html?: string | undefined;
|
||||
id: string;
|
||||
logo_props: TLogoProps | undefined;
|
||||
name?: string;
|
||||
description_html?: string;
|
||||
color?: string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
sort_order: number | undefined;
|
||||
updated_at?: string | undefined;
|
||||
updated_by?: string | undefined;
|
||||
workspace: string | undefined;
|
||||
};
|
||||
|
||||
@@ -69,7 +69,6 @@
|
||||
"postcss-cli": "^11.0.0",
|
||||
"postcss-nested": "^6.0.1",
|
||||
"storybook": "^8.1.1",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"tsup": "^7.2.0",
|
||||
"typescript": "5.3.3"
|
||||
},
|
||||
|
||||
+15
-16
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
@@ -15,11 +15,11 @@ import { Breadcrumbs, Button, CustomMenu, Tooltip, Header } from "@plane/ui";
|
||||
import { BreadcrumbLink, Logo } from "@/components/common";
|
||||
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
|
||||
// constants
|
||||
import { ViewQuickActions } from "@/components/views";
|
||||
import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
|
||||
import { EViewAccess } from "@/constants/views";
|
||||
// helpers
|
||||
import { isIssueFilterActive } from "@/helpers/filter.helper";
|
||||
import { getPublishViewLink } from "@/helpers/project-views.helpers";
|
||||
import { truncateText } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import {
|
||||
@@ -38,6 +38,8 @@ import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
|
||||
|
||||
export const ProjectViewIssuesHeader: React.FC = observer(() => {
|
||||
// refs
|
||||
const parentRef = useRef(null);
|
||||
// router
|
||||
const { workspaceSlug, projectId, viewId } = useParams();
|
||||
// store hooks
|
||||
@@ -133,7 +135,8 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.PROJECT
|
||||
);
|
||||
const publishLink = getPublishViewLink(viewDetails?.anchor);
|
||||
|
||||
if (!viewDetails) return;
|
||||
|
||||
return (
|
||||
<Header>
|
||||
@@ -203,19 +206,14 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
|
||||
<></>
|
||||
)}
|
||||
|
||||
{viewDetails?.anchor && publishLink ? (
|
||||
<a
|
||||
href={publishLink}
|
||||
className="px-3 py-1.5 bg-green-500/20 text-green-500 rounded text-xs font-medium flex items-center gap-1.5"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span className="flex-shrink-0 rounded-full size-1.5 bg-green-500" />
|
||||
Live
|
||||
</a>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<div className="hidden md:block">
|
||||
<ViewQuickActions
|
||||
parentRef={parentRef}
|
||||
projectId={projectId.toString()}
|
||||
view={viewDetails}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
/>
|
||||
</div>
|
||||
</Header.LeftItem>
|
||||
<Header.RightItem>
|
||||
{!viewDetails?.is_locked ? (
|
||||
@@ -246,6 +244,7 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||
}
|
||||
projectId={projectId.toString()}
|
||||
labels={projectLabels}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { useParams } from "next/navigation";
|
||||
import { Breadcrumbs, Button, Header, RecentStickyIcon } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
|
||||
// hooks
|
||||
import { StickySearch } from "@/components/stickies/modal/search";
|
||||
import { useStickyOperations } from "@/components/stickies/sticky/use-operations";
|
||||
// plane-web
|
||||
import { useSticky } from "@/hooks/use-stickies";
|
||||
|
||||
export const WorkspaceStickyHeader = observer(() => {
|
||||
const { workspaceSlug } = useParams();
|
||||
// hooks
|
||||
const { creatingSticky, toggleShowNewSticky } = useSticky();
|
||||
const { stickyOperations } = useStickyOperations({ workspaceSlug: workspaceSlug?.toString() });
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
label={`Stickies`}
|
||||
icon={<RecentStickyIcon className="size-5 rotate-90 text-custom-text-200" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
</Header.LeftItem>
|
||||
|
||||
<Header.RightItem>
|
||||
<StickySearch />
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="items-center gap-1"
|
||||
onClick={() => {
|
||||
toggleShowNewSticky(true);
|
||||
stickyOperations.create();
|
||||
}}
|
||||
loading={creatingSticky}
|
||||
>
|
||||
Add sticky
|
||||
</Button>
|
||||
</Header.RightItem>
|
||||
</Header>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { AppHeader, ContentWrapper } from "@/components/core";
|
||||
import { WorkspaceStickyHeader } from "./header";
|
||||
|
||||
export default function WorkspaceStickiesLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<WorkspaceStickyHeader />} />
|
||||
<ContentWrapper>{children}</ContentWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { StickiesInfinite } from "@/components/stickies";
|
||||
|
||||
export default function WorkspaceStickiesPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Your stickies" />
|
||||
<div className="relative h-full w-full overflow-hidden overflow-y-auto">
|
||||
<StickiesInfinite />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// components
|
||||
@@ -16,19 +17,25 @@ const GlobalViewIssuesPage = observer(() => {
|
||||
const { globalViewId } = useParams();
|
||||
// store hooks
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
// states
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// derived values
|
||||
const defaultView = DEFAULT_GLOBAL_VIEWS_LIST.find((view) => view.key === globalViewId);
|
||||
const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - All Views` : undefined;
|
||||
|
||||
// handlers
|
||||
const toggleLoading = (value: boolean) => setIsLoading(value);
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="h-full overflow-hidden bg-custom-background-100">
|
||||
<div className="flex h-full w-full flex-col border-b border-custom-border-300">
|
||||
<GlobalViewsHeader />
|
||||
{globalViewId && <GlobalViewsAppliedFiltersRoot globalViewId={globalViewId.toString()} />}
|
||||
<AllIssueLayoutRoot isDefaultView={!!defaultView} />
|
||||
{globalViewId && (
|
||||
<GlobalViewsAppliedFiltersRoot globalViewId={globalViewId.toString()} isLoading={isLoading} />
|
||||
)}
|
||||
<AllIssueLayoutRoot isDefaultView={!!defaultView} isLoading={isLoading} toggleLoading={toggleLoading} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -7,6 +7,7 @@ interface Props {
|
||||
projectId: string;
|
||||
workspaceSlug: string;
|
||||
transferrableIssuesCount: number;
|
||||
cycleName: string;
|
||||
}
|
||||
|
||||
export const EndCycleModal: React.FC<Props> = () => <></>;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import React, { FC } from "react";
|
||||
|
||||
type Props = {
|
||||
issueId: string;
|
||||
};
|
||||
|
||||
export const IssueStats: FC<Props> = (props) => {
|
||||
const { issueId } = props;
|
||||
return <></>;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export type { ICycleStore } from "@/store/cycle.store";
|
||||
@@ -196,7 +196,7 @@ export const CustomTreeMapContent: React.FC<any> = ({
|
||||
L${pX},${pY + LAYOUT.RADIUS}
|
||||
Q${pX},${pY} ${pX + LAYOUT.RADIUS},${pY}
|
||||
`}
|
||||
className={cn("transition-colors duration-200 hover:opacity-90 cursor-pointer", fillClassName)}
|
||||
className={cn("transition-colors duration-200 hover:opacity-90", fillClassName)}
|
||||
fill={fillColor ?? "currentColor"}
|
||||
/>
|
||||
|
||||
@@ -269,7 +269,7 @@ export const CustomTreeMapContent: React.FC<any> = ({
|
||||
|
||||
return (
|
||||
<g>
|
||||
<rect x={x} y={y} width={width} height={height} fill="transparent" className="cursor-pointer" />
|
||||
<rect x={x} y={y} width={width} height={height} fill="transparent" />
|
||||
{renderContent()}
|
||||
</g>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ interface IContentOverflowWrapper {
|
||||
buttonClassName?: string;
|
||||
containerClassName?: string;
|
||||
fallback?: ReactNode;
|
||||
customButton?: ReactNode;
|
||||
}
|
||||
|
||||
export const ContentOverflowWrapper = observer((props: IContentOverflowWrapper) => {
|
||||
@@ -18,6 +19,7 @@ export const ContentOverflowWrapper = observer((props: IContentOverflowWrapper)
|
||||
buttonClassName = "text-sm font-medium text-custom-primary-100",
|
||||
containerClassName,
|
||||
fallback = null,
|
||||
customButton,
|
||||
} = props;
|
||||
|
||||
// states
|
||||
@@ -131,16 +133,18 @@ export const ContentOverflowWrapper = observer((props: IContentOverflowWrapper)
|
||||
pointerEvents: isTransitioning ? "none" : "auto",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
"gap-1 w-full text-custom-primary-100 text-sm font-medium transition-opacity duration-300",
|
||||
buttonClassName
|
||||
)}
|
||||
onClick={handleToggle}
|
||||
disabled={isTransitioning}
|
||||
>
|
||||
{showAll ? "Show less" : "Show all"}
|
||||
</button>
|
||||
{customButton || (
|
||||
<button
|
||||
className={cn(
|
||||
"gap-1 w-full text-custom-primary-100 text-sm font-medium transition-opacity duration-300",
|
||||
buttonClassName
|
||||
)}
|
||||
onClick={handleToggle}
|
||||
disabled={isTransitioning}
|
||||
>
|
||||
{showAll ? "Show less" : "Show all"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { FC, MouseEvent, useEffect, useMemo, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useParams, usePathname, useSearchParams } from "next/navigation";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Eye, Users } from "lucide-react";
|
||||
// types
|
||||
@@ -58,6 +58,8 @@ const defaultValues: Partial<ICycle> = {
|
||||
|
||||
export const CycleListItemAction: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, cycleId, cycleDetails, parentRef, isActive = false } = props;
|
||||
// router
|
||||
const { projectId: routerProjectId } = useParams();
|
||||
//states
|
||||
const [transferIssuesModal, setTransferIssuesModal] = useState(false);
|
||||
// hooks
|
||||
@@ -80,9 +82,11 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
|
||||
|
||||
// derived values
|
||||
const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft";
|
||||
|
||||
const showIssueCount = useMemo(() => cycleStatus === "draft" || cycleStatus === "upcoming", [cycleStatus]);
|
||||
const transferableIssuesCount = cycleDetails ? cycleDetails.total_issues - cycleDetails.completed_issues : 0;
|
||||
const showTransferIssues = transferableIssuesCount > 0 && cycleStatus === "completed";
|
||||
|
||||
const showTransferIssues = routerProjectId && cycleDetails.pending_issues > 0 && cycleStatus === "completed";
|
||||
|
||||
const isEditingAllowed = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
@@ -254,7 +258,7 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
|
||||
}}
|
||||
>
|
||||
<TransferIcon className="fill-custom-primary-200 w-4" />
|
||||
<span>Transfer {transferableIssuesCount} issues</span>
|
||||
<span>Transfer {cycleDetails.pending_issues} issues</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -42,7 +42,6 @@ export const CycleQuickActions: React.FC<Props> = observer((props) => {
|
||||
const isArchived = !!cycleDetails?.archived_at;
|
||||
const isCompleted = cycleDetails?.status?.toLowerCase() === "completed";
|
||||
const isCurrentCycle = cycleDetails?.status?.toLowerCase() === "current";
|
||||
const transferableIssuesCount = cycleDetails ? cycleDetails.total_issues - cycleDetails.completed_issues : 0;
|
||||
// auth
|
||||
const isEditingAllowed = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
@@ -170,18 +169,21 @@ export const CycleQuickActions: React.FC<Props> = observer((props) => {
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<EndCycleModal
|
||||
isOpen={isEndCycleModalOpen}
|
||||
handleClose={() => setEndCycleModalOpen(false)}
|
||||
cycleId={cycleId}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
transferrableIssuesCount={transferableIssuesCount}
|
||||
/>
|
||||
{isCurrentCycle && (
|
||||
<EndCycleModal
|
||||
isOpen={isEndCycleModalOpen}
|
||||
handleClose={() => setEndCycleModalOpen(false)}
|
||||
cycleId={cycleId}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
transferrableIssuesCount={cycleDetails.pending_issues}
|
||||
cycleName={cycleDetails.name}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
|
||||
<CustomMenu ellipsis placement="bottom-end" closeOnSelect>
|
||||
<CustomMenu ellipsis placement="bottom-end" closeOnSelect maxHeight="lg">
|
||||
{MENU_ITEMS.map((item) => {
|
||||
if (item.shouldRender === false) return null;
|
||||
return (
|
||||
|
||||
@@ -26,7 +26,7 @@ export const TransferIssuesModal: React.FC<Props> = observer((props) => {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
// store hooks
|
||||
const { currentProjectIncompleteCycleIds, getCycleById, fetchCycleDetails } = useCycle();
|
||||
const { currentProjectIncompleteCycleIds, getCycleById, fetchActiveCycleProgress } = useCycle();
|
||||
const {
|
||||
issues: { transferIssuesFromCycle },
|
||||
} = useIssues(EIssuesStoreType.CYCLE);
|
||||
@@ -57,8 +57,8 @@ export const TransferIssuesModal: React.FC<Props> = observer((props) => {
|
||||
/**To update issue counts in target cycle and current cycle */
|
||||
const getCycleDetails = async (newCycleId: string) => {
|
||||
const cyclesFetch = [
|
||||
fetchCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleId),
|
||||
fetchCycleDetails(workspaceSlug.toString(), projectId.toString(), newCycleId),
|
||||
fetchActiveCycleProgress(workspaceSlug.toString(), projectId.toString(), cycleId),
|
||||
fetchActiveCycleProgress(workspaceSlug.toString(), projectId.toString(), newCycleId),
|
||||
];
|
||||
await Promise.all(cyclesFetch).catch((error) => {
|
||||
setToast({
|
||||
|
||||
@@ -31,10 +31,11 @@ type CycleOptionsProps = {
|
||||
placement: Placement | undefined;
|
||||
isOpen: boolean;
|
||||
canRemoveCycle: boolean;
|
||||
currentCycleId?: string;
|
||||
};
|
||||
|
||||
export const CycleOptions: FC<CycleOptionsProps> = observer((props) => {
|
||||
const { projectId, isOpen, referenceElement, placement, canRemoveCycle } = props;
|
||||
const { projectId, isOpen, referenceElement, placement, canRemoveCycle, currentCycleId } = props;
|
||||
//state hooks
|
||||
const [query, setQuery] = useState("");
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
@@ -68,6 +69,7 @@ export const CycleOptions: FC<CycleOptionsProps> = observer((props) => {
|
||||
|
||||
const cycleIds = (getProjectCycleIds(projectId) ?? [])?.filter((cycleId) => {
|
||||
const cycleDetails = getCycleById(cycleId);
|
||||
if (currentCycleId && currentCycleId === cycleId) return false;
|
||||
return cycleDetails?.status ? (cycleDetails?.status.toLowerCase() != "completed" ? true : false) : true;
|
||||
});
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ type Props = TDropdownProps & {
|
||||
value: string | null;
|
||||
canRemoveCycle?: boolean;
|
||||
renderByDefault?: boolean;
|
||||
currentCycleId?: string;
|
||||
};
|
||||
|
||||
export const CycleDropdown: React.FC<Props> = observer((props) => {
|
||||
@@ -49,6 +50,7 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
|
||||
value,
|
||||
canRemoveCycle = true,
|
||||
renderByDefault = true,
|
||||
currentCycleId,
|
||||
} = props;
|
||||
// states
|
||||
|
||||
@@ -145,6 +147,7 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
|
||||
placement={placement}
|
||||
referenceElement={referenceElement}
|
||||
canRemoveCycle={canRemoveCycle}
|
||||
currentCycleId={currentCycleId}
|
||||
/>
|
||||
)}
|
||||
</ComboDropDown>
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { useEffect, useRef, useState } from "react";
|
||||
import { Placement } from "@popperjs/core";
|
||||
import { DateRange, DayPicker, Matcher } from "react-day-picker";
|
||||
import { usePopper } from "react-popper";
|
||||
import { ArrowRight, CalendarDays } from "lucide-react";
|
||||
import { ArrowRight, CalendarCheck2, CalendarDays } from "lucide-react";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
// ui
|
||||
import { Button, ComboDropDown } from "@plane/ui";
|
||||
@@ -33,7 +33,6 @@ type Props = {
|
||||
from?: boolean;
|
||||
to?: boolean;
|
||||
};
|
||||
icon?: React.ReactNode;
|
||||
minDate?: Date;
|
||||
maxDate?: Date;
|
||||
onSelect: (range: DateRange | undefined) => void;
|
||||
@@ -50,6 +49,7 @@ type Props = {
|
||||
to: Date | undefined;
|
||||
};
|
||||
renderByDefault?: boolean;
|
||||
renderPlaceholder?: boolean;
|
||||
};
|
||||
|
||||
export const DateRangeDropdown: React.FC<Props> = (props) => {
|
||||
@@ -68,7 +68,6 @@ export const DateRangeDropdown: React.FC<Props> = (props) => {
|
||||
from: true,
|
||||
to: true,
|
||||
},
|
||||
icon = <CalendarDays className="h-3 w-3 flex-shrink-0" />,
|
||||
minDate,
|
||||
maxDate,
|
||||
onSelect,
|
||||
@@ -82,6 +81,7 @@ export const DateRangeDropdown: React.FC<Props> = (props) => {
|
||||
tabIndex,
|
||||
value,
|
||||
renderByDefault = true,
|
||||
renderPlaceholder = true,
|
||||
} = props;
|
||||
// states
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -166,15 +166,15 @@ export const DateRangeDropdown: React.FC<Props> = (props) => {
|
||||
<span
|
||||
className={cn("h-full flex items-center justify-center gap-1 rounded-sm flex-grow", buttonFromDateClassName)}
|
||||
>
|
||||
{!hideIcon.from && icon}
|
||||
{dateRange.from ? renderFormattedDate(dateRange.from) : placeholder.from}
|
||||
{!hideIcon.from && <CalendarDays className="h-3 w-3 flex-shrink-0" />}
|
||||
{dateRange.from ? renderFormattedDate(dateRange.from) : renderPlaceholder ? placeholder.from : ""}
|
||||
</span>
|
||||
<ArrowRight className="h-3 w-3 flex-shrink-0" />
|
||||
<span
|
||||
className={cn("h-full flex items-center justify-center gap-1 rounded-sm flex-grow", buttonToDateClassName)}
|
||||
>
|
||||
{!hideIcon.to && icon}
|
||||
{dateRange.to ? renderFormattedDate(dateRange.to) : placeholder.to}
|
||||
{!hideIcon.to && <CalendarCheck2 className="h-3 w-3 flex-shrink-0" />}
|
||||
{dateRange.to ? renderFormattedDate(dateRange.to) : renderPlaceholder ? placeholder.to : ""}
|
||||
</span>
|
||||
</DropdownButton>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { TSticky } from "@plane/types";
|
||||
|
||||
export const STICKY_COLORS_LIST: {
|
||||
key: string;
|
||||
label: string;
|
||||
backgroundColor: string;
|
||||
}[] = [
|
||||
{
|
||||
key: "gray",
|
||||
label: "Gray",
|
||||
backgroundColor: "var(--editor-colors-gray-background)",
|
||||
},
|
||||
{
|
||||
key: "peach",
|
||||
label: "Peach",
|
||||
backgroundColor: "var(--editor-colors-peach-background)",
|
||||
},
|
||||
{
|
||||
key: "pink",
|
||||
label: "Pink",
|
||||
backgroundColor: "var(--editor-colors-pink-background)",
|
||||
},
|
||||
{
|
||||
key: "orange",
|
||||
label: "Orange",
|
||||
backgroundColor: "var(--editor-colors-orange-background)",
|
||||
},
|
||||
{
|
||||
key: "green",
|
||||
label: "Green",
|
||||
backgroundColor: "var(--editor-colors-green-background)",
|
||||
},
|
||||
{
|
||||
key: "light-blue",
|
||||
label: "Light blue",
|
||||
backgroundColor: "var(--editor-colors-light-blue-background)",
|
||||
},
|
||||
{
|
||||
key: "dark-blue",
|
||||
label: "Dark blue",
|
||||
backgroundColor: "var(--editor-colors-dark-blue-background)",
|
||||
},
|
||||
{
|
||||
key: "purple",
|
||||
label: "Purple",
|
||||
backgroundColor: "var(--editor-colors-purple-background)",
|
||||
},
|
||||
];
|
||||
|
||||
type TProps = {
|
||||
handleUpdate: (data: Partial<TSticky>) => Promise<void>;
|
||||
};
|
||||
|
||||
export const ColorPalette = (props: TProps) => {
|
||||
const { handleUpdate } = props;
|
||||
return (
|
||||
<div className="absolute z-10 bottom-5 left-0 w-56 shadow p-2 rounded-md bg-custom-background-100 mb-2">
|
||||
<div className="text-sm font-semibold text-custom-text-400 mb-2">Background colors</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{STICKY_COLORS_LIST.map((color) => (
|
||||
<button
|
||||
key={color.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleUpdate({
|
||||
background_color: color.key,
|
||||
});
|
||||
}}
|
||||
className="h-6 w-6 rounded-md hover:ring-2 hover:ring-custom-primary focus:outline-none focus:ring-2 focus:ring-custom-primary transition-all"
|
||||
style={{
|
||||
backgroundColor: color.backgroundColor,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
import { TSticky } from "@plane/types";
|
||||
|
||||
export const STICKY_COLORS = [
|
||||
"#D4DEF7", // light periwinkle
|
||||
"#B4E4FF", // light blue
|
||||
"#FFF2B4", // light yellow
|
||||
"#E3E3E3", // light gray
|
||||
"#FFE2DD", // light pink
|
||||
"#F5D1A5", // light orange
|
||||
"#D1F7C4", // light green
|
||||
"#E5D4FF", // light purple
|
||||
];
|
||||
|
||||
type TProps = {
|
||||
handleUpdate: (data: Partial<TSticky>) => Promise<void>;
|
||||
};
|
||||
|
||||
export const ColorPalette = (props: TProps) => {
|
||||
const { handleUpdate } = props;
|
||||
return (
|
||||
<div className="absolute z-10 bottom-5 left-0 w-56 shadow p-2 rounded-md bg-custom-background-100 mb-2">
|
||||
<div className="text-sm font-semibold text-custom-text-400 mb-2">Background colors</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{STICKY_COLORS.map((color, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={() => handleUpdate({ color })}
|
||||
className="h-6 w-6 rounded-md hover:ring-2 hover:ring-custom-primary focus:outline-none focus:ring-2 focus:ring-custom-primary transition-all"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -82,26 +82,28 @@ export const StickyEditor = React.forwardRef<EditorRefApi, StickyEditorWrapperPr
|
||||
containerClassName={cn(containerClassName, "relative")}
|
||||
{...rest}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"transition-all duration-300 ease-out origin-top",
|
||||
isFocused ? "max-h-[200px] opacity-100 scale-y-100 mt-3" : "max-h-0 opacity-0 scale-y-0 invisible"
|
||||
)}
|
||||
>
|
||||
<Toolbar
|
||||
executeCommand={(item) => {
|
||||
// TODO: update this while toolbar homogenization
|
||||
// @ts-expect-error type mismatch here
|
||||
editorRef?.executeMenuItemCommand({
|
||||
itemKey: item.itemKey,
|
||||
...item.extraProps,
|
||||
});
|
||||
}}
|
||||
handleDelete={handleDelete}
|
||||
handleColorChange={handleColorChange}
|
||||
editorRef={editorRef}
|
||||
/>
|
||||
</div>
|
||||
{showToolbar && (
|
||||
<div
|
||||
className={cn(
|
||||
"transition-all duration-300 ease-out origin-top",
|
||||
isFocused ? "max-h-[200px] opacity-100 scale-y-100 mt-3" : "max-h-0 opacity-0 scale-y-0 invisible"
|
||||
)}
|
||||
>
|
||||
<Toolbar
|
||||
executeCommand={(item) => {
|
||||
// TODO: update this while toolbar homogenization
|
||||
// @ts-expect-error type mismatch here
|
||||
editorRef?.executeMenuItemCommand({
|
||||
itemKey: item.itemKey,
|
||||
...item.extraProps,
|
||||
});
|
||||
}}
|
||||
handleDelete={handleDelete}
|
||||
handleColorChange={handleColorChange}
|
||||
editorRef={editorRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Tooltip } from "@plane/ui";
|
||||
import { TOOLBAR_ITEMS, ToolbarMenuItem } from "@/constants/editor";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { ColorPalette } from "./color-pallete";
|
||||
import { ColorPalette } from "./color-palette";
|
||||
|
||||
type Props = {
|
||||
executeCommand: (item: ToolbarMenuItem) => void;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
// ui
|
||||
import { Button } from "@plane/ui/src/button";
|
||||
// utils
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type EmptyStateSize = "sm" | "md" | "lg";
|
||||
|
||||
type ButtonConfig = {
|
||||
text: string;
|
||||
prependIcon?: React.ReactNode;
|
||||
appendIcon?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description?: string;
|
||||
assetPath?: string;
|
||||
size?: EmptyStateSize;
|
||||
primaryButton?: ButtonConfig;
|
||||
secondaryButton?: ButtonConfig;
|
||||
customPrimaryButton?: React.ReactNode;
|
||||
customSecondaryButton?: React.ReactNode;
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "md:min-w-[24rem] max-w-[45rem]",
|
||||
md: "md:min-w-[28rem] max-w-[50rem]",
|
||||
lg: "md:min-w-[30rem] max-w-[60rem]",
|
||||
} as const;
|
||||
|
||||
const CustomButton = ({
|
||||
config,
|
||||
variant,
|
||||
size,
|
||||
}: {
|
||||
config: ButtonConfig;
|
||||
variant: "primary" | "neutral-primary";
|
||||
size: EmptyStateSize;
|
||||
}) => (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
onClick={config.onClick}
|
||||
prependIcon={config.prependIcon}
|
||||
appendIcon={config.appendIcon}
|
||||
disabled={config.disabled}
|
||||
>
|
||||
{config.text}
|
||||
</Button>
|
||||
);
|
||||
|
||||
export const DetailedEmptyState: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
size = "lg",
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
customPrimaryButton,
|
||||
customSecondaryButton,
|
||||
assetPath,
|
||||
} = props;
|
||||
|
||||
const hasButtons = primaryButton || secondaryButton || customPrimaryButton || customSecondaryButton;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-full min-w-full overflow-y-auto py-10 md:px-20 px-5">
|
||||
<div className={cn("flex flex-col gap-5", sizeClasses[size])}>
|
||||
<div className="flex flex-col gap-1.5 flex-shrink">
|
||||
<h3 className={cn("text-xl font-semibold", { "font-medium": !description })}>{title}</h3>
|
||||
{description && <p className="text-sm">{description}</p>}
|
||||
</div>
|
||||
|
||||
{assetPath && (
|
||||
<Image src={assetPath} alt={title} width={384} height={250} layout="responsive" lazyBoundary="100%" />
|
||||
)}
|
||||
|
||||
{hasButtons && (
|
||||
<div className="relative flex items-center justify-center gap-2 flex-shrink-0 w-full">
|
||||
{/* primary button */}
|
||||
{customPrimaryButton ??
|
||||
(primaryButton?.text && <CustomButton config={primaryButton} variant="primary" size={size} />)}
|
||||
{/* secondary button */}
|
||||
{customSecondaryButton ??
|
||||
(secondaryButton?.text && (
|
||||
<CustomButton config={secondaryButton} variant="neutral-primary" size={size} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import Link from "next/link";
|
||||
import { useTheme } from "next-themes";
|
||||
// hooks
|
||||
// components
|
||||
import { Button, TButtonVariant } from "@plane/ui";
|
||||
import { Button, TButtonSizes, TButtonVariant } from "@plane/ui";
|
||||
// constant
|
||||
import { EMPTY_STATE_DETAILS, EmptyStateType } from "@/constants/empty-state";
|
||||
// helpers
|
||||
@@ -18,10 +18,14 @@ import { EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
|
||||
import { ComicBoxButton } from "./comic-box-button";
|
||||
|
||||
export type EmptyStateProps = {
|
||||
size?: TButtonSizes;
|
||||
type: EmptyStateType;
|
||||
size?: "sm" | "md" | "lg";
|
||||
layout?: "screen-detailed" | "screen-simple";
|
||||
additionalPath?: string;
|
||||
primaryButtonConfig?: {
|
||||
size?: TButtonSizes;
|
||||
variant?: TButtonVariant;
|
||||
};
|
||||
primaryButtonOnClick?: () => void;
|
||||
primaryButtonLink?: string;
|
||||
secondaryButtonOnClick?: () => void;
|
||||
@@ -29,10 +33,14 @@ export type EmptyStateProps = {
|
||||
|
||||
export const EmptyState: React.FC<EmptyStateProps> = observer((props) => {
|
||||
const {
|
||||
type,
|
||||
size = "lg",
|
||||
type,
|
||||
layout = "screen-detailed",
|
||||
additionalPath = "",
|
||||
primaryButtonConfig = {
|
||||
size: "lg",
|
||||
variant: "primary",
|
||||
},
|
||||
primaryButtonOnClick,
|
||||
primaryButtonLink,
|
||||
secondaryButtonOnClick,
|
||||
@@ -67,8 +75,8 @@ export const EmptyState: React.FC<EmptyStateProps> = observer((props) => {
|
||||
if (!primaryButton) return null;
|
||||
|
||||
const commonProps = {
|
||||
size: size,
|
||||
variant: "primary" as TButtonVariant,
|
||||
size: primaryButtonConfig.size,
|
||||
variant: primaryButtonConfig.variant,
|
||||
prependIcon: primaryButton.icon,
|
||||
onClick: primaryButtonOnClick ? primaryButtonOnClick : undefined,
|
||||
disabled: !isEditingAllowed,
|
||||
@@ -145,12 +153,10 @@ export const EmptyState: React.FC<EmptyStateProps> = observer((props) => {
|
||||
)}
|
||||
|
||||
{anyButton && (
|
||||
<>
|
||||
<div className="relative flex items-center justify-center gap-2 flex-shrink-0 w-full">
|
||||
{renderPrimaryButton()}
|
||||
{renderSecondaryButton()}
|
||||
</div>
|
||||
</>
|
||||
<div className="relative flex items-center justify-center gap-2 flex-shrink-0 w-full">
|
||||
{renderPrimaryButton()}
|
||||
{renderSecondaryButton()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -175,6 +181,12 @@ export const EmptyState: React.FC<EmptyStateProps> = observer((props) => {
|
||||
) : (
|
||||
<h3 className="text-sm font-medium text-custom-text-400 whitespace-pre-line">{title}</h3>
|
||||
)}
|
||||
{anyButton && (
|
||||
<div className="relative flex items-center justify-center gap-2 flex-shrink-0 w-full">
|
||||
{renderPrimaryButton()}
|
||||
{renderSecondaryButton()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
export * from "./empty-state";
|
||||
export * from "./helper";
|
||||
export * from "./comic-box-button";
|
||||
export * from "./detailed-empty-state-root";
|
||||
export * from "./simple-empty-state-root";
|
||||
export * from "./section-empty-state-root";
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
|
||||
type Props = {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
actionElement?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const SectionEmptyState: FC<Props> = (props) => {
|
||||
const { title, description, icon, actionElement } = props;
|
||||
return (
|
||||
<div className="flex flex-col gap-4 items-center justify-center rounded-md border border-custom-border-200 p-10">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="flex items-center justify-center size-8 bg-custom-background-80 rounded">{icon}</div>
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
{description && <span className="text-xs text-custom-text-300">{description}</span>}
|
||||
</div>
|
||||
{actionElement && <>{actionElement}</>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
// utils
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type EmptyStateSize = "sm" | "md" | "lg";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description?: string;
|
||||
assetPath?: string;
|
||||
size?: EmptyStateSize;
|
||||
};
|
||||
|
||||
const sizeConfig = {
|
||||
sm: {
|
||||
container: "size-20",
|
||||
dimensions: 78,
|
||||
},
|
||||
md: {
|
||||
container: "size-24",
|
||||
dimensions: 80,
|
||||
},
|
||||
lg: {
|
||||
container: "size-28",
|
||||
dimensions: 96,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const getTitleClassName = (hasDescription: boolean) =>
|
||||
cn("font-medium whitespace-pre-line", {
|
||||
"text-sm text-custom-text-400": !hasDescription,
|
||||
"text-lg text-custom-text-300": hasDescription,
|
||||
});
|
||||
|
||||
export const SimpleEmptyState = observer((props: Props) => {
|
||||
const { title, description, size = "sm", assetPath } = props;
|
||||
|
||||
return (
|
||||
<div className="text-center flex flex-col gap-2.5 items-center">
|
||||
{assetPath && (
|
||||
<div className={sizeConfig[size].container}>
|
||||
<Image
|
||||
src={assetPath}
|
||||
alt={title}
|
||||
height={sizeConfig[size].dimensions}
|
||||
width={sizeConfig[size].dimensions}
|
||||
layout="responsive"
|
||||
lazyBoundary="100%"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className={getTitleClassName(!!description)}>{title}</h3>
|
||||
|
||||
{description && <p className="text-base font-medium text-custom-text-400 whitespace-pre-line">{description}</p>}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,54 +1,96 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// types
|
||||
// plane types
|
||||
import { THomeWidgetKeys, THomeWidgetProps } from "@plane/types";
|
||||
// components
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
// constants
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
// hooks
|
||||
import { useHome } from "@/hooks/store/use-home";
|
||||
// components
|
||||
// plane web components
|
||||
import { HomePageHeader } from "@/plane-web/components/home/header";
|
||||
import { StickiesWidget } from "../stickies";
|
||||
import { RecentActivityWidget } from "./widgets";
|
||||
import { DashboardQuickLinks } from "./widgets/links";
|
||||
import { ManageWidgetsModal } from "./widgets/manage";
|
||||
|
||||
const WIDGETS_LIST: {
|
||||
[key in THomeWidgetKeys]: { component: React.FC<THomeWidgetProps> | null; fullWidth: boolean };
|
||||
export const HOME_WIDGETS_LIST: {
|
||||
[key in THomeWidgetKeys]: {
|
||||
component: React.FC<THomeWidgetProps> | null;
|
||||
fullWidth: boolean;
|
||||
title: string;
|
||||
};
|
||||
} = {
|
||||
quick_links: { component: DashboardQuickLinks, fullWidth: false },
|
||||
recents: { component: RecentActivityWidget, fullWidth: false },
|
||||
my_stickies: { component: StickiesWidget, fullWidth: false },
|
||||
new_at_plane: { component: null, fullWidth: false },
|
||||
quick_tutorial: { component: null, fullWidth: false },
|
||||
quick_links: {
|
||||
component: DashboardQuickLinks,
|
||||
fullWidth: false,
|
||||
title: "Quick links",
|
||||
},
|
||||
recents: {
|
||||
component: RecentActivityWidget,
|
||||
fullWidth: false,
|
||||
title: "Recents",
|
||||
},
|
||||
my_stickies: {
|
||||
component: StickiesWidget,
|
||||
fullWidth: false,
|
||||
title: "Your stickies",
|
||||
},
|
||||
new_at_plane: {
|
||||
component: null,
|
||||
fullWidth: false,
|
||||
title: "New at Plane",
|
||||
},
|
||||
quick_tutorial: {
|
||||
component: null,
|
||||
fullWidth: false,
|
||||
title: "Quick tutorial",
|
||||
},
|
||||
};
|
||||
|
||||
export const DashboardWidgets = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { toggleWidgetSettings, widgetsMap, showWidgetSettings, orderedWidgets } = useHome();
|
||||
const { toggleWidgetSettings, widgetsMap, showWidgetSettings, orderedWidgets, isAnyWidgetEnabled } = useHome();
|
||||
|
||||
if (!workspaceSlug) return null;
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col gap-7">
|
||||
<div className="h-full w-full relative flex flex-col gap-7">
|
||||
<HomePageHeader />
|
||||
<ManageWidgetsModal
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
isModalOpen={showWidgetSettings}
|
||||
handleOnClose={() => toggleWidgetSettings(false)}
|
||||
/>
|
||||
<div className="flex flex-col divide-y-[1px] divide-custom-border-100">
|
||||
{orderedWidgets.map((key) => {
|
||||
const WidgetComponent = WIDGETS_LIST[key]?.component;
|
||||
const isEnabled = widgetsMap[key]?.is_enabled;
|
||||
if (!WidgetComponent || !isEnabled) return null;
|
||||
return (
|
||||
<div key={key} className="py-4">
|
||||
<WidgetComponent workspaceSlug={workspaceSlug.toString()} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{isAnyWidgetEnabled ? (
|
||||
<div className="flex flex-col divide-y-[1px] divide-custom-border-100">
|
||||
{orderedWidgets.map((key) => {
|
||||
const WidgetComponent = HOME_WIDGETS_LIST[key]?.component;
|
||||
const isEnabled = widgetsMap[key]?.is_enabled;
|
||||
if (!WidgetComponent || !isEnabled) return null;
|
||||
return (
|
||||
<div key={key} className="py-4">
|
||||
<WidgetComponent workspaceSlug={workspaceSlug.toString()} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full w-full grid place-items-center">
|
||||
<EmptyState
|
||||
type={EmptyStateType.HOME_WIDGETS}
|
||||
layout="screen-simple"
|
||||
primaryButtonOnClick={() => toggleWidgetSettings(true)}
|
||||
primaryButtonConfig={{
|
||||
size: "sm",
|
||||
variant: "neutral-primary",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -59,12 +59,11 @@ export const WorkspaceHomeView = observer(() => {
|
||||
<>
|
||||
<IssuePeekOverview />
|
||||
<ContentWrapper
|
||||
className={cn("gap-7 bg-custom-background-90/20", {
|
||||
className={cn("gap-6 bg-custom-background-90/20", {
|
||||
"vertical-scrollbar scrollbar-lg": windowWidth >= 768,
|
||||
})}
|
||||
>
|
||||
{currentUser && <UserGreetingsView user={currentUser} handleWidgetModal={() => toggleWidgetSettings(true)} />}
|
||||
|
||||
<DashboardWidgets />
|
||||
</ContentWrapper>
|
||||
</>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { FC } from "react";
|
||||
// hooks
|
||||
import { Shapes } from "lucide-react";
|
||||
// plane types
|
||||
import { IUser } from "@plane/types";
|
||||
// plane ui
|
||||
import { Button } from "@plane/ui";
|
||||
// hooks
|
||||
import { useCurrentTime } from "@/hooks/use-current-time";
|
||||
// types
|
||||
|
||||
export interface IUserGreetingsView {
|
||||
user: IUser;
|
||||
@@ -51,13 +53,10 @@ export const UserGreetingsView: FC<IUserGreetingsView> = (props) => {
|
||||
</div>
|
||||
</h6>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleWidgetModal}
|
||||
className="flex items-center gap-2 font-medium text-custom-text-300 justify-center border border-custom-border-200 rounded p-2 my-auto mb-0"
|
||||
>
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleWidgetModal} className="my-auto mb-0">
|
||||
<Shapes size={16} />
|
||||
<div className="text-xs font-medium">Manage widgets</div>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,27 +1,12 @@
|
||||
import { Link2, Plus } from "lucide-react";
|
||||
import { Button } from "@plane/ui";
|
||||
import { Link2 } from "lucide-react";
|
||||
|
||||
type TProps = {
|
||||
handleCreate: () => void;
|
||||
};
|
||||
export const LinksEmptyState = (props: TProps) => {
|
||||
const { handleCreate } = props;
|
||||
return (
|
||||
<div className="min-h-[200px] flex w-full justify-center py-6 border-[1.5px] border-custom-border-100 rounded">
|
||||
<div className="m-auto">
|
||||
<div
|
||||
className={`mb-2 rounded-full mx-auto last:rounded-full w-[50px] h-[50px] flex items-center justify-center bg-custom-background-80/40 transition-transform duration-300`}
|
||||
>
|
||||
<Link2 size={30} className="text-custom-text-400 -rotate-45" />
|
||||
export const LinksEmptyState = () => (
|
||||
<div className="min-h-[110px] flex w-full justify-center py-6 bg-custom-border-100 rounded">
|
||||
<div className="m-auto flex gap-2">
|
||||
<Link2 size={30} className="text-custom-text-400/40 -rotate-45" />
|
||||
<div className="text-custom-text-400 text-sm text-center my-auto">
|
||||
Add any links you need for quick access to your work.
|
||||
</div>
|
||||
<div className="text-custom-text-100 font-medium text-base text-center mb-1">No quick links yet</div>
|
||||
<div className="text-custom-text-300 text-sm text-center mb-2">
|
||||
Add any links you need for quick access to your work.{" "}
|
||||
</div>
|
||||
<Button variant="accent-primary" size="sm" onClick={handleCreate} className="mx-auto">
|
||||
<Plus className="size-4 my-auto" /> <span>Add quick link</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,38 @@
|
||||
import { History } from "lucide-react";
|
||||
import { Briefcase, FileText, History } from "lucide-react";
|
||||
import { LayersIcon } from "@plane/ui";
|
||||
|
||||
export const RecentsEmptyState = () => (
|
||||
<div className="h-[200px] flex w-full justify-center py-6 border-[1.5px] border-custom-border-100 rounded">
|
||||
<div className="m-auto">
|
||||
<div
|
||||
className={`mb-2 rounded-full mx-auto last:rounded-full w-[50px] h-[50px] flex items-center justify-center bg-custom-background-80/40 transition-transform duration-300`}
|
||||
>
|
||||
<History size={30} className="text-custom-text-400 -rotate-45" />
|
||||
export const RecentsEmptyState = ({ type }: { type: string }) => {
|
||||
const getDisplayContent = () => {
|
||||
switch (type) {
|
||||
case "project":
|
||||
return {
|
||||
icon: <Briefcase size={30} className="text-custom-text-400/40" />,
|
||||
text: "Your recent projects will appear here once you visit one.",
|
||||
};
|
||||
case "page":
|
||||
return {
|
||||
icon: <FileText size={30} className="text-custom-text-400/40" />,
|
||||
text: "Your recent pages will appear here once you visit one.",
|
||||
};
|
||||
case "issue":
|
||||
return {
|
||||
icon: <LayersIcon className="text-custom-text-400/40 w-[30px] h-[30px]" />,
|
||||
text: "Your recent issues will appear here once you visit one.",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: <History size={30} className="text-custom-text-400/40" />,
|
||||
text: "You don’t have any recent items yet.",
|
||||
};
|
||||
}
|
||||
};
|
||||
const { icon, text } = getDisplayContent();
|
||||
|
||||
return (
|
||||
<div className="min-h-[120px] flex w-full justify-center py-6 bg-custom-border-100 rounded">
|
||||
<div className="m-auto flex gap-2">
|
||||
{icon} <div className="text-custom-text-400 text-sm text-center my-auto">{text}</div>
|
||||
</div>
|
||||
<div className="text-custom-text-100 font-medium text-base text-center mb-1">No recent items yet</div>
|
||||
<div className="text-custom-text-300 text-sm text-center mb-2">You don’t have any recent items yet. </div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,17 +2,22 @@ import React from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Briefcase, Hotel, Users } from "lucide-react";
|
||||
// helpers
|
||||
import { getFileURL } from "@/helpers/file.helper";
|
||||
// hooks
|
||||
import { useCommandPalette, useEventTracker, useUser, useUserPermissions } from "@/hooks/store";
|
||||
// plane web constants
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants";
|
||||
|
||||
export const EmptyWorkspace = () => {
|
||||
// navigation
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { toggleCreateProjectModal } = useCommandPalette();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const { data: currentUser } = useUser();
|
||||
|
||||
// derived values
|
||||
const canCreateProject = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
@@ -83,6 +88,7 @@ export const EmptyWorkspace = () => {
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{EMPTY_STATE_DATA.map((item) => (
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// plane ui
|
||||
import { RecentStickyIcon } from "@plane/ui";
|
||||
|
||||
export const StickiesEmptyState = () => (
|
||||
<div className="min-h-[110px] flex w-full justify-center py-6 bg-custom-border-100 rounded">
|
||||
<div className="m-auto flex gap-2">
|
||||
<RecentStickyIcon className="h-[30px] w-[30px] text-custom-text-400/40" />
|
||||
<div className="text-custom-text-400 text-sm text-center my-auto">
|
||||
No stickies yet. Add one to start making quick notes.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -4,12 +4,11 @@ import { FC } from "react";
|
||||
// hooks
|
||||
// ui
|
||||
import { observer } from "mobx-react";
|
||||
import { Pencil, Trash2, ExternalLink, EllipsisVertical, Link, Link2 } from "lucide-react";
|
||||
import { Pencil, Trash2, ExternalLink, EllipsisVertical, Link2, Link } from "lucide-react";
|
||||
import { TOAST_TYPE, setToast, CustomMenu, TContextMenuItem } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "@plane/utils";
|
||||
import { cn, copyTextToClipboard } from "@plane/utils";
|
||||
import { calculateTimeAgo } from "@/helpers/date-time.helper";
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
import { useHome } from "@/hooks/store/use-home";
|
||||
import { TLinkOperations } from "./use-links";
|
||||
|
||||
@@ -37,7 +36,7 @@ export const ProjectLinkDetail: FC<TProjectLinkDetail> = observer((props) => {
|
||||
};
|
||||
|
||||
const handleCopyText = () =>
|
||||
copyUrlToClipboard(viewLink).then(() => {
|
||||
copyTextToClipboard(viewLink).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Link Copied!",
|
||||
@@ -74,7 +73,10 @@ export const ProjectLinkDetail: FC<TProjectLinkDetail> = observer((props) => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="group btn btn-primary flex bg-custom-background-100 px-4 w-[230px] h-[56px] border-[0.5px] border-custom-border-200 rounded-md gap-4 hover:shadow-md">
|
||||
<div
|
||||
onClick={handleOpenInNewTab}
|
||||
className="cursor-pointer group btn btn-primary flex bg-custom-background-100 px-4 w-[230px] h-[56px] border-[0.5px] border-custom-border-200 rounded-md gap-4 hover:shadow-md"
|
||||
>
|
||||
<div className="rounded p-2 bg-custom-background-80/40 w-8 h-8 my-auto">
|
||||
<Link2 className="h-4 w-4 stroke-2 text-custom-text-350 -rotate-45" />
|
||||
</div>
|
||||
|
||||
@@ -20,14 +20,14 @@ export const ProjectLinkList: FC<TProjectLinkList> = observer((props) => {
|
||||
const { linkOperations, workspaceSlug } = props;
|
||||
// hooks
|
||||
const {
|
||||
quickLinks: { getLinksByWorkspaceId, toggleLinkModal },
|
||||
quickLinks: { getLinksByWorkspaceId },
|
||||
} = useHome();
|
||||
|
||||
const links = getLinksByWorkspaceId(workspaceSlug);
|
||||
|
||||
if (links === undefined) return <WidgetLoader widgetKey={EWidgetKeys.QUICK_LINKS} />;
|
||||
|
||||
if (links.length === 0) return <LinksEmptyState handleCreate={() => toggleLinkModal(true)} />;
|
||||
if (links.length === 0) return <LinksEmptyState />;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
|
||||
@@ -32,7 +32,6 @@ export const useLinks = (workspaceSlug: string) => {
|
||||
create: async (data: Partial<TProjectLink>) => {
|
||||
try {
|
||||
if (!workspaceSlug) throw new Error("Missing required fields");
|
||||
console.log("data", data, workspaceSlug);
|
||||
await createLink(workspaceSlug, data);
|
||||
setToast({
|
||||
message: "The link has been successfully created",
|
||||
|
||||
@@ -11,18 +11,18 @@ import {
|
||||
import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
|
||||
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
|
||||
import { attachInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane helpers
|
||||
import { useParams } from "next/navigation";
|
||||
import { createRoot } from "react-dom/client";
|
||||
// ui
|
||||
// plane types
|
||||
import { InstructionType, TWidgetEntityData } from "@plane/types";
|
||||
// components
|
||||
// plane ui
|
||||
import { DropIndicator, ToggleSwitch } from "@plane/ui";
|
||||
// helpers
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useHome } from "@/hooks/store/use-home";
|
||||
import { HOME_WIDGETS_LIST } from "../../home-dashboard-widgets";
|
||||
import { WidgetItemDragHandle } from "./widget-item-drag-handle";
|
||||
import { getCanDrop, getInstructionFromPayload } from "./widget.helpers";
|
||||
|
||||
@@ -46,6 +46,7 @@ export const WidgetItem: FC<Props> = observer((props) => {
|
||||
const { widgetsMap } = useHome();
|
||||
// derived values
|
||||
const widget = widgetsMap[widgetId] as TWidgetEntityData;
|
||||
const widgetTitle = HOME_WIDGETS_LIST[widget.key]?.title;
|
||||
|
||||
// drag and drop
|
||||
useEffect(() => {
|
||||
@@ -119,7 +120,7 @@ export const WidgetItem: FC<Props> = observer((props) => {
|
||||
<div
|
||||
ref={elementRef}
|
||||
className={cn(
|
||||
"px-2 relative flex items-center py-2 font-medium text-sm capitalize group/widget-item rounded hover:bg-custom-background-80 justify-between",
|
||||
"px-2 relative flex items-center py-2 font-medium text-sm group/widget-item rounded hover:bg-custom-background-80 justify-between",
|
||||
{
|
||||
"cursor-grabbing bg-custom-background-80": isDragging,
|
||||
}
|
||||
@@ -127,7 +128,7 @@ export const WidgetItem: FC<Props> = observer((props) => {
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<WidgetItemDragHandle sort_order={widget.sort_order} isDragging={isDragging} />
|
||||
<div>{widget.key.replaceAll("_", " ")}</div>
|
||||
<div>{widgetTitle}</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
value={widget.is_enabled}
|
||||
|
||||
@@ -74,7 +74,7 @@ export const RecentActivityWidget: React.FC<THomeWidgetProps> = observer((props)
|
||||
<FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<RecentsEmptyState />
|
||||
<RecentsEmptyState type={filter} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -120,6 +120,7 @@ const HeaderFilters = observer((props: Props) => {
|
||||
layoutDisplayFiltersOptions={layoutDisplayFiltersOptions}
|
||||
labels={projectLabels}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
projectId={projectId}
|
||||
states={projectStates}
|
||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||
|
||||
@@ -37,7 +37,8 @@ export const useSubIssueOperations = (
|
||||
const { peekIssue: epicPeekIssue } = useIssueDetail(EIssueServiceType.EPICS);
|
||||
// const { updateEpicAnalytics } = useIssueTypes();
|
||||
const { updateAnalytics } = updateEpicAnalytics();
|
||||
const { fetchSubIssues, removeSubIssue } = useIssueDetail(issueServiceType);
|
||||
const { fetchSubIssues } = useIssueDetail();
|
||||
const { removeSubIssue } = useIssueDetail(issueServiceType);
|
||||
const { captureIssueEvent } = useEventTracker();
|
||||
|
||||
// derived values
|
||||
|
||||
+19
-10
@@ -10,7 +10,7 @@ import { EIssueFilterType, EIssuesStoreType } from "@plane/constants";
|
||||
import { IIssueFilterOptions, TStaticViewTypes } from "@plane/types";
|
||||
//ui
|
||||
// components
|
||||
import { Header, EHeaderVariant } from "@plane/ui";
|
||||
import { Header, EHeaderVariant, Loader } from "@plane/ui";
|
||||
import { AppliedFiltersList } from "@/components/issues";
|
||||
import { UpdateViewComponent } from "@/components/views/update-view-component";
|
||||
import { CreateUpdateWorkspaceViewModal } from "@/components/workspace";
|
||||
@@ -27,10 +27,11 @@ import { getAreFiltersEqual } from "../../../utils";
|
||||
|
||||
type Props = {
|
||||
globalViewId: string;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export const GlobalViewsAppliedFiltersRoot = observer((props: Props) => {
|
||||
const { globalViewId } = props;
|
||||
const { globalViewId, isLoading = false } = props;
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
@@ -154,14 +155,22 @@ export const GlobalViewsAppliedFiltersRoot = observer((props: Props) => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<AppliedFiltersList
|
||||
labels={workspaceLabels ?? undefined}
|
||||
appliedFilters={appliedFilters ?? {}}
|
||||
handleClearAllFilters={handleClearAllFilters}
|
||||
handleRemoveFilter={handleRemoveFilter}
|
||||
disableEditing={isLocked}
|
||||
alwaysAllowEditing
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Loader className="flex flex-wrap items-stretch gap-2 bg-custom-background-100 truncate my-auto">
|
||||
<Loader.Item height="36px" width="150px" />
|
||||
<Loader.Item height="36px" width="100px" />
|
||||
<Loader.Item height="36px" width="300px" />
|
||||
</Loader>
|
||||
) : (
|
||||
<AppliedFiltersList
|
||||
labels={workspaceLabels ?? undefined}
|
||||
appliedFilters={appliedFilters ?? {}}
|
||||
handleClearAllFilters={handleClearAllFilters}
|
||||
handleRemoveFilter={handleRemoveFilter}
|
||||
disableEditing={isLocked}
|
||||
alwaysAllowEditing
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isDefaultView ? (
|
||||
<UpdateViewComponent
|
||||
|
||||
+18
-1
@@ -27,9 +27,11 @@ import {
|
||||
FilterIssueGrouping,
|
||||
} from "@/components/issues";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web components
|
||||
import { FilterIssueTypes, FilterTeamProjects } from "@/plane-web/components/issues";
|
||||
import { EUserPermissions } from "@/plane-web/constants";
|
||||
|
||||
type Props = {
|
||||
filters: IIssueFilterOptions;
|
||||
@@ -37,6 +39,7 @@ type Props = {
|
||||
handleDisplayFiltersUpdate?: (updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => void;
|
||||
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
|
||||
layoutDisplayFiltersOptions: ILayoutDisplayFiltersOptions | undefined;
|
||||
projectId?: string;
|
||||
labels?: IIssueLabel[] | undefined;
|
||||
memberIds?: string[] | undefined;
|
||||
states?: IState[] | undefined;
|
||||
@@ -52,6 +55,7 @@ export const FilterSelection: React.FC<Props> = observer((props) => {
|
||||
handleDisplayFiltersUpdate,
|
||||
handleFiltersUpdate,
|
||||
layoutDisplayFiltersOptions,
|
||||
projectId,
|
||||
labels,
|
||||
memberIds,
|
||||
states,
|
||||
@@ -62,9 +66,22 @@ export const FilterSelection: React.FC<Props> = observer((props) => {
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { moduleId, cycleId } = useParams();
|
||||
const {
|
||||
project: { getProjectMemberDetails },
|
||||
} = useMember();
|
||||
// states
|
||||
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
|
||||
|
||||
// filter guests from assignees
|
||||
const assigneeIds = memberIds?.filter((id) => {
|
||||
if (projectId) {
|
||||
const memeberDetails = getProjectMemberDetails(id, projectId);
|
||||
const isGuest = (memeberDetails?.role || EUserPermissions.GUEST) === EUserPermissions.GUEST;
|
||||
if (isGuest && memeberDetails) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const isFilterEnabled = (filter: keyof IIssueFilterOptions) => layoutDisplayFiltersOptions?.filters.includes(filter);
|
||||
|
||||
const isDisplayFilterEnabled = (displayFilter: keyof IIssueDisplayFilterOptions) =>
|
||||
@@ -140,7 +157,7 @@ export const FilterSelection: React.FC<Props> = observer((props) => {
|
||||
<FilterAssignees
|
||||
appliedFilters={filters.assignees ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("assignees", val)}
|
||||
memberIds={memberIds}
|
||||
memberIds={assigneeIds}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { TSelectionHelper } from "@/hooks/use-multiple-select";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web components
|
||||
import { IssueIdentifier } from "@/plane-web/components/issues";
|
||||
import { IssueStats } from "@/plane-web/components/issues/issue-layouts/issue-stats";
|
||||
// types
|
||||
import { TRenderQuickActions } from "./list-view-types";
|
||||
|
||||
@@ -276,6 +277,7 @@ export const IssueBlock = observer((props: IssueBlockProps) => {
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
{!issue?.tempId ? (
|
||||
<>
|
||||
{isEpic && <IssueStats issueId={issue.id} />}
|
||||
<IssueProperties
|
||||
className={`relative flex flex-wrap ${isSidebarCollapsed ? "md:flex-grow md:flex-shrink-0" : "lg:flex-grow lg:flex-shrink-0"} items-center gap-2 whitespace-nowrap`}
|
||||
issue={issue}
|
||||
|
||||
@@ -29,10 +29,12 @@ import { TRenderQuickActions } from "../list/list-view-types";
|
||||
|
||||
type Props = {
|
||||
isDefaultView: boolean;
|
||||
isLoading?: boolean;
|
||||
toggleLoading: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export const AllIssueLayoutRoot: React.FC<Props> = observer((props: Props) => {
|
||||
const { isDefaultView } = props;
|
||||
const { isDefaultView, isLoading = false, toggleLoading } = props;
|
||||
// router
|
||||
const { workspaceSlug, globalViewId } = useParams();
|
||||
const router = useAppRouter();
|
||||
@@ -92,7 +94,7 @@ export const AllIssueLayoutRoot: React.FC<Props> = observer((props: Props) => {
|
||||
if (workspaceSlug && globalViewId) fetchNextIssues(workspaceSlug.toString(), globalViewId.toString());
|
||||
}, [fetchNextIssues, workspaceSlug, globalViewId]);
|
||||
|
||||
const { isLoading } = useSWR(
|
||||
const { isLoading: globalViewsLoading } = useSWR(
|
||||
workspaceSlug ? `WORKSPACE_GLOBAL_VIEWS_${workspaceSlug}` : null,
|
||||
async () => {
|
||||
if (workspaceSlug) {
|
||||
@@ -102,11 +104,12 @@ export const AllIssueLayoutRoot: React.FC<Props> = observer((props: Props) => {
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
useSWR(
|
||||
const { isLoading: issuesLoading } = useSWR(
|
||||
workspaceSlug && globalViewId ? `WORKSPACE_GLOBAL_VIEW_ISSUES_${workspaceSlug}_${globalViewId}` : null,
|
||||
async () => {
|
||||
if (workspaceSlug && globalViewId) {
|
||||
clear();
|
||||
toggleLoading(true);
|
||||
await fetchFilters(workspaceSlug.toString(), globalViewId.toString());
|
||||
await fetchIssues(
|
||||
workspaceSlug.toString(),
|
||||
@@ -118,6 +121,7 @@ export const AllIssueLayoutRoot: React.FC<Props> = observer((props: Props) => {
|
||||
}
|
||||
);
|
||||
routerFilterParams();
|
||||
toggleLoading(false);
|
||||
}
|
||||
},
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
@@ -171,7 +175,7 @@ export const AllIssueLayoutRoot: React.FC<Props> = observer((props: Props) => {
|
||||
);
|
||||
|
||||
// when the call is not loading and the view does not exist and the view is not a default view, show empty state
|
||||
if (!isLoading && !viewDetails && !isDefaultView) {
|
||||
if (!isLoading && !globalViewsLoading && !issuesLoading && !viewDetails && !isDefaultView) {
|
||||
return (
|
||||
<EmptyState
|
||||
image={emptyView}
|
||||
@@ -185,7 +189,7 @@ export const AllIssueLayoutRoot: React.FC<Props> = observer((props: Props) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (getIssueLoader() === "init-loader" || !globalViewId || !groupedIssueIds) {
|
||||
if ((isLoading && issuesLoading && getIssueLoader() === "init-loader") || !globalViewId || !groupedIssueIds) {
|
||||
return <SpreadsheetLayoutLoader />;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,9 +42,7 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
|
||||
} = props;
|
||||
const issueStoreType = useIssueStoreType();
|
||||
|
||||
const storeType = (issueStoreFromProps ? issueStoreFromProps : issueStoreType === EIssuesStoreType.EPIC)
|
||||
? EIssuesStoreType.PROJECT
|
||||
: issueStoreType;
|
||||
const storeType = issueStoreFromProps ?? issueStoreType;
|
||||
// ref
|
||||
const issueTitleRef = useRef<HTMLInputElement>(null);
|
||||
// states
|
||||
|
||||
@@ -56,8 +56,10 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
|
||||
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
subIssues: { subIssueHelpersByIssueId, setSubIssueHelpers },
|
||||
} = useIssueDetail(issueServiceType);
|
||||
const {
|
||||
subIssues: { subIssueHelpersByIssueId, setSubIssueHelpers },
|
||||
} = useIssueDetail();
|
||||
const { toggleCreateIssueModal, toggleDeleteIssueModal } = useIssueDetail(issueServiceType);
|
||||
const project = useProject();
|
||||
const { getProjectStates } = useProjectState();
|
||||
|
||||
@@ -34,8 +34,9 @@ export const WorkspaceDraftIssuesRoot: FC<TWorkspaceDraftIssuesRoot> = observer(
|
||||
|
||||
// fetching issues
|
||||
const { isLoading } = useSWR(
|
||||
workspaceSlug && issueIds.length <= 0 ? `WORKSPACE_DRAFT_ISSUES_${workspaceSlug}` : null,
|
||||
workspaceSlug && issueIds.length <= 0 ? async () => await fetchIssues(workspaceSlug, "init-loader") : null
|
||||
workspaceSlug ? `WORKSPACE_DRAFT_ISSUES_${workspaceSlug}` : null,
|
||||
workspaceSlug ? async () => await fetchIssues(workspaceSlug, "init-loader") : null,
|
||||
{ revalidateOnFocus: false, revalidateIfStale: false }
|
||||
);
|
||||
|
||||
// handle nest issues
|
||||
|
||||
@@ -3,25 +3,37 @@ import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import { Plus, StickyNote as StickyIcon, X } from "lucide-react";
|
||||
// plane hooks
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
// plane ui
|
||||
import { RecentStickyIcon, StickyNoteIcon, Tooltip } from "@plane/ui";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useCommandPalette } from "@/hooks/store";
|
||||
import { useSticky } from "@/hooks/use-stickies";
|
||||
// components
|
||||
import { STICKY_COLORS_LIST } from "../editor/sticky-editor/color-palette";
|
||||
import { AllStickiesModal } from "./modal";
|
||||
import { StickyNote } from "./sticky";
|
||||
|
||||
export const StickyActionBar = observer(() => {
|
||||
const { workspaceSlug } = useParams();
|
||||
// states
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [newSticky, setNewSticky] = useState(false);
|
||||
const [showRecentSticky, setShowRecentSticky] = useState(false);
|
||||
// navigation
|
||||
const { workspaceSlug } = useParams();
|
||||
// refs
|
||||
const ref = useRef(null);
|
||||
|
||||
// hooks
|
||||
// store hooks
|
||||
const { stickies, activeStickyId, recentStickyId, updateActiveStickyId, fetchRecentSticky, toggleShowNewSticky } =
|
||||
useSticky();
|
||||
const { toggleAllStickiesModal, allStickiesModal } = useCommandPalette();
|
||||
// derived values
|
||||
const recentStickyBackgroundColor = recentStickyId
|
||||
? STICKY_COLORS_LIST.find((c) => c.key === stickies[recentStickyId].background_color)?.backgroundColor
|
||||
: STICKY_COLORS_LIST[0].backgroundColor;
|
||||
|
||||
useSWR(
|
||||
workspaceSlug ? `WORKSPACE_STICKIES_RECENT_${workspaceSlug}` : null,
|
||||
@@ -63,7 +75,7 @@ export const StickyActionBar = observer(() => {
|
||||
<div
|
||||
className="absolute top-0 right-0 h-full w-full"
|
||||
style={{
|
||||
background: `linear-gradient(to top, ${stickies[recentStickyId]?.color}, transparent)`,
|
||||
background: `linear-gradient(to top, ${recentStickyBackgroundColor}, transparent)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -75,9 +87,9 @@ export const StickyActionBar = observer(() => {
|
||||
<button
|
||||
className="btn btn--icon rounded-full w-10 h-10 flex items-center justify-center shadow-sm bg-custom-background-100"
|
||||
onClick={() => setShowRecentSticky(true)}
|
||||
style={{ color: stickies[recentStickyId]?.color }}
|
||||
style={{ color: recentStickyBackgroundColor }}
|
||||
>
|
||||
<StickyNoteIcon className={cn("size-5 rotate-90")} color={stickies[recentStickyId]?.color} />
|
||||
<StickyNoteIcon className={cn("size-5 rotate-90")} color={recentStickyBackgroundColor} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Plus, StickyNote as StickyIcon } from "lucide-react";
|
||||
import { Button } from "@plane/ui";
|
||||
|
||||
type TProps = {
|
||||
handleCreate: () => void;
|
||||
creatingSticky?: boolean;
|
||||
};
|
||||
export const EmptyState = (props: TProps) => {
|
||||
const { handleCreate, creatingSticky } = props;
|
||||
return (
|
||||
<div className="flex justify-center h-[500px] rounded border-[1.5px] border-custom-border-100 mx-2">
|
||||
<div className="m-auto">
|
||||
<div
|
||||
className={`mb-2 rounded-full mx-auto last:rounded-full w-[50px] h-[50px] flex items-center justify-center bg-custom-background-80/40 transition-transform duration-300`}
|
||||
>
|
||||
<StickyIcon className="size-[30px] rotate-90 text-custom-text-350/20" />
|
||||
</div>
|
||||
<div className="text-custom-text-100 font-medium text-lg text-center mb-1">No stickies yet</div>
|
||||
<div className="text-custom-text-300 text-sm text-center mb-2">
|
||||
All your stickies in this workspace will appear here.
|
||||
</div>
|
||||
<Button size="sm" variant="accent-primary" className="mx-auto" onClick={handleCreate} disabled={creatingSticky}>
|
||||
<Plus className="size-4 my-auto" /> <span>Add sticky</span>
|
||||
{creatingSticky && (
|
||||
<div className="flex items-center justify-center ml-2">
|
||||
<div
|
||||
className={`w-4 h-4 border-2 border-t-transparent rounded-full animate-spin border-custom-primary-100`}
|
||||
role="status"
|
||||
aria-label="loading"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./action-bar";
|
||||
export * from "./widget";
|
||||
export * from "./layout";
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./stickies-infinite";
|
||||
export * from "./stickies-list";
|
||||
export * from "./stickies-truncated";
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import { STICKIES_PER_PAGE } from "@plane/constants";
|
||||
import { ContentWrapper, Loader } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
|
||||
import { useSticky } from "@/hooks/use-stickies";
|
||||
import { StickiesLayout } from "./stickies-list";
|
||||
|
||||
export const StickiesInfinite = observer(() => {
|
||||
const { workspaceSlug } = useParams();
|
||||
// hooks
|
||||
const { fetchWorkspaceStickies, fetchNextWorkspaceStickies, getWorkspaceStickyIds, loader, paginationInfo } =
|
||||
useSticky();
|
||||
//state
|
||||
const [elementRef, setElementRef] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
// ref
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useSWR(
|
||||
workspaceSlug ? `WORKSPACE_STICKIES_${workspaceSlug}` : null,
|
||||
workspaceSlug ? () => fetchWorkspaceStickies(workspaceSlug.toString()) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (loader === "pagination") return;
|
||||
fetchNextWorkspaceStickies(workspaceSlug?.toString());
|
||||
};
|
||||
|
||||
const hasNextPage = paginationInfo?.next_page_results && paginationInfo?.next_cursor !== undefined;
|
||||
const shouldObserve = hasNextPage && loader !== "pagination";
|
||||
const workspaceStickies = getWorkspaceStickyIds(workspaceSlug?.toString());
|
||||
useIntersectionObserver(containerRef, shouldObserve ? elementRef : null, handleLoadMore);
|
||||
|
||||
return (
|
||||
<ContentWrapper ref={containerRef} className="space-y-4">
|
||||
<StickiesLayout
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
intersectionElement={
|
||||
hasNextPage &&
|
||||
workspaceStickies?.length >= STICKIES_PER_PAGE && (
|
||||
<div
|
||||
className={cn("flex min-h-[300px] box-border p-2 w-full")}
|
||||
ref={setElementRef}
|
||||
id="intersection-element"
|
||||
>
|
||||
<div className="flex w-full rounded min-h-[300px]">
|
||||
<Loader className="w-full h-full">
|
||||
<Loader.Item height="100%" width="100%" />
|
||||
</Loader>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</ContentWrapper>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type {
|
||||
DropTargetRecord,
|
||||
DragLocationHistory,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types";
|
||||
import type { ElementDragPayload } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Masonry from "react-masonry-component";
|
||||
// plane ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
// constants
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
// hooks
|
||||
import { useSticky } from "@/hooks/use-stickies";
|
||||
import { useStickyOperations } from "../sticky/use-operations";
|
||||
import { StickyDNDWrapper } from "./sticky-dnd-wrapper";
|
||||
import { getInstructionFromPayload } from "./sticky.helpers";
|
||||
import { StickiesEmptyState } from "@/components/home/widgets/empty-states/stickies";
|
||||
|
||||
type TStickiesLayout = {
|
||||
workspaceSlug: string;
|
||||
intersectionElement?: React.ReactNode | null;
|
||||
};
|
||||
|
||||
type TProps = TStickiesLayout & {
|
||||
columnCount: number;
|
||||
};
|
||||
|
||||
export const StickiesList = observer((props: TProps) => {
|
||||
const { workspaceSlug, intersectionElement, columnCount } = props;
|
||||
// navigation
|
||||
const pathname = usePathname();
|
||||
// store hooks
|
||||
const { getWorkspaceStickyIds, toggleShowNewSticky, searchQuery, loader } = useSticky();
|
||||
// sticky operations
|
||||
const { stickyOperations } = useStickyOperations({ workspaceSlug: workspaceSlug?.toString() });
|
||||
// derived values
|
||||
const workspaceStickyIds = getWorkspaceStickyIds(workspaceSlug?.toString());
|
||||
const itemWidth = `${100 / columnCount}%`;
|
||||
const totalRows = Math.ceil(workspaceStickyIds.length / columnCount);
|
||||
const isStickiesPage = pathname?.includes("stickies");
|
||||
|
||||
// Function to determine if an item is in first or last row
|
||||
const getRowPositions = (index: number) => {
|
||||
const currentRow = Math.floor(index / columnCount);
|
||||
return {
|
||||
isInFirstRow: currentRow === 0,
|
||||
isInLastRow: currentRow === totalRows - 1 || index >= workspaceStickyIds.length - columnCount,
|
||||
};
|
||||
};
|
||||
|
||||
const handleDrop = (self: DropTargetRecord, source: ElementDragPayload, location: DragLocationHistory) => {
|
||||
const dropTargets = location?.current?.dropTargets ?? [];
|
||||
if (!dropTargets || dropTargets.length <= 0) return;
|
||||
|
||||
const dropTarget = dropTargets[0];
|
||||
if (!dropTarget?.data?.id || !source.data?.id) return;
|
||||
|
||||
const instruction = getInstructionFromPayload(dropTarget, source, location);
|
||||
const droppedId = dropTarget.data.id;
|
||||
const sourceId = source.data.id;
|
||||
|
||||
try {
|
||||
if (!instruction || !droppedId || !sourceId) return;
|
||||
stickyOperations.updatePosition(workspaceSlug, sourceId as string, droppedId as string, instruction);
|
||||
} catch (error) {
|
||||
console.error("Error reordering sticky:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loader === "init-loader") {
|
||||
return (
|
||||
<div className="min-h-[500px] overflow-scroll pb-2">
|
||||
<Loader>
|
||||
<Loader.Item height="300px" width="255px" />
|
||||
</Loader>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loader === "loaded" && workspaceStickyIds.length === 0) {
|
||||
return (
|
||||
<div className="size-full grid place-items-center">
|
||||
{isStickiesPage ? (
|
||||
<EmptyState
|
||||
type={searchQuery ? EmptyStateType.STICKIES_SEARCH : EmptyStateType.STICKIES}
|
||||
layout={searchQuery ? "screen-simple" : "screen-detailed"}
|
||||
primaryButtonOnClick={() => {
|
||||
toggleShowNewSticky(true);
|
||||
stickyOperations.create();
|
||||
}}
|
||||
primaryButtonConfig={{
|
||||
size: "sm",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<StickiesEmptyState />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="transition-opacity duration-300 ease-in-out">
|
||||
{/* @ts-expect-error type mismatch here */}
|
||||
<Masonry elementType="div">
|
||||
{workspaceStickyIds.map((stickyId, index) => {
|
||||
const { isInFirstRow, isInLastRow } = getRowPositions(index);
|
||||
return (
|
||||
<StickyDNDWrapper
|
||||
key={stickyId}
|
||||
stickyId={stickyId}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
itemWidth={itemWidth}
|
||||
handleDrop={handleDrop}
|
||||
isLastChild={index === workspaceStickyIds.length - 1}
|
||||
isInFirstRow={isInFirstRow}
|
||||
isInLastRow={isInLastRow}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{intersectionElement && <div style={{ width: itemWidth }}>{intersectionElement}</div>}
|
||||
</Masonry>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const StickiesLayout = (props: TStickiesLayout) => {
|
||||
// states
|
||||
const [containerWidth, setContainerWidth] = useState<number | null>(null);
|
||||
// refs
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref?.current) return;
|
||||
|
||||
setContainerWidth(ref?.current.offsetWidth);
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
setContainerWidth(entry.contentRect.width);
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(ref?.current);
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
const getColumnCount = (width: number | null): number => {
|
||||
if (width === null) return 4;
|
||||
|
||||
if (width < 640) return 2; // sm
|
||||
if (width < 768) return 3; // md
|
||||
if (width < 1024) return 4; // lg
|
||||
if (width < 1280) return 5; // xl
|
||||
return 6; // 2xl and above
|
||||
};
|
||||
const columnCount = getColumnCount(containerWidth);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="size-full min-h-[500px]">
|
||||
<StickiesList {...props} columnCount={columnCount} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useSticky } from "@/hooks/use-stickies";
|
||||
// components
|
||||
import { ContentOverflowWrapper } from "../../core/content-overflow-HOC";
|
||||
import { StickiesLayout } from "./stickies-list";
|
||||
|
||||
export const StickiesTruncated = observer(() => {
|
||||
// navigation
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { fetchWorkspaceStickies } = useSticky();
|
||||
|
||||
useSWR(
|
||||
workspaceSlug ? `WORKSPACE_STICKIES_${workspaceSlug}` : null,
|
||||
workspaceSlug ? () => fetchWorkspaceStickies(workspaceSlug.toString()) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
return (
|
||||
<ContentOverflowWrapper
|
||||
maxHeight={620}
|
||||
containerClassName="pb-2 box-border"
|
||||
fallback={null}
|
||||
customButton={
|
||||
<Link
|
||||
href={`/${workspaceSlug}/stickies`}
|
||||
className={cn(
|
||||
"gap-1 w-full text-custom-primary-100 text-sm font-medium transition-opacity duration-300 bg-custom-background-90/20"
|
||||
)}
|
||||
>
|
||||
Show all
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<StickiesLayout workspaceSlug={workspaceSlug?.toString()} />
|
||||
</ContentOverflowWrapper>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import type {
|
||||
DropTargetRecord,
|
||||
DragLocationHistory,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types";
|
||||
import {
|
||||
draggable,
|
||||
dropTargetForElements,
|
||||
ElementDragPayload,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
|
||||
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
|
||||
import { attachInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { InstructionType } from "@plane/types";
|
||||
import { DropIndicator } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
import { StickyNote } from "../sticky";
|
||||
import { getInstructionFromPayload } from "./sticky.helpers";
|
||||
|
||||
// Draggable Sticky Wrapper Component
|
||||
export const StickyDNDWrapper = observer(
|
||||
({
|
||||
stickyId,
|
||||
workspaceSlug,
|
||||
itemWidth,
|
||||
isLastChild,
|
||||
isInFirstRow,
|
||||
isInLastRow,
|
||||
handleDrop,
|
||||
}: {
|
||||
stickyId: string;
|
||||
workspaceSlug: string;
|
||||
itemWidth: string;
|
||||
isLastChild: boolean;
|
||||
isInFirstRow: boolean;
|
||||
isInLastRow: boolean;
|
||||
handleDrop: (self: DropTargetRecord, source: ElementDragPayload, location: DragLocationHistory) => void;
|
||||
}) => {
|
||||
const pathName = usePathname();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [instruction, setInstruction] = useState<InstructionType | undefined>(undefined);
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const element = elementRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const initialData = { id: stickyId, type: "sticky" };
|
||||
|
||||
if (pathName.includes("stickies"))
|
||||
return combine(
|
||||
draggable({
|
||||
element,
|
||||
dragHandle: element,
|
||||
getInitialData: () => initialData,
|
||||
onDragStart: () => {
|
||||
setIsDragging(true);
|
||||
},
|
||||
onDrop: () => {
|
||||
setIsDragging(false);
|
||||
},
|
||||
onGenerateDragPreview: ({ nativeSetDragImage }) => {
|
||||
setCustomNativeDragPreview({
|
||||
getOffset: pointerOutsideOfPreview({ x: "-200px", y: "0px" }),
|
||||
render: ({ container }) => {
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
<div className="scale-50">
|
||||
<div className="-m-2 max-h-[150px]">
|
||||
<StickyNote
|
||||
className={"w-[290px]"}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
stickyId={stickyId}
|
||||
showToolbar={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return () => root.unmount();
|
||||
},
|
||||
nativeSetDragImage,
|
||||
});
|
||||
},
|
||||
}),
|
||||
dropTargetForElements({
|
||||
element,
|
||||
canDrop: ({ source }) => source.data?.type === "sticky",
|
||||
getData: ({ input, element }) => {
|
||||
const blockedStates: InstructionType[] = ["make-child"];
|
||||
if (!isLastChild) {
|
||||
blockedStates.push("reorder-below");
|
||||
}
|
||||
|
||||
return attachInstruction(initialData, {
|
||||
input,
|
||||
element,
|
||||
currentLevel: 1,
|
||||
indentPerLevel: 0,
|
||||
mode: isLastChild ? "last-in-group" : "standard",
|
||||
block: blockedStates,
|
||||
});
|
||||
},
|
||||
onDrag: ({ self, source, location }) => {
|
||||
const instruction = getInstructionFromPayload(self, source, location);
|
||||
setInstruction(instruction);
|
||||
},
|
||||
onDragLeave: () => {
|
||||
setInstruction(undefined);
|
||||
},
|
||||
onDrop: ({ self, source, location }) => {
|
||||
setInstruction(undefined);
|
||||
handleDrop(self, source, location);
|
||||
},
|
||||
})
|
||||
);
|
||||
}, [stickyId, isDragging]);
|
||||
|
||||
return (
|
||||
<div className="relative" style={{ width: itemWidth }}>
|
||||
{!isInFirstRow && <DropIndicator isVisible={instruction === "reorder-above"} />}
|
||||
<div
|
||||
ref={elementRef}
|
||||
className={cn("flex min-h-[300px] box-border p-2", {
|
||||
"opacity-50": isDragging,
|
||||
})}
|
||||
>
|
||||
<StickyNote key={stickyId || "new"} workspaceSlug={workspaceSlug} stickyId={stickyId} />
|
||||
</div>
|
||||
{!isInLastRow && <DropIndicator isVisible={instruction === "reorder-below"} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,45 @@
|
||||
import { extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
|
||||
import { InstructionType, IPragmaticPayloadLocation, TDropTarget } from "@plane/types";
|
||||
|
||||
export type TargetData = {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
isGroup: boolean;
|
||||
isChild: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* extracts the Payload and translates the instruction for the current dropTarget based on drag and drop payload
|
||||
* @param dropTarget dropTarget for which the instruction is required
|
||||
* @param source the dragging sticky data that is being dragged on the dropTarget
|
||||
* @param location location includes the data of all the dropTargets the source is being dragged on
|
||||
* @returns Instruction for dropTarget
|
||||
*/
|
||||
export const getInstructionFromPayload = (
|
||||
dropTarget: TDropTarget,
|
||||
source: TDropTarget,
|
||||
location: IPragmaticPayloadLocation
|
||||
): InstructionType | undefined => {
|
||||
const dropTargetData = dropTarget?.data as TargetData;
|
||||
const sourceData = source?.data as TargetData;
|
||||
const allDropTargets = location?.current?.dropTargets;
|
||||
|
||||
// if all the dropTargets are greater than 1 meaning the source is being dragged on a group and its child at the same time
|
||||
// and also if the dropTarget in question is also a group then, it should be a child of the current Droptarget
|
||||
if (allDropTargets?.length > 1 && dropTargetData?.isGroup) return "make-child";
|
||||
|
||||
if (!dropTargetData || !sourceData) return undefined;
|
||||
|
||||
let instruction = extractInstruction(dropTargetData)?.type;
|
||||
|
||||
// If the instruction is blocked then set an instruction based on if dropTarget it is a child or not
|
||||
if (instruction === "instruction-blocked") {
|
||||
instruction = dropTargetData.isChild ? "reorder-above" : "make-child";
|
||||
}
|
||||
|
||||
// if source that is being dragged is a group. A group cannon be a child of any other sticky,
|
||||
// hence if current instruction is to be a child of dropTarget then reorder-above instead
|
||||
if (instruction === "make-child" && sourceData.isGroup) instruction = "reorder-above";
|
||||
|
||||
return instruction;
|
||||
};
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { FC, useRef, useState } from "react";
|
||||
import { FC, useCallback, useRef, useState } from "react";
|
||||
import { debounce } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Search, X } from "lucide-react";
|
||||
// plane hooks
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
@@ -10,25 +12,41 @@ import { cn } from "@/helpers/common.helper";
|
||||
import { useSticky } from "@/hooks/use-stickies";
|
||||
|
||||
export const StickySearch: FC = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// hooks
|
||||
const { searchQuery, updateSearchQuery } = useSticky();
|
||||
const { searchQuery, updateSearchQuery, fetchWorkspaceStickies } = useSticky();
|
||||
// refs
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
// states
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
|
||||
// outside click detector hook
|
||||
useOutsideClickDetector(inputRef, () => {
|
||||
if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false);
|
||||
});
|
||||
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Escape") {
|
||||
if (searchQuery && searchQuery.trim() !== "") updateSearchQuery("");
|
||||
else setIsSearchOpen(false);
|
||||
if (searchQuery && searchQuery.trim() !== "") {
|
||||
updateSearchQuery("");
|
||||
fetchStickies();
|
||||
} else setIsSearchOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchStickies = async () => {
|
||||
await fetchWorkspaceStickies(workspaceSlug.toString());
|
||||
};
|
||||
|
||||
const debouncedSearch = useCallback(
|
||||
debounce(async () => {
|
||||
await fetchStickies();
|
||||
}, 500),
|
||||
[fetchWorkspaceStickies]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center mr-2">
|
||||
<div className="flex items-center mr-2 my-auto">
|
||||
{!isSearchOpen && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -55,7 +73,10 @@ export const StickySearch: FC = observer(() => {
|
||||
className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 placeholder:text-custom-text-400 focus:outline-none"
|
||||
placeholder="Search by title"
|
||||
value={searchQuery}
|
||||
onChange={(e) => updateSearchQuery(e.target.value)}
|
||||
onChange={(e) => {
|
||||
updateSearchQuery(e.target.value);
|
||||
debouncedSearch();
|
||||
}}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
/>
|
||||
{isSearchOpen && (
|
||||
@@ -65,6 +86,7 @@ export const StickySearch: FC = observer(() => {
|
||||
onClick={() => {
|
||||
updateSearchQuery("");
|
||||
setIsSearchOpen(false);
|
||||
fetchStickies();
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
|
||||
@@ -3,8 +3,7 @@ import { useParams } from "next/navigation";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { RecentStickyIcon } from "@plane/ui";
|
||||
import { useSticky } from "@/hooks/use-stickies";
|
||||
import { STICKY_COLORS } from "../../editor/sticky-editor/color-pallete";
|
||||
import { StickiesLayout } from "../stickies-layout";
|
||||
import { StickiesTruncated } from "../layout/stickies-truncated";
|
||||
import { useStickyOperations } from "../sticky/use-operations";
|
||||
import { StickySearch } from "./search";
|
||||
|
||||
@@ -19,13 +18,13 @@ export const Stickies = observer((props: TProps) => {
|
||||
const { stickyOperations } = useStickyOperations({ workspaceSlug: workspaceSlug?.toString() });
|
||||
|
||||
return (
|
||||
<div className="p-6 pb-0">
|
||||
<div className="p-6 pb-0 min-h-[620px]">
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
{/* Title */}
|
||||
<div className="text-custom-text-100 flex gap-2">
|
||||
<RecentStickyIcon className="size-5 rotate-90" />
|
||||
<p className="text-lg font-medium">My Stickies</p>
|
||||
<p className="text-lg font-medium">Your stickies</p>
|
||||
</div>
|
||||
{/* actions */}
|
||||
<div className="flex gap-2">
|
||||
@@ -33,7 +32,7 @@ export const Stickies = observer((props: TProps) => {
|
||||
<button
|
||||
onClick={() => {
|
||||
toggleShowNewSticky(true);
|
||||
stickyOperations.create({ color: STICKY_COLORS[0] });
|
||||
stickyOperations.create();
|
||||
}}
|
||||
className="flex gap-1 text-sm font-medium text-custom-primary-100 my-auto"
|
||||
disabled={creatingSticky}
|
||||
@@ -61,7 +60,7 @@ export const Stickies = observer((props: TProps) => {
|
||||
</div>
|
||||
{/* content */}
|
||||
<div className="mb-4 max-h-[625px] overflow-scroll">
|
||||
<StickiesLayout />
|
||||
<StickiesTruncated />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user