feat: monorepo

This commit is contained in:
Henry
2024-12-12 14:34:10 +00:00
parent b8eed7a90c
commit 0c8d17dce5
370 changed files with 10280 additions and 39805 deletions

View File

@@ -0,0 +1,221 @@
import { formatDistanceToNow } from "date-fns";
import {
HiOutlineArrowLeft,
HiOutlineArrowRight,
HiOutlinePencil,
HiOutlinePlus,
HiOutlineTag,
HiOutlineUserMinus,
HiOutlineUserPlus,
} from "react-icons/hi2";
import { type GetCardByIdOutput } from "@kan/api/types";
import Avatar from "~/components/Avatar";
import Comment from "./Comment";
type ActivityType =
NonNullable<GetCardByIdOutput>["activities"][number]["type"];
const ACTIVITY_TYPE_MAP = {
"card.created": "created the card",
"card.updated.title": "updated the title",
"card.updated.description": "updated the description",
"card.updated.list": "moved the card to another list",
"card.updated.label.added": "added a label to the card",
"card.updated.label.removed": "removed a label from the card",
"card.updated.member.added": "added a member to the card",
"card.updated.member.removed": "removed a member from the card",
} as const;
const getActivityText = ({
type,
toTitle,
fromList,
toList,
memberName,
isSelf,
label,
}: {
type: ActivityType;
toTitle: string | null;
fromList: string | null;
toList: string | null;
memberName: string | null;
isSelf: boolean;
label: string | null;
}) => {
if (!(type in ACTIVITY_TYPE_MAP)) return null;
const baseText = ACTIVITY_TYPE_MAP[type as keyof typeof ACTIVITY_TYPE_MAP];
const TextHighlight = ({ children }: { children: React.ReactNode }) => (
<span className="font-medium text-light-1000 dark:text-dark-1000">
{children}
</span>
);
if (type === "card.updated.title" && toTitle) {
return (
<>
updated the title to <TextHighlight>{toTitle}</TextHighlight>
</>
);
}
if (type === "card.updated.list" && fromList && toList) {
return (
<>
moved the card from <TextHighlight>{fromList}</TextHighlight> to
<TextHighlight>{toList}</TextHighlight>
</>
);
}
if (type === "card.updated.member.added" && memberName) {
if (isSelf) return <>self-assigned the card</>;
return (
<>
assigned <TextHighlight>{memberName}</TextHighlight> to the card
</>
);
}
if (type === "card.updated.member.removed" && memberName) {
if (isSelf) return <>unassigned themselves from the card</>;
return (
<>
unassigned <TextHighlight>{memberName}</TextHighlight> from the card
</>
);
}
if (type === "card.updated.label.added" && label) {
return (
<>
added label <TextHighlight>{label}</TextHighlight>
</>
);
}
if (type === "card.updated.label.removed" && label) {
return (
<>
removed label <TextHighlight>{label}</TextHighlight>
</>
);
}
return baseText;
};
const ACTIVITY_ICON_MAP: Partial<Record<ActivityType, React.ReactNode | null>> =
{
"card.created": <HiOutlinePlus />,
"card.updated.title": <HiOutlinePencil />,
"card.updated.description": <HiOutlinePencil />,
"card.updated.label.added": <HiOutlineTag />,
"card.updated.label.removed": <HiOutlineTag />,
"card.updated.member.added": <HiOutlineUserPlus />,
"card.updated.member.removed": <HiOutlineUserMinus />,
} as const;
const getActivityIcon = (
type: ActivityType,
fromIndex?: number | null,
toIndex?: number | null,
): React.ReactNode | null => {
console.log({ fromIndex, toIndex });
if (type === "card.updated.list" && fromIndex != null && toIndex != null) {
return fromIndex > toIndex ? (
<HiOutlineArrowLeft />
) : (
<HiOutlineArrowRight />
);
}
return ACTIVITY_ICON_MAP[type] ?? null;
};
const ActivityList = ({
activities,
cardPublicId,
isLoading,
}: {
activities: NonNullable<GetCardByIdOutput>["activities"];
cardPublicId: string;
isLoading: boolean;
}) => {
return (
<div className="flex flex-col space-y-4 pt-4">
{activities?.map((activity, index) => {
const activityText = getActivityText({
type: activity.type,
toTitle: activity.toTitle,
fromList: activity.fromList?.name ?? null,
toList: activity.toList?.name ?? null,
memberName: activity.member?.user?.name ?? null,
isSelf: activity.member?.user?.id === activity.user?.id,
label: activity.label?.name ?? null,
});
if (activity.type === "card.updated.comment.added")
return (
<Comment
key={activity.publicId}
publicId={activity.comment?.publicId}
cardPublicId={cardPublicId}
name={activity.user?.name ?? ""}
email={activity.user?.email ?? ""}
isLoading={isLoading}
createdAt={activity.createdAt}
comment={activity.comment?.comment}
isEdited={!!activity.comment?.updatedAt}
/>
);
if (!activityText) return null;
return (
<div
key={activity.publicId}
className="relative flex items-center space-x-2"
>
<div className="relative">
<Avatar
size="sm"
name={activity.user?.name ?? ""}
email={activity.user?.email ?? ""}
icon={getActivityIcon(
activity.type,
activity.fromList?.index,
activity.toList?.index,
)}
isLoading={isLoading}
/>
{index !== activities.length - 1 &&
activities[index + 1]?.type !==
"card.updated.comment.added" && (
<div className="absolute bottom-[-14px] left-1/2 top-[30px] w-0.5 -translate-x-1/2 bg-light-600 dark:bg-dark-600" />
)}
</div>
<p className="text-sm">
<span className="font-medium dark:text-dark-1000">{`${activity.user?.name} `}</span>
<span className="space-x-1 text-light-900 dark:text-dark-800">
{activityText}
</span>
<span className="mx-1 text-light-900 dark:text-dark-800">·</span>
<span className="space-x-1 text-light-900 dark:text-dark-800">
{formatDistanceToNow(new Date(activity.createdAt), {
addSuffix: true,
})}
</span>
</p>
</div>
);
})}
</div>
);
};
export default ActivityList;

