Created the new proposal form dialog

This commit is contained in:
2023-03-25 04:08:09 -05:00
parent a1c2c9c030
commit 0a71f91311
7 changed files with 173 additions and 123 deletions

View File

@@ -1,84 +1,49 @@
import { typo } from "@styles/typography";
import { cva } from "class-variance-authority"; import { cva } from "class-variance-authority";
import { useState } from "react"; import React from "react";
import type { ZodString } from "zod";
const input = cva([ const input = cva([
typo({ tag: "p" }), "text-r-lg w-full rounded border-[6px] py-2 px-3 md:py-3 md:px-4 leading-tight border-fg placeholder-fg/50 bg-fg/20",
"w-full rounded border-[6px] py-2 px-3 md:py-3 md:px-4 leading-tight border-fg placeholder-fg/50 bg-fg/20",
"focus:outline-none", "focus:outline-none",
]); ]);
interface Props extends React.InputHTMLAttributes<HTMLInputElement> { interface Props extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string; label?: string;
placeholder?: string; placeholder?: string;
error?: string;
prefixIcon?: React.ReactNode; prefixIcon?: React.ReactNode;
suffixIcon?: React.ReactNode; suffixIcon?: React.ReactNode;
validator?: ZodString;
onChange?: React.ChangeEventHandler<HTMLInputElement>;
value?: string;
} }
export const TextInput: React.FC<Props> = ({ export const TextInput = React.forwardRef<HTMLInputElement, Props>(
label, ({ label, placeholder, error, prefixIcon, suffixIcon, ...rest }, ref) => {
placeholder, return (
prefixIcon, <fieldset className="w-full">
suffixIcon, {label && (
validator, <label className="text-r-lg mb-1 block text-fg">{label}</label>
onChange, )}
value: valueProp, <div
...rest className={input({
}) => { className: "flex flex-row items-center gap-x-2 px-2",
const [value, setValue] = useState<string | undefined>(valueProp); })}
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const newValue = event.target.value;
setValue(newValue);
if (onChange) {
onChange(event);
}
};
// let validationError: string | null = null;
// if (validator) {
// const result = validator.safeParse(value);
// if (result.success) {
// result.data;
// } else {
// validationError = result.error.message;
// }
// }
return (
<div className="w-full">
{label && (
<label
className={typo({ size: "base", className: "mb-1 block text-fg" })}
> >
{label} {prefixIcon && prefixIcon}
</label> <input
)} type="text"
<div placeholder={placeholder}
className={input({ className="w-full appearance-none border-none bg-transparent leading-tight text-fg placeholder-fg/50 focus:outline-none"
className: "flex flex-row items-center gap-x-2 px-2", ref={ref}
})} {...rest}
> />
{prefixIcon && prefixIcon} {suffixIcon && suffixIcon}
<input </div>
type="text" {error && (
placeholder={placeholder} <p className="text-r-xl overflow-clip overflow-ellipsis whitespace-nowrap text-error">
value={value ?? ""} {error}
onChange={handleChange} </p>
className="w-full appearance-none border-none bg-transparent leading-tight text-fg placeholder-fg/50 focus:outline-none" )}
{...rest} </fieldset>
/> );
{suffixIcon && suffixIcon} }
</div> );
{/* {validationError && (
<span className="validation-error">{validationError}</span> TextInput.displayName = "TextInput";
)} */}
</div>
);
};

View File

