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 { 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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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 { 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user