feat: switch next auth and drizzle to supabase (#1)

* feat: add supabase

* feat: setup auth

* feat: test refactoring board queries

* feat: convert to pages router

* feat: convert board router to supabase

* feat: swap out drizzle for supabase in label, list and workspace routers

* feat: swap out drizzle for supabase on the import router

* feat: swap drizzle for supabase on create new card

* feat: switch card router to use supabase

* feat: finish swapping drizzle for supabase

* chore: lint all router files

* fix: signout

* chore: fix types
This commit is contained in:
Henry
2024-04-23 11:57:34 +01:00
committed by GitHub
parent 1711df03e7
commit d15870118d
96 changed files with 3636 additions and 1521 deletions

View File

@@ -0,0 +1,20 @@
import { Login } from "~/components/auth";
export default function LoginPage() {
return (
<main className="flex h-screen flex-col items-center justify-center bg-dark-50">
<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">Get started</p>
<div className="w-full rounded-md border border-dark-600 bg-dark-300 px-10 py-10 sm:max-w-md">
<div className=" sm:mx-auto sm:w-full sm:max-w-sm">
<Login />
</div>
</div>
<p className="mt-10 text-center text-sm text-dark-1000">
{"Already have an account?"}
</p>
</main>
);
}

View File

@@ -0,0 +1,12 @@
export default function VerifyPage() {
return (
<main className="bg-dark-50 flex h-screen flex-col items-center justify-center">
<p className="text-dark-1000 mb-10 text-3xl">Check your inbox</p>
<div className=" sm:mx-auto sm:w-full sm:max-w-sm">
<p className="text-dark-1000 text-md mt-5 text-center">
{`Click on the link we've sent to you@example.com to sign in.`}
</p>
</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,49 @@
import { useRouter } from "next/navigation";
import { api } from "~/utils/api";
import { useBoard } from "~/providers/board";
import { useModal } from "~/providers/modal";
export function DeleteBoardConfirmation() {
const router = useRouter();
const { boardData } = useBoard();
const { closeModal } = useModal();
const deleteBoard = api.board.delete.useMutation({
onSuccess: () => {
closeModal();
router.push(`/boards`);
},
});
return (
<>
<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 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={() =>
deleteBoard.mutate({
boardPublicId: boardData.publicId,
})
}
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>
</>
);
}

View File

@@ -0,0 +1,56 @@
import { api } from "~/utils/api";
import { useBoard } from "~/providers/board";
import { useModal } from "~/providers/modal";
interface DeleteListConfirmationProps {
listPublicId: string;
}
export function DeleteListConfirmation({
listPublicId,
}: DeleteListConfirmationProps) {
const utils = api.useUtils();
const { boardData } = useBoard();
const { closeModal } = useModal();
const refetchBoard = () =>
utils.board.byId.refetch({ id: boardData.publicId });
const deleteList = api.list.delete.useMutation({
onSuccess: async () => {
closeModal();
await refetchBoard();
},
});
return (
<>
<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 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={() =>
deleteList.mutate({
listPublicId,
})
}
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>
</>
);
}

View File

@@ -0,0 +1,106 @@
import { type ReactNode } from "react";
import { HiOutlinePlusSmall } from "react-icons/hi2";
import { Draggable } from "react-beautiful-dnd";
import { useFormik } from "formik";
import { api } from "~/utils/api";
import { useModal } from "~/providers/modal";
import ListDropdown from "./ListDropdown";
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 formik = useFormik({
initialValues: {
listPublicId: list.publicId,
name: list.name,
},
onSubmit: (values: FormValues) => {
updateList.mutate({
listPublicId: values.listPublicId,
name: values.name,
});
},
enableReinitialize: true,
});
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-400 dark:bg-dark-200"
>
<div className="flex justify-between">
<form
onSubmit={formik.handleSubmit}
className="focus-visible:outline-none"
>
<input
type="name"
id="name"
name="name"
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.submitForm}
className="font-mediumfocus:ring-0 mb-4 block border-0 bg-transparent px-4 pt-1 text-sm text-neutral-900 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-400"
onClick={() => openNewCardForm(list.publicId)}
>
<HiOutlinePlusSmall
className="h-5 w-5 text-dark-900"
aria-hidden="true"
/>
</button>
<ListDropdown
setSelectedPublicListId={() =>
setSelectedPublicListId(list.publicId)
}
/>
</div>
</div>
{children}
</div>
)}
</Draggable>
);
}

