style: project setting ui revamp (#2177)

* style: project settings navigation sidebar added

* chore: emoji and image picker close on outside click added

* style: project setting general page revamp

* style: project setting member page revamp

* style: project setting features page revamp

* style: project setting state page revamp

* style: project setting integrations page revamp

* style: project setting estimates page revamp

* style: project setting automation page revamp

* style: project setting label page revamp

* chore: member select improvement for member setting page

* chore: toggle switch component improvement

* style: project automation setting ui improvement

* style: module icon added

* style: toggle switch improvement

* style: ui and spacing consistency

* style: project label setting revamp

* style: project state setting ui improvement

* chore: integration setting repo select validation

* chore: code refactor

* fix: build fix
This commit is contained in:
Anmol Singh Bhatia 2023-09-13 23:09:55 +05:30 committed by GitHub
parent d0f6ca3bac
commit 87abf3ccb1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1090 additions and 876 deletions

View File

@ -3,8 +3,8 @@ import React, { useState } from "react";
// component // component
import { CustomSelect, ToggleSwitch } from "components/ui"; import { CustomSelect, ToggleSwitch } from "components/ui";
import { SelectMonthModal } from "components/automation"; import { SelectMonthModal } from "components/automation";
// icons // icon
import { ChevronDownIcon } from "@heroicons/react/24/outline"; import { ArchiveRestore } from "lucide-react";
// constants // constants
import { PROJECT_AUTOMATION_MONTHS } from "constants/project"; import { PROJECT_AUTOMATION_MONTHS } from "constants/project";
// types // types
@ -28,14 +28,18 @@ export const AutoArchiveAutomation: React.FC<Props> = ({ projectDetails, handleC
handleClose={() => setmonthModal(false)} handleClose={() => setmonthModal(false)}
handleChange={handleChange} handleChange={handleChange}
/> />
<div className="flex flex-col gap-7 px-6 py-5 rounded-[10px] border border-custom-border-300 bg-custom-background-90"> <div className="flex flex-col gap-4 border-b border-custom-border-200 px-4 py-6">
<div className="flex items-center justify-between gap-x-8 gap-y-2"> <div className="flex items-center justify-between">
<div className="flex flex-col gap-2.5"> <div className="flex items-start gap-3">
<h4 className="text-lg font-semibold">Auto-archive closed issues</h4> <div className="flex items-center justify-center p-3 rounded bg-custom-background-90">
<p className="text-sm text-custom-text-200"> <ArchiveRestore className="h-4 w-4 text-custom-text-100 flex-shrink-0" />
Plane will automatically archive issues that have been completed or cancelled for the </div>
configured time period. <div className="">
</p> <h4 className="text-sm font-medium">Auto-archive closed issues</h4>
<p className="text-sm text-custom-text-200 tracking-tight">
Plane will auto archive issues that have been completed or canceled.
</p>
</div>
</div> </div>
<ToggleSwitch <ToggleSwitch
value={projectDetails?.archive_in !== 0} value={projectDetails?.archive_in !== 0}
@ -47,40 +51,43 @@ export const AutoArchiveAutomation: React.FC<Props> = ({ projectDetails, handleC
size="sm" size="sm"
/> />
</div> </div>
{projectDetails?.archive_in !== 0 && (
<div className="flex items-center justify-between gap-2 w-full">
<div className="w-1/2 text-base font-medium">
Auto-archive issues that are closed for
</div>
<div className="w-1/2">
<CustomSelect
value={projectDetails?.archive_in}
label={`${projectDetails?.archive_in} ${
projectDetails?.archive_in === 1 ? "Month" : "Months"
}`}
onChange={(val: number) => {
handleChange({ archive_in: val });
}}
input
verticalPosition="top"
width="w-full"
>
<>
{PROJECT_AUTOMATION_MONTHS.map((month) => (
<CustomSelect.Option key={month.label} value={month.value}>
{month.label}
</CustomSelect.Option>
))}
<button {projectDetails?.archive_in !== 0 && (
type="button" <div className="ml-12">
className="flex w-full select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80" <div className="flex items-center justify-between rounded px-5 py-4 bg-custom-background-90 border border-custom-border-200 gap-2 w-full">
onClick={() => setmonthModal(true)} <div className="w-1/2 text-sm font-medium">
> Auto-archive issues that are closed for
Customise Time Range </div>
</button> <div className="w-1/2">
</> <CustomSelect
</CustomSelect> value={projectDetails?.archive_in}
label={`${projectDetails?.archive_in} ${
projectDetails?.archive_in === 1 ? "Month" : "Months"
}`}
onChange={(val: number) => {
handleChange({ archive_in: val });
}}
input
verticalPosition="bottom"
width="w-full"
>
<>
{PROJECT_AUTOMATION_MONTHS.map((month) => (
<CustomSelect.Option key={month.label} value={month.value}>
<span className="text-sm">{month.label}</span>
</CustomSelect.Option>
))}
<button
type="button"
className="flex w-full text-sm select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80"
onClick={() => setmonthModal(true)}
>
Customise Time Range
</button>
</>
</CustomSelect>
</div>
</div> </div>
</div> </div>
)} )}

View File

@ -5,11 +5,12 @@ import useSWR from "swr";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// component // component
import { CustomSearchSelect, CustomSelect, ToggleSwitch } from "components/ui"; import { CustomSearchSelect, CustomSelect, Icon, ToggleSwitch } from "components/ui";
import { SelectMonthModal } from "components/automation"; import { SelectMonthModal } from "components/automation";
// icons // icons
import { ChevronDownIcon, Squares2X2Icon } from "@heroicons/react/24/outline"; import { Squares2X2Icon } from "@heroicons/react/24/outline";
import { StateGroupIcon } from "components/icons"; import { StateGroupIcon } from "components/icons";
import { ArchiveX } from "lucide-react";
// services // services
import stateService from "services/state.service"; import stateService from "services/state.service";
// constants // constants
@ -76,14 +77,18 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
handleChange={handleChange} handleChange={handleChange}
/> />
<div className="flex flex-col gap-7 px-6 py-5 rounded-[10px] border border-custom-border-300 bg-custom-background-90"> <div className="flex flex-col gap-4 border-b border-custom-border-200 px-4 py-6">
<div className="flex items-center justify-between gap-x-8 gap-y-2 "> <div className="flex items-center justify-between">
<div className="flex flex-col gap-2.5"> <div className="flex items-start gap-3">
<h4 className="text-lg font-semibold">Auto-close inactive issues</h4> <div className="flex items-center justify-center p-3 rounded bg-custom-background-90">
<p className="text-sm text-custom-text-200"> <ArchiveX className="h-4 w-4 text-red-500 flex-shrink-0" />
Plane will automatically close the issues that have not been updated for the </div>
configured time period. <div className="">
</p> <h4 className="text-sm font-medium">Auto-close issues</h4>
<p className="text-sm text-custom-text-200 tracking-tight">
Plane will automatically close issue that havent been completed or canceled.
</p>
</div>
</div> </div>
<ToggleSwitch <ToggleSwitch
value={projectDetails?.close_in !== 0} value={projectDetails?.close_in !== 0}
@ -95,82 +100,86 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
size="sm" size="sm"
/> />
</div> </div>
{projectDetails?.close_in !== 0 && ( {projectDetails?.close_in !== 0 && (
<div className="flex flex-col gap-4 w-full"> <div className="ml-12">
<div className="flex items-center justify-between gap-2 w-full"> <div className="flex flex-col gap-4">
<div className="w-1/2 text-base font-medium"> <div className="flex items-center justify-between rounded px-5 py-4 bg-custom-background-90 border border-custom-border-200 gap-2 w-full">
Auto-close issues that are inactive for <div className="w-1/2 text-sm font-medium">
Auto-close issues that are inactive for
</div>
<div className="w-1/2">
<CustomSelect
value={projectDetails?.close_in}
label={`${projectDetails?.close_in} ${
projectDetails?.close_in === 1 ? "Month" : "Months"
}`}
onChange={(val: number) => {
handleChange({ close_in: val });
}}
input
width="w-full"
>
<>
{PROJECT_AUTOMATION_MONTHS.map((month) => (
<CustomSelect.Option key={month.label} value={month.value}>
{month.label}
</CustomSelect.Option>
))}
<button
type="button"
className="flex w-full select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80"
onClick={() => setmonthModal(true)}
>
Customise Time Range
</button>
</>
</CustomSelect>
</div>
</div> </div>
<div className="w-1/2">
<CustomSelect <div className="flex items-center justify-between rounded px-5 py-4 bg-custom-background-90 border border-custom-border-200 gap-2 w-full">
value={projectDetails?.close_in} <div className="w-1/2 text-sm font-medium">Auto-close Status</div>
label={`${projectDetails?.close_in} ${ <div className="w-1/2 ">
projectDetails?.close_in === 1 ? "Month" : "Months" <CustomSearchSelect
}`} value={
onChange={(val: number) => { projectDetails?.default_state ? projectDetails?.default_state : defaultState
handleChange({ close_in: val }); }
}} label={
input <div className="flex items-center gap-2">
width="w-full" {selectedOption ? (
> <StateGroupIcon
<> stateGroup={selectedOption.group}
{PROJECT_AUTOMATION_MONTHS.map((month) => ( color={selectedOption.color}
<CustomSelect.Option key={month.label} value={month.value}> height="16px"
{month.label} width="16px"
</CustomSelect.Option> />
))} ) : currentDefaultState ? (
<button <StateGroupIcon
type="button" stateGroup={currentDefaultState.group}
className="flex w-full select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80" color={currentDefaultState.color}
onClick={() => setmonthModal(true)} height="16px"
> width="16px"
Customise Time Range />
</button> ) : (
</> <Squares2X2Icon className="h-3.5 w-3.5 text-custom-text-200" />
</CustomSelect> )}
</div> {selectedOption?.name
</div> ? selectedOption.name
<div className="flex items-center justify-between gap-2 w-full"> : currentDefaultState?.name ?? (
<div className="w-1/2 text-base font-medium">Auto-close Status</div> <span className="text-custom-text-200">State</span>
<div className="w-1/2 "> )}
<CustomSearchSelect </div>
value={ }
projectDetails?.default_state ? projectDetails?.default_state : defaultState onChange={(val: string) => {
} handleChange({ default_state: val });
label={ }}
<div className="flex items-center gap-2"> options={options}
{selectedOption ? ( disabled={!multipleOptions}
<StateGroupIcon width="w-full"
stateGroup={selectedOption.group} input
color={selectedOption.color} />
height="16px" </div>
width="16px"
/>
) : currentDefaultState ? (
<StateGroupIcon
stateGroup={currentDefaultState.group}
color={currentDefaultState.color}
height="16px"
width="16px"
/>
) : (
<Squares2X2Icon className="h-3.5 w-3.5 text-custom-text-200" />
)}
{selectedOption?.name
? selectedOption.name
: currentDefaultState?.name ?? (
<span className="text-custom-text-200">State</span>
)}
</div>
}
onChange={(val: string) => {
handleChange({ default_state: val });
}}
options={options}
disabled={!multipleOptions}
width="w-full"
input
/>
</div> </div>
</div> </div>
</div> </div>

View File

@ -20,6 +20,7 @@ import fileService from "services/file.service";
import { Input, Spinner, PrimaryButton, SecondaryButton } from "components/ui"; import { Input, Spinner, PrimaryButton, SecondaryButton } from "components/ui";
// hooks // hooks
import useWorkspaceDetails from "hooks/use-workspace-details"; import useWorkspaceDetails from "hooks/use-workspace-details";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
const unsplashEnabled = const unsplashEnabled =
process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "true" || process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "true" ||
@ -67,6 +68,8 @@ export const ImagePickerPopover: React.FC<Props> = ({
fileService.getUnsplashImages(1, searchParams) fileService.getUnsplashImages(1, searchParams)
); );
const imagePickerRef = useRef<HTMLDivElement>(null);
const { workspaceDetails } = useWorkspaceDetails(); const { workspaceDetails } = useWorkspaceDetails();
const onDrop = useCallback((acceptedFiles: File[]) => { const onDrop = useCallback((acceptedFiles: File[]) => {
@ -116,12 +119,14 @@ export const ImagePickerPopover: React.FC<Props> = ({
onChange(images[0].urls.regular); onChange(images[0].urls.regular);
}, [value, onChange, images]); }, [value, onChange, images]);
useOutsideClickDetector(imagePickerRef, () => setIsOpen(false));
if (!unsplashEnabled) return null; if (!unsplashEnabled) return null;
return ( return (
<Popover className="relative z-[2]" ref={ref}> <Popover className="relative z-[2]" ref={ref}>
<Popover.Button <Popover.Button
className="rounded-md border border-custom-border-300 bg-custom-background-100 px-2 py-1 text-xs text-custom-text-200 hover:text-custom-text-100" className="rounded-sm border border-custom-border-300 bg-custom-background-100 px-2 py-1 text-xs text-custom-text-200 hover:text-custom-text-100"
onClick={() => setIsOpen((prev) => !prev)} onClick={() => setIsOpen((prev) => !prev)}
disabled={disabled} disabled={disabled}
> >
@ -137,7 +142,10 @@ export const ImagePickerPopover: React.FC<Props> = ({
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
> >
<Popover.Panel className="absolute right-0 z-10 mt-2 rounded-md border border-custom-border-200 bg-custom-background-80 shadow-lg"> <Popover.Panel className="absolute right-0 z-10 mt-2 rounded-md border border-custom-border-200 bg-custom-background-80 shadow-lg">
<div className="h-96 md:h-[28rem] w-80 md:w-[36rem] flex flex-col overflow-auto rounded border border-custom-border-300 bg-custom-background-100 p-3 shadow-2xl"> <div
ref={imagePickerRef}
className="h-96 md:h-[28rem] w-80 md:w-[36rem] flex flex-col overflow-auto rounded border border-custom-border-300 bg-custom-background-100 p-3 shadow-2xl"
>
<Tab.Group> <Tab.Group>
<div> <div>
<Tab.List as="span" className="inline-block rounded bg-custom-background-80 p-1"> <Tab.List as="span" className="inline-block rounded bg-custom-background-80 p-1">

View File

@ -1,8 +1,10 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState, useRef } from "react";
// headless ui // headless ui
import { Tab, Transition, Popover } from "@headlessui/react"; import { Tab, Transition, Popover } from "@headlessui/react";
// react colors // react colors
import { TwitterPicker } from "react-color"; import { TwitterPicker } from "react-color";
// hooks
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// types // types
import { Props } from "./types"; import { Props } from "./types";
// emojis // emojis
@ -36,6 +38,8 @@ const EmojiIconPicker: React.FC<Props> = ({
const [recentEmojis, setRecentEmojis] = useState<string[]>([]); const [recentEmojis, setRecentEmojis] = useState<string[]>([]);
const emojiPickerRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
setRecentEmojis(getRecentEmojis()); setRecentEmojis(getRecentEmojis());
}, []); }, []);
@ -44,6 +48,8 @@ const EmojiIconPicker: React.FC<Props> = ({
if (!value || value?.length === 0) onChange(getRandomEmoji()); if (!value || value?.length === 0) onChange(getRandomEmoji());
}, [value, onChange]); }, [value, onChange]);
useOutsideClickDetector(emojiPickerRef, () => setIsOpen(false));
return ( return (
<Popover className="relative z-[1]"> <Popover className="relative z-[1]">
<Popover.Button <Popover.Button
@ -63,7 +69,10 @@ const EmojiIconPicker: React.FC<Props> = ({
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
> >
<Popover.Panel className="absolute z-10 mt-2 w-[250px] rounded-[4px] border border-custom-border-200 bg-custom-background-80 shadow-lg"> <Popover.Panel className="absolute z-10 mt-2 w-[250px] rounded-[4px] border border-custom-border-200 bg-custom-background-80 shadow-lg">
<div className="h-[230px] w-[250px] overflow-auto rounded-[4px] border border-custom-border-200 bg-custom-background-80 p-2 shadow-xl"> <div
ref={emojiPickerRef}
className="h-[230px] w-[250px] overflow-auto rounded-[4px] border border-custom-border-200 bg-custom-background-80 p-2 shadow-xl"
>
<Tab.Group as="div" className="flex h-full w-full flex-col"> <Tab.Group as="div" className="flex h-full w-full flex-col">
<Tab.List className="flex-0 -mx-2 flex justify-around gap-1 p-1"> <Tab.List className="flex-0 -mx-2 flex justify-around gap-1 p-1">
{tabOptions.map((tab) => ( {tabOptions.map((tab) => (

View File

@ -66,7 +66,7 @@ export const SingleEstimate: React.FC<Props> = ({
return ( return (
<> <>
<div className="gap-2 py-3"> <div className="gap-2 p-4 border-b border-custom-border-200">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h6 className="flex w-[40vw] items-center gap-2 truncate text-sm font-medium"> <h6 className="flex w-[40vw] items-center gap-2 truncate text-sm font-medium">

View File

@ -84,3 +84,4 @@ export * from "./clock-icon";
export * from "./bell-icon"; export * from "./bell-icon";
export * from "./single-comment-icon"; export * from "./single-comment-icon";
export * from "./related-icon"; export * from "./related-icon";
export * from "./module-icon";

View File

@ -0,0 +1,59 @@
import React from "react";
import type { Props } from "./types";
export const ModuleIcon: React.FC<Props> = ({
width = "24",
height = "24",
className,
color = "#F15B5B",
}) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12.6667 2H3.33333C2.59695 2 2 2.59695 2 3.33333V12.6667C2 13.403 2.59695 14 3.33333 14H12.6667C13.403 14 14 13.403 14 12.6667V3.33333C14 2.59695 13.403 2 12.6667 2Z"
stroke="#F15B5B"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M5.84925 4.66667H4.81221C4.73039 4.66667 4.66406 4.733 4.66406 4.81482V5.85185C4.66406 5.93367 4.73039 6 4.81221 6H5.84925C5.93107 6 5.9974 5.93367 5.9974 5.85185V4.81482C5.9974 4.733 5.93107 4.66667 5.84925 4.66667Z"
fill={color}
stroke="#F15B5B"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M5.84925 10H4.81221C4.73039 10 4.66406 10.0663 4.66406 10.1481V11.1852C4.66406 11.267 4.73039 11.3333 4.81221 11.3333H5.84925C5.93107 11.3333 5.9974 11.267 5.9974 11.1852V10.1481C5.9974 10.0663 5.93107 10 5.84925 10Z"
fill={color}
stroke="#F15B5B"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M11.1852 4.66667H10.1481C10.0663 4.66667 10 4.733 10 4.81482V5.85185C10 5.93367 10.0663 6 10.1481 6H11.1852C11.267 6 11.3333 5.93367 11.3333 5.85185V4.81482C11.3333 4.733 11.267 4.66667 11.1852 4.66667Z"
fill={color}
stroke="#F15B5B"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M11.1852 10H10.1481C10.0663 10 10 10.0663 10 10.1481V11.1852C10 11.267 10.0663 11.3333 10.1481 11.3333H11.1852C11.267 11.3333 11.3333 11.267 11.3333 11.1852V10.1481C11.3333 10.0663 11.267 10 11.1852 10Z"
fill={color}
stroke="#F15B5B"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
);

View File

@ -66,6 +66,8 @@ export const SelectRepository: React.FC<Props> = ({
content: <p>{truncateText(repo.full_name, characterLimit)}</p>, content: <p>{truncateText(repo.full_name, characterLimit)}</p>,
})) ?? []; })) ?? [];
if (userRepositories.length < 1) return null;
return ( return (
<CustomSearchSelect <CustomSearchSelect
value={value} value={value}

View File

@ -83,9 +83,7 @@ export const SelectChannel: React.FC<Props> = ({ integration }) => {
{projectIntegration ? ( {projectIntegration ? (
<button <button
type="button" type="button"
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${ className={`relative inline-flex h-4 w-6 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none bg-gray-700`}
slackChannelAvailabilityToggle ? "bg-green-500" : "bg-gray-200"
}`}
role="switch" role="switch"
aria-checked aria-checked
onClick={() => { onClick={() => {
@ -94,8 +92,8 @@ export const SelectChannel: React.FC<Props> = ({ integration }) => {
> >
<span <span
aria-hidden="true" aria-hidden="true"
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${ className={`self-center inline-block h-2 w-2 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
slackChannelAvailabilityToggle ? "translate-x-5" : "translate-x-0" slackChannelAvailabilityToggle ? "translate-x-3" : "translate-x-0"
}`} }`}
/> />
</button> </button>

View File

@ -17,7 +17,7 @@ import issuesService from "services/issues.service";
// ui // ui
import { Input, PrimaryButton, SecondaryButton } from "components/ui"; import { Input, PrimaryButton, SecondaryButton } from "components/ui";
// icons // icons
import { ChevronDownIcon } from "@heroicons/react/24/outline"; import { Component } from "lucide-react";
// types // types
import { IIssueLabels } from "types"; import { IIssueLabels } from "types";
// fetch-keys // fetch-keys
@ -132,7 +132,7 @@ export const CreateUpdateLabelInline = forwardRef<HTMLDivElement, Props>(
return ( return (
<div <div
className={`flex scroll-m-8 items-center gap-2 rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-5 ${ className={`flex scroll-m-8 items-center gap-2 rounded border border-custom-border-200 bg-custom-background-100 px-3.5 py-2 ${
labelForm ? "" : "hidden" labelForm ? "" : "hidden"
}`} }`}
ref={ref} ref={ref}
@ -146,18 +146,12 @@ export const CreateUpdateLabelInline = forwardRef<HTMLDivElement, Props>(
open ? "text-custom-text-100" : "text-custom-text-200" open ? "text-custom-text-100" : "text-custom-text-200"
}`} }`}
> >
<span <Component
className="h-5 w-5 rounded" className="h-4 w-4 text-custom-text-100 flex-shrink-0"
style={{ style={{
backgroundColor: watch("color"), color: watch("color"),
}} }}
/> />
<ChevronDownIcon
className={`ml-2 h-5 w-5 group-hover:text-custom-text-200 ${
open ? "text-gray-600" : "text-gray-400"
}`}
aria-hidden="true"
/>
</Popover.Button> </Popover.Button>
<Transition <Transition

View File

@ -13,12 +13,12 @@ import { CustomMenu } from "components/ui";
// icons // icons
import { import {
ChevronDownIcon, ChevronDownIcon,
RectangleGroupIcon,
XMarkIcon, XMarkIcon,
PlusIcon, PlusIcon,
PencilIcon, PencilIcon,
TrashIcon, TrashIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { Component, X } from "lucide-react";
// types // types
import { ICurrentUserResponse, IIssueLabels } from "types"; import { ICurrentUserResponse, IIssueLabels } from "types";
// fetch-keys // fetch-keys
@ -76,20 +76,18 @@ export const SingleLabelGroup: React.FC<Props> = ({
return ( return (
<Disclosure <Disclosure
as="div" as="div"
className="rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-5 text-custom-text-100" className="rounded border border-custom-border-200 bg-custom-background-100 px-3.5 py-3 text-custom-text-100"
defaultOpen defaultOpen
> >
{({ open }) => ( {({ open }) => (
<> <>
<div className="flex cursor-pointer items-center justify-between gap-2"> <div className="flex cursor-pointer items-center justify-between gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span> <Component className="h-4 w-4 text-custom-text-100 flex-shrink-0" />
<RectangleGroupIcon className="h-4 w-4" />
</span>
<h6>{label.name}</h6> <h6>{label.name}</h6>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CustomMenu ellipsis> <CustomMenu ellipsis buttonClassName="!text-custom-sidebar-text-400">
<CustomMenu.MenuItem onClick={() => addLabelToGroup(label)}> <CustomMenu.MenuItem onClick={() => addLabelToGroup(label)}>
<span className="flex items-center justify-start gap-2"> <span className="flex items-center justify-start gap-2">
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
@ -112,7 +110,9 @@ export const SingleLabelGroup: React.FC<Props> = ({
<Disclosure.Button> <Disclosure.Button>
<span> <span>
<ChevronDownIcon <ChevronDownIcon
className={`h-4 w-4 text-custom-text-100 ${!open ? "rotate-90 transform" : ""}`} className={`h-4 w-4 text-custom-sidebar-text-400 ${
!open ? "rotate-90 transform" : ""
}`}
/> />
</span> </span>
</Disclosure.Button> </Disclosure.Button>
@ -128,15 +128,15 @@ export const SingleLabelGroup: React.FC<Props> = ({
leaveTo="transform opacity-0" leaveTo="transform opacity-0"
> >
<Disclosure.Panel> <Disclosure.Panel>
<div className="mt-3 ml-6 space-y-3"> <div className="mt-2.5 ml-6">
{labelChildren.map((child) => ( {labelChildren.map((child) => (
<div <div
key={child.id} key={child.id}
className="group flex items-center justify-between rounded-md border border-custom-border-200 p-2 text-sm" className="group flex items-center justify-between border-b border-custom-border-200 px-4 py-2.5 text-sm last:border-0"
> >
<h5 className="flex items-center gap-3"> <h5 className="flex items-center gap-3">
<span <span
className="h-2.5 w-2.5 flex-shrink-0 rounded-full" className="h-3.5 w-3.5 flex-shrink-0 rounded-full"
style={{ style={{
backgroundColor: backgroundColor:
child.color && child.color !== "" ? child.color : "#000000", child.color && child.color !== "" ? child.color : "#000000",
@ -144,27 +144,38 @@ export const SingleLabelGroup: React.FC<Props> = ({
/> />
{child.name} {child.name}
</h5> </h5>
<div className="pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100"> <div className="flex items-center gap-3.5 pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100">
<CustomMenu ellipsis> <div className="h-4 w-4">
<CustomMenu.MenuItem onClick={() => removeFromGroup(child)}> <CustomMenu
<span className="flex items-center justify-start gap-2"> customButton={
<XMarkIcon className="h-4 w-4" /> <div className="h-4 w-4">
<span>Remove from group</span> <Component className="h-4 w-4 leading-4 text-custom-sidebar-text-400 flex-shrink-0" />
</span> </div>
</CustomMenu.MenuItem> }
<CustomMenu.MenuItem onClick={() => editLabel(child)}> >
<span className="flex items-center justify-start gap-2"> <CustomMenu.MenuItem onClick={() => removeFromGroup(child)}>
<PencilIcon className="h-4 w-4" /> <span className="flex items-center justify-start gap-2">
<span>Edit label</span> <XMarkIcon className="h-4 w-4" />
</span> <span>Remove from group</span>
</CustomMenu.MenuItem> </span>
<CustomMenu.MenuItem onClick={handleLabelDelete}> </CustomMenu.MenuItem>
<span className="flex items-center justify-start gap-2"> <CustomMenu.MenuItem onClick={() => editLabel(child)}>
<TrashIcon className="h-4 w-4" /> <span className="flex items-center justify-start gap-2">
<span>Delete label</span> <PencilIcon className="h-4 w-4" />
</span> <span>Edit label</span>
</CustomMenu.MenuItem> </span>
</CustomMenu> </CustomMenu.MenuItem>
</CustomMenu>
</div>
<div className="flex items-center">
<button
className="flex items-center justify-start gap-2"
onClick={handleLabelDelete}
>
<X className="h-[18px] w-[18px] text-custom-sidebar-text-400 flex-shrink-0" />
</button>
</div>
</div> </div>
</div> </div>
))} ))}

View File

@ -5,7 +5,8 @@ import { CustomMenu } from "components/ui";
// types // types
import { IIssueLabels } from "types"; import { IIssueLabels } from "types";
//icons //icons
import { RectangleGroupIcon, PencilIcon, TrashIcon } from "@heroicons/react/24/outline"; import { RectangleGroupIcon, PencilIcon } from "@heroicons/react/24/outline";
import { Component, X } from "lucide-react";
type Props = { type Props = {
label: IIssueLabels; label: IIssueLabels;
@ -20,8 +21,8 @@ export const SingleLabel: React.FC<Props> = ({
editLabel, editLabel,
handleLabelDelete, handleLabelDelete,
}) => ( }) => (
<div className="gap-2 space-y-3 divide-y divide-custom-border-200 rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-5"> <div className="gap-2 space-y-3 divide-y divide-custom-border-200 rounded border border-custom-border-200 bg-custom-background-100 px-4 py-2.5">
<div className="flex items-center justify-between"> <div className="group flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span <span
className="h-3.5 w-3.5 flex-shrink-0 rounded-full" className="h-3.5 w-3.5 flex-shrink-0 rounded-full"
@ -31,26 +32,36 @@ export const SingleLabel: React.FC<Props> = ({
/> />
<h6 className="text-sm">{label.name}</h6> <h6 className="text-sm">{label.name}</h6>
</div> </div>
<CustomMenu ellipsis> <div className="flex items-center gap-3.5 pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100">
<CustomMenu.MenuItem onClick={() => addLabelToGroup(label)}> <div className="h-4 w-4">
<span className="flex items-center justify-start gap-2"> <CustomMenu
<RectangleGroupIcon className="h-4 w-4" /> customButton={
<span>Convert to group</span> <div className="h-4 w-4">
</span> <Component className="h-4 w-4 leading-4 text-custom-sidebar-text-400 flex-shrink-0" />
</CustomMenu.MenuItem> </div>
<CustomMenu.MenuItem onClick={() => editLabel(label)}> }
<span className="flex items-center justify-start gap-2"> >
<PencilIcon className="h-4 w-4" /> <CustomMenu.MenuItem onClick={() => addLabelToGroup(label)}>
<span>Edit label</span> <span className="flex items-center justify-start gap-2">
</span> <RectangleGroupIcon className="h-4 w-4" />
</CustomMenu.MenuItem> <span>Convert to group</span>
<CustomMenu.MenuItem onClick={handleLabelDelete}> </span>
<span className="flex items-center justify-start gap-2"> </CustomMenu.MenuItem>
<TrashIcon className="h-4 w-4" /> <CustomMenu.MenuItem onClick={() => editLabel(label)}>
<span>Delete label</span> <span className="flex items-center justify-start gap-2">
</span> <PencilIcon className="h-4 w-4" />
</CustomMenu.MenuItem> <span>Edit label</span>
</CustomMenu> </span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
<div className="flex items-center">
<button className="flex items-center justify-start gap-2" onClick={handleLabelDelete}>
<X className="h-[18px] w-[18px] text-custom-sidebar-text-400 flex-shrink-0" />
</button>
</div>
</div>
</div> </div>
</div> </div>
); );

View File

@ -1,8 +1,9 @@
export * from "./create-project-modal"; export * from "./create-project-modal";
export * from "./delete-project-modal"; export * from "./delete-project-modal";
export * from "./sidebar-list"; export * from "./sidebar-list";
export * from "./settings-header"; export * from "./settings-sidebar";
export * from "./single-integration-card"; export * from "./single-integration-card";
export * from "./single-project-card"; export * from "./single-project-card";
export * from "./single-sidebar-project"; export * from "./single-sidebar-project";
export * from "./confirm-project-leave-modal"; export * from "./confirm-project-leave-modal";
export * from "./member-select";

View File

@ -0,0 +1,74 @@
import React from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import projectService from "services/project.service";
// ui
import { Avatar, CustomSearchSelect } from "components/ui";
// fetch-keys
import { PROJECT_MEMBERS } from "constants/fetch-keys";
type Props = {
value: any;
onChange: (val: string) => void;
};
export const MemberSelect: React.FC<Props> = ({ value, onChange }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: members } = useSWR(
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
: null
);
const options = members?.map((member) => ({
value: member.member.id,
query: member.member.display_name,
content: (
<div className="flex items-center gap-2">
<Avatar user={member.member} />
{member.member.display_name}
</div>
),
}));
const selectedOption = members?.find((m) => m.member.id === value)?.member;
return (
<CustomSearchSelect
value={value}
label={
<div className="flex items-center gap-2">
{selectedOption && <Avatar user={selectedOption} />}
{selectedOption ? (
selectedOption?.display_name
) : (
<span className="text-sm py-0.5 text-custom-text-200">Select</span>
)}
</div>
}
buttonClassName="!px-3 !py-2"
options={
options &&
options && [
...options,
{
value: "none",
query: "none",
content: <div className="flex items-center gap-2">None</div>,
},
]
}
maxHeight="md"
position="right"
width="w-full"
onChange={onChange}
/>
);
};

View File

@ -1,13 +0,0 @@
import SettingsNavbar from "layouts/settings-navbar";
export const SettingsHeader = () => (
<div className="mb-8 space-y-6">
<div>
<h3 className="text-2xl font-semibold">Project Settings</h3>
<p className="mt-1 text-sm text-custom-text-200">
This information will be displayed to every member of the project.
</p>
</div>
<SettingsNavbar />
</div>
);

View File

@ -0,0 +1,72 @@
import React from "react";
import { useRouter } from "next/router";
import Link from "next/link";
export const SettingsSidebar = () => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const projectLinks: Array<{
label: string;
href: string;
}> = [
{
label: "General",
href: `/${workspaceSlug}/projects/${projectId}/settings`,
},
{
label: "Members",
href: `/${workspaceSlug}/projects/${projectId}/settings/members`,
},
{
label: "Features",
href: `/${workspaceSlug}/projects/${projectId}/settings/features`,
},
{
label: "States",
href: `/${workspaceSlug}/projects/${projectId}/settings/states`,
},
{
label: "Labels",
href: `/${workspaceSlug}/projects/${projectId}/settings/labels`,
},
{
label: "Integrations",
href: `/${workspaceSlug}/projects/${projectId}/settings/integrations`,
},
{
label: "Estimates",
href: `/${workspaceSlug}/projects/${projectId}/settings/estimates`,
},
{
label: "Automations",
href: `/${workspaceSlug}/projects/${projectId}/settings/automations`,
},
];
return (
<div className="flex flex-col gap-2 w-80 px-9">
<span className="text-xs text-custom-sidebar-text-400 font-semibold">SETTINGS</span>
<div className="flex flex-col gap-1 w-full">
{projectLinks.map((link) => (
<Link key={link.href} href={link.href}>
<a>
<div
className={`px-4 py-2 text-sm font-medium rounded-md ${
(
link.label === "Import"
? router.asPath.includes(link.href)
: router.asPath === link.href
)
? "bg-custom-primary-100/10 text-custom-primary-100"
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
}`}
>
{link.label}
</div>
</a>
</Link>
))}
</div>
</div>
);
};

View File

@ -30,8 +30,7 @@ const integrationDetails: { [key: string]: any } = {
}, },
slack: { slack: {
logo: SlackLogo, logo: SlackLogo,
description: description: "Get regular updates and control which notification you want to receive.",
"Connect your slack channel to this project to get regular updates. Control which notification you want to receive.",
}, },
}; };
@ -93,19 +92,19 @@ export const SingleIntegration: React.FC<Props> = ({ integration }) => {
return ( return (
<> <>
{integration && ( {integration && (
<div className="flex items-center justify-between gap-2 rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-5"> <div className="flex items-center justify-between gap-2 border-b border-custom-border-200 bg-custom-background-100 px-4 py-6">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="h-12 w-12 flex-shrink-0"> <div className="h-10 w-10 flex-shrink-0">
<Image <Image
src={integrationDetails[integration.integration_detail.provider].logo} src={integrationDetails[integration.integration_detail.provider].logo}
alt={`${integration.integration_detail.title} Logo`} alt={`${integration.integration_detail.title} Logo`}
/> />
</div> </div>
<div> <div>
<h3 className="flex items-center gap-4 text-xl font-semibold"> <h3 className="flex items-center gap-4 text-sm font-medium">
{integration.integration_detail.title} {integration.integration_detail.title}
</h3> </h3>
<p className="text-sm text-custom-text-200"> <p className="text-sm text-custom-text-200 tracking-tight">
{integrationDetails[integration.integration_detail.provider].description} {integrationDetails[integration.integration_detail.provider].description}
</p> </p>
</div> </div>

View File

@ -9,13 +9,10 @@ import stateService from "services/state.service";
// ui // ui
import { Tooltip } from "components/ui"; import { Tooltip } from "components/ui";
// icons // icons
import { import { ArrowDownIcon, ArrowUpIcon } from "@heroicons/react/24/outline";
ArrowDownIcon,
ArrowUpIcon,
PencilSquareIcon,
TrashIcon,
} from "@heroicons/react/24/outline";
import { StateGroupIcon } from "components/icons"; import { StateGroupIcon } from "components/icons";
import { Pencil, X } from "lucide-react";
// helpers // helpers
import { addSpaceIfCamelCase } from "helpers/string.helper"; import { addSpaceIfCamelCase } from "helpers/string.helper";
import { groupBy, orderArrayBy } from "helpers/array.helper"; import { groupBy, orderArrayBy } from "helpers/array.helper";
@ -160,15 +157,15 @@ export const SingleState: React.FC<Props> = ({
}; };
return ( return (
<div className="group flex items-center justify-between gap-2 border-custom-border-200 bg-custom-background-100 p-5 first:rounded-t-[10px] last:rounded-b-[10px]"> <div className="group flex items-center justify-between gap-2 rounded border border-custom-border-200 bg-custom-background-100 px-4 py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<StateGroupIcon stateGroup={state.group} color={state.color} height="20px" width="20px" /> <StateGroupIcon stateGroup={state.group} color={state.color} height="16px" width="16px" />
<div> <div>
<h6 className="text-sm">{addSpaceIfCamelCase(state.name)}</h6> <h6 className="text-sm font-medium">{addSpaceIfCamelCase(state.name)}</h6>
<p className="text-xs text-custom-text-200">{state.description}</p> <p className="text-xs text-custom-text-200">{state.description}</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="group flex items-center gap-2.5">
{index !== 0 && ( {index !== 0 && (
<button <button
type="button" type="button"
@ -192,37 +189,43 @@ export const SingleState: React.FC<Props> = ({
) : ( ) : (
<button <button
type="button" type="button"
className="hidden text-xs text-custom-text-200 group-hover:inline-block" className="hidden text-xs text-custom-sidebar-text-400 group-hover:inline-block"
onClick={handleMakeDefault} onClick={handleMakeDefault}
disabled={isSubmitting} disabled={isSubmitting}
> >
Set as default Mark as default
</button> </button>
)} )}
<div className=" items-center gap-2.5 hidden group-hover:flex">
<button
type="button"
className="grid place-items-center group-hover:opacity-100 opacity-0"
onClick={handleEditState}
>
<Pencil className="h-3.5 w-3.5 text-custom-text-200" />
</button>
<button type="button" className="grid place-items-center" onClick={handleEditState}> <button
<PencilSquareIcon className="h-4 w-4 text-custom-text-200" /> type="button"
</button> className={`group-hover:opacity-100 opacity-0 ${
<button state.default || groupLength === 1 ? "cursor-not-allowed" : ""
type="button" } grid place-items-center`}
className={`${ onClick={handleDeleteState}
state.default || groupLength === 1 ? "cursor-not-allowed" : "" disabled={state.default || groupLength === 1}
} grid place-items-center`} >
onClick={handleDeleteState} {state.default ? (
disabled={state.default || groupLength === 1} <Tooltip tooltipContent="Cannot delete the default state.">
> <X className="h-3.5 w-3.5 text-red-500" />
{state.default ? ( </Tooltip>
<Tooltip tooltipContent="Cannot delete the default state."> ) : groupLength === 1 ? (
<TrashIcon className="h-4 w-4 text-red-500" /> <Tooltip tooltipContent="Cannot have an empty group.">
</Tooltip> <X className="h-3.5 w-3.5 text-red-500" />
) : groupLength === 1 ? ( </Tooltip>
<Tooltip tooltipContent="Cannot have an empty group."> ) : (
<TrashIcon className="h-4 w-4 text-red-500" /> <X className="h-3.5 w-3.5 text-red-500" />
</Tooltip> )}
) : ( </button>
<TrashIcon className="h-4 w-4 text-red-500" /> </div>
)}
</button>
</div> </div>
</div> </div>
); );

View File

@ -6,8 +6,8 @@ type Props = {
}; };
export const IntegrationAndImportExportBanner: React.FC<Props> = ({ bannerName, description }) => ( export const IntegrationAndImportExportBanner: React.FC<Props> = ({ bannerName, description }) => (
<div className="flex flex-col items-start gap-3"> <div className="flex flex-col items-start gap-3 py-3.5 border-b border-custom-border-200">
<h3 className="text-2xl font-semibold">{bannerName}</h3> <h3 className="text-xl font-medium">{bannerName}</h3>
{description && ( {description && (
<div className="flex items-center gap-3 rounded-[10px] border border-custom-primary/75 bg-custom-primary/5 p-4 text-sm text-custom-text-100"> <div className="flex items-center gap-3 rounded-[10px] border border-custom-primary/75 bg-custom-primary/5 p-4 text-sm text-custom-text-100">
<ExclamationIcon height={24} width={24} className="fill-current text-custom-text-100" /> <ExclamationIcon height={24} width={24} className="fill-current text-custom-text-100" />

View File

@ -18,24 +18,24 @@ export const ToggleSwitch: React.FC<Props> = (props) => {
disabled={disabled} disabled={disabled}
onChange={onChange} onChange={onChange}
className={`relative flex-shrink-0 inline-flex ${ className={`relative flex-shrink-0 inline-flex ${
size === "sm" ? "h-3.5 w-6" : size === "md" ? "h-4 w-7" : "h-6 w-11" size === "sm" ? "h-4 w-6" : size === "md" ? "h-5 w-8" : "h-6 w-10"
} flex-shrink-0 cursor-pointer rounded-full border-2 border-custom-border-200 transition-colors duration-200 ease-in-out focus:outline-none ${ } flex-shrink-0 cursor-pointer rounded-full border border-custom-border-200 transition-colors duration-200 ease-in-out focus:outline-none ${
value ? "bg-green-500" : "bg-custom-background-80" value ? "bg-custom-primary-100" : "bg-gray-700"
} ${className || ""}`} } ${className || ""}`}
> >
<span className="sr-only">{label}</span> <span className="sr-only">{label}</span>
<span <span
aria-hidden="true" aria-hidden="true"
className={`inline-block ${ className={`self-center inline-block ${
size === "sm" ? "h-2.5 w-2.5" : size === "md" ? "h-3 w-3" : "h-5 w-5" size === "sm" ? "h-2 w-2" : size === "md" ? "h-3 w-3" : "h-4 w-4"
} transform rounded-full shadow ring-0 transition duration-200 ease-in-out ${ } transform rounded-full shadow ring-0 transition duration-200 ease-in-out ${
value value
? (size === "sm" ? (size === "sm"
? "translate-x-2.5"
: size === "md"
? "translate-x-3" ? "translate-x-3"
: size === "md"
? "translate-x-4"
: "translate-x-5") + " bg-white" : "translate-x-5") + " bg-white"
: "translate-x-0 bg-custom-background-90" : "translate-x-1 bg-custom-background-90"
}`} }`}
/> />
</Switch> </Switch>

View File

@ -57,7 +57,7 @@
"js-cookie": "^3.0.1", "js-cookie": "^3.0.1",
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"lowlight": "^2.9.0", "lowlight": "^2.9.0",
"lucide-react": "^0.263.1", "lucide-react": "^0.269.0",
"mobx": "^6.10.0", "mobx": "^6.10.0",
"mobx-react-lite": "^4.0.3", "mobx-react-lite": "^4.0.3",
"next": "12.3.2", "next": "12.3.2",

View File

@ -13,8 +13,8 @@ import useUserAuth from "hooks/use-user-auth";
import useProjectDetails from "hooks/use-project-details"; import useProjectDetails from "hooks/use-project-details";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// components // components
import { SettingsHeader } from "components/project";
import { AutoArchiveAutomation, AutoCloseAutomation } from "components/automation"; import { AutoArchiveAutomation, AutoCloseAutomation } from "components/automation";
import { SettingsSidebar } from "components/project";
// ui // ui
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// types // types
@ -75,11 +75,16 @@ const AutomationsSettings: NextPage = () => {
</Breadcrumbs> </Breadcrumbs>
} }
> >
<div className="p-8"> <div className="flex flex-row gap-2">
<SettingsHeader /> <div className="w-80 py-8">
<section className="space-y-5"> <SettingsSidebar />
<AutoCloseAutomation projectDetails={projectDetails} handleChange={handleChange} /> </div>
<section className="pr-9 py-8 w-full">
<div className="flex items-center py-3.5 border-b border-custom-border-200">
<h3 className="text-xl font-medium">Automations</h3>
</div>
<AutoArchiveAutomation projectDetails={projectDetails} handleChange={handleChange} /> <AutoArchiveAutomation projectDetails={projectDetails} handleChange={handleChange} />
<AutoCloseAutomation projectDetails={projectDetails} handleChange={handleChange} />
</section> </section>
</div> </div>
</ProjectAuthorizationWrapper> </ProjectAuthorizationWrapper>

View File

@ -1,241 +0,0 @@
import React, { useEffect } from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
// layouts
import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
// services
import projectService from "services/project.service";
// hooks
import useToast from "hooks/use-toast";
import useUserAuth from "hooks/use-user-auth";
// components
import { SettingsHeader } from "components/project";
// ui
import { CustomSelect, Loader, SecondaryButton } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// types
import { IProject, IUserLite, IWorkspace } from "types";
import type { NextPage } from "next";
// fetch-keys
import { PROJECTS_LIST, PROJECT_DETAILS, PROJECT_MEMBERS } from "constants/fetch-keys";
// helper
import { truncateText } from "helpers/string.helper";
const defaultValues: Partial<IProject> = {
project_lead: null,
default_assignee: null,
};
const ControlSettings: NextPage = () => {
const { setToastAlert } = useToast();
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { user } = useUserAuth();
const { data: projectDetails } = useSWR<IProject>(
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.getProject(workspaceSlug as string, projectId as string)
: null
);
const { data: people } = useSWR(
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
: null
);
const {
handleSubmit,
reset,
control,
formState: { isSubmitting },
} = useForm<IProject>({ defaultValues });
const onSubmit = async (formData: IProject) => {
if (!workspaceSlug || !projectId || !projectDetails) return;
const payload: Partial<IProject> = {
default_assignee: formData.default_assignee,
project_lead: formData.project_lead,
};
await projectService
.updateProject(workspaceSlug as string, projectId as string, payload, user)
.then((res) => {
mutate(PROJECT_DETAILS(projectId as string));
mutate(
PROJECTS_LIST(workspaceSlug as string, {
is_favorite: "all",
})
);
setToastAlert({
title: "Success",
type: "success",
message: "Project updated successfully",
});
})
.catch((err) => {
console.log(err);
});
};
useEffect(() => {
if (projectDetails)
reset({
...projectDetails,
default_assignee: projectDetails.default_assignee?.id ?? projectDetails.default_assignee,
project_lead: (projectDetails.project_lead as IUserLite)?.id ?? projectDetails.project_lead,
workspace: (projectDetails.workspace as IWorkspace).id,
});
}, [projectDetails, reset]);
return (
<ProjectAuthorizationWrapper
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem
title={`${truncateText(projectDetails?.name ?? "Project", 32)}`}
link={`/${workspaceSlug}/projects/${projectId}/issues`}
linkTruncate
/>
<BreadcrumbItem title="Control Settings" unshrinkTitle />
</Breadcrumbs>
}
>
<form onSubmit={handleSubmit(onSubmit)} className="p-8">
<SettingsHeader />
<div className="space-y-8 sm:space-y-12">
<div className="grid grid-cols-12 items-start gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">Project Lead</h4>
<p className="text-sm text-custom-text-200">Select the project leader.</p>
</div>
<div className="col-span-12 sm:col-span-6">
{projectDetails ? (
<Controller
name="project_lead"
control={control}
render={({ field }) => (
<CustomSelect
{...field}
label={
people?.find((person) => person.member.id === field.value)?.member
.display_name ?? <span className="text-custom-text-200">Select lead</span>
}
width="w-full"
input
>
{people?.map((person) => (
<CustomSelect.Option
key={person.member.id}
value={person.member.id}
className="flex items-center gap-2"
>
<div className="flex items-center gap-2">
{person.member.avatar && person.member.avatar !== "" ? (
<div className="relative h-4 w-4">
<img
src={person.member.avatar}
className="absolute top-0 left-0 h-full w-full object-cover rounded-full"
alt="User Avatar"
/>
</div>
) : (
<div className="grid h-4 w-4 flex-shrink-0 place-items-center rounded-full bg-gray-700 capitalize text-white">
{person.member.display_name?.charAt(0)}
</div>
)}
{person.member.display_name}
</div>
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
) : (
<Loader className="h-9 w-full">
<Loader.Item width="100%" height="100%" />
</Loader>
)}
</div>
</div>
<div className="grid grid-cols-12 items-start gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">Default Assignee</h4>
<p className="text-sm text-custom-text-200">
Select the default assignee for the project.
</p>
</div>
<div className="col-span-12 sm:col-span-6">
{projectDetails ? (
<Controller
name="default_assignee"
control={control}
render={({ field }) => (
<CustomSelect
{...field}
label={
people?.find((p) => p.member.id === field.value)?.member.display_name ?? (
<span className="text-custom-text-200">Select default assignee</span>
)
}
width="w-full"
input
>
{people?.map((person) => (
<CustomSelect.Option
key={person.member.id}
value={person.member.id}
className="flex items-center gap-2"
>
<div className="flex items-center gap-2">
{person.member.avatar && person.member.avatar !== "" ? (
<div className="relative h-4 w-4">
<img
src={person.member.avatar}
className="absolute top-0 left-0 h-full w-full object-cover rounded-full"
alt="User Avatar"
/>
</div>
) : (
<div className="grid h-4 w-4 flex-shrink-0 place-items-center rounded-full bg-gray-700 capitalize text-white">
{person.member.display_name?.charAt(0)}
</div>
)}
{person.member.display_name}
</div>
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
) : (
<Loader className="h-9 w-full">
<Loader.Item width="100%" height="100%" />
</Loader>
)}
</div>
</div>
<div className="sm:text-right">
<SecondaryButton type="submit" loading={isSubmitting}>
{isSubmitting ? "Updating Project..." : "Update Project"}
</SecondaryButton>
</div>
</div>
</form>
</ProjectAuthorizationWrapper>
);
};
export default ControlSettings;

View File

@ -13,12 +13,12 @@ import useProjectDetails from "hooks/use-project-details";
import { ProjectAuthorizationWrapper } from "layouts/auth-layout"; import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
// components // components
import { CreateUpdateEstimateModal, SingleEstimate } from "components/estimates"; import { CreateUpdateEstimateModal, SingleEstimate } from "components/estimates";
import { SettingsHeader } from "components/project"; import { SettingsSidebar } from "components/project";
//hooks //hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import useUserAuth from "hooks/use-user-auth"; import useUserAuth from "hooks/use-user-auth";
// ui // ui
import { EmptyState, Loader, SecondaryButton } from "components/ui"; import { EmptyState, Loader, PrimaryButton, SecondaryButton } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// icons // icons
import { PlusIcon } from "@heroicons/react/24/outline"; import { PlusIcon } from "@heroicons/react/24/outline";
@ -125,66 +125,68 @@ const EstimatesSettings: NextPage = () => {
</Breadcrumbs> </Breadcrumbs>
} }
> >
<div className="h-full flex flex-col p-8 overflow-hidden"> <div className="flex flex-row gap-2 h-full overflow-hidden">
<SettingsHeader /> <div className="w-80 py-8">
<section className="flex items-center justify-between"> <SettingsSidebar />
<h3 className="text-2xl font-semibold">Estimates</h3> </div>
<div className="col-span-12 space-y-5 sm:col-span-7"> <div className="pr-9 py-8 flex flex-col w-full">
<div className="flex items-center gap-2"> <section className="flex items-center justify-between pt-2 pb-3.5 border-b border-custom-border-200">
<div <h3 className="text-xl font-medium">Estimates</h3>
className="flex cursor-pointer items-center gap-2 text-custom-primary-100 hover:text-custom-primary-200" <div className="col-span-12 space-y-5 sm:col-span-7">
onClick={() => { <div className="flex items-center gap-2">
setEstimateToUpdate(undefined); <PrimaryButton
setEstimateFormOpen(true); onClick={() => {
}}
>
<PlusIcon className="h-4 w-4" />
Create New Estimate
</div>
{projectDetails?.estimate && (
<SecondaryButton onClick={disableEstimates}>Disable Estimates</SecondaryButton>
)}
</div>
</div>
</section>
{estimatesList ? (
estimatesList.length > 0 ? (
<section className="h-full mt-5 divide-y divide-custom-border-200 rounded-xl border border-custom-border-200 bg-custom-background-100 px-6 overflow-y-auto">
{estimatesList.map((estimate) => (
<SingleEstimate
key={estimate.id}
estimate={estimate}
editEstimate={(estimate) => editEstimate(estimate)}
handleEstimateDelete={(estimateId) => removeEstimate(estimateId)}
user={user}
/>
))}
</section>
) : (
<div className="h-full w-full overflow-y-auto">
<EmptyState
title="No estimates yet"
description="Estimates help you communicate the complexity of an issue."
image={emptyEstimate}
primaryButton={{
icon: <PlusIcon className="h-4 w-4" />,
text: "Add Estimate",
onClick: () => {
setEstimateToUpdate(undefined); setEstimateToUpdate(undefined);
setEstimateFormOpen(true); setEstimateFormOpen(true);
}, }}
}} >
/> Add Estimate
</PrimaryButton>
{projectDetails?.estimate && (
<SecondaryButton onClick={disableEstimates}>Disable Estimates</SecondaryButton>
)}
</div>
</div> </div>
) </section>
) : ( {estimatesList ? (
<Loader className="mt-5 space-y-5"> estimatesList.length > 0 ? (
<Loader.Item height="40px" /> <section className="h-full bg-custom-background-100 overflow-y-auto">
<Loader.Item height="40px" /> {estimatesList.map((estimate) => (
<Loader.Item height="40px" /> <SingleEstimate
<Loader.Item height="40px" /> key={estimate.id}
</Loader> estimate={estimate}
)} editEstimate={(estimate) => editEstimate(estimate)}
handleEstimateDelete={(estimateId) => removeEstimate(estimateId)}
user={user}
/>
))}
</section>
) : (
<div className="h-full w-full overflow-y-auto">
<EmptyState
title="No estimates yet"
description="Estimates help you communicate the complexity of an issue."
image={emptyEstimate}
primaryButton={{
icon: <PlusIcon className="h-4 w-4" />,
text: "Add Estimate",
onClick: () => {
setEstimateToUpdate(undefined);
setEstimateFormOpen(true);
},
}}
/>
</div>
)
) : (
<Loader className="mt-5 space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
)}
</div>
</div> </div>
</ProjectAuthorizationWrapper> </ProjectAuthorizationWrapper>
</> </>

View File

@ -13,13 +13,13 @@ import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import useUserAuth from "hooks/use-user-auth"; import useUserAuth from "hooks/use-user-auth";
// components // components
import { SettingsHeader } from "components/project"; import { SettingsSidebar } from "components/project";
// ui // ui
import { SecondaryButton, ToggleSwitch } from "components/ui"; import { ToggleSwitch } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// icons // icons
import { ContrastIcon, PeopleGroupIcon, ViewListIcon, InboxIcon } from "components/icons"; import { ModuleIcon } from "components/icons";
import { DocumentTextIcon } from "@heroicons/react/24/outline"; import { Contrast, FileText, Inbox, Layers } from "lucide-react";
// types // types
import { IProject } from "types"; import { IProject } from "types";
import type { NextPage } from "next"; import type { NextPage } from "next";
@ -33,35 +33,35 @@ const featuresList = [
title: "Cycles", title: "Cycles",
description: description:
"Cycles are enabled for all the projects in this workspace. Access them from the sidebar.", "Cycles are enabled for all the projects in this workspace. Access them from the sidebar.",
icon: <ContrastIcon color="#3f76ff" width={28} height={28} className="flex-shrink-0" />, icon: <Contrast className="h-4 w-4 text-custom-primary-100 flex-shrink-0" />,
property: "cycle_view", property: "cycle_view",
}, },
{ {
title: "Modules", title: "Modules",
description: description:
"Modules are enabled for all the projects in this workspace. Access it from the sidebar.", "Modules are enabled for all the projects in this workspace. Access it from the sidebar.",
icon: <PeopleGroupIcon color="#ff6b00" width={28} height={28} className="flex-shrink-0" />, icon: <ModuleIcon width={16} height={16} className="flex-shrink-0" />,
property: "module_view", property: "module_view",
}, },
{ {
title: "Views", title: "Views",
description: description:
"Views are enabled for all the projects in this workspace. Access it from the sidebar.", "Views are enabled for all the projects in this workspace. Access it from the sidebar.",
icon: <ViewListIcon color="#05c3ff" width={28} height={28} className="flex-shrink-0" />, icon: <Layers className="h-4 w-4 text-cyan-500 flex-shrink-0" />,
property: "issue_views_view", property: "issue_views_view",
}, },
{ {
title: "Pages", title: "Pages",
description: description:
"Pages are enabled for all the projects in this workspace. Access it from the sidebar.", "Pages are enabled for all the projects in this workspace. Access it from the sidebar.",
icon: <DocumentTextIcon color="#fcbe1d" width={28} height={28} className="flex-shrink-0" />, icon: <FileText className="h-4 w-4 text-red-400 flex-shrink-0" />,
property: "page_view", property: "page_view",
}, },
{ {
title: "Inbox", title: "Inbox",
description: description:
"Inbox are enabled for all the projects in this workspace. Access it from the issues views page.", "Inbox are enabled for all the projects in this workspace. Access it from the issues views page.",
icon: <InboxIcon color="#fcbe1d" width={24} height={24} className="flex-shrink-0" />, icon: <Inbox className="h-4 w-4 text-cyan-500 flex-shrink-0" />,
property: "inbox_view", property: "inbox_view",
}, },
]; ];
@ -149,21 +149,29 @@ const FeaturesSettings: NextPage = () => {
</Breadcrumbs> </Breadcrumbs>
} }
> >
<div className="p-8"> <div className="flex flex-row gap-2">
<SettingsHeader /> <div className="w-80 py-8">
<section className="space-y-5"> <SettingsSidebar />
<h3 className="text-2xl font-semibold">Features</h3> </div>
<div className="space-y-5"> <section className="pr-9 py-8 w-full">
<div className="flex items-center py-3.5 border-b border-custom-border-200">
<h3 className="text-xl font-medium">Features</h3>
</div>
<div>
{featuresList.map((feature) => ( {featuresList.map((feature) => (
<div <div
key={feature.property} key={feature.property}
className="flex items-center justify-between gap-x-8 gap-y-2 rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-5" className="flex items-center justify-between gap-x-8 gap-y-2 border-b border-custom-border-200 bg-custom-background-100 p-4"
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{feature.icon} <div className="flex items-center justify-center p-3 rounded bg-custom-background-90">
{feature.icon}
</div>
<div className=""> <div className="">
<h4 className="text-lg font-semibold">{feature.title}</h4> <h4 className="text-sm font-medium">{feature.title}</h4>
<p className="text-sm text-custom-text-200">{feature.description}</p> <p className="text-sm text-custom-text-200 tracking-tight">
{feature.description}
</p>
</div> </div>
</div> </div>
<ToggleSwitch <ToggleSwitch
@ -187,29 +195,11 @@ const FeaturesSettings: NextPage = () => {
[feature.property]: !projectDetails?.[feature.property as keyof IProject], [feature.property]: !projectDetails?.[feature.property as keyof IProject],
}); });
}} }}
size="lg" size="sm"
/> />
</div> </div>
))} ))}
</div> </div>
<div className="flex items-center gap-2 text-custom-text-200">
<a
href="https://plane.so/"
target="_blank"
rel="noreferrer"
className="hover:text-custom-text-100"
>
<SecondaryButton outline>Plane is open-source, view Roadmap</SecondaryButton>
</a>
<a
href="https://github.com/makeplane/plane"
target="_blank"
rel="noreferrer"
className="hover:text-custom-text-100"
>
<SecondaryButton outline>Star us on GitHub</SecondaryButton>
</a>
</div>
</section> </section>
</div> </div>
</ProjectAuthorizationWrapper> </ProjectAuthorizationWrapper>

View File

@ -4,6 +4,8 @@ import { useRouter } from "next/router";
import useSWR, { mutate } from "swr"; import useSWR, { mutate } from "swr";
// headless ui
import { Disclosure, Transition } from "@headlessui/react";
// react-hook-form // react-hook-form
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
// layouts // layouts
@ -11,7 +13,7 @@ import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
// services // services
import projectService from "services/project.service"; import projectService from "services/project.service";
// components // components
import { DeleteProjectModal, SettingsHeader } from "components/project"; import { DeleteProjectModal, SettingsSidebar } from "components/project";
import { ImagePickerPopover } from "components/core"; import { ImagePickerPopover } from "components/core";
import EmojiIconPicker from "components/emoji-icon-picker"; import EmojiIconPicker from "components/emoji-icon-picker";
// hooks // hooks
@ -25,11 +27,14 @@ import {
CustomSelect, CustomSelect,
SecondaryButton, SecondaryButton,
DangerButton, DangerButton,
Icon,
PrimaryButton,
} from "components/ui"; } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// helpers // helpers
import { renderEmoji } from "helpers/emoji.helper"; import { renderEmoji } from "helpers/emoji.helper";
import { truncateText } from "helpers/string.helper"; import { truncateText } from "helpers/string.helper";
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
// types // types
import { IProject, IWorkspace } from "types"; import { IProject, IWorkspace } from "types";
import type { NextPage } from "next"; import type { NextPage } from "next";
@ -185,229 +190,258 @@ const GeneralSettings: NextPage = () => {
onClose={() => setSelectedProject(null)} onClose={() => setSelectedProject(null)}
user={user} user={user}
/> />
<form onSubmit={handleSubmit(onSubmit)} className="p-8"> <form onSubmit={handleSubmit(onSubmit)}>
<SettingsHeader /> <div className="flex flex-row gap-2">
<div className={`space-y-8 sm:space-y-12 ${isAdmin ? "" : "opacity-60"}`}> <div className="w-80 py-8">
<div className="grid grid-cols-12 items-start gap-4 sm:gap-16"> <SettingsSidebar />
<div className="col-span-12 sm:col-span-6"> </div>
<h4 className="text-lg font-semibold">Icon & Name</h4> <div className={`pr-9 py-8 w-full ${isAdmin ? "" : "opacity-60"}`}>
<p className="text-sm text-custom-text-200"> <div className="relative h-44 w-full mt-6">
Select an icon and a name for your project. <img
</p> src={watch("cover_image")!}
</div> alt={watch("cover_image")!}
<div className="col-span-12 flex gap-2 sm:col-span-6"> className="h-44 w-full rounded-md object-cover"
{projectDetails ? ( />
<div className="h-7 w-7 grid place-items-center"> <div className="flex items-end justify-between absolute bottom-4 w-full px-4">
<Controller <div className="flex gap-3">
control={control} <div className="flex items-center justify-center bg-custom-background-90 h-[52px] w-[52px] rounded-lg">
name="emoji_and_icon" {projectDetails ? (
render={({ field: { value, onChange } }) => ( <div className="h-7 w-7 grid place-items-center">
<EmojiIconPicker <Controller
label={value ? renderEmoji(value) : "Icon"} control={control}
value={value} name="emoji_and_icon"
onChange={onChange} render={({ field: { value, onChange } }) => (
disabled={!isAdmin} <EmojiIconPicker
/> label={value ? renderEmoji(value) : "Icon"}
value={value}
onChange={onChange}
disabled={!isAdmin}
/>
)}
/>
</div>
) : (
<Loader>
<Loader.Item height="46px" width="46px" />
</Loader>
)} )}
/> </div>
</div> <div className="flex flex-col gap-1 text-white">
) : ( <span className="text-lg font-semibold">{watch("name")}</span>
<Loader> <span className="flex items-center gap-2 text-sm">
<Loader.Item height="46px" width="46px" /> <span>
</Loader> {watch("identifier")} . {currentNetwork?.label}
)} </span>
{projectDetails ? ( </span>
<Input
id="name"
name="name"
error={errors.name}
register={register}
placeholder="Project Name"
validations={{
required: "Name is required",
}}
disabled={!isAdmin}
/>
) : (
<Loader>
<Loader.Item height="46px" width="225px" />
</Loader>
)}
</div>
</div>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">Description</h4>
<p className="text-sm text-custom-text-200">Give a description to your project.</p>
</div>
<div className="col-span-12 sm:col-span-6">
{projectDetails ? (
<TextArea
id="description"
name="description"
error={errors.description}
register={register}
placeholder="Enter project description"
validations={{}}
className="min-h-[46px] text-sm"
disabled={!isAdmin}
/>
) : (
<Loader className="w-full">
<Loader.Item height="46px" width="full" />
</Loader>
)}
</div>
</div>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">Cover Photo</h4>
<p className="text-sm text-custom-text-200">
Select your cover photo from the given library.
</p>
</div>
<div className="col-span-12 sm:col-span-6">
{watch("cover_image") ? (
<div className="h-32 w-full rounded border border-custom-border-200 p-1">
<div className="relative h-full w-full rounded">
<img
src={watch("cover_image")!}
className="absolute top-0 left-0 h-full w-full object-cover rounded"
alt={projectDetails?.name ?? "Cover image"}
/>
<div className="absolute bottom-0 flex w-full justify-end">
<ImagePickerPopover
label={"Change cover"}
onChange={(imageUrl) => {
setValue("cover_image", imageUrl);
}}
value={watch("cover_image")}
disabled={!isAdmin}
/>
</div>
</div> </div>
</div> </div>
) : (
<Loader className="w-full">
<Loader.Item height="46px" width="full" />
</Loader>
)}
</div>
</div>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">Identifier</h4>
<p className="text-sm text-custom-text-200">
Create a 1-6 characters{"'"} identifier for the project.
</p>
</div>
<div className="col-span-12 sm:col-span-6">
{projectDetails ? (
<Input
id="identifier"
name="identifier"
error={errors.identifier}
register={register}
placeholder="Enter identifier"
onChange={handleIdentifierChange}
validations={{
required: "Identifier is required",
validate: (value) =>
/^[A-Z0-9]+$/.test(value.toUpperCase()) || "Identifier must be in uppercase.",
minLength: {
value: 1,
message: "Identifier must at least be of 1 character",
},
maxLength: {
value: 5,
message: "Identifier must at most be of 5 characters",
},
}}
disabled={!isAdmin}
/>
) : (
<Loader>
<Loader.Item height="46px" width="160px" />
</Loader>
)}
</div>
</div>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">Network</h4>
<p className="text-sm text-custom-text-200">Select privacy type for the project.</p>
</div>
<div className="col-span-12 sm:col-span-6">
{projectDetails ? (
<Controller
name="network"
control={control}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={currentNetwork?.label ?? "Select network"}
input
disabled={!isAdmin}
>
{NETWORK_CHOICES.map((network) => (
<CustomSelect.Option key={network.key} value={network.key}>
{network.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
) : (
<Loader className="w-full">
<Loader.Item height="46px" width="160px" />
</Loader>
)}
</div>
</div>
{isAdmin && ( <div className="flex justify-center">
<> {projectDetails ? (
<div className="sm:text-right"> <div>
<Controller
control={control}
name="cover_image"
render={({ field: { value, onChange } }) => (
<ImagePickerPopover
label={"Change cover"}
onChange={(imageUrl) => {
setValue("cover_image", imageUrl);
}}
value={watch("cover_image")}
disabled={!isAdmin}
/>
)}
/>
</div>
) : (
<Loader>
<Loader.Item height="32px" width="108px" />
</Loader>
)}
</div>
</div>
</div>
<div className="flex flex-col gap-8 my-8">
<div className="flex flex-col gap-1">
<h4 className="text-sm">Project Name</h4>
{projectDetails ? ( {projectDetails ? (
<SecondaryButton type="submit" loading={isSubmitting} disabled={!isAdmin}> <Input
{isSubmitting ? "Updating Project..." : "Update Project"} id="name"
</SecondaryButton> name="name"
error={errors.name}
register={register}
className="!p-3 rounded-md font-medium"
placeholder="Project Name"
validations={{
required: "Name is required",
}}
disabled={!isAdmin}
/>
) : (
<Loader>
<Loader.Item height="46px" width="100%" />
</Loader>
)}
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm">Description</h4>
{projectDetails ? (
<TextArea
id="description"
name="description"
error={errors.description}
register={register}
placeholder="Enter project description"
validations={{}}
className="min-h-[102px] text-sm"
disabled={!isAdmin}
/>
) : (
<Loader className="w-full">
<Loader.Item height="102px" width="full" />
</Loader>
)}
</div>
<div className="flex items-center justify-between gap-10 w-full">
<div className="flex flex-col gap-1 w-1/2">
<h4 className="text-sm">Identifier</h4>
{projectDetails ? (
<Input
id="identifier"
name="identifier"
error={errors.identifier}
register={register}
placeholder="Enter identifier"
onChange={handleIdentifierChange}
validations={{
required: "Identifier is required",
validate: (value) =>
/^[A-Z0-9]+$/.test(value.toUpperCase()) ||
"Identifier must be in uppercase.",
minLength: {
value: 1,
message: "Identifier must at least be of 1 character",
},
maxLength: {
value: 5,
message: "Identifier must at most be of 5 characters",
},
}}
disabled={!isAdmin}
/>
) : (
<Loader>
<Loader.Item height="36px" width="100%" />
</Loader>
)}
</div>
<div className="flex flex-col gap-1 w-1/2">
<h4 className="text-sm">Network</h4>
{projectDetails ? (
<Controller
name="network"
control={control}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={currentNetwork?.label ?? "Select network"}
className="!border-custom-border-200 !shadow-none"
input
disabled={!isAdmin}
>
{NETWORK_CHOICES.map((network) => (
<CustomSelect.Option key={network.key} value={network.key}>
{network.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
) : (
<Loader className="w-full">
<Loader.Item height="46px" width="100%" />
</Loader>
)}
</div>
</div>
<div className="flex items-center justify-between py-2">
{projectDetails ? (
<>
<PrimaryButton type="submit" loading={isSubmitting} disabled={!isAdmin}>
{isSubmitting ? "Updating Project..." : "Update Project"}
</PrimaryButton>
<span className="text-sm text-custom-sidebar-text-400 italic">
Created on {renderShortDateWithYearFormat(projectDetails?.created_at)}
</span>
</>
) : ( ) : (
<Loader className="mt-2 w-full"> <Loader className="mt-2 w-full">
<Loader.Item height="34px" width="100px" /> <Loader.Item height="34px" width="100px" />
</Loader> </Loader>
)} )}
</div> </div>
<div className="grid grid-cols-12 gap-4 sm:gap-16"> </div>
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">Danger Zone</h4> <Disclosure as="div" className="border-t border-custom-border-400">
<p className="text-sm text-custom-text-200"> {({ open }) => (
The danger zone of the project delete page is a critical area that requires <div className="w-full">
careful consideration and attention. When deleting a project, all of the data <Disclosure.Button
and resources within that project will be permanently removed and cannot be as="button"
recovered. type="button"
</p> className="flex items-center justify-between w-full py-4"
>
<span className="text-xl tracking-tight">Danger Zone</span>
<Icon iconName={open ? "expand_more" : "expand_less"} className="!text-2xl" />
</Disclosure.Button>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<Disclosure.Panel>
<div className="flex flex-col gap-8">
<span className="text-sm tracking-tight">
The danger zone of the project delete page is a critical area that
requires careful consideration and attention. When deleting a project, all
of the data and resources within that project will be permanently removed
and cannot be recovered.
</span>
<div>
{projectDetails ? (
<div>
<DangerButton
onClick={() => setSelectedProject(projectDetails.id ?? null)}
className="!text-sm"
outline
>
Delete my project
</DangerButton>
</div>
) : (
<Loader className="mt-2 w-full">
<Loader.Item height="38px" width="144px" />
</Loader>
)}
</div>
</div>
</Disclosure.Panel>
</Transition>
</div> </div>
<div className="col-span-12 sm:col-span-6"> )}
{projectDetails ? ( </Disclosure>
<div> </div>
<DangerButton
onClick={() => setSelectedProject(projectDetails.id ?? null)}
outline
>
Delete Project
</DangerButton>
</div>
) : (
<Loader className="mt-2 w-full">
<Loader.Item height="46px" width="100px" />
</Loader>
)}
</div>
</div>
</>
)}
</div> </div>
</form> </form>
</ProjectAuthorizationWrapper> </ProjectAuthorizationWrapper>

View File

@ -10,7 +10,7 @@ import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
import IntegrationService from "services/integration"; import IntegrationService from "services/integration";
import projectService from "services/project.service"; import projectService from "services/project.service";
// components // components
import { SettingsHeader, SingleIntegration } from "components/project"; import { SettingsSidebar, SingleIntegration } from "components/project";
// ui // ui
import { EmptyState, IntegrationAndImportExportBanner, Loader } from "components/ui"; import { EmptyState, IntegrationAndImportExportBanner, Loader } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
@ -58,13 +58,15 @@ const ProjectIntegrations: NextPage = () => {
</Breadcrumbs> </Breadcrumbs>
} }
> >
<div className="h-full flex flex-col p-8 overflow-hidden"> <div className="flex flex-row gap-2 h-full overflow-hidden">
<SettingsHeader /> <div className="w-80 py-8">
<SettingsSidebar />
</div>
{workspaceIntegrations ? ( {workspaceIntegrations ? (
workspaceIntegrations.length > 0 ? ( workspaceIntegrations.length > 0 ? (
<section className="space-y-8 overflow-y-auto"> <section className="pr-9 py-8 overflow-y-auto w-full">
<IntegrationAndImportExportBanner bannerName="Integrations" /> <IntegrationAndImportExportBanner bannerName="Integrations" />
<div className="space-y-5"> <div>
{workspaceIntegrations.map((integration) => ( {workspaceIntegrations.map((integration) => (
<SingleIntegration <SingleIntegration
key={integration.integration_detail.id} key={integration.integration_detail.id}

View File

@ -19,7 +19,7 @@ import {
SingleLabel, SingleLabel,
SingleLabelGroup, SingleLabelGroup,
} from "components/labels"; } from "components/labels";
import { SettingsHeader } from "components/project"; import { SettingsSidebar } from "components/project";
// ui // ui
import { EmptyState, Loader, PrimaryButton } from "components/ui"; import { EmptyState, Loader, PrimaryButton } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
@ -113,20 +113,23 @@ const LabelsSettings: NextPage = () => {
</Breadcrumbs> </Breadcrumbs>
} }
> >
<div className="p-8"> <div className="flex flex-row gap-2">
<SettingsHeader /> <div className="w-80 py-8">
<section className="grid grid-cols-12 gap-10"> <SettingsSidebar />
<div className="col-span-12 sm:col-span-5"> </div>
<h3 className="text-2xl font-semibold">Labels</h3> <section className="pr-9 py-8 gap-10 w-full">
<p className="text-custom-text-200">Manage the labels of this project.</p> <div className="flex items-center justify-between pt-2 pb-3.5 border-b border-custom-border-200">
<PrimaryButton onClick={newLabel} size="sm" className="mt-4"> <h3 className="text-xl font-medium">Labels</h3>
<span className="flex items-center gap-2">
<PlusIcon className="h-4 w-4" /> <PrimaryButton
New label onClick={newLabel}
</span> size="sm"
className="flex items-center justify-center"
>
Add label
</PrimaryButton> </PrimaryButton>
</div> </div>
<div className="col-span-12 space-y-5 sm:col-span-7"> <div className="space-y-3 py-6">
{labelForm && ( {labelForm && (
<CreateUpdateLabelInline <CreateUpdateLabelInline
labelForm={labelForm} labelForm={labelForm}

View File

@ -1,9 +1,9 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import Link from "next/link"; import Link from "next/link";
import useSWR from "swr"; import useSWR, { mutate } from "swr";
// services // services
import projectService from "services/project.service"; import projectService from "services/project.service";
@ -13,22 +13,35 @@ import useToast from "hooks/use-toast";
import useUser from "hooks/use-user"; import useUser from "hooks/use-user";
import useProjectMembers from "hooks/use-project-members"; import useProjectMembers from "hooks/use-project-members";
import useProjectDetails from "hooks/use-project-details"; import useProjectDetails from "hooks/use-project-details";
import { Controller, useForm } from "react-hook-form";
// layouts // layouts
import { ProjectAuthorizationWrapper } from "layouts/auth-layout"; import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
// components // components
import ConfirmProjectMemberRemove from "components/project/confirm-project-member-remove"; import ConfirmProjectMemberRemove from "components/project/confirm-project-member-remove";
import SendProjectInvitationModal from "components/project/send-project-invitation-modal"; import SendProjectInvitationModal from "components/project/send-project-invitation-modal";
import { SettingsHeader } from "components/project"; import { MemberSelect, SettingsSidebar } from "components/project";
// ui // ui
import { CustomMenu, CustomSelect, Loader } from "components/ui"; import {
CustomMenu,
CustomSearchSelect,
CustomSelect,
Icon,
Loader,
PrimaryButton,
SecondaryButton,
} from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// icons // icons
import { PlusIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { PlusIcon, XMarkIcon } from "@heroicons/react/24/outline";
// types // types
import type { NextPage } from "next"; import type { NextPage } from "next";
import { IProject, IUserLite, IWorkspace } from "types";
// fetch-keys // fetch-keys
import { import {
PROJECTS_LIST,
PROJECT_DETAILS,
PROJECT_INVITATIONS_WITH_EMAIL, PROJECT_INVITATIONS_WITH_EMAIL,
PROJECT_MEMBERS,
PROJECT_MEMBERS_WITH_EMAIL, PROJECT_MEMBERS_WITH_EMAIL,
WORKSPACE_DETAILS, WORKSPACE_DETAILS,
} from "constants/fetch-keys"; } from "constants/fetch-keys";
@ -37,6 +50,11 @@ import { ROLE } from "constants/workspace";
// helper // helper
import { truncateText } from "helpers/string.helper"; import { truncateText } from "helpers/string.helper";
const defaultValues: Partial<IProject> = {
project_lead: null,
default_assignee: null,
};
const MembersSettings: NextPage = () => { const MembersSettings: NextPage = () => {
const [inviteModal, setInviteModal] = useState(false); const [inviteModal, setInviteModal] = useState(false);
const [selectedRemoveMember, setSelectedRemoveMember] = useState<string | null>(null); const [selectedRemoveMember, setSelectedRemoveMember] = useState<string | null>(null);
@ -55,11 +73,25 @@ const MembersSettings: NextPage = () => {
Boolean(workspaceSlug && projectId) Boolean(workspaceSlug && projectId)
); );
const {
handleSubmit,
reset,
control,
formState: { isSubmitting },
} = useForm<IProject>({ defaultValues });
const { data: activeWorkspace } = useSWR( const { data: activeWorkspace } = useSWR(
workspaceSlug ? WORKSPACE_DETAILS(workspaceSlug as string) : null, workspaceSlug ? WORKSPACE_DETAILS(workspaceSlug as string) : null,
() => (workspaceSlug ? workspaceService.getWorkspace(workspaceSlug as string) : null) () => (workspaceSlug ? workspaceService.getWorkspace(workspaceSlug as string) : null)
); );
const { data: people } = useSWR(
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
: null
);
const { data: projectMembers, mutate: mutateMembers } = useSWR( const { data: projectMembers, mutate: mutateMembers } = useSWR(
workspaceSlug && projectId workspaceSlug && projectId
? PROJECT_MEMBERS_WITH_EMAIL(workspaceSlug.toString(), projectId.toString()) ? PROJECT_MEMBERS_WITH_EMAIL(workspaceSlug.toString(), projectId.toString())
@ -110,6 +142,76 @@ const MembersSettings: NextPage = () => {
const handleProjectInvitationSuccess = () => {}; const handleProjectInvitationSuccess = () => {};
const onSubmit = async (formData: IProject) => {
if (!workspaceSlug || !projectId || !projectDetails) return;
const payload: Partial<IProject> = {
default_assignee: formData.default_assignee,
project_lead: formData.project_lead === "none" ? null : formData.project_lead,
};
await projectService
.updateProject(workspaceSlug as string, projectId as string, payload, user)
.then((res) => {
mutate(PROJECT_DETAILS(projectId as string));
mutate(
PROJECTS_LIST(workspaceSlug as string, {
is_favorite: "all",
})
);
setToastAlert({
title: "Success",
type: "success",
message: "Project updated successfully",
});
})
.catch((err) => {
console.log(err);
});
};
useEffect(() => {
if (projectDetails)
reset({
...projectDetails,
default_assignee: projectDetails.default_assignee?.id ?? projectDetails.default_assignee,
project_lead: (projectDetails.project_lead as IUserLite)?.id ?? projectDetails.project_lead,
workspace: (projectDetails.workspace as IWorkspace).id,
});
}, [projectDetails, reset]);
const submitChanges = async (formData: Partial<IProject>) => {
if (!workspaceSlug || !projectId) return;
const payload: Partial<IProject> = {
default_assignee: formData.default_assignee === "none" ? null : formData.default_assignee,
project_lead: formData.project_lead === "none" ? null : formData.project_lead,
};
await projectService
.updateProject(workspaceSlug as string, projectId as string, payload, user)
.then((res) => {
mutate(PROJECT_DETAILS(projectId as string));
mutate(
PROJECTS_LIST(workspaceSlug as string, {
is_favorite: "all",
})
);
setToastAlert({
title: "Success",
type: "success",
message: "Project updated successfully",
});
})
.catch((err) => {
console.log(err);
});
};
return ( return (
<ProjectAuthorizationWrapper <ProjectAuthorizationWrapper
breadcrumbs={ breadcrumbs={
@ -171,19 +273,69 @@ const MembersSettings: NextPage = () => {
user={user} user={user}
onSuccess={() => mutateMembers()} onSuccess={() => mutateMembers()}
/> />
<div className="p-8"> <div className="flex flex-row gap-2">
<SettingsHeader /> <div className="w-80 py-8">
<section className="space-y-5"> <SettingsSidebar />
<div className="flex items-end justify-between gap-4"> </div>
<h3 className="text-2xl font-semibold">Members</h3> <section className="pr-9 py-8 w-full">
<button <div className="flex items-center py-3.5 border-b border-custom-border-200">
type="button" <h3 className="text-xl font-medium">Defaults</h3>
className="flex items-center gap-2 text-custom-primary outline-none" </div>
onClick={() => setInviteModal(true)} <div className="flex flex-col gap-2 pb-4 w-full">
> <div className="flex items-center py-8 gap-4 w-full">
<PlusIcon className="h-4 w-4" /> <div className="flex flex-col gap-2 w-1/2">
Add Member <h4 className="text-sm">Project Lead</h4>
</button> <div className="">
{projectDetails ? (
<Controller
control={control}
name="project_lead"
render={({ field: { value } }) => (
<MemberSelect
value={value}
onChange={(val: string) => {
submitChanges({ project_lead: val });
}}
/>
)}
/>
) : (
<Loader className="h-9 w-full">
<Loader.Item width="100%" height="100%" />
</Loader>
)}
</div>
</div>
<div className="flex flex-col gap-2 w-1/2">
<h4 className="text-sm">Default Assignee</h4>
<div className="">
{projectDetails ? (
<Controller
control={control}
name="default_assignee"
render={({ field: { value } }) => (
<MemberSelect
value={value}
onChange={(val: string) => {
submitChanges({ default_assignee: val });
}}
/>
)}
/>
) : (
<Loader className="h-9 w-full">
<Loader.Item width="100%" height="100%" />
</Loader>
)}
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between gap-4 py-3.5 border-b border-custom-border-200">
<h4 className="text-xl font-medium border-b border-custom-border-100">Members</h4>
<PrimaryButton onClick={() => setInviteModal(true)}>Add Member</PrimaryButton>
</div> </div>
{!projectMembers || !projectInvitations ? ( {!projectMembers || !projectInvitations ? (
<Loader className="space-y-5"> <Loader className="space-y-5">
@ -193,10 +345,13 @@ const MembersSettings: NextPage = () => {
<Loader.Item height="40px" /> <Loader.Item height="40px" />
</Loader> </Loader>
) : ( ) : (
<div className="divide-y divide-custom-border-200 rounded-[10px] border border-custom-border-200 bg-custom-background-100 px-6"> <div className="divide-y divide-custom-border-200">
{members.length > 0 {members.length > 0
? members.map((member) => ( ? members.map((member) => (
<div key={member.id} className="flex items-center justify-between py-6"> <div
key={member.id}
className="flex items-center justify-between px-3.5 py-[18px]"
>
<div className="flex items-center gap-x-6 gap-y-2"> <div className="flex items-center gap-x-6 gap-y-2">
{member.avatar && member.avatar !== "" ? ( {member.avatar && member.avatar !== "" ? (
<div className="relative flex h-10 w-10 items-center justify-center rounded-lg p-4 capitalize text-white"> <div className="relative flex h-10 w-10 items-center justify-center rounded-lg p-4 capitalize text-white">
@ -242,7 +397,20 @@ const MembersSettings: NextPage = () => {
</div> </div>
)} )}
<CustomSelect <CustomSelect
label={ROLE[member.role as keyof typeof ROLE]} customButton={
<button className="flex item-center gap-1">
<span
className={`flex items-center text-sm font-medium ${
member.memberId !== user?.id ? "" : "text-custom-sidebar-text-400"
}`}
>
{ROLE[member.role as keyof typeof ROLE]}
</span>
{member.memberId !== user?.id && (
<Icon iconName="expand_more" className="text-lg font-medium" />
)}
</button>
}
value={member.role} value={member.role}
onChange={(value: 5 | 10 | 15 | 20 | undefined) => { onChange={(value: 5 | 10 | 15 | 20 | undefined) => {
if (!activeWorkspace || !projectDetails) return; if (!activeWorkspace || !projectDetails) return;
@ -306,7 +474,11 @@ const MembersSettings: NextPage = () => {
> >
<span className="flex items-center justify-start gap-2"> <span className="flex items-center justify-start gap-2">
<XMarkIcon className="h-4 w-4" /> <XMarkIcon className="h-4 w-4" />
<span>Remove member</span>
<span>
{" "}
{member.memberId !== user?.id ? "Remove member" : "Leave project"}
</span>
</span> </span>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
</CustomMenu> </CustomMenu>

View File

@ -18,7 +18,7 @@ import {
SingleState, SingleState,
StateGroup, StateGroup,
} from "components/states"; } from "components/states";
import { SettingsHeader } from "components/project"; import { SettingsSidebar } from "components/project";
// ui // ui
import { Loader } from "components/ui"; import { Loader } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
@ -73,31 +73,33 @@ const StatesSettings: NextPage = () => {
</Breadcrumbs> </Breadcrumbs>
} }
> >
<div className="p-8"> <div className="flex flex-row gap-2">
<SettingsHeader /> <div className="w-80 py-8">
<div className="grid grid-cols-12 gap-10"> <SettingsSidebar />
<div className="col-span-12 sm:col-span-5"> </div>
<h3 className="text-2xl font-semibold text-custom-text-100">States</h3> <div className="pr-9 py-8 gap-10 w-full">
<p className="text-custom-text-200">Manage the states of this project.</p> <div className="flex items-center py-3.5 border-b border-custom-border-200">
<h3 className="text-xl font-medium">States</h3>
</div> </div>
<div className="col-span-12 space-y-8 sm:col-span-7"> <div className="space-y-8 py-6">
{states && projectDetails && orderedStateGroups ? ( {states && projectDetails && orderedStateGroups ? (
Object.keys(orderedStateGroups).map((key) => { Object.keys(orderedStateGroups).map((key) => {
if (orderedStateGroups[key].length !== 0) if (orderedStateGroups[key].length !== 0)
return ( return (
<div key={key}> <div key={key} className="flex flex-col gap-2">
<div className="mb-2 flex w-full justify-between"> <div className="flex w-full justify-between">
<h4 className="text-custom-text-200 capitalize">{key}</h4> <h4 className="text-base font-medium text-custom-text-200 capitalize">
{key}
</h4>
<button <button
type="button" type="button"
className="flex items-center gap-2 text-custom-primary-100 hover:text-custom-primary-200 outline-none" className="flex items-center gap-2 text-custom-primary-100 px-2 hover:text-custom-primary-200 outline-none"
onClick={() => setActiveGroup(key as keyof StateGroup)} onClick={() => setActiveGroup(key as keyof StateGroup)}
> >
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
Add
</button> </button>
</div> </div>
<div className="divide-y divide-custom-border-200 rounded-[10px] border border-custom-border-200"> <div className="flex flex-col gap-2 rounded">
{key === activeGroup && ( {key === activeGroup && (
<CreateUpdateStateInline <CreateUpdateStateInline
groupLength={orderedStateGroups[key].length} groupLength={orderedStateGroups[key].length}

View File

@ -2,7 +2,7 @@
import APIService from "services/api.service"; import APIService from "services/api.service";
import trackEventServices from "./track-event.service"; import trackEventServices from "./track-event.service";
// types // types
import type { IIssueViewOptions, IModule, IIssue, ICurrentUserResponse } from "types"; import type { IModule, IIssue, ICurrentUserResponse } from "types";
import { API_BASE_URL } from "helpers/common.helper"; import { API_BASE_URL } from "helpers/common.helper";
const { NEXT_PUBLIC_API_BASE_URL } = process.env; const { NEXT_PUBLIC_API_BASE_URL } = process.env;