├── public └── favicon.ico ├── src ├── app │ ├── @authModal │ │ ├── default.tsx │ │ ├── (.)sign-in │ │ │ └── page.tsx │ │ └── (.)sign-up │ │ │ └── page.tsx │ ├── api │ │ ├── auth │ │ │ └── [...nextauth] │ │ │ │ └── route.ts │ │ ├── uploadthing │ │ │ ├── route.ts │ │ │ └── core.ts │ │ ├── search │ │ │ └── route.ts │ │ ├── subreddit │ │ │ ├── post │ │ │ │ ├── comment │ │ │ │ │ ├── route.ts │ │ │ │ │ └── vote │ │ │ │ │ │ └── route.ts │ │ │ │ ├── create │ │ │ │ │ └── route.ts │ │ │ │ └── vote │ │ │ │ │ └── route.ts │ │ │ ├── route.ts │ │ │ ├── subscribe │ │ │ │ └── route.ts │ │ │ └── unsubscribe │ │ │ │ └── route.ts │ │ ├── link │ │ │ └── route.ts │ │ ├── username │ │ │ └── route.ts │ │ └── posts │ │ │ └── route.ts │ ├── (auth) │ │ ├── sign-in │ │ │ └── page.tsx │ │ └── sign-up │ │ │ └── page.tsx │ ├── settings │ │ └── page.tsx │ ├── layout.tsx │ ├── r │ │ ├── [slug] │ │ │ ├── page.tsx │ │ │ ├── submit │ │ │ │ └── page.tsx │ │ │ ├── post │ │ │ │ └── [postId] │ │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ └── create │ │ │ └── page.tsx │ └── page.tsx ├── config.ts ├── lib │ ├── redis.ts │ ├── validators │ │ ├── username.ts │ │ ├── comment.ts │ │ ├── subreddit.ts │ │ ├── post.ts │ │ └── vote.ts │ ├── uploadthing.ts │ ├── db.ts │ ├── utils.ts │ └── auth.ts ├── types │ ├── db.d.ts │ ├── redis.d.ts │ ├── editor.d.ts │ └── next-auth.d.ts ├── components │ ├── renderers │ │ ├── CustomCodeRenderer.tsx │ │ └── CustomImageRenderer.tsx │ ├── CloseModal.tsx │ ├── Providers.tsx │ ├── homepage │ │ ├── GeneralFeed.tsx │ │ └── CustomFeed.tsx │ ├── ui │ │ ├── Label.tsx │ │ ├── Textarea.tsx │ │ ├── Input.tsx │ │ ├── Toaster.tsx │ │ ├── Avatar.tsx │ │ ├── Card.tsx │ │ ├── Button.tsx │ │ ├── Dialog.tsx │ │ ├── Toast.tsx │ │ ├── Command.tsx │ │ └── DropdownMenu.tsx │ ├── EditorOutput.tsx │ ├── UserAvatar.tsx │ ├── ToFeedButton.tsx │ ├── SignIn.tsx │ ├── SignUp.tsx │ ├── Navbar.tsx │ ├── UserAuthForm.tsx │ ├── MiniCreatePost.tsx │ ├── post-vote │ │ ├── PostVoteServer.tsx │ │ └── PostVoteClient.tsx │ ├── UserAccountNav.tsx │ ├── CreateComment.tsx │ ├── Icons.tsx │ ├── Post.tsx │ ├── SearchBar.tsx │ ├── PostFeed.tsx │ ├── SubscribeLeaveToggle.tsx │ ├── UserNameForm.tsx │ ├── CommentsSection.tsx │ ├── CommentVotes.tsx │ ├── comments │ │ └── PostComment.tsx │ └── Editor.tsx ├── middleware.ts ├── hooks │ ├── use-custom-toasts.tsx │ ├── use-on-click-outside.ts │ └── use-toast.ts └── styles │ ├── editor.css │ └── globals.css ├── postcss.config.js ├── .vscode └── settings.json ├── .eslintrc.json ├── .env.example ├── next.config.js ├── .gitignore ├── tsconfig.json ├── README.md ├── package.json ├── tailwind.config.js └── prisma └── schema.prisma /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joschan21/breadit/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/app/@authModal/default.tsx: -------------------------------------------------------------------------------- 1 | export default function Default() { 2 | return null 3 | } 4 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules\\typescript\\lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true 4 | } -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | // 2 to demonstrate infinite scroll, should be higher in production 2 | export const INFINITE_SCROLL_PAGINATION_RESULTS = 2 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals", 3 | "rules": { 4 | "no-console": ["warn"], 5 | "no-unused-vars": ["warn"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/redis.ts: -------------------------------------------------------------------------------- 1 | import { Redis } from '@upstash/redis' 2 | 3 | export const redis = new Redis({ 4 | url: process.env.REDIS_URL!, 5 | token: process.env.REDIS_SECRET!, 6 | }) 7 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL= 2 | NEXTAUTH_SECRET= 3 | 4 | GOOGLE_CLIENT_ID= 5 | GOOGLE_CLIENT_SECRET= 6 | 7 | UPLOADTHING_SECRET= 8 | UPLOADTHING_APP_ID= 9 | 10 | REDIS_URL= 11 | REDIS_SECRET= -------------------------------------------------------------------------------- /src/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import { authOptions } from "@/lib/auth" 2 | import NextAuth from "next-auth" 3 | 4 | const handler = NextAuth(authOptions) 5 | 6 | export { handler as GET, handler as POST } -------------------------------------------------------------------------------- /src/lib/validators/username.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const UsernameValidator = z.object({ 4 | name: z 5 | .string() 6 | .min(3) 7 | .max(32) 8 | .regex(/^[a-zA-Z0-9_]+$/), 9 | }) 10 | -------------------------------------------------------------------------------- /src/lib/uploadthing.ts: -------------------------------------------------------------------------------- 1 | import { generateReactHelpers } from '@uploadthing/react/hooks' 2 | 3 | import type { OurFileRouter } from '@/app/api/uploadthing/core' 4 | 5 | export const { uploadFiles } = generateReactHelpers() 6 | -------------------------------------------------------------------------------- /src/types/db.d.ts: -------------------------------------------------------------------------------- 1 | import type { Post, Subreddit, User, Vote, Comment } from '@prisma/client' 2 | 3 | export type ExtendedPost = Post & { 4 | subreddit: Subreddit 5 | votes: Vote[] 6 | author: User 7 | comments: Comment[] 8 | } 9 | -------------------------------------------------------------------------------- /src/types/redis.d.ts: -------------------------------------------------------------------------------- 1 | import { Vote } from '@prisma/client' 2 | 3 | export type CachedPost = { 4 | id: string 5 | title: string 6 | authorUsername: string 7 | content: string 8 | currentVote: Vote['type'] | null 9 | createdAt: Date 10 | } 11 | -------------------------------------------------------------------------------- /src/types/editor.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@editorjs/embed" 2 | declare module "@editorjs/table" 3 | declare module "@editorjs/list" 4 | declare module "@editorjs/code" 5 | declare module "@editorjs/link" 6 | declare module "@editorjs/inline-code" 7 | declare module "@editorjs/image" -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | domains: ['uploadthing.com', 'lh3.googleusercontent.com'], 5 | }, 6 | experimental: { 7 | appDir: true 8 | } 9 | } 10 | 11 | module.exports = nextConfig 12 | -------------------------------------------------------------------------------- /src/app/api/uploadthing/route.ts: -------------------------------------------------------------------------------- 1 | import { createNextRouteHandler } from 'uploadthing/next' 2 | 3 | import { ourFileRouter } from './core' 4 | 5 | // Export routes for Next App Router 6 | export const { GET, POST } = createNextRouteHandler({ 7 | router: ourFileRouter, 8 | }) 9 | -------------------------------------------------------------------------------- /src/lib/validators/comment.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const CommentValidator = z.object({ 4 | postId: z.string(), 5 | text: z.string(), 6 | replyToId: z.string().optional() 7 | }) 8 | 9 | export type CommentRequest = z.infer 10 | -------------------------------------------------------------------------------- /src/components/renderers/CustomCodeRenderer.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | function CustomCodeRenderer({ data }: any) { 4 | (data) 5 | 6 | return ( 7 |
 8 |       {data.code}
 9 |     