View File

@@ -0,0 +1,146 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import ContentEditable from "react-contenteditable";
import { formatDistanceToNow } from "date-fns";
import { api } from "~/utils/api";
import { usePopup } from "~/providers/popup";
import Avatar from "~/components/Avatar";
import Button from "~/components/Button";
import Dropdown from "~/components/Dropdown";
import { HiEllipsisHorizontal, HiPencil } from "react-icons/hi2";
interface FormValues {
comment: string;
}
const Comment = ({
publicId,
cardPublicId,
name,
email,
isLoading,
createdAt,
comment,
isEdited = false,
}: {
publicId: string | undefined;
cardPublicId: string;
name: string;
email: string;
isLoading: boolean;
createdAt: string;
comment: string | undefined;
isEdited: boolean;
}) => {
const [isEditing, setIsEditing] = useState(false);
const utils = api.useUtils();
const { showPopup } = usePopup();
const { handleSubmit, setValue, watch } = useForm<FormValues>({
defaultValues: {
comment,
},
});
if (!publicId) return null;
const updateCommentMutation = api.card.updateComment.useMutation({
onSuccess: async () => {
await utils.card.byId.refetch();
setIsEditing(false);
},
onError: () => {
showPopup({
header: "Unable to update comment",
message: "Please try again later, or contact customer support.",
});
},
});
const onSubmit = (data: FormValues) => {
updateCommentMutation.mutate({
cardPublicId,
comment: data.comment,
commentPublicId: publicId,
});
};
return (
<div
key={publicId}
className="group relative flex w-full flex-col rounded-xl border border-light-600 bg-light-200 p-4 text-light-900 focus-visible:outline-none dark:border-dark-400 dark:bg-dark-100 dark:text-dark-1000 sm:text-sm sm:leading-6"
>
<div className="flex justify-between">
<div className="flex items-center space-x-2">
<Avatar
size="sm"
name={name ?? ""}
email={email ?? ""}
isLoading={isLoading}
/>
<p className="text-sm">
<span className="font-medium dark:text-dark-1000">{`${name} `}</span>
<span className="mx-1 text-light-900 dark:text-dark-800">·</span>
<span className="space-x-1 text-light-900 dark:text-dark-800">
{formatDistanceToNow(new Date(createdAt), {
addSuffix: true,
})}
</span>
{isEdited && (
<span className="text-light-900 dark:text-dark-800">
{" (edited)"}
</span>
)}
</p>
</div>
<div className="absolute right-4 top-4">
<Dropdown
items={[
{
label: "Edit comment",
action: () => setIsEditing(true),
icon: <HiPencil className="h-[18px] w-[18px] text-dark-900" />,
},
]}
>
<HiEllipsisHorizontal className="h-5 w-5 text-light-900 dark:text-dark-800" />
</Dropdown>
</div>
</div>
{!isEditing ? (
<p className="mt-2 text-sm">{comment}</p>
) : (
<form onSubmit={handleSubmit(onSubmit)}>
<ContentEditable
placeholder="Add a comment..."
html={watch("comment")}
disabled={false}
onChange={(e) => setValue("comment", e.target.value)}
className="block w-full border-0 bg-transparent py-1.5 text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6"
/>
<div className="flex justify-end space-x-2">
<Button
size="sm"
variant="ghost"
onClick={() => setIsEditing(false)}
>
Cancel
</Button>
<Button
isLoading={updateCommentMutation.isPending}
type="submit"
size="sm"
>
Save
</Button>
</div>
</form>
)}
</div>
);
};
export default Comment;

