Refactored SidePanel

This commit is contained in:
2023-05-21 20:50:31 -05:00
parent 628dc3ceef
commit 5a67c1bdd4
4 changed files with 342 additions and 275 deletions

View File

@@ -0,0 +1,168 @@
import { zodResolver } from "@hookform/resolvers/zod";
import * as Dialog from "@radix-ui/react-dialog";
import { api } from "@utils/api";
import { getRootContainer } from "@utils/constants/htmlTools";
import { proposalSchema } from "@utils/constants/schema/project";
import type { Children } from "@utils/types/props";
import { cx } from "class-variance-authority";
import { useForm, type SubmitHandler } from "react-hook-form";
import toast from "react-hot-toast";
import Button from "./Button";
import { Divider } from "./Divider";
import { WithScroll } from "./SidePanel";
import { TextInput, MultilineTextInput } from "./TextInput";
import type { z } from "zod";
import { useState } from "react";
type ProposalForm = z.infer<typeof proposalSchema>;
interface NewProposalPopupProps extends Children {
onOpen?: () => void;
onClose?: () => void;
onSubmit?: () => void;
}
export const NewProposalPopup: React.FC<NewProposalPopupProps> = ({
children,
onOpen,
onClose,
onSubmit,
}) => {
// === Constants ============================================================
const container = getRootContainer();
// === Hooks ================================================================
const [isOpen, setIsOpen] = useState(false);
const ctx = api.useContext();
const { handleSubmit, register, formState, reset } = useForm<ProposalForm>({
resolver: zodResolver(proposalSchema),
});
const { mutate, isLoading: isPosting } =
api.projects.createProposal.useMutation({
onSuccess: () => {
toast.success("Successfully created a new proposal");
// Close form
setIsOpen(false);
// Invalidate project caches
void ctx.projects.getAll.invalidate();
void ctx.projects.getAllByCurrentUser.invalidate();
// Handle any extra functions
onSubmit?.();
},
onError: (e) => {
toast.error(e.message);
},
});
// === Functions ============================================================
const handleOnSubmit: SubmitHandler<ProposalForm> = (data): void => {
if (isPosting) return;
mutate(data);
};
const handleOnClose = () => {
// Close form
setIsOpen(false);
// Clear form
reset();
// Handle any extra functions
onClose?.();
};
const handleOnOpen = () => {
// Open form
setIsOpen(true);
// Handle any extra functions
onOpen?.();
};
// === Components ===========================================================
const FullDivider = () => (
<div className="mt-4">
<Divider />
</div>
);
return (
<Dialog.Root
open={isOpen}
onOpenChange={(isOpen) => {
isOpen ? handleOnOpen() : handleOnClose();
}}
>
<Dialog.Trigger asChild>{children}</Dialog.Trigger>
<Dialog.Portal container={container}>
<Dialog.Overlay className="fixed inset-0 bg-black/40 data-[state=open]:animate-overlayShow" />
<Dialog.Content
className={cx(
"fixed left-[50%] top-[50%] w-[90vw] max-w-[750px] translate-x-[-50%] translate-y-[-50%]",
"rounded-3xl bg-bg",
"focus:outline-none data-[state=open]:animate-contentShow",
"overflow-clip"
)}
>
<div className="px-10 pt-10">
<Dialog.Title asChild>
<h1 className="font-bold text-primary text-r-xl">
Create new proposal
</h1>
</Dialog.Title>
<Dialog.Description asChild>
<p className="text-r-lg">
Let others know a little about your project idea.
</p>
</Dialog.Description>
</div>
<FullDivider />
<WithScroll height="70vh">
<form
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onSubmit={handleSubmit(handleOnSubmit)}
className="mt-4 px-10 pb-10"
>
<div className="mb-8 flex flex-col gap-y-4">
<TextInput
label="Title"
placeholder={proposalSchema.shape.title.description}
error={formState.errors.title?.message}
{...register("title")}
/>
<MultilineTextInput
hasAdaptiveHeight
label="Description"
placeholder={proposalSchema.shape.description.description}
error={formState.errors.description?.message}
{...register("description")}
/>
{/* <ImageInput label="Image" /> */}
</div>
<div className="flex flex-row justify-end">
<Button
variant={{ size: "small" }}
type="submit"
disabled={isPosting}
>
Create
</Button>
</div>
</form>
</WithScroll>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
};

View File

@@ -0,0 +1,147 @@
import type { Children } from "@utils/types/props";
import Link from "next/link";
import {
FiMoreVertical,
FiLogOut,
FiSettings,
FiExternalLink,
FiPlus,
} from "react-icons/fi";
import { Button } from "@components/Button";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import * as Dialog from "@radix-ui/react-dialog";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { cx } from "class-variance-authority";
import { TextInput, MultilineTextInput } from "@components/TextInput";
import { Divider } from "@components/Divider";
import { ImageInput } from "@components/ImageInput";
import { api } from "@utils/api";
import { getRootContainer } from "@utils/constants/htmlTools";
import toast from "react-hot-toast";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { proposalSchema } from "@utils/constants/schema/project";
import type { z } from "zod";
import type { SubmitHandler } from "react-hook-form";
import { signOut } from "next-auth/react";
import { navItems } from "@utils/constants/sidepanel";
import { NewProposalPopup } from "./NewProposalPopup";
type ProposalForm = z.infer<typeof proposalSchema>;
export const SidePanel: React.FC = () => {
return (
<div id="side-panel" className="hidden md:block">
<div className="flex flex-row-reverse">
<div className="w-1.5 border-x-2 border-x-fg/10" />
<header className="flex h-screen flex-col justify-between gap-y-8 px-12 py-8">
<div>
<h1>
<Link href="/" className="font-bold text-primary text-r-3xl">
<span className="text-fg">||</span> Parallel
</Link>
</h1>
<nav className="my-8 flex flex-col gap-y-5">
{navItems.map(({ label, route, Icon }) => (
<Link
key={label}
href={route}
className="flex flex-row items-center gap-x-2 font-medium text-r-2xl hover:font-bold hover:text-fg-400"
>
<Icon size={28} /> {label}
</Link>
))}
</nav>
<NewProposalPopup>
<Button
variant={{ size: "small" }}
prefixIcon={<FiPlus size={25} />}
>
New Proposal
</Button>
</NewProposalPopup>
</div>
<MoreMenu />
</header>
</div>
</div>
);
};
const MoreMenu: React.FC = () => {
const container = getRootContainer();
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button className="flex flex-row items-center gap-x-2 font-medium text-r-2xl hover:font-bold hover:text-fg-400">
<FiMoreVertical size={28} /> More
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal container={container}>
<DropdownMenu.Content
side="top"
className="min-w-[250px] rounded-md bg-bg-700 p-3 data-[side=top]:animate-slideUpAndFade"
sideOffset={10}
>
{/* Settings */}
<DropdownMenu.Item>
<Link
href="/settings"
className="flex w-full select-none items-center justify-between px-[5px] pl-[25px] outline-none"
>
<p className="text-r-lg">Settings</p>
<FiSettings />
</Link>
</DropdownMenu.Item>
{/* Landing page */}
<DropdownMenu.Item>
<Link
href="/"
className="flex w-full select-none items-center justify-between px-[5px] pl-[25px] outline-none"
>
<p className="text-r-lg">Parallel Homepage</p>
<FiExternalLink />
</Link>
</DropdownMenu.Item>
{/* Sign out */}
<DropdownMenu.Item>
<button
type="button"
className="flex w-full select-none items-center justify-between px-[5px] pl-[25px] outline-none"
onClick={() => void signOut()}
>
<p className="text-r-lg">Sign Out</p>
<FiLogOut />
</button>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
};
export const WithScroll: React.FC<Children & { height: string }> = ({
height,
children,
}) => {
return (
<ScrollArea.Root
type="auto"
className={`h-${height} w-full overflow-hidden`}
>
<ScrollArea.Viewport className="h-full w-full">
{children}
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="flex touch-none select-none bg-bg-600 p-0.5 transition-colors duration-150 ease-out hover:bg-bg-700 data-[orientation=horizontal]:h-2.5 data-[orientation=vertical]:w-2.5 data-[orientation=horizontal]:flex-col"
orientation="vertical"
>
<ScrollArea.Thumb className="relative flex-1 rounded-full bg-fg-700 before:absolute before:left-1/2 before:top-1/2 before:h-full before:min-h-[44px] before:w-full before:min-w-[44px] before:-translate-x-1/2 before:-translate-y-1/2 before:content-[''] hover:bg-fg" />
</ScrollArea.Scrollbar>
</ScrollArea.Root>
);
};

View File

@@ -1,283 +1,11 @@
import { SidePanel } from "@components/SidePanel";
import type { Children } from "@utils/types/props";
import Link from "next/link";
import {
FiMenu,
FiHome,
FiSearch,
FiClipboard,
FiEdit,
FiArchive,
FiUser,
FiLogOut,
FiSettings,
FiExternalLink,
} from "react-icons/fi";
import type { IconType } from "react-icons/lib";
import { Button } from "@components/Button";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import * as Dialog from "@radix-ui/react-dialog";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { cx } from "class-variance-authority";
import { TextInput, MultilineTextInput } from "@components/TextInput";
import { Divider } from "@components/Divider";
import { ImageInput } from "@components/ImageInput";
import { api } from "@utils/api";
import { getRootContainer } from "@utils/constants/htmlTools";
import toast from "react-hot-toast";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { proposalSchema } from "@utils/constants/schema/project";
import type { z } from "zod";
import type { SubmitHandler } from "react-hook-form";
import { signOut } from "next-auth/react";
export const MainLayout: React.FC<Children> = ({ children }) => {
return (
<>
<div className="min-w-screen flex h-screen flex-row">
<SidePanel />
<main className="mx-96">{children}</main>
</>
);
};
// === Side Panel =============================================================
interface NavItem {
label: string;
route: string;
Icon: IconType;
}
const navItems: NavItem[] = [
{ label: "Home", route: "/projects", Icon: FiHome },
{ label: "Search", route: "/discover", Icon: FiSearch },
{ label: "Proposals", route: "/discover/proposals", Icon: FiClipboard },
{ label: "Revisions", route: "/discover/revisions", Icon: FiEdit },
{ label: "Archive", route: "/discover/archive", Icon: FiArchive },
{ label: "Profile", route: "/profile", Icon: FiUser },
];
export const SidePanel: React.FC = () => {
return (
<div
id="side-panel"
className="fixed top-0 left-0 bottom-0 hidden bg-bg-600 md:block"
>
<header className="flex h-screen flex-col justify-between gap-y-8 px-12 py-8">
<div>
<h1>
<Link href="/" className="text-r-3xl font-bold text-primary">
<span className="text-fg">||</span> Parallel
</Link>
</h1>
<nav className="my-8 flex flex-col gap-y-5">
{navItems.map(({ label, route, Icon }) => (
<Link
key={label}
href={route}
className="text-r-2xl flex flex-row items-center gap-x-2 font-medium hover:font-bold hover:text-fg-400"
>
<Icon size={28} /> {label}
</Link>
))}
</nav>
<NewProposalButton />
</div>
<MoreMenu />
</header>
<main className="w-full overflow-y-scroll">{children}</main>
</div>
);
};
type ProposalForm = z.infer<typeof proposalSchema>;
const NewProposalButton: React.FC = () => {
// === Constants ============================================================
const container = getRootContainer();
// === Hooks ================================================================
const { handleSubmit, register, formState, reset } = useForm<ProposalForm>({
resolver: zodResolver(proposalSchema),
});
const ctx = api.useContext();
const { mutate, isLoading: isPosting } =
api.projects.createProposal.useMutation({
onSuccess: () => {
reset();
void ctx.projects.getAll.invalidate();
},
onError: (e) => {
toast.error(e.message);
},
});
// === Functions ============================================================
const onSubmit: SubmitHandler<ProposalForm> = (data): void => {
if (isPosting) return;
mutate(data);
};
// === Components ===========================================================
const FullDivider = () => (
<div className="mt-4">
<Divider />
</div>
);
return (
<Dialog.Root
onOpenChange={(isOpen) => {
if (!isOpen) reset();
}}
>
<Dialog.Trigger asChild>
<Button variant={{ size: "small" }}>New Proposal</Button>
</Dialog.Trigger>
<Dialog.Portal container={container}>
<Dialog.Overlay className="fixed inset-0 bg-black/40 data-[state=open]:animate-overlayShow" />
<Dialog.Content
className={cx(
"fixed left-[50%] top-[50%] w-[90vw] max-w-[750px] translate-x-[-50%] translate-y-[-50%]",
"rounded-3xl bg-bg",
"focus:outline-none data-[state=open]:animate-contentShow",
"overflow-clip"
)}
>
<div className="px-10 pt-10">
<Dialog.Title asChild>
<h1 className="text-r-xl font-bold text-primary">
Create new proposal
</h1>
</Dialog.Title>
<Dialog.Description asChild>
<p className="text-r-lg">
Let others know a little about your project idea.
</p>
</Dialog.Description>
</div>
<FullDivider />
<WithScroll height="70vh">
<form
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onSubmit={handleSubmit(onSubmit)}
className="mt-4 px-10 pb-10"
>
<div className="mb-8 flex flex-col gap-y-4">
<TextInput
label="Title"
placeholder={proposalSchema.shape.title.description}
error={formState.errors.title?.message}
{...register("title")}
/>
<MultilineTextInput
hasAdaptiveHeight
label="Description"
placeholder={proposalSchema.shape.description.description}
error={formState.errors.description?.message}
{...register("description")}
/>
{/* <ImageInput label="Image" /> */}
</div>
<div className="flex flex-row justify-end">
<Button
variant={{ size: "small" }}
type="submit"
disabled={isPosting}
>
Create
</Button>
</div>
</form>
</WithScroll>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
};
const MoreMenu: React.FC = () => {
const container = getRootContainer();
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button className="text-r-2xl flex flex-row items-center gap-x-2 font-medium hover:font-bold hover:text-fg-400">
<FiMenu size={28} /> More
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal container={container}>
<DropdownMenu.Content
side="top"
className="min-w-[250px] rounded-md bg-bg-700 p-3 data-[side=top]:animate-slideUpAndFade"
sideOffset={10}
>
{/* Settings */}
<DropdownMenu.Item>
<Link
href="/settings"
className="flex w-full select-none items-center justify-between px-[5px] pl-[25px] outline-none"
>
<p className="text-r-lg">Settings</p>
<FiSettings />
</Link>
</DropdownMenu.Item>
{/* Landing page */}
<DropdownMenu.Item>
<Link
href="/"
className="flex w-full select-none items-center justify-between px-[5px] pl-[25px] outline-none"
>
<p className="text-r-lg">Parallel Homepage</p>
<FiExternalLink />
</Link>
</DropdownMenu.Item>
{/* Sign out */}
<DropdownMenu.Item>
<button
type="button"
className="flex w-full select-none items-center justify-between px-[5px] pl-[25px] outline-none"
onClick={() => void signOut()}
>
<p className="text-r-lg">Sign Out</p>
<FiLogOut />
</button>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
};
export const WithScroll: React.FC<Children & { height: string }> = ({
height,
children,
}) => {
return (
<ScrollArea.Root
type="auto"
className={`h-${height} w-full overflow-hidden`}
>
<ScrollArea.Viewport className="h-full w-full">
{children}
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="flex touch-none select-none bg-bg-600 p-0.5 transition-colors duration-150 ease-out hover:bg-bg-700 data-[orientation=horizontal]:h-2.5 data-[orientation=vertical]:w-2.5 data-[orientation=horizontal]:flex-col"
orientation="vertical"
>
<ScrollArea.Thumb className="relative flex-1 rounded-full bg-fg-700 before:absolute before:top-1/2 before:left-1/2 before:h-full before:min-h-[44px] before:w-full before:min-w-[44px] before:-translate-x-1/2 before:-translate-y-1/2 before:content-[''] hover:bg-fg" />
</ScrollArea.Scrollbar>
</ScrollArea.Root>
);
};

View File

@@ -0,0 +1,24 @@
import type { IconType } from "react-icons";
import {
FiHome,
FiSearch,
FiClipboard,
FiEdit,
FiArchive,
FiUser,
} from "react-icons/fi";
interface NavItem {
label: string;
route: string;
Icon: IconType;
}
export const navItems: NavItem[] = [
{ label: "Home", route: "/projects", Icon: FiHome },
{ label: "Search", route: "/discover", Icon: FiSearch },
{ label: "Proposals", route: "/discover/proposals", Icon: FiClipboard },
{ label: "Revisions", route: "/discover/revisions", Icon: FiEdit },
{ label: "Archive", route: "/discover/archive", Icon: FiArchive },
{ label: "Profile", route: "/profile", Icon: FiUser },
];