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,56 @@
import { useState } from "react";
// import { useRouter } from "next/navigation";
import { Auth } from "~/components/AuthForm";
import { PageHead } from "~/components/PageHead";
// import { api } from "~/utils/api";
export default function LoginPage() {
// const router = useRouter();
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
const handleMagicLinkSent = (value: boolean, recipient: string) => {
setIsMagicLinkSent(value);
setMagicLinkRecipient(recipient);
};
// const authCookieExists = document.cookie
// .split("; ")
// .some((cookie) => cookie.includes("auth-token"));
// const { data } = api.auth.getUser.useQuery(undefined, {
// enabled: authCookieExists ? true : false,
// });
// if (data?.id) router.push("/boards");
return (
<>
<PageHead title="Login | kan.bn" />
<main className="h-screen bg-dark-50">
<div className="flex h-full flex-col items-center justify-center">
<h1 className="mb-6 text-lg font-bold tracking-tight text-dark-1000">
kan.bn
</h1>
<p className="mb-10 text-3xl text-dark-1000">
{isMagicLinkSent ? "Check your inbox" : "Welcome back"}
</p>
{isMagicLinkSent ? (
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
<p className="text-md mt-2 text-center text-dark-1000">
{`Click on the link we've sent to ${magicLinkRecipient} to sign in.`}
</p>
</div>
) : (
<div className="w-full rounded-lg border border-dark-400 bg-dark-200 px-10 py-10 sm:max-w-md">
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
<Auth setIsMagicLinkSent={handleMagicLinkSent} />
</div>
</div>
)}
</div>
</main>
</>
);
}

View File

@@ -0,0 +1,55 @@
import { useState } from "react";
// import { useRouter } from "next/navigation";
import { Auth } from "~/components/AuthForm";
import { PageHead } from "~/components/PageHead";
// import { api } from "~/utils/api";
export default function SignupPage() {
// const router = useRouter();
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
const handleMagicLinkSent = (value: boolean, recipient: string) => {
setIsMagicLinkSent(value);
setMagicLinkRecipient(recipient);
};
// const authCookieExists = document.cookie
// .split("; ")
// .some((cookie) => cookie.includes("auth-token"));
// const { data } = api.auth.getUser.useQuery(undefined, {
// enabled: authCookieExists ? true : false,
// });
// if (data?.id) router.push("/boards");
return (
<>
<PageHead title="Signup | kan.bn" />
<main className="h-screen bg-dark-50">
<div className="flex h-full flex-col items-center justify-center">
<h1 className="mb-6 text-lg font-bold tracking-tight text-dark-1000">
kan.bn
</h1>
<p className="mb-10 text-3xl text-dark-1000">
{isMagicLinkSent ? "Check your inbox" : "Get started"}
</p>
{isMagicLinkSent ? (
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
<p className="text-md mt-2 text-center text-dark-1000">
{`Click on the link we've sent to ${magicLinkRecipient} to sign in.`}
</p>
</div>
) : (
<div className="w-full rounded-lg border border-dark-400 bg-dark-200 px-10 py-10 sm:max-w-md">
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
<Auth setIsMagicLinkSent={handleMagicLinkSent} />
</div>
</div>
)}
</div>
</main>
</>
);
}

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 BoardDropdown() {
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_BOARD")}
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 board
</button>
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>
);
}

View File

