feat: card filters
This commit is contained in:
@@ -3,10 +3,12 @@ import { twMerge } from "tailwind-merge";
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "primary" | "secondary" | "danger";
|
||||
isLoading?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Button = ({
|
||||
children,
|
||||
icon,
|
||||
isLoading,
|
||||
variant = "primary",
|
||||
...props
|
||||
@@ -14,7 +16,7 @@ const Button = ({
|
||||
return (
|
||||
<button
|
||||
className={twMerge(
|
||||
"inline-flex items-center justify-center rounded-md px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none",
|
||||
"inline-flex items-center justify-center rounded-md px-3 py-2 text-sm font-semibold text-light-1000 shadow-sm focus-visible:outline-none",
|
||||
variant === "primary" &&
|
||||
"bg-light-1000 dark:bg-dark-1000 dark:text-dark-50",
|
||||
variant === "secondary" &&
|
||||
@@ -51,7 +53,15 @@ const Button = ({
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
<span className={isLoading ? "invisible" : "visible"}>{children}</span>
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center",
|
||||
isLoading ? "invisible" : "visible",
|
||||
)}
|
||||
>
|
||||
{icon && <span className="mr-2">{icon}</span>}
|
||||
{children}
|
||||
</div>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,27 +1,50 @@
|
||||
import { Fragment } from "react";
|
||||
import { Fragment, useState } from "react";
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface Item {
|
||||
key: string;
|
||||
value: string;
|
||||
selected: boolean;
|
||||
leftIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface Group {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
items: Item[];
|
||||
}
|
||||
|
||||
interface CheckboxDropdownProps {
|
||||
children: React.ReactNode;
|
||||
items: {
|
||||
key: string;
|
||||
value: string | null;
|
||||
selected: boolean;
|
||||
}[];
|
||||
handleSelect: (item: { key: string }) => void;
|
||||
items?: Item[];
|
||||
groups?: Group[];
|
||||
menuSpacing?: "sm" | "md" | "lg";
|
||||
handleSelect: (groupKey: string | null, item: { key: string }) => void;
|
||||
}
|
||||
|
||||
export default function CheckboxDropdown({
|
||||
children,
|
||||
items,
|
||||
groups,
|
||||
menuSpacing = "sm",
|
||||
handleSelect,
|
||||
}: CheckboxDropdownProps) {
|
||||
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
||||
|
||||
const menuSpacingClass = {
|
||||
sm: "top-[26px]",
|
||||
md: "top-[32px]",
|
||||
lg: "top-[38px]",
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu
|
||||
as="div"
|
||||
className="relative flex w-full flex-wrap items-center text-left"
|
||||
>
|
||||
<Menu
|
||||
as="div"
|
||||
className="relative flex w-full flex-wrap items-center text-left"
|
||||
>
|
||||
<>
|
||||
<Menu.Button className="focus-visible:outline-none">
|
||||
{children}
|
||||
</Menu.Button>
|
||||
@@ -34,41 +57,110 @@ export default function CheckboxDropdown({
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
afterLeave={() => setSelectedGroup(null)}
|
||||
>
|
||||
<Menu.Items className="absolute left-0 top-[26px] z-10 mt-2 w-56 origin-top-left rounded-md border-[1px] border-light-200 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-500 dark:bg-dark-200">
|
||||
<Menu.Items
|
||||
className={twMerge(
|
||||
"absolute left-0 z-50 mt-2 w-56 origin-top-left rounded-md border-[1px] border-light-200 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-500 dark:bg-dark-200",
|
||||
menuSpacingClass[menuSpacing],
|
||||
)}
|
||||
>
|
||||
<div className="p-1">
|
||||
{items?.map((item) => (
|
||||
<Menu.Item key={item.key}>
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelect({ key: item.key });
|
||||
}}
|
||||
>
|
||||
<input
|
||||
id={item.key}
|
||||
name={item.key}
|
||||
type="checkbox"
|
||||
className="h-[14px] w-[14px] rounded bg-transparent"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onChange={() => handleSelect({ key: item.key })}
|
||||
checked={item.selected}
|
||||
/>
|
||||
<label
|
||||
htmlFor={item.key}
|
||||
className="ml-3 text-sm text-dark-900"
|
||||
>
|
||||
{item.value}
|
||||
</label>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
))}
|
||||
{!selectedGroup ? (
|
||||
<>
|
||||
{items?.map((item) => (
|
||||
<Menu.Item key={item.key}>
|
||||
<div
|
||||
className="flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelect(null, { key: item.key });
|
||||
}}
|
||||
>
|
||||
<input
|
||||
id={item.key}
|
||||
name={item.key}
|
||||
type="checkbox"
|
||||
className="h-[14px] w-[14px] rounded bg-transparent"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onChange={() => handleSelect(null, { key: item.key })}
|
||||
checked={item.selected}
|
||||
/>
|
||||
{item.leftIcon && (
|
||||
<span className="ml-3 flex items-center">
|
||||
{item.leftIcon}
|
||||
</span>
|
||||
)}
|
||||
<label
|
||||
htmlFor={item.key}
|
||||
className="ml-3 text-[12px] text-dark-900"
|
||||
>
|
||||
{item.value}
|
||||
</label>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
))}
|
||||
{groups?.map((group) => (
|
||||
<Menu.Item key={group.key}>
|
||||
<div
|
||||
className="flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setSelectedGroup(group.key);
|
||||
}}
|
||||
>
|
||||
<span className="mr-2 text-dark-900">{group.icon}</span>
|
||||
<span className="pointer-events-none text-[12px] text-dark-900">
|
||||
{group.label}
|
||||
</span>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{groups
|
||||
?.find((g) => g.key === selectedGroup)
|
||||
?.items.map((item) => (
|
||||
<Menu.Item key={item.key}>
|
||||
<div
|
||||
className="flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelect(selectedGroup, { key: item.key });
|
||||
}}
|
||||
>
|
||||
<input
|
||||
id={item.key}
|
||||
name={item.key}
|
||||
type="checkbox"
|
||||
className="h-[14px] w-[14px] rounded bg-transparent"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onChange={() =>
|
||||
handleSelect(selectedGroup, { key: item.key })
|
||||
}
|
||||
checked={item.selected}
|
||||
/>
|
||||
{item.leftIcon && (
|
||||
<span className="ml-3 flex items-center">
|
||||
{item.leftIcon}
|
||||
</span>
|
||||
)}
|
||||
<label
|
||||
htmlFor={item.key}
|
||||
className="ml-3 text-[12px] text-dark-900"
|
||||
>
|
||||
{item.value}
|
||||
</label>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</>
|
||||
</>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,10 @@ export const BoardProvider: React.FC<{ children: ReactNode }> = ({
|
||||
try {
|
||||
const data = await utils.board.byId.fetch({
|
||||
boardPublicId: boardData.publicId,
|
||||
filters: {
|
||||
members: [],
|
||||
labels: [],
|
||||
},
|
||||
});
|
||||
if (data) setBoardData(data);
|
||||
} catch (e) {
|
||||
|
||||
@@ -28,9 +28,23 @@ export const boardRouter = createTRPCRouter({
|
||||
return result;
|
||||
}),
|
||||
byId: protectedProcedure
|
||||
.input(z.object({ boardPublicId: z.string().min(12) }))
|
||||
.input(
|
||||
z.object({
|
||||
boardPublicId: z.string().min(12),
|
||||
filters: z.object({
|
||||
members: z.array(z.string().min(12)),
|
||||
labels: z.array(z.string().min(12)),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const result = await boardRepo.getByPublicId(ctx.db, input.boardPublicId);
|
||||
const result = await boardRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.boardPublicId,
|
||||
input.filters,
|
||||
);
|
||||
|
||||
console.log(result);
|
||||
|
||||
return result;
|
||||
}),
|
||||
|
||||
@@ -18,8 +18,12 @@ export const getAllByWorkspaceId = async (
|
||||
export const getByPublicId = async (
|
||||
db: SupabaseClient<Database>,
|
||||
boardPublicId: string,
|
||||
filters: {
|
||||
members: string[];
|
||||
labels: string[];
|
||||
},
|
||||
) => {
|
||||
const { data } = await db
|
||||
let query = db
|
||||
.from("board")
|
||||
.select(
|
||||
`
|
||||
@@ -50,12 +54,12 @@ export const getByPublicId = async (
|
||||
description,
|
||||
listId,
|
||||
index,
|
||||
labels:label (
|
||||
labels:label${filters.labels.length > 0 ? "!inner" : ""} (
|
||||
publicId,
|
||||
name,
|
||||
colourCode
|
||||
),
|
||||
members:workspace_members (
|
||||
members:workspace_members${filters.members.length > 0 ? "!inner" : ""} (
|
||||
publicId,
|
||||
user (
|
||||
name
|
||||
@@ -68,12 +72,24 @@ export const getByPublicId = async (
|
||||
.eq("publicId", boardPublicId)
|
||||
.is("deletedAt", null)
|
||||
.is("lists.deletedAt", null)
|
||||
.is("lists.cards.deletedAt", null)
|
||||
.is("lists.cards.deletedAt", null);
|
||||
|
||||
if (filters.labels.length > 0) {
|
||||
query = query.in("lists.cards.labels.publicId", filters.labels);
|
||||
}
|
||||
|
||||
if (filters.members.length > 0) {
|
||||
query = query.in("lists.cards.members.publicId", filters.members);
|
||||
}
|
||||
|
||||
const { data, error } = await query
|
||||
.order("index", { foreignTable: "list", ascending: true })
|
||||
.order("index", { foreignTable: "list.card", ascending: true })
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
console.log(error);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ export const update = async (
|
||||
workspacePublicId: string,
|
||||
name: string,
|
||||
) => {
|
||||
const { data, error } = await db
|
||||
const { data } = await db
|
||||
.from("workspace")
|
||||
.update({ name })
|
||||
.eq("publicId", workspacePublicId)
|
||||
|
||||
@@ -18,7 +18,13 @@ export function DeleteListConfirmation({
|
||||
const refetchBoard = async () => {
|
||||
if (boardData?.publicId) {
|
||||
try {
|
||||
await utils.board.byId.refetch({ boardPublicId: boardData.publicId });
|
||||
await utils.board.byId.refetch({
|
||||
boardPublicId: boardData.publicId,
|
||||
filters: {
|
||||
members: [],
|
||||
labels: [],
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
105
src/views/board/components/Filters.tsx
Normal file
105
src/views/board/components/Filters.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { IoFilterOutline } from "react-icons/io5";
|
||||
import { HiOutlineUserCircle, HiOutlineTag } from "react-icons/hi2";
|
||||
import { useRouter } from "next/router";
|
||||
import Button from "~/components/Button";
|
||||
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
||||
|
||||
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 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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<CheckboxDropdown
|
||||
groups={groups}
|
||||
handleSelect={handleSelect}
|
||||
menuSpacing="md"
|
||||
>
|
||||
<Button variant="secondary" icon={<IoFilterOutline />}>
|
||||
Filter
|
||||
</Button>
|
||||
</CheckboxDropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Filters;
|
||||
@@ -64,7 +64,7 @@ export default function List({
|
||||
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"
|
||||
className="dark-text-dark-1000 z-0 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
|
||||
|
||||
@@ -178,9 +178,7 @@ export function NewCardForm({ listPublicId }: NewCardFormProps) {
|
||||
<div className="w-fit">
|
||||
<CheckboxDropdown
|
||||
items={formattedLists}
|
||||
handleSelect={(list: { key: string }) =>
|
||||
handleSelectList(list.key)
|
||||
}
|
||||
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}
|
||||
@@ -190,9 +188,7 @@ export function NewCardForm({ listPublicId }: NewCardFormProps) {
|
||||
<div className="w-fit">
|
||||
<CheckboxDropdown
|
||||
items={formattedMembers}
|
||||
handleSelect={(list: { key: string }) =>
|
||||
handleSelectMembers(list.key)
|
||||
}
|
||||
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 ? (
|
||||
@@ -228,9 +224,7 @@ export function NewCardForm({ listPublicId }: NewCardFormProps) {
|
||||
<div className="w-fit">
|
||||
<CheckboxDropdown
|
||||
items={formattedLabels}
|
||||
handleSelect={(list: { key: string }) =>
|
||||
handleSelectLabels(list.key)
|
||||
}
|
||||
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 ? (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useParams } from "next/navigation";
|
||||
import { HiOutlinePlusSmall } from "react-icons/hi2";
|
||||
import {
|
||||
@@ -24,13 +25,22 @@ import List from "./components/List";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { NewCardForm } from "./components/NewCardForm";
|
||||
import { NewListForm } from "./components/NewListForm";
|
||||
import Filters from "./components/Filters";
|
||||
|
||||
import { type UpdateBoardInput } from "~/types/router.types";
|
||||
|
||||
type PublicListId = string;
|
||||
|
||||
const formatToArray = (value: string | string[] | undefined): string[] => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((item) => item !== undefined);
|
||||
}
|
||||
return value ? [value] : [];
|
||||
};
|
||||
|
||||
export default function BoardPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { boardData, setBoardData, updateCard, updateList } = useBoard();
|
||||
const { openModal, modalContentType } = useModal();
|
||||
const [selectedPublicListId, setSelectedPublicListId] =
|
||||
@@ -55,7 +65,13 @@ export default function BoardPage() {
|
||||
};
|
||||
|
||||
const { data, isSuccess, isLoading } = api.board.byId.useQuery(
|
||||
{ boardPublicId: boardId ?? "" },
|
||||
{
|
||||
boardPublicId: boardId ?? "",
|
||||
filters: {
|
||||
members: formatToArray(router.query.members),
|
||||
labels: formatToArray(router.query.labels),
|
||||
},
|
||||
},
|
||||
{
|
||||
enabled: !!boardId,
|
||||
},
|
||||
@@ -150,7 +166,8 @@ export default function BoardPage() {
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="flex items-center">
|
||||
<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"
|
||||
@@ -166,7 +183,7 @@ export default function BoardPage() {
|
||||
</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">
|
||||
<div className="scrollbar-w-none z-0 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">
|
||||
{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-200" />
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import Modal from "~/components/modal";
|
||||
import Button from "~/components/Button";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user