10 | ) 11 | } 12 | 13 | export default CustomCodeRenderer 14 | -------------------------------------------------------------------------------- /src/components/renderers/CustomImageRenderer.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import Image from 'next/image' 4 | 5 | function CustomImageRenderer({ data }: any) { 6 | const src = data.file.url 7 | 8 | return ( 9 |
10 | image 11 |
12 | ) 13 | } 14 | 15 | export default CustomImageRenderer 16 | -------------------------------------------------------------------------------- /src/lib/validators/subreddit.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const SubredditValidator = z.object({ 4 | name: z.string().min(3).max(21), 5 | }) 6 | 7 | export const SubredditSubscriptionValidator = z.object({ 8 | subredditId: z.string(), 9 | }) 10 | 11 | export type CreateSubredditPayload = z.infer 12 | export type SubscribeToSubredditPayload = z.infer< 13 | typeof SubredditSubscriptionValidator 14 | > 15 | -------------------------------------------------------------------------------- /src/types/next-auth.d.ts: -------------------------------------------------------------------------------- 1 | import type { Session, User } from 'next-auth' 2 | import type { JWT } from 'next-auth/jwt' 3 | 4 | type UserId = string 5 | 6 | declare module 'next-auth/jwt' { 7 | interface JWT { 8 | id: UserId 9 | username?: string | null 10 | } 11 | } 12 | 13 | declare module 'next-auth' { 14 | interface Session { 15 | user: User & { 16 | id: UserId 17 | username?: string | null 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/lib/validators/post.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const PostValidator = z.object({ 4 | title: z 5 | .string() 6 | .min(3, { 7 | message: 'Title must be at least 3 characters long', 8 | }) 9 | .max(128, { 10 | message: 'Title must be less than 128 characters long', 11 | }), 12 | subredditId: z.string(), 13 | content: z.any(), 14 | }) 15 | 16 | export type PostCreationRequest = z.infer 17 | -------------------------------------------------------------------------------- /src/lib/validators/vote.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const PostVoteValidator = z.object({ 4 | postId: z.string(), 5 | voteType: z.enum(['UP', 'DOWN']), 6 | }) 7 | 8 | export type PostVoteRequest = z.infer 9 | 10 | export const CommentVoteValidator = z.object({ 11 | commentId: z.string(), 12 | voteType: z.enum(['UP', 'DOWN']), 13 | }) 14 | 15 | export type CommentVoteRequest = z.infer 16 | -------------------------------------------------------------------------------- /src/lib/db.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client' 2 | import "server-only" 3 | 4 | declare global { 5 | // eslint-disable-next-line no-var, no-unused-vars 6 | var cachedPrisma: PrismaClient 7 | } 8 | 9 | let prisma: PrismaClient 10 | if (process.env.NODE_ENV === 'production') { 11 | prisma = new PrismaClient() 12 | } else { 13 | if (!global.cachedPrisma) { 14 | global.cachedPrisma = new PrismaClient() 15 | } 16 | prisma = global.cachedPrisma 17 | } 18 | 19 | export const db = prisma -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { getToken } from 'next-auth/jwt' 2 | import { NextResponse } from 'next/server' 3 | import type { NextRequest } from 'next/server' 4 | 5 | export async function middleware(req: NextRequest) { 6 | const token = await getToken({ req }) 7 | 8 | if (!token) { 9 | return NextResponse.redirect(new URL('/sign-in', req.nextUrl)) 10 | } 11 | } 12 | 13 | // See "Matching Paths" below to learn more 14 | export const config = { 15 | matcher: ['/r/:path*/submit', '/r/create'], 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | .env 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /src/app/api/search/route.ts: -------------------------------------------------------------------------------- 1 | import { db } from '@/lib/db' 2 | 3 | export async function GET(req: Request) { 4 | const url = new URL(req.url) 5 | const q = url.searchParams.get('q') 6 | 7 | if (!q) return new Response('Invalid query', { status: 400 }) 8 | 9 | const results = await db.subreddit.findMany({ 10 | where: { 11 | name: { 12 | startsWith: q, 13 | }, 14 | }, 15 | include: { 16 | _count: true, 17 | }, 18 | take: 5, 19 | }) 20 | 21 | return new Response(JSON.stringify(results)) 22 | } 23 | -------------------------------------------------------------------------------- /src/components/CloseModal.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { X } from 'lucide-react' 4 | import { useRouter } from 'next/navigation' 5 | import { FC } from 'react' 6 | import { Button } from './ui/Button' 7 | 8 | interface CloseModalProps {} 9 | 10 | const CloseModal: FC = ({}) => { 11 | const router = useRouter() 12 | 13 | return ( 14 | 17 | ) 18 | } 19 | 20 | export default CloseModal 21 | -------------------------------------------------------------------------------- /src/components/Providers.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { QueryClientProvider, QueryClient } from '@tanstack/react-query' 4 | import { SessionProvider } from 'next-auth/react' 5 | import { FC, ReactNode } from 'react' 6 | 7 | interface LayoutProps { 8 | children: ReactNode 9 | } 10 | 11 | const queryClient = new QueryClient() 12 | 13 | const Providers: FC = ({ children }) => { 14 | return ( 15 | 16 | {children} 17 | 18 | ) 19 | } 20 | 21 | export default Providers 22 | -------------------------------------------------------------------------------- /src/app/api/uploadthing/core.ts: -------------------------------------------------------------------------------- 1 | import { getToken } from 'next-auth/jwt' 2 | import { createUploadthing, type FileRouter } from 'uploadthing/next' 3 | 4 | const f = createUploadthing() 5 | 6 | export const ourFileRouter = { 7 | imageUploader: f({ image: { maxFileSize: '4MB' } }) 8 | .middleware(async (req) => { 9 | const user = await getToken({ req }) 10 | 11 | if (!user) throw new Error('Unauthorized') 12 | 13 | return { userId: user.id } 14 | }) 15 | .onUploadComplete(async ({ metadata, file }) => {}), 16 | } satisfies FileRouter 17 | 18 | export type OurFileRouter = typeof ourFileRouter 19 | -------------------------------------------------------------------------------- /src/app/@authModal/(.)sign-in/page.tsx: -------------------------------------------------------------------------------- 1 | import CloseModal from '@/components/CloseModal' 2 | import SignIn from '@/components/SignIn' 3 | import { FC } from 'react' 4 | 5 | const page: FC = () => { 6 | return ( 7 |
8 |
9 |
10 |
11 | 12 |
13 | 14 | 15 |
16 |
17 |
18 | ) 19 | } 20 | 21 | export default page 22 | -------------------------------------------------------------------------------- /src/components/homepage/GeneralFeed.tsx: -------------------------------------------------------------------------------- 1 | import { db } from '@/lib/db' 2 | import PostFeed from '../PostFeed' 3 | import { INFINITE_SCROLL_PAGINATION_RESULTS } from '@/config' 4 | 5 | const GeneralFeed = async () => { 6 | const posts = await db.post.findMany({ 7 | orderBy: { 8 | createdAt: 'desc', 9 | }, 10 | include: { 11 | votes: true, 12 | author: true, 13 | comments: true, 14 | subreddit: true, 15 | }, 16 | take: INFINITE_SCROLL_PAGINATION_RESULTS, // 4 to demonstrate infinite scroll, should be higher in production 17 | }) 18 | 19 | return 20 | } 21 | 22 | export default GeneralFeed 23 | -------------------------------------------------------------------------------- /src/hooks/use-custom-toasts.tsx: -------------------------------------------------------------------------------- 1 | import { buttonVariants } from '@/components/ui/Button' 2 | import { toast } from '@/hooks/use-toast' 3 | import Link from 'next/link' 4 | 5 | export const useCustomToasts = () => { 6 | const loginToast = () => { 7 | const { dismiss } = toast({ 8 | title: 'Login required.', 9 | description: 'You need to be logged in to do that.', 10 | variant: 'destructive', 11 | action: ( 12 | dismiss()} 14 | href='/sign-in' 15 | className={buttonVariants({ variant: 'outline' })}> 16 | Login 17 | 18 | ), 19 | }) 20 | } 21 | 22 | return { loginToast } 23 | } 24 | -------------------------------------------------------------------------------- /src/app/@authModal/(.)sign-up/page.tsx: -------------------------------------------------------------------------------- 1 | import CloseModal from '@/components/CloseModal' 2 | import SignUp from '@/components/SignUp' 3 | import { FC } from 'react' 4 | 5 | interface pageProps {} 6 | 7 | const page: FC = ({}) => { 8 | return ( 9 |
10 |
11 |
12 |
13 | 14 |
15 | 16 | 17 |
18 |
19 |
20 | ) 21 | } 22 | 23 | export default page 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./src/*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /src/app/(auth)/sign-in/page.tsx: -------------------------------------------------------------------------------- 1 | import SignIn from '@/components/SignIn' 2 | import { buttonVariants } from '@/components/ui/Button' 3 | import { cn } from '@/lib/utils' 4 | import { ChevronLeft } from 'lucide-react' 5 | import Link from 'next/link' 6 | import { FC } from 'react' 7 | 8 | const page: FC = () => { 9 | return ( 10 |
11 |
12 | 18 | 19 | Home 20 | 21 | 22 | 23 |
24 |
25 | ) 26 | } 27 | 28 | export default page 29 | -------------------------------------------------------------------------------- /src/components/ui/Label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /src/components/ui/Textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface TextareaProps 6 | extends React.TextareaHTMLAttributes {} 7 | 8 | const Textarea = React.forwardRef( 9 | ({ className, ...props }, ref) => { 10 | return ( 11 |