View File

@@ -0,0 +1,52 @@
import { Fragment } from "react";
import { Menu, Transition } from "@headlessui/react";
import { HiEllipsisHorizontal } from "react-icons/hi2";
import { useModal } from "~/providers/modal";
interface ListDropdownProps {
setSelectedPublicListId: () => void;
}
export default function ListDropdown({
setSelectedPublicListId,
}: ListDropdownProps) {
const { openModal } = useModal();
const handleOpenDeleteListConfirmation = () => {
setSelectedPublicListId();
openModal("DELETE_LIST");
};
return (
<Menu as="div" className="relative inline-block text-left">
<div>
<Menu.Button className="mr-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-400">
<HiEllipsisHorizontal className="h-5 w-5 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="dark-text-dark-1000 absolute right-0 z-10 mt-2 w-56 origin-top-right rounded-md border border-light-400 bg-light-50 text-neutral-900 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={handleOpenDeleteListConfirmation}
className="m-1 w-full rounded-[5px] px-3 py-2 text-left text-sm hover:bg-light-400 dark:hover:bg-dark-400"
>
Delete list
</button>
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>
);
}

View File

@@ -0,0 +1,303 @@
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { api } from "~/utils/api";
import { HiXMark } from "react-icons/hi2";
import { Switch } from "@headlessui/react";
import CheckboxDropdown from "~/components/CheckboxDropdown";
import { useBoard } from "~/providers/board";
import { useModal } from "~/providers/modal";
interface FormData {
title: string;
listPublicId: string;
labelPublicIds: string[];
memberPublicIds: string[];
isCreateAnotherEnabled: boolean;
}
interface NewCardFormProps {
listPublicId: string;
}
function classNames(...classes: string[]): string {
return classes.filter(Boolean).join(" ");
}
export function NewCardForm({ listPublicId }: NewCardFormProps) {
const utils = api.useUtils();
const { boardData } = useBoard();
const { closeModal } = useModal();
const { register, handleSubmit, reset, setValue, watch } = useForm<FormData>({
defaultValues: {
title: "",
listPublicId,
labelPublicIds: [],
memberPublicIds: [],
isCreateAnotherEnabled: false,
},
});
const labelPublicIds = watch("labelPublicIds") || [];
const memberPublicIds = watch("memberPublicIds") || [];
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
const refetchBoard = () =>
utils.board.byId.refetch({ id: boardData.publicId });
const createCard = api.card.create.useMutation({
onSuccess: async () => {
try {
await refetchBoard();
if (!isCreateAnotherEnabled) closeModal();
reset({
title: "",
listPublicId: watch("listPublicId"),
labelPublicIds: [],
memberPublicIds: [],
isCreateAnotherEnabled: watch("isCreateAnotherEnabled"),
});
} catch (e) {
console.log(e);
}
},
});
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: FormData) => {
createCard.mutate({
title: data.title,
listPublicId: data.listPublicId,
labelsPublicIds: data.labelPublicIds,
memberPublicIds: data.memberPublicIds,
});
};
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 (
<>
<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 card
</h2>
<button
className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
onClick={() => closeModal()}
>
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
</button>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label
htmlFor="title"
className="block pb-2 text-sm font-normal leading-6 text-neutral-900 dark:text-dark-1000"
>
Title
</label>
<input
id="title"
type="text"
{...register("title")}
className="block w-full rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 text-neutral-900 shadow-sm ring-1 ring-inset ring-light-600 focus:ring-2 focus:ring-inset focus:ring-light-600 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6"
/>
</div>
<div className="mt-2 flex space-x-1">
<div className="w-fit">
<CheckboxDropdown
items={formattedLists}
handleSelect={(list: { key: string }) =>
handleSelectList(list.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={(list: { key: string }) =>
handleSelectMembers(list.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={(list: { key: string }) =>
handleSelectLabels(list.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>
</div>
<div className="mt-3 flex items-center justify-end">
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
Create more
</span>
<Switch
checked={isCreateAnotherEnabled}
onChange={handleToggleCreateAnother}
className={classNames(
isCreateAnotherEnabled
? "bg-indigo-600"
: "bg-light-800 dark:bg-dark-800",
"relative inline-flex h-4 w-6 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none",
)}
>
<span className="sr-only">Create another</span>
<span
aria-hidden="true"
className={classNames(
isCreateAnotherEnabled ? "translate-x-2" : "translate-x-0",
"pointer-events-none inline-block h-3 w-3 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out",
)}
/>
</Switch>
</div>
<div className="mt-5 sm:mt-6">
<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 card
</button>
</div>
</form>
</>
);
}

View File

@@ -0,0 +1,124 @@
import { useState, useEffect } from "react";
import { api } from "~/utils/api";
import { HiXMark } from "react-icons/hi2";
import { Switch } from "@headlessui/react";
import { useBoard } from "~/providers/board";
import { useModal } from "~/providers/modal";
import { Formik, Form, Field } from "formik";
interface FormValues {
name: string;
}
interface boardPublicId {
boardPublicId: string;
}
function classNames(...classes: string[]): string {
return classes.filter(Boolean).join(" ");
}
export function NewListForm({ boardPublicId }: boardPublicId) {
const utils = api.useUtils();
const { boardData } = useBoard();
const { closeModal } = useModal();
const [isCreateAnotherEnabled, setIsCreateAnotherEnabled] = useState(false);
const refetchBoard = () =>
utils.board.byId.refetch({ id: boardData.publicId });
const createList = api.list.create.useMutation({
onSuccess: async () => {
try {
await refetchBoard();
if (!isCreateAnotherEnabled) closeModal();
} catch (e) {
console.log(e);
}
},
});
useEffect(() => {
const nameElement: HTMLElement | null =
document?.querySelector<HTMLElement>("#list-name");
if (nameElement) nameElement.focus();
}, []);
return (
<>
<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={() => closeModal()}
>
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
</button>
</div>
<Formik
initialValues={{
name: "",
}}
onSubmit={(values: FormValues, { resetForm }) => {
createList.mutate({
name: values.name,
boardPublicId,
});
resetForm();
}}
>
<Form>
<label
htmlFor="list-name"
className="block pb-2 text-sm font-normal leading-6 text-neutral-900 dark:text-dark-1000"
>
Name
</label>
<Field
id="list-name"
name="name"
className="block w-full rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 text-neutral-900 shadow-sm ring-1 ring-inset ring-light-600 focus:ring-2 focus:ring-inset focus:ring-light-600 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6"
/>
<div className="mt-3 flex items-center justify-end">
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
Create more
</span>
<Switch
checked={isCreateAnotherEnabled}
onChange={setIsCreateAnotherEnabled}
className={classNames(
isCreateAnotherEnabled
? "bg-indigo-600"
: "bg-light-800 dark:bg-dark-800",
"relative inline-flex h-4 w-6 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none",
)}
>
<span className="sr-only">Create more</span>
<span
aria-hidden="true"
className={classNames(
isCreateAnotherEnabled ? "translate-x-2" : "translate-x-0",
"pointer-events-none inline-block h-3 w-3 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out",
)}
/>
</Switch>
</div>
<div className="mt-5 sm:mt-6">
<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 list
</button>
</div>
</Form>
</Formik>
</>
);
}

344
src/views/board/index.tsx Normal file
View File

@@ -0,0 +1,344 @@
import { useState } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { HiOutlinePlusSmall } from "react-icons/hi2";
import {
DragDropContext,
Droppable,
type DropResult,
Draggable,
} from "react-beautiful-dnd";
import { useFormik } from "formik";
import { api } from "~/utils/api";
import { useBoard } from "~/providers/board";
import { useModal } from "~/providers/modal";
import Modal from "~/components/modal";
import BoardDropdown from "./components/BoardDropdown";
import { DeleteBoardConfirmation } from "./components/DeleteBoardConfirmation";
import { DeleteListConfirmation } from "./components/DeleteListConfirmation";
import List from "./components/List";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { NewCardForm } from "./components/NewCardForm";
import { NewListForm } from "./components/NewListForm";
interface List {
publicId: string;
name: string;
cards?: Card[];
}
interface Card {
publicId: string;
title: string;
labels?: Label[];
members?: Member[];
}
interface Label {
publicId: string;
name: string;
colourCode: string;
}
interface Member {
publicId: string;
user: User;
}
interface User {
name: string;
}
interface FormValues {
boardId: string;
name: string;
}
type PublicListId = string;
export default function BoardPage() {
const params = useParams();
const { boardData, setBoardData, updateCard, updateList } = useBoard();
const { openModal, modalContentType } = useModal();
const [selectedPublicListId, setSelectedPublicListId] =
useState<PublicListId>("");
console.log({ boardData });
const boardId = params?.boardId?.length ? params.boardId[0] : null;
const updateBoard = api.board.update.useMutation();
const formik = useFormik({
initialValues: {
boardId: boardId ?? "",
name: boardData?.name ? boardData.name : "",
},
onSubmit: (values: FormValues) => {
updateBoard.mutate({
boardId: values.boardId,
name: values.name,
});
},
enableReinitialize: true,
});
const { data, isSuccess } = api.board.byId.useQuery(
{ id: boardId ?? "" },
{
enabled: !!boardId,
},
);
if (isSuccess && data) {
console.log({ data });
setBoardData(data);
}
if (!boardId) 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({
boardId,
listId: 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({
cardId: draggableId,
newListId: destination.droppableId,
newIndex: destination.index,
});
}
};
return (
<div className="relative flex h-full flex-col">
<div>
<svg
style={{
position: "absolute",
width: "100%",
height: "100%",
top: "0px",
left: "0px",
color: "white",
}}
>
<pattern
id="pattern"
x="0.034759358288862785"
y="3.335370511841166"
width="14.423223834988539"
height="14.423223834988539"
patternUnits="userSpaceOnUse"
patternTransform="translate(-0.45072574484339184,-0.45072574484339184)"
>
<circle
cx="0.45072574484339184"
cy="0.45072574484339184"
r="0.45072574484339184"
fill="#3e3e3e"
></circle>
</pattern>
<rect
x="0"
y="0"
width="100%"
height="100%"
fill="url(#pattern)"
></rect>
</svg>
</div>
<div className="z-20 flex w-full justify-between p-8 ">
<form
onSubmit={formik.handleSubmit}
className="focus-visible:outline-none"
>
<input
type="name"
id="name"
name="name"
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.submitForm}
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">
<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 z-10 flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain pb-5 scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300">
<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: 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="h-full max-h-[calc(100vh-250px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar scrollbar-track-dark-100 scrollbar-thumb-dark-600 scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px]"
>
{list.cards?.map((card, index) => (
<Draggable
key={card.publicId}
draggableId={card.publicId}
index={index}
>
{(provided) => (
<Link
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-500 dark:text-dark-1000"
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}
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>
{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,65 @@
import Link from "next/link";
import { api } from "~/utils/api";
import { useWorkspace } from "~/providers/workspace";
export function BoardsList() {
const { workspace } = useWorkspace();
const { data } = api.board.all.useQuery(
{ workspacePublicId: workspace?.publicId },
{ enabled: workspace?.publicId ? true : false },
);
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-600 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-100 dark:hover:bg-dark-200">
<div>
<svg
style={{
position: "absolute",
width: "100%",
height: "100%",
top: "0px",
left: "0px",
color: "white",
}}
>
<pattern
id="pattern"
x="0.034759358288862785"
y="3.335370511841166"
width="14.423223834988539"
height="14.423223834988539"
patternUnits="userSpaceOnUse"
patternTransform="translate(-0.45072574484339184,-0.45072574484339184)"
>
<circle
cx="0.45072574484339184"
cy="0.45072574484339184"
r="0.45072574484339184"
fill="#3e3e3e"
></circle>
</pattern>
<rect
x="0"
y="0"
width="100%"
height="100%"
fill="url(#pattern)"
></rect>
</svg>
</div>
<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,271 @@
import { Fragment, useState } from "react";
import { api } from "~/utils/api";
import { Listbox, Transition } from "@headlessui/react";
import { FaTrello } from "react-icons/fa";
import { HiChevronUpDown, HiXMark } from "react-icons/hi2";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { useFormik } from "formik";
interface TrelloFormValues {
apiKey: string;
token: string;
}
const sources = [{ source: "Trello" }];
const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
const formik = useFormik({
initialValues: {
source: "Trello",
},
onSubmit: () => {
handleNextStep();
},
});
return (
<form onSubmit={formik.handleSubmit}>
<label
htmlFor="name"
className="block pb-2 text-sm font-normal leading-6 text-neutral-900 dark:text-dark-1000"
>
Source
</label>
<Listbox value={formik.values.source} onChange={formik.handleChange}>
{({ 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">
{formik.values.source}
</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 className="mt-5 sm:mt-6">
<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"
>
Select source
</button>
</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 formik = useFormik({
initialValues: {
apiKey: "",
token: "",
},
onSubmit: (values: TrelloFormValues) => {
handleSetAuthDetails(values.apiKey, values.token);
},
});
const boardsFormik = useFormik({
initialValues: {
...Object.fromEntries(
boards?.data?.map((board) => [board.id, true]) ?? [],
),
},
onSubmit: () => {
const boardIds = Object.keys(boardsFormik.values).filter(
(key) => boardsFormik.values[key] === true,
);
importBoards.mutate({
boardIds,
apiKey: formik.values.apiKey,
token: formik.values.token,
workspacePublicId: workspace?.publicId,
});
},
enableReinitialize: true,
});
if (boards?.data?.length)
return (
<form onSubmit={boardsFormik.handleSubmit}>
<div className="h-[105px] overflow-scroll">
{boards.data.map((board) => (
<div key={board.id}>
<div
className="flex items-center rounded-[5px] p-2 hover:bg-dark-300"
onClick={async (e) => {
e.preventDefault();
await boardsFormik.setFieldValue(
board.id,
!boardsFormik.values[board.id],
);
}}
>
<input
id={board.id}
name={board.id}
type="checkbox"
className="h-[14px] w-[14px] rounded bg-transparent"
onClick={(event) => event.stopPropagation()}
checked={boardsFormik.values[board.id]}
/>
<label
htmlFor={board.id}
className="ml-3 text-sm text-dark-1000"
>
{board.name}
</label>
</div>
</div>
))}
</div>
<div className="mt-5 sm:mt-6">
<button
type="submit"
className="inline-flex w-full justify-center rounded-md bg-dark-1000 px-3 py-2 text-sm font-semibold text-dark-50 shadow-sm focus-visible:outline-none"
>
Import selected
</button>
</div>
</form>
);
return (
<form
onSubmit={formik.handleSubmit}
className="text-neutral-900 dark:text-dark-1000"
>
<label
htmlFor="apiKey"
className="block pb-2 text-sm font-normal leading-6"
>
API Key
</label>
<input
id="apiKey"
name="apiKey"
className="mb-2 block w-full rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 shadow-sm ring-1 ring-inset ring-light-600 focus:ring-2 focus:ring-inset focus:ring-light-700 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6"
value={formik.values.apiKey}
onChange={formik.handleChange}
/>
<label
htmlFor="token"
className="block pb-2 text-sm font-normal leading-6"
>
Token
</label>
<input
id="token"
name="token"
className="mb-2 block w-full rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 shadow-sm ring-1 ring-inset ring-light-600 focus:ring-2 focus:ring-inset focus:ring-light-700 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6"
value={formik.values.token}
onChange={formik.handleChange}
/>
<div className="mt-5 sm:mt-6">
<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"
>
Fetch boards
</button>
</div>
</form>
);
};
export function ImportBoardsForm() {
const { closeModal } = useModal();
const [step, setStep] = useState(1);
return (
<>
<div className="flex w-full justify-between pb-4">
<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 />}
</>
);
}

View File

@@ -0,0 +1,75 @@
import { api } from "~/utils/api";
import { HiXMark } from "react-icons/hi2";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { Formik, Form, Field } from "formik";
interface FormValues {
name: string;
}
export function NewBoardForm() {
const utils = api.useUtils();
const { closeModal } = useModal();
const { workspace } = useWorkspace();
const refetchBoards = () => utils.board.all.refetch();
const createBoard = api.board.create.useMutation({
onSuccess: async () => {
closeModal();
await refetchBoards();
},
});
return (
<>
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
<h2 className="text-sm font-bold">New board</h2>
<button
className="rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
onClick={() => closeModal()}
>
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
</button>
</div>
<Formik
initialValues={{
name: "",
}}
onSubmit={(values: FormValues) => {
createBoard.mutate({
name: values.name,
workspacePublicId: workspace?.publicId,
});
}}
>
<Form>
<label
htmlFor="name"
className="block pb-2 text-sm font-normal leading-6 text-neutral-900 dark:text-dark-1000"
>
Name
</label>
<Field
id="name"
name="name"
className="block w-full rounded-md border-0 bg-white/5 py-1.5 text-neutral-900 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 className="mt-5 sm:mt-6">
<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>
</Form>
</Formik>
</>
);
}

View File

@@ -0,0 +1,58 @@
import { HiArrowDownTray, HiOutlinePlusSmall } from "react-icons/hi2";
import { BoardsList } from "./components/BoardsList";
import { useModal } from "~/providers/modal";
import Modal from "~/components/modal";
import { ImportBoardsForm } from "./components/ImportBoardsForm";
import { NewBoardForm } from "./components/NewBoardForm";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
export default function BoardsPage() {
const { openModal, modalContentType } = useModal();
return (
<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,54 @@
import { useRouter } from "next/navigation";
import { api } from "~/utils/api";
import { useModal } from "~/providers/modal";
interface DeleteCardConfirmationProps {
cardPublicId: string;
boardPublicId: string;
}
export function DeleteCardConfirmation({
cardPublicId,
boardPublicId,
}: DeleteCardConfirmationProps) {
const { closeModal } = useModal();
const router = useRouter();
const deleteCard = api.card.delete.useMutation({
onSuccess: () => {
closeModal();
router.push(`/boards/${boardPublicId}`);
},
});
return (
<>
<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={() =>
deleteCard.mutate({
cardPublicId,
})
}
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>
</>
);
}

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,150 @@
import { Fragment } from "react";
import { api } from "~/utils/api";
import { Menu, Transition } from "@headlessui/react";
import { HiMiniPlus } from "react-icons/hi2";
import { useFormik } from "formik";
import { useModal } from "~/providers/modal";
interface LabelSelectorProps {
cardPublicId: string;
labels: {
publicId: string;
name: string;
selected: boolean;
colourCode: string;
}[];
}
export default function LabelSelector({
cardPublicId,
labels,
}: LabelSelectorProps) {
const { openModal } = useModal();
const utils = api.useUtils();
const refetchCard = () => utils.card.byId.refetch({ id: cardPublicId });
const addOrRemoveLabel = api.card.addOrRemoveLabel.useMutation({
onSuccess: async () => {
await refetchCard();
},
});
const formik = useFormik({
initialValues: {
...Object.fromEntries(
labels?.map((label) => [label.publicId, label.selected]) ?? [],
),
},
onSubmit: (values) => {
console.log({ values });
},
enableReinitialize: true,
});
const selectedLabels = labels.filter((label) => label.selected);
return (
<>
<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={formik.handleSubmit}>
{labels?.map((label) => (
<Menu.Item key={label.publicId}>
{() => (
<div
key={label.publicId}
className="flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={async (e) => {
e.preventDefault();
await formik.setFieldValue(
label.publicId,
!formik.values[label.publicId],
);
addOrRemoveLabel.mutate({
cardPublicId,
labelPublicId: label.publicId,
});
await formik.submitForm();
}}
>
<input
id={label.publicId}
name={label.publicId}
type="checkbox"
className="h-[14px] w-[14px] rounded bg-transparent"
onClick={(event) => event.stopPropagation()}
onChange={formik.handleChange}
checked={formik.values[label.publicId]}
/>
<label
htmlFor={label.publicId}
className="ml-3 text-sm"
>
{label.name}
</label>
</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,107 @@
import { Fragment } from "react";
import { api } from "~/utils/api";
import { Menu, Transition } from "@headlessui/react";
import { useFormik } from "formik";
interface ListSelectorProps {
cardPublicId: string;
lists: {
publicId: string;
name: string;
selected: boolean;
}[];
}
export default function ListSelector({
cardPublicId,
lists,
}: ListSelectorProps) {
const utils = api.useUtils();
const refetchCard = () => utils.card.byId.refetch({ id: cardPublicId });
const updateCardList = api.card.reorder.useMutation({
onSuccess: async () => {
await refetchCard();
},
});
const formik = useFormik({
initialValues: {
...Object.fromEntries(
lists?.map((list) => [list.publicId, list.selected]) ?? [],
),
},
onSubmit: (values) => {
console.log({ values });
},
enableReinitialize: true,
});
const selectedList = lists.find((list) => list.selected);
return (
<>
<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={formik.handleSubmit}>
{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={async (e) => {
e.preventDefault();
await formik.setFieldValue(
list.publicId,
!formik.values[list.publicId],
);
updateCardList.mutate({
cardId: cardPublicId,
newListId: list.publicId,
});
await formik.submitForm();
}}
>
<input
id={list.publicId}
name={list.publicId}
type="checkbox"
className="h-[14px] w-[14px] rounded bg-transparent"
onClick={(event) => event.stopPropagation()}
onChange={formik.handleChange}
checked={formik.values[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,134 @@
import { Fragment } from "react";
import { api } from "~/utils/api";
import { Menu, Transition } from "@headlessui/react";
import { HiMiniPlus } from "react-icons/hi2";
import { useFormik } from "formik";
interface MemberSelectorProps {
cardPublicId: string;
members: {
publicId: string;
user: {
id: string;
name: string | null;
};
selected: boolean;
}[];
}
export default function LabelSelector({
cardPublicId,
members,
}: MemberSelectorProps) {
const utils = api.useUtils();
const refetchCard = () => utils.card.byId.refetch({ id: cardPublicId });
const addOrRemoveMember = api.card.addOrRemoveMember.useMutation({
onSuccess: async () => {
await refetchCard();
},
});
const formik = useFormik({
initialValues: {
...Object.fromEntries(
members?.map((member) => [member.publicId, member.selected]) ?? [],
),
},
onSubmit: (values) => {
console.log({ values });
},
enableReinitialize: true,
});
const selectedMembers = members.filter((member) => member.selected);
return (
<>
<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={formik.handleSubmit}>
{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={async (e) => {
e.preventDefault();
await formik.setFieldValue(
member.publicId,
!formik.values[member.publicId],
);
addOrRemoveMember.mutate({
cardPublicId,
workspaceMemberPublicId: member.publicId,
});
await formik.submitForm();
}}
>
<input
id={member.publicId}
name={member.publicId}
type="checkbox"
className="h-[14px] w-[14px] rounded bg-transparent"
onClick={(event) => event.stopPropagation()}
onChange={formik.handleChange}
checked={formik.values[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,163 @@
import { Fragment, useState } from "react";
import { Listbox, Transition } from "@headlessui/react";
import { api } from "~/utils/api";
import { HiChevronUpDown, HiXMark } from "react-icons/hi2";
import { useModal } from "~/providers/modal";
import { Formik, Form, Field } from "formik";
interface FormValues {
name: string;
colour: Colour | undefined;
}
interface Colour {
name: string;
code: string;
}
interface cardPublicId {
cardPublicId: 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 NewLabelForm({ cardPublicId }: cardPublicId) {
const [selected, setSelected] = useState(colours[0]);
const utils = api.useUtils();
const { closeModal } = useModal();
const refetchCard = () => utils.card.byId.refetch({ id: cardPublicId });
const createLabel = api.label.create.useMutation({
onSuccess: async () => {
try {
await refetchCard();
closeModal();
} catch (e) {
console.log(e);
}
},
});
return (
<>
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
<h2 className="text-sm font-medium">New label</h2>
<button
className="rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
onClick={() => closeModal()}
>
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
</button>
</div>
<Formik
initialValues={{
name: "",
colour: colours[0],
}}
onSubmit={(values: FormValues) => {
if (!values.colour?.code) return;
createLabel.mutate({
name: values.name,
cardPublicId,
colourCode: values.colour.code,
});
}}
>
<Form>
<label
htmlFor="name"
className="block pb-2 text-sm font-normal leading-6 text-neutral-900 dark:text-dark-1000"
>
Name
</label>
<Field
id="name"
name="name"
className="block w-full rounded-md border-0 bg-white/5 py-1.5 text-neutral-900 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"
/>
<Listbox value={selected} onChange={setSelected}>
{({ 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: selected?.code }}
className={`inline-block h-2 w-2 flex-shrink-0 rounded-full`}
/>
<span className="ml-3 block truncate">
{selected?.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 className="mt-5 sm:mt-6">
<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 label
</button>
</div>
</Form>
</Formik>
</>
);
}

174
src/views/card/index.tsx Normal file
View File

@@ -0,0 +1,174 @@
import Link from "next/link";
import { useParams } from "next/navigation";
import { useFormik } from "formik";
import ContentEditable from "react-contenteditable";
import { IoChevronForwardSharp } from "react-icons/io5";
import Dropdown from "./components/Dropdown";
import { DeleteCardConfirmation } from "./components/DeleteCardConfirmation";
import LabelSelector from "./components/LabelSelector";
import ListSelector from "./components/ListSelector";
import MemberSelector from "./components/MemberSelector";
import { NewLabelForm } from "./components/NewLabelForm";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import Modal from "~/components/modal";
import { useModal } from "~/providers/modal";
import { api } from "~/utils/api";
interface FormValues {
cardId: string;
title: string;
description: string;
}
export default function CardPage() {
const params = useParams();
const { modalContentType } = useModal();
const cardId = Array.isArray(params?.cardId)
? params.cardId[0]
: params?.cardId;
const { data, error } = api.card.byId.useQuery({ id: cardId ?? "" });
const board = data?.list?.board;
const boardId = board?.publicId;
const labels = board?.labels;
const workspaceMembers = board?.workspace?.members;
const selectedLabels = data?.labels;
const selectedMembers = data?.members;
console.log({ board: error });
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();
const formik = useFormik({
initialValues: {
cardId: cardId ?? "",
title: data?.title ? data.title : "",
description: data?.description ? data.description : "",
},
onSubmit: (values: FormValues) => {
updateCard.mutate({
cardId: values.cardId,
title: values.title,
description: values.description,
});
},
enableReinitialize: true,
});
if (!cardId) return <></>;
return (
<div className="flex h-full flex-1 flex-row">
<div className="w-full p-8">
<div className="mb-8 flex w-full items-center justify-between">
<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={formik.handleSubmit} className="w-full space-y-6">
<div>
<input
type="text"
id="title"
name="title"
value={formik.values.title}
onChange={formik.handleChange}
onBlur={formik.submitForm}
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-8 flex w-full max-w-2xl justify-between">
<form onSubmit={formik.handleSubmit} className="w-full space-y-6">
<div className="mt-2">
<ContentEditable
placeholder="Add description..."
html={formik.values.description}
disabled={false}
onChange={(e) =>
formik.setFieldValue("description", e.target.value)
}
onBlur={formik.submitForm}
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>
<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} />
</div>
<div className="mb-4 flex w-full">
<p className="my-2 w-[100px] text-sm">Labels</p>
<LabelSelector cardPublicId={cardId} labels={formattedLabels} />
</div>
<div className="flex w-full">
<p className="my-2 w-[100px] text-sm">Members</p>
<MemberSelector cardPublicId={cardId} members={formattedMembers} />
</div>
</div>
<Modal>
{modalContentType === "NEW_LABEL" && (
<NewLabelForm cardPublicId={cardId} />
)}
{modalContentType === "DELETE_CARD" && (
<DeleteCardConfirmation
boardPublicId={boardId ?? ""}
cardPublicId={cardId}
/>
)}
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
</Modal>
</div>
);
}

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>
);
}

116
src/views/members/index.tsx Normal file
View File

@@ -0,0 +1,116 @@
import { HiOutlinePlusSmall } from "react-icons/hi2";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { api } from "~/utils/api";
export default function MembersPage() {
const { modalContentType } = useModal();
const { workspace } = useWorkspace();
const { data } = api.workspace.byId.useQuery(
{ publicId: workspace.publicId },
{ enabled: workspace?.publicId ? true : false },
);
return (
<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("")}
>
<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-auto 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="overflow-hidden 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="bg-light-300 dark:bg-dark-200">
<tr>
<th
scope="col"
className="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="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">
{data?.members.map((member) => (
<tr key={member.publicId}>
<td>
<div className="flex items-center p-4">
<div className="flex-shrink-0">
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-light-1000 dark:bg-dark-400">
<span className="text-sm font-medium leading-none text-white">
{member.user?.name
?.split(" ")
.map((namePart) =>
namePart.charAt(0).toUpperCase(),
)
.join("")}
</span>
</span>
</div>
<div className="ml-2 min-w-0 flex-1">
<div>
<div className="flex items-center">
<p className="mr-2 text-sm font-medium text-neutral-900 dark:text-dark-1000">
{member.user?.name}
</p>
</div>
<p className="truncate text-sm text-dark-900">
{member.user?.email}
</p>
</div>
</div>
</div>
</td>
<td>
<div className="px-3">
<span className="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">
{member.role.charAt(0).toUpperCase() +
member.role.slice(1)}
</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
<Modal>
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
</Modal>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { useModal } from "~/providers/modal";
import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
export default function SettingsPage() {
const { modalContentType } = useModal();
return (
<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="mt-8 flow-root">
<div className="-mx-4 -my-2 overflow-x-auto 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="overflow-hidden shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg"></div>
</div>
</div>
</div>
<Modal>
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
</Modal>
</div>
);
}