@@ -0,0 +1,46 @@
import { useRouter } from "next/navigation";
import { api } from "~/utils/api";
import { useBoard } from "~/providers/board";
import { useModal } from "~/providers/modal";
import Button from "~/components/Button";
export function DeleteBoardConfirmation() {
const router = useRouter();
const { boardData } = useBoard();
const { closeModal } = useModal();
const deleteBoard = api.board.delete.useMutation({
onSuccess: () => {
closeModal();
router.push(`/boards`);
},
});
const handleDeleteBoard = () => {
if (boardData?.publicId)
deleteBoard.mutate({
boardPublicId: boardData.publicId,
});
};
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 board?
</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 onClick={() => closeModal()} variant="secondary">
Cancel
</Button>
<Button onClick={handleDeleteBoard}>Delete</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,68 @@
import { api } from "~/utils/api";
import { useBoard } from "~/providers/board";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import Button from "~/components/Button";
interface DeleteListConfirmationProps {
listPublicId: string;
}
export function DeleteListConfirmation({
listPublicId,
}: DeleteListConfirmationProps) {
const utils = api.useUtils();
const { boardData } = useBoard();
const { closeModal } = useModal();
const { showPopup } = usePopup();
const refetchBoard = async () => {
if (boardData?.publicId) {
try {
await utils.board.byId.refetch();
} catch (e) {
console.error(e);
}
}
};
const deleteList = api.list.delete.useMutation({
onSuccess: () => {
closeModal();
return refetchBoard();
},
onError: async () => {
closeModal();
await refetchBoard();
showPopup({
header: "Unable to delete list",
message: "Please try again later, or contact customer support.",
});
},
});
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 list?
</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 onClick={() => closeModal()} variant="secondary">
Cancel
</Button>
<Button
isLoading={deleteList.isPending}
onClick={() => deleteList.mutate({ listPublicId })}
>
Delete
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,139 @@
import { IoFilterOutline } from "react-icons/io5";
import {
HiOutlineUserCircle,
HiOutlineTag,
HiMiniXMark,
} from "react-icons/hi2";
import { useRouter } from "next/router";
import Button from "~/components/Button";
import CheckboxDropdown from "~/components/CheckboxDropdown";
import { formatToArray } from "~/utils/helpers";
import { useBoard } from "~/providers/board";
const LabelIcon = ({ colourCode }: { colourCode: string | null }) => (
<svg
fill={colourCode ?? "#3730a3"}
className="h-2 w-2"
viewBox="0 0 6 6"
aria-hidden="true"
>
<circle cx={3} cy={3} r={3} />
</svg>
);
const Avatar = ({ name }: { name: string }) => (
<span className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-gray-400 ring-1 ring-light-200 dark:ring-dark-500">
<span className="text-[8px] font-medium leading-none text-white">
{name
?.split(" ")
.map((namePart) => namePart.charAt(0).toUpperCase())
.join("")}
</span>
</span>
);
const Filters = () => {
const { boardData } = useBoard();
const router = useRouter();
const clearFilters = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
try {
await router.push({
pathname: router.pathname,
query: { ...router.query, members: [], labels: [] },
});
} catch (error) {
console.error(error);
}
};
const formattedMembers =
boardData?.workspace?.members?.map((member) => ({
key: member.publicId,
value: member.user?.name ?? "",
selected: !!router.query.members?.includes(member.publicId),
leftIcon: <Avatar name={member.user?.name ?? ""} />,
})) ?? [];
const formattedLabels =
boardData?.labels.map((label) => ({
key: label.publicId,
value: label.name,
selected: !!router.query.labels?.includes(label.publicId),
leftIcon: <LabelIcon colourCode={label.colourCode} />,
})) ?? [];
const groups = [
{
key: "members",
label: "Members",
icon: <HiOutlineUserCircle size={16} />,
items: formattedMembers,
},
{
key: "labels",
label: "Labels",
icon: <HiOutlineTag size={16} />,
items: formattedLabels,
},
];
const handleSelect = async (
groupKey: string | null,
item: { key: string },
) => {
if (groupKey === null) return;
const currentQuery = router.query[groupKey] ?? [];
const formattedCurrentQuery = Array.isArray(currentQuery)
? currentQuery
: [currentQuery];
const updatedQuery = formattedCurrentQuery.includes(item.key)
? formattedCurrentQuery.filter((key) => key !== item.key)
: [...formattedCurrentQuery, item.key];
try {
await router.push({
pathname: router.pathname,
query: { ...router.query, [groupKey]: updatedQuery },
});
} catch (error) {
console.error(error);
}
};
const numOfFilters = [
...formatToArray(router.query.members),
...formatToArray(router.query.labels),
].length;
return (
<div className="relative">
<CheckboxDropdown
groups={groups}
handleSelect={handleSelect}
menuSpacing="md"
>
<Button variant="secondary" iconLeft={<IoFilterOutline />}>
Filter
{numOfFilters > 0 && (
<button
onClick={clearFilters}
className="group absolute -right-[18px] -top-[15px] flex h-5 w-5 items-center justify-center rounded-full border-2 border-light-100 bg-light-1000 text-[8px] font-[700] text-light-600 dark:border-dark-50 dark:bg-dark-1000 dark:text-dark-600 dark:text-dark-600"
>
<span className="group-hover:hidden">{numOfFilters}</span>
<span className="hidden text-light-50 group-hover:inline dark:text-dark-50">
<HiMiniXMark size={12} />
</span>
</button>
)}
</Button>
</CheckboxDropdown>
</div>
);
};
export default Filters;

View File

@@ -0,0 +1,135 @@
import { type ReactNode } from "react";
import {
HiOutlinePlusSmall,
HiEllipsisHorizontal,
HiOutlineTrash,
HiOutlineSquaresPlus,
} from "react-icons/hi2";
import { Draggable } from "react-beautiful-dnd";
import { useForm } from "react-hook-form";
import { api } from "~/utils/api";
import { useModal } from "~/providers/modal";
import Dropdown from "~/components/Dropdown";
interface ListProps {
children: ReactNode;
index: number;
list: List;
setSelectedPublicListId: (publicListId: PublicListId) => void;
}
interface List {
publicId: string;
name: string;
}
interface FormValues {
listPublicId: string;
name: string;
}
type PublicListId = string;
export default function List({
children,
index,
list,
setSelectedPublicListId,
}: ListProps) {
const { openModal } = useModal();
const openNewCardForm = (publicListId: PublicListId) => {
openModal("NEW_CARD");
setSelectedPublicListId(publicListId);
};
const updateList = api.list.update.useMutation();
const { register, handleSubmit } = useForm<FormValues>({
defaultValues: {
listPublicId: list.publicId,
name: list.name,
},
values: {
listPublicId: list.publicId,
name: list.name,
},
});
const onSubmit = (values: FormValues) => {
updateList.mutate({
listPublicId: values.listPublicId,
name: values.name,
});
};
const handleOpenDeleteListConfirmation = () => {
setSelectedPublicListId(list.publicId);
openModal("DELETE_LIST");
};
return (
<Draggable key={list.publicId} draggableId={list.publicId} index={index}>
{(provided) => (
<div
key={list.publicId}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className="dark-text-dark-1000 mr-5 h-fit min-w-[18rem] max-w-[18rem] rounded-md border border-light-400 bg-light-300 py-2 pl-2 pr-1 text-neutral-900 dark:border-dark-300 dark:bg-dark-100"
>
<div className="flex justify-between">
<form
onSubmit={handleSubmit(onSubmit)}
className="focus-visible:outline-none"
>
<input
id="name"
type="text"
{...register("name")}
onBlur={handleSubmit(onSubmit)}
className="mb-4 block border-0 bg-transparent px-4 pt-1 text-sm font-medium text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000"
/>
</form>
<div>
<button
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-400 dark:hover:bg-dark-200"
onClick={() => openNewCardForm(list.publicId)}
>
<HiOutlinePlusSmall
className="h-5 w-5 text-dark-900"
aria-hidden="true"
/>
</button>
<div className="relative mr-1 inline-block">
<Dropdown
items={[
{
label: "Add a card",
action: () => openNewCardForm(list.publicId),
icon: (
<HiOutlineSquaresPlus className="h-[18px] w-[18px] text-dark-900" />
),
},
{
label: "Delete list",
action: handleOpenDeleteListConfirmation,
icon: (
<HiOutlineTrash className="h-[18px] w-[18px] text-dark-900" />
),
},
]}
>
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
</Dropdown>
</div>
</div>
</div>
{children}
</div>
)}
</Draggable>
);
}

View File

@@ -0,0 +1,300 @@
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import {
HiOutlineBarsArrowDown,
HiOutlineBarsArrowUp,
HiXMark,
} from "react-icons/hi2";
import { type NewCardInput } from "@kan/api/types";
import Button from "~/components/Button";
import CheckboxDropdown from "~/components/CheckboxDropdown";
import Input from "~/components/Input";
import Toggle from "~/components/Toggle";
import { useBoard } from "~/providers/board";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
type NewCardFormInput = NewCardInput & {
isCreateAnotherEnabled: boolean;
};
interface NewCardFormProps {
listPublicId: string;
}
export function NewCardForm({ listPublicId }: NewCardFormProps) {
const { boardData, addCard, refetchBoard } = useBoard();
const { showPopup } = usePopup();
const { closeModal } = useModal();
const { register, handleSubmit, reset, setValue, watch } =
useForm<NewCardFormInput>({
defaultValues: {
title: "",
description: "",
listPublicId,
labelPublicIds: [],
memberPublicIds: [],
isCreateAnotherEnabled: false,
position: "start",
},
});
const labelPublicIds = watch("labelPublicIds") || [];
const memberPublicIds = watch("memberPublicIds") || [];
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
const position = watch("position");
const createCard = api.card.create.useMutation({
onSuccess: async () => {
await refetchBoard();
},
onError: async () => {
closeModal();
await refetchBoard();
showPopup({
header: "Unable to create card",
message: "Please try again later, or contact customer support.",
});
},
});
useEffect(() => {
const titleElement: HTMLElement | null =
document?.querySelector<HTMLElement>("#title");
if (titleElement) titleElement.focus();
}, []);
const formattedLabels =
boardData?.labels.map((label) => ({
key: label.publicId,
value: label.name,
selected: labelPublicIds.includes(label.publicId),
})) ?? [];
const formattedLists =
boardData?.lists.map((list) => ({
key: list.publicId,
value: list.name,
selected: list.publicId === watch("listPublicId"),
})) ?? [];
const formattedMembers =
boardData?.workspace?.members?.map((member) => ({
key: member.publicId,
value: member.user?.name ?? "",
selected: memberPublicIds.includes(member.publicId),
})) ?? [];
const onSubmit = (data: NewCardInput) => {
addCard(data);
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
if (!isCreateAnotherEnabled) closeModal();
reset({
title: "",
description: "",
listPublicId: watch("listPublicId"),
labelPublicIds: [],
memberPublicIds: [],
isCreateAnotherEnabled,
position,
});
createCard.mutate({
title: data.title,
description: data.description,
listPublicId: data.listPublicId,
labelPublicIds: data.labelPublicIds,
memberPublicIds: data.memberPublicIds,
position: data.position,
});
};
const handleToggleCreateAnother = (): void => {
setValue("isCreateAnotherEnabled", !isCreateAnotherEnabled);
};
const handleSelectList = (listPublicId: string): void => {
setValue("listPublicId", listPublicId);
};
const handleSelectMembers = (memberPublicId: string): void => {
const currentIndex = memberPublicIds.indexOf(memberPublicId);
if (currentIndex === -1) {
setValue("memberPublicIds", [...memberPublicIds, memberPublicId]);
} else {
const newMemberPublicIds = [...memberPublicIds];
newMemberPublicIds.splice(currentIndex, 1);
setValue("memberPublicIds", newMemberPublicIds);
}
};
const handleSelectLabels = (labelPublicId: string): void => {
const currentIndex = labelPublicIds.indexOf(labelPublicId);
if (currentIndex === -1) {
setValue("labelPublicIds", [...labelPublicIds, labelPublicId]);
} else {
const newLabelPublicIds = [...labelPublicIds];
newLabelPublicIds.splice(currentIndex, 1);
setValue("labelPublicIds", newLabelPublicIds);
}
};
const selectedList = formattedLists.find((item) => item.selected);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="px-5 pt-5">
<div className="flex w-full items-center justify-between pb-5">
<h2 className="text-sm font-bold text-neutral-900 dark:text-dark-1000">
New card
</h2>
<button
className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => {
closeModal();
e.preventDefault();
}}
>
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
</button>
</div>
<div>
<Input id="title" placeholder="Card title" {...register("title")} />
</div>
<div className="mt-2">
<Input
placeholder="Add description..."
onChange={(e) => setValue("description", e.target.value)}
value={watch("description")}
contentEditable
/>
</div>
<div className="mt-2 flex space-x-1">
<div className="w-fit">
<CheckboxDropdown
items={formattedLists}
handleSelect={(_groupKey, item) => handleSelectList(item.key)}
>
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-600 bg-light-200 px-2 py-1 text-left text-xs text-light-800 hover:bg-light-300 dark:border-dark-600 dark:bg-dark-400 dark:text-dark-1000 dark:hover:bg-dark-500">
{selectedList?.value}
</div>
</CheckboxDropdown>
</div>
<div className="w-fit">
<CheckboxDropdown
items={formattedMembers}
handleSelect={(_groupKey, item) => handleSelectMembers(item.key)}
>
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-600 bg-light-200 px-2 py-1 text-left text-xs text-light-800 hover:bg-light-300 dark:border-dark-600 dark:bg-dark-400 dark:text-dark-1000 dark:hover:bg-dark-500">
{!memberPublicIds.length ? (
"Members"
) : (
<div className="flex -space-x-1 overflow-hidden">
{memberPublicIds.map((memberPublicId) => {
const member = formattedMembers.find(
(member) => member.key === memberPublicId,
);
return (
<span
key={member?.key}
className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-gray-400 ring-1 ring-light-200 dark:ring-dark-500"
>
<span className="text-[8px] font-medium leading-none text-white">
{member?.value
?.split(" ")
.map((namePart) =>
namePart.charAt(0).toUpperCase(),
)
.join("")}
</span>
</span>
);
})}
</div>
)}
</div>
</CheckboxDropdown>
</div>
<div className="w-fit">
<CheckboxDropdown
items={formattedLabels}
handleSelect={(_groupKey, item) => handleSelectLabels(item.key)}
>
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-600 bg-light-200 px-2 py-1 text-left text-xs text-light-800 hover:bg-light-300 dark:border-dark-600 dark:bg-dark-400 dark:text-dark-1000 dark:hover:bg-dark-500">
{!labelPublicIds.length ? (
"Labels"
) : (
<>
<div
className={
labelPublicIds.length > 1
? "flex -space-x-[2px] overflow-hidden"
: "flex items-center"
}
>
{labelPublicIds.map((labelPublicId) => {
const label = boardData?.labels.find(
(label) => label.publicId === labelPublicId,
);
return (
<>
<svg
fill={label?.colourCode ?? "#3730a3"}
className="h-2 w-2"
viewBox="0 0 6 6"
aria-hidden="true"
>
<circle cx={3} cy={3} r={3} />
</svg>
{labelPublicIds.length === 1 && (
<div className="ml-1">{label?.name}</div>
)}
</>
);
})}
</div>
{labelPublicIds.length > 1 && (
<div className="ml-1">{`${labelPublicIds.length} labels`}</div>
)}
</>
)}
</div>
</CheckboxDropdown>
</div>
<button
onClick={(e) => {
e.preventDefault();
setValue("position", position === "start" ? "end" : "start");
}}
className="flex h-auto items-center rounded-[5px] border-[1px] border-light-600 bg-light-200 px-1.5 py-1 text-left text-xs text-light-800 hover:bg-light-300 focus-visible:outline-none dark:border-dark-600 dark:bg-dark-400 dark:text-dark-1000 dark:hover:bg-dark-500"
>
{position === "start" ? (
<HiOutlineBarsArrowUp size={14} />
) : (
<HiOutlineBarsArrowDown size={14} />
)}
</button>
</div>
</div>
<div className="mt-5 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle
label="Create another"
isChecked={isCreateAnotherEnabled}
onChange={handleToggleCreateAnother}
/>
<div>
<Button type="submit">Create card</Button>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,105 @@
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2";
import { type NewListInput } from "@kan/api/types";
import Button from "~/components/Button";
import Input from "~/components/Input";
import Toggle from "~/components/Toggle";
import { useBoard } from "~/providers/board";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
type NewListFormInput = NewListInput & {
isCreateAnotherEnabled: boolean;
};
export function NewListForm({ boardPublicId }: { boardPublicId: string }) {
const { refetchBoard, addList } = useBoard();
const { closeModal } = useModal();
const { showPopup } = usePopup();
const { register, handleSubmit, reset, setValue, watch } =
useForm<NewListFormInput>({
defaultValues: {
name: "",
boardPublicId: boardPublicId,
isCreateAnotherEnabled: false,
},
});
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
const createList = api.list.create.useMutation({
onSuccess: async () => {
await refetchBoard();
},
onError: async () => {
closeModal();
await refetchBoard();
showPopup({
header: "Unable to create list",
message: "Please try again later, or contact customer support.",
});
},
});
useEffect(() => {
const nameElement: HTMLElement | null =
document?.querySelector<HTMLElement>("#list-name");
if (nameElement) nameElement.focus();
}, []);
const onSubmit = (data: NewListInput) => {
addList(data);
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
if (!isCreateAnotherEnabled) closeModal();
reset({
name: "",
isCreateAnotherEnabled,
});
createList.mutate({
name: data.name,
boardPublicId,
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="px-5 pt-5">
<div className="flex w-full items-center justify-between pb-4">
<h2 className="text-sm font-bold text-neutral-900 dark:text-dark-1000">
New list
</h2>
<button
className="rounded p-1 hover:bg-light-200 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="list-name" placeholder="List name" {...register("name")} />
</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">
<Toggle
label="Create another"
isChecked={isCreateAnotherEnabled}
onChange={() =>
setValue("isCreateAnotherEnabled", !isCreateAnotherEnabled)
}
/>
<div>
<Button type="submit">Create list</Button>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,319 @@
import type { DropResult } from "react-beautiful-dnd";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { DragDropContext, Draggable, Droppable } from "react-beautiful-dnd";
import { useForm } from "react-hook-form";
import { HiOutlinePlusSmall } from "react-icons/hi2";
import { type UpdateBoardInput } from "@kan/api/types";
import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
import PatternedBackground from "~/components/PatternedBackground";
import { useBoard } from "~/providers/board";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import { formatToArray } from "~/utils/helpers";
import BoardDropdown from "./components/BoardDropdown";
import { DeleteBoardConfirmation } from "./components/DeleteBoardConfirmation";
import { DeleteListConfirmation } from "./components/DeleteListConfirmation";
import Filters from "./components/Filters";
import List from "./components/List";
import { NewCardForm } from "./components/NewCardForm";
import { NewListForm } from "./components/NewListForm";
type PublicListId = string;
export default function BoardPage() {
const params = useParams();
const router = useRouter();
const { boardData, setBoardData, updateCard, updateList } = useBoard();
const { workspace } = useWorkspace();
const { openModal, modalContentType } = useModal();
const [selectedPublicListId, setSelectedPublicListId] =
useState<PublicListId>("");
const boardId = params?.boardId?.length ? params.boardId[0] : null;
const updateBoard = api.board.update.useMutation();
const { register, handleSubmit, setValue } = useForm<UpdateBoardInput>({
values: {
boardPublicId: boardId ?? "",
name: "",
},
});
const onSubmit = (values: UpdateBoardInput) => {
updateBoard.mutate({
boardPublicId: values.boardPublicId,
name: values.name,
});
};
const { data, isSuccess, isLoading } = api.board.byId.useQuery(
{
boardPublicId: boardId ?? "",
members: formatToArray(router.query.members),
labels: formatToArray(router.query.labels),
},
{
enabled: !!boardId,
},
);
useEffect(() => {
if (isSuccess && data) {
setBoardData(data);
setValue("name", data.name || "");
}
}, [isSuccess, data, setBoardData, setValue]);
if (!boardId || !boardData) return <></>;
const openNewListForm = (publicBoardId: string) => {
openModal("NEW_LIST");
setSelectedPublicListId(publicBoardId);
};
const onDragEnd = ({
source,
destination,
draggableId,
type,
}: DropResult): void => {
if (!destination) {
return;
}
if (type === "LIST") {
const updatedLists = Array.from(boardData.lists);
const removedList = updatedLists.splice(source.index, 1)[0];
if (removedList) {
updatedLists.splice(destination.index, 0, removedList);
setBoardData({ ...boardData, lists: updatedLists });
}
updateList({
listPublicId: draggableId,
currentIndex: source.index,
newIndex: destination.index,
});
}
if (type === "CARD") {
const updatedLists = Array.from(boardData.lists);
const sourceList = updatedLists.find(
(list) => list.publicId === source.droppableId,
);
const destinationList = updatedLists.find(
(list) => list.publicId === destination.droppableId,
);
const removedCard = sourceList?.cards.splice(source.index, 1)[0];
if (sourceList && destinationList && removedCard) {
destinationList.cards.splice(destination.index, 0, removedCard);
setBoardData({ ...boardData, lists: updatedLists });
}
updateCard({
cardPublicId: draggableId,
newListPublicId: destination.droppableId,
newIndex: destination.index,
});
}
};
return (
<>
<PageHead
title={`${boardData?.name ?? "Board"} | ${workspace?.name ?? "Workspace"}`}
/>
<div className="relative flex h-full flex-col">
<PatternedBackground />
<div className="z-10 flex w-full justify-between p-8">
{isLoading ? (
<div className="flex space-x-2">
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-100" />
</div>
) : (
<form
onSubmit={handleSubmit(onSubmit)}
className="focus-visible:outline-none"
>
<input
id="name"
type="text"
{...register("name")}
onBlur={handleSubmit(onSubmit)}
className="block border-0 bg-transparent p-0 py-0 font-medium leading-[2.3rem] tracking-tight text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000 sm:text-[1.2rem]"
/>
</form>
)}
<div className="flex items-center space-x-2">
<Filters />
<button
type="button"
className="mr-2 inline-flex items-center gap-x-1.5 rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 dark:bg-dark-1000 dark:text-dark-50"
onClick={() => openNewListForm(boardId)}
>
<HiOutlinePlusSmall
className="-mr-0.5 h-5 w-5"
aria-hidden="true"
/>
New list
</button>
<BoardDropdown />
</div>
</div>
<div className="scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] z-0 flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300">
{isLoading ? (
<div className="ml-[2rem] flex">
<div className="0 mr-5 h-[500px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
<div className="0 mr-5 h-[275px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
<div className="0 mr-5 h-[375px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
</div>
) : (
<DragDropContext onDragEnd={onDragEnd}>
<Droppable
droppableId="all-lists"
direction="horizontal"
type="LIST"
>
{(provided) => (
<div
className="flex"
ref={provided.innerRef}
{...provided.droppableProps}
>
<div className="min-w-[2rem]" />
{boardData?.lists?.map((list, index) => (
<List
index={index}
key={index}
list={list}
setSelectedPublicListId={(publicListId) =>
setSelectedPublicListId(publicListId)
}
>
<Droppable droppableId={`${list.publicId}`} type="CARD">
{(provided) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className="scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] z-10 h-full max-h-[calc(100vh-265px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar scrollbar-track-dark-100 scrollbar-thumb-dark-600"
>
{list.cards?.map((card, index) => (
<Draggable
key={card.publicId}
draggableId={card.publicId}
index={index}
>
{(provided) => (
<Link
onClick={(e) => {
if (
card.publicId.startsWith(
"PLACEHOLDER",
)
)
e.preventDefault();
}}
key={card.publicId}
href={`/cards/${card.publicId}`}
className={`mb-2 flex !cursor-pointer flex-col rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300 ${
card.publicId.startsWith("PLACEHOLDER")
? "pointer-events-none"
: ""
}`}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<div>{card.title}</div>
{(card.labels?.length ?? 0) ||
(card.members?.length ?? 0) ? (
<div className="mt-2 flex justify-end space-x-1">
{card.labels?.map((label) => (
<span
key={label.publicId}
className="inline-flex w-fit items-center gap-x-1.5 rounded-full px-2 py-1 text-[10px] font-medium text-neutral-600 ring-1 ring-inset ring-light-600 dark:text-dark-1000 dark:ring-dark-800"
>
<svg
fill={
label.colourCode ?? undefined
}
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>
</span>
))}
<div className="isolate flex -space-x-1 overflow-hidden">
{card.members?.map((member) => (
<span
key={member.publicId}
className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-light-900 ring-2 ring-light-50 dark:bg-gray-500 dark:ring-dark-500"
>
<span className="text-[10px] font-medium leading-none text-white">
{member?.user?.name
?.split(" ")
.map((namePart) =>
namePart
.charAt(0)
.toUpperCase(),
)
.join("")}
</span>
</span>
))}
</div>
</div>
) : null}
</Link>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</List>
))}
<div className="min-w-[0.75rem]" />
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
)}
</div>
<Modal modalSize={modalContentType === "NEW_CARD" ? "md" : "sm"}>
{modalContentType === "DELETE_BOARD" && <DeleteBoardConfirmation />}
{modalContentType === "DELETE_LIST" && (
<DeleteListConfirmation listPublicId={selectedPublicListId} />
)}
{modalContentType === "NEW_CARD" && (
<NewCardForm listPublicId={selectedPublicListId} />
)}
{modalContentType === "NEW_LIST" && (
<NewListForm boardPublicId={boardId} />
)}
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
</Modal>
</div>
</>
);
}

View File

@@ -0,0 +1,40 @@
import Link from "next/link";
import { api } from "~/utils/api";
import { useWorkspace } from "~/providers/workspace";
import PatternedBackground from "~/components/PatternedBackground";
export function BoardsList() {
const { workspace } = useWorkspace();
const { data, isLoading } = api.board.all.useQuery(
{ workspacePublicId: workspace?.publicId },
{ enabled: workspace?.publicId ? true : false },
);
if (isLoading)
return (
<div className="grid w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 xxl:grid-cols-5">
<div className="mr-5 flex h-[150px] w-full animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
<div className="mr-5 flex h-[150px] w-full animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
<div className="mr-5 flex h-[150px] w-full animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
</div>
);
if (data?.length === 0) return <></>;
return (
<div className="grid w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 xxl:grid-cols-5">
{data?.map((board) => (
<Link key={board.publicId} href={`boards/${board.publicId}`}>
<div className="align-center relative mr-5 flex h-[150px] w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100">
<PatternedBackground />
<p className="text-md px-4 font-medium text-neutral-900 dark:text-dark-1000">
{board.name}
</p>
</div>
</Link>
))}
</div>
);
}

View File

@@ -0,0 +1,238 @@
import { Fragment, useState } from "react";
import { api } from "~/utils/api";
import { Listbox, Transition } from "@headlessui/react";
import { useForm, Controller } from "react-hook-form";
import { FaTrello } from "react-icons/fa";
import { HiChevronUpDown, HiXMark } from "react-icons/hi2";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import Button from "~/components/Button";
import Input from "~/components/Input";
interface TrelloFormValues {
apiKey: string;
token: string;
}
const sources = [{ source: "Trello" }];
const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
const { control, handleSubmit } = useForm({
defaultValues: {
source: "Trello",
},
});
const onSubmit = () => {
handleNextStep();
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="px-5">
<Controller
name="source"
control={control}
render={({ field }) => (
<Listbox {...field}>
{({ open }) => (
<>
<div className="relative">
<Listbox.Button className="focus-ring-light-700 block w-full rounded-md border-0 bg-dark-300 bg-white/5 px-4 py-1.5 text-neutral-900 shadow-sm ring-1 ring-inset ring-light-600 focus:ring-2 focus:ring-inset dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6">
<span className="flex items-center">
<FaTrello />
<span className="ml-2 block truncate">
{field.value}
</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-1 text-base text-neutral-900 shadow-lg ring-1 ring-light-600 ring-opacity-5 focus:outline-none dark:bg-dark-300 dark:text-dark-1000 sm:text-sm">
{sources.map(({ source }, index) => (
<Listbox.Option
key={`source_${index}`}
className="relative cursor-default select-none px-1"
value={source}
>
<div className="flex items-center rounded-[5px] p-1 hover:bg-light-200 dark:hover:bg-dark-400">
<FaTrello className="ml-1" />
<span className="ml-2 block truncate font-normal">
{source}
</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">
<div>
<Button type="submit">Select source</Button>
</div>
</div>
</form>
);
};
const ImportTrello: React.FC = () => {
const utils = api.useUtils();
const [apiKey, setApiKey] = useState("");
const [token, setToken] = useState("");
const { closeModal } = useModal();
const { workspace } = useWorkspace();
const refetchBoards = () => utils.board.all.refetch();
const boards = api.import.trello.getBoards.useQuery(
{ apiKey, token },
{
enabled: apiKey && token ? true : false,
},
);
const handleSetAuthDetails = (apiKey: string, token: string) => {
setApiKey(apiKey);
setToken(token);
};
const importBoards = api.import.trello.importBoards.useMutation({
onSuccess: async () => {
try {
await refetchBoards();
closeModal();
} catch (e) {
console.log(e);
}
},
});
const { register, handleSubmit } = useForm<TrelloFormValues>({
defaultValues: {
apiKey: "",
token: "",
},
});
const onSubmit = (values: TrelloFormValues) => {
handleSetAuthDetails(values.apiKey, values.token);
};
const { register: registerBoards, handleSubmit: handleSubmitBoards } =
useForm({
defaultValues: Object.fromEntries(
boards?.data?.map((board) => [board.id, true]) ?? [],
),
});
const onSubmitBoards = (values: Record<string, boolean>) => {
const boardIds = Object.keys(values).filter((key) => values[key] === true);
importBoards.mutate({
boardIds,
apiKey,
token,
workspacePublicId: workspace?.publicId,
});
};
if (boards?.data?.length)
return (
<form onSubmit={handleSubmitBoards(onSubmitBoards)}>
<div className="h-[105px] overflow-scroll px-5">
{boards.data.map((board) => (
<div key={board.id}>
<label
className="flex cursor-pointer items-center rounded-[5px] p-2 hover:bg-light-100 dark:hover:bg-dark-300"
htmlFor={board.id}
>
<input
id={board.id}
type="checkbox"
className="h-[14px] w-[14px] rounded bg-transparent ring-0 focus:outline-none focus:ring-0 focus:ring-offset-0"
{...registerBoards(board.id)}
/>
<span className="ml-3 text-sm text-neutral-900 dark:text-dark-1000">
{board.name}
</span>
</label>
</div>
))}
</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">
<div>
<Button type="submit" isLoading={importBoards.isPending}>
Import boards
</Button>
</div>
</div>
</form>
);
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="text-neutral-900 dark:text-dark-1000"
>
<div className="space-y-4 px-5">
<Input id="apiKey" placeholder="API key" {...register("apiKey")} />
<Input id="token" placeholder="Token" {...register("token")} />
</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">
<div>
<Button type="submit" isLoading={boards.isLoading}>
Fetch boards
</Button>
</div>
</div>
</form>
);
};
export function ImportBoardsForm() {
const { closeModal } = useModal();
const [step, setStep] = useState(1);
return (
<div>
<div className="flex w-full items-center justify-between px-5 pb-4 pt-5">
<h2 className="text-sm font-medium text-neutral-900 dark:text-dark-1000">
New import
</h2>
<button
className="rounded p-1 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={() => closeModal()}
>
<HiXMark size={18} className="text-dark-900" />
</button>
</div>
{step === 1 && <SelectSource handleNextStep={() => setStep(step + 1)} />}
{step === 2 && <ImportTrello />}
</div>
);
}

View File

@@ -0,0 +1,70 @@
import { useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2";
import { type NewBoardInput } from "@kan/api/types";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
export function NewBoardForm() {
const utils = api.useUtils();
const { closeModal } = useModal();
const { workspace } = useWorkspace();
const { register, handleSubmit } = useForm<NewBoardInput>({
defaultValues: {
name: "",
workspacePublicId: workspace?.publicId || "",
},
});
const refetchBoards = () => utils.board.all.refetch();
const createBoard = api.board.create.useMutation({
onSuccess: async () => {
closeModal();
await refetchBoards();
},
});
const onSubmit = (data: NewBoardInput) => {
createBoard.mutate(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="px-5 pt-5">
<div className="text-neutral-9000 flex w-full items-center justify-between pb-4 dark:text-dark-1000">
<h2 className="text-sm font-bold">New board</h2>
<button
className="hover:bg-li ght-300 rounded p-1 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
closeModal();
}}
>
<HiXMark size={18} className="dark:text-dark-9000 text-light-900" />
</button>
</div>
<input
id="name"
placeholder="Name"
{...register("name", { required: true })}
className="block w-full rounded-md border-0 bg-white/5 py-1.5 text-neutral-900 placeholder-dark-800 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"
/>
</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">
<div>
<button
type="submit"
className="inline-flex w-full 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"
>
Create board
</button>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,67 @@
import { HiArrowDownTray, HiOutlinePlusSmall } from "react-icons/hi2";
import { BoardsList } from "./components/BoardsList";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import Modal from "~/components/modal";
import { PageHead } from "~/components/PageHead";
import { ImportBoardsForm } from "./components/ImportBoardsForm";
import { NewBoardForm } from "./components/NewBoardForm";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
export default function BoardsPage() {
const { openModal, modalContentType } = useModal();
const { workspace } = useWorkspace();
return (
<>
<PageHead title={`Boards | ${workspace?.name ?? "Workspace"}`} />
<div className="p-8">
<div className="mb-8 flex w-full justify-between">
<h1 className="font-medium tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
Boards
</h1>
<div className="flex">
<button
type="button"
className="bg-dark-3000 mr-2 flex items-center gap-x-1.5 rounded-md border-[1px] border-light-600 px-3 py-2 text-sm text-neutral-900 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 dark:border-dark-600 dark:text-dark-1000"
onClick={() => openModal("IMPORT_BOARDS")}
>
<div className="flex h-5 w-5 items-center">
<HiArrowDownTray
className="-mr-0.5 h-4 w-4"
aria-hidden="true"
/>
</div>
Import
</button>
<button
type="button"
className="flex items-center gap-x-1.5 rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 dark:bg-dark-1000 dark:text-dark-50"
onClick={() => openModal("NEW_BOARD")}
>
<div className="h-5 w-5 items-center">
<HiOutlinePlusSmall
className="-mr-0.5 h-5 w-5"
aria-hidden="true"
/>
</div>
New
</button>
</div>
</div>
<Modal>
{modalContentType === "NEW_BOARD" && <NewBoardForm />}
{modalContentType === "IMPORT_BOARDS" && <ImportBoardsForm />}
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
</Modal>
<div className="flex flex-row">
<BoardsList />
</div>
</div>
</>
);
}

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;

View File

@@ -0,0 +1,246 @@
import Link from "next/link";
import { useParams } from "next/navigation";
import { useForm } from "react-hook-form";
import ContentEditable from "react-contenteditable";
import { IoChevronForwardSharp } from "react-icons/io5";
import ActivityList from "./components/ActivityList";
import Dropdown from "./components/Dropdown";
import { DeleteCardConfirmation } from "./components/DeleteCardConfirmation";
import { DeleteLabelConfirmation } from "./components/DeleteLabelConfirmation";
import LabelSelector from "./components/LabelSelector";
import ListSelector from "./components/ListSelector";
import MemberSelector from "./components/MemberSelector";
import { LabelForm } from "./components/LabelForm";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import NewCommentForm from "./components/NewCommentForm";
import { PageHead } from "~/components/PageHead";
import Modal from "~/components/modal";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
interface FormValues {
cardId: string;
title: string;
description: string;
}
export default function CardPage() {
const params = useParams();
const utils = api.useUtils();
const { modalContentType, entityId } = useModal();
const { showPopup } = usePopup();
const cardId = Array.isArray(params?.cardId)
? params.cardId[0]
: params?.cardId;
const { data, isLoading } = api.card.byId.useQuery({
cardPublicId: cardId ?? "",
});
const board = data?.list?.board;
const boardId = board?.publicId;
const labels = board?.labels;
const activities = data?.activities;
const workspaceMembers = board?.workspace?.members;
const selectedLabels = data?.labels;
const selectedMembers = data?.members;
const formattedLabels =
labels?.map((label) => {
const isSelected = selectedLabels?.some(
(selectedLabel) => selectedLabel.publicId === label.publicId,
);
return {
...label,
selected: isSelected ?? false,
colourCode: label.colourCode ?? "",
};
}) ?? [];
const formattedLists =
board?.lists.map((list) => ({
...list,
selected: list.publicId === data?.list?.publicId,
})) ?? [];
const formattedMembers =
workspaceMembers?.map((member) => {
const isSelected = selectedMembers?.some(
(assignedMember) => assignedMember.publicId === member.publicId,
);
return {
...member,
user: member.user ?? { id: "", name: null },
selected: isSelected ?? false,
};
}) ?? [];
const updateCard = api.card.update.useMutation({
onSuccess: async () => {
await utils.card.byId.refetch();
},
onError: () => {
showPopup({
header: "Unable to update card",
message: "Please try again later, or contact customer support.",
});
},
});
const { register, handleSubmit, setValue, watch } = useForm<FormValues>({
values: {
cardId: cardId ?? "",
title: data?.title ?? "",
description: data?.description ?? "",
},
});
const onSubmit = (values: FormValues) => {
updateCard.mutate({
cardPublicId: values.cardId,
title: values.title,
description: values.description,
});
};
if (!cardId) return <></>;
return (
<>
<PageHead
title={`${data?.title ?? "Card"} | ${board?.name ?? "Board"}`}
/>
<div className="flex h-full flex-1 flex-row">
<div className="flex h-full w-full flex-col overflow-hidden">
<div className="h-full max-h-[calc(100vh-4rem)] overflow-y-auto p-8">
<div className="mb-8 flex w-full items-center justify-between">
{isLoading ? (
<div className="flex space-x-2">
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
</div>
) : (
<>
<Link
className="whitespace-nowrap font-medium leading-[2.3rem] tracking-tight text-light-900 dark:text-dark-900 sm:text-[1.2rem]"
href={`/boards/${board?.publicId}`}
>
{board?.name}
</Link>
<IoChevronForwardSharp
size={18}
className="mx-2 text-light-900 dark:text-dark-900"
/>
<form
onSubmit={handleSubmit(onSubmit)}
className="w-full space-y-6"
>
<div>
<input
type="text"
id="title"
{...register("title")}
onBlur={handleSubmit(onSubmit)}
className="block w-full border-0 bg-transparent p-0 py-0 font-medium tracking-tight text-neutral-900 focus:ring-0 dark:text-dark-1000 sm:text-[1.2rem]"
/>
</div>
</form>
<div className="flex">
<Dropdown />
</div>
</>
)}
</div>
<div className="mb-10 flex w-full max-w-2xl justify-between">
<form
onSubmit={handleSubmit(onSubmit)}
className="w-full space-y-6"
>
<div className="mt-2">
<ContentEditable
placeholder="Add description..."
html={watch("description")}
disabled={false}
onChange={(e) => setValue("description", e.target.value)}
onBlur={handleSubmit(onSubmit)}
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>
</form>
</div>
<div className="border-t-[1px] border-light-600 pt-12 dark:border-dark-400">
<h2 className="text-md pb-4 font-medium text-light-900 dark:text-dark-1000">
Activity
</h2>
<div>
<ActivityList
cardPublicId={cardId}
activities={activities ?? []}
isLoading={isLoading}
/>
</div>
<div className="mt-6">
<NewCommentForm cardPublicId={cardId} />
</div>
</div>
</div>
</div>
<div className="min-w-[325px] border-l-[1px] border-light-600 bg-light-200 p-8 text-light-900 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-900">
<div className="mb-4 flex w-full">
<p className="my-2 w-[100px] text-sm">List</p>
<ListSelector
cardPublicId={cardId}
lists={formattedLists}
isLoading={isLoading}
/>
</div>
<div className="mb-4 flex w-full">
<p className="my-2 w-[100px] text-sm">Labels</p>
<LabelSelector
cardPublicId={cardId}
labels={formattedLabels}
isLoading={isLoading}
/>
</div>
<div className="flex w-full">
<p className="my-2 w-[100px] text-sm">Members</p>
<MemberSelector
cardPublicId={cardId}
members={formattedMembers}
isLoading={isLoading}
/>
</div>
</div>
<Modal>
{modalContentType === "NEW_LABEL" && (
<LabelForm cardPublicId={cardId} />
)}
{modalContentType === "EDIT_LABEL" && (
<LabelForm cardPublicId={cardId} isEdit />
)}
{modalContentType === "DELETE_LABEL" && (
<DeleteLabelConfirmation
cardPublicId={cardId}
labelPublicId={entityId}
/>
)}
{modalContentType === "DELETE_CARD" && (
<DeleteCardConfirmation
boardPublicId={boardId ?? ""}
cardPublicId={cardId}
/>
)}
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
</Modal>
</div>
</>
);
}

View File

@@ -0,0 +1,155 @@
import { useState } from "react";
import Link from "next/link";
import LottieIcon from "~/components/LottieIcon";
import boardVisibilityIconLight from "~/assets/board-visibility-light.json";
import boardVisibilityIconDark from "~/assets/board-visibility-dark.json";
import membersIconLight from "~/assets/members-light.json";
import membersIconDark from "~/assets/members-dark.json";
import commentsIconLight from "~/assets/comments-light.json";
import commentsIconDark from "~/assets/comments-dark.json";
import integrationsIconLight from "~/assets/integrations-light.json";
import integrationsIconDark from "~/assets/integrations-dark.json";
import labelsIconLight from "~/assets/labels-light.json";
import labelsIconDark from "~/assets/labels-dark.json";
import importsIconLight from "~/assets/imports-light.json";
import importsIconDark from "~/assets/imports-dark.json";
import activityLogsIconLight from "~/assets/activity-logs-light.json";
import activityLogsIconDark from "~/assets/activity-logs-dark.json";
import templatesIconLight from "~/assets/templates-light.json";
import templatesIconDark from "~/assets/templates-dark.json";
const FeatureItem = ({
feature,
}: {
feature: {
title: string;
description: string;
icon: Record<string, unknown>;
comingSoon?: boolean;
};
}) => {
const [isHovered, setIsHovered] = useState(false);
const [index, setIndex] = useState(0);
const handleMouseEnter = () => {
setIsHovered(true);
setIndex((index) => index + 1);
};
return (
<div
onMouseEnter={handleMouseEnter}
className="group relative flex h-56 w-56 flex-col items-center justify-center overflow-hidden rounded-3xl border border-light-200 bg-light-50 dark:border-dark-200 dark:bg-dark-50"
>
<div className="absolute left-8 top-8 h-2 w-2 rounded-full bg-light-200 dark:bg-dark-200 " />
<div className="absolute right-8 top-8 h-2 w-2 rounded-full bg-light-200 dark:bg-dark-200" />
<div className="absolute bottom-8 left-8 h-2 w-2 rounded-full bg-light-200 dark:bg-dark-200" />
<div className="absolute bottom-8 right-8 h-2 w-2 rounded-full bg-light-200 dark:bg-dark-200" />
<div className="flex h-10 w-10 items-center justify-center rounded-xl border border-light-300 bg-light-200 dark:border-dark-600 dark:bg-dark-200">
<LottieIcon index={index} json={feature.icon} isPlaying={isHovered} />
</div>
<div className="relative mt-2 w-full px-4 text-center">
<p className="text-sm font-bold text-light-1000 transition-opacity duration-200 group-hover:opacity-0 dark:text-dark-1000">
{feature.title}
</p>
<p className="absolute inset-0 px-4 text-sm text-light-950 opacity-0 transition-opacity duration-200 group-hover:opacity-100 dark:text-dark-900">
{feature.description}
</p>
</div>
{feature.comingSoon && (
<div className="absolute right-4 top-4 rounded-full border border-light-300 px-2 py-1 text-[10px] text-light-1000 dark:border-dark-600 dark:bg-dark-50 dark:text-dark-900">
Coming soon
</div>
)}
</div>
);
};
const Features = ({ theme }: { theme: "light" | "dark" }) => {
const isDark = theme === "dark";
const features = [
{
title: "Board visibility",
description: "Control who can view and edit your boards.",
icon: isDark ? boardVisibilityIconDark : boardVisibilityIconLight,
},
{
title: "Workspace members",
description: "Collaborate seamlessly with your team.",
icon: isDark ? membersIconDark : membersIconLight,
},
{
title: "Trello imports",
description: "Import your Trello boards and hit the ground running.",
icon: isDark ? importsIconDark : importsIconLight,
},
{
title: "Labels & Filters",
description:
"Organize and find cards quickly with powerful filtering tools.",
icon: isDark ? labelsIconDark : labelsIconLight,
},
{
title: "Comments",
description: "Discuss and collaborate on cards.",
icon: isDark ? commentsIconDark : commentsIconLight,
},
{
title: "Activity logs",
description: "Track all card changes with detailed activity history.",
icon: isDark ? activityLogsIconDark : activityLogsIconLight,
},
{
title: "Templates",
description: "Save time with reusable board templates.",
icon: isDark ? templatesIconDark : templatesIconLight,
comingSoon: true,
},
{
title: "Integrations",
description: "Connect your favorite tools to streamline your workflow.",
icon: isDark ? integrationsIconDark : integrationsIconLight,
comingSoon: true,
},
];
return (
<>
<div className="flex flex-col items-center justify-center pb-24">
<div className="flex items-center gap-2 rounded-full border bg-light-50 px-4 py-1 text-center text-sm text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900">
<p>Features</p>
</div>
<p className="mt-2 text-center text-4xl font-bold text-light-1000 dark:text-dark-1000">
Kanban simplified
</p>
<p className="mt-3 max-w-[600px] text-center text-lg text-dark-900">
Simple, visual task management that just works. Drag and drop cards,
collaborate with your team, and get more done.
</p>
<div className="mt-16 grid grid-cols-4 gap-6 [mask-image:linear-gradient(to_bottom,black_80%,transparent_100%)]">
{features.map((feature, index) => {
return <FeatureItem key={`feature-${index}`} feature={feature} />;
})}
</div>
<div>
<div className="mt-8 flex items-center gap-2 rounded-full border bg-light-50 px-4 py-1 text-center text-sm text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900">
<p>
{`We're just getting started. `}
<Link href="/roadmap" className="underline">
View our roadmap.
</Link>
</p>
</div>
</div>
</div>
</>
);
};
export default Features;

View File

@@ -0,0 +1,143 @@
import Link from "next/link";
import { FaDiscord, FaGithub } from "react-icons/fa";
const navigation = {
documentation: [
{ name: "Getting started", href: "#" },
{ name: "Importing from Trello", href: "#" },
{ name: "API Reference", href: "#" },
],
company: [
{ name: "Roadmap", href: "/roadmap" },
{ name: "GitHub", href: "https://github.com/kanbn/kan" },
{ name: "Contact", href: "mailto:support@kan.bn" },
],
legal: [
{ name: "Terms of service", href: "/terms" },
{ name: "Privacy policy", href: "/privacy" },
{
name: "License",
href: "https://github.com/kanbn/kan?tab=GPL-3.0-1-ov-file#readme",
},
],
resources: [
{ name: "Features", href: "/#features" },
{ name: "Pricing", href: "/#pricing" },
{ name: "FAQs", href: "/#faq" },
],
};
const StatusMarker = () => (
<Link
href="https://openstatus.dev"
target="_blank"
rel="noopener noreferrer"
className="flex w-fit items-center gap-1.5 rounded-full border border-light-300 py-2 pl-3 pr-4 text-xs text-light-950 hover:bg-light-100 dark:border-dark-300 dark:text-dark-800 dark:hover:bg-dark-100"
>
<span className="relative mr-1 h-2 w-2">
<span className="absolute -inset-[1px] animate-[ping_1s_infinite] rounded-full bg-green-500/30"></span>
<span className="absolute inset-0 rounded-full bg-green-500"></span>
</span>
All systems operational
</Link>
);
const Footer = () => {
return (
<footer className="z-10 mt-20 w-full border-t border-light-300 border-light-300 bg-light-50 py-8 dark:border-dark-300 dark:bg-dark-50">
<div className="mx-auto max-w-7xl px-6 py-16 sm:py-24 lg:px-8 lg:py-24">
<div className="xl:grid xl:grid-cols-3 xl:gap-8">
<div>
<div className="mb-2 flex items-center gap-2">
<Link href="https://github.com/kanbn/kan" target="_blank">
<FaGithub className="h-8 w-8 rounded-lg border border-light-300 border-light-300 p-1.5 text-light-1000 hover:bg-light-100 dark:border-dark-300 dark:text-dark-1000 dark:hover:bg-dark-100" />
</Link>
<Link href="#" target="_blank">
<FaDiscord className="h-8 w-8 rounded-lg border border-light-300 border-light-300 p-1.5 text-light-1000 hover:bg-light-100 dark:border-dark-300 dark:text-dark-1000 dark:hover:bg-dark-100" />
</Link>
</div>
<StatusMarker />
</div>
<div className="mt-16 grid grid-cols-2 gap-8 xl:col-span-2 xl:mt-0">
<div className="md:grid md:grid-cols-2 md:gap-8">
<div>
<h3 className="text-sm/6 font-semibold text-light-1000 dark:text-dark-1000">
Documentation
</h3>
<ul role="list" className="mt-6 space-y-4">
{navigation.documentation.map((item) => (
<li key={item.name}>
<a
href={item.href}
className="text-sm/6 text-light-900 hover:text-light-1000 dark:text-dark-950 dark:hover:text-dark-1000"
>
{item.name}
</a>
</li>
))}
</ul>
</div>
<div>
<h3 className="text-sm/6 font-semibold text-light-1000 dark:text-dark-1000">
Company
</h3>
<ul role="list" className="mt-6 space-y-4">
{navigation.company.map((item) => (
<li key={item.name}>
<a
href={item.href}
className="text-sm/6 text-light-900 hover:text-light-1000 dark:text-dark-950 dark:hover:text-dark-1000"
>
{item.name}
</a>
</li>
))}
</ul>
</div>
</div>
<div className="md:grid md:grid-cols-2 md:gap-8">
<div>
<h3 className="text-sm/6 font-semibold text-light-1000 dark:text-dark-1000">
Resources
</h3>
<ul role="list" className="mt-6 space-y-4">
{navigation.resources.map((item) => (
<li key={item.name}>
<a
href={item.href}
className="text-sm/6 text-light-900 hover:text-light-1000 dark:text-dark-950 dark:hover:text-dark-1000"
>
{item.name}
</a>
</li>
))}
</ul>
</div>
<div>
<h3 className="text-sm/6 font-semibold text-light-1000 dark:text-dark-1000">
Legal
</h3>
<ul role="list" className="mt-6 space-y-4">
{navigation.legal.map((item) => (
<li key={item.name}>
<a
href={item.href}
className="text-sm/6 text-light-900 hover:text-light-1000 dark:text-dark-950 dark:hover:text-dark-1000"
>
{item.name}
</a>
</li>
))}
</ul>
</div>
</div>
</div>
</div>
</div>
</footer>
);
};
export default Footer;

View File

@@ -0,0 +1,72 @@
import { useState, useEffect } from "react";
import Link from "next/link";
import Button from "~/components/Button";
import { twMerge } from "tailwind-merge";
const Header = ({ isLoggedIn }: { isLoggedIn: boolean }) => {
const [isScrolled, setIsScrolled] = useState(false);
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 0);
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
return (
<>
<div
className={twMerge(
"z-50 m-auto flex w-full max-w-[1100px] px-4 pt-4",
isScrolled && "fixed",
)}
>
<div
className={twMerge(
"m-auto flex h-[4rem] min-h-[4rem] w-full rounded-3xl border border-transparent px-5 py-2 align-middle transition-colors duration-200",
isScrolled &&
"rounded-2xl border border-light-300 bg-dark-100 bg-light-50/80 shadow-sm backdrop-blur-[10px] dark:border-dark-300 dark:bg-dark-50/90",
)}
>
<div className="flex w-full items-center justify-between px-2">
<div className="my-auto flex items-center justify-between pl-2">
<h1 className="w-[200px] text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000">
kan.bn
</h1>
</div>
<div className="flex justify-center gap-10 dark:text-dark-1000">
<Link href="/roadmap" className="text-sm font-bold">
Roadmap
</Link>
<Link href="#features" className="text-sm font-bold">
Features
</Link>
<Link href="#pricing" className="text-sm font-bold">
Pricing
</Link>
<Link href="/docs" className="text-sm font-bold">
Docs
</Link>
</div>
<div className="flex w-[200px] justify-end gap-2">
{isLoggedIn ? (
<Button href="/boards">Go to app</Button>
) : (
<>
<Button href="/login" variant="ghost">
Sign in
</Button>
<Button href="/signup">Get started</Button>
</>
)}
</div>
</div>
</div>
</div>
{isScrolled && <div className="h-[5rem] min-h-[5rem]"></div>}
</>
);
};
export default Header;

View File

@@ -0,0 +1,221 @@
import { useState } from "react";
import Link from "next/link";
import { twMerge } from "tailwind-merge";
import { Radio, RadioGroup } from "@headlessui/react";
import { HiCheckCircle } from "react-icons/hi2";
type Frequency = "monthly" | "annually";
const frequencies = [
{
value: "monthly" as Frequency,
label: "Monthly",
priceSuffix: "per user/month",
},
{
value: "annually" as Frequency,
label: "Yearly",
priceSuffix: "per user/month",
},
];
const tiers = [
{
name: "Individuals",
id: "tier-individuals",
href: "signup",
buttonText: "Get Started",
price: { monthly: "Free", annually: "Free" },
description:
"Everything you need, free forever. Unlimited boards, unlimited lists, unlimited cards. Upgrade any time.",
featureHeader: "Free, forever",
features: [
"1 user",
"Unlimited boards",
"Unlimited lists",
"Unlimited cards",
"Unlimited comments",
"Unlimited activity log",
],
showPrice: true,
},
{
name: "Teams",
id: "tier-teams",
href: "signup",
buttonText: "Get Started",
price: { monthly: "£7.50", annually: "£6" },
description:
"Kanban is better with a team. Perfect for small and growing teams looking to collaborate.",
featureHeader: "Everything in the free plan, plus:",
features: [
"Workspace members",
"Admin roles",
"Priority email support",
"Support the development of the project",
],
highlighted: true,
showPrice: true,
showPriceSuffix: true,
},
{
name: "Self Host",
id: "tier-self-host",
href: "https://github.com/kanbn/kan",
buttonText: "View docs",
price: { monthly: "Free", annually: "Free" },
description:
"Spin up a self hosted version of Kan on your own infrastructure. Ideal for organisations that need complete control over their data.",
featureHeader: "Complete control and ownership:",
features: [
"Run on your own infrastructure",
"Own your data",
"Custom domain",
],
mostPopular: false,
showPrice: false,
},
];
const Pricing = () => {
const initialFrequency = frequencies[1]!;
const [frequency, setFrequency] = useState(initialFrequency);
return (
<>
<div className="flex flex-col items-center justify-center pb-10">
<div className="flex items-center gap-2 rounded-full border bg-light-50 px-4 py-1 text-center text-sm text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900">
<p>Pricing</p>
</div>
<p className="mt-2 text-center text-4xl font-bold text-light-1000 dark:text-dark-1000">
Simple pricing to fit your needs
</p>
<p className="mt-3 max-w-[600px] text-center text-lg text-dark-900">
Get started for free, with no usage limits. For collaboration, upgrade
to a plan that fits the size of your team.
</p>
<div className="mt-16 flex justify-center">
<fieldset aria-label="Payment frequency">
<RadioGroup
value={frequency}
onChange={(value) => setFrequency(value)}
className="grid grid-cols-2 gap-x-1 rounded-full p-1 text-center text-xs/5 font-semibold ring-1 ring-inset ring-light-600 dark:ring-dark-600"
>
{frequencies.map((option) => (
<Radio
key={option.value}
value={option}
className="cursor-pointer rounded-full px-2.5 py-1 text-light-900 data-[checked]:bg-dark-50 data-[checked]:text-white dark:data-[checked]:bg-light-50 dark:data-[checked]:text-dark-50"
>
{option.label}
</Radio>
))}
</RadioGroup>
</fieldset>
</div>
</div>
<div className="isolate mx-auto mb-20 grid max-w-md grid-cols-1 gap-8 lg:mx-0 lg:max-w-none lg:grid-cols-3">
{tiers.map((tier) => (
<div
key={tier.id}
className={twMerge(
tier.highlighted
? "bg-dark-100 dark:bg-light-50"
: "bg-light-50 ring-1 ring-light-300 dark:bg-dark-50 dark:ring-dark-300",
"rounded-3xl p-8 xl:p-10",
)}
>
<div className="flex items-center justify-between gap-x-4">
<h3
id={tier.id}
className={twMerge(
tier.highlighted
? "text-dark-1000 dark:text-dark-100"
: "text-dark-50 dark:text-dark-1000",
"text-lg/8 font-semibold",
)}
>
{tier.name}
</h3>
{tier.highlighted && frequency?.value === "annually" ? (
<p className="rounded-full bg-light-50 px-2.5 py-1 text-xs/5 font-semibold text-dark-500 dark:bg-dark-50 dark:text-dark-1000">
-20%
</p>
) : null}
</div>
<p
className={twMerge(
"mt-4 text-sm/6 text-dark-950",
tier.highlighted
? "text-light-100 dark:text-dark-100"
: "text-dark-50 dark:text-dark-1000",
)}
>
{tier.description}
</p>
<p className="mt-6 flex items-baseline gap-x-1">
<span
className={twMerge(
"text-4xl font-semibold tracking-tight text-light-100",
tier.highlighted
? "text-light-50 dark:text-dark-100"
: "text-gray-900 dark:text-dark-1000",
!tier.showPrice && "opacity-0",
)}
>
{tier.price[frequency.value]}
</span>
{tier.showPriceSuffix && (
<span className="text-sm/6 font-semibold text-light-50 dark:text-dark-900">
{frequency.priceSuffix}
</span>
)}
</p>
<Link
href={tier.href}
aria-describedby={tier.id}
className={twMerge(
tier.highlighted
? "bg-light-50 text-dark-50 shadow-sm dark:bg-dark-50 dark:text-dark-1000"
: "bg-dark-50 text-light-50 dark:bg-light-50 dark:text-dark-50",
"mt-6 block rounded-md px-3 py-2 text-center text-sm/6 font-semibold focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600",
)}
>
{tier.buttonText}
</Link>
<p
className={twMerge(
"mt-8 text-sm/6 font-bold",
tier.highlighted
? "text-light-100 dark:text-dark-100"
: "text-dark-50 dark:text-dark-1000",
)}
>
{tier.featureHeader}
</p>
<ul
role="list"
className={twMerge(
"mt-2 space-y-3 text-sm/6 text-light-600",
tier.highlighted
? "text-light-100 dark:text-dark-100"
: "text-dark-50 dark:text-dark-1000",
)}
>
{tier.features.map((feature) => (
<li key={feature} className="flex items-center gap-x-3">
<HiCheckCircle className="h-5 w-5" />
{feature}
</li>
))}
</ul>
</div>
))}
</div>
</>
);
};
export default Pricing;

View File

@@ -0,0 +1,113 @@
import Image from "next/image";
import Link from "next/link";
import Cookies from "js-cookie";
import { IoLogoGithub } from "react-icons/io";
import Button from "~/components/Button";
import PatternedBackground from "~/components/PatternedBackground";
import { env } from "~/env";
import { useTheme } from "~/providers/theme";
import { api } from "~/utils/api";
import Features from "./components/Features";
import Footer from "./components/Footer";
import Header from "./components/Header";
import Pricing from "./components/Pricing";
export default function HomeView() {
const theme = useTheme();
const token =
typeof window !== "undefined"
? Cookies.get(env.NEXT_PUBLIC_SUPABASE_AUTH_COOKIE_NAME)
: null;
const { data } = api.auth.getUser.useQuery(undefined, {
enabled: !!token,
});
const isLoggedIn = !!data;
const isDarkMode = theme.activeTheme === "dark";
return (
<>
<style jsx global>{`
html {
scroll-behavior: smooth;
}
body {
background-color: ${!isDarkMode ? "hsl(0deg 0% 98.8%)" : "#161616"};
}
`}</style>
<div className="mx-auto flex h-full min-h-screen flex-col items-center bg-light-100 dark:bg-dark-50">
<PatternedBackground />
<div className="z-10 mx-auto h-full w-full max-w-[1100px]">
<Header isLoggedIn={isLoggedIn} />
<div className="flex h-full w-full flex-col">
<div className="w-full py-32">
<div className="my-10 flex h-full w-full flex-col items-center justify-center">
<div className="relative overflow-hidden rounded-full bg-gradient-to-b from-light-300 to-light-400 p-[2px] dark:from-dark-300 dark:to-dark-400">
<div className="gradient-border absolute inset-0 animate-border-spin" />
<div className="relative z-10 rounded-full bg-light-50 dark:bg-dark-50">
<Link
href="https://github.com/kanbn/kan"
rel="noopener noreferrer"
target="_blank"
className="flex items-center gap-2 px-4 py-1 text-center text-sm text-light-1000 dark:text-dark-1000"
>
Star on Github
<IoLogoGithub size={20} />
</Link>
</div>
</div>
<p className="mt-2 text-center text-5xl font-bold text-light-1000 dark:text-dark-1000">
The open source <br />
alternative to Trello
</p>
<p className="mt-3 max-w-[600px] text-center text-lg text-dark-900">
A powerful, flexible kanban app that helps you organise work,
track progress, and deliver resultsall in one place.
</p>
<div className="mt-6 flex gap-2">
<Button href="/signup">Get started on Cloud</Button>
<Button
variant="secondary"
href="https://github.com/kanbn/kan"
openInNewTab
>
Self host with Github
</Button>
</div>
<p className="mt-4 text-center text-sm text-dark-900">
No credit card required
</p>
</div>
</div>
<div className="mb-24 rounded-[24px] border border-light-300 bg-light-50 p-2 shadow-md dark:border-dark-300 dark:bg-dark-100">
<div className="overflow-hidden rounded-[16px] border border-light-300 shadow-sm dark:border-dark-300">
<Image
src={`/hero-${isDarkMode ? "dark" : "light"}.png`}
alt="kanban"
width={1100}
height={1000}
/>
</div>
</div>
<div className="relative pt-10">
<div id="features" className="absolute -top-20" />
<Features theme={theme.activeTheme} />
</div>
<div className="relative pt-10">
<div id="pricing" className="absolute -top-20" />
<Pricing />
</div>
</div>
</div>
<Footer />
</div>
</>
);
}

View File

@@ -0,0 +1,60 @@
import { api } from "~/utils/api";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
import Button from "~/components/Button";
export function DeleteMemberConfirmation() {
const utils = api.useUtils();
const { closeModal, entityLabel, entityId } = useModal();
const { workspace } = useWorkspace();
const { showPopup } = usePopup();
const deleteMember = api.member.delete.useMutation({
onSuccess: async () => {
closeModal();
try {
await utils.workspace.byId.refetch();
} catch (e) {
console.error(e);
}
},
onError: () => {
showPopup({
header: "Unable to remove member",
message: "Please try again later, or contact customer support.",
});
closeModal();
},
});
const handleDeleteMember = () => {
if (entityId)
deleteMember.mutate({
memberPublicId: entityId,
workspacePublicId: workspace.publicId,
});
};
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 want to remove ${entityLabel}?`}
</h2>
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
{"They won't be able to access this workspace."}
</p>
</div>
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
<Button onClick={() => closeModal()} variant="secondary">
Cancel
</Button>
<Button onClick={handleDeleteMember} isLoading={deleteMember.isPending}>
Remove
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
import { useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2";
import { type InviteMemberInput } from "@kan/api/types";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
export function InviteMemberForm() {
const utils = api.useUtils();
const { closeModal } = useModal();
const { workspace } = useWorkspace();
const { register, handleSubmit } = useForm<InviteMemberInput>({
defaultValues: {
email: "",
workspacePublicId: workspace?.publicId || "",
},
});
const refetchBoards = () => utils.board.all.refetch();
const createBoard = api.member.invite.useMutation({
onSuccess: async () => {
closeModal();
await utils.workspace.byId.refetch();
await refetchBoards();
},
});
const onSubmit = (data: InviteMemberInput) => {
createBoard.mutate(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="px-5 pt-5">
<div className="text-neutral-9000 flex w-full items-center justify-between pb-4 dark:text-dark-1000">
<h2 className="text-sm font-bold">Add member</h2>
<button
className="hover:bg-li ght-300 rounded p-1 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
closeModal();
}}
>
<HiXMark size={18} className="dark:text-dark-9000 text-light-900" />
</button>
</div>
<input
id="email"
placeholder="Email"
{...register("email", { required: true })}
className="block w-full rounded-md border-0 bg-white/5 py-1.5 text-neutral-900 placeholder-dark-800 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"
/>
</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">
<div>
<button
type="submit"
className="inline-flex w-full 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"
>
Invite member
</button>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,42 @@
import { Fragment } from "react";
import { Menu, Transition } from "@headlessui/react";
import { HiEllipsisVertical } from "react-icons/hi2";
export default function MemberDropdown() {
return (
<Menu as="div" className="relative inline-block text-left">
<div>
<Menu.Button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-transparent text-dark-900 focus:outline-none "
>
<span className="sr-only">Open options</span>
<HiEllipsisVertical className="h-5 w-5" aria-hidden="true" />
</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-10 mt-2 w-56 origin-top-right rounded-md border border-dark-400 bg-dark-200 shadow-sm ring-1 ring-black ring-opacity-5 focus:outline-none">
<div className="flex flex-col">
<Menu.Item>
<button
// onClick={}
className="m-1 flex items-center rounded-[5px] px-3 py-2 text-left text-xs text-dark-1000 hover:bg-dark-400"
>
Remove
</button>
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>
);
}

View File

@@ -0,0 +1,222 @@
import { HiOutlinePlusSmall, HiEllipsisHorizontal } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { PageHead } from "~/components/PageHead";
import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { InviteMemberForm } from "./components/InviteMemberForm";
import { DeleteMemberConfirmation } from "./components/DeleteMemberConfirmation";
import Dropdown from "~/components/Dropdown";
import { api } from "~/utils/api";
import { getInitialsFromName, inferInitialsFromEmail } from "~/utils/helpers";
export default function MembersPage() {
const { modalContentType, openModal } = useModal();
const { workspace } = useWorkspace();
const { data, isLoading } = api.workspace.byId.useQuery(
{ workspacePublicId: workspace.publicId },
// { enabled: workspace?.publicId ? true : false },
);
const TableRow = ({
memberPublicId,
memberName,
memberEmail,
memberRole,
memberStatus,
isLastRow,
showSkeleton,
}: {
memberPublicId?: string;
memberName?: string | null | undefined;
memberEmail?: string | null | undefined;
memberRole?: string;
memberStatus?: string;
isLastRow?: boolean;
showSkeleton?: boolean;
}) => {
const initials = memberName
? getInitialsFromName(memberName)
: inferInitialsFromEmail(memberEmail ?? "");
return (
<tr className="rounded-b-lg">
<td className={twMerge("w-[65%]", isLastRow ? "rounded-bl-lg" : "")}>
<div className="flex items-center p-4">
<div className="flex-shrink-0">
<span
className={twMerge(
"inline-flex h-9 w-9 items-center justify-center rounded-full bg-light-1000 dark:bg-dark-400",
showSkeleton && "animate-pulse bg-light-200 dark:bg-dark-200",
)}
>
<span className="text-sm font-medium leading-none text-white">
{initials}
</span>
</span>
</div>
<div className="ml-2 min-w-0 flex-1">
<div>
<div className="flex items-center">
<p
className={twMerge(
"mr-2 text-sm font-medium text-neutral-900 dark:text-dark-1000",
showSkeleton &&
"md mb-2 h-3 w-[125px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
)}
>
{memberName}
</p>
</div>
<p
className={twMerge(
"truncate text-sm text-dark-900",
showSkeleton &&
"h-3 w-[175px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
)}
>
{memberEmail}
</p>
</div>
</div>
</div>
</td>
<td
className={twMerge(
"w-[35%] min-w-[150px]",
isLastRow && "rounded-br-lg",
)}
>
<div className="flex w-full items-center justify-between px-3">
<div>
<span
className={twMerge(
"inline-flex items-center rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[11px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20",
showSkeleton &&
"h-5 w-[50px] animate-pulse bg-light-200 ring-0 dark:bg-dark-200",
)}
>
{memberRole &&
memberRole.charAt(0).toUpperCase() + memberRole.slice(1)}
</span>
{memberStatus === "invited" && (
<span className="ml-2 inline-flex items-center rounded-md bg-gray-500/10 px-1.5 py-0.5 text-[11px] font-medium text-gray-400 ring-1 ring-inset ring-gray-500/20">
Pending
</span>
)}
</div>
<div className={twMerge("relative", showSkeleton && "hidden")}>
<Dropdown
items={[
{
label: "Remove member",
action: () =>
openModal(
"REMOVE_MEMBER",
memberPublicId,
memberEmail ?? "",
),
},
]}
>
<HiEllipsisHorizontal
size={25}
className="text-light-900 dark:text-dark-900"
/>
</Dropdown>
</div>
</div>
</td>
</tr>
);
};
return (
<>
<PageHead title={`Members | ${workspace?.name ?? "Workspace"}`} />
<div className="px-28 py-12">
<div className="mb-8 flex w-full justify-between">
<h1 className="font-medium tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
Members
</h1>
<div className="flex">
<button
type="button"
className="flex items-center gap-x-1.5 rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 dark:bg-dark-1000 dark:text-dark-50"
onClick={() => openModal("INVITE_MEMBER")}
>
<div className="h-5 w-5 items-center">
<HiOutlinePlusSmall
className="-mr-0.5 h-5 w-5"
aria-hidden="true"
/>
</div>
Invite
</button>
</div>
</div>
<div className="mt-8 flow-root">
<div className="-mx-4 -my-2 overflow-x-visible sm:-mx-6 lg:-mx-8">
<div className="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<div className="h-full shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg">
<table className="min-w-full divide-y divide-light-600 dark:divide-dark-600">
<thead className="rounded-t-lg bg-light-300 dark:bg-dark-200">
<tr>
<th
scope="col"
className="w-[65%] rounded-tl-lg py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-light-900 dark:text-dark-900 sm:pl-6"
>
User
</th>
<th
scope="col"
className="w-[35%] rounded-tr-lg px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
>
Role
</th>
</tr>
</thead>
<tbody className="divide-y divide-light-600 bg-light-50 dark:divide-dark-600 dark:bg-dark-100">
{!isLoading &&
data?.members.map((member, index) => (
<TableRow
key={member.publicId}
memberPublicId={member.publicId}
memberName={member?.user?.name}
memberEmail={member?.user?.email}
memberRole={member.role}
memberStatus={member.status}
isLastRow={index === data.members.length - 1}
/>
))}
{isLoading && (
<>
<TableRow showSkeleton />
<TableRow showSkeleton />
<TableRow showSkeleton isLastRow />
</>
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
<Modal>
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
{modalContentType === "INVITE_MEMBER" && <InviteMemberForm />}
{modalContentType === "REMOVE_MEMBER" && <DeleteMemberConfirmation />}
</Modal>
</div>
</>
);
}

View File

@@ -0,0 +1,96 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { api } from "~/utils/api";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
import Button from "~/components/Button";
export function DeleteWorkspaceConfirmation() {
const { closeModal } = useModal();
const { workspace, switchWorkspace, availableWorkspaces } = useWorkspace();
const { showPopup } = usePopup();
const router = useRouter();
const [isAcknowledgmentChecked, setIsAcknowledgmentChecked] = useState(false);
const deleteWorkspaceMutation = api.workspace.delete.useMutation({
onSuccess: () => {
closeModal();
const filteredWorkspaces = availableWorkspaces.filter(
(ws) => ws.publicId !== workspace?.publicId,
);
if (filteredWorkspaces.length > 0 && filteredWorkspaces[0]) {
switchWorkspace(filteredWorkspaces[0]);
} else {
router.push("/");
}
},
onError: () => {
closeModal();
showPopup({
header: "Error deleting workspace",
message: "Please try again later, or contact customer support.",
});
},
});
const handleDeleteWorkspace = () => {
deleteWorkspaceMutation.mutate({
workspacePublicId: workspace?.publicId,
});
};
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 the workspace ${workspace?.name}?`}
</h2>
<p className="mb-4 text-sm text-light-900 dark:text-dark-900">
Keep in mind that this action is irreversible.
</p>
<p className="text-sm text-light-900 dark:text-dark-900">
This will result in the permanent deletion of all data associated with
this workspace.
</p>
</div>
<div className="relative flex items-start">
<div className="flex h-6 items-center">
<input
id="acknowledgment"
name="acknowledgment"
type="checkbox"
aria-describedby="acknowledgment-description"
className="mt-2 h-[14px] w-[14px] rounded border-gray-300 bg-transparent text-indigo-600 focus:shadow-none focus:ring-0 focus:ring-offset-0"
checked={isAcknowledgmentChecked}
onChange={() =>
setIsAcknowledgmentChecked(!isAcknowledgmentChecked)
}
/>
</div>
<div className="ml-3 text-sm leading-6">
<p id="comments-description" className="text-dark-1000">
I acknowledge that all of the workspace data will be permanently
deleted and want to proceed.
</p>
</div>
</div>
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
<Button variant="secondary" onClick={() => closeModal()}>
Cancel
</Button>
<Button
variant="danger"
onClick={handleDeleteWorkspace}
disabled={!isAcknowledgmentChecked}
isLoading={deleteWorkspaceMutation.isPending}
>
Delete workspace
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import Input from "~/components/Input";
import Button from "~/components/Button";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
const schema = z.object({
name: z
.string()
.min(3, { message: "Workspace name must be at least 3 characters long" })
.max(24, { message: "Workspace name cannot exceed 24 characters" }),
});
type FormValues = z.infer<typeof schema>;
const UpdateWorkspaceNameForm = ({
workspacePublicId,
workspaceName,
}: {
workspacePublicId: string;
workspaceName: string;
}) => {
const utils = api.useUtils();
const { showPopup } = usePopup();
const {
register,
handleSubmit,
formState: { isDirty, errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
values: {
name: workspaceName,
},
});
const updateWorkspaceName = api.workspace.update.useMutation({
onSuccess: async () => {
try {
await utils.workspace.all.refetch();
} catch (e) {
console.error(e);
throw e;
}
},
onError: () => {
showPopup({
header: "Error updating workspace name",
message: "Please try again later, or contact customer support.",
});
},
});
const onSubmit = (data: FormValues) => {
updateWorkspaceName.mutate({
workspacePublicId,
name: data.name,
});
};
return (
<>
<div className="mb-4 flex max-w-[350px] items-center gap-2">
<Input {...register("name")} errorMessage={errors.name?.message} />
</div>
<Button
onClick={handleSubmit(onSubmit)}
variant="primary"
disabled={!isDirty || updateWorkspaceName.isPending}
isLoading={updateWorkspaceName.isPending}
>
Update
</Button>
</>
);
};
export default UpdateWorkspaceNameForm;

View File

@@ -0,0 +1,59 @@
import Modal from "~/components/modal";
import Button from "~/components/Button";
import { PageHead } from "~/components/PageHead";
import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation";
import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
export default function SettingsPage() {
const { modalContentType, openModal } = useModal();
const { workspace } = useWorkspace();
return (
<>
<PageHead title={`Settings | ${workspace?.name ?? "Workspace"}`} />
<div className="px-28 py-12">
<div className="mb-8 flex w-full justify-between">
<h1 className="font-medium tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
Settings
</h1>
</div>
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
Workspace name
</h2>
<UpdateWorkspaceNameForm
workspacePublicId={workspace?.publicId}
workspaceName={workspace?.name}
/>
</div>
<div className="border-t border-light-300 dark:border-dark-300">
<h2 className="mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
Delete workspace
</h2>
<p className="mb-8 mt-2 text-sm text-neutral-500 dark:text-dark-900">
Once you delete your workspace, there is no going back. Please be
certain.
</p>
<Button
variant="primary"
onClick={() => openModal("DELETE_WORKSPACE")}
>
Delete workspace
</Button>
</div>
<Modal>
{modalContentType === "DELETE_WORKSPACE" && (
<DeleteWorkspaceConfirmation />
)}
</Modal>
</div>
</>
);
}