-
On [./src/app/layout.tsx]
- Here we havae our metadata which is what will appear on search results
- We use the inter font fron next fonts and apply it to our html togethe with the other tailwind classes e.g. antialised
import { Navbar } from '@/components/Navbar'; import { Toaster } from '@/components/ui/toaster'; import { cn } from '@/lib/utils'; // the cn function is a helér that allow us to combine various class together using tailwindmerge and conditionally using clsx import '@/styles/globals.css'; import { Inter } from 'next/font/google'; export const metadata = { title: 'Breadit', description: 'A Reddit clone built with Next.js and TypeScript.', }; const inter = Inter({ subsets: ['latin'] }); export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( <html lang="en" className={cn( 'bg=white text-slate-900 antialiased light', inter.className )} > <body className="min-h-screen pt-12 bg-slate-50 antialiased"> <Navbar /> <div className="container max-w-7xl mx-auto h-full pt-12"> {children} </div> <Toaster /> </body> </html> ); }
- With shadcn we can apply, for exmaple, a button style into a Link by importing its variant into the Link classname:
<Link href="/sign-in" className={buttonVariants()}> Sign In </Link>
- On utils we created a cn utility function which help us combine multiple classnames together
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
-
On [./src/components/UserAuthForm.tsx]
- We create a client component where our logig with google client logic is done
- Our toast component and hook are taken from shadcn, to use it we added our Toaster to our RootLayout and invoked it here.
'use client'; import { FC, useState } from 'react'; import { Button } from './ui/Button'; import { cn } from '@/lib/utils'; import { signIn } from 'next-auth/react'; import { Icons } from './Icons'; import { useToast } from '@/hooks/use-toast'; interface UserAuthFormProps extends React.HTMLAttributes<HTMLDivElement> {} const UserAuthForm: FC<UserAuthFormProps> = ({ className, ...props }) => { const [isLoading, setIsLoading] = useState(false); const { toast } = useToast(); const loginWithGoogle = async () => { setIsLoading(true); try { await signIn('google'); } catch (err) { toast({ title: 'There was a problem', description: 'Error while trying to login with google', variant: 'destructive', }); } finally { setIsLoading(false); } }; return ( <div className={cn('flex, justify-center', className)} {...props}> <Button size="sm" className="w-full" onClick={loginWithGoogle} isLoading={isLoading} > {isLoading ? null : <Icons.google className="w-4 h-4 mr-2" />} Google </Button> </div> ); }; export default UserAuthForm;
-
To make our authentication we created a [./src/app/api/auth/[...nextauth]/route.ts]
- Here we simply create a handler for our nextauth make both our get anad post auth routs available.
import NextAuth from 'next-auth/next'; import { authOptions } from '@/lib/auth'; const handler = NextAuth(authOptions); export { handler as GET, handler as POST };
-
And for our [./src/lib/auth.ts]
- Here wee configure our authhOptions lib passing our db, strategy, and google credentials.
- We also create callbacks function, session will take our google data and pass it to our token.
- JWT is w
//because we're preparaing a library to be used in our applicaiton import { NextAuthOptions } from 'next-auth'; import { db } from './db'; import { PrismaAdapter } from '@next-auth/prisma-adapter'; import GoogleProvider from 'next-auth/providers/google'; import { nanoid } from 'nanoid'; export const authOptions: NextAuthOptions = { adapter: PrismaAdapter(db), session: { strategy: 'jwt', }, pages: { signIn: '/sign-in', }, providers: [ GoogleProvider({ clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, }), ], callbacks: { async session({ token, session }) { if (token) { session.user.id = token.id; session.user.name = token.name; session.user.email = token.email; session.user.image = token.picture; session.user.username = token.username; } }, async jwt({ token, user }) { const dbUser = await db.user.findFirst({ where: { email: token.email, }, }); if (!dbUser) { token.id = user!.id; return token; } if (!dbUser.username) { await db.user.update({ where: { id: dbUser.id, }, data: { username: nanoid(10), }, }); } return { id: dbUser.id, name: dbUser.name, emai: dbUser.email, picture: dbUser.image, username: dbUser.username, }; }, redirect() { return '/'; }, }, };
-
On [./src/types/next-auth.d.ts]:
- Here we declare or jwt and next-auth with the inclusion of our usename and id.
import type { Session, User } from 'next-auth'; import type { JWT } from 'next-auth/jwt'; type UserId = string; declare module 'next-auth/jwt' { interface JWT { id: UserId; username?: string | null; } } declare module 'next-auth' { interface Session { user: User & { id: UserId; username?: string | null; }; } }
-
On [./src/components/UserAccountNav.tsx]:
- Using shadcn DropDownMenu component we create our user menu, which has:
- A trigger, which is our UserAvatar
- Content, where our user data is available
- A separator
- Ou action links/buttons
const UserAccountNav: FC<UserAccountNavProps> = ({ user }) => { const handleSignOut = (e: Event) => { e.preventDefault(); signOut({ callbackUrl: `${window.location.origin}/sign-in`, }); }; return ( <DropdownMenu> <DropdownMenuTrigger> <UserAvatar user={{ name: user.name || null, image: user.image || null, }} className="h-8 w-8" /> </DropdownMenuTrigger> <DropdownMenuContent className="bg-white" align="end"> <div className="flex items-center justify-start gap-2 p-2"> <div className="flex flex-col space-y-1 leading-none"> {user.name && <p className="font-medium">{user.name}</p>} {user.email ? ( <p className="w-[200px] truncate text-sm text-zinc-700"> {user.email} </p> ) : ( <p className="w-[200px] truncate text-sm text-zinc-700"> email@teste.com </p> )} </div> </div> <DropdownMenuSeparator /> <DropdownMenuItem asChild> <Link href="/">Feed</Link> </DropdownMenuItem> <DropdownMenuItem asChild> <Link href="/r/create">Create community</Link> </DropdownMenuItem> <DropdownMenuItem asChild> <Link href="settings">Settings</Link> </DropdownMenuItem> <DropdownMenuItem className="cursor-pointer" onSelect={handleSignOut}> Sign out </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ); };
- Using shadcn DropDownMenu component we create our user menu, which has:
-
Our authentications routes, when clicked will first open a modal, and will only redirect to a page when refreshed. To do that we have an
@componentName/(.)route-which-is--redirected
. -
On [./src/app/@authModal/default.tsx] we return null to say by default it won't redirect.
-
On [./src/app/@authModal/(.)sign-in/page.tsx]:
- We create a modal component.
CloseModal
is simply a button which return to the previous route.- We do tha same to
/(.)sign-up
const page: FC<PageProps> = ({}) => { return ( <div className="fixed inset-0 bg-zinc-900/20 z-10"> <div className="container flex items-center h-full maax-w-lg mx-auto"> <div className="relative bg-white w-full h-fit py-20 px-2 rounded-lg"> <div className="absolute top-4 right-4"> <CloseModal /> </div> <SignIn /> </div> </div> </div> ); }; export default page;
-
On [./prisma/schema.prisma] we create our tables: Subreddits, Posts, Comments, Votes and sub-tables to support them.
-
On the model comment we have the following relation between one comment and another comments.
-
We can break the @relation statement into:
- It's name(optinal)
- The field in the table that do the relation.
- The field it references on the other tablea.
- The actions the DB do when the comment it is related to is deleted, in this case since they're on the same table, it mustn't do anything.
-
replayTo and repalyToId here are optional since not all commeents are a reply to another.
-
replies is an array of comments, it is a one to many relationship
model Comment { id String @id @default(cuid()) replyToId String? replyTo Comment? @relation("ReplyTo", fields: [replyToId], references: [id], onDelete: NoAction, onUpdate: NoAction) replies Comment[] @relation("ReplyTo") }
-
-
Here another example not between an user and a subreddit and a user and a subscription:
- Here we can better understand relationships in Prisma.
- First we have the relationship between User and Subreddit, where our subreddit is related to our user based on tis id. And likewise on user we have a Subscription[] to point out it can have many of them created.
- Subscription is how we create many-to-many relationships. On Subscription we relate user and subreddit referencing their Id and creating a relation to the respective fields.
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
username String? @unique
image String?
createdSubreddits Subreddit[] @relation("CreatedBy")
accounts Account[]
sessions Session[]
Post Post[]
Comment Comment[]
CommentVote CommentVote[]
Vote Vote[]
Subscription Subscription[]
}
model Subreddit {
id String @id @default(cuid())
name String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
posts Post[]
creatorId String?
Creator User? @relation("CreatedBy", fields: [creatorId], references: [id])
subscriber Subscription[]
@@index([name])
}
model Subscription {
user User @relation(fields: [userId], references: [id])
userId String
subreddit Subreddit @relation(fields: [subredditId], references: [id])
subredditId String
@@id([userId, subredditId])
}
-
On [./src/app/r/create/page.tsx]:
- With useMutation from react query we send our data (name) to our api and receive back its response.
- We then treat our axioserrors based on their http code, or show a generic toast if it doesnt match any of them.
const { mutate: createCommunity, isLoading } = useMutation({ mutationFn: async () => { const payload: CreateSubredditPayload = { name: input }; const { data } = await axios.post('/api/subreddit', payload); return data as string; }, onError: (err) => { if (err instanceof AxiosError) { if (err.response?.status === 400) { return toast({ title: 'Subreddit already exits', description: 'Please choose a different name', variant: 'destructive', }); } if (err.response?.status === 422) { return toast({ title: 'Invalid subreddit name', description: 'Please choose a name between 3 and 21 characters.', variant: 'destructive', }); } if (err.response?.status === 401) { return loginToast(); } } toast({ title: 'There was an error', description: 'Could not create subreddit.', variant: 'destructive', }); }, onSuccess: (data) => router.push(`/r/${data}`), });
-
To toasts which will be commonly show we create a hook called useCustomToast:
import Link from 'next/link';
import { toast } from './use-toast';
import { buttonVariants } from '@/components/ui/Button';
export const useCustomToast = () => {
const loginToast = () => {
const { dismiss } = toast({
title: 'Login required.',
description: 'You need to be logged in to do that.',
variant: 'destructive',
action: (
<Link
href="sign-in"
onClick={() => dismiss()}
className={buttonVariants({ variant: 'outline' })}
>
Login
</Link>
),
});
};
return { loginToast };
};
-
API to create a subreddit:
- Our POST route will make the due validations on the serve-side and if everything is ok return our response. In case it is not we returnr our status code.
import { getAuthSession } from '@/lib/auth'; import { db } from '@/lib/db'; import { SubredditValidator } from '@/lib/validators/subreddit'; import { z } from 'zod'; export async function POST(req: Request) { try { const session = await getAuthSession(); if (!session?.user) { return new Response('Unauthorized', { status: 401 }); } const body = await req.json(); const { name } = SubredditValidator.parse(body); const subredditExists = await db.subreddit.findFirst({ where: { name, }, }); if (subredditExists) { return new Response('Subreddit already exists', { status: 409 }); } const subreddit = await db.subreddit.create({ data: { name, creatorId: session.user.id, }, }); await db.subscription.create({ data: { userId: session.user.id, subredditId: subreddit.id, }, }); return new Response(subreddit.name); } catch (err) { if (err instanceof z.ZodError) { return new Response(err.message, { status: 422 }); } return new Response('Could not create subreddit', { status: 500 }); } }
-
To make it all typesafe we create a [./src/lib/validators/subreddit.ts]:
import { z } from 'zod'; export const SubredditValidator = z.object({ name: z.string().min(3).max(21), }); export const SubredditSubscriptionValidator = z.object({ subredditId: z.string(), }); export type CreateSubredditPayload = z.infer<typeof SubredditValidator>; export type SubscribeToSubredditPaloyad = z.infer< typeof SubredditSubscriptionValidator >;
-
On [./src/app/r/[slug]/layout.tsx]:
- We create a definition list
<dl>
which receives a data term<dl>
which defines a data descriptiondd
- It is alo here that we render our buttons to post and subscribe.
<dl className="divide-y divide-g-gray100 px-6 py-4 text-sm leading-6 bg-white"> <div className="flex justify-between gap-x-4 py-3"> <dt className="text-gray-500">Created</dt> <dd className="text-gray-700"> <time dateTime={subreddit.createdAt.toDateString()}> {format(subreddit.createdAt, 'MMM d, yyyy')} </time> </dd> </div> <div className="flex justify-between gap-x-4 py-3"> <dt className="text-gray-500">Members</dt> <dd className="text-gray-700"> <div className="text-gray-900">{memberCount}</div> </dd> </div> {subreddit.creatorId === session?.user.id ? ( <div className="flex justify-between gap-x-4 py-3"> <p className="text-gray-500">You created this community</p> </div> ) : null} {subreddit.creatorId !== session?.user.id && ( <SubscribeLeaveToggle subredditId={subreddit.id} subredditName={subreddit.name} isSubscribed={isSubscribed} /> )} <Link className={buttonVariants({ variant: 'outline', className: 'w-full mb-6', })} href={`r/${slug}/submit`} > Create Post </Link> </dl>
- To get our posts from the db we invoke it directly from oru layout, which is async.
- We include on that search the posts together with its related tables.
- We also make a call to check if our user is subscribed on the subreddit.
- Finally we count the number of subscription on the subreddit to show it.
const subreddit = await db.subreddit.findFirst({ where: { name: slug }, include: { posts: { include: { author: true, votes: true, }, }, }, }); const subscription = !session?.user ? undefined : await db.subscription.findFirst({ where: { subreddit: { name: slug, }, user: { id: session.user.id, }, }, }); const isSubscribed = !!subscription; if (!subreddit) return notFound(); const memberCount = await db.subscription.count({ where: { subreddit: { name: slug, }, }, });
- We create a definition list
-
On [./src/components/SubscribeLeaveToggle.tsx]:
- Here we create a mutation using tanQuery for both calls.
- We also make all the error handling based on the error type and status code received.
- In case of success we refresh the screen to update to the ne estate and inform our user
const { mutate: subscribe, isLoading: isSubLoading } = useMutation({ mutationFn: async () => { const payload: SubscribeToSubredditPaloyad = { subredditId, }; const { data } = await axios.post('/api/subreddit/subscribe', payload); return data as string; }, onError: (err) => { if (err instanceof AxiosError) { if (err.response?.status === 401) { return loginToast(); } } return toast({ title: 'There was a problem', description: 'Something went wrong, please try again.', variant: 'destructive', }); }, onSuccess: () => { startTransition(() => { router.refresh(); }); return toast({ title: 'Subscribed', description: `You are now subscribed to r/${subredditName}`, }); }, });
-
Our button will be rendered contionally based on whether or not the user is subscribed:
return isSubscribed ? ( <Button onClick={() => unsubscribe()} isLoading={isUnsubLoading} className="w-full mt-1 mb-4" > Leave community </Button> ) : ( <Button onClick={() => subscribe()} isLoading={isSubLoading} className="w-full mt-1 mb-4" > Join to post </Button>
-
On [./src/app/api/subreddit/unsubscribe/route.ts]:
- Here is how one of our routes look like, we start by making some verifications, first whether the user is logged on or not, and in this case whether he already has a subscription and if the subreddit is theirs.
- In case all is ok we delete the subscription from the db and say "Ok"
- In our catch we treat other possible erros like zod ones, or unknown error which gives back a generic message
- HTTP error handling
- 401 for authetnication,
- 400 (bad request) when it does not follow our business rules,
- 422 when data is passed incorrectly
- 500 as a server error and generic
- 409 for concflict, when there's already a row with that unique data
import { getAuthSession } from '@/lib/auth'; import { db } from '@/lib/db'; import { SubredditSubscriptionValidator } from '@/lib/validators/subreddit'; import { z } from 'zod'; export async function POST(req: Request) { try { const session = await getAuthSession(); if (!session?.user) { return new Response('Unauthorized', { status: 401 }); } const body = await req.json(); const { subredditId } = SubredditSubscriptionValidator.parse(body); const subscriptionExists = await db.subscription.findFirst({ where: { subredditId, userId: session.user.id, }, }); //check if user is creator of subreddit const subreddit = await db.subreddit.findFirst({ where: { id: subredditId, creatorId: session.user.id, }, }); if (subreddit) { return new Response('You can\t unsubscribe from a subreddit you own', { status: 400, }); } if (!subscriptionExists) { return new Response('You are not subscribed to this subreddit.', { status: 400, }); } await db.subscription.delete({ where: { userId_subredditId: { subredditId, userId: session.user.id, }, }, }); return new Response(subredditId); } catch (err) { if (err instanceof z.ZodError) { return new Response('Invalid request data passed', { status: 422 }); } return new Response('Could not unsubscribe to subreddit', { status: 500, }); } }
- To create our editor we need to install a few dependencies, the first are all editorjs exodia parts we need to make our editor a multimedia behemot, an for react-textarea-autosize it will serve to change the textbox size according to the quanity of text inserted.
{
"@editorjs/code": "^2.8.0",
"@editorjs/editorjs": "^2.27.0",
"@editorjs/embed": "^2.5.3",
"@editorjs/header": "^2.7.0",
"@editorjs/image": "^2.8.1",
"@editorjs/inline-code": "^1.4.0",
"@editorjs/link": "^2.5.0",
"@editorjs/list": "^1.8.0",
"@editorjs/paragraph": "^2.9.0",
"@editorjs/table": "^2.2.1",
"editorjs-react-renderer": "^3.5.1",
"react-textarea-autosize": "^8.4.1"
}
-
On [./src/components/Post.tsx]
-
It takes some time for our editor to load, and since we don't want to it to delay our whole page load we put inside an async callback where all parts of our editor are imported async and mounted through an object.
const initializeEditor = useCallback(async () => { const EditorJS = (await import('@editorjs/editorjs')).default; const Header = (await import('@editorjs/header')).default; const Embed = (await import('@editorjs/embed')).default; const Table = (await import('@editorjs/table')).default; const List = (await import('@editorjs/list')).default; const Code = (await import('@editorjs/code')).default; const LinkTool = (await import('@editorjs/link')).default; const InlineCode = (await import('@editorjs/inline-code')).default; const ImageTool = (await import('@editorjs/image')).default; if (!ref.current) { const editor = new EditorJS({ holder: 'editor', onReady() { ref.current = editor; }, placeholder: 'Type here to write your post...', inlineToolbar: true, data: { blocks: [] }, tools: { header: Header, linkTool: { class: LinkTool, config: { endpoint: '/api/link', }, }, image: { class: ImageTool, config: { uploader: { async uploadByFile(file: File) { const [res] = await uploadFiles([file], 'imageUploader'); return { success: 1, file: { url: res.fileUrl, }, }; }, }, }, }, list: List, code: Code, inlineCode: InlineCode, table: Table, embed: Embed, }, }); } }, []);
- We have here three different useEffects, one will check if the window loaded, the second will show us errors, while our last loads our editor, and focus on the title, after doing that it destorys the editor ref and makes it null.
const ref = useRef<EditorJSProps | null>(null); const _titleRef = useRef<HTMLTextAreaElement | null>(null); const [isMounted, setIsMounted] = useState<boolean>(false); useEffect(() => { if (typeof window !== undefined) { setIsMounted(true); } }); useEffect(() => { if (Object.keys(errors).length) { for (const [_key, value] of Object.entries(errors)) { toast({ title: "Something wen't wrong", description: (value as { message: string }).message, variant: 'destructive', }); } } }, [errors]); useEffect(() => { const init = async () => { await initializeEditor(); }; setTimeout(() => { _titleRef.current?.focus(); }, 0); if (isMounted) { init(); return () => { ref.current?.destroy(); ref.current = null; }; } }, [isMounted, initializeEditor]);
- To render our titlee component we first need to brake our
register(title)
in two, that's because our TextAutosize receives two of them, onf from our hookform and another for the autofocus. - As four our editor it will be rendered in our div with the id with the same nama the name which was dfined previously in our object
holder: 'editor',
const { ref: titleRef, ...rest } = register('title'); return ( <div className="w-full p-4 bg-zinc-50 rounded-lg border border-zinc-200"> <form id="subreddit-post-form" className="w-fit" onSubmit={handleSubmit((e) => onSubmit(e))} > <div className="prose prose-stone dark:prose-invert"> <TextareaAutosize ref={(e) => { titleRef(e); _titleRef.current = e; }} {...rest} placeholder="Title" className="w-full resize-none appearence-none overflow-hidden bg-transparent text-5xl font-bold focus:outline-none" /> </div> <div id="editor" className="min-h-[500px]" /> </form> </div> );
-
In [./src/app/api/link/route.ts]:
- To show link previews we need a route first, which will receive our req url.
- In case its valid we get the page and use regex to get the matching title, description and image, if any and return a json passing the data in the format asked by editorjs.
import axios from 'axios'; export async function GET(req: Request) { const url = new URL(req.url); const href = url.searchParams.get('url'); if (!href) { return new Response('Invalid href', { status: 400 }); } const res = await axios.get(href); const titleMatch = res.data.match(/<title>(.*?)<\/title>/); const title = titleMatch ? titleMatch[1] : ''; const descriptionMatch = res.data.match( /<meta name="description" content="(.*?)"/ ); const description = descriptionMatch ? descriptionMatch[1] : ''; const imageMatch = res.data.match( /<meta property="og:image" content="(.*?)"/ ); const imageUrl = imageMatch ? imageMatch[1] : ''; return new Response( JSON.stringify({ success: 1, meta: { title, description, image: { url: imageUrl, }, }, }) ); }
-
In [./src/app/api/uploadthing/route.ts]:
- Here we use uploadthing as our image bucket, using the basic docs setup
import { createUploadthing, type FileRouter } from 'uploadthing/next'; const f = createUploadthing(); const auth = (req: Request) => ({ id: 'fakeId' }); // Fake auth function export const ourFileRouter = { imageUploader: f({ image: { maxFileSize: '4MB' } }) .middleware(async ({ req }) => { const user = await auth(req); if (!user) throw new Error('Unauthorized'); return { userId: user.id }; }) .onUploadComplete(async ({ metadata, file }) => { console.log('Upload complete for userId:', metadata.userId); console.log('file url', file.url); }), } satisfies FileRouter; export type OurFileRouter = typeof ourFileRouter;
-
On [./src/lib/uploadthing.ts]:
- We invoke this when uploading our files, and it returns its url.
import { generateReactHelpers } from '@uploadthing/react/hooks';
import type { OurFileRouter } from '@/app/api/uploadthing/core';
export const { uploadFiles } = generateReactHelpers<OurFileRouter>();
-
On [./src/app/api/subreddit/post/create/route.ts]:
- We check stuff, we put it on db.
import { getAuthSession } from '@/lib/auth'; import { db } from '@/lib/db'; import { PostValidator } from '@/lib/validators/post'; import { SubredditSubscriptionValidator } from '@/lib/validators/subreddit'; import { z } from 'zod'; export async function POST(req: Request) { try { const session = await getAuthSession(); if (!session?.user) { return new Response('Unauthorized', { status: 401 }); } const body = await req.json(); const { subredditId, title, content } = PostValidator.parse(body); const subscriptionExists = await db.subscription.findFirst({ where: { subredditId, userId: session.user.id, }, }); console.log( 'LS -> src/app/api/subreddit/post/create/route.ts:24 -> subscriptionExists: ', subscriptionExists ); if (!subscriptionExists) { return new Response('Subscribe to post', { status: 400, }); } await db.post.create({ data: { subredditId, title, content, authorId: session.user.id, }, }); return new Response('Ok'); } catch (err) { console.log( 'LS -> src/app/api/subreddit/post/create/route.ts:42 -> err: ', err ); if (err instanceof z.ZodError) { return new Response('Invalid request data passed', { status: 422 }); } return new Response( 'Could not post to subreddit at this time, please try again laterr', { status: 500 } ); } }