This commit is contained in:
DCCONSTRUCTIONS
2026-04-18 18:39:25 +03:00
commit 3ba092b60c
4944 changed files with 497564 additions and 0 deletions
@@ -0,0 +1,114 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React, { useRef, useState } from "react";
import { observer } from "mobx-react";
import { useForm, Controller } from "react-hook-form";
// plane imports
import type { EditorRefApi } from "@plane/editor";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { SitesFileService } from "@plane/services";
import type { TIssuePublicComment } from "@plane/types";
// editor components
import { LiteTextEditor } from "@/components/editor/lite-text-editor";
// hooks
import { usePublish } from "@/hooks/store/publish";
import { useIssueDetails } from "@/hooks/store/use-issue-details";
import { useUser } from "@/hooks/store/use-user";
// services
const fileService = new SitesFileService();
const defaultValues: Partial<TIssuePublicComment> = {
comment_html: "",
};
type Props = {
anchor: string;
disabled?: boolean;
};
export const AddComment = observer(function AddComment(props: Props) {
const { anchor } = props;
// states
const [uploadedAssetIds, setUploadAssetIds] = useState<string[]>([]);
// refs
const editorRef = useRef<EditorRefApi>(null);
// store hooks
const { peekId: issueId, addIssueComment, uploadCommentAsset } = useIssueDetails();
const { data: currentUser } = useUser();
const { workspace: workspaceID } = usePublish(anchor);
// form info
const {
handleSubmit,
control,
watch,
formState: { isSubmitting },
reset,
} = useForm<TIssuePublicComment>({ defaultValues });
const onSubmit = async (formData: TIssuePublicComment) => {
if (!anchor || !issueId || isSubmitting || !formData.comment_html) return;
await addIssueComment(anchor, issueId, formData)
.then(async (res) => {
reset(defaultValues);
editorRef.current?.clearEditor();
if (uploadedAssetIds.length > 0) {
await fileService.updateBulkAssetsUploadStatus(anchor, res.id, {
asset_ids: uploadedAssetIds,
});
setUploadAssetIds([]);
}
})
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Comment could not be posted. Please try again.",
})
);
};
// TODO: on click if he user is not logged in redirect to login page
return (
<div>
<div className="issue-comments-section">
<Controller
name="comment_html"
control={control}
render={({ field: { value, onChange } }) => (
<LiteTextEditor
editable
onEnterKeyPress={(e) => {
if (currentUser) handleSubmit(onSubmit)(e);
}}
anchor={anchor}
workspaceId={workspaceID?.toString() ?? ""}
ref={editorRef}
id="peek-overview-add-comment"
initialValue={
!value || value === "" || (typeof value === "object" && Object.keys(value).length === 0)
? watch("comment_html")
: value
}
onChange={(comment_json, comment_html) => onChange(comment_html)}
isSubmitting={isSubmitting}
placeholder="Add comment..."
uploadFile={async (blockId, file) => {
const { asset_id } = await uploadCommentAsset(file, anchor);
setUploadAssetIds((prev) => [...prev, asset_id]);
return asset_id;
}}
displayConfig={{
fontSize: "small-font",
}}
/>
)}
/>
</div>
</div>
);
});
@@ -0,0 +1,228 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React, { useRef, useState } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
import { MessageSquare, MoreVertical } from "lucide-react";
import { Menu, Transition } from "@headlessui/react";
// plane imports
import type { EditorRefApi } from "@plane/editor";
import { CheckIcon, CloseIcon } from "@plane/propel/icons";
import type { TIssuePublicComment } from "@plane/types";
import { getFileURL } from "@plane/utils";
// components
import { LiteTextEditor } from "@/components/editor/lite-text-editor";
import { CommentReactions } from "@/components/issues/peek-overview/comment/comment-reactions";
// helpers
import { timeAgo } from "@/helpers/date-time.helper";
// hooks
import { usePublish } from "@/hooks/store/publish";
import { useIssueDetails } from "@/hooks/store/use-issue-details";
import { useUser } from "@/hooks/store/use-user";
import useIsInIframe from "@/hooks/use-is-in-iframe";
type Props = {
anchor: string;
comment: TIssuePublicComment;
};
export const CommentCard = observer(function CommentCard(props: Props) {
const { anchor, comment } = props;
// store hooks
const { peekId, deleteIssueComment, updateIssueComment, uploadCommentAsset } = useIssueDetails();
const { data: currentUser } = useUser();
const { workspace: workspaceID } = usePublish(anchor);
const isInIframe = useIsInIframe();
// states
const [isEditing, setIsEditing] = useState(false);
// refs
const editorRef = useRef<EditorRefApi>(null);
const showEditorRef = useRef<EditorRefApi>(null);
// form info
const {
control,
formState: { isSubmitting },
handleSubmit,
} = useForm<TIssuePublicComment>({
defaultValues: { comment_html: comment.comment_html },
});
const handleDelete = () => {
if (!anchor || !peekId) return;
deleteIssueComment(anchor, peekId, comment.id);
};
const handleCommentUpdate = async (formData: TIssuePublicComment) => {
if (!anchor || !peekId) return;
updateIssueComment(anchor, peekId, comment.id, formData);
setIsEditing(false);
editorRef.current?.setEditorValue(formData.comment_html);
showEditorRef.current?.setEditorValue(formData.comment_html);
};
return (
<div className="relative flex items-start space-x-3">
<div className="relative px-1">
{comment.actor_detail.avatar_url && comment.actor_detail.avatar_url !== "" ? (
<img
src={getFileURL(comment.actor_detail.avatar_url)}
alt={
comment.actor_detail.is_bot ? comment.actor_detail.first_name + " Bot" : comment.actor_detail.display_name
}
height={30}
width={30}
className="grid size-7 place-items-center rounded-full border-2 border-strong-1"
/>
) : (
<div
className={`bg-gray-500 grid size-7 place-items-center rounded-full border-2 border-strong-1 text-on-color`}
>
{comment.actor_detail.is_bot
? comment?.actor_detail?.first_name?.charAt(0)
: comment?.actor_detail?.display_name?.charAt(0)}
</div>
)}
<span className="absolute -right-1 -bottom-0.5 rounded-tl-sm bg-layer-1 px-0.5 py-px">
<MessageSquare className="size-3 text-secondary" aria-hidden="true" strokeWidth={2} />
</span>
</div>
<div className="min-w-0 flex-1">
<div>
<div className="text-11">
{comment.actor_detail.is_bot ? comment.actor_detail.first_name + " Bot" : comment.actor_detail.display_name}
</div>
<p className="mt-0.5 text-11 text-secondary">
<>commented {timeAgo(comment.created_at)}</>
</p>
</div>
<div className="issue-comments-section p-0">
<form
onSubmit={handleSubmit(handleCommentUpdate)}
className={`flex-col gap-2 ${isEditing ? "flex" : "hidden"}`}
>
<div>
<Controller
control={control}
name="comment_html"
render={({ field: { onChange, value } }) => (
<LiteTextEditor
editable
anchor={anchor}
workspaceId={workspaceID?.toString() ?? ""}
onEnterKeyPress={handleSubmit(handleCommentUpdate)}
ref={editorRef}
id={comment.id}
initialValue={value}
value={null}
onChange={(comment_json, comment_html) => onChange(comment_html)}
isSubmitting={isSubmitting}
showSubmitButton={false}
uploadFile={async (blockId, file) => {
const { asset_id } = await uploadCommentAsset(file, anchor, comment.id);
return asset_id;
}}
displayConfig={{
fontSize: "small-font",
}}
/>
)}
/>
</div>
<div className="flex gap-1 self-end">
<button
type="submit"
disabled={isSubmitting}
className="group shadow-md rounded-sm border border-success-strong bg-success-primary p-2 duration-300 hover:bg-success-primary"
>
<CheckIcon className="h-3 w-3 text-on-color" strokeWidth={2} />
</button>
<button
type="button"
className="group shadow-md rounded-sm border border-danger-strong bg-danger-primary p-2 duration-300 hover:bg-danger-primary-hover"
onClick={() => setIsEditing(false)}
>
<CloseIcon className="h-3 w-3 text-on-color" strokeWidth={2} />
</button>
</div>
</form>
<div className={`${isEditing ? "hidden" : ""}`}>
<LiteTextEditor
editable={false}
anchor={anchor}
workspaceId={workspaceID?.toString() ?? ""}
ref={showEditorRef}
id={comment.id}
initialValue={comment.comment_html}
displayConfig={{
fontSize: "small-font",
}}
/>
<CommentReactions anchor={anchor} commentId={comment.id} />
</div>
</div>
</div>
{!isInIframe && currentUser?.id === comment?.actor_detail?.id && (
<Menu as="div" className="relative w-min text-left">
<Menu.Button
type="button"
onClick={() => {}}
className="relative grid cursor-pointer place-items-center rounded-sm p-1 text-tertiary outline-none hover:bg-layer-transparent-hover"
>
<MoreVertical className="size-4" strokeWidth={2} />
</Menu.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="shadow-lg absolute right-0 z-10 mt-1 max-h-36 min-w-[8rem] origin-top-right overflow-auto overflow-y-scroll rounded-md border border-strong bg-surface-1 p-1 text-11 whitespace-nowrap focus:outline-none">
<Menu.Item>
{({ active }) => (
<div className="py-1">
<button
type="button"
onClick={() => {
setIsEditing(true);
}}
className={`w-full truncate rounded-sm px-1 py-1.5 text-left text-secondary select-none hover:bg-layer-transparent-hover ${
active ? "bg-layer-transparent-hover" : ""
}`}
>
Edit
</button>
</div>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<div className="py-1">
<button
type="button"
onClick={handleDelete}
className={`w-full truncate rounded-sm px-1 py-1.5 text-left text-secondary select-none hover:bg-layer-transparent-hover ${
active ? "bg-layer-transparent-hover" : ""
}`}
>
Delete
</button>
</div>
)}
</Menu.Item>
</Menu.Items>
</Transition>
</Menu>
)}
</div>
);
});
@@ -0,0 +1,144 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React, { useMemo, useState } from "react";
import { observer } from "mobx-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
// plane imports
import { stringToEmoji } from "@plane/propel/emoji-icon-picker";
import { EmojiReactionGroup, EmojiReactionPicker } from "@plane/propel/emoji-reaction";
import type { EmojiReactionType } from "@plane/propel/emoji-reaction";
// helpers
import { groupReactions } from "@/helpers/emoji.helper";
import { queryParamGenerator } from "@/helpers/query-param-generator";
// hooks
import { useIssueDetails } from "@/hooks/store/use-issue-details";
import { useUser } from "@/hooks/store/use-user";
import useIsInIframe from "@/hooks/use-is-in-iframe";
type Props = {
anchor: string;
commentId: string;
};
export const CommentReactions = observer(function CommentReactions(props: Props) {
const { anchor, commentId } = props;
// state
const [isPickerOpen, setIsPickerOpen] = useState(false);
const router = useRouter();
const pathName = usePathname();
const searchParams = useSearchParams();
// query params
const board = searchParams.get("board") || undefined;
const state = searchParams.get("state") || undefined;
const priority = searchParams.get("priority") || undefined;
const labels = searchParams.get("labels") || undefined;
// hooks
const { addCommentReaction, removeCommentReaction, details, peekId } = useIssueDetails();
const { data: user } = useUser();
const isInIframe = useIsInIframe();
const commentReactions = useMemo(() => {
if (!peekId) return [];
const peekDetails = details[peekId];
if (!peekDetails) return [];
const comment = peekDetails.comments?.find((c) => c.id === commentId);
return comment?.comment_reactions ?? [];
}, [peekId, details, commentId]);
const groupedReactions = useMemo(() => {
if (!peekId) return {};
return groupReactions(commentReactions ?? [], "reaction");
}, [peekId, commentReactions]);
const userReactions = commentReactions?.filter((r) => r?.actor_detail?.id === user?.id);
const handleAddReaction = (reactionHex: string) => {
if (!anchor || !peekId) return;
addCommentReaction(anchor, peekId, commentId, reactionHex);
};
const handleRemoveReaction = (reactionHex: string) => {
if (!anchor || !peekId) return;
removeCommentReaction(anchor, peekId, commentId, reactionHex);
};
const handleReactionClick = (reactionHex: string) => {
const userReaction = userReactions?.find((r) => r.actor_detail.id === user?.id && r.reaction === reactionHex);
if (userReaction) handleRemoveReaction(reactionHex);
else handleAddReaction(reactionHex);
};
// derived values
const { queryParam } = queryParamGenerator({ peekId, board, state, priority, labels });
// Transform reactions data to Propel EmojiReactionType format
const propelReactions: EmojiReactionType[] = useMemo(() => {
const REACTIONS_LIMIT = 1000;
return Object.keys(groupedReactions || {})
.filter((reaction) => groupedReactions?.[reaction]?.length > 0)
.map((reaction) => {
const reactionList = groupedReactions?.[reaction] ?? [];
const userNames = reactionList
.map((r) => r?.actor_detail?.display_name)
.filter((name): name is string => !!name)
.slice(0, REACTIONS_LIMIT);
return {
emoji: stringToEmoji(reaction),
count: reactionList.length,
reacted: commentReactions?.some((r) => r?.actor_detail?.id === user?.id && r.reaction === reaction) || false,
users: userNames,
};
});
}, [groupedReactions, commentReactions, user?.id]);
const handleEmojiClick = (emoji: string) => {
if (isInIframe) return;
if (!user) {
router.push(`/?next_path=${pathName}?${queryParam}`);
return;
}
// Convert emoji back to decimal string format for the API
const emojiCodePoints = Array.from(emoji)
.map((char) => char.codePointAt(0))
.filter((cp): cp is number => cp !== undefined);
const reactionString = emojiCodePoints.join("-");
handleReactionClick(reactionString);
};
const handleEmojiSelect = (emoji: string) => {
if (!user) {
router.push(`/?next_path=${pathName}?${queryParam}`);
return;
}
// emoji is already in decimal string format from EmojiReactionPicker
handleReactionClick(emoji);
};
return (
<div className="mt-2">
<EmojiReactionPicker
isOpen={isPickerOpen}
handleToggle={setIsPickerOpen}
onChange={handleEmojiSelect}
disabled={isInIframe}
label={
<EmojiReactionGroup
reactions={propelReactions}
onReactionClick={handleEmojiClick}
showAddButton={!isInIframe}
onAddReaction={() => setIsPickerOpen(true)}
/>
}
placement="bottom-start"
/>
</div>
);
});
@@ -0,0 +1,74 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// plane imports
import { Loader } from "@plane/ui";
// types
import type { IIssue } from "@/types/issue";
// local imports
import { PeekOverviewHeader } from "./header";
import { PeekOverviewIssueActivity } from "./issue-activity";
import { PeekOverviewIssueDetails } from "./issue-details";
import { PeekOverviewIssueProperties } from "./issue-properties";
type Props = {
anchor: string;
handleClose: () => void;
issueDetails: IIssue | undefined;
};
export const FullScreenPeekView = observer(function FullScreenPeekView(props: Props) {
const { anchor, handleClose, issueDetails } = props;
return (
<div className="grid h-full w-full grid-cols-10 divide-x divide-subtle-1 overflow-hidden">
<div className="col-span-7 flex h-full w-full flex-col overflow-hidden">
<div className="w-full p-5">
<PeekOverviewHeader handleClose={handleClose} issueDetails={issueDetails} />
</div>
{issueDetails ? (
<div className="h-full w-full overflow-y-auto px-6">
{/* issue title and description */}
<div className="w-full">
<PeekOverviewIssueDetails anchor={anchor} issueDetails={issueDetails} />
</div>
{/* divider */}
<div className="my-5 h-[1] w-full border-t border-subtle" />
{/* issue activity/comments */}
<div className="w-full pb-5">
<PeekOverviewIssueActivity anchor={anchor} issueDetails={issueDetails} />
</div>
</div>
) : (
<Loader className="px-6">
<Loader.Item height="30px" />
<div className="mt-3 space-y-2">
<Loader.Item height="20px" width="70%" />
<Loader.Item height="20px" width="60%" />
<Loader.Item height="20px" width="60%" />
</div>
</Loader>
)}
</div>
<div className="col-span-3 h-full w-full overflow-y-auto">
{/* issue properties */}
<div className="w-full px-6 py-5">
{issueDetails ? (
<PeekOverviewIssueProperties issueDetails={issueDetails} />
) : (
<Loader className="mt-11 space-y-4">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</Loader>
)}
</div>
</div>
</div>
);
});
@@ -0,0 +1,131 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import { observer } from "mobx-react";
import { MoveRight } from "lucide-react";
import { Listbox, Transition } from "@headlessui/react";
// ui
import { LinkIcon, CenterPanelIcon, FullScreenPanelIcon, SidePanelIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
// helpers
import { copyTextToClipboard } from "@/helpers/string.helper";
// hooks
import { useIssueDetails } from "@/hooks/store/use-issue-details";
import useClipboardWritePermission from "@/hooks/use-clipboard-write-permission";
// types
import type { IIssue, IPeekMode } from "@/types/issue";
type Props = {
handleClose: () => void;
issueDetails: IIssue | undefined;
};
const PEEK_MODES: {
key: IPeekMode;
icon: any;
label: string;
}[] = [
{ key: "side", icon: SidePanelIcon, label: "Side Peek" },
{
key: "modal",
icon: CenterPanelIcon,
label: "Modal",
},
{
key: "full",
icon: FullScreenPanelIcon,
label: "Full Screen",
},
];
export const PeekOverviewHeader = observer(function PeekOverviewHeader(props: Props) {
const { handleClose } = props;
const { peekMode, setPeekMode } = useIssueDetails();
const isClipboardWriteAllowed = useClipboardWritePermission();
const handleCopyLink = () => {
const urlToCopy = window.location.href;
copyTextToClipboard(urlToCopy).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link copied!",
message: "Work item link copied to clipboard.",
});
});
};
const Icon = PEEK_MODES.find((m) => m.key === peekMode)?.icon ?? SidePanelIcon;
return (
<>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{peekMode === "side" && (
<button type="button" onClick={handleClose} className="text-tertiary hover:text-secondary">
<MoveRight className="size-4" />
</button>
)}
<Listbox
as="div"
value={peekMode}
onChange={(val) => setPeekMode(val)}
className="relative shrink-0 text-left"
>
<Listbox.Button
className={`grid place-items-center text-tertiary hover:text-secondary ${peekMode === "full" ? "rotate-45" : ""}`}
>
<Icon className="h-4 w-4 text-tertiary hover:text-secondary" />
</Listbox.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Listbox.Options className="shadow-lg absolute left-0 z-10 mt-1 min-w-[12rem] origin-top-left overflow-y-auto rounded-md border border-strong bg-surface-2 text-11 whitespace-nowrap focus:outline-none">
<div className="space-y-1 p-2">
{PEEK_MODES.map((mode) => (
<Listbox.Option
key={mode.key}
value={mode.key}
className={({ active, selected }) =>
`cursor-pointer truncate rounded-sm px-1 py-1.5 select-none ${
active ? "bg-layer-transparent-hover" : ""
} ${selected ? "text-primary" : "text-secondary"}`
}
>
<div className="flex items-center gap-1.5">
<mode.icon className="-my-1 h-4 w-4 flex-shrink-0" />
{mode.label}
</div>
</Listbox.Option>
))}
</div>
</Listbox.Options>
</Transition>
</Listbox>
</div>
{isClipboardWriteAllowed && (peekMode === "side" || peekMode === "modal") && (
<button
type="button"
onClick={handleCopyLink}
className="shrink-0 text-tertiary hover:text-secondary focus:outline-none"
tabIndex={1}
>
<LinkIcon className="h-4 w-4 -rotate-45" />
</button>
)}
</div>
</>
);
});
@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./layout";
@@ -0,0 +1,73 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { Link } from "react-router";
import { usePathname } from "next/navigation";
import { Lock } from "lucide-react";
// plane imports
import { Button } from "@plane/propel/button";
// components
import { AddComment } from "@/components/issues/peek-overview/comment/add-comment";
import { CommentCard } from "@/components/issues/peek-overview/comment/comment-detail-card";
// hooks
import { usePublish } from "@/hooks/store/publish";
import { useIssueDetails } from "@/hooks/store/use-issue-details";
import { useUser } from "@/hooks/store/use-user";
import useIsInIframe from "@/hooks/use-is-in-iframe";
// types
import type { IIssue } from "@/types/issue";
type Props = {
anchor: string;
issueDetails: IIssue;
};
export const PeekOverviewIssueActivity = observer(function PeekOverviewIssueActivity(props: Props) {
const { anchor } = props;
// router
const pathname = usePathname();
// store hooks
const { details, peekId } = useIssueDetails();
const { data: currentUser } = useUser();
const { canComment } = usePublish(anchor);
// derived values
const comments = details[peekId || ""]?.comments || [];
const isInIframe = useIsInIframe();
return (
<div className="pb-10">
<h4 className="font-medium">Comments</h4>
<div className="mt-4">
<div className="space-y-4">
{comments.map((comment) => (
<CommentCard key={comment.id} anchor={anchor} comment={comment} />
))}
</div>
{!isInIframe &&
(currentUser ? (
<>
{canComment && (
<div className="mt-4">
<AddComment anchor={anchor} disabled={!currentUser} />
</div>
)}
</>
) : (
<div className="mt-4 flex items-center justify-between gap-2 rounded-sm border border-strong bg-layer-2 px-2 py-2.5">
<p className="flex items-center gap-2 overflow-hidden text-13 break-words text-secondary">
<Lock className="size-3 shrink-0" />
Sign in to add your comment
</p>
<Link to={`/?next_path=${pathname}`}>
<Button variant="primary">Sign in</Button>
</Link>
</div>
))}
</div>
</div>
);
});
@@ -0,0 +1,46 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// plane imports
import { RichTextEditor } from "@/components/editor/rich-text-editor";
import { usePublish } from "@/hooks/store/publish";
// types
import type { IIssue } from "@/types/issue";
// local imports
import { IssueReactions } from "./issue-reaction";
type Props = {
anchor: string;
issueDetails: IIssue;
};
export const PeekOverviewIssueDetails = observer(function PeekOverviewIssueDetails(props: Props) {
const { anchor, issueDetails } = props;
// store hooks
const { project_details, workspace: workspaceID } = usePublish(anchor);
// derived values
const description = issueDetails.description_html;
return (
<div className="space-y-2">
<h6 className="text-14 font-medium text-placeholder">
{project_details?.identifier}-{issueDetails?.sequence_id}
</h6>
<h4 className="text-20 font-medium break-words">{issueDetails.name}</h4>
{description && description !== "" && description !== "<p></p>" && (
<RichTextEditor
editable={false}
anchor={anchor}
id={issueDetails.id}
initialValue={description}
workspaceId={workspaceID?.toString() ?? ""}
/>
)}
<IssueReactions anchor={anchor} />
</div>
);
});
@@ -0,0 +1,137 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { LinkIcon } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import {
StatePropertyIcon,
StateGroupIcon,
PriorityPropertyIcon,
DueDatePropertyIcon,
PriorityIcon,
} from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { cn, getIssuePriorityFilters } from "@plane/utils";
// helpers
import { renderFormattedDate } from "@/helpers/date-time.helper";
import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
import { copyTextToClipboard, addSpaceIfCamelCase } from "@/helpers/string.helper";
// hooks
import { usePublish } from "@/hooks/store/publish";
import { useStates } from "@/hooks/store/use-state";
// types
import type { IIssue, IPeekMode } from "@/types/issue";
type Props = {
issueDetails: IIssue;
mode?: IPeekMode;
};
export const PeekOverviewIssueProperties = observer(function PeekOverviewIssueProperties({
issueDetails,
mode,
}: Props) {
// hooks
const { t } = useTranslation();
const { getStateById } = useStates();
const state = getStateById(issueDetails?.state_id ?? undefined);
const { anchor } = useParams();
const { project_details } = usePublish(anchor?.toString());
const priority = issueDetails.priority ? getIssuePriorityFilters(issueDetails.priority) : null;
const handleCopyLink = () => {
const urlToCopy = window.location.href;
copyTextToClipboard(urlToCopy).then(() => {
setToast({
type: TOAST_TYPE.INFO,
title: "Link copied!",
message: "Work item link copied to clipboard",
});
});
};
return (
<div className={mode === "full" ? "divide-y divide-subtle-1" : ""}>
{mode === "full" && (
<div className="flex justify-between gap-2 pb-3">
<h6 className="flex items-center gap-2 font-medium">
{project_details?.identifier}-{issueDetails.sequence_id}
</h6>
<div className="flex items-center gap-2">
<button type="button" onClick={handleCopyLink} className="-rotate-45">
<LinkIcon className="size-3.5 shrink-0" />
</button>
</div>
</div>
)}
<div className={`space-y-2 ${mode === "full" ? "pt-3" : ""}`}>
<div className="flex h-8 items-center gap-3">
<div className="flex w-1/4 flex-shrink-0 items-center gap-1 text-13 text-tertiary">
<StatePropertyIcon className="size-4 flex-shrink-0" />
<span>State</span>
</div>
<div className="flex w-3/4 items-center gap-1.5 py-0.5 text-13">
<StateGroupIcon stateGroup={state?.group ?? "backlog"} color={state?.color} />
{addSpaceIfCamelCase(state?.name ?? "")}
</div>
</div>
<div className="flex h-8 items-center gap-3">
<div className="flex w-1/4 flex-shrink-0 items-center gap-1 text-13 text-tertiary">
<PriorityPropertyIcon className="size-4 flex-shrink-0" />
<span>Priority</span>
</div>
<div className="w-3/4">
<div
className={`inline-flex items-center gap-1.5 rounded-sm bg-layer-2 px-2.5 py-0.5 text-left text-13 capitalize ${
priority?.key === "urgent"
? "border-priority-urgent text-priority-urgent"
: priority?.key === "high"
? "border-priority-high text-priority-high"
: priority?.key === "medium"
? "border-priority-medium text-priority-medium"
: priority?.key === "low"
? "border-priority-low text-priority-low"
: "border-priority-none text-priority-none"
}`}
>
{priority && <PriorityIcon priority={priority?.key} size={12} className="flex-shrink-0" />}
<span>{t(priority?.titleTranslationKey || "common.none")}</span>
</div>
</div>
</div>
<div className="flex h-8 items-center gap-3">
<div className="flex w-1/4 flex-shrink-0 items-center gap-1 text-13 text-tertiary">
<DueDatePropertyIcon className="size-4 flex-shrink-0" />
<span>Due date</span>
</div>
<div>
{issueDetails.target_date ? (
<div
className={cn("flex items-center gap-1.5 rounded-sm py-0.5 text-11 text-primary", {
"text-danger-primary": shouldHighlightIssueDueDate(issueDetails.target_date, state?.group),
})}
>
<DueDatePropertyIcon className="size-3" />
{renderFormattedDate(issueDetails.target_date)}
</div>
) : (
<span className="text-13 text-secondary">Empty</span>
)}
</div>
</div>
</div>
</div>
);
});
@@ -0,0 +1,39 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// components
import { IssueEmojiReactions } from "@/components/issues/reactions/issue-emoji-reactions";
import { IssueVotes } from "@/components/issues/reactions/issue-vote-reactions";
// hooks
import { usePublish } from "@/hooks/store/publish";
import useIsInIframe from "@/hooks/use-is-in-iframe";
type Props = {
anchor: string;
};
export const IssueReactions = observer(function IssueReactions(props: Props) {
const { anchor } = props;
// store hooks
const { canVote, canReact } = usePublish(anchor);
const isInIframe = useIsInIframe();
return (
<div className="mt-4 flex items-center gap-3">
{canVote && (
<div className="flex items-center gap-2">
<IssueVotes anchor={anchor} />
</div>
)}
{!isInIframe && canReact && (
<div className="flex items-center gap-2">
<IssueEmojiReactions anchor={anchor} />
</div>
)}
</div>
);
});
@@ -0,0 +1,124 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Fragment, useEffect } from "react";
import { observer } from "mobx-react";
import { useRouter, useSearchParams } from "next/navigation";
import { Dialog, Transition } from "@headlessui/react";
// hooks
import { useIssueDetails } from "@/hooks/store/use-issue-details";
// local imports
import { FullScreenPeekView } from "./full-screen-peek-view";
import { SidePeekView } from "./side-peek-view";
type TIssuePeekOverview = {
anchor: string;
peekId: string;
handlePeekClose?: () => void;
};
export const IssuePeekOverview = observer(function IssuePeekOverview(props: TIssuePeekOverview) {
const { anchor, peekId, handlePeekClose } = props;
const router = useRouter();
const searchParams = useSearchParams();
// query params
const board = searchParams.get("board") || undefined;
const state = searchParams.get("state") || undefined;
const priority = searchParams.get("priority") || undefined;
const labels = searchParams.get("labels") || undefined;
// store
const { peekMode, setPeekId, getIssueById, fetchIssueDetails } = useIssueDetails();
// derived values
const issueDetails = peekId ? getIssueById(peekId.toString()) : undefined;
// state
const isSidePeekOpen = !!peekId && peekMode === "side";
const isModalPeekOpen = !!peekId && (peekMode === "modal" || peekMode === "full");
useEffect(() => {
if (anchor && peekId) {
fetchIssueDetails(anchor, peekId.toString());
}
}, [anchor, fetchIssueDetails, peekId]);
const handleClose = () => {
// if close logic is passed down, call that instead of the below logic
if (handlePeekClose) {
handlePeekClose();
return;
}
setPeekId(null);
let queryParams: any = {
board,
};
if (priority && priority.length > 0) queryParams = { ...queryParams, priority: priority };
if (state && state.length > 0) queryParams = { ...queryParams, state: state };
if (labels && labels.length > 0) queryParams = { ...queryParams, labels: labels };
queryParams = new URLSearchParams(queryParams).toString();
router.push(`/issues/${anchor}?${queryParams}`);
};
return (
<>
<Transition.Root appear show={isSidePeekOpen} as={Fragment}>
<Dialog as="div" onClose={handleClose}>
<Transition.Child
as={Fragment}
enter="transition-transform duration-300"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transition-transform duration-200"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<Dialog.Panel className="fixed top-0 right-0 z-20 h-full w-1/2 border-l border-subtle-1 bg-surface-1 shadow-raised-200">
<SidePeekView anchor={anchor} handleClose={handleClose} issueDetails={issueDetails} />
</Dialog.Panel>
</Transition.Child>
</Dialog>
</Transition.Root>
<Transition.Root appear show={isModalPeekOpen} as={Fragment}>
<Dialog as="div" onClose={handleClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-20 bg-backdrop transition-opacity" />
</Transition.Child>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Dialog.Panel>
<div
className={`fixed top-1/2 left-1/2 z-20 -translate-x-1/2 -translate-y-1/2 rounded-lg bg-surface-1 transition-all duration-300 ${
peekMode === "modal" ? "h-[70%] w-3/5" : "size-[95%]"
}`}
>
{peekMode === "modal" && (
<SidePeekView anchor={anchor} handleClose={handleClose} issueDetails={issueDetails} />
)}
{peekMode === "full" && (
<FullScreenPeekView anchor={anchor} handleClose={handleClose} issueDetails={issueDetails} />
)}
</div>
</Dialog.Panel>
</Transition.Child>
</Dialog>
</Transition.Root>
</>
);
});
@@ -0,0 +1,67 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// plane imports
import { Loader } from "@plane/ui";
// store hooks
import { usePublish } from "@/hooks/store/publish";
// types
import type { IIssue } from "@/types/issue";
// local imports
import { PeekOverviewHeader } from "./header";
import { PeekOverviewIssueActivity } from "./issue-activity";
import { PeekOverviewIssueDetails } from "./issue-details";
import { PeekOverviewIssueProperties } from "./issue-properties";
type Props = {
anchor: string;
handleClose: () => void;
issueDetails: IIssue | undefined;
};
export const SidePeekView = observer(function SidePeekView(props: Props) {
const { anchor, handleClose, issueDetails } = props;
// store hooks
const { canComment } = usePublish(anchor);
return (
<div className="flex size-full flex-col overflow-hidden">
<div className="w-full p-5">
<PeekOverviewHeader handleClose={handleClose} issueDetails={issueDetails} />
</div>
{issueDetails ? (
<div className="size-full overflow-y-auto px-6">
{/* issue title and description */}
<div className="w-full">
<PeekOverviewIssueDetails anchor={anchor} issueDetails={issueDetails} />
</div>
{/* issue properties */}
<div className="mt-6 w-full">
<PeekOverviewIssueProperties issueDetails={issueDetails} />
</div>
{/* divider */}
<div className="my-5 h-[1] w-full border-t border-subtle" />
{/* issue activity/comments */}
{canComment && (
<div className="w-full pb-5">
<PeekOverviewIssueActivity anchor={anchor} issueDetails={issueDetails} />
</div>
)}
</div>
) : (
<Loader className="px-6">
<Loader.Item height="30px" />
<div className="mt-3 space-y-2">
<Loader.Item height="20px" width="70%" />
<Loader.Item height="20px" width="60%" />
<Loader.Item height="20px" width="60%" />
</div>
</Loader>
)}
</div>
);
});