Created the new proposal form dialog
This commit is contained in:
@@ -1,84 +1,49 @@
|
||||
import { typo } from "@styles/typography";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { useState } from "react";
|
||||
import type { ZodString } from "zod";
|
||||
import React from "react";
|
||||
|
||||
const input = cva([
|
||||
typo({ tag: "p" }),
|
||||
"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",
|
||||
"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",
|
||||
"focus:outline-none",
|
||||
]);
|
||||
|
||||
interface Props extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
error?: string;
|
||||
prefixIcon?: React.ReactNode;
|
||||
suffixIcon?: React.ReactNode;
|
||||
validator?: ZodString;
|
||||
onChange?: React.ChangeEventHandler<HTMLInputElement>;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export const TextInput: React.FC<Props> = ({
|
||||
label,
|
||||
placeholder,
|
||||
prefixIcon,
|
||||
suffixIcon,
|
||||
validator,
|
||||
onChange,
|
||||
value: valueProp,
|
||||
...rest
|
||||
}) => {
|
||||
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" })}
|
||||
export const TextInput = React.forwardRef<HTMLInputElement, Props>(
|
||||
({ label, placeholder, error, prefixIcon, suffixIcon, ...rest }, ref) => {
|
||||
return (
|
||||
<fieldset className="w-full">
|
||||
{label && (
|
||||
<label className="text-r-lg mb-1 block text-fg">{label}</label>
|
||||
)}
|
||||
<div
|
||||
className={input({
|
||||
className: "flex flex-row items-center gap-x-2 px-2",
|
||||
})}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div
|
||||
className={input({
|
||||
className: "flex flex-row items-center gap-x-2 px-2",
|
||||
})}
|
||||
>
|
||||
{prefixIcon && prefixIcon}
|
||||
<input
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
value={value ?? ""}
|
||||
onChange={handleChange}
|
||||
className="w-full appearance-none border-none bg-transparent leading-tight text-fg placeholder-fg/50 focus:outline-none"
|
||||
{...rest}
|
||||
/>
|
||||
{suffixIcon && suffixIcon}
|
||||
</div>
|
||||
{/* {validationError && (
|
||||
<span className="validation-error">{validationError}</span>
|
||||
)} */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
{prefixIcon && prefixIcon}
|
||||
<input
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
className="w-full appearance-none border-none bg-transparent leading-tight text-fg placeholder-fg/50 focus:outline-none"
|
||||
ref={ref}
|
||||
{...rest}
|
||||
/>
|
||||
{suffixIcon && suffixIcon}
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-r-xl overflow-clip overflow-ellipsis whitespace-nowrap text-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
TextInput.displayName = "TextInput";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Children } from "@utils/types/props";
|
||||
import { typo } from "@styles/typography";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
FiMenu,
|
||||
@@ -19,6 +18,14 @@ import { cx } from "class-variance-authority";
|
||||
import { TextInput } from "@components/TextInput";
|
||||
import { Divider } from "@components/Divider";
|
||||
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 }) => {
|
||||
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">
|
||||
<div>
|
||||
<h1>
|
||||
<Link
|
||||
href="/"
|
||||
className={typo({ tag: "h3", className: "text-primary" })}
|
||||
>
|
||||
<Link href="/" className="text-r-3xl text-primary">
|
||||
<span className="text-fg">||</span> Parallel
|
||||
</Link>
|
||||
</h1>
|
||||
@@ -67,11 +71,7 @@ export const SidePanel: React.FC = () => {
|
||||
<Link
|
||||
key={label}
|
||||
href={route}
|
||||
className={typo({
|
||||
size: "2xl",
|
||||
className:
|
||||
"flex flex-row items-center gap-x-2 font-medium hover:font-bold hover:text-fg-400",
|
||||
})}
|
||||
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>
|
||||
@@ -85,9 +85,39 @@ export const SidePanel: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
type ProposalForm = z.infer<typeof proposalSchema>;
|
||||
|
||||
const NewProposalButton: React.FC = () => {
|
||||
const container =
|
||||
typeof window !== "undefined" ? document.getElementById("root") : null;
|
||||
// === 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 => {
|
||||
console.log("Submitting form data", data);
|
||||
mutate(data);
|
||||
};
|
||||
// === Components ===========================================================
|
||||
|
||||
const FullDivider = () => (
|
||||
<div className="my-4 -mx-10">
|
||||
@@ -96,7 +126,11 @@ const NewProposalButton: React.FC = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog.Root>
|
||||
<Dialog.Root
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) reset();
|
||||
}}
|
||||
>
|
||||
<Dialog.Trigger asChild>
|
||||
<Button variant={{ size: "small" }}>New Proposal</Button>
|
||||
</Dialog.Trigger>
|
||||
@@ -111,28 +145,38 @@ const NewProposalButton: React.FC = () => {
|
||||
"focus:outline-none data-[state=open]:animate-contentShow"
|
||||
)}
|
||||
>
|
||||
<Dialog.Title>
|
||||
<h1 className={typo({ tag: "h5", className: "text-primary" })}>
|
||||
Create new proposal
|
||||
</h1>
|
||||
<Dialog.Title asChild>
|
||||
<h1 className="text-r-xl text-primary">Create new proposal</h1>
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Let others know a little about your project idea.
|
||||
<Dialog.Description asChild>
|
||||
<p className="text-r-lg">
|
||||
Let others know a little about your project idea.
|
||||
</p>
|
||||
</Dialog.Description>
|
||||
<FullDivider />
|
||||
<div className="mb-8 flex flex-col gap-y-4">
|
||||
<TextInput label="Title" />
|
||||
<TextInput
|
||||
label="Description"
|
||||
placeholder="Describe your project"
|
||||
/>
|
||||
<ImageInput label="Image" />
|
||||
</div>
|
||||
<Dialog.Close asChild>
|
||||
<div className="flex flex-row justify-end">
|
||||
<Button variant={{ size: "small" }}>Create</Button>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-misused-promises */}
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<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")}
|
||||
/>
|
||||
<TextInput
|
||||
label="Description"
|
||||
placeholder={proposalSchema.shape.description.description}
|
||||
error={formState.errors.description?.message}
|
||||
{...register("description")}
|
||||
/>
|
||||
{/* <ImageInput label="Image" /> */}
|
||||
</div>
|
||||
</Dialog.Close>
|
||||
<div className="flex flex-row justify-end">
|
||||
<Button variant={{ size: "small" }} type="submit">
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
@@ -140,19 +184,12 @@ const NewProposalButton: React.FC = () => {
|
||||
};
|
||||
|
||||
const MoreMenu: React.FC = () => {
|
||||
const container =
|
||||
typeof window !== "undefined" ? document.getElementById("root") : null;
|
||||
const container = getRootContainer();
|
||||
|
||||
return (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button
|
||||
className={typo({
|
||||
size: "2xl",
|
||||
className:
|
||||
"flex flex-row items-center gap-x-2 font-medium hover:font-bold hover:text-fg-400",
|
||||
})}
|
||||
>
|
||||
<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>
|
||||
@@ -163,8 +200,8 @@ const MoreMenu: React.FC = () => {
|
||||
className="min-w-[250px] rounded-md bg-bg-700 p-3 data-[side=top]:animate-slideUpAndFade"
|
||||
sideOffset={10}
|
||||
>
|
||||
<DropdownMenu.Item className="flex select-none items-center justify-between px-[5px] pl-[25px] text-lg outline-none">
|
||||
<p className={typo({ tag: "p" })}>Settings</p>
|
||||
<DropdownMenu.Item className="flex select-none items-center justify-between px-[5px] pl-[25px] outline-none">
|
||||
<p className="text-r-lg">Settings</p>
|
||||
<FiSettings />
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
|
||||
2
src/constants/elements.ts
Normal file
2
src/constants/elements.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const getRootContainer = () =>
|
||||
typeof window !== "undefined" ? document.getElementById("root") : null;
|
||||
22
src/constants/schema.ts
Normal file
22
src/constants/schema.ts
Normal 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"),
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import Head from "next/head";
|
||||
import { RootLayout } from "@components/layouts";
|
||||
import { api } from "@utils/api";
|
||||
import "@styles/globals.css";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
const ParallelApp: AppType<{ session: Session | null }> = ({
|
||||
Component,
|
||||
@@ -41,6 +42,7 @@ const ParallelApp: AppType<{ session: Session | null }> = ({
|
||||
<SSRProvider>
|
||||
<SessionProvider session={session}>
|
||||
<RootLayout>
|
||||
<Toaster position="top-center" />
|
||||
<Component {...pageProps} />
|
||||
</RootLayout>
|
||||
</SessionProvider>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { DiscoverLayout } from "@components/layouts";
|
||||
import { getServerAuthSession } from "@server/auth";
|
||||
import { typo } from "@styles/typography";
|
||||
import { api } from "@utils/api";
|
||||
import type {
|
||||
GetServerSideProps,
|
||||
@@ -8,21 +7,30 @@ import type {
|
||||
InferGetServerSidePropsType,
|
||||
NextPage,
|
||||
} from "next";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const Discover: NextPage<
|
||||
InferGetServerSidePropsType<typeof getServerSideProps>
|
||||
> = () => {
|
||||
const { data } = api.projects.getAll.useQuery();
|
||||
const { data, isLoading } = api.projects.getAll.useQuery();
|
||||
|
||||
return (
|
||||
<DiscoverLayout>
|
||||
<h2 className={typo({ tag: "h2", className: "mb-8" })}>Favorites</h2>
|
||||
{data?.map((project) => (
|
||||
<div key={project.id}>
|
||||
<h4 className={typo({ tag: "h4" })}>{project.title}</h4>
|
||||
<p>{project.description}</p>
|
||||
</div>
|
||||
))}
|
||||
<h2 className="text-r-4xl mb-8">Favorites</h2>
|
||||
{isLoading ? (
|
||||
<p>Loading ...</p>
|
||||
) : (
|
||||
data?.map((project) => (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import { z } from "zod";
|
||||
import { proposalSchema } from "@constants/schema";
|
||||
|
||||
import { createTRPCRouter, publicProcedure, protectedProcedure } from "../trpc";
|
||||
|
||||
export const projectsRouter = createTRPCRouter({
|
||||
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;
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user