UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: glass shell и компактный composer открытой карточки внутреннего контура

This commit is contained in:
DCCONSTRUCTIONS 2026-04-19 14:21:23 +03:00
parent fc72c35e5a
commit 4ba06307ed
11 changed files with 138 additions and 29 deletions

View File

@ -26,6 +26,7 @@ type TCommentCreate = {
showToolbarInitially?: boolean;
projectId?: string;
onSubmitCallback?: (elementId: string) => void;
appearance?: "default" | "issue-peek-compact";
};
// services
@ -39,6 +40,7 @@ export const CommentCreate = observer(function CommentCreate(props: TCommentCrea
showToolbarInitially = false,
projectId,
onSubmitCallback,
appearance = "default",
} = props;
// states
const [uploadedAssetIds, setUploadedAssetIds] = useState<string[]>([]);
@ -89,10 +91,13 @@ export const CommentCreate = observer(function CommentCreate(props: TCommentCrea
const commentHTML = watch("comment_html");
const isEmpty = isCommentEmpty(commentHTML ?? undefined);
const isCompactAppearance = appearance === "issue-peek-compact";
return (
<div
className={cn("sticky bottom-0 z-[4] bg-surface-1 sm:static")}
className={cn(
isCompactAppearance ? "relative z-[4] bg-transparent" : "sticky bottom-0 z-[4] bg-surface-1 sm:static"
)}
onKeyDown={(e) => {
if (
e.key === "Enter" &&
@ -128,7 +133,6 @@ export const CommentCreate = observer(function CommentCreate(props: TCommentCrea
}}
ref={editorRef}
initialValue={value ?? "<p></p>"}
containerClassName="min-h-min"
onChange={(comment_json, comment_html) => onChange(comment_html)}
accessSpecifier={accessValue ?? EIssueCommentAccessSpecifier.INTERNAL}
handleAccessChange={onAccessChange}
@ -144,7 +148,16 @@ export const CommentCreate = observer(function CommentCreate(props: TCommentCrea
return asset_id;
}}
showToolbarInitially={showToolbarInitially}
parentClassName="p-2"
parentClassName={cn(
isCompactAppearance
? "rounded-[20px] border border-subtle/70 bg-layer-2/75 p-4 backdrop-blur-xl"
: "p-2"
)}
variant={isCompactAppearance ? "full" : "full"}
toolbarMode={isCompactAppearance ? "compact-comment" : "default"}
showPlaceholderOnEmpty={isCompactAppearance}
editorClassName={cn(isCompactAppearance && "min-h-[112px]")}
containerClassName={cn(isCompactAppearance ? "min-h-[112px] border-none p-0" : "min-h-min")}
displayConfig={{
fontSize: "small-font",
}}

View File

@ -45,6 +45,7 @@ type LiteTextEditorWrapperProps = MakeOptional<
parentClassName?: string;
editorClassName?: string;
submitButtonText?: string;
toolbarMode?: "default" | "compact-comment";
} & (
| {
editable: false;
@ -81,6 +82,7 @@ export const LiteTextEditor = React.forwardRef(function LiteTextEditor(
editorClassName = "",
showPlaceholderOnEmpty = true,
submitButtonText = "common.comment",
toolbarMode = "default",
...rest
} = props;
// states
@ -217,6 +219,7 @@ export const LiteTextEditor = React.forwardRef(function LiteTextEditor(
editorRef={editorRef}
showSubmitButton={showSubmitButton}
submitButtonText={submitButtonText}
mode={toolbarMode}
/>
</div>
)}

View File

@ -5,6 +5,7 @@
*/
import React, { useEffect, useState, useCallback } from "react";
import { ArrowUp, Paperclip, SmilePlus } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { EIssueCommentAccessSpecifier } from "@plane/constants";
@ -20,7 +21,7 @@ import { Tooltip } from "@plane/propel/tooltip";
// constants
import { cn } from "@plane/utils";
import type { ToolbarMenuItem } from "@/constants/editor";
import { TOOLBAR_ITEMS } from "@/constants/editor";
import { IMAGE_ITEM, TOOLBAR_ITEMS } from "@/constants/editor";
// helpers
type Props = {
@ -34,6 +35,7 @@ type Props = {
showSubmitButton: boolean;
editorRef: EditorRefApi | null;
submitButtonText?: string;
mode?: "default" | "compact-comment";
};
type TCommentAccessType = {
@ -56,6 +58,13 @@ const COMMENT_ACCESS_SPECIFIERS: TCommentAccessType[] = [
];
const toolbarItems = TOOLBAR_ITEMS.lite;
const EMOJI_ITEM: ToolbarMenuItem<"emoji"> = {
itemKey: "emoji",
renderKey: "emoji",
name: "Emoji",
icon: SmilePlus,
editors: ["lite", "document"],
};
export function IssueCommentToolbar(props: Props) {
const { t } = useTranslation();
@ -70,6 +79,7 @@ export function IssueCommentToolbar(props: Props) {
showSubmitButton,
editorRef,
submitButtonText = "common.comment",
mode = "default",
} = props;
// State to manage active states of toolbar items
const [activeStates, setActiveStates] = useState<Record<string, boolean>>({});
@ -102,6 +112,43 @@ export function IssueCommentToolbar(props: Props) {
const isEditorReadyToDiscard = editorRef?.isEditorReadyToDiscard();
const isSubmitButtonDisabled = isCommentEmpty || !isEditorReadyToDiscard;
if (mode === "compact-comment") {
return (
<div className="flex items-center justify-between gap-3 border-t border-subtle/70 pt-3">
<div className="flex items-center gap-2">
<Tooltip tooltipContent={t("common.attach")}>
<button
type="button"
onClick={() => executeCommand(IMAGE_ITEM)}
className="grid size-10 place-items-center rounded-[16px] bg-layer-1/85 text-secondary transition-colors hover:bg-layer-1 focus-visible:outline-none"
>
<Paperclip className="h-4 w-4" strokeWidth={2} />
</button>
</Tooltip>
<Tooltip tooltipContent="Emoji">
<button
type="button"
onClick={() => executeCommand(EMOJI_ITEM)}
className="grid size-10 place-items-center rounded-[16px] bg-layer-1/85 text-secondary transition-colors hover:bg-layer-1 focus-visible:outline-none"
>
<SmilePlus className="h-4 w-4" strokeWidth={2} />
</button>
</Tooltip>
</div>
{showSubmitButton && (
<button
type="button"
onClick={handleSubmit}
disabled={isSubmitButtonDisabled || isSubmitting}
className="grid size-10 place-items-center rounded-[16px] bg-accent-primary text-on-color transition-colors hover:bg-accent-primary-hover focus-visible:outline-none disabled:bg-layer-disabled disabled:text-on-color-disabled"
>
<ArrowUp className="h-4 w-4" strokeWidth={2} />
</button>
)}
</div>
);
}
return (
<div className="flex h-9 w-full items-stretch gap-1.5 overflow-x-scroll bg-surface-2">
{showAccessSpecifier && (

View File

@ -10,6 +10,7 @@ import { useTranslation } from "@plane/i18n";
import { LinkIcon, ViewsIcon, RelationPropertyIcon } from "@plane/propel/icons";
// plane imports
import type { TIssueServiceType, TWorkItemWidgets } from "@plane/types";
import { cn } from "@plane/utils";
// plane web imports
import { WorkItemAdditionalWidgetActionButtons } from "@/plane-web/components/issues/issue-detail-widgets/action-buttons";
// local imports
@ -26,15 +27,16 @@ type Props = {
disabled: boolean;
issueServiceType: TIssueServiceType;
hideWidgets?: TWorkItemWidgets[];
compactView?: boolean;
};
export function IssueDetailWidgetActionButtons(props: Props) {
const { workspaceSlug, projectId, issueId, disabled, issueServiceType, hideWidgets } = props;
const { workspaceSlug, projectId, issueId, disabled, issueServiceType, hideWidgets, compactView = false } = props;
// translation
const { t } = useTranslation();
return (
<div className="flex flex-wrap items-center gap-2">
<div className={cn("flex flex-wrap items-center gap-2", compactView ? "justify-center" : "justify-start")}>
{!hideWidgets?.includes("sub-work-items") && (
<SubIssuesActionButton
issueId={issueId}
@ -43,6 +45,7 @@ export function IssueDetailWidgetActionButtons(props: Props) {
title={t("issue.add.sub_issue")}
icon={<ViewsIcon className="h-3.5 w-3.5 flex-shrink-0" strokeWidth={2} />}
disabled={disabled}
compactView={compactView}
/>
}
disabled={disabled}
@ -57,6 +60,7 @@ export function IssueDetailWidgetActionButtons(props: Props) {
title={t("issue.add.relation")}
icon={<RelationPropertyIcon className="h-3.5 w-3.5 flex-shrink-0" />}
disabled={disabled}
compactView={compactView}
/>
}
disabled={disabled}
@ -70,6 +74,7 @@ export function IssueDetailWidgetActionButtons(props: Props) {
title={t("issue.add.link")}
icon={<LinkIcon className="h-3.5 w-3.5 flex-shrink-0" strokeWidth={2} />}
disabled={disabled}
compactView={compactView}
/>
}
disabled={disabled}
@ -86,6 +91,7 @@ export function IssueDetailWidgetActionButtons(props: Props) {
title={t("common.attach")}
icon={<Paperclip className="h-3.5 w-3.5 flex-shrink-0" strokeWidth={2} />}
disabled={disabled}
compactView={compactView}
/>
}
disabled={disabled}

View File

@ -20,6 +20,7 @@ type Props = {
renderWidgetModals?: boolean;
issueServiceType: TIssueServiceType;
hideWidgets?: TWorkItemWidgets[];
compactView?: boolean;
};
export function IssueDetailWidgets(props: Props) {
@ -31,6 +32,7 @@ export function IssueDetailWidgets(props: Props) {
renderWidgetModals = true,
issueServiceType,
hideWidgets,
compactView = false,
} = props;
return (
@ -43,6 +45,7 @@ export function IssueDetailWidgets(props: Props) {
disabled={disabled}
issueServiceType={issueServiceType}
hideWidgets={hideWidgets}
compactView={compactView}
/>
<IssueDetailWidgetCollapsibles
workspaceSlug={workspaceSlug}

View File

@ -7,17 +7,29 @@
import React from "react";
// helpers
import { Button } from "@plane/propel/button";
import { cn } from "@plane/utils";
type Props = {
icon: React.ReactNode;
title: string;
disabled?: boolean;
compactView?: boolean;
};
export function IssueDetailWidgetButton(props: Props) {
const { icon, title, disabled = false } = props;
const { icon, title, disabled = false, compactView = false } = props;
return (
<Button variant={"secondary"} disabled={disabled} size="lg">
<Button
variant={"secondary"}
disabled={disabled}
size="xl"
className={cn(
"border-transparent shadow-none focus-visible:outline-none",
compactView
? "h-10 rounded-[18px] bg-layer-2/80 px-4 backdrop-blur-xl hover:bg-layer-2-active"
: "rounded-md"
)}
>
{icon && icon}
<span className="text-body-xs-medium">{title}</span>
</Button>

View File

@ -15,6 +15,7 @@ import { useLocalStorage } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
//types
import type { TFileSignedURLResponse, TIssueComment } from "@plane/types";
import { cn } from "@plane/utils";
// components
import { CommentCreate } from "@/components/comments/comment-create";
// hooks
@ -34,6 +35,7 @@ type TIssueActivity = {
issueId: string;
disabled?: boolean;
isIntakeIssue?: boolean;
compactComposer?: boolean;
};
export type TActivityOperations = {
@ -44,7 +46,7 @@ export type TActivityOperations = {
};
export const IssueActivity = observer(function IssueActivity(props: TIssueActivity) {
const { workspaceSlug, projectId, issueId, disabled = false, isIntakeIssue = false } = props;
const { workspaceSlug, projectId, issueId, disabled = false, isIntakeIssue = false, compactComposer = false } = props;
// i18n
const { t } = useTranslation();
// hooks
@ -92,15 +94,19 @@ export const IssueActivity = observer(function IssueActivity(props: TIssueActivi
const project = getProjectById(projectId);
const renderCommentCreationBox = useMemo(
() => (
<CommentCreate
workspaceSlug={workspaceSlug}
entityId={issueId}
activityOperations={activityOperations}
showToolbarInitially
projectId={projectId}
/>
<div className={cn(compactComposer && "space-y-3 rounded-[24px] border border-subtle/70 bg-surface-2/70 p-4 backdrop-blur-xl")}>
{compactComposer && <div className="text-body-sm-medium text-primary">{t("issue.comments.placeholder")}</div>}
<CommentCreate
workspaceSlug={workspaceSlug}
entityId={issueId}
activityOperations={activityOperations}
showToolbarInitially
projectId={projectId}
appearance={compactComposer ? "issue-peek-compact" : "default"}
/>
</div>
),
[workspaceSlug, issueId, activityOperations, projectId]
[workspaceSlug, issueId, activityOperations, projectId, compactComposer, t]
);
if (!project) return <></>;

View File

@ -16,6 +16,7 @@ import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { EIssueServiceType } from "@plane/types";
import { Loader } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useUserPermissions } from "@/hooks/store/user";
@ -25,10 +26,11 @@ export type TIssueSubscription = {
projectId: string;
issueId: string;
serviceType?: EIssueServiceType;
buttonClassName?: string;
};
export const IssueSubscription = observer(function IssueSubscription(props: TIssueSubscription) {
const { workspaceSlug, projectId, issueId, serviceType = EIssueServiceType.ISSUES } = props;
const { workspaceSlug, projectId, issueId, serviceType = EIssueServiceType.ISSUES, buttonClassName } = props;
const { t } = useTranslation();
// hooks
const {
@ -84,7 +86,7 @@ export const IssueSubscription = observer(function IssueSubscription(props: TIss
<Button
prependIcon={isSubscribed ? <BellOff /> : <Bell className="h-3 w-3" />}
variant="secondary"
className="hover:!bg-accent-primary/20"
className={cn("hover:!bg-accent-primary/20", buttonClassName)}
onClick={handleSubscription}
disabled={!isEditable || loading}
size="lg"

View File

@ -37,6 +37,7 @@ type TWorkItemDetailQuickActionProps = IQuickActionProps & {
toggleDuplicateIssueModal?: (value: boolean) => void;
toggleArchiveIssueModal?: (value: boolean) => void;
isPeekMode?: boolean;
buttonClassName?: string;
};
export const WorkItemDetailQuickActions = observer(function WorkItemDetailQuickActions(
@ -57,6 +58,7 @@ export const WorkItemDetailQuickActions = observer(function WorkItemDetailQuickA
toggleDuplicateIssueModal,
toggleArchiveIssueModal,
isPeekMode = false,
buttonClassName,
} = props;
// router
const { workspaceSlug } = useParams();
@ -241,7 +243,7 @@ export const WorkItemDetailQuickActions = observer(function WorkItemDetailQuickA
<CustomMenu
ellipsis
placement={placements}
customButton={<IconButton size="lg" variant="secondary" icon={Ellipsis} />}
customButton={<IconButton size="lg" variant="secondary" icon={Ellipsis} className={buttonClassName} />}
portalElement={portalElement}
menuItemsClassName="z-[14]"
maxHeight="lg"

View File

@ -154,8 +154,8 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
return (
<div
className={`relative flex items-center justify-between p-4 ${
currentMode?.key === "full-screen" ? "border-b border-subtle" : ""
className={`relative flex items-center justify-between px-6 py-5 ${
currentMode?.key === "full-screen" ? "border-b border-subtle/70" : ""
}`}
>
<div className="flex items-center gap-4">
@ -203,10 +203,21 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
<NameDescriptionUpdateStatus isSubmitting={isSubmitting} />
<div className="flex items-center gap-2">
{currentUser && !isArchived && (
<IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
<IssueSubscription
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
buttonClassName="!h-10 rounded-[18px] border-transparent bg-layer-2/80 px-4 shadow-none backdrop-blur-xl hover:!bg-layer-2-active focus-visible:outline-none"
/>
)}
<Tooltip tooltipContent={t("common.actions.copy_link")} isMobile={isMobile}>
<IconButton variant="secondary" size="lg" onClick={handleCopyText} icon={CopyLinkIcon} />
<IconButton
variant="secondary"
size="lg"
onClick={handleCopyText}
icon={CopyLinkIcon}
className="size-10 rounded-[18px] border-transparent bg-layer-2/80 shadow-none backdrop-blur-xl hover:bg-layer-2-active focus-visible:outline-none"
/>
</Tooltip>
{issueDetails && (
<WorkItemDetailQuickActions
@ -221,6 +232,7 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
toggleDuplicateIssueModal={toggleDuplicateIssueModal}
toggleEditIssueModal={toggleEditIssueModal}
isPeekMode
buttonClassName="size-10 rounded-[18px] border-transparent bg-layer-2/80 shadow-none backdrop-blur-xl hover:bg-layer-2-active focus-visible:outline-none"
/>
)}
</div>

View File

@ -120,12 +120,13 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
const peekOverviewIssueClassName = cn(
!embedIssue
? "absolute z-[25] flex flex-col overflow-hidden rounded-sm border border-subtle bg-surface-1 transition-all duration-300"
? "absolute z-[25] flex flex-col overflow-hidden border border-subtle/70 bg-surface-1/80 backdrop-blur-2xl transition-all duration-300"
: `h-full w-full`,
!embedIssue && {
"top-0 right-0 bottom-0 w-full border-0 border-l md:w-[50%]": peekMode === "side-peek",
"top-[8.33%] left-[8.33%] size-5/6": peekMode === "modal",
"absolute inset-0 m-4": peekMode === "full-screen",
"top-3 right-3 bottom-3 w-[calc(100%-1.5rem)] resize-x rounded-[28px] border md:w-[50%] md:min-w-[640px] md:max-w-[calc(100vw-1.5rem)]":
peekMode === "side-peek",
"top-[8.33%] left-[8.33%] size-5/6 rounded-[28px]": peekMode === "modal",
"absolute inset-0 m-4 rounded-[28px]": peekMode === "full-screen",
}
);
@ -174,7 +175,7 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
{/* content */}
<div className="vertical-scrollbar relative scrollbar-md h-full w-full overflow-hidden overflow-y-auto">
{["side-peek", "modal"].includes(peekMode) ? (
<div className="relative flex flex-col gap-3 space-y-3 px-8 py-5">
<div className="relative flex flex-col gap-4 space-y-3 px-8 py-6">
<PeekOverviewIssueDetails
editorRef={editorRef}
workspaceSlug={workspaceSlug}
@ -193,6 +194,7 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
projectId={projectId}
issueId={issueId}
disabled={disabled || is_archived}
compactView
issueServiceType={EIssueServiceType.ISSUES}
/>
</div>
@ -210,6 +212,7 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
projectId={projectId}
issueId={issueId}
disabled={is_archived}
compactComposer
/>
</div>
) : (