@@ -1,5 +1,4 @@
import type { Children } from "@utils/types/props"; import type { Children } from "@utils/types/props";
import { typo } from "@styles/typography";
import Link from "next/link"; import Link from "next/link";
import { import {
FiMenu, FiMenu,
@@ -19,6 +18,14 @@ import { cx } from "class-variance-authority";
import { TextInput } from "@components/TextInput"; import { TextInput } from "@components/TextInput";
import { Divider } from "@components/Divider"; import { Divider } from "@components/Divider";
import { ImageInput } from "@components/ImageInput"; import { ImageInput } from "@components/ImageInput";
import { api } from "@utils/api";
import { getRootContainer } from "@constants/elements";
import toast from "react-hot-toast";
import { SubmitErrorHandler, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { proposalSchema } from "@constants/schema";
import type { z } from "zod";
import type { SubmitHandler } from "react-hook-form";
export const MainLayout: React.FC<Children> = ({ children }) => { export const MainLayout: React.FC<Children> = ({ children }) => {
return ( return (
@@ -55,10 +62,7 @@ export const SidePanel: React.FC = () => {
<header className="flex h-screen flex-col justify-between gap-y-8 px-12 py-8"> <header className="flex h-screen flex-col justify-between gap-y-8 px-12 py-8">
<div> <div>
<h1> <h1>
<Link <Link href="/" className="text-r-3xl text-primary">
href="/"
className={typo({ tag: "h3", className: "text-primary" })}
>
<span className="text-fg">||</span> Parallel <span className="text-fg">||</span> Parallel
</Link> </Link>
</h1> </h1>
@@ -67,11 +71,7 @@ export const SidePanel: React.FC = () => {
<Link <Link
key={label} key={label}
href={route} href={route}
className={typo({ className="text-r-2xl flex flex-row items-center gap-x-2 font-medium hover:font-bold hover:text-fg-400"
size: "2xl",
className:
"flex flex-row items-center gap-x-2 font-medium hover:font-bold hover:text-fg-400",
})}
> >
<Icon size={28} /> {label} <Icon size={28} /> {label}
</Link> </Link>
@@ -85,9 +85,39 @@ export const SidePanel: React.FC = () => {
); );
}; };
type ProposalForm = z.infer<typeof proposalSchema>;
const NewProposalButton: React.FC = () => { const NewProposalButton: React.FC = () => {
const container = // === Constants ============================================================
typeof window !== "undefined" ? document.getElementById("root") : null;
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 => {
console.log("Submitting form data", data);
mutate(data);
};
// === Components ===========================================================
const FullDivider = () => ( const FullDivider = () => (
<div className="my-4 -mx-10"> <div className="my-4 -mx-10">
@@ -96,7 +126,11 @@ const NewProposalButton: React.FC = () => {
); );
return ( return (
<Dialog.Root> <Dialog.Root
onOpenChange={(isOpen) => {
if (!isOpen) reset();
}}
>
<Dialog.Trigger asChild> <Dialog.Trigger asChild>
<Button variant={{ size: "small" }}>New Proposal</Button> <Button variant={{ size: "small" }}>New Proposal</Button>
</Dialog.Trigger> </Dialog.Trigger>
@@ -111,28 +145,38 @@ const NewProposalButton: React.FC = () => {
"focus:outline-none data-[state=open]:animate-contentShow" "focus:outline-none data-[state=open]:animate-contentShow"
)} )}
> >
<Dialog.Title> <Dialog.Title asChild>
<h1 className={typo({ tag: "h5", className: "text-primary" })}> <h1 className="text-r-xl text-primary">Create new proposal</h1>
Create new proposal
</h1>
</Dialog.Title> </Dialog.Title>
<Dialog.Description> <Dialog.Description asChild>
Let others know a little about your project idea. <p className="text-r-lg">
Let others know a little about your project idea.
</p>
</Dialog.Description> </Dialog.Description>
<FullDivider /> <FullDivider />
<div className="mb-8 flex flex-col gap-y-4"> {/* eslint-disable-next-line @typescript-eslint/no-misused-promises */}
<TextInput label="Title" /> <form onSubmit={handleSubmit(onSubmit)}>
<TextInput <div className="mb-8 flex flex-col gap-y-4">
label="Description" <TextInput
placeholder="Describe your project" label="Title"
/> placeholder={proposalSchema.shape.title.description}
<ImageInput label="Image" /> error={formState.errors.title?.message}
</div> {...register("title")}
<Dialog.Close asChild> />
<div className="flex flex-row justify-end"> <TextInput
<Button variant={{ size: "small" }}>Create</Button> label="Description"
placeholder={proposalSchema.shape.description.description}
error={formState.errors.description?.message}
{...register("description")}
/>
{/* <ImageInput label="Image" /> */}
</div> </div>
</Dialog.Close> <div className="flex flex-row justify-end">
<Button variant={{ size: "small" }} type="submit">
Create
</Button>
</div>
</form>
</Dialog.Content> </Dialog.Content>
</Dialog.Portal> </Dialog.Portal>
</Dialog.Root> </Dialog.Root>
@@ -140,19 +184,12 @@ const NewProposalButton: React.FC = () => {
}; };
const MoreMenu: React.FC = () => { const MoreMenu: React.FC = () => {
const container = const container = getRootContainer();
typeof window !== "undefined" ? document.getElementById("root") : null;
return ( return (
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger asChild> <DropdownMenu.Trigger asChild>
<button <button className="text-r-2xl flex flex-row items-center gap-x-2 font-medium hover:font-bold hover:text-fg-400">
className={typo({
size: "2xl",
className:
"flex flex-row items-center gap-x-2 font-medium hover:font-bold hover:text-fg-400",
})}
>
<FiMenu size={28} /> More <FiMenu size={28} /> More
</button> </button>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
@@ -163,8 +200,8 @@ const MoreMenu: React.FC = () => {
className="min-w-[250px] rounded-md bg-bg-700 p-3 data-[side=top]:animate-slideUpAndFade" className="min-w-[250px] rounded-md bg-bg-700 p-3 data-[side=top]:animate-slideUpAndFade"
sideOffset={10} sideOffset={10}
> >
<DropdownMenu.Item className="flex select-none items-center justify-between px-[5px] pl-[25px] text-lg outline-none"> <DropdownMenu.Item className="flex select-none items-center justify-between px-[5px] pl-[25px] outline-none">
<p className={typo({ tag: "p" })}>Settings</p> <p className="text-r-lg">Settings</p>
<FiSettings /> <FiSettings />
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Content> </DropdownMenu.Content>

View File

@@ -0,0 +1,2 @@
export const getRootContainer = () =>
typeof window !== "undefined" ? document.getElementById("root") : null;

22
src/constants/schema.ts Normal file
View File

@@ -0,0 +1,22 @@
import { z } from "zod";
export const proposalSchema = z.object({
title: z
.string({
description: "Give your project a title",
required_error: "Title is required",
invalid_type_error: "Title must be a string",
})
.trim()
.min(5, "Titles must be at least 5 characters long")
.max(64, "Titles must be 64 characters or less"),
description: z
.string({
description: "Describe your project",
required_error: "Description is required",
invalid_type_error: "Description must be a string",
})
.trim()
.min(5, "Descriptions must be at least 5 characters long")
.max(1028, "Descriptions must be 1028 characters or less"),
});

View File

@@ -6,6 +6,7 @@ import Head from "next/head";
import { RootLayout } from "@components/layouts"; import { RootLayout } from "@components/layouts";
import { api } from "@utils/api"; import { api } from "@utils/api";
import "@styles/globals.css"; import "@styles/globals.css";
import { Toaster } from "react-hot-toast";
const ParallelApp: AppType<{ session: Session | null }> = ({ const ParallelApp: AppType<{ session: Session | null }> = ({
Component, Component,
@@ -41,6 +42,7 @@ const ParallelApp: AppType<{ session: Session | null }> = ({
<SSRProvider> <SSRProvider>
<SessionProvider session={session}> <SessionProvider session={session}>
<RootLayout> <RootLayout>
<Toaster position="top-center" />
<Component {...pageProps} /> <Component {...pageProps} />
</RootLayout> </RootLayout>
</SessionProvider> </SessionProvider>

View File

@@ -1,6 +1,5 @@
import { DiscoverLayout } from "@components/layouts"; import { DiscoverLayout } from "@components/layouts";
import { getServerAuthSession } from "@server/auth"; import { getServerAuthSession } from "@server/auth";
import { typo } from "@styles/typography";
import { api } from "@utils/api"; import { api } from "@utils/api";
import type { import type {
GetServerSideProps, GetServerSideProps,
@@ -8,21 +7,30 @@ import type {
InferGetServerSidePropsType, InferGetServerSidePropsType,
NextPage, NextPage,
} from "next"; } from "next";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(relativeTime);
const Discover: NextPage< const Discover: NextPage<
InferGetServerSidePropsType<typeof getServerSideProps> InferGetServerSidePropsType<typeof getServerSideProps>
> = () => { > = () => {
const { data } = api.projects.getAll.useQuery(); const { data, isLoading } = api.projects.getAll.useQuery();
return ( return (
<DiscoverLayout> <DiscoverLayout>
<h2 className={typo({ tag: "h2", className: "mb-8" })}>Favorites</h2> <h2 className="text-r-4xl mb-8">Favorites</h2>
{data?.map((project) => ( {isLoading ? (
<div key={project.id}> <p>Loading ...</p>
<h4 className={typo({ tag: "h4" })}>{project.title}</h4> ) : (
<p>{project.description}</p> data?.map((project) => (
</div> <div key={project.id} className="mb-4">
))} <h4 className="text-r-2xl">{project.title}</h4>
<p className="text-r-sm">{dayjs(project.createdAt).fromNow()}</p>
<p className="text-r-lg">{project.description}</p>
</div>
))
)}
</DiscoverLayout> </DiscoverLayout>
); );
}; };

View File

@@ -1,9 +1,23 @@
import { z } from "zod"; import { proposalSchema } from "@constants/schema";
import { createTRPCRouter, publicProcedure, protectedProcedure } from "../trpc"; import { createTRPCRouter, publicProcedure, protectedProcedure } from "../trpc";
export const projectsRouter = createTRPCRouter({ export const projectsRouter = createTRPCRouter({
getAll: publicProcedure.query(({ ctx }) => { getAll: publicProcedure.query(({ ctx }) => {
return ctx.prisma.project.findMany({ take: 100 }); return ctx.prisma.project.findMany({
take: 100,
orderBy: [{ createdAt: "desc" }],
});
}), }),
createProposal: protectedProcedure
.input(proposalSchema)
.mutation(async ({ ctx, input }) => {
const authorId = ctx.session.user.id;
const proposal = await ctx.prisma.project.create({
data: { authorId, title: input.title, description: input.description },
});
return proposal;
}),
}); });