View File

@@ -0,0 +1,67 @@
import { useRouter } from "next/navigation";
import { api } from "~/utils/api";
import { useModal } from "~/providers/modal";
import { useBoard } from "~/providers/board";
import { usePopup } from "~/providers/popup";
interface DeleteCardConfirmationProps {
cardPublicId: string;
boardPublicId: string;
}
export function DeleteCardConfirmation({
cardPublicId,
boardPublicId,
}: DeleteCardConfirmationProps) {
const { closeModal } = useModal();
const router = useRouter();
const { removeCard, refetchBoard } = useBoard();
const { showPopup } = usePopup();
const deleteCardMutation = api.card.delete.useMutation({
onSuccess: () => refetchBoard(),
onError: () =>
showPopup({
header: "Error deleting card",
message: "Please try again later, or contact customer support.",
}),
});
const handleDeleteCard = () => {
removeCard({
cardPublicId,
});
closeModal();
router.push(`/boards/${boardPublicId}`);
deleteCardMutation.mutate({
cardPublicId,
});
};
return (
<div className="p-5">
<div className="flex w-full flex-col justify-between pb-4">
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
Are you sure you want to delete this card?
</h2>
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
{"This action can't be undone."}
</p>
</div>
<div className="mt-5 flex justify-end sm:mt-6">
<button
className="mr-4 inline-flex justify-center rounded-md border-[1px] border-light-600 bg-light-50 px-3 py-2 text-sm font-semibold text-neutral-900 shadow-sm focus-visible:outline-none dark:border-dark-600 dark:bg-dark-300 dark:text-dark-1000"
onClick={() => closeModal()}
>
Cancel
</button>
<button
onClick={handleDeleteCard}
className="inline-flex justify-center rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none dark:bg-dark-1000 dark:text-dark-50"
>
Delete
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { api } from "~/utils/api";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import Button from "~/components/Button";
export function DeleteLabelConfirmation({
cardPublicId,
labelPublicId,
}: {
cardPublicId: string;
labelPublicId: string;
}) {
const utils = api.useUtils();
const { closeModal } = useModal();
const { showPopup } = usePopup();
const refetchCard = () => utils.card.byId.refetch({ cardPublicId });
const deleteLabelMutation = api.label.delete.useMutation({
onSuccess: () => refetchCard(),
onError: () =>
showPopup({
header: "Error deleting label",
message: "Please try again later, or contact customer support.",
}),
});
const handleDeleteLabel = () => {
closeModal();
deleteLabelMutation.mutate({
labelPublicId,
});
};
return (
<div className="p-5">
<div className="flex w-full flex-col justify-between pb-4">
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
Are you sure you want to delete this label?
</h2>
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
{"This action can't be undone."}
</p>
</div>
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
<Button variant="secondary" onClick={() => closeModal()}>
Cancel
</Button>
<Button onClick={handleDeleteLabel}>Delete</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,44 @@
import { Fragment } from "react";
import { Menu, Transition } from "@headlessui/react";
import { HiEllipsisHorizontal } from "react-icons/hi2";
import { useModal } from "~/providers/modal";
export default function Dropdown() {
const { openModal } = useModal();
return (
<Menu as="div" className="relative inline-block text-left">
<div>
<Menu.Button className="flex h-8 w-8 items-center justify-center rounded-[5px] hover:bg-light-200 dark:hover:bg-dark-200">
<HiEllipsisHorizontal
size={25}
className="text-light-900 dark:text-dark-900"
/>
</Menu.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute right-0 z-30 mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
<div className="flex">
<Menu.Item>
<button
onClick={() => openModal("DELETE_CARD")}
className="m-1 w-full rounded-[5px] px-3 py-2 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
>
Delete card
</button>
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>
);
}

View File

@@ -0,0 +1,229 @@
import { Fragment } from "react";
import { HiChevronUpDown, HiXMark } from "react-icons/hi2";
import { useForm, Controller } from "react-hook-form";
import { Listbox, Transition } from "@headlessui/react";
import { api } from "~/utils/api";
import { useModal } from "~/providers/modal";
import Button from "~/components/Button";
import Input from "~/components/Input";
import Toggle from "~/components/Toggle";
type LabelFormInput = {
name: string;
colour: Colour;
isCreateAnotherEnabled?: boolean;
};
type Colour = {
name: string;
code: string;
};
const colours = [
{ name: "Teal", code: "#0d9488" },
{ name: "Green", code: "#65a30d" },
{ name: "Blue", code: "#0284c7" },
{ name: "Purple", code: "#4f46e5" },
{ name: "Yellow", code: "#ca8a04" },
{ name: "Orange", code: "#ea580c" },
{ name: "Red", code: "#dc2626" },
{ name: "Pink", code: "#db2777" },
];
export function LabelForm({
cardPublicId,
isEdit,
}: {
cardPublicId: string;
isEdit?: boolean;
}) {
const utils = api.useUtils();
const { closeModal, entityId, openModal } = useModal();
const label = api.label.byPublicId.useQuery(
{
labelPublicId: entityId,
},
{
enabled: isEdit && !!entityId,
},
);
const { control, register, reset, handleSubmit, setValue, watch } =
useForm<LabelFormInput>({
values: {
name: isEdit && label?.data?.name ? label?.data?.name : "",
colour: (isEdit && label?.data?.colourCode
? colours.find((c) => c.code === label?.data?.colourCode)
: colours[0]) as Colour,
isCreateAnotherEnabled: false,
},
});
const refetchCard = () => utils.card.byId.refetch({ cardPublicId });
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
const createLabel = api.label.create.useMutation({
onSuccess: async () => {
const currentColourIndex = colours.findIndex(
(c) => c.code === watch("colour").code,
);
try {
await refetchCard();
if (!isCreateAnotherEnabled) closeModal();
reset({
name: "",
colour: colours[(currentColourIndex + 1) % colours.length],
isCreateAnotherEnabled,
});
} catch (e) {
console.log(e);
}
},
});
const updateLabel = api.label.update.useMutation({
onSuccess: async () => {
await refetchCard();
closeModal();
reset({
name: "",
colour: colours[0],
});
},
});
const onSubmit = (values: LabelFormInput) => {
if (!values.colour?.code) return;
if (isEdit) {
updateLabel.mutate({
labelPublicId: label.data?.publicId ?? "",
name: values.name,
colourCode: values.colour.code,
});
} else {
createLabel.mutate({
name: values.name,
cardPublicId,
colourCode: values.colour.code,
});
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="px-5 pt-5">
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
<h2 className="text-sm font-medium">
{isEdit ? "Edit label" : "New label"}
</h2>
<button
className="rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
closeModal();
}}
>
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
</button>
</div>
<Input id="label-name" placeholder="Name" {...register("name")} />
<Controller
name="colour"
control={control}
render={({ field }) => (
<Listbox {...field}>
{({ open }) => (
<>
<div className="relative mt-4">
<Listbox.Button className="block w-full rounded-md border-0 bg-white/5 px-4 py-1.5 shadow-sm ring-1 ring-inset ring-light-600 focus:ring-2 focus:ring-inset focus:ring-light-600 dark:bg-dark-300 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6">
<span className="flex items-center">
<span
style={{ backgroundColor: field.value?.code }}
className={`inline-block h-2 w-2 flex-shrink-0 rounded-full`}
/>
<span className="ml-3 block truncate">
{field.value?.name}
</span>
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<HiChevronUpDown
className="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</Listbox.Button>
<Transition
show={open}
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-light-50 py-2 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:bg-dark-300 sm:text-sm">
{colours.map((colour, index) => (
<Listbox.Option
key={`colours_${index}`}
className="relative cursor-default select-none px-2 text-neutral-900 dark:text-dark-1000 "
value={colour}
>
{() => (
<>
<div className="flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-400">
<span
style={{ backgroundColor: colour?.code }}
className="ml-2 inline-block h-2 w-2 flex-shrink-0 rounded-full"
aria-hidden="true"
/>
<span className="ml-3 block truncate font-normal">
{colour.name}
</span>
</div>
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
/>
</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
{!isEdit && (
<Toggle
label="Create another"
isChecked={!!isCreateAnotherEnabled}
onChange={() =>
setValue("isCreateAnotherEnabled", !isCreateAnotherEnabled)
}
/>
)}
<div className="space-x-2">
{isEdit && (
<Button
variant="secondary"
onClick={() => openModal("DELETE_LABEL", entityId)}
>
Delete
</Button>
)}
<Button type="submit">
{isEdit ? "Update label" : "Create label"}
</Button>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,165 @@
import { Fragment } from "react";
import { api } from "~/utils/api";
import { Menu, Transition } from "@headlessui/react";
import { HiMiniPlus } from "react-icons/hi2";
import { useForm } from "react-hook-form";
import { useModal } from "~/providers/modal";
import { HiEllipsisHorizontal } from "react-icons/hi2";
interface LabelSelectorProps {
cardPublicId: string;
labels: {
publicId: string;
name: string;
selected: boolean;
colourCode: string;
}[];
isLoading: boolean;
}
export default function LabelSelector({
cardPublicId,
labels,
isLoading,
}: LabelSelectorProps) {
const { openModal } = useModal();
const utils = api.useUtils();
const refetchCard = () => utils.card.byId.refetch({ cardPublicId });
const addOrRemoveLabel = api.card.addOrRemoveLabel.useMutation({
onSuccess: async () => {
await refetchCard();
},
});
const { register, handleSubmit, setValue, watch } = useForm({
values: Object.fromEntries(
labels?.map((label) => [label.publicId, label.selected]) ?? [],
),
});
const onSubmit = (values: Record<string, boolean>) => {
console.log({ values });
};
const selectedLabels = labels.filter((label) => label.selected);
return (
<>
{isLoading ? (
<div className="flex w-full">
<div className="h-full w-[175px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
</div>
) : (
<Menu
as="div"
className="relative flex w-full flex-wrap items-center text-left"
>
{selectedLabels.length ? (
<>
{selectedLabels.map((label) => (
<Menu.Button
key={label.publicId}
className="my-1 mr-2 inline-flex w-fit items-center gap-x-1.5 rounded-full px-2 py-1 text-[12px] font-medium text-light-800 ring-1 ring-inset ring-light-600 dark:text-dark-1000 dark:ring-dark-800"
>
<svg
fill={label.colourCode}
className="h-2 w-2"
viewBox="0 0 6 6"
aria-hidden="true"
>
<circle cx={3} cy={3} r={3} />
</svg>
<div>{label.name}</div>
</Menu.Button>
))}
<Menu.Button className="my-1 inline-flex w-fit items-center gap-x-1.5 rounded-full py-1 pl-2 pr-4 text-[12px] font-medium text-dark-800 ring-inset ring-dark-800 hover:bg-light-400 dark:hover:bg-dark-200">
<HiMiniPlus size={16} />
Add label
</Menu.Button>
</>
) : (
<Menu.Button className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-200 pl-2 text-left text-sm text-neutral-900 hover:bg-light-300 dark:border-dark-100 dark:text-dark-1000 dark:hover:border-dark-300 dark:hover:bg-dark-200">
<HiMiniPlus size={22} className="pr-2" />
Add label
</Menu.Button>
)}
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute right-[200px] top-[30px] z-10 mt-2 w-56 origin-top-right rounded-md border-[1px] border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-500 dark:bg-dark-200">
<div className="p-2">
<form onSubmit={handleSubmit(onSubmit)}>
{labels?.map((label) => (
<Menu.Item key={label.publicId}>
{() => (
<div
key={label.publicId}
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={() => {
const newValue = !watch(label.publicId);
setValue(label.publicId, newValue);
addOrRemoveLabel.mutate({
cardPublicId,
labelPublicId: label.publicId,
});
handleSubmit(onSubmit);
}}
>
<input
id={label.publicId}
type="checkbox"
className="h-[14px] w-[14px] rounded bg-transparent"
onClick={(event) => event.stopPropagation()}
{...register(label.publicId)}
checked={watch(label.publicId)}
/>
<div className="flex w-full items-center justify-between">
<label
htmlFor={label.publicId}
className="ml-3 text-sm"
>
{label.name}
</label>
<button
className="invisible group-hover:visible"
onClick={(event) => {
event.stopPropagation();
openModal("EDIT_LABEL", label.publicId);
}}
>
<HiEllipsisHorizontal size={20} />
</button>
</div>
</div>
)}
</Menu.Item>
))}
<button
onClick={() => openModal("NEW_LABEL")}
className="flex w-full items-center rounded-[5px] p-1.5 px-2 text-sm hover:bg-light-200 dark:hover:bg-dark-300"
>
<HiMiniPlus size={22} className="pr-2" />
Create new label
</button>
</form>
</div>
</Menu.Items>
</Transition>
</Menu>
)}
</>
);
}

View File

@@ -0,0 +1,114 @@
import { Fragment } from "react";
import { api } from "~/utils/api";
import { Menu, Transition } from "@headlessui/react";
import { useForm } from "react-hook-form";
interface ListSelectorProps {
cardPublicId: string;
lists: {
publicId: string;
name: string;
selected: boolean;
}[];
isLoading: boolean;
}
export default function ListSelector({
cardPublicId,
lists,
isLoading,
}: ListSelectorProps) {
const utils = api.useUtils();
const refetchCard = () => utils.card.byId.refetch({ cardPublicId });
const updateCardList = api.card.reorder.useMutation({
onSuccess: async () => {
await refetchCard();
},
});
const { register, handleSubmit, setValue, watch } = useForm({
values: Object.fromEntries(
lists?.map((list) => [list.publicId, list.selected]) ?? [],
),
});
const onSubmit = (values: Record<string, boolean>) => {
console.log({ values });
};
const selectedList = lists.find((list) => list.selected);
return (
<>
{isLoading ? (
<div className="flex w-full">
<div className="h-full w-[150px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
</div>
) : (
<Menu
as="div"
className="relative flex w-full flex-wrap items-center text-left"
>
<Menu.Button className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-200 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-300 dark:border-dark-100 dark:text-dark-1000 dark:hover:border-dark-300 dark:hover:bg-dark-200">
{selectedList?.name}
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute right-[200px] top-[30px] z-10 mt-2 w-56 origin-top-right rounded-md border-[1px] border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-500 dark:bg-dark-200">
<div className="p-2">
<form onSubmit={handleSubmit(onSubmit)}>
{lists?.map((list) => (
<Menu.Item key={list.publicId}>
{() => (
<div
key={list.publicId}
className="flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={() => {
const newValue = !watch(list.publicId);
setValue(list.publicId, newValue);
updateCardList.mutate({
cardPublicId,
newListPublicId: list.publicId,
});
handleSubmit(onSubmit);
}}
>
<input
id={list.publicId}
type="checkbox"
className="h-[14px] w-[14px] rounded bg-transparent"
onClick={(event) => event.stopPropagation()}
{...register(list.publicId)}
checked={watch(list.publicId)}
/>
<label
htmlFor={list.publicId}
className="ml-3 text-sm"
>
{list.name}
</label>
</div>
)}
</Menu.Item>
))}
</form>
</div>
</Menu.Items>
</Transition>
</Menu>
)}
</>
);
}

View File

@@ -0,0 +1,141 @@
import { Fragment } from "react";
import { api } from "~/utils/api";
import { Menu, Transition } from "@headlessui/react";
import { HiMiniPlus } from "react-icons/hi2";
import { useForm } from "react-hook-form";
interface MemberSelectorProps {
cardPublicId: string;
members: {
publicId: string;
user: {
id: string;
name: string | null;
};
selected: boolean;
}[];
isLoading: boolean;
}
export default function MemberSelector({
cardPublicId,
members,
isLoading,
}: MemberSelectorProps) {
const utils = api.useUtils();
const refetchCard = () => utils.card.byId.refetch({ cardPublicId });
const addOrRemoveMember = api.card.addOrRemoveMember.useMutation({
onSuccess: async () => {
await refetchCard();
},
});
const { register, handleSubmit, setValue, watch } = useForm({
values: Object.fromEntries(
members?.map((member) => [member.publicId, member.selected]) ?? [],
),
});
const onSubmit = (values: Record<string, boolean>) => {
console.log({ values });
};
const selectedMembers = members.filter((member) => member.selected);
return (
<>
{isLoading ? (
<div className="flex w-full">
<div className="h-full w-[125px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
</div>
) : (
<Menu
as="div"
className="relative flex w-full flex-wrap items-center text-left"
>
<Menu.Button className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-200 pl-2 text-left text-sm text-neutral-900 hover:bg-light-300 dark:border-dark-100 dark:text-dark-1000 dark:hover:border-dark-300 dark:hover:bg-dark-200">
{selectedMembers.length ? (
<div className="isolate flex -space-x-1 overflow-hidden">
{selectedMembers.map((member) => (
<span
key={member.publicId}
className="relative z-30 inline-flex h-6 w-6 items-center justify-center rounded-full bg-gray-500 ring-1 ring-light-200 dark:ring-dark-100"
>
<span className="text-[10px] font-medium leading-none text-white">
{member.user?.name
? member.user?.name
.split(" ")
.map((namePart) => namePart.charAt(0).toUpperCase())
.join("")
: null}
</span>
</span>
))}
</div>
) : (
<>
<HiMiniPlus size={22} className="pr-2" />
{"Add member"}
</>
)}
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute right-[200px] top-[30px] z-10 mt-2 w-56 origin-top-right rounded-md border-[1px] border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-500 dark:bg-dark-200">
<div className="p-2">
<form onSubmit={handleSubmit(onSubmit)}>
{members?.map((member) => (
<Menu.Item key={member.publicId}>
{() => (
<div
key={member.publicId}
className="flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={() => {
const newValue = !watch(member.publicId);
setValue(member.publicId, newValue);
addOrRemoveMember.mutate({
cardPublicId,
workspaceMemberPublicId: member.publicId,
});
handleSubmit(onSubmit);
}}
>
<input
id={member.publicId}
type="checkbox"
className="h-[14px] w-[14px] rounded bg-transparent"
onClick={(event) => event.stopPropagation()}
{...register(member.publicId)}
checked={watch(member.publicId)}
/>
<label
htmlFor={member.publicId}
className="ml-3 text-sm"
>
{member?.user?.name}
</label>
</div>
)}
</Menu.Item>
))}
</form>
</div>
</Menu.Items>
</Transition>
</Menu>
)}
</>
);
}

View File

@@ -0,0 +1,72 @@
import { useForm } from "react-hook-form";
import ContentEditable from "react-contenteditable";
import { HiOutlineArrowUp } from "react-icons/hi2";
import LoadingSpinner from "~/components/LoadingSpinner";
import { api } from "~/utils/api";
import { usePopup } from "~/providers/popup";
interface FormValues {
comment: string;
}
const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
const utils = api.useUtils();
const { showPopup } = usePopup();
const { handleSubmit, setValue, watch, reset } = useForm<FormValues>({
values: {
comment: "",
},
});
const addCommentMutation = api.card.addComment.useMutation({
onSuccess: async () => {
await utils.card.byId.refetch();
reset();
},
onError: () => {
showPopup({
header: "Unable to add comment",
message: "Please try again later, or contact customer support.",
});
},
});
const onSubmit = (data: FormValues) => {
addCommentMutation.mutate({
cardPublicId,
comment: data.comment,
});
};
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="flex w-full flex-col rounded-xl border border-light-600 bg-light-200 p-4 text-light-900 focus-visible:outline-none dark:border-dark-400 dark:bg-dark-100 dark:text-dark-1000 sm:text-sm sm:leading-6"
>
<ContentEditable
placeholder="Add a comment..."
html={watch("comment")}
disabled={false}
onChange={(e) => setValue("comment", e.target.value)}
className="block w-full border-0 bg-transparent py-1.5 text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6"
/>
<div className="flex justify-end">
<button
type="submit"
disabled={addCommentMutation.isPending}
className="flex h-8 w-8 items-center justify-center rounded-full border border-light-600 bg-light-300 hover:bg-light-400 disabled:opacity-50 dark:border-dark-400 dark:bg-dark-200 dark:hover:bg-dark-400"
>
{addCommentMutation.isPending ? (
<LoadingSpinner size="sm" />
) : (
<HiOutlineArrowUp />
)}
</button>
</div>
</form>
);
};
export default NewCommentForm;