mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
feat: pages (#533)
* style: page details * style: page blocks design * chore: pages list end points * feat: add blocks, push blocks to issues * feat: page labels, color options * feat: added labels to pages * fix: update page mutation
This commit is contained in:
parent
578d724e41
commit
5d67029b5a
24
apps/app/components/icons/color-pallette-icon.tsx
Normal file
24
apps/app/components/icons/color-pallette-icon.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
|
||||
import type { Props } from "./types";
|
||||
|
||||
export const ColorPalletteIcon: React.FC<Props> = ({
|
||||
width = "20",
|
||||
height = "20",
|
||||
className,
|
||||
color = "#858e96",
|
||||
}) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M9 16.5C7.975 16.5 7.00625 16.3031 6.09375 15.9094C5.18125 15.5156 4.38437 14.9781 3.70312 14.2969C3.02187 13.6156 2.48437 12.8187 2.09062 11.9062C1.69687 10.9937 1.5 10.025 1.5 9C1.5 7.9375 1.7 6.95 2.1 6.0375C2.5 5.125 3.04687 4.33125 3.74062 3.65625C4.43437 2.98125 5.24687 2.45312 6.17812 2.07187C7.10937 1.69062 8.10625 1.5 9.16875 1.5C10.1562 1.5 11.0937 1.66563 11.9812 1.99687C12.8687 2.32812 13.6469 2.7875 14.3156 3.375C14.9844 3.9625 15.5156 4.65937 15.9094 5.46562C16.3031 6.27187 16.5 7.15625 16.5 8.11875C16.5 9.46875 16.1062 10.5344 15.3187 11.3156C14.5312 12.0969 13.4875 12.4875 12.1875 12.4875H10.7812C10.5562 12.4875 10.3625 12.575 10.2 12.75C10.0375 12.925 9.95625 13.1187 9.95625 13.3312C9.95625 13.6687 10.0469 13.9562 10.2281 14.1937C10.4094 14.4312 10.5 14.7062 10.5 15.0187C10.5 15.4937 10.3687 15.8594 10.1062 16.1156C9.84375 16.3719 9.475 16.5 9 16.5ZM4.63125 9.4875C4.88125 9.4875 5.1 9.39375 5.2875 9.20625C5.475 9.01875 5.56875 8.8 5.56875 8.55C5.56875 8.3 5.475 8.08125 5.2875 7.89375C5.1 7.70625 4.88125 7.6125 4.63125 7.6125C4.38125 7.6125 4.1625 7.70625 3.975 7.89375C3.7875 8.08125 3.69375 8.3 3.69375 8.55C3.69375 8.8 3.7875 9.01875 3.975 9.20625C4.1625 9.39375 4.38125 9.4875 4.63125 9.4875ZM6.99375 6.3C7.24375 6.3 7.4625 6.20625 7.65 6.01875C7.8375 5.83125 7.93125 5.6125 7.93125 5.3625C7.93125 5.1125 7.8375 4.89375 7.65 4.70625C7.4625 4.51875 7.24375 4.425 6.99375 4.425C6.74375 4.425 6.525 4.51875 6.3375 4.70625C6.15 4.89375 6.05625 5.1125 6.05625 5.3625C6.05625 5.6125 6.15 5.83125 6.3375 6.01875C6.525 6.20625 6.74375 6.3 6.99375 6.3ZM11.0062 6.3C11.2562 6.3 11.475 6.20625 11.6625 6.01875C11.85 5.83125 11.9437 5.6125 11.9437 5.3625C11.9437 5.1125 11.85 4.89375 11.6625 4.70625C11.475 4.51875 11.2562 4.425 11.0062 4.425C10.7562 4.425 10.5375 4.51875 10.35 4.70625C10.1625 4.89375 10.0687 5.1125 10.0687 5.3625C10.0687 5.6125 10.1625 5.83125 10.35 6.01875C10.5375 6.20625 10.7562 6.3 11.0062 6.3ZM13.4625 9.4875C13.7125 9.4875 13.9312 9.39375 14.1187 9.20625C14.3062 9.01875 14.4 8.8 14.4 8.55C14.4 8.3 14.3062 8.08125 14.1187 7.89375C13.9312 7.70625 13.7125 7.6125 13.4625 7.6125C13.2125 7.6125 12.9937 7.70625 12.8062 7.89375C12.6187 8.08125 12.525 8.3 12.525 8.55C12.525 8.8 12.6187 9.01875 12.8062 9.20625C12.9937 9.39375 13.2125 9.4875 13.4625 9.4875ZM9 15.375C9.1375 15.375 9.23437 15.3469 9.29062 15.2906C9.34687 15.2344 9.375 15.1437 9.375 15.0187C9.375 14.8437 9.28437 14.6812 9.10312 14.5312C8.92187 14.3812 8.83125 14.05 8.83125 13.5375C8.83125 12.9625 9.01875 12.4562 9.39375 12.0187C9.76875 11.5812 10.2437 11.3625 10.8187 11.3625H12.1875C13.1375 11.3625 13.9062 11.0844 14.4937 10.5281C15.0812 9.97187 15.375 9.16875 15.375 8.11875C15.375 6.46875 14.75 5.14062 13.5 4.13437C12.25 3.12812 10.8062 2.625 9.16875 2.625C7.34375 2.625 5.79687 3.24062 4.52812 4.47187C3.25937 5.70312 2.625 7.2125 2.625 9C2.625 10.7625 3.24687 12.2656 4.49062 13.5094C5.73438 14.7531 7.2375 15.375 9 15.375Z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
);
|
@ -7,6 +7,7 @@ export * from "./calendar-month-icon";
|
||||
export * from "./cancel-icon";
|
||||
export * from "./cancelled-state-icon";
|
||||
export * from "./clipboard-icon";
|
||||
export * from "./color-pallette-icon";
|
||||
export * from "./comment-icon";
|
||||
export * from "./completed-cycle-icon";
|
||||
export * from "./completed-state-icon";
|
||||
@ -23,6 +24,7 @@ export * from "./started-state-icon";
|
||||
export * from "./layer-diagonal-icon";
|
||||
export * from "./lock-icon";
|
||||
export * from "./menu-icon";
|
||||
export * from "./pencil-scribble-icon";
|
||||
export * from "./plus-icon";
|
||||
export * from "./priority-icon";
|
||||
export * from "./question-mark-circle-icon";
|
||||
@ -52,3 +54,4 @@ export * from "./cloud-upload";
|
||||
export * from "./users";
|
||||
export * from "./import-layers";
|
||||
export * from "./check";
|
||||
export * from "./water-drop-icon";
|
||||
|
23
apps/app/components/icons/pencil-scribble-icon.tsx
Normal file
23
apps/app/components/icons/pencil-scribble-icon.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
|
||||
import type { Props } from "./types";
|
||||
|
||||
export const PencilScribbleIcon: React.FC<Props> = ({
|
||||
width = "20",
|
||||
height = "20",
|
||||
className,
|
||||
color = "#000000",
|
||||
}) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 96 960 960"
|
||||
>
|
||||
<path
|
||||
d="M560 936q-12 0-21-9t-9-21q0-13 9-21.5t21-8.5q59 0 99.5-24t40.5-56q0-23-29.5-45T591 717l47-47q63 19 92.5 52.5T760 796q0 67-61 103.5T560 936ZM240 642q-64-14-92-44t-28-62q0-35 26-63t120-62q66-24 85-39t19-35q0-25-22-43t-68-18q-27 0-46 7t-34 22q-8 8-20.5 9.5T157 308q-11-8-11.5-20t7.5-21q17-22 51-36.5t76-14.5q68 0 109 32.5t41 88.5q0 41-28.5 69.5T290 466q-67 25-88.5 39.5T180 536q0 16 27 30.5t81 27.5l-48 48Zm496-154L608 360l45-45q18-18 40-18t40 18l48 48q18 18 18 40t-18 40l-45 45ZM220 876h42l345-345-42-42-345 345v42Zm-60 60V808l405-405 128 128-405 405H160Zm405-447 42 42-42-42Z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
);
|
24
apps/app/components/icons/water-drop-icon.tsx
Normal file
24
apps/app/components/icons/water-drop-icon.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
|
||||
import type { Props } from "./types";
|
||||
|
||||
export const WaterDropIcon: React.FC<Props> = ({
|
||||
width = "24",
|
||||
height = "24",
|
||||
className,
|
||||
color = "#858e96",
|
||||
}) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 14 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M7.00016 17.334C5.23627 17.334 3.68419 16.7194 2.34391 15.4902C1.00363 14.2611 0.333496 12.5979 0.333496 10.5007C0.333496 9.16732 0.843913 7.7194 1.86475 6.1569C2.88558 4.5944 4.43766 2.90343 6.521 1.08398C6.59044 1.02843 6.66683 0.98329 6.75016 0.948568C6.8335 0.913845 6.91683 0.896484 7.00016 0.896484C7.0835 0.896484 7.16683 0.913845 7.25016 0.948568C7.3335 0.98329 7.40988 1.02843 7.47933 1.08398C9.56266 2.90343 11.1147 4.5944 12.1356 6.1569C13.1564 7.7194 13.6668 9.16732 13.6668 10.5007C13.6668 12.5979 12.9967 14.2611 11.6564 15.4902C10.3161 16.7194 8.76405 17.334 7.00016 17.334ZM7.00016 16.084C8.52794 16.084 9.81266 15.5562 10.8543 14.5007C11.896 13.4451 12.4168 12.1118 12.4168 10.5007C12.4168 9.40343 11.955 8.1569 11.0314 6.76107C10.1078 5.36523 8.76405 3.88954 7.00016 2.33398C5.23627 3.88954 3.89252 5.36523 2.96891 6.76107C2.0453 8.1569 1.5835 9.40343 1.5835 10.5007C1.5835 12.1118 2.10433 13.4451 3.146 14.5007C4.18766 15.5562 5.47238 16.084 7.00016 16.084ZM6.97933 14.6673C7.20155 14.6673 7.37169 14.6291 7.48975 14.5527C7.6078 14.4763 7.66683 14.3618 7.66683 14.209C7.66683 14.0562 7.6078 13.9382 7.48975 13.8548C7.37169 13.7715 7.19461 13.7298 6.9585 13.7298C6.37516 13.7298 5.78141 13.5458 5.17725 13.1777C4.57308 12.8097 4.18766 12.1604 4.021 11.2298C3.99322 11.1048 3.93072 11.0041 3.8335 10.9277C3.73627 10.8513 3.63211 10.8132 3.521 10.8132C3.36822 10.8132 3.25016 10.8722 3.16683 10.9902C3.0835 11.1083 3.05572 11.2298 3.0835 11.3548C3.29183 12.5215 3.78488 13.3652 4.56266 13.8861C5.34044 14.4069 6.146 14.6673 6.97933 14.6673Z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
);
|
@ -49,174 +49,170 @@ export const IssueLabelSelect: React.FC<Props> = ({ setIsOpen, value, onChange,
|
||||
: issueLabels?.filter((l) => l.name.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Combobox
|
||||
as="div"
|
||||
value={value}
|
||||
onChange={(val) => onChange(val)}
|
||||
className="relative flex-shrink-0"
|
||||
multiple
|
||||
>
|
||||
{({ open }: any) => (
|
||||
<>
|
||||
<Combobox.Button
|
||||
className={({ open }) =>
|
||||
`flex cursor-pointer items-center rounded-md border text-xs shadow-sm duration-200
|
||||
<Combobox
|
||||
as="div"
|
||||
value={value}
|
||||
onChange={(val) => onChange(val)}
|
||||
className="relative flex-shrink-0"
|
||||
multiple
|
||||
>
|
||||
{({ open }: any) => (
|
||||
<>
|
||||
<Combobox.Button
|
||||
className={({ open }) =>
|
||||
`flex cursor-pointer items-center rounded-md border text-xs shadow-sm duration-200
|
||||
${
|
||||
open
|
||||
? "border-theme bg-theme/5 outline-none ring-1 ring-theme "
|
||||
: "hover:bg-theme/5 "
|
||||
}`
|
||||
}
|
||||
>
|
||||
{value && value.length > 0 ? (
|
||||
<span className="flex items-center justify-center gap-2 px-3 py-1 text-xs">
|
||||
<IssueLabelsList
|
||||
labels={value.map((v) => issueLabels?.find((l) => l.id === v)?.color) ?? []}
|
||||
length={3}
|
||||
showLength
|
||||
/>
|
||||
<span className=" text-gray-600">{value.length} Labels</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center justify-center gap-2 px-3 py-1.5 text-xs">
|
||||
<TagIcon className="h-3 w-3 text-gray-500" />
|
||||
<span className=" text-gray-500">Label</span>
|
||||
</span>
|
||||
)}
|
||||
</Combobox.Button>
|
||||
}
|
||||
>
|
||||
{value && value.length > 0 ? (
|
||||
<span className="flex items-center justify-center gap-2 px-3 py-1 text-xs">
|
||||
<IssueLabelsList
|
||||
labels={value.map((v) => issueLabels?.find((l) => l.id === v)?.color) ?? []}
|
||||
length={3}
|
||||
showLength
|
||||
/>
|
||||
<span className=" text-gray-600">{value.length} Labels</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center justify-center gap-2 px-3 py-1.5 text-xs">
|
||||
<TagIcon className="h-3 w-3 text-gray-500" />
|
||||
<span className=" text-gray-500">Label</span>
|
||||
</span>
|
||||
)}
|
||||
</Combobox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Combobox.Options
|
||||
className={`absolute z-10 mt-1 max-h-52 min-w-[8rem] overflow-auto rounded-md border-none
|
||||
<Transition
|
||||
show={open}
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Combobox.Options
|
||||
className={`absolute z-10 mt-1 max-h-52 min-w-[8rem] overflow-auto rounded-md border-none
|
||||
bg-white px-2 py-2 text-xs shadow-md focus:outline-none`}
|
||||
>
|
||||
<div className="flex w-full items-center justify-start rounded-sm border-[0.6px] bg-gray-100 px-2">
|
||||
<MagnifyingGlassIcon className="h-3 w-3 text-gray-500" />
|
||||
<Combobox.Input
|
||||
className="w-full bg-transparent py-1 px-2 text-xs text-gray-500 focus:outline-none"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search for label..."
|
||||
displayValue={(assigned: any) => assigned?.name}
|
||||
/>
|
||||
</div>
|
||||
<div className="py-1.5">
|
||||
{issueLabels && filteredOptions ? (
|
||||
filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((label) => {
|
||||
const children = issueLabels?.filter((l) => l.parent === label.id);
|
||||
>
|
||||
<div className="flex w-full items-center justify-start rounded-sm border-[0.6px] bg-gray-100 px-2">
|
||||
<MagnifyingGlassIcon className="h-3 w-3 text-gray-500" />
|
||||
<Combobox.Input
|
||||
className="w-full bg-transparent py-1 px-2 text-xs text-gray-500 focus:outline-none"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search for label..."
|
||||
displayValue={(assigned: any) => assigned?.name}
|
||||
/>
|
||||
</div>
|
||||
<div className="py-1.5">
|
||||
{issueLabels && filteredOptions ? (
|
||||
filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((label) => {
|
||||
const children = issueLabels?.filter((l) => l.parent === label.id);
|
||||
|
||||
if (children.length === 0) {
|
||||
if (!label.parent)
|
||||
return (
|
||||
<Combobox.Option
|
||||
key={label.id}
|
||||
className={({ active }) =>
|
||||
`${
|
||||
active ? "bg-gray-200" : ""
|
||||
} group flex min-w-[14rem] cursor-pointer select-none items-center gap-2 truncate rounded px-1 py-1.5 text-gray-600`
|
||||
}
|
||||
value={label.id}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<div className="flex w-full justify-between gap-2 rounded">
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<span
|
||||
className="h-3 w-3 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
label.color && label.color !== ""
|
||||
? label.color
|
||||
: "#000",
|
||||
}}
|
||||
/>
|
||||
<span>{label.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center rounded p-1">
|
||||
<CheckIcon
|
||||
className={`h-3 w-3 ${
|
||||
selected ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
);
|
||||
} else
|
||||
if (children.length === 0) {
|
||||
if (!label.parent)
|
||||
return (
|
||||
<div className="border-y border-gray-400 bg-gray-50">
|
||||
<div className="flex select-none items-center gap-2 truncate p-2 font-medium text-gray-900">
|
||||
<RectangleGroupIcon className="h-3 w-3" /> {label.name}
|
||||
</div>
|
||||
<div>
|
||||
{children.map((child) => (
|
||||
<Combobox.Option
|
||||
key={child.id}
|
||||
className={({ active }) =>
|
||||
`${
|
||||
active ? "bg-gray-200" : ""
|
||||
} group flex min-w-[14rem] cursor-pointer select-none items-center gap-2 truncate rounded px-1 py-1.5 text-gray-600`
|
||||
}
|
||||
value={child.id}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<div className="flex w-full justify-between gap-2 rounded">
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<span
|
||||
className="h-3 w-3 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: child?.color ?? "black",
|
||||
}}
|
||||
/>
|
||||
<span>{child.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center rounded p-1">
|
||||
<CheckIcon
|
||||
className={`h-3 w-3 ${
|
||||
selected ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Combobox.Option
|
||||
key={label.id}
|
||||
className={({ active }) =>
|
||||
`${
|
||||
active ? "bg-gray-200" : ""
|
||||
} group flex min-w-[14rem] cursor-pointer select-none items-center gap-2 truncate rounded px-1 py-1.5 text-gray-600`
|
||||
}
|
||||
value={label.id}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<div className="flex w-full justify-between gap-2 rounded">
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<span
|
||||
className="h-3 w-3 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
label.color && label.color !== "" ? label.color : "#000",
|
||||
}}
|
||||
/>
|
||||
<span>{label.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center rounded p-1">
|
||||
<CheckIcon
|
||||
className={`h-3 w-3 ${
|
||||
selected ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="px-2 text-xs text-gray-500">No labels found</p>
|
||||
)
|
||||
} else
|
||||
return (
|
||||
<div className="border-y border-gray-400 bg-gray-50">
|
||||
<div className="flex select-none items-center gap-2 truncate p-2 font-medium text-gray-900">
|
||||
<RectangleGroupIcon className="h-3 w-3" /> {label.name}
|
||||
</div>
|
||||
<div>
|
||||
{children.map((child) => (
|
||||
<Combobox.Option
|
||||
key={child.id}
|
||||
className={({ active }) =>
|
||||
`${
|
||||
active ? "bg-gray-200" : ""
|
||||
} group flex min-w-[14rem] cursor-pointer select-none items-center gap-2 truncate rounded px-1 py-1.5 text-gray-600`
|
||||
}
|
||||
value={child.id}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<div className="flex w-full justify-between gap-2 rounded">
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<span
|
||||
className="h-3 w-3 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: child?.color ?? "black",
|
||||
}}
|
||||
/>
|
||||
<span>{child.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center rounded p-1">
|
||||
<CheckIcon
|
||||
className={`h-3 w-3 ${
|
||||
selected ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="px-2 text-xs text-gray-500">Loading...</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full select-none items-center rounded py-2 px-1 hover:bg-gray-200"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<span className="flex items-center justify-start gap-1">
|
||||
<PlusIcon className="h-4 w-4 text-gray-600" aria-hidden="true" />
|
||||
<span className="text-gray-600">Create New Label</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</Combobox.Options>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Combobox>
|
||||
</>
|
||||
<p className="px-2 text-xs text-gray-500">No labels found</p>
|
||||
)
|
||||
) : (
|
||||
<p className="px-2 text-xs text-gray-500">Loading...</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full select-none items-center rounded py-2 px-1 hover:bg-gray-200"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<span className="flex items-center justify-start gap-1">
|
||||
<PlusIcon className="h-4 w-4 text-gray-600" aria-hidden="true" />
|
||||
<span className="text-gray-600">Create New Label</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</Combobox.Options>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
|
34
apps/app/components/pages/all-pages-list.tsx
Normal file
34
apps/app/components/pages/all-pages-list.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// services
|
||||
import pagesService from "services/pages.service";
|
||||
// components
|
||||
import { PagesView } from "components/pages";
|
||||
// types
|
||||
import { TPageViewProps } from "types";
|
||||
// fetch-keys
|
||||
import { ALL_PAGES_LIST } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
viewType: TPageViewProps;
|
||||
};
|
||||
|
||||
export const AllPagesList: React.FC<Props> = ({ viewType }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: pages } = useSWR(
|
||||
workspaceSlug && projectId ? ALL_PAGES_LIST(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => pagesService.getAllPages(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-4">
|
||||
<PagesView pages={pages} viewType={viewType} />
|
||||
</div>
|
||||
);
|
||||
};
|
@ -13,14 +13,14 @@ import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { PageForm } from "./page-form";
|
||||
// types
|
||||
import { IPage, IPageForm } from "types";
|
||||
import { IPage } from "types";
|
||||
// fetch-keys
|
||||
import { PAGE_LIST } from "constants/fetch-keys";
|
||||
import { RECENT_PAGES_LIST } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
data?: IPage;
|
||||
data?: IPage | null;
|
||||
};
|
||||
|
||||
export const CreateUpdatePageModal: React.FC<Props> = ({ isOpen, handleClose, data }) => {
|
||||
@ -33,11 +33,11 @@ export const CreateUpdatePageModal: React.FC<Props> = ({ isOpen, handleClose, da
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const createPage = async (payload: IPageForm) => {
|
||||
const createPage = async (payload: IPage) => {
|
||||
await pagesService
|
||||
.createPage(workspaceSlug as string, projectId as string, payload)
|
||||
.then(() => {
|
||||
mutate(PAGE_LIST(projectId as string));
|
||||
mutate(RECENT_PAGES_LIST(projectId as string));
|
||||
onClose();
|
||||
|
||||
setToastAlert({
|
||||
@ -55,20 +55,11 @@ export const CreateUpdatePageModal: React.FC<Props> = ({ isOpen, handleClose, da
|
||||
});
|
||||
};
|
||||
|
||||
const updatePage = async (payload: IPageForm) => {
|
||||
const updatePage = async (payload: IPage) => {
|
||||
await pagesService
|
||||
.patchPage(workspaceSlug as string, projectId as string, data?.id ?? "", payload)
|
||||
.then((res) => {
|
||||
mutate<IPage[]>(
|
||||
PAGE_LIST(projectId as string),
|
||||
(prevData) =>
|
||||
prevData?.map((p) => {
|
||||
if (p.id === res.id) return { ...p, ...payload };
|
||||
|
||||
return p;
|
||||
}),
|
||||
false
|
||||
);
|
||||
.then(() => {
|
||||
mutate(RECENT_PAGES_LIST(projectId as string));
|
||||
onClose();
|
||||
|
||||
setToastAlert({
|
||||
@ -86,7 +77,7 @@ export const CreateUpdatePageModal: React.FC<Props> = ({ isOpen, handleClose, da
|
||||
});
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (formData: IPageForm) => {
|
||||
const handleFormSubmit = async (formData: IPage) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
if (!data) await createPage(formData);
|
||||
|
@ -1,8 +1,7 @@
|
||||
import React, { useState } from "react";
|
||||
// next
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
// swr
|
||||
import { mutate } from "swr";
|
||||
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// services
|
||||
@ -15,13 +14,12 @@ import { DangerButton, SecondaryButton } from "components/ui";
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import type { IPage } from "types";
|
||||
|
||||
type TConfirmPageDeletionProps = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
data?: IPage;
|
||||
data?: IPage | null;
|
||||
};
|
||||
// fetch-keys
|
||||
import { PAGE_LIST } from "constants/fetch-keys";
|
||||
|
||||
export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = ({
|
||||
isOpen,
|
||||
@ -31,7 +29,7 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = ({
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
@ -42,22 +40,16 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = ({
|
||||
|
||||
const handleDeletion = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
if (!data || !workspaceSlug) return;
|
||||
if (!data || !workspaceSlug || !projectId) return;
|
||||
|
||||
await pagesService
|
||||
.deletePage(workspaceSlug as string, data.project, data.id)
|
||||
.then(() => {
|
||||
mutate<IPage[]>(
|
||||
PAGE_LIST(data.project),
|
||||
(prevData) => prevData?.filter((page) => page.id !== data?.id),
|
||||
false
|
||||
);
|
||||
handleClose();
|
||||
|
||||
setToastAlert({
|
||||
title: "Success",
|
||||
type: "success",
|
||||
message: "Page deleted successfully",
|
||||
title: "Success!",
|
||||
message: "Page deleted successfully.",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
|
34
apps/app/components/pages/favorite-pages-list.tsx
Normal file
34
apps/app/components/pages/favorite-pages-list.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// services
|
||||
import pagesService from "services/pages.service";
|
||||
// components
|
||||
import { PagesView } from "components/pages";
|
||||
// types
|
||||
import { TPageViewProps } from "types";
|
||||
// fetch-keys
|
||||
import { FAVORITE_PAGES_LIST } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
viewType: TPageViewProps;
|
||||
};
|
||||
|
||||
export const FavoritePagesList: React.FC<Props> = ({ viewType }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: pages } = useSWR(
|
||||
workspaceSlug && projectId ? FAVORITE_PAGES_LIST(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => pagesService.getFavoritePages(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-4">
|
||||
<PagesView pages={pages} viewType={viewType} />
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,5 +1,12 @@
|
||||
export * from "./all-pages-list";
|
||||
export * from "./create-update-page-modal";
|
||||
export * from "./delete-page-modal";
|
||||
export * from "./favorite-pages-list";
|
||||
export * from "./my-pages-list";
|
||||
export * from "./other-pages-list";
|
||||
export * from "./page-form";
|
||||
export * from "./pages-list";
|
||||
export * from "./pages-view";
|
||||
export * from "./recent-pages-list";
|
||||
export * from "./single-page-block";
|
||||
export * from "./single-page-detailed-item";
|
||||
export * from "./single-page-list-item";
|
||||
|
34
apps/app/components/pages/my-pages-list.tsx
Normal file
34
apps/app/components/pages/my-pages-list.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// services
|
||||
import pagesService from "services/pages.service";
|
||||
// components
|
||||
import { PagesView } from "components/pages";
|
||||
// types
|
||||
import { TPageViewProps } from "types";
|
||||
// fetch-keys
|
||||
import { MY_PAGES_LIST } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
viewType: TPageViewProps;
|
||||
};
|
||||
|
||||
export const MyPagesList: React.FC<Props> = ({ viewType }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: pages } = useSWR(
|
||||
workspaceSlug && projectId ? MY_PAGES_LIST(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => pagesService.getMyPages(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-4">
|
||||
<PagesView pages={pages} viewType={viewType} />
|
||||
</div>
|
||||
);
|
||||
};
|
34
apps/app/components/pages/other-pages-list.tsx
Normal file
34
apps/app/components/pages/other-pages-list.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// services
|
||||
import pagesService from "services/pages.service";
|
||||
// components
|
||||
import { PagesView } from "components/pages";
|
||||
// types
|
||||
import { TPageViewProps } from "types";
|
||||
// fetch-keys
|
||||
import { OTHER_PAGES_LIST } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
viewType: TPageViewProps;
|
||||
};
|
||||
|
||||
export const OtherPagesList: React.FC<Props> = ({ viewType }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: pages } = useSWR(
|
||||
workspaceSlug && projectId ? OTHER_PAGES_LIST(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => pagesService.getOtherPages(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-4">
|
||||
<PagesView pages={pages} viewType={viewType} />
|
||||
</div>
|
||||
);
|
||||
};
|
@ -3,16 +3,16 @@ import { useForm } from "react-hook-form";
|
||||
// ui
|
||||
import { Input, PrimaryButton, SecondaryButton, TextArea } from "components/ui";
|
||||
// types
|
||||
import { IPageForm } from "types";
|
||||
import { IPage } from "types";
|
||||
|
||||
type Props = {
|
||||
handleFormSubmit: (values: IPageForm) => Promise<void>;
|
||||
handleFormSubmit: (values: IPage) => Promise<void>;
|
||||
handleClose: () => void;
|
||||
status: boolean;
|
||||
data?: IPageForm;
|
||||
data?: IPage | null;
|
||||
};
|
||||
|
||||
const defaultValues: IPageForm = {
|
||||
const defaultValues = {
|
||||
name: "",
|
||||
description: "",
|
||||
};
|
||||
@ -23,11 +23,11 @@ export const PageForm: React.FC<Props> = ({ handleFormSubmit, handleClose, statu
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
reset,
|
||||
} = useForm<IPageForm>({
|
||||
} = useForm<IPage>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const handleCreateUpdatePage = async (formData: IPageForm) => {
|
||||
const handleCreateUpdatePage = async (formData: IPage) => {
|
||||
await handleFormSubmit(formData);
|
||||
|
||||
reset({
|
||||
|
@ -1,172 +0,0 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { mutate } from "swr";
|
||||
|
||||
// services
|
||||
import pagesService from "services/pages.service";
|
||||
// ui
|
||||
import { CustomMenu } from "components/ui";
|
||||
// icons
|
||||
import { PencilIcon, StarIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { truncateText } from "helpers/string.helper";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// types
|
||||
import { IPage } from "types";
|
||||
// fetch keys
|
||||
import { PAGE_LIST } from "constants/fetch-keys";
|
||||
import Label from "./page-label";
|
||||
|
||||
type TSingleStatProps = {
|
||||
page: IPage;
|
||||
handleEditPage: () => void;
|
||||
handleDeletePage: () => void;
|
||||
};
|
||||
|
||||
export const SinglePageGridItem: React.FC<TSingleStatProps> = (props) => {
|
||||
const { page, handleEditPage, handleDeletePage } = props;
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const handleAddToFavorites = () => {
|
||||
if (!workspaceSlug && !projectId && !page) return;
|
||||
|
||||
pagesService
|
||||
.addPageToFavorites(workspaceSlug as string, projectId as string, {
|
||||
page: page.id,
|
||||
})
|
||||
.then(() => {
|
||||
mutate<IPage[]>(
|
||||
PAGE_LIST(projectId as string),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((m) => ({
|
||||
...m,
|
||||
is_favorite: m.id === page.id ? true : m.is_favorite,
|
||||
})),
|
||||
false
|
||||
);
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Successfully added the page to favorites.",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't add the page to favorites. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveFromFavorites = () => {
|
||||
if (!workspaceSlug || !page) return;
|
||||
|
||||
pagesService
|
||||
.removePageFromFavorites(workspaceSlug as string, projectId as string, page.id)
|
||||
.then(() => {
|
||||
mutate<IPage[]>(
|
||||
PAGE_LIST(projectId as string),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((m) => ({
|
||||
...m,
|
||||
is_favorite: m.id === page.id ? false : m.is_favorite,
|
||||
})),
|
||||
false
|
||||
);
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Successfully removed the page from favorites.",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't remove the page from favorites. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<li>
|
||||
<div className="relative rounded px-4 py-4 sm:px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/pages/${page.id}`}>
|
||||
<a className="after:absolute after:inset-0">
|
||||
<p className="mr-2 truncate text-sm font-medium">{truncateText(page.name, 75)}</p>
|
||||
</a>
|
||||
</Link>
|
||||
<Label variant="green">Meetings</Label>
|
||||
<Label variant="red">Standup</Label>
|
||||
<Label variant="blue">Plans</Label>
|
||||
</div>
|
||||
|
||||
<div className="ml-2 flex flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm text-gray-400">
|
||||
{new Date(page.updated_at).toLocaleTimeString()}
|
||||
</p>
|
||||
{page.is_favorite ? (
|
||||
<button onClick={handleRemoveFromFavorites} className="z-10">
|
||||
<StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" />
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={handleAddToFavorites} type="button" className="z-10">
|
||||
<StarIcon className="h-4 w-4 " color="#858E96" />
|
||||
</button>
|
||||
)}
|
||||
<CustomMenu width="auto" verticalEllipsis>
|
||||
<CustomMenu.MenuItem onClick={handleEditPage}>
|
||||
<span className="flex items-center justify-start gap-2 text-gray-800">
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
<span>Edit Page</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleDeletePage}>
|
||||
<span className="flex items-center justify-start gap-2 text-gray-800">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
<span>Delete Page</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mt-6 space-y-2 text-sm leading-relaxed text-gray-600">
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Reiciendis atque aliquam
|
||||
saepe sapiente illo ratione delectus dolorem repellat, id autem, molestiae neque
|
||||
quaerat ipsum perspiciatis pariatur? Unde consectetur quibusdam ut.
|
||||
</p>
|
||||
<p>
|
||||
Quisquam quas expedita cupiditate ipsum cumque fugit at, optio quia ea? Id doloribus
|
||||
assumenda ad magni laborum aut, aspernatur nemo similique, suscipit dolores porro
|
||||
necessitatibus, inventore ab aliquid molestias. Aspernatur.
|
||||
</p>
|
||||
<p>
|
||||
Beatae obcaecati minus temporibus sunt, quo nulla, tenetur nisi sit maiores aspernatur
|
||||
numquam facilis asperiores eos rerum, ad dolorem quos laboriosam dicta eaque! Pariatur
|
||||
magni eos, architecto itaque esse minus.
|
||||
</p>
|
||||
<p>
|
||||
Dolorum saepe impedit officiis odit! Porro aliquid dolorum corporis impedit eaque
|
||||
iusto, illo hic neque quia vero aperiam? Nemo aliquam, hic incidunt mollitia totam
|
||||
asperiores sunt nam inventore voluptatibus eum?
|
||||
</p>
|
||||
<div className="absolute bottom-0 h-24 w-full bg-gradient-to-t from-white" />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,23 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
type TLabelProps = {
|
||||
variant: "red" | "blue" | string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const Label: React.FC<TLabelProps> = (props) => {
|
||||
let color = "bg-green-100 text-green-800";
|
||||
|
||||
if (props.variant === "red") {
|
||||
color = "bg-red-100 text-red-800";
|
||||
} else if (props.variant === "blue") {
|
||||
color = "bg-blue-100 text-blue-800";
|
||||
}
|
||||
return (
|
||||
<p className={`inline-flex rounded-full px-2 text-xs font-semibold leading-5 ${color}`}>
|
||||
{props.children}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
export default Label;
|
@ -1,58 +0,0 @@
|
||||
import { useState } from "react";
|
||||
// components
|
||||
import { DeletePageModal } from "components/pages";
|
||||
import { Loader } from "components/ui";
|
||||
import { SinglePageGridItem } from "components/pages/page-grid-item";
|
||||
// types
|
||||
import { IPage } from "types";
|
||||
|
||||
export const PagesGrid: React.FC<any> = ({ pages, setCreateUpdatePageModal, setSelectedPage }) => {
|
||||
const [pageDeleteModal, setPageDeleteModal] = useState(false);
|
||||
const [selectedPageForDelete, setSelectedPageForDelete] = useState<any>();
|
||||
|
||||
const handleDeletePage = (page: IPage) => {
|
||||
setSelectedPageForDelete({ ...page, actionType: "delete" });
|
||||
setPageDeleteModal(true);
|
||||
};
|
||||
|
||||
const handleEditPage = (page: IPage) => {
|
||||
setSelectedPage({ ...page, actionType: "edit" });
|
||||
setCreateUpdatePageModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeletePageModal
|
||||
isOpen={
|
||||
pageDeleteModal &&
|
||||
!!selectedPageForDelete &&
|
||||
selectedPageForDelete.actionType === "delete"
|
||||
}
|
||||
setIsOpen={setPageDeleteModal}
|
||||
data={selectedPageForDelete}
|
||||
/>
|
||||
{pages ? (
|
||||
pages.length > 0 ? (
|
||||
<div className="rounded-[10px] border border-gray-200 bg-white">
|
||||
<ul role="list" className="divide-y divide-gray-200">
|
||||
{pages.map((page: any) => (
|
||||
<SinglePageGridItem
|
||||
page={page}
|
||||
key={page.id}
|
||||
handleDeletePage={() => handleDeletePage(page)}
|
||||
handleEditPage={() => handleEditPage(page)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
"No Pages found"
|
||||
)
|
||||
) : (
|
||||
<Loader className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Loader.Item height="200px" />
|
||||
</Loader>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,65 +0,0 @@
|
||||
import { useState } from "react";
|
||||
// components
|
||||
import { DeletePageModal } from "components/pages";
|
||||
import { Loader } from "components/ui";
|
||||
// types
|
||||
import { IPage } from "types";
|
||||
import { SinglePageListItem } from "./single-page-list-item";
|
||||
type TPagesListProps = {
|
||||
pages: IPage[] | undefined;
|
||||
setCreateUpdatePageModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setSelectedPage: React.Dispatch<React.SetStateAction<any>>;
|
||||
};
|
||||
|
||||
export const PagesList: React.FC<TPagesListProps> = ({
|
||||
pages,
|
||||
setCreateUpdatePageModal,
|
||||
setSelectedPage,
|
||||
}) => {
|
||||
const [pageDeleteModal, setPageDeleteModal] = useState(false);
|
||||
const [selectedPageForDelete, setSelectedPageForDelete] = useState<any>();
|
||||
|
||||
const handleDeletePage = (page: IPage) => {
|
||||
setSelectedPageForDelete({ ...page, actionType: "delete" });
|
||||
setPageDeleteModal(true);
|
||||
};
|
||||
|
||||
const handleEditPage = (page: IPage) => {
|
||||
setSelectedPage({ ...page, actionType: "edit" });
|
||||
setCreateUpdatePageModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeletePageModal
|
||||
isOpen={
|
||||
pageDeleteModal &&
|
||||
!!selectedPageForDelete &&
|
||||
selectedPageForDelete.actionType === "delete"
|
||||
}
|
||||
setIsOpen={setPageDeleteModal}
|
||||
data={selectedPageForDelete}
|
||||
/>
|
||||
{pages ? (
|
||||
pages.length > 0 ? (
|
||||
<ul role="list" className="divide-y divide-gray-200">
|
||||
{pages.map((page) => (
|
||||
<SinglePageListItem
|
||||
page={page}
|
||||
key={page.id}
|
||||
handleDeletePage={() => handleDeletePage(page)}
|
||||
handleEditPage={() => handleEditPage(page)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
"No Pages found"
|
||||
)
|
||||
) : (
|
||||
<Loader className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Loader.Item height="200px" />
|
||||
</Loader>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,82 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
// ui
|
||||
import { CustomMenu } from "components/ui";
|
||||
// icons
|
||||
import { PencilIcon, StarIcon, SwatchIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
const MasonryItem: React.FC<any> = (props) => (
|
||||
<div
|
||||
className="mb-6 w-full rounded-lg border border-gray-200 bg-white p-3"
|
||||
style={{
|
||||
backgroundColor: props.color,
|
||||
}}
|
||||
>
|
||||
<h2 className="text-lg font-medium">Personal Diary</h2>
|
||||
<p className="mt-2 text-sm leading-relaxed">{props.children}</p>
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => {}} type="button" className="z-10">
|
||||
<SwatchIcon className="h-4 w-4 " color="#858E96" />
|
||||
</button>
|
||||
{false ? (
|
||||
<button onClick={() => {}} className="z-10">
|
||||
<StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" />
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => {}} type="button" className="z-10">
|
||||
<StarIcon className="h-4 w-4 " color="#858E96" />
|
||||
</button>
|
||||
)}
|
||||
<CustomMenu width="auto" verticalEllipsis>
|
||||
<CustomMenu.MenuItem onClick={() => {}}>
|
||||
<span className="flex items-center justify-start gap-2 text-gray-800">
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
<span>Edit Page</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => {}}>
|
||||
<span className="flex items-center justify-start gap-2 text-gray-800">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
<span>Delete Page</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">9:41 PM</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function PagesMasonry() {
|
||||
return (
|
||||
<div className="columns-4 gap-6">
|
||||
<MasonryItem color="#FF9E9E" isVideo>
|
||||
Lorem, ipsum dolor sit amet consectetur adipisicing elit. Impedit porro mollitia iure,
|
||||
reiciendis quo tempora rem debitis velit quas doloremque. Dicta velit voluptas, blanditiis
|
||||
excepturi vitae eum corporis totam eius?
|
||||
</MasonryItem>
|
||||
<MasonryItem>
|
||||
Lorem, ipsum dolor sit amet consectetur adipisicing elit. Impedit porro mollitia iure,
|
||||
reiciendis quo tempora rem debitis velit quas doloremque. Dicta velit voluptas, blanditiis
|
||||
excepturi vitae eum corporis totam eius? Lorem, ipsum dolor sit amet consectetur adipisicing
|
||||
elit. Impedit porro mollitia iure, reiciendis quo tempora rem debitis velit quas doloremque.
|
||||
Dicta velit voluptas, blanditiis excepturi vitae eum corporis totam eius?
|
||||
</MasonryItem>
|
||||
<MasonryItem color="#FCBE1D">
|
||||
Lorem, ipsum dolor sit amet consectetur adipisicing elit. Impedit porro mollitia iure,
|
||||
reiciendis quo tempora rem debitis velit quas doloremque. Dicta velit voluptas, blanditiis
|
||||
excepturi vitae eum corporis totam eius?
|
||||
</MasonryItem>
|
||||
<MasonryItem>
|
||||
Lorem, ipsum dolor sit amet consectetur adipisicing elit. Impedit porro mollitia iure,
|
||||
reiciendis quo tempora rem debitis velit quas doloremque. Dicta velit voluptas, blanditiis
|
||||
excepturi vitae eum corporis totam eius? Lorem, ipsum dolor sit amet consectetur adipisicing
|
||||
elit. Impedit porro mollitia iure, reiciendis quo tempora rem debitis velit quas doloremque.
|
||||
Dicta velit voluptas, blanditiis excepturi vitae eum corporis totam eius?
|
||||
</MasonryItem>
|
||||
</div>
|
||||
);
|
||||
}
|
172
apps/app/components/pages/pages-view.tsx
Normal file
172
apps/app/components/pages/pages-view.tsx
Normal file
@ -0,0 +1,172 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// services
|
||||
import pagesService from "services/pages.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import {
|
||||
CreateUpdatePageModal,
|
||||
DeletePageModal,
|
||||
SinglePageDetailedItem,
|
||||
SinglePageListItem,
|
||||
} from "components/pages";
|
||||
// ui
|
||||
import { EmptyState, Loader } from "components/ui";
|
||||
// images
|
||||
import emptyPage from "public/empty-state/empty-page.svg";
|
||||
// types
|
||||
import { IPage, TPageViewProps } from "types";
|
||||
|
||||
type Props = {
|
||||
pages: IPage[] | undefined;
|
||||
viewType: TPageViewProps;
|
||||
};
|
||||
|
||||
export const PagesView: React.FC<Props> = ({ pages, viewType }) => {
|
||||
const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false);
|
||||
const [selectedPageToUpdate, setSelectedPageToUpdate] = useState<IPage | null>(null);
|
||||
|
||||
const [deletePageModal, setDeletePageModal] = useState(false);
|
||||
const [selectedPageToDelete, setSelectedPageToDelete] = useState<IPage | null>(null);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const handleEditPage = (page: IPage) => {
|
||||
setSelectedPageToUpdate(page);
|
||||
setCreateUpdatePageModal(true);
|
||||
};
|
||||
|
||||
const handleDeletePage = (page: IPage) => {
|
||||
setSelectedPageToDelete(page);
|
||||
setDeletePageModal(true);
|
||||
};
|
||||
|
||||
const handleAddToFavorites = (page: IPage) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
pagesService
|
||||
.addPageToFavorites(workspaceSlug as string, projectId as string, {
|
||||
page: page.id,
|
||||
})
|
||||
.then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Successfully added the page to favorites.",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't add the page to favorites. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveFromFavorites = (page: IPage) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
pagesService
|
||||
.removePageFromFavorites(workspaceSlug as string, projectId as string, page.id)
|
||||
.then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Successfully removed the page from favorites.",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't remove the page from favorites. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateUpdatePageModal
|
||||
isOpen={createUpdatePageModal}
|
||||
handleClose={() => setCreateUpdatePageModal(false)}
|
||||
data={selectedPageToUpdate}
|
||||
/>
|
||||
<DeletePageModal
|
||||
isOpen={deletePageModal}
|
||||
setIsOpen={setDeletePageModal}
|
||||
data={selectedPageToDelete}
|
||||
/>
|
||||
{pages ? (
|
||||
pages.length > 0 ? (
|
||||
viewType === "list" ? (
|
||||
<ul role="list" className="divide-y">
|
||||
{pages.map((page) => (
|
||||
<SinglePageListItem
|
||||
key={page.id}
|
||||
page={page}
|
||||
handleEditPage={() => handleEditPage(page)}
|
||||
handleDeletePage={() => handleDeletePage(page)}
|
||||
handleAddToFavorites={() => handleAddToFavorites(page)}
|
||||
handleRemoveFromFavorites={() => handleRemoveFromFavorites(page)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : viewType === "detailed" ? (
|
||||
<div className="rounded-[10px] border border-gray-200 bg-white">
|
||||
{pages.map((page) => (
|
||||
<SinglePageDetailedItem
|
||||
key={page.id}
|
||||
page={page}
|
||||
handleEditPage={() => handleEditPage(page)}
|
||||
handleDeletePage={() => handleDeletePage(page)}
|
||||
handleAddToFavorites={() => handleAddToFavorites(page)}
|
||||
handleRemoveFromFavorites={() => handleRemoveFromFavorites(page)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-[10px] border border-gray-200 bg-white">
|
||||
{pages.map((page) => (
|
||||
<SinglePageDetailedItem
|
||||
key={page.id}
|
||||
page={page}
|
||||
handleEditPage={() => handleEditPage(page)}
|
||||
handleDeletePage={() => handleDeletePage(page)}
|
||||
handleAddToFavorites={() => handleAddToFavorites(page)}
|
||||
handleRemoveFromFavorites={() => handleRemoveFromFavorites(page)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<EmptyState
|
||||
type="page"
|
||||
title="Create New Page"
|
||||
description="Sprint more effectively with Cycles by confining your project
|
||||
to a fixed amount of time. Create new cycle now."
|
||||
imgURL={emptyPage}
|
||||
/>
|
||||
)
|
||||
) : viewType === "list" ? (
|
||||
<Loader className="space-y-4">
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
</Loader>
|
||||
) : (
|
||||
<Loader className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Loader.Item height="150px" />
|
||||
<Loader.Item height="150px" />
|
||||
<Loader.Item height="150px" />
|
||||
</Loader>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
46
apps/app/components/pages/recent-pages-list.tsx
Normal file
46
apps/app/components/pages/recent-pages-list.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import React from "react";
|
||||
|
||||
// components
|
||||
import { PagesView } from "components/pages";
|
||||
// ui
|
||||
import { Loader } from "components/ui";
|
||||
// helpers
|
||||
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
||||
// types
|
||||
import { RecentPagesResponse, TPageViewProps } from "types";
|
||||
|
||||
type Props = {
|
||||
pages: RecentPagesResponse | undefined;
|
||||
viewType: TPageViewProps;
|
||||
};
|
||||
|
||||
export const RecentPagesList: React.FC<Props> = ({ pages, viewType }) => (
|
||||
<>
|
||||
{pages ? (
|
||||
Object.keys(pages).length > 0 ? (
|
||||
<div className="mt-4 space-y-4">
|
||||
{Object.keys(pages).map((key) => {
|
||||
if (pages[key].length === 0) return null;
|
||||
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
<h2 className="text-xl font-medium capitalize">
|
||||
{replaceUnderscoreIfSnakeCase(key)}
|
||||
</h2>
|
||||
<PagesView pages={pages[key as keyof RecentPagesResponse]} viewType={viewType} />
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-4 text-center">No issues found</p>
|
||||
)
|
||||
) : (
|
||||
<Loader className="mt-8 space-y-4">
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
</Loader>
|
||||
)}
|
||||
</>
|
||||
);
|
230
apps/app/components/pages/single-page-block.tsx
Normal file
230
apps/app/components/pages/single-page-block.tsx
Normal file
@ -0,0 +1,230 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// services
|
||||
import pagesService from "services/pages.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { CustomMenu, Loader, TextArea } from "components/ui";
|
||||
// icons
|
||||
import { WaterDropIcon } from "components/icons";
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
// types
|
||||
import { IPageBlock } from "types";
|
||||
// fetch-keys
|
||||
import { PAGE_BLOCKS_LIST } from "constants/fetch-keys";
|
||||
import { CreateUpdateIssueModal } from "components/issues";
|
||||
|
||||
type Props = {
|
||||
block: IPageBlock;
|
||||
};
|
||||
|
||||
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<Loader>
|
||||
<Loader.Item height="100px" width="100%" />
|
||||
</Loader>
|
||||
),
|
||||
});
|
||||
|
||||
export const SinglePageBlock: React.FC<Props> = ({ block }) => {
|
||||
const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, pageId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const { handleSubmit, watch, reset, setValue, control } = useForm<IPageBlock>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: {},
|
||||
description_html: "<p></p>",
|
||||
},
|
||||
});
|
||||
|
||||
const updatePageBlock = async (formData: Partial<IPageBlock>) => {
|
||||
if (!workspaceSlug || !projectId || !pageId) return;
|
||||
|
||||
if (!formData.name || formData.name.length === 0 || formData.name === "") return;
|
||||
|
||||
mutate<IPageBlock[]>(
|
||||
PAGE_BLOCKS_LIST(pageId as string),
|
||||
(prevData) =>
|
||||
prevData?.map((p) => {
|
||||
if (p.id === block.id) return { ...p, ...formData };
|
||||
|
||||
return p;
|
||||
}),
|
||||
false
|
||||
);
|
||||
|
||||
await pagesService.patchPageBlock(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
pageId as string,
|
||||
block.id,
|
||||
{
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
description_html: formData.description_html,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const pushBlockIntoIssues = async () => {
|
||||
if (!workspaceSlug || !projectId || !pageId) return;
|
||||
|
||||
await pagesService
|
||||
.convertPageBlockToIssue(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
pageId as string,
|
||||
block.id
|
||||
)
|
||||
.then((res) => {
|
||||
mutate<IPageBlock[]>(
|
||||
PAGE_BLOCKS_LIST(pageId as string),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => {
|
||||
if (p.id === block.id) return { ...p, issue: res.id };
|
||||
|
||||
return p;
|
||||
}),
|
||||
false
|
||||
);
|
||||
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Page block converted to issue successfully.",
|
||||
});
|
||||
})
|
||||
.catch((res) => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Page block could not be converted to issue. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const editAndPushBlockIntoIssues = async () => {
|
||||
setCreateUpdateIssueModal(true);
|
||||
};
|
||||
|
||||
const deletePageBlock = async () => {
|
||||
if (!workspaceSlug || !projectId || !pageId) return;
|
||||
|
||||
mutate<IPageBlock[]>(
|
||||
PAGE_BLOCKS_LIST(pageId as string),
|
||||
(prevData) => (prevData ?? []).filter((p) => p.id !== block.id),
|
||||
false
|
||||
);
|
||||
|
||||
await pagesService
|
||||
.deletePageBlock(workspaceSlug as string, projectId as string, pageId as string, block.id)
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Page could not be deleted. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopyText = () => {
|
||||
const originURL =
|
||||
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
|
||||
copyTextToClipboard(
|
||||
`${originURL}/${workspaceSlug}/projects/${projectId}/issues/${block.issue}`
|
||||
).then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Link Copied!",
|
||||
message: "Issue link copied to clipboard.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!block) return;
|
||||
|
||||
reset({ ...block });
|
||||
}, [reset, block]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={createUpdateIssueModal}
|
||||
handleClose={() => setCreateUpdateIssueModal(false)}
|
||||
prePopulateData={{
|
||||
name: watch("name"),
|
||||
description: watch("description"),
|
||||
description_html: watch("description_html"),
|
||||
}}
|
||||
/>
|
||||
<div className="-mx-3 -mt-2 flex items-center justify-between gap-2">
|
||||
<TextArea
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Enter issue name"
|
||||
value={watch("name")}
|
||||
onBlur={handleSubmit(updatePageBlock)}
|
||||
onChange={(e) => setValue("name", e.target.value)}
|
||||
required={true}
|
||||
className="min-h-10 block w-full resize-none overflow-hidden border-none bg-transparent text-base font-medium"
|
||||
role="textbox"
|
||||
disabled={block.issue ? true : false}
|
||||
/>
|
||||
<CustomMenu label={<WaterDropIcon width={14} height={15} />} noBorder noChevron>
|
||||
{block.issue ? (
|
||||
<CustomMenu.MenuItem onClick={handleCopyText}>Copy issue link</CustomMenu.MenuItem>
|
||||
) : (
|
||||
<>
|
||||
<CustomMenu.MenuItem onClick={pushBlockIntoIssues}>
|
||||
Push into issues
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={editAndPushBlockIntoIssues}>
|
||||
Edit and push into issues
|
||||
</CustomMenu.MenuItem>
|
||||
</>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={deletePageBlock}>Delete block</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
<div className="page-block-section -mx-3 -mt-5">
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field: { value } }) => (
|
||||
<RemirrorRichTextEditor
|
||||
value={
|
||||
!value || (typeof value === "object" && Object.keys(value).length === 0)
|
||||
? watch("description_html")
|
||||
: value
|
||||
}
|
||||
onBlur={handleSubmit(updatePageBlock)}
|
||||
onJSONChange={(jsonValue) => setValue("description", jsonValue)}
|
||||
onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)}
|
||||
placeholder="Description..."
|
||||
editable={block.issue ? false : true}
|
||||
customClassName="text-gray-500"
|
||||
noBorder
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
126
apps/app/components/pages/single-page-detailed-item.tsx
Normal file
126
apps/app/components/pages/single-page-detailed-item.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import React from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
// ui
|
||||
import { CustomMenu, Loader } from "components/ui";
|
||||
// icons
|
||||
import { PencilIcon, StarIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { truncateText } from "helpers/string.helper";
|
||||
import { renderShortTime } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { IPage } from "types";
|
||||
|
||||
type TSingleStatProps = {
|
||||
page: IPage;
|
||||
handleEditPage: () => void;
|
||||
handleDeletePage: () => void;
|
||||
handleAddToFavorites: () => void;
|
||||
handleRemoveFromFavorites: () => void;
|
||||
};
|
||||
|
||||
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<Loader className="p-4">
|
||||
<Loader.Item height="100px" width="100%" />
|
||||
</Loader>
|
||||
),
|
||||
});
|
||||
|
||||
export const SinglePageDetailedItem: React.FC<TSingleStatProps> = ({
|
||||
page,
|
||||
handleEditPage,
|
||||
handleDeletePage,
|
||||
handleAddToFavorites,
|
||||
handleRemoveFromFavorites,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
return (
|
||||
<div className="relative rounded border p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/pages/${page.id}`}>
|
||||
<a className="after:absolute after:inset-0">
|
||||
<p className="mr-2 truncate text-sm font-medium">{truncateText(page.name, 75)}</p>
|
||||
</a>
|
||||
</Link>
|
||||
{page.label_details.length > 0 &&
|
||||
page.label_details.map((label) => (
|
||||
<div
|
||||
key={label.id}
|
||||
className="group flex items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs"
|
||||
style={{
|
||||
backgroundColor: `${
|
||||
label?.color && label.color !== "" ? label.color : "#000000"
|
||||
}20`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label?.color && label.color !== "" ? label.color : "#000000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm text-gray-400">{renderShortTime(page.updated_at)}</p>
|
||||
{page.is_favorite ? (
|
||||
<button onClick={handleRemoveFromFavorites} className="z-10 grid place-items-center">
|
||||
<StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleAddToFavorites}
|
||||
type="button"
|
||||
className="z-10 grid place-items-center"
|
||||
>
|
||||
<StarIcon className="h-4 w-4 " color="#858E96" />
|
||||
</button>
|
||||
)}
|
||||
<CustomMenu verticalEllipsis>
|
||||
<CustomMenu.MenuItem onClick={handleEditPage}>
|
||||
<span className="flex items-center justify-start gap-2 text-gray-800">
|
||||
<PencilIcon className="h-3.5 w-3.5" />
|
||||
<span>Edit Page</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleDeletePage}>
|
||||
<span className="flex items-center justify-start gap-2 text-gray-800">
|
||||
<TrashIcon className="h-3.5 w-3.5" />
|
||||
<span>Delete Page</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mt-6 space-y-2 text-sm text-gray-600">
|
||||
<div className="page-block-section -m-4 -mt-6">
|
||||
{page.blocks.length > 0 ? (
|
||||
<RemirrorRichTextEditor
|
||||
value={
|
||||
!page.blocks[0].description ||
|
||||
(typeof page.blocks[0].description === "object" &&
|
||||
Object.keys(page.blocks[0].description).length === 0)
|
||||
? page.blocks[0].description_html
|
||||
: page.blocks[0].description
|
||||
}
|
||||
editable={false}
|
||||
customClassName="text-gray-500"
|
||||
noBorder
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,148 +1,103 @@
|
||||
import React from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { mutate } from "swr";
|
||||
|
||||
// services
|
||||
import pagesService from "services/pages.service";
|
||||
// ui
|
||||
import { CustomMenu } from "components/ui";
|
||||
import Label from "./page-label";
|
||||
import { CustomMenu, Tooltip } from "components/ui";
|
||||
// icons
|
||||
import { PencilIcon, StarIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { truncateText } from "helpers/string.helper";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import { renderShortDate, renderShortTime } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { IPage } from "types";
|
||||
// fetch keys
|
||||
import { PAGE_LIST } from "constants/fetch-keys";
|
||||
|
||||
type TSingleStatProps = {
|
||||
page: IPage;
|
||||
handleEditPage: () => void;
|
||||
handleDeletePage: () => void;
|
||||
handleAddToFavorites: () => void;
|
||||
handleRemoveFromFavorites: () => void;
|
||||
};
|
||||
|
||||
export const SinglePageListItem: React.FC<TSingleStatProps> = (props) => {
|
||||
const { page, handleEditPage, handleDeletePage } = props;
|
||||
|
||||
export const SinglePageListItem: React.FC<TSingleStatProps> = ({
|
||||
page,
|
||||
handleEditPage,
|
||||
handleDeletePage,
|
||||
handleAddToFavorites,
|
||||
handleRemoveFromFavorites,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const handleAddToFavorites = () => {
|
||||
if (!workspaceSlug && !projectId && !page) return;
|
||||
|
||||
pagesService
|
||||
.addPageToFavorites(workspaceSlug as string, projectId as string, {
|
||||
page: page.id,
|
||||
})
|
||||
.then(() => {
|
||||
mutate<IPage[]>(
|
||||
PAGE_LIST(projectId as string),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((m) => ({
|
||||
...m,
|
||||
is_favorite: m.id === page.id ? true : m.is_favorite,
|
||||
})),
|
||||
false
|
||||
);
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Successfully added the page to favorites.",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't add the page to favorites. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveFromFavorites = () => {
|
||||
if (!workspaceSlug || !page) return;
|
||||
|
||||
pagesService
|
||||
.removePageFromFavorites(workspaceSlug as string, projectId as string, page.id)
|
||||
.then(() => {
|
||||
mutate<IPage[]>(
|
||||
PAGE_LIST(projectId as string),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((m) => ({
|
||||
...m,
|
||||
is_favorite: m.id === page.id ? false : m.is_favorite,
|
||||
})),
|
||||
false
|
||||
);
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Successfully removed the page from favorites.",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't remove the page from favorites. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<li>
|
||||
<div className="relative rounded px-4 py-4 hover:bg-gray-200 sm:px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/pages/${page.id}`}>
|
||||
<a className="after:absolute after:inset-0">
|
||||
<p className="mr-2 truncate text-sm font-medium">{truncateText(page.name, 75)}</p>
|
||||
</a>
|
||||
</Link>
|
||||
<Label variant="green">Meetings</Label>
|
||||
<Label variant="red">Standup</Label>
|
||||
<Label variant="blue">Plans</Label>
|
||||
</div>
|
||||
<div className="ml-2 flex flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm text-gray-400">
|
||||
{new Date(page.updated_at).toLocaleTimeString()}
|
||||
</p>
|
||||
{page.is_favorite ? (
|
||||
<button onClick={handleRemoveFromFavorites} className="z-10">
|
||||
<StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" />
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={handleAddToFavorites} type="button" className="z-10">
|
||||
<StarIcon className="h-4 w-4 " color="#858E96" />
|
||||
</button>
|
||||
)}
|
||||
<CustomMenu width="auto" verticalEllipsis>
|
||||
<CustomMenu.MenuItem onClick={handleEditPage}>
|
||||
<span className="flex items-center justify-start gap-2 text-gray-800">
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
<span>Edit Page</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleDeletePage}>
|
||||
<span className="flex items-center justify-start gap-2 text-gray-800">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
<span>Delete Page</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
<li>
|
||||
<div className="relative rounded p-4 hover:bg-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/pages/${page.id}`}>
|
||||
<a>
|
||||
<p className="mr-2 truncate text-sm font-medium">{truncateText(page.name, 75)}</p>
|
||||
</a>
|
||||
</Link>
|
||||
{page.label_details.length > 0 &&
|
||||
page.label_details.map((label) => (
|
||||
<div
|
||||
key={label.id}
|
||||
className="group flex items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs"
|
||||
style={{
|
||||
backgroundColor: `${
|
||||
label?.color && label.color !== "" ? label.color : "#000000"
|
||||
}20`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label?.color && label.color !== "" ? label.color : "#000000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="ml-2 flex flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip
|
||||
tooltipContent={`Last updated at ${renderShortTime(
|
||||
page.updated_at
|
||||
)} ${renderShortDate(page.updated_at)}`}
|
||||
>
|
||||
<p className="text-sm text-gray-400">{renderShortTime(page.updated_at)}</p>
|
||||
</Tooltip>
|
||||
{page.is_favorite ? (
|
||||
<button onClick={handleRemoveFromFavorites}>
|
||||
<StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" />
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={handleAddToFavorites} type="button">
|
||||
<StarIcon className="h-4 w-4 " color="#858e96" />
|
||||
</button>
|
||||
)}
|
||||
<CustomMenu width="auto" verticalEllipsis>
|
||||
<CustomMenu.MenuItem onClick={handleEditPage}>
|
||||
<span className="flex items-center justify-start gap-2 text-gray-800">
|
||||
<PencilIcon className="h-3.5 w-3.5" />
|
||||
<span>Edit Page</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleDeletePage}>
|
||||
<span className="flex items-center justify-start gap-2 text-gray-800">
|
||||
<TrashIcon className="h-3.5 w-3.5" />
|
||||
<span>Delete Page</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
@ -10,6 +10,7 @@ import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
ContrastIcon,
|
||||
LayerDiagonalIcon,
|
||||
PencilScribbleIcon,
|
||||
PeopleGroupIcon,
|
||||
SettingIcon,
|
||||
ViewListIcon,
|
||||
@ -52,7 +53,7 @@ const navigation = (workspaceSlug: string, projectId: string) => [
|
||||
{
|
||||
name: "Pages",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/pages`,
|
||||
icon: ViewListIcon,
|
||||
icon: PencilScribbleIcon,
|
||||
},
|
||||
{
|
||||
name: "Settings",
|
||||
@ -164,6 +165,7 @@ export const SingleSidebarProject: React.FC<Props> = ({
|
||||
? "text-gray-900"
|
||||
: "text-gray-500 group-hover:text-gray-900"
|
||||
} ${!sidebarCollapse ? "mr-3" : ""}`}
|
||||
color={item.href === router.asPath ? "#111827" : "#858e96"}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
@ -48,6 +48,7 @@ export interface IRemirrorRichTextEditor {
|
||||
showToolbar?: boolean;
|
||||
editable?: boolean;
|
||||
customClassName?: string;
|
||||
noBorder?: boolean;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-duplicate-imports
|
||||
@ -65,6 +66,7 @@ const RemirrorRichTextEditor: FC<IRemirrorRichTextEditor> = (props) => {
|
||||
showToolbar = true,
|
||||
editable = true,
|
||||
customClassName,
|
||||
noBorder = false,
|
||||
} = props;
|
||||
|
||||
const [imageLoader, setImageLoader] = useState(false);
|
||||
@ -184,7 +186,9 @@ const RemirrorRichTextEditor: FC<IRemirrorRichTextEditor> = (props) => {
|
||||
manager={manager}
|
||||
initialContent={state}
|
||||
classNames={[
|
||||
`p-4 relative focus:outline-none rounded-md border focus:border-theme ${customClassName}`,
|
||||
`p-4 relative focus:outline-none rounded-md focus:border-theme ${
|
||||
noBorder ? "" : "border"
|
||||
} ${customClassName}`,
|
||||
]}
|
||||
editable={editable}
|
||||
onBlur={() => {
|
||||
|
@ -1,3 +1,4 @@
|
||||
export * from "./danger-button";
|
||||
export * from "./no-border-button";
|
||||
export * from "./primary-button";
|
||||
export * from "./secondary-button";
|
||||
|
36
apps/app/components/ui/buttons/no-border-button.tsx
Normal file
36
apps/app/components/ui/buttons/no-border-button.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
// types
|
||||
import { ButtonProps } from "./type";
|
||||
|
||||
export const NoBorderButton: React.FC<ButtonProps> = ({
|
||||
children,
|
||||
className = "",
|
||||
onClick,
|
||||
type = "button",
|
||||
disabled = false,
|
||||
loading = false,
|
||||
size = "sm",
|
||||
outline = false,
|
||||
}) => (
|
||||
<button
|
||||
type={type}
|
||||
className={`${className} border border-red-500 font-medium duration-300 ${
|
||||
size === "sm"
|
||||
? "rounded px-3 py-2 text-xs"
|
||||
: size === "md"
|
||||
? "rounded-md px-3.5 py-2 text-sm"
|
||||
: "rounded-lg px-4 py-2 text-base"
|
||||
} ${
|
||||
disabled
|
||||
? "cursor-not-allowed border-gray-300 bg-gray-300 text-black hover:border-gray-300 hover:border-opacity-100 hover:bg-gray-300 hover:bg-opacity-100 hover:text-black"
|
||||
: ""
|
||||
} ${
|
||||
outline
|
||||
? "bg-transparent hover:bg-red-500 hover:text-white"
|
||||
: "bg-red-500 text-white hover:border-opacity-90 hover:bg-opacity-90"
|
||||
} ${loading ? "cursor-wait" : ""}`}
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
@ -9,7 +9,7 @@ import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
import { capitalizeFirstLetter } from "helpers/string.helper";
|
||||
|
||||
type Props = {
|
||||
type: "cycle" | "module" | "project" | "issue" | "view";
|
||||
type: "cycle" | "module" | "project" | "issue" | "view" | "page";
|
||||
title: string;
|
||||
description: React.ReactNode | string;
|
||||
imgURL: string;
|
||||
|
@ -107,7 +107,11 @@ export const SUB_ISSUES = (issueId: string) => `SUB_ISSUES_${issueId}`;
|
||||
// integrations
|
||||
|
||||
// Pages
|
||||
export const PAGE_LIST = (pageId: string) => `PAGE_LIST_${pageId}`;
|
||||
export const RECENT_PAGES_LIST = (projectId: string) => `RECENT_PAGES_LIST_${projectId}`;
|
||||
export const ALL_PAGES_LIST = (projectId: string) => `ALL_PAGES_LIST_${projectId}`;
|
||||
export const FAVORITE_PAGES_LIST = (projectId: string) => `FAVORITE_PAGES_LIST_${projectId}`;
|
||||
export const MY_PAGES_LIST = (projectId: string) => `MY_PAGES_LIST_${projectId}`;
|
||||
export const OTHER_PAGES_LIST = (projectId: string) => `OTHER_PAGES_LIST_${projectId}`;
|
||||
export const PAGE_DETAILS = (pageId: string) => `PAGE_DETAILS_${pageId}`;
|
||||
export const PAGE_BLOCK_LIST = (pageId: string) => `PAGE_BLOCK_LIST_${pageId}`;
|
||||
export const PAGE_BLOCKS_LIST = (pageId: string) => `PAGE_BLOCK_LIST_${pageId}`;
|
||||
export const PAGE_BLOCK_DETAILS = (pageId: string) => `PAGE_BLOCK_DETAILS_${pageId}`;
|
||||
|
@ -105,7 +105,11 @@ export const getDateRangeStatus = (startDate: string | null, endDate: string | n
|
||||
}
|
||||
};
|
||||
|
||||
export const renderShortDateWithYearFormat = (date: Date) => {
|
||||
export const renderShortDateWithYearFormat = (date: string | Date) => {
|
||||
if (!date || date === "") return null;
|
||||
|
||||
date = new Date(date);
|
||||
|
||||
const months = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
@ -126,7 +130,11 @@ export const renderShortDateWithYearFormat = (date: Date) => {
|
||||
return isNaN(date.getTime()) ? "N/A" : ` ${month} ${day}, ${year}`;
|
||||
};
|
||||
|
||||
export const renderShortDate = (date: Date) => {
|
||||
export const renderShortDate = (date: string | Date) => {
|
||||
if (!date || date === "") return null;
|
||||
|
||||
date = new Date(date);
|
||||
|
||||
const months = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
@ -145,3 +153,19 @@ export const renderShortDate = (date: Date) => {
|
||||
const month = months[date.getMonth()];
|
||||
return isNaN(date.getTime()) ? "N/A" : `${day} ${month}`;
|
||||
};
|
||||
|
||||
export const renderShortTime = (date: string | Date) => {
|
||||
if (!date || date === "") return null;
|
||||
|
||||
date = new Date(date);
|
||||
|
||||
const hours = date.getHours();
|
||||
let minutes: any = date.getMinutes();
|
||||
|
||||
// Add leading zero to single-digit minutes
|
||||
if (minutes < 10) {
|
||||
minutes = "0" + minutes;
|
||||
}
|
||||
|
||||
return hours + ":" + minutes;
|
||||
};
|
||||
|
@ -1,126 +1,78 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { useForm } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
// react-color
|
||||
import { TwitterPicker } from "react-color";
|
||||
// lib
|
||||
import { requiredAuth } from "lib/auth";
|
||||
|
||||
// services
|
||||
import projectService from "services/project.service";
|
||||
|
||||
import pagesService from "services/pages.service";
|
||||
import issuesService from "services/issues.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// layouts
|
||||
import AppLayout from "layouts/app-layout";
|
||||
// components
|
||||
import { SinglePageBlock } from "components/pages";
|
||||
// ui
|
||||
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
|
||||
|
||||
// fetching keys
|
||||
import { PAGE_BLOCK_LIST, PROJECT_DETAILS } from "constants/fetch-keys";
|
||||
// components
|
||||
import { CustomMenu } from "components/ui";
|
||||
|
||||
import { CustomSearchSelect, Loader, PrimaryButton, TextArea } from "components/ui";
|
||||
// icons
|
||||
import { ArrowLeftIcon, PlusIcon, ShareIcon, StarIcon } from "@heroicons/react/24/outline";
|
||||
import { ColorPalletteIcon } from "components/icons";
|
||||
// helpers
|
||||
import { renderShortTime } from "helpers/date-time.helper";
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
// types
|
||||
import { IPageBlock, IView } from "types";
|
||||
import type { NextPage, GetServerSidePropsContext } from "next";
|
||||
import pagesService from "services/pages.service";
|
||||
import useToast from "hooks/use-toast";
|
||||
import { IIssueLabels, IPage, IPageBlock } from "types";
|
||||
// fetch-keys
|
||||
import {
|
||||
PAGE_BLOCKS_LIST,
|
||||
PAGE_DETAILS,
|
||||
PROJECT_DETAILS,
|
||||
PROJECT_ISSUE_LABELS,
|
||||
} from "constants/fetch-keys";
|
||||
|
||||
const SinglePage: NextPage = () => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, pageId } = router.query;
|
||||
|
||||
const PageBlock: React.FC<any> = ({ pageBlock }: { pageBlock: IPageBlock }) => {
|
||||
const [name, setName] = useState(pageBlock.name);
|
||||
const { setToastAlert } = useToast();
|
||||
const {
|
||||
query: { workspaceSlug, projectId, pageId },
|
||||
} = useRouter();
|
||||
|
||||
const updatePageBlock = async () => {
|
||||
const pageBlockId = pageBlock.id;
|
||||
await pagesService
|
||||
.patchPageBlock(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
pageId as string,
|
||||
pageBlockId as string,
|
||||
{
|
||||
name,
|
||||
}
|
||||
)
|
||||
.then(() => {
|
||||
mutate(PAGE_BLOCK_LIST(pageId as string));
|
||||
console.log("Updated block");
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Page could not be updated. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
const { handleSubmit, reset, watch, setValue, control } = useForm<IPage>({
|
||||
defaultValues: { name: "" },
|
||||
});
|
||||
|
||||
const deletePageBlock = async () => {
|
||||
const pageBlockId = pageBlock.id;
|
||||
await pagesService
|
||||
.deletePageBlock(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
pageId as string,
|
||||
pageBlockId as string
|
||||
)
|
||||
.then(() => {
|
||||
mutate(PAGE_BLOCK_LIST(pageId as string));
|
||||
console.log("deleted block");
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Page could not be deleted. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<li className="group flex justify-between rounded p-2 hover:bg-slate-100">
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
console.log("Updating...");
|
||||
updatePageBlock();
|
||||
}
|
||||
}}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
}}
|
||||
className="border-none bg-transparent outline-none"
|
||||
/>
|
||||
<div className="hidden group-hover:block">
|
||||
<CustomMenu>
|
||||
<CustomMenu.MenuItem>Convert to issue</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={deletePageBlock}>Delete block</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const ProjectPages: NextPage = () => {
|
||||
const { setToastAlert } = useToast();
|
||||
const {
|
||||
query: { workspaceSlug, projectId, pageId },
|
||||
} = useRouter();
|
||||
|
||||
const { data: activeProject } = useSWR(
|
||||
const { data: projectDetails } = useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => projectService.getProject(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: pageDetails } = useSWR(
|
||||
workspaceSlug && projectId && pageId ? PAGE_DETAILS(pageId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () =>
|
||||
pagesService.getPageDetails(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
pageId as string
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: pageBlocks } = useSWR(
|
||||
workspaceSlug && projectId && pageId ? PAGE_BLOCK_LIST(pageId as string) : null,
|
||||
workspaceSlug && projectId && pageId ? PAGE_BLOCKS_LIST(pageId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () =>
|
||||
pagesService.listPageBlocks(
|
||||
@ -131,13 +83,65 @@ const ProjectPages: NextPage = () => {
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: labels } = useSWR<IIssueLabels[]>(
|
||||
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => issuesService.getIssueLabels(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const updatePage = async (formData: IPage) => {
|
||||
if (!workspaceSlug || !projectId || !pageId) return;
|
||||
|
||||
if (!formData.name || formData.name.length === 0 || formData.name === "") return;
|
||||
|
||||
await pagesService
|
||||
.patchPage(workspaceSlug as string, projectId as string, pageId as string, formData)
|
||||
.then(() => {
|
||||
mutate<IPage>(
|
||||
PAGE_DETAILS(pageId as string),
|
||||
(prevData) => ({
|
||||
...prevData,
|
||||
...formData,
|
||||
}),
|
||||
false
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const partialUpdatePage = async (formData: Partial<IPage>) => {
|
||||
if (!workspaceSlug || !projectId || !pageId) return;
|
||||
|
||||
mutate<IPage>(
|
||||
PAGE_DETAILS(pageId as string),
|
||||
(prevData) => ({
|
||||
...(prevData as IPage),
|
||||
...formData,
|
||||
labels: formData.labels_list ? formData.labels_list : (prevData as IPage).labels,
|
||||
}),
|
||||
false
|
||||
);
|
||||
|
||||
await pagesService
|
||||
.patchPage(workspaceSlug as string, projectId as string, pageId as string, formData)
|
||||
.then(() => {
|
||||
mutate(PAGE_DETAILS(pageId as string));
|
||||
});
|
||||
};
|
||||
|
||||
const createPageBlock = async () => {
|
||||
if (!workspaceSlug || !projectId || !pageId) return;
|
||||
|
||||
await pagesService
|
||||
.createPageBlock(workspaceSlug as string, projectId as string, pageId as string, {
|
||||
name: "New block",
|
||||
})
|
||||
.then(() => {
|
||||
mutate(PAGE_BLOCK_LIST(pageId as string));
|
||||
.then((res) => {
|
||||
mutate<IPageBlock[]>(
|
||||
PAGE_BLOCKS_LIST(pageId as string),
|
||||
(prevData) => [...(prevData as IPageBlock[]), res],
|
||||
false
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
@ -148,6 +152,82 @@ const ProjectPages: NextPage = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddToFavorites = () => {
|
||||
if (!workspaceSlug || !projectId || !pageId) return;
|
||||
|
||||
mutate<IPage>(
|
||||
PAGE_DETAILS(pageId as string),
|
||||
(prevData) => ({
|
||||
...(prevData as IPage),
|
||||
is_favorite: true,
|
||||
}),
|
||||
false
|
||||
);
|
||||
|
||||
pagesService.addPageToFavorites(workspaceSlug as string, projectId as string, {
|
||||
page: pageId as string,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveFromFavorites = () => {
|
||||
if (!workspaceSlug || !projectId || !pageId) return;
|
||||
|
||||
mutate<IPage>(
|
||||
PAGE_DETAILS(pageId as string),
|
||||
(prevData) => ({
|
||||
...(prevData as IPage),
|
||||
is_favorite: false,
|
||||
}),
|
||||
false
|
||||
);
|
||||
|
||||
pagesService.removePageFromFavorites(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
pageId as string
|
||||
);
|
||||
};
|
||||
|
||||
const handleCopyText = () => {
|
||||
const originURL =
|
||||
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
|
||||
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/pages/${pageId}`).then(
|
||||
() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Link Copied!",
|
||||
message: "Page link copied to clipboard.",
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const options =
|
||||
labels?.map((label) => ({
|
||||
value: label.id,
|
||||
query: label.name,
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label.color && label.color !== "" ? label.color : "#000000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</div>
|
||||
),
|
||||
})) ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!pageDetails) return;
|
||||
|
||||
reset({
|
||||
...pageDetails,
|
||||
});
|
||||
}, [reset, pageDetails]);
|
||||
|
||||
return (
|
||||
<AppLayout
|
||||
meta={{
|
||||
@ -156,21 +236,205 @@ const ProjectPages: NextPage = () => {
|
||||
breadcrumbs={
|
||||
<Breadcrumbs>
|
||||
<BreadcrumbItem title="Projects" link={`/${workspaceSlug}/projects`} />
|
||||
<BreadcrumbItem title={`${activeProject?.name ?? "Project"} Pages`} />
|
||||
<BreadcrumbItem title={`${projectDetails?.name ?? "Project"} Pages`} />
|
||||
</Breadcrumbs>
|
||||
}
|
||||
>
|
||||
<div className="flex space-x-4 px-2">
|
||||
<button onClick={createPageBlock}>Li</button>
|
||||
<button onClick={() => {}}>P</button>
|
||||
</div>
|
||||
<div className="rounded border border-slate-200 bg-white p-4 ">
|
||||
{pageBlocks
|
||||
? pageBlocks.length === 0
|
||||
? "Write something..."
|
||||
: pageBlocks.map((pageBlock) => <PageBlock key={pageBlock.id} pageBlock={pageBlock} />)
|
||||
: "Loading..."}
|
||||
</div>
|
||||
{pageDetails ? (
|
||||
<div className="h-full w-full space-y-4 rounded-md border bg-white p-4">
|
||||
<div className="flex items-center justify-between gap-2 px-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 text-sm text-gray-500"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
Back
|
||||
</button>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{pageDetails.labels.length > 0 ? (
|
||||
<>
|
||||
{pageDetails.labels.map((labelId) => {
|
||||
const label = labels?.find((label) => label.id === labelId);
|
||||
|
||||
if (!label) return;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={label.id}
|
||||
className="group flex items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs"
|
||||
style={{
|
||||
backgroundColor: `${
|
||||
label?.color && label.color !== "" ? label.color : "#000000"
|
||||
}20`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
label?.color && label.color !== "" ? label.color : "#000000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<CustomSearchSelect
|
||||
customButton={
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-md bg-gray-100 p-1.5 text-xs hover:bg-gray-200"
|
||||
>
|
||||
<PlusIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
}
|
||||
value={pageDetails.labels}
|
||||
onChange={(val: string[]) => partialUpdatePage({ labels_list: val })}
|
||||
options={options}
|
||||
multiple
|
||||
noChevron
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<CustomSearchSelect
|
||||
customButton={
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-md bg-gray-100 px-3 py-1.5 text-xs hover:bg-gray-200"
|
||||
>
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
Add new label
|
||||
</button>
|
||||
}
|
||||
value={pageDetails.labels}
|
||||
onChange={(val: string[]) => partialUpdatePage({ labels_list: val })}
|
||||
options={options}
|
||||
multiple
|
||||
noChevron
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-500">
|
||||
{renderShortTime(pageDetails.created_at)}
|
||||
</span>
|
||||
<PrimaryButton className="flex items-center gap-2" onClick={handleCopyText}>
|
||||
<ShareIcon className="h-4 w-4" />
|
||||
Share
|
||||
</PrimaryButton>
|
||||
<button type="button" className="text-sm">
|
||||
AI
|
||||
</button>
|
||||
<div className="flex-shrink-0">
|
||||
<Popover className="relative grid place-items-center">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
type="button"
|
||||
className={`group inline-flex items-center outline-none ${
|
||||
open ? "text-gray-900" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{watch("color") && watch("color") !== "" ? (
|
||||
<span
|
||||
className="h-4 w-4 rounded"
|
||||
style={{
|
||||
backgroundColor: watch("color") ?? "black",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ColorPalletteIcon height={16} width={16} />
|
||||
)}
|
||||
</Popover.Button>
|
||||
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute top-full right-0 z-20 mt-1 max-w-xs px-2 sm:px-0">
|
||||
<TwitterPicker
|
||||
color={pageDetails.color}
|
||||
onChange={(val) => partialUpdatePage({ color: val.hex })}
|
||||
/>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
{pageDetails.is_favorite ? (
|
||||
<button onClick={handleRemoveFromFavorites} className="z-10">
|
||||
<StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" />
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={handleAddToFavorites} type="button" className="z-10">
|
||||
<StarIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<TextArea
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Enter issue name"
|
||||
value={watch("name")}
|
||||
onBlur={handleSubmit(updatePage)}
|
||||
onChange={(e) => setValue("name", e.target.value)}
|
||||
required={true}
|
||||
className="min-h-10 block w-full resize-none overflow-hidden rounded border-none bg-transparent px-3 py-2 text-2xl font-semibold outline-none ring-0 focus:ring-1 focus:ring-theme"
|
||||
role="textbox"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-3">
|
||||
{pageBlocks ? (
|
||||
pageBlocks.length === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded px-2.5 py-1 text-xs hover:bg-gray-100"
|
||||
onClick={createPageBlock}
|
||||
>
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
Add new block
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
{pageBlocks.map((block) => (
|
||||
<SinglePageBlock key={block.id} block={block} />
|
||||
))}
|
||||
</div>
|
||||
<div className="">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded px-2.5 py-1 text-xs hover:bg-gray-100"
|
||||
onClick={createPageBlock}
|
||||
>
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
Add new block
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<Loader>
|
||||
<Loader.Item height="150px" />
|
||||
<Loader.Item height="150px" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Loader>
|
||||
<Loader.Item height="200px" />
|
||||
</Loader>
|
||||
)}
|
||||
</AppLayout>
|
||||
);
|
||||
};
|
||||
@ -196,4 +460,4 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
};
|
||||
};
|
||||
|
||||
export default ProjectPages;
|
||||
export default SinglePage;
|
||||
|
@ -1,197 +1,248 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import type { GetServerSidePropsContext, NextPage } from "next";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { useForm } from "react-hook-form";
|
||||
// lib
|
||||
import { requiredAuth } from "lib/auth";
|
||||
|
||||
// headless ui
|
||||
import { Tab } from "@headlessui/react";
|
||||
// services
|
||||
import projectService from "services/project.service";
|
||||
import pagesService from "services/pages.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// icons
|
||||
import { PlusIcon } from "components/icons";
|
||||
// layouts
|
||||
import AppLayout from "layouts/app-layout";
|
||||
// ui
|
||||
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
|
||||
// fetching keys
|
||||
import { PAGE_LIST, PROJECT_DETAILS } from "constants/fetch-keys";
|
||||
// components
|
||||
import { HeaderButton } from "components/ui";
|
||||
import { CreateUpdatePageModal } from "components/pages/create-update-page-modal";
|
||||
import { PagesList } from "components/pages/pages-list";
|
||||
import { IPage } from "types";
|
||||
import PagesMasonry from "components/pages/pages-masonry";
|
||||
import { Tab } from "@headlessui/react";
|
||||
import { ListBulletIcon, RectangleGroupIcon, Squares2X2Icon } from "@heroicons/react/20/solid";
|
||||
import { PagesGrid } from "components/pages/pages-grid";
|
||||
import { RecentPagesList, CreateUpdatePageModal } from "components/pages";
|
||||
// ui
|
||||
import { HeaderButton, Input, PrimaryButton } from "components/ui";
|
||||
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
|
||||
// icons
|
||||
import { ListBulletIcon, RectangleGroupIcon } from "@heroicons/react/20/solid";
|
||||
// types
|
||||
import { IPage, TPageViewProps } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECT_DETAILS, RECENT_PAGES_LIST } from "constants/fetch-keys";
|
||||
|
||||
const TabPill: React.FC<any> = (props) => (
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
`rounded-full border px-5 py-1.5 text-sm outline-none ${
|
||||
selected
|
||||
? "border-theme bg-theme text-white"
|
||||
: "border-gray-300 bg-white hover:bg-hover-gray"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</Tab>
|
||||
const AllPagesList = dynamic<{ viewType: TPageViewProps }>(
|
||||
() => import("components/pages").then((a) => a.AllPagesList),
|
||||
{
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
const FavoritePagesList = dynamic<{ viewType: TPageViewProps }>(
|
||||
() => import("components/pages").then((a) => a.FavoritePagesList),
|
||||
{
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
const MyPagesList = dynamic<{ viewType: TPageViewProps }>(
|
||||
() => import("components/pages").then((a) => a.MyPagesList),
|
||||
{
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
const OtherPagesList = dynamic<{ viewType: TPageViewProps }>(
|
||||
() => import("components/pages").then((a) => a.OtherPagesList),
|
||||
{
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
const ProjectPages: NextPage = () => {
|
||||
const [isCreateUpdatePageModalOpen, setIsCreateUpdatePageModalOpen] = useState(false);
|
||||
const [selectedPage, setSelectedPage] = useState<IPage>();
|
||||
const [viewType, setViewType] = useState("list");
|
||||
const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false);
|
||||
|
||||
const [viewType, setViewType] = useState<TPageViewProps>("list");
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: activeProject } = useSWR(
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
watch,
|
||||
reset,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<Partial<IPage>>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
},
|
||||
});
|
||||
|
||||
const { data: projectDetails } = useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => projectService.getProject(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: pages } = useSWR(
|
||||
workspaceSlug && projectId ? PAGE_LIST(projectId as string) : null,
|
||||
const { data: recentPages } = useSWR(
|
||||
workspaceSlug && projectId ? RECENT_PAGES_LIST(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => pagesService.listPages(workspaceSlug as string, projectId as string)
|
||||
? () => pagesService.getRecentPages(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCreateUpdatePageModalOpen) return;
|
||||
const timer = setTimeout(() => {
|
||||
setSelectedPage(undefined);
|
||||
clearTimeout(timer);
|
||||
}, 500);
|
||||
const createPage = async (formData: Partial<IPage>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [isCreateUpdatePageModalOpen]);
|
||||
if (formData.name === "") {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Page name is required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await pagesService
|
||||
.createPage(workspaceSlug as string, projectId as string, formData)
|
||||
.then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Page created successfully.",
|
||||
});
|
||||
reset();
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Page could not be created. Please try again",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLayout
|
||||
meta={{
|
||||
title: "Plane - Pages",
|
||||
}}
|
||||
breadcrumbs={
|
||||
<Breadcrumbs>
|
||||
<BreadcrumbItem title="Projects" link={`/${workspaceSlug}/projects`} />
|
||||
<BreadcrumbItem title={`${activeProject?.name ?? "Project"} Pages`} />
|
||||
</Breadcrumbs>
|
||||
}
|
||||
right={
|
||||
<HeaderButton
|
||||
Icon={PlusIcon}
|
||||
label="Create Page"
|
||||
onClick={() => setIsCreateUpdatePageModalOpen(true)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<>
|
||||
<CreateUpdatePageModal
|
||||
isOpen={isCreateUpdatePageModalOpen}
|
||||
handleClose={() => setIsCreateUpdatePageModalOpen(false)}
|
||||
data={selectedPage}
|
||||
isOpen={createUpdatePageModal}
|
||||
handleClose={() => setCreateUpdatePageModal(false)}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white px-4 pt-3 pb-4 shadow-sm ">
|
||||
<label htmlFor="name" className="sr-only">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
className="block w-full border-0 pt-2.5 text-lg font-medium placeholder-gray-500 outline-none focus:ring-0"
|
||||
placeholder="Title"
|
||||
/>
|
||||
<label htmlFor="description" className="sr-only">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
name="description"
|
||||
id="description"
|
||||
className="block w-full resize-none border-0 pb-8 placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
|
||||
placeholder="Write something..."
|
||||
defaultValue={""}
|
||||
<AppLayout
|
||||
meta={{
|
||||
title: "Plane - Pages",
|
||||
}}
|
||||
breadcrumbs={
|
||||
<Breadcrumbs>
|
||||
<BreadcrumbItem title="Projects" link={`/${workspaceSlug}/projects`} />
|
||||
<BreadcrumbItem title={`${projectDetails?.name ?? "Project"} Pages`} />
|
||||
</Breadcrumbs>
|
||||
}
|
||||
right={
|
||||
<HeaderButton
|
||||
Icon={PlusIcon}
|
||||
label="Create Page"
|
||||
onClick={() => setCreateUpdatePageModal(true)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<form
|
||||
onSubmit={handleSubmit(createPage)}
|
||||
className="flex items-center justify-between gap-2 rounded-[10px] border border-gray-200 bg-white p-2 shadow-sm"
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
name="name"
|
||||
register={register}
|
||||
className="border-none outline-none focus:ring-0"
|
||||
placeholder="Type to create a new page..."
|
||||
/>
|
||||
{watch("name") !== "" && (
|
||||
<PrimaryButton type="submit" loading={isSubmitting}>
|
||||
{isSubmitting ? "Creating..." : "Create"}
|
||||
</PrimaryButton>
|
||||
)}
|
||||
</form>
|
||||
<div>
|
||||
<Tab.Group>
|
||||
<Tab.List as="div" className="flex items-center justify-between">
|
||||
<div className="flex gap-4">
|
||||
{["Recent", "All", "Favorites", "Created by me", "Created by others"].map(
|
||||
(tab, index) => (
|
||||
<Tab
|
||||
key={index}
|
||||
className={({ selected }) =>
|
||||
`rounded-full border px-5 py-1.5 text-sm outline-none ${
|
||||
selected
|
||||
? "border-theme bg-theme text-white"
|
||||
: "border-gray-300 bg-white hover:bg-hover-gray"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{tab}
|
||||
</Tab>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<button
|
||||
type="button"
|
||||
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${
|
||||
viewType === "list" ? "bg-gray-200" : ""
|
||||
}`}
|
||||
onClick={() => setViewType("list")}
|
||||
>
|
||||
<ListBulletIcon className="h-4 w-4" />
|
||||
</button>
|
||||
{/* <button
|
||||
type="button"
|
||||
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${
|
||||
viewType === "detailed" ? "bg-gray-200" : ""
|
||||
}`}
|
||||
onClick={() => setViewType("detailed")}
|
||||
>
|
||||
<Squares2X2Icon className="h-4 w-4" />
|
||||
</button> */}
|
||||
<button
|
||||
type="button"
|
||||
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${
|
||||
viewType === "masonry" ? "bg-gray-200" : ""
|
||||
}`}
|
||||
onClick={() => setViewType("masonry")}
|
||||
>
|
||||
<RectangleGroupIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
<Tab.Panel>
|
||||
<RecentPagesList pages={recentPages} viewType={viewType} />
|
||||
</Tab.Panel>
|
||||
<Tab.Panel>
|
||||
<AllPagesList viewType={viewType} />
|
||||
</Tab.Panel>
|
||||
<Tab.Panel>
|
||||
<FavoritePagesList viewType={viewType} />
|
||||
</Tab.Panel>
|
||||
<Tab.Panel>
|
||||
<MyPagesList viewType={viewType} />
|
||||
</Tab.Panel>
|
||||
<Tab.Panel>
|
||||
<OtherPagesList viewType={viewType} />
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <div className="space-y-2 pb-8">
|
||||
<h3 className="text-3xl font-semibold text-black">Pages</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
Note down all the important and minor details in the way you want to.
|
||||
</p>
|
||||
</div> */}
|
||||
<div>
|
||||
<Tab.Group>
|
||||
<Tab.List as="div" className="flex items-center justify-between ">
|
||||
<div className="flex gap-4 text-base font-medium">
|
||||
<TabPill>Recent</TabPill>
|
||||
<TabPill>All</TabPill>
|
||||
<TabPill>Favorites</TabPill>
|
||||
<TabPill>Created by me</TabPill>
|
||||
<TabPill>Created by others</TabPill>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<button
|
||||
type="button"
|
||||
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${
|
||||
viewType === "list" ? "bg-gray-200" : ""
|
||||
}`}
|
||||
onClick={() => setViewType("list")}
|
||||
>
|
||||
<ListBulletIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${
|
||||
viewType === "grid" ? "bg-gray-200" : ""
|
||||
}`}
|
||||
onClick={() => setViewType("grid")}
|
||||
>
|
||||
<Squares2X2Icon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${
|
||||
viewType === "masonry" ? "bg-gray-200" : ""
|
||||
}`}
|
||||
onClick={() => setViewType("masonry")}
|
||||
>
|
||||
<RectangleGroupIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Tab.List>
|
||||
</Tab.Group>
|
||||
</div>
|
||||
|
||||
{viewType === "list" && (
|
||||
<PagesList
|
||||
setSelectedPage={setSelectedPage}
|
||||
setCreateUpdatePageModal={setIsCreateUpdatePageModalOpen}
|
||||
pages={pages}
|
||||
/>
|
||||
)}
|
||||
{viewType === "grid" && (
|
||||
<PagesGrid
|
||||
setSelectedPage={setSelectedPage}
|
||||
setCreateUpdatePageModal={setIsCreateUpdatePageModalOpen}
|
||||
pages={pages}
|
||||
/>
|
||||
)}
|
||||
{viewType === "masonry" && <PagesMasonry />}
|
||||
</div>
|
||||
</AppLayout>
|
||||
</AppLayout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
40
apps/app/public/empty-state/empty-page.svg
Normal file
40
apps/app/public/empty-state/empty-page.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 19 KiB |
@ -1,7 +1,8 @@
|
||||
// services
|
||||
import APIService from "services/api.service";
|
||||
import { IIssue } from "types";
|
||||
// types
|
||||
import { IPage, IPageBlock, IPageBlockForm, IPageFavorite, IPageForm } from "types/pages";
|
||||
import { IPage, IPageBlock, RecentPagesResponse } from "types/pages";
|
||||
|
||||
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
|
||||
|
||||
@ -10,7 +11,7 @@ class PageServices extends APIService {
|
||||
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
|
||||
}
|
||||
|
||||
async createPage(workspaceSlug: string, projectId: string, data: IPageForm): Promise<IPage> {
|
||||
async createPage(workspaceSlug: string, projectId: string, data: Partial<IPage>): Promise<IPage> {
|
||||
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/`, data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
@ -22,7 +23,7 @@ class PageServices extends APIService {
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
pageId: string,
|
||||
data: Partial<IPageForm>
|
||||
data: Partial<IPage>
|
||||
): Promise<IPage> {
|
||||
return this.patch(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`,
|
||||
@ -48,7 +49,7 @@ class PageServices extends APIService {
|
||||
data: {
|
||||
page: string;
|
||||
}
|
||||
): Promise<IPageFavorite> {
|
||||
): Promise<any> {
|
||||
return this.post(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/user-favorite-pages/`,
|
||||
data
|
||||
@ -69,7 +70,15 @@ class PageServices extends APIService {
|
||||
});
|
||||
}
|
||||
|
||||
async listPages(workspaceSlug: string, projectId: string): Promise<IPage[]> {
|
||||
async getRecentPages(workspaceSlug: string, projectId: string): Promise<RecentPagesResponse> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/recent-pages/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getAllPages(workspaceSlug: string, projectId: string): Promise<IPage[]> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
@ -77,12 +86,46 @@ class PageServices extends APIService {
|
||||
});
|
||||
}
|
||||
|
||||
async getFavoritePages(workspaceSlug: string, projectId: string): Promise<IPage[]> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/favorite-pages/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getMyPages(workspaceSlug: string, projectId: string): Promise<IPage[]> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/my-pages/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getOtherPages(workspaceSlug: string, projectId: string): Promise<IPage[]> {
|
||||
return this.get(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/created-by-other-pages/`
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getPageDetails(workspaceSlug: string, projectId: string, pageId: string): Promise<IPage> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async createPageBlock(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
pageId: string,
|
||||
data: IPageBlockForm
|
||||
): Promise<IPage> {
|
||||
data: Partial<IPageBlock>
|
||||
): Promise<IPageBlock> {
|
||||
return this.post(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/page-blocks/`,
|
||||
data
|
||||
@ -113,7 +156,7 @@ class PageServices extends APIService {
|
||||
projectId: string,
|
||||
pageId: string,
|
||||
pageBlockId: string,
|
||||
data: Partial<IPageBlockForm>
|
||||
data: Partial<IPageBlock>
|
||||
): Promise<IPage> {
|
||||
return this.patch(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/page-blocks/${pageBlockId}/`,
|
||||
@ -153,6 +196,21 @@ class PageServices extends APIService {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async convertPageBlockToIssue(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
pageId: string,
|
||||
blockId: string
|
||||
): Promise<IIssue> {
|
||||
return this.post(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/page-blocks/${blockId}/issues/`
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new PageServices();
|
||||
|
@ -358,7 +358,8 @@ img.ProseMirror-separator {
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.issue-comments-section .remirror-editor-wrapper .remirror-editor {
|
||||
.issue-comments-section .remirror-editor-wrapper .remirror-editor,
|
||||
.page-block-section .remirror-editor-wrapper .remirror-editor {
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
|
88
apps/app/types/pages.d.ts
vendored
88
apps/app/types/pages.d.ts
vendored
@ -1,74 +1,48 @@
|
||||
export interface LabelDetail {
|
||||
id: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
name: string;
|
||||
description: string;
|
||||
color: string;
|
||||
created_by: string;
|
||||
updated_by: string;
|
||||
project: string;
|
||||
workspace: string;
|
||||
parent: string | null;
|
||||
}
|
||||
// types
|
||||
import { IIssueLabels } from "./issues";
|
||||
|
||||
export interface IPage {
|
||||
id: string;
|
||||
is_favorite: boolean;
|
||||
access: number;
|
||||
blocks: IPageBlock[];
|
||||
color: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
name: string;
|
||||
labels: string[];
|
||||
label_details: LabelDetail[];
|
||||
created_by: string;
|
||||
description: string;
|
||||
description_html: string;
|
||||
description_stripped: string | null;
|
||||
access: number;
|
||||
created_by: string;
|
||||
updated_by: string;
|
||||
project: string;
|
||||
workspace: string;
|
||||
id: string;
|
||||
is_favorite: boolean;
|
||||
label_details: IIssueLabels[];
|
||||
labels: string[];
|
||||
labels_list: string[];
|
||||
name: string;
|
||||
owned_by: string;
|
||||
project: string;
|
||||
updated_at: Date;
|
||||
updated_by: string;
|
||||
workspace: string;
|
||||
}
|
||||
|
||||
export interface IPageForm {
|
||||
name: string;
|
||||
description?: string;
|
||||
labels_list?: string[];
|
||||
export interface RecentPagesResponse {
|
||||
[key: string]: IPage[];
|
||||
}
|
||||
|
||||
export interface IPageBlock {
|
||||
id: string;
|
||||
issue_detail: string | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
name: string;
|
||||
description: string;
|
||||
description_html: string;
|
||||
description_stripped: string | null;
|
||||
completed_at: Date | null;
|
||||
created_by: string;
|
||||
updated_by: string;
|
||||
project: string;
|
||||
workspace: string;
|
||||
page: string;
|
||||
issue: string | null;
|
||||
}
|
||||
|
||||
export interface IPageBlockForm {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface IPageFavorite {
|
||||
id: string;
|
||||
page_detail: IPage;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
created_by: string;
|
||||
updated_by: string;
|
||||
project: string;
|
||||
workspace: string;
|
||||
user: string;
|
||||
description: any;
|
||||
description_html: any;
|
||||
id: string;
|
||||
issue: string | null;
|
||||
issue_detail: string | null;
|
||||
name: string;
|
||||
page: string;
|
||||
project: string;
|
||||
sort_order: number;
|
||||
updated_at: Date;
|
||||
updated_by: string;
|
||||
workspace: string;
|
||||
}
|
||||
|
||||
export type TPageViewProps = "list" | "detailed" | "masonry";
|
||||
|
Loading…
Reference in New Issue
Block a user