├── .eslintrc.json ├── .gitignore ├── README.md ├── components.json ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── prettier.config.js ├── prisma └── schema.prisma ├── public ├── file-text.svg ├── globe.svg ├── next.svg ├── vercel.svg └── window.svg ├── src ├── app │ ├── (auth) │ │ ├── actions.ts │ │ ├── layout.tsx │ │ ├── login │ │ │ ├── LoginForm.tsx │ │ │ ├── actions.ts │ │ │ ├── google │ │ │ │ ├── GoogleSignInButton.tsx │ │ │ │ └── route.ts │ │ │ └── page.tsx │ │ └── signup │ │ │ ├── SignUpForm.tsx │ │ │ ├── actions.ts │ │ │ └── page.tsx │ ├── (main) │ │ ├── FollowingFeed.tsx │ │ ├── ForYouFeed.tsx │ │ ├── MenuBar.tsx │ │ ├── MessagesButton.tsx │ │ ├── Navbar.tsx │ │ ├── NotificationsButton.tsx │ │ ├── SessionProvider.tsx │ │ ├── bookmarks │ │ │ ├── Bookmarks.tsx │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── loading.tsx │ │ ├── messages │ │ │ ├── Chat.tsx │ │ │ ├── ChatChannel.tsx │ │ │ ├── ChatSidebar.tsx │ │ │ ├── NewChatDialog.tsx │ │ │ ├── page.tsx │ │ │ └── useInitializeChatClient.ts │ │ ├── not-found.tsx │ │ ├── notifications │ │ │ ├── Notification.tsx │ │ │ ├── Notifications.tsx │ │ │ └── page.tsx │ │ ├── page.tsx │ │ ├── posts │ │ │ └── [postId] │ │ │ │ └── page.tsx │ │ ├── search │ │ │ ├── SearchResults.tsx │ │ │ └── page.tsx │ │ └── users │ │ │ └── [username] │ │ │ ├── EditProfileButton.tsx │ │ │ ├── EditProfileDialog.tsx │ │ │ ├── UserPosts.tsx │ │ │ ├── actions.ts │ │ │ ├── mutations.ts │ │ │ └── page.tsx │ ├── ReactQueryProvider.tsx │ ├── api │ │ ├── auth │ │ │ └── callback │ │ │ │ └── google │ │ │ │ └── route.ts │ │ ├── clear-uploads │ │ │ └── route.ts │ │ ├── get-token │ │ │ └── route.ts │ │ ├── messages │ │ │ └── unread-count │ │ │ │ └── route.ts │ │ ├── notifications │ │ │ ├── mark-as-read │ │ │ │ └── route.ts │ │ │ ├── route.ts │ │ │ └── unread-count │ │ │ │ └── route.ts │ │ ├── posts │ │ │ ├── [postId] │ │ │ │ ├── bookmark │ │ │ │ │ └── route.ts │ │ │ │ ├── comments │ │ │ │ │ └── route.ts │ │ │ │ └── likes │ │ │ │ │ └── route.ts │ │ │ ├── bookmarked │ │ │ │ └── route.ts │ │ │ ├── following │ │ │ │ └── route.ts │ │ │ └── for-you │ │ │ │ └── route.ts │ │ ├── search │ │ │ └── route.ts │ │ ├── uploadthing │ │ │ ├── core.ts │ │ │ └── route.ts │ │ └── users │ │ │ ├── [userId] │ │ │ ├── followers │ │ │ │ └── route.ts │ │ │ └── posts │ │ │ │ └── route.ts │ │ │ └── username │ │ │ └── [username] │ │ │ └── route.ts │ ├── favicon.ico │ ├── fonts │ │ ├── GeistMonoVF.woff │ │ └── GeistVF.woff │ ├── globals.css │ ├── layout.tsx │ └── loading.tsx ├── assets │ ├── avatar-placeholder.png │ ├── login-image.jpg │ └── signup-image.jpg ├── auth.ts ├── components │ ├── CropImageDialog.tsx │ ├── FollowButton.tsx │ ├── FollowerCount.tsx │ ├── InfiniteScrollContainer.tsx │ ├── Linkify.tsx │ ├── LoadingButton.tsx │ ├── PasswordInput.tsx │ ├── SearchField.tsx │ ├── TrendsSidebar.tsx │ ├── UserAvatar.tsx │ ├── UserButton.tsx │ ├── UserLinkWithTooltip.tsx │ ├── UserTooltip.tsx │ ├── comments │ │ ├── Comment.tsx │ │ ├── CommentInput.tsx │ │ ├── CommentMoreButton.tsx │ │ ├── Comments.tsx │ │ ├── DeleteCommentDialog.tsx │ │ ├── actions.ts │ │ └── mutations.ts │ ├── posts │ │ ├── BookmarkButton.tsx │ │ ├── DeletePostDialog.tsx │ │ ├── LikeButton.tsx │ │ ├── Post.tsx │ │ ├── PostMoreButton.tsx │ │ ├── PostsLoadingSkeleton.tsx │ │ ├── actions.ts │ │ ├── editor │ │ │ ├── PostEditor.tsx │ │ │ ├── actions.ts │ │ │ ├── mutations.ts │ │ │ ├── styles.css │ │ │ └── useMediaUpload.ts │ │ └── mutations.ts │ └── ui │ │ ├── button.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── skeleton.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toast.tsx │ │ ├── toaster.tsx │ │ ├── tooltip.tsx │ │ └── use-toast.ts ├── hooks │ ├── useDebounce.ts │ └── useFollowerInfo.ts └── lib │ ├── ky.ts │ ├── prisma.ts │ ├── stream.ts │ ├── types.ts │ ├── uploadthing.ts │ ├── utils.ts │ └── validation.ts ├── tailwind.config.ts ├── tsconfig.json └── vercel.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "prettier"] 3 | } 4 | -------------------------------------------------------------------------------- /.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 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # env files (can opt-in for commiting if needed) 29 | .env* 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next.js 15 Social Media App 2 | 3 | A full-stack social media app with infinite loading, optimistic updates, authentication, DMs, notifications, file uploads, and much more. 4 | 5 | Watch the free tutorial on YouTube: https://www.youtube.com/watch?v=TyV12oBDsYI 6 | 7 | ![thumbnail 7](https://github.com/user-attachments/assets/686b37e4-3d16-4bc4-a7f2-9d152c3addf5) 8 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | staleTimes: { 5 | dynamic: 30, 6 | }, 7 | }, 8 | serverExternalPackages: ["@node-rs/argon2"], 9 | images: { 10 | remotePatterns: [ 11 | { 12 | protocol: "https", 13 | hostname: "utfs.io", 14 | pathname: `/a/${process.env.NEXT_PUBLIC_UPLOADTHING_APP_ID}/*`, 15 | }, 16 | ], 17 | }, 18 | rewrites: () => { 19 | return [ 20 | { 21 | source: "/hashtag/:tag", 22 | destination: "/search?q=%23:tag", 23 | }, 24 | ]; 25 | }, 26 | }; 27 | 28 | export default nextConfig; 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs-15-social-media-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "postinstall": "prisma generate" 11 | }, 12 | "dependencies": { 13 | "@hookform/resolvers": "^3.9.0", 14 | "@lucia-auth/adapter-prisma": "^4.0.1", 15 | "@prisma/client": "^5.16.1", 16 | "@radix-ui/react-dialog": "^1.1.1", 17 | "@radix-ui/react-dropdown-menu": "^2.1.1", 18 | "@radix-ui/react-label": "^2.1.0", 19 | "@radix-ui/react-slot": "^1.1.0", 20 | "@radix-ui/react-tabs": "^1.1.0", 21 | "@radix-ui/react-toast": "^1.2.1", 22 | "@radix-ui/react-tooltip": "^1.1.2", 23 | "@tanstack/react-query": "^5.50.1", 24 | "@tanstack/react-query-devtools": "^5.50.1", 25 | "@tiptap/extension-placeholder": "^2.4.0", 26 | "@tiptap/pm": "^2.4.0", 27 | "@tiptap/react": "^2.4.0", 28 | "@tiptap/starter-kit": "^2.4.0", 29 | "@uploadthing/react": "^6.7.2", 30 | "arctic": "^1.9.1", 31 | "class-variance-authority": "^0.7.0", 32 | "clsx": "^2.1.1", 33 | "date-fns": "^3.6.0", 34 | "ky": "^1.4.0", 35 | "lucia": "^3.2.0", 36 | "lucide-react": "^0.402.0", 37 | "next": "15.0.0-rc.0", 38 | "next-themes": "^0.3.0", 39 | "prisma": "^5.16.1", 40 | "react": "19.0.0-rc-f994737d14-20240522", 41 | "react-cropper": "^2.3.3", 42 | "react-dom": "19.0.0-rc-f994737d14-20240522", 43 | "react-hook-form": "^7.52.1", 44 | "react-image-file-resizer": "^0.4.8", 45 | "react-intersection-observer": "^9.10.3", 46 | "react-linkify-it": "^1.0.8", 47 | "stream-chat": "^8.37.0", 48 | "stream-chat-react": "^11.23.0", 49 | "tailwind-merge": "^2.4.0", 50 | "tailwindcss-animate": "^1.0.7", 51 | "uploadthing": "^6.13.2", 52 | "zod": "^3.23.8" 53 | }, 54 | "devDependencies": { 55 | "@types/node": "^20", 56 | "@types/react": "^18", 57 | "@types/react-dom": "^18", 58 | "eslint": "^8", 59 | "eslint-config-next": "15.0.0-rc.0", 60 | "eslint-config-prettier": "^9.1.0", 61 | "postcss": "^8", 62 | "prettier": "^3.3.2", 63 | "prettier-plugin-tailwindcss": "^0.6.5", 64 | "tailwindcss": "^3.4.1", 65 | "typescript": "^5" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: ["prettier-plugin-tailwindcss"], 3 | }; 4 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | // Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? 5 | // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init 6 | 7 | generator client { 8 | provider = "prisma-client-js" 9 | previewFeatures = ["fullTextSearch"] 10 | } 11 | 12 | datasource db { 13 | provider = "postgresql" 14 | url = env("POSTGRES_PRISMA_URL") // uses connection pooling 15 | directUrl = env("POSTGRES_URL_NON_POOLING") // uses a direct connection 16 | } 17 | 18 | model User { 19 | id String @id 20 | username String @unique 21 | displayName String 22 | email String? @unique 23 | passwordHash String? 24 | googleId String? @unique 25 | avatarUrl String? 26 | bio String? 27 | sessions Session[] 28 | posts Post[] 29 | following Follow[] @relation("Following") 30 | followers Follow[] @relation("Followers") 31 | likes Like[] 32 | bookmarks Bookmark[] 33 | comments Comment[] 34 | receivedNotifications Notification[] @relation("Recipient") 35 | issuedNotifications Notification[] @relation("Issuer") 36 | 37 | createdAt DateTime @default(now()) 38 | 39 | @@map("users") 40 | } 41 | 42 | model Session { 43 | id String @id 44 | userId String 45 | expiresAt DateTime 46 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 47 | 48 | @@map("sessions") 49 | } 50 | 51 | model Follow { 52 | followerId String 53 | follower User @relation("Following", fields: [followerId], references: [id], onDelete: Cascade) 54 | followingId String 55 | following User @relation("Followers", fields: [followingId], references: [id], onDelete: Cascade) 56 | 57 | @@unique([followerId, followingId]) 58 | @@map("follows") 59 | } 60 | 61 | model Post { 62 | id String @id @default(cuid()) 63 | content String 64 | userId String 65 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 66 | attachments Media[] 67 | likes Like[] 68 | bookmarks Bookmark[] 69 | comments Comment[] 70 | linkedNotifications Notification[] 71 | 72 | createdAt DateTime @default(now()) 73 | 74 | @@map("posts") 75 | } 76 | 77 | model Media { 78 | id String @id @default(cuid()) 79 | postId String? 80 | post Post? @relation(fields: [postId], references: [id], onDelete: SetNull) 81 | type MediaType 82 | url String 83 | 84 | createdAt DateTime @default(now()) 85 | 86 | @@map("post_media") 87 | } 88 | 89 | enum MediaType { 90 | IMAGE 91 | VIDEO 92 | } 93 | 94 | model Comment { 95 | id String @id @default(cuid()) 96 | content String 97 | userId String 98 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 99 | postId String 100 | post Post @relation(fields: [postId], references: [id], onDelete: Cascade) 101 | 102 | createdAt DateTime @default(now()) 103 | 104 | @@map("comments") 105 | } 106 | 107 | model Like { 108 | userId String 109 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 110 | postId String 111 | post Post @relation(fields: [postId], references: [id], onDelete: Cascade) 112 | 113 | @@unique([userId, postId]) 114 | @@map("likes") 115 | } 116 | 117 | model Bookmark { 118 | id String @id @default(cuid()) 119 | userId String 120 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 121 | postId String 122 | post Post @relation(fields: [postId], references: [id], onDelete: Cascade) 123 | 124 | createdAt DateTime @default(now()) 125 | 126 | @@unique([userId, postId]) 127 | @@map("bookmarks") 128 | } 129 | 130 | model Notification { 131 | id String @id @default(cuid()) 132 | recipientId String 133 | recipient User @relation("Recipient", fields: [recipientId], references: [id], onDelete: Cascade) 134 | issuerId String 135 | issuer User @relation("Issuer", fields: [issuerId], references: [id], onDelete: Cascade) 136 | postId String? 137 | post Post? @relation(fields: [postId], references: [id], onDelete: Cascade) 138 | type NotificationType 139 | read Boolean @default(false) 140 | 141 | createdAt DateTime @default(now()) 142 | 143 | @@map("notifications") 144 | } 145 | 146 | enum NotificationType { 147 | LIKE 148 | FOLLOW 149 | COMMENT 150 | } 151 | -------------------------------------------------------------------------------- /public/file-text.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/app/(auth)/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { lucia, validateRequest } from "@/auth"; 4 | import { cookies } from "next/headers"; 5 | import { redirect } from "next/navigation"; 6 | 7 | export async function logout() { 8 | const { session } = await validateRequest(); 9 | 10 | if (!session) { 11 | throw new Error("Unauthorized"); 12 | } 13 | 14 | await lucia.invalidateSession(session.id); 15 | 16 | const sessionCookie = lucia.createBlankSessionCookie(); 17 | 18 | cookies().set( 19 | sessionCookie.name, 20 | sessionCookie.value, 21 | sessionCookie.attributes, 22 | ); 23 | 24 | return redirect("/login"); 25 | } 26 | -------------------------------------------------------------------------------- /src/app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { validateRequest } from "@/auth"; 2 | import { redirect } from "next/navigation"; 3 | 4 | export default async function Layout({ 5 | children, 6 | }: { 7 | children: React.ReactNode; 8 | }) { 9 | const { user } = await validateRequest(); 10 | 11 | if (user) redirect("/"); 12 | 13 | return <>{children}; 14 | } 15 | -------------------------------------------------------------------------------- /src/app/(auth)/login/LoginForm.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import LoadingButton from "@/components/LoadingButton"; 4 | import { PasswordInput } from "@/components/PasswordInput"; 5 | import { 6 | Form, 7 | FormControl, 8 | FormField, 9 | FormItem, 10 | FormLabel, 11 | FormMessage, 12 | } from "@/components/ui/form"; 13 | import { Input } from "@/components/ui/input"; 14 | import { loginSchema, LoginValues } from "@/lib/validation"; 15 | import { zodResolver } from "@hookform/resolvers/zod"; 16 | import { useState, useTransition } from "react"; 17 | import { useForm } from "react-hook-form"; 18 | import { login } from "./actions"; 19 | 20 | export default function LoginForm() { 21 | const [error, setError] = useState(); 22 | 23 | const [isPending, startTransition] = useTransition(); 24 | 25 | const form = useForm({ 26 | resolver: zodResolver(loginSchema), 27 | defaultValues: { 28 | username: "", 29 | password: "", 30 | }, 31 | }); 32 | 33 | async function onSubmit(values: LoginValues) { 34 | setError(undefined); 35 | startTransition(async () => { 36 | const { error } = await login(values); 37 | if (error) setError(error); 38 | }); 39 | } 40 | 41 | return ( 42 |
43 | 44 | {error &&

{error}

} 45 | ( 49 | 50 | Username 51 | 52 | 53 | 54 | 55 | 56 | )} 57 | /> 58 | ( 62 | 63 | Password 64 | 65 | 66 | 67 | 68 | 69 | )} 70 | /> 71 | 72 | Log in 73 | 74 | 75 | 76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /src/app/(auth)/login/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { lucia } from "@/auth"; 4 | import prisma from "@/lib/prisma"; 5 | import { loginSchema, LoginValues } from "@/lib/validation"; 6 | import { verify } from "@node-rs/argon2"; 7 | import { isRedirectError } from "next/dist/client/components/redirect"; 8 | import { cookies } from "next/headers"; 9 | import { redirect } from "next/navigation"; 10 | 11 | export async function login( 12 | credentials: LoginValues, 13 | ): Promise<{ error: string }> { 14 | try { 15 | const { username, password } = loginSchema.parse(credentials); 16 | 17 | const existingUser = await prisma.user.findFirst({ 18 | where: { 19 | username: { 20 | equals: username, 21 | mode: "insensitive", 22 | }, 23 | }, 24 | }); 25 | 26 | if (!existingUser || !existingUser.passwordHash) { 27 | return { 28 | error: "Incorrect username or password", 29 | }; 30 | } 31 | 32 | const validPassword = await verify(existingUser.passwordHash, password, { 33 | memoryCost: 19456, 34 | timeCost: 2, 35 | outputLen: 32, 36 | parallelism: 1, 37 | }); 38 | 39 | if (!validPassword) { 40 | return { 41 | error: "Incorrect username or password", 42 | }; 43 | } 44 | 45 | const session = await lucia.createSession(existingUser.id, {}); 46 | const sessionCookie = lucia.createSessionCookie(session.id); 47 | cookies().set( 48 | sessionCookie.name, 49 | sessionCookie.value, 50 | sessionCookie.attributes, 51 | ); 52 | 53 | return redirect("/"); 54 | } catch (error) { 55 | if (isRedirectError(error)) throw error; 56 | console.error(error); 57 | return { 58 | error: "Something went wrong. Please try again.", 59 | }; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/app/(auth)/login/google/GoogleSignInButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | 3 | export default function GoogleSignInButton() { 4 | return ( 5 | 15 | ); 16 | } 17 | 18 | function GoogleIcon() { 19 | return ( 20 | 26 | 30 | 34 | 38 | 42 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/app/(auth)/login/google/route.ts: -------------------------------------------------------------------------------- 1 | import { google } from "@/auth"; 2 | import { generateCodeVerifier, generateState } from "arctic"; 3 | import { cookies } from "next/headers"; 4 | 5 | export async function GET() { 6 | const state = generateState(); 7 | const codeVerifier = generateCodeVerifier(); 8 | 9 | const url = await google.createAuthorizationURL(state, codeVerifier, { 10 | scopes: ["profile", "email"], 11 | }); 12 | 13 | cookies().set("state", state, { 14 | path: "/", 15 | secure: process.env.NODE_ENV === "production", 16 | httpOnly: true, 17 | maxAge: 60 * 10, 18 | sameSite: "lax", 19 | }); 20 | 21 | cookies().set("code_verifier", codeVerifier, { 22 | path: "/", 23 | secure: process.env.NODE_ENV === "production", 24 | httpOnly: true, 25 | maxAge: 60 * 10, 26 | sameSite: "lax", 27 | }); 28 | 29 | return Response.redirect(url); 30 | } 31 | -------------------------------------------------------------------------------- /src/app/(auth)/login/page.tsx: -------------------------------------------------------------------------------- 1 | import loginImage from "@/assets/login-image.jpg"; 2 | import { Metadata } from "next"; 3 | import Image from "next/image"; 4 | import Link from "next/link"; 5 | import GoogleSignInButton from "./google/GoogleSignInButton"; 6 | import LoginForm from "./LoginForm"; 7 | 8 | export const metadata: Metadata = { 9 | title: "Login", 10 | }; 11 | 12 | export default function Page() { 13 | return ( 14 |
15 |
16 |
17 |

Login to bugbook

18 |
19 | 20 |
21 |
22 | OR 23 |
24 |
25 | 26 | 27 | Don't have an account? Sign up 28 | 29 |
30 |
31 | 36 |
37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/app/(auth)/signup/SignUpForm.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import LoadingButton from "@/components/LoadingButton"; 4 | import { PasswordInput } from "@/components/PasswordInput"; 5 | import { 6 | Form, 7 | FormControl, 8 | FormField, 9 | FormItem, 10 | FormLabel, 11 | FormMessage, 12 | } from "@/components/ui/form"; 13 | import { Input } from "@/components/ui/input"; 14 | import { signUpSchema, SignUpValues } from "@/lib/validation"; 15 | import { zodResolver } from "@hookform/resolvers/zod"; 16 | import { useState, useTransition } from "react"; 17 | import { useForm } from "react-hook-form"; 18 | import { signUp } from "./actions"; 19 | 20 | export default function SignUpForm() { 21 | const [error, setError] = useState(); 22 | 23 | const [isPending, startTransition] = useTransition(); 24 | 25 | const form = useForm({ 26 | resolver: zodResolver(signUpSchema), 27 | defaultValues: { 28 | email: "", 29 | username: "", 30 | password: "", 31 | }, 32 | }); 33 | 34 | async function onSubmit(values: SignUpValues) { 35 | setError(undefined); 36 | startTransition(async () => { 37 | const { error } = await signUp(values); 38 | if (error) setError(error); 39 | }); 40 | } 41 | 42 | return ( 43 |
44 | 45 | {error &&

{error}

} 46 | ( 50 | 51 | Username 52 | 53 | 54 | 55 | 56 | 57 | )} 58 | /> 59 | ( 63 | 64 | Email 65 | 66 | 67 | 68 | 69 | 70 | )} 71 | /> 72 | ( 76 | 77 | Password 78 | 79 | 80 | 81 | 82 | 83 | )} 84 | /> 85 | 86 | Create account 87 | 88 | 89 | 90 | ); 91 | } 92 | -------------------------------------------------------------------------------- /src/app/(auth)/signup/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { lucia } from "@/auth"; 4 | import prisma from "@/lib/prisma"; 5 | import streamServerClient from "@/lib/stream"; 6 | import { signUpSchema, SignUpValues } from "@/lib/validation"; 7 | import { hash } from "@node-rs/argon2"; 8 | import { generateIdFromEntropySize } from "lucia"; 9 | import { isRedirectError } from "next/dist/client/components/redirect"; 10 | import { cookies } from "next/headers"; 11 | import { redirect } from "next/navigation"; 12 | 13 | export async function signUp( 14 | credentials: SignUpValues, 15 | ): Promise<{ error: string }> { 16 | try { 17 | const { username, email, password } = signUpSchema.parse(credentials); 18 | 19 | const passwordHash = await hash(password, { 20 | memoryCost: 19456, 21 | timeCost: 2, 22 | outputLen: 32, 23 | parallelism: 1, 24 | }); 25 | 26 | const userId = generateIdFromEntropySize(10); 27 | 28 | const existingUsername = await prisma.user.findFirst({ 29 | where: { 30 | username: { 31 | equals: username, 32 | mode: "insensitive", 33 | }, 34 | }, 35 | }); 36 | 37 | if (existingUsername) { 38 | return { 39 | error: "Username already taken", 40 | }; 41 | } 42 | 43 | const existingEmail = await prisma.user.findFirst({ 44 | where: { 45 | email: { 46 | equals: email, 47 | mode: "insensitive", 48 | }, 49 | }, 50 | }); 51 | 52 | if (existingEmail) { 53 | return { 54 | error: "Email already taken", 55 | }; 56 | } 57 | 58 | await prisma.$transaction(async (tx) => { 59 | await tx.user.create({ 60 | data: { 61 | id: userId, 62 | username, 63 | displayName: username, 64 | email, 65 | passwordHash, 66 | }, 67 | }); 68 | await streamServerClient.upsertUser({ 69 | id: userId, 70 | username, 71 | name: username, 72 | }); 73 | }); 74 | 75 | const session = await lucia.createSession(userId, {}); 76 | const sessionCookie = lucia.createSessionCookie(session.id); 77 | cookies().set( 78 | sessionCookie.name, 79 | sessionCookie.value, 80 | sessionCookie.attributes, 81 | ); 82 | 83 | return redirect("/"); 84 | } catch (error) { 85 | if (isRedirectError(error)) throw error; 86 | console.error(error); 87 | return { 88 | error: "Something went wrong. Please try again.", 89 | }; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/app/(auth)/signup/page.tsx: -------------------------------------------------------------------------------- 1 | import signupImage from "@/assets/signup-image.jpg"; 2 | import { Metadata } from "next"; 3 | import Image from "next/image"; 4 | import Link from "next/link"; 5 | import SignUpForm from "./SignUpForm"; 6 | 7 | export const metadata: Metadata = { 8 | title: "Sign Up", 9 | }; 10 | 11 | export default function Page() { 12 | return ( 13 |
14 |
15 |
16 |
17 |

Sign up to bugbook

18 |

19 | A place where even you can find a 20 | friend. 21 |

22 |
23 |
24 | 25 | 26 | Already have an account? Log in 27 | 28 |
29 |
30 | 35 |
36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/app/(main)/FollowingFeed.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import InfiniteScrollContainer from "@/components/InfiniteScrollContainer"; 4 | import Post from "@/components/posts/Post"; 5 | import PostsLoadingSkeleton from "@/components/posts/PostsLoadingSkeleton"; 6 | import kyInstance from "@/lib/ky"; 7 | import { PostsPage } from "@/lib/types"; 8 | import { useInfiniteQuery } from "@tanstack/react-query"; 9 | import { Loader2 } from "lucide-react"; 10 | 11 | export default function FollowingFeed() { 12 | const { 13 | data, 14 | fetchNextPage, 15 | hasNextPage, 16 | isFetching, 17 | isFetchingNextPage, 18 | status, 19 | } = useInfiniteQuery({ 20 | queryKey: ["post-feed", "following"], 21 | queryFn: ({ pageParam }) => 22 | kyInstance 23 | .get( 24 | "/api/posts/following", 25 | pageParam ? { searchParams: { cursor: pageParam } } : {}, 26 | ) 27 | .json(), 28 | initialPageParam: null as string | null, 29 | getNextPageParam: (lastPage) => lastPage.nextCursor, 30 | }); 31 | 32 | const posts = data?.pages.flatMap((page) => page.posts) || []; 33 | 34 | if (status === "pending") { 35 | return ; 36 | } 37 | 38 | if (status === "success" && !posts.length && !hasNextPage) { 39 | return ( 40 |

41 | No posts found. Start following people to see their posts here. 42 |

43 | ); 44 | } 45 | 46 | if (status === "error") { 47 | return ( 48 |

49 | An error occurred while loading posts. 50 |

51 | ); 52 | } 53 | 54 | return ( 55 | hasNextPage && !isFetching && fetchNextPage()} 58 | > 59 | {posts.map((post) => ( 60 | 61 | ))} 62 | {isFetchingNextPage && } 63 | 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /src/app/(main)/ForYouFeed.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import InfiniteScrollContainer from "@/components/InfiniteScrollContainer"; 4 | import Post from "@/components/posts/Post"; 5 | import PostsLoadingSkeleton from "@/components/posts/PostsLoadingSkeleton"; 6 | import kyInstance from "@/lib/ky"; 7 | import { PostsPage } from "@/lib/types"; 8 | import { useInfiniteQuery } from "@tanstack/react-query"; 9 | import { Loader2 } from "lucide-react"; 10 | 11 | export default function ForYouFeed() { 12 | const { 13 | data, 14 | fetchNextPage, 15 | hasNextPage, 16 | isFetching, 17 | isFetchingNextPage, 18 | status, 19 | } = useInfiniteQuery({ 20 | queryKey: ["post-feed", "for-you"], 21 | queryFn: ({ pageParam }) => 22 | kyInstance 23 | .get( 24 | "/api/posts/for-you", 25 | pageParam ? { searchParams: { cursor: pageParam } } : {}, 26 | ) 27 | .json(), 28 | initialPageParam: null as string | null, 29 | getNextPageParam: (lastPage) => lastPage.nextCursor, 30 | }); 31 | 32 | const posts = data?.pages.flatMap((page) => page.posts) || []; 33 | 34 | if (status === "pending") { 35 | return ; 36 | } 37 | 38 | if (status === "success" && !posts.length && !hasNextPage) { 39 | return ( 40 |

41 | No one has posted anything yet. 42 |

43 | ); 44 | } 45 | 46 | if (status === "error") { 47 | return ( 48 |

49 | An error occurred while loading posts. 50 |

51 | ); 52 | } 53 | 54 | return ( 55 | hasNextPage && !isFetching && fetchNextPage()} 58 | > 59 | {posts.map((post) => ( 60 | 61 | ))} 62 | {isFetchingNextPage && } 63 | 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /src/app/(main)/MenuBar.tsx: -------------------------------------------------------------------------------- 1 | import { validateRequest } from "@/auth"; 2 | import { Button } from "@/components/ui/button"; 3 | import prisma from "@/lib/prisma"; 4 | import streamServerClient from "@/lib/stream"; 5 | import { Bookmark, Home } from "lucide-react"; 6 | import Link from "next/link"; 7 | import MessagesButton from "./MessagesButton"; 8 | import NotificationsButton from "./NotificationsButton"; 9 | 10 | interface MenuBarProps { 11 | className?: string; 12 | } 13 | 14 | export default async function MenuBar({ className }: MenuBarProps) { 15 | const { user } = await validateRequest(); 16 | 17 | if (!user) return null; 18 | 19 | const [unreadNotificationsCount, unreadMessagesCount] = await Promise.all([ 20 | prisma.notification.count({ 21 | where: { 22 | recipientId: user.id, 23 | read: false, 24 | }, 25 | }), 26 | (await streamServerClient.getUnreadCount(user.id)).total_unread_count, 27 | ]); 28 | 29 | return ( 30 |
31 | 42 | 45 | 46 | 57 |
58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /src/app/(main)/MessagesButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import kyInstance from "@/lib/ky"; 5 | import { MessageCountInfo } from "@/lib/types"; 6 | import { useQuery } from "@tanstack/react-query"; 7 | import { Mail } from "lucide-react"; 8 | import Link from "next/link"; 9 | 10 | interface MessagesButtonProps { 11 | initialState: MessageCountInfo; 12 | } 13 | 14 | export default function MessagesButton({ initialState }: MessagesButtonProps) { 15 | const { data } = useQuery({ 16 | queryKey: ["unread-messages-count"], 17 | queryFn: () => 18 | kyInstance.get("/api/messages/unread-count").json(), 19 | initialData: initialState, 20 | refetchInterval: 60 * 1000, 21 | }); 22 | 23 | return ( 24 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/app/(main)/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import SearchField from "@/components/SearchField"; 2 | import UserButton from "@/components/UserButton"; 3 | import Link from "next/link"; 4 | 5 | export default function Navbar() { 6 | return ( 7 |
8 |
9 | 10 | bugbook 11 | 12 | 13 | 14 |
15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/app/(main)/NotificationsButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import kyInstance from "@/lib/ky"; 5 | import { NotificationCountInfo } from "@/lib/types"; 6 | import { useQuery } from "@tanstack/react-query"; 7 | import { Bell } from "lucide-react"; 8 | import Link from "next/link"; 9 | 10 | interface NotificationsButtonProps { 11 | initialState: NotificationCountInfo; 12 | } 13 | 14 | export default function NotificationsButton({ 15 | initialState, 16 | }: NotificationsButtonProps) { 17 | const { data } = useQuery({ 18 | queryKey: ["unread-notification-count"], 19 | queryFn: () => 20 | kyInstance 21 | .get("/api/notifications/unread-count") 22 | .json(), 23 | initialData: initialState, 24 | refetchInterval: 60 * 1000, 25 | }); 26 | 27 | return ( 28 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/app/(main)/SessionProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Session, User } from "lucia"; 4 | import React, { createContext, useContext } from "react"; 5 | 6 | interface SessionContext { 7 | user: User; 8 | session: Session; 9 | } 10 | 11 | const SessionContext = createContext(null); 12 | 13 | export default function SessionProvider({ 14 | children, 15 | value, 16 | }: React.PropsWithChildren<{ value: SessionContext }>) { 17 | return ( 18 | {children} 19 | ); 20 | } 21 | 22 | export function useSession() { 23 | const context = useContext(SessionContext); 24 | if (!context) { 25 | throw new Error("useSession must be used within a SessionProvider"); 26 | } 27 | return context; 28 | } 29 | -------------------------------------------------------------------------------- /src/app/(main)/bookmarks/Bookmarks.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import InfiniteScrollContainer from "@/components/InfiniteScrollContainer"; 4 | import Post from "@/components/posts/Post"; 5 | import PostsLoadingSkeleton from "@/components/posts/PostsLoadingSkeleton"; 6 | import kyInstance from "@/lib/ky"; 7 | import { PostsPage } from "@/lib/types"; 8 | import { useInfiniteQuery } from "@tanstack/react-query"; 9 | import { Loader2 } from "lucide-react"; 10 | 11 | export default function Bookmarks() { 12 | const { 13 | data, 14 | fetchNextPage, 15 | hasNextPage, 16 | isFetching, 17 | isFetchingNextPage, 18 | status, 19 | } = useInfiniteQuery({ 20 | queryKey: ["post-feed", "bookmarks"], 21 | queryFn: ({ pageParam }) => 22 | kyInstance 23 | .get( 24 | "/api/posts/bookmarked", 25 | pageParam ? { searchParams: { cursor: pageParam } } : {}, 26 | ) 27 | .json(), 28 | initialPageParam: null as string | null, 29 | getNextPageParam: (lastPage) => lastPage.nextCursor, 30 | }); 31 | 32 | const posts = data?.pages.flatMap((page) => page.posts) || []; 33 | 34 | if (status === "pending") { 35 | return ; 36 | } 37 | 38 | if (status === "success" && !posts.length && !hasNextPage) { 39 | return ( 40 |

41 | You don't have any bookmarks yet. 42 |

43 | ); 44 | } 45 | 46 | if (status === "error") { 47 | return ( 48 |

49 | An error occurred while loading bookmarks. 50 |

51 | ); 52 | } 53 | 54 | return ( 55 | hasNextPage && !isFetching && fetchNextPage()} 58 | > 59 | {posts.map((post) => ( 60 | 61 | ))} 62 | {isFetchingNextPage && } 63 | 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /src/app/(main)/bookmarks/page.tsx: -------------------------------------------------------------------------------- 1 | import TrendsSidebar from "@/components/TrendsSidebar"; 2 | import { Metadata } from "next"; 3 | import Bookmarks from "./Bookmarks"; 4 | 5 | export const metadata: Metadata = { 6 | title: "Bookmarks", 7 | }; 8 | 9 | export default function Page() { 10 | return ( 11 |
12 |
13 |
14 |

Bookmarks

15 |
16 | 17 |
18 | 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/app/(main)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { validateRequest } from "@/auth"; 2 | import { redirect } from "next/navigation"; 3 | import MenuBar from "./MenuBar"; 4 | import Navbar from "./Navbar"; 5 | import SessionProvider from "./SessionProvider"; 6 | 7 | export default async function Layout({ 8 | children, 9 | }: { 10 | children: React.ReactNode; 11 | }) { 12 | const session = await validateRequest(); 13 | 14 | if (!session.user) redirect("/login"); 15 | 16 | return ( 17 | 18 |
19 | 20 |
21 | 22 | {children} 23 |
24 | 25 |
26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/app/(main)/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Loader2 } from "lucide-react"; 2 | 3 | export default function Loading() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/(main)/messages/Chat.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Loader2 } from "lucide-react"; 4 | import { useTheme } from "next-themes"; 5 | import { useState } from "react"; 6 | import { Chat as StreamChat } from "stream-chat-react"; 7 | import ChatChannel from "./ChatChannel"; 8 | import ChatSidebar from "./ChatSidebar"; 9 | import useInitializeChatClient from "./useInitializeChatClient"; 10 | 11 | export default function Chat() { 12 | const chatClient = useInitializeChatClient(); 13 | 14 | const { resolvedTheme } = useTheme(); 15 | 16 | const [sidebarOpen, setSidebarOpen] = useState(false); 17 | 18 | if (!chatClient) { 19 | return ; 20 | } 21 | 22 | return ( 23 |
24 |
25 | 33 | setSidebarOpen(false)} 36 | /> 37 | setSidebarOpen(true)} 40 | /> 41 | 42 |
43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/app/(main)/messages/ChatChannel.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { cn } from "@/lib/utils"; 3 | import { Menu } from "lucide-react"; 4 | import { 5 | Channel, 6 | ChannelHeader, 7 | ChannelHeaderProps, 8 | MessageInput, 9 | MessageList, 10 | Window, 11 | } from "stream-chat-react"; 12 | 13 | interface ChatChannelProps { 14 | open: boolean; 15 | openSidebar: () => void; 16 | } 17 | 18 | export default function ChatChannel({ open, openSidebar }: ChatChannelProps) { 19 | return ( 20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | ); 30 | } 31 | 32 | interface CustomChannelHeaderProps extends ChannelHeaderProps { 33 | openSidebar: () => void; 34 | } 35 | 36 | function CustomChannelHeader({ 37 | openSidebar, 38 | ...props 39 | }: CustomChannelHeaderProps) { 40 | return ( 41 |
42 |
43 | 46 |
47 | 48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /src/app/(main)/messages/ChatSidebar.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { cn } from "@/lib/utils"; 3 | import { useQueryClient } from "@tanstack/react-query"; 4 | import { MailPlus, X } from "lucide-react"; 5 | import { useCallback, useEffect, useState } from "react"; 6 | import { 7 | ChannelList, 8 | ChannelPreviewMessenger, 9 | ChannelPreviewUIComponentProps, 10 | useChatContext, 11 | } from "stream-chat-react"; 12 | import { useSession } from "../SessionProvider"; 13 | import NewChatDialog from "./NewChatDialog"; 14 | 15 | interface ChatSidebarProps { 16 | open: boolean; 17 | onClose: () => void; 18 | } 19 | 20 | export default function ChatSidebar({ open, onClose }: ChatSidebarProps) { 21 | const { user } = useSession(); 22 | 23 | const queryClient = useQueryClient(); 24 | 25 | const { channel } = useChatContext(); 26 | 27 | useEffect(() => { 28 | if (channel?.id) { 29 | queryClient.invalidateQueries({ queryKey: ["unread-messages-count"] }); 30 | } 31 | }, [channel?.id, queryClient]); 32 | 33 | const ChannelPreviewCustom = useCallback( 34 | (props: ChannelPreviewUIComponentProps) => ( 35 | { 38 | props.setActiveChannel?.(props.channel, props.watchers); 39 | onClose(); 40 | }} 41 | /> 42 | ), 43 | [onClose], 44 | ); 45 | 46 | return ( 47 |
53 | 54 | 72 |
73 | ); 74 | } 75 | 76 | interface MenuHeaderProps { 77 | onClose: () => void; 78 | } 79 | 80 | function MenuHeader({ onClose }: MenuHeaderProps) { 81 | const [showNewChatDialog, setShowNewChatDialog] = useState(false); 82 | 83 | return ( 84 | <> 85 |
86 |
87 | 90 |
91 |

Messages

92 | 100 |
101 | {showNewChatDialog && ( 102 | { 105 | setShowNewChatDialog(false); 106 | onClose(); 107 | }} 108 | /> 109 | )} 110 | 111 | ); 112 | } 113 | -------------------------------------------------------------------------------- /src/app/(main)/messages/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from "next"; 2 | import Chat from "./Chat"; 3 | 4 | export const metadata: Metadata = { 5 | title: "Messages", 6 | }; 7 | 8 | export default function Page() { 9 | return ; 10 | } 11 | -------------------------------------------------------------------------------- /src/app/(main)/messages/useInitializeChatClient.ts: -------------------------------------------------------------------------------- 1 | import kyInstance from "@/lib/ky"; 2 | import { useEffect, useState } from "react"; 3 | import { StreamChat } from "stream-chat"; 4 | import { useSession } from "../SessionProvider"; 5 | 6 | export default function useInitializeChatClient() { 7 | const { user } = useSession(); 8 | const [chatClient, setChatClient] = useState(null); 9 | 10 | useEffect(() => { 11 | const client = StreamChat.getInstance(process.env.NEXT_PUBLIC_STREAM_KEY!); 12 | 13 | client 14 | .connectUser( 15 | { 16 | id: user.id, 17 | username: user.username, 18 | name: user.displayName, 19 | image: user.avatarUrl, 20 | }, 21 | async () => 22 | kyInstance 23 | .get("/api/get-token") 24 | .json<{ token: string }>() 25 | .then((data) => data.token), 26 | ) 27 | .catch((error) => console.error("Failed to connect user", error)) 28 | .then(() => setChatClient(client)); 29 | 30 | return () => { 31 | setChatClient(null); 32 | client 33 | .disconnectUser() 34 | .catch((error) => console.error("Failed to disconnect user", error)) 35 | .then(() => console.log("Connection closed")); 36 | }; 37 | }, [user.id, user.username, user.displayName, user.avatarUrl]); 38 | 39 | return chatClient; 40 | } 41 | -------------------------------------------------------------------------------- /src/app/(main)/not-found.tsx: -------------------------------------------------------------------------------- 1 | export default function NotFound() { 2 | return ( 3 |
4 |

Not Found

5 |

The page you are looking for does not exist.

6 |
7 | ); 8 | } 9 | -------------------------------------------------------------------------------- /src/app/(main)/notifications/Notification.tsx: -------------------------------------------------------------------------------- 1 | import UserAvatar from "@/components/UserAvatar"; 2 | import { NotificationData } from "@/lib/types"; 3 | import { cn } from "@/lib/utils"; 4 | import { NotificationType } from "@prisma/client"; 5 | import { Heart, MessageCircle, User2 } from "lucide-react"; 6 | import Link from "next/link"; 7 | 8 | interface NotificationProps { 9 | notification: NotificationData; 10 | } 11 | 12 | export default function Notification({ notification }: NotificationProps) { 13 | const notificationTypeMap: Record< 14 | NotificationType, 15 | { message: string; icon: JSX.Element; href: string } 16 | > = { 17 | FOLLOW: { 18 | message: `${notification.issuer.displayName} followed you`, 19 | icon: , 20 | href: `/users/${notification.issuer.username}`, 21 | }, 22 | COMMENT: { 23 | message: `${notification.issuer.displayName} commented on your post`, 24 | icon: , 25 | href: `/posts/${notification.postId}`, 26 | }, 27 | LIKE: { 28 | message: `${notification.issuer.displayName} liked your post`, 29 | icon: , 30 | href: `/posts/${notification.postId}`, 31 | }, 32 | }; 33 | 34 | const { message, icon, href } = notificationTypeMap[notification.type]; 35 | 36 | return ( 37 | 38 |
44 |
{icon}
45 |
46 | 47 |
48 | {notification.issuer.displayName}{" "} 49 | {message} 50 |
51 | {notification.post && ( 52 |
53 | {notification.post.content} 54 |
55 | )} 56 |
57 |
58 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /src/app/(main)/notifications/Notifications.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import InfiniteScrollContainer from "@/components/InfiniteScrollContainer"; 4 | import PostsLoadingSkeleton from "@/components/posts/PostsLoadingSkeleton"; 5 | import kyInstance from "@/lib/ky"; 6 | import { NotificationsPage } from "@/lib/types"; 7 | import { 8 | useInfiniteQuery, 9 | useMutation, 10 | useQueryClient, 11 | } from "@tanstack/react-query"; 12 | import { Loader2 } from "lucide-react"; 13 | import { useEffect } from "react"; 14 | import Notification from "./Notification"; 15 | 16 | export default function Notifications() { 17 | const { 18 | data, 19 | fetchNextPage, 20 | hasNextPage, 21 | isFetching, 22 | isFetchingNextPage, 23 | status, 24 | } = useInfiniteQuery({ 25 | queryKey: ["notifications"], 26 | queryFn: ({ pageParam }) => 27 | kyInstance 28 | .get( 29 | "/api/notifications", 30 | pageParam ? { searchParams: { cursor: pageParam } } : {}, 31 | ) 32 | .json(), 33 | initialPageParam: null as string | null, 34 | getNextPageParam: (lastPage) => lastPage.nextCursor, 35 | }); 36 | 37 | const queryClient = useQueryClient(); 38 | 39 | const { mutate } = useMutation({ 40 | mutationFn: () => kyInstance.patch("/api/notifications/mark-as-read"), 41 | onSuccess: () => { 42 | queryClient.setQueryData(["unread-notification-count"], { 43 | unreadCount: 0, 44 | }); 45 | }, 46 | onError(error) { 47 | console.error("Failed to mark notifications as read", error); 48 | }, 49 | }); 50 | 51 | useEffect(() => { 52 | mutate(); 53 | }, [mutate]); 54 | 55 | const notifications = data?.pages.flatMap((page) => page.notifications) || []; 56 | 57 | if (status === "pending") { 58 | return ; 59 | } 60 | 61 | if (status === "success" && !notifications.length && !hasNextPage) { 62 | return ( 63 |

64 | You don't have any notifications yet. 65 |

66 | ); 67 | } 68 | 69 | if (status === "error") { 70 | return ( 71 |

72 | An error occurred while loading notifications. 73 |

74 | ); 75 | } 76 | 77 | return ( 78 | hasNextPage && !isFetching && fetchNextPage()} 81 | > 82 | {notifications.map((notification) => ( 83 | 84 | ))} 85 | {isFetchingNextPage && } 86 | 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /src/app/(main)/notifications/page.tsx: -------------------------------------------------------------------------------- 1 | import TrendsSidebar from "@/components/TrendsSidebar"; 2 | import { Metadata } from "next"; 3 | import Notifications from "./Notifications"; 4 | 5 | export const metadata: Metadata = { 6 | title: "Notifications", 7 | }; 8 | 9 | export default function Page() { 10 | return ( 11 |
12 |
13 |
14 |

Notifications

15 |
16 | 17 |
18 | 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/app/(main)/page.tsx: -------------------------------------------------------------------------------- 1 | import PostEditor from "@/components/posts/editor/PostEditor"; 2 | import TrendsSidebar from "@/components/TrendsSidebar"; 3 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 4 | import FollowingFeed from "./FollowingFeed"; 5 | import ForYouFeed from "./ForYouFeed"; 6 | 7 | export default function Home() { 8 | return ( 9 |
10 |
11 | 12 | 13 | 14 | For you 15 | Following 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/app/(main)/posts/[postId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { validateRequest } from "@/auth"; 2 | import FollowButton from "@/components/FollowButton"; 3 | import Linkify from "@/components/Linkify"; 4 | import Post from "@/components/posts/Post"; 5 | import UserAvatar from "@/components/UserAvatar"; 6 | import UserTooltip from "@/components/UserTooltip"; 7 | import prisma from "@/lib/prisma"; 8 | import { getPostDataInclude, UserData } from "@/lib/types"; 9 | import { Loader2 } from "lucide-react"; 10 | import { Metadata } from "next"; 11 | import Link from "next/link"; 12 | import { notFound } from "next/navigation"; 13 | import { cache, Suspense } from "react"; 14 | 15 | interface PageProps { 16 | params: { postId: string }; 17 | } 18 | 19 | const getPost = cache(async (postId: string, loggedInUserId: string) => { 20 | const post = await prisma.post.findUnique({ 21 | where: { 22 | id: postId, 23 | }, 24 | include: getPostDataInclude(loggedInUserId), 25 | }); 26 | 27 | if (!post) notFound(); 28 | 29 | return post; 30 | }); 31 | 32 | export async function generateMetadata({ 33 | params: { postId }, 34 | }: PageProps): Promise { 35 | const { user } = await validateRequest(); 36 | 37 | if (!user) return {}; 38 | 39 | const post = await getPost(postId, user.id); 40 | 41 | return { 42 | title: `${post.user.displayName}: ${post.content.slice(0, 50)}...`, 43 | }; 44 | } 45 | 46 | export default async function Page({ params: { postId } }: PageProps) { 47 | const { user } = await validateRequest(); 48 | 49 | if (!user) { 50 | return ( 51 |

52 | You're not authorized to view this page. 53 |

54 | ); 55 | } 56 | 57 | const post = await getPost(postId, user.id); 58 | 59 | return ( 60 |
61 |
62 | 63 |
64 |
65 | }> 66 | 67 | 68 |
69 |
70 | ); 71 | } 72 | 73 | interface UserInfoSidebarProps { 74 | user: UserData; 75 | } 76 | 77 | async function UserInfoSidebar({ user }: UserInfoSidebarProps) { 78 | const { user: loggedInUser } = await validateRequest(); 79 | 80 | if (!loggedInUser) return null; 81 | 82 | return ( 83 |
84 |
About this user
85 | 86 | 90 | 91 |
92 |

93 | {user.displayName} 94 |

95 |

96 | @{user.username} 97 |

98 |
99 | 100 |
101 | 102 |
103 | {user.bio} 104 |
105 |
106 | {user.id !== loggedInUser.id && ( 107 | followerId === loggedInUser.id, 113 | ), 114 | }} 115 | /> 116 | )} 117 |
118 | ); 119 | } 120 | -------------------------------------------------------------------------------- /src/app/(main)/search/SearchResults.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import InfiniteScrollContainer from "@/components/InfiniteScrollContainer"; 4 | import Post from "@/components/posts/Post"; 5 | import PostsLoadingSkeleton from "@/components/posts/PostsLoadingSkeleton"; 6 | import kyInstance from "@/lib/ky"; 7 | import { PostsPage } from "@/lib/types"; 8 | import { useInfiniteQuery } from "@tanstack/react-query"; 9 | import { Loader2 } from "lucide-react"; 10 | 11 | interface SearchResultsProps { 12 | query: string; 13 | } 14 | 15 | export default function SearchResults({ query }: SearchResultsProps) { 16 | const { 17 | data, 18 | fetchNextPage, 19 | hasNextPage, 20 | isFetching, 21 | isFetchingNextPage, 22 | status, 23 | } = useInfiniteQuery({ 24 | queryKey: ["post-feed", "search", query], 25 | queryFn: ({ pageParam }) => 26 | kyInstance 27 | .get("/api/search", { 28 | searchParams: { 29 | q: query, 30 | ...(pageParam ? { cursor: pageParam } : {}), 31 | }, 32 | }) 33 | .json(), 34 | initialPageParam: null as string | null, 35 | getNextPageParam: (lastPage) => lastPage.nextCursor, 36 | gcTime: 0, 37 | }); 38 | 39 | const posts = data?.pages.flatMap((page) => page.posts) || []; 40 | 41 | if (status === "pending") { 42 | return ; 43 | } 44 | 45 | if (status === "success" && !posts.length && !hasNextPage) { 46 | return ( 47 |

48 | No posts found for this query. 49 |

50 | ); 51 | } 52 | 53 | if (status === "error") { 54 | return ( 55 |

56 | An error occurred while loading posts. 57 |

58 | ); 59 | } 60 | 61 | return ( 62 | hasNextPage && !isFetching && fetchNextPage()} 65 | > 66 | {posts.map((post) => ( 67 | 68 | ))} 69 | {isFetchingNextPage && } 70 | 71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /src/app/(main)/search/page.tsx: -------------------------------------------------------------------------------- 1 | import TrendsSidebar from "@/components/TrendsSidebar"; 2 | import { Metadata } from "next"; 3 | import SearchResults from "./SearchResults"; 4 | 5 | interface PageProps { 6 | searchParams: { q: string }; 7 | } 8 | 9 | export function generateMetadata({ searchParams: { q } }: PageProps): Metadata { 10 | return { 11 | title: `Search results for "${q}"`, 12 | }; 13 | } 14 | 15 | export default function Page({ searchParams: { q } }: PageProps) { 16 | return ( 17 |
18 |
19 |
20 |

21 | Search results for "{q}" 22 |

23 |
24 | 25 |
26 | 27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/app/(main)/users/[username]/EditProfileButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { UserData } from "@/lib/types"; 5 | import { useState } from "react"; 6 | import EditProfileDialog from "./EditProfileDialog"; 7 | 8 | interface EditProfileButtonProps { 9 | user: UserData; 10 | } 11 | 12 | export default function EditProfileButton({ user }: EditProfileButtonProps) { 13 | const [showDialog, setShowDialog] = useState(false); 14 | 15 | return ( 16 | <> 17 | 20 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/app/(main)/users/[username]/UserPosts.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import InfiniteScrollContainer from "@/components/InfiniteScrollContainer"; 4 | import Post from "@/components/posts/Post"; 5 | import PostsLoadingSkeleton from "@/components/posts/PostsLoadingSkeleton"; 6 | import kyInstance from "@/lib/ky"; 7 | import { PostsPage } from "@/lib/types"; 8 | import { useInfiniteQuery } from "@tanstack/react-query"; 9 | import { Loader2 } from "lucide-react"; 10 | 11 | interface UserPostsProps { 12 | userId: string; 13 | } 14 | 15 | export default function UserPosts({ userId }: UserPostsProps) { 16 | const { 17 | data, 18 | fetchNextPage, 19 | hasNextPage, 20 | isFetching, 21 | isFetchingNextPage, 22 | status, 23 | } = useInfiniteQuery({ 24 | queryKey: ["post-feed", "user-posts", userId], 25 | queryFn: ({ pageParam }) => 26 | kyInstance 27 | .get( 28 | `/api/users/${userId}/posts`, 29 | pageParam ? { searchParams: { cursor: pageParam } } : {}, 30 | ) 31 | .json(), 32 | initialPageParam: null as string | null, 33 | getNextPageParam: (lastPage) => lastPage.nextCursor, 34 | }); 35 | 36 | const posts = data?.pages.flatMap((page) => page.posts) || []; 37 | 38 | if (status === "pending") { 39 | return ; 40 | } 41 | 42 | if (status === "success" && !posts.length && !hasNextPage) { 43 | return ( 44 |

45 | This user hasn't posted anything yet. 46 |

47 | ); 48 | } 49 | 50 | if (status === "error") { 51 | return ( 52 |

53 | An error occurred while loading posts. 54 |

55 | ); 56 | } 57 | 58 | return ( 59 | hasNextPage && !isFetching && fetchNextPage()} 62 | > 63 | {posts.map((post) => ( 64 | 65 | ))} 66 | {isFetchingNextPage && } 67 | 68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /src/app/(main)/users/[username]/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { validateRequest } from "@/auth"; 4 | import prisma from "@/lib/prisma"; 5 | import streamServerClient from "@/lib/stream"; 6 | import { getUserDataSelect } from "@/lib/types"; 7 | import { 8 | updateUserProfileSchema, 9 | UpdateUserProfileValues, 10 | } from "@/lib/validation"; 11 | 12 | export async function updateUserProfile(values: UpdateUserProfileValues) { 13 | const validatedValues = updateUserProfileSchema.parse(values); 14 | 15 | const { user } = await validateRequest(); 16 | 17 | if (!user) throw new Error("Unauthorized"); 18 | 19 | const updatedUser = await prisma.$transaction(async (tx) => { 20 | const updatedUser = await tx.user.update({ 21 | where: { id: user.id }, 22 | data: validatedValues, 23 | select: getUserDataSelect(user.id), 24 | }); 25 | await streamServerClient.partialUpdateUser({ 26 | id: user.id, 27 | set: { 28 | name: validatedValues.displayName, 29 | }, 30 | }); 31 | return updatedUser; 32 | }); 33 | 34 | return updatedUser; 35 | } 36 | -------------------------------------------------------------------------------- /src/app/(main)/users/[username]/mutations.ts: -------------------------------------------------------------------------------- 1 | import { useToast } from "@/components/ui/use-toast"; 2 | import { PostsPage } from "@/lib/types"; 3 | import { useUploadThing } from "@/lib/uploadthing"; 4 | import { UpdateUserProfileValues } from "@/lib/validation"; 5 | import { 6 | InfiniteData, 7 | QueryFilters, 8 | useMutation, 9 | useQueryClient, 10 | } from "@tanstack/react-query"; 11 | import { useRouter } from "next/navigation"; 12 | import { updateUserProfile } from "./actions"; 13 | 14 | export function useUpdateProfileMutation() { 15 | const { toast } = useToast(); 16 | 17 | const router = useRouter(); 18 | 19 | const queryClient = useQueryClient(); 20 | 21 | const { startUpload: startAvatarUpload } = useUploadThing("avatar"); 22 | 23 | const mutation = useMutation({ 24 | mutationFn: async ({ 25 | values, 26 | avatar, 27 | }: { 28 | values: UpdateUserProfileValues; 29 | avatar?: File; 30 | }) => { 31 | return Promise.all([ 32 | updateUserProfile(values), 33 | avatar && startAvatarUpload([avatar]), 34 | ]); 35 | }, 36 | onSuccess: async ([updatedUser, uploadResult]) => { 37 | const newAvatarUrl = uploadResult?.[0].serverData.avatarUrl; 38 | 39 | const queryFilter: QueryFilters = { 40 | queryKey: ["post-feed"], 41 | }; 42 | 43 | await queryClient.cancelQueries(queryFilter); 44 | 45 | queryClient.setQueriesData>( 46 | queryFilter, 47 | (oldData) => { 48 | if (!oldData) return; 49 | 50 | return { 51 | pageParams: oldData.pageParams, 52 | pages: oldData.pages.map((page) => ({ 53 | nextCursor: page.nextCursor, 54 | posts: page.posts.map((post) => { 55 | if (post.user.id === updatedUser.id) { 56 | return { 57 | ...post, 58 | user: { 59 | ...updatedUser, 60 | avatarUrl: newAvatarUrl || updatedUser.avatarUrl, 61 | }, 62 | }; 63 | } 64 | return post; 65 | }), 66 | })), 67 | }; 68 | }, 69 | ); 70 | 71 | router.refresh(); 72 | 73 | toast({ 74 | description: "Profile updated", 75 | }); 76 | }, 77 | onError(error) { 78 | console.error(error); 79 | toast({ 80 | variant: "destructive", 81 | description: "Failed to update profile. Please try again.", 82 | }); 83 | }, 84 | }); 85 | 86 | return mutation; 87 | } 88 | -------------------------------------------------------------------------------- /src/app/(main)/users/[username]/page.tsx: -------------------------------------------------------------------------------- 1 | import { validateRequest } from "@/auth"; 2 | import FollowButton from "@/components/FollowButton"; 3 | import FollowerCount from "@/components/FollowerCount"; 4 | import Linkify from "@/components/Linkify"; 5 | import TrendsSidebar from "@/components/TrendsSidebar"; 6 | import UserAvatar from "@/components/UserAvatar"; 7 | import prisma from "@/lib/prisma"; 8 | import { FollowerInfo, getUserDataSelect, UserData } from "@/lib/types"; 9 | import { formatNumber } from "@/lib/utils"; 10 | import { formatDate } from "date-fns"; 11 | import { Metadata } from "next"; 12 | import { notFound } from "next/navigation"; 13 | import { cache } from "react"; 14 | import EditProfileButton from "./EditProfileButton"; 15 | import UserPosts from "./UserPosts"; 16 | 17 | interface PageProps { 18 | params: { username: string }; 19 | } 20 | 21 | const getUser = cache(async (username: string, loggedInUserId: string) => { 22 | const user = await prisma.user.findFirst({ 23 | where: { 24 | username: { 25 | equals: username, 26 | mode: "insensitive", 27 | }, 28 | }, 29 | select: getUserDataSelect(loggedInUserId), 30 | }); 31 | 32 | if (!user) notFound(); 33 | 34 | return user; 35 | }); 36 | 37 | export async function generateMetadata({ 38 | params: { username }, 39 | }: PageProps): Promise { 40 | const { user: loggedInUser } = await validateRequest(); 41 | 42 | if (!loggedInUser) return {}; 43 | 44 | const user = await getUser(username, loggedInUser.id); 45 | 46 | return { 47 | title: `${user.displayName} (@${user.username})`, 48 | }; 49 | } 50 | 51 | export default async function Page({ params: { username } }: PageProps) { 52 | const { user: loggedInUser } = await validateRequest(); 53 | 54 | if (!loggedInUser) { 55 | return ( 56 |

57 | You're not authorized to view this page. 58 |

59 | ); 60 | } 61 | 62 | const user = await getUser(username, loggedInUser.id); 63 | 64 | return ( 65 |
66 |
67 | 68 |
69 |

70 | {user.displayName}'s posts 71 |

72 |
73 | 74 |
75 | 76 |
77 | ); 78 | } 79 | 80 | interface UserProfileProps { 81 | user: UserData; 82 | loggedInUserId: string; 83 | } 84 | 85 | async function UserProfile({ user, loggedInUserId }: UserProfileProps) { 86 | const followerInfo: FollowerInfo = { 87 | followers: user._count.followers, 88 | isFollowedByUser: user.followers.some( 89 | ({ followerId }) => followerId === loggedInUserId, 90 | ), 91 | }; 92 | 93 | return ( 94 |
95 | 100 |
101 |
102 |
103 |

{user.displayName}

104 |
@{user.username}
105 |
106 |
Member since {formatDate(user.createdAt, "MMM d, yyyy")}
107 |
108 | 109 | Posts:{" "} 110 | 111 | {formatNumber(user._count.posts)} 112 | 113 | 114 | 115 |
116 |
117 | {user.id === loggedInUserId ? ( 118 | 119 | ) : ( 120 | 121 | )} 122 |
123 | {user.bio && ( 124 | <> 125 |
126 | 127 |
128 | {user.bio} 129 |
130 |
131 | 132 | )} 133 |
134 | ); 135 | } 136 | -------------------------------------------------------------------------------- /src/app/ReactQueryProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 4 | import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; 5 | import { useState } from "react"; 6 | 7 | export default function ReactQueryProvider({ 8 | children, 9 | }: { 10 | children: React.ReactNode; 11 | }) { 12 | const [client] = useState(new QueryClient()); 13 | 14 | return ( 15 | 16 | {children} 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/app/api/auth/callback/google/route.ts: -------------------------------------------------------------------------------- 1 | import { google, lucia } from "@/auth"; 2 | import kyInstance from "@/lib/ky"; 3 | import prisma from "@/lib/prisma"; 4 | import streamServerClient from "@/lib/stream"; 5 | import { slugify } from "@/lib/utils"; 6 | import { OAuth2RequestError } from "arctic"; 7 | import { generateIdFromEntropySize } from "lucia"; 8 | import { cookies } from "next/headers"; 9 | import { NextRequest } from "next/server"; 10 | 11 | export async function GET(req: NextRequest) { 12 | const code = req.nextUrl.searchParams.get("code"); 13 | const state = req.nextUrl.searchParams.get("state"); 14 | 15 | const storedState = cookies().get("state")?.value; 16 | const storedCodeVerifier = cookies().get("code_verifier")?.value; 17 | 18 | if ( 19 | !code || 20 | !state || 21 | !storedState || 22 | !storedCodeVerifier || 23 | state !== storedState 24 | ) { 25 | return new Response(null, { status: 400 }); 26 | } 27 | 28 | try { 29 | const tokens = await google.validateAuthorizationCode( 30 | code, 31 | storedCodeVerifier, 32 | ); 33 | 34 | const googleUser = await kyInstance 35 | .get("https://www.googleapis.com/oauth2/v1/userinfo", { 36 | headers: { 37 | Authorization: `Bearer ${tokens.accessToken}`, 38 | }, 39 | }) 40 | .json<{ id: string; name: string }>(); 41 | 42 | const existingUser = await prisma.user.findUnique({ 43 | where: { 44 | googleId: googleUser.id, 45 | }, 46 | }); 47 | 48 | if (existingUser) { 49 | const session = await lucia.createSession(existingUser.id, {}); 50 | const sessionCookie = lucia.createSessionCookie(session.id); 51 | cookies().set( 52 | sessionCookie.name, 53 | sessionCookie.value, 54 | sessionCookie.attributes, 55 | ); 56 | return new Response(null, { 57 | status: 302, 58 | headers: { 59 | Location: "/", 60 | }, 61 | }); 62 | } 63 | 64 | const userId = generateIdFromEntropySize(10); 65 | 66 | const username = slugify(googleUser.name) + "-" + userId.slice(0, 4); 67 | 68 | await prisma.$transaction(async (tx) => { 69 | await tx.user.create({ 70 | data: { 71 | id: userId, 72 | username, 73 | displayName: googleUser.name, 74 | googleId: googleUser.id, 75 | }, 76 | }); 77 | await streamServerClient.upsertUser({ 78 | id: userId, 79 | username, 80 | name: username, 81 | }); 82 | }); 83 | 84 | const session = await lucia.createSession(userId, {}); 85 | const sessionCookie = lucia.createSessionCookie(session.id); 86 | cookies().set( 87 | sessionCookie.name, 88 | sessionCookie.value, 89 | sessionCookie.attributes, 90 | ); 91 | 92 | return new Response(null, { 93 | status: 302, 94 | headers: { 95 | Location: "/", 96 | }, 97 | }); 98 | } catch (error) { 99 | console.error(error); 100 | if (error instanceof OAuth2RequestError) { 101 | return new Response(null, { 102 | status: 400, 103 | }); 104 | } 105 | return new Response(null, { 106 | status: 500, 107 | }); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/app/api/clear-uploads/route.ts: -------------------------------------------------------------------------------- 1 | import prisma from "@/lib/prisma"; 2 | import { UTApi } from "uploadthing/server"; 3 | 4 | export async function GET(req: Request) { 5 | try { 6 | const authHeader = req.headers.get("Authorization"); 7 | 8 | if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { 9 | return Response.json( 10 | { message: "Invalid authorization header" }, 11 | { status: 401 }, 12 | ); 13 | } 14 | 15 | const unusedMedia = await prisma.media.findMany({ 16 | where: { 17 | postId: null, 18 | ...(process.env.NODE_ENV === "production" 19 | ? { 20 | createdAt: { 21 | lte: new Date(Date.now() - 1000 * 60 * 60 * 24), 22 | }, 23 | } 24 | : {}), 25 | }, 26 | select: { 27 | id: true, 28 | url: true, 29 | }, 30 | }); 31 | 32 | new UTApi().deleteFiles( 33 | unusedMedia.map( 34 | (m) => 35 | m.url.split(`/a/${process.env.NEXT_PUBLIC_UPLOADTHING_APP_ID}/`)[1], 36 | ), 37 | ); 38 | 39 | await prisma.media.deleteMany({ 40 | where: { 41 | id: { 42 | in: unusedMedia.map((m) => m.id), 43 | }, 44 | }, 45 | }); 46 | 47 | return new Response(); 48 | } catch (error) { 49 | console.error(error); 50 | return Response.json({ error: "Internal server error" }, { status: 500 }); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/app/api/get-token/route.ts: -------------------------------------------------------------------------------- 1 | import { validateRequest } from "@/auth"; 2 | import streamServerClient from "@/lib/stream"; 3 | 4 | export async function GET() { 5 | try { 6 | const { user } = await validateRequest(); 7 | 8 | console.log("Calling get-token for user: ", user?.id); 9 | 10 | if (!user) { 11 | return Response.json({ error: "Unauthorized" }, { status: 401 }); 12 | } 13 | 14 | const expirationTime = Math.floor(Date.now() / 1000) + 60 * 60; 15 | 16 | const issuedAt = Math.floor(Date.now() / 1000) - 60; 17 | 18 | const token = streamServerClient.createToken( 19 | user.id, 20 | expirationTime, 21 | issuedAt, 22 | ); 23 | 24 | return Response.json({ token }); 25 | } catch (error) { 26 | console.error(error); 27 | return Response.json({ error: "Internal server error" }, { status: 500 }); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/app/api/messages/unread-count/route.ts: -------------------------------------------------------------------------------- 1 | import { validateRequest } from "@/auth"; 2 | import streamServerClient from "@/lib/stream"; 3 | import { MessageCountInfo } from "@/lib/types"; 4 | 5 | export async function GET() { 6 | try { 7 | const { user } = await validateRequest(); 8 | 9 | if (!user) { 10 | return Response.json({ error: "Unauthorized" }, { status: 401 }); 11 | } 12 | 13 | const { total_unread_count } = await streamServerClient.getUnreadCount( 14 | user.id, 15 | ); 16 | 17 | const data: MessageCountInfo = { 18 | unreadCount: total_unread_count, 19 | }; 20 | 21 | return Response.json(data); 22 | } catch (error) { 23 | console.error(error); 24 | return Response.json({ error: "Internal server error" }, { status: 500 }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/app/api/notifications/mark-as-read/route.ts: -------------------------------------------------------------------------------- 1 | import { validateRequest } from "@/auth"; 2 | import prisma from "@/lib/prisma"; 3 | 4 | export async function PATCH() { 5 | try { 6 | const { user } = await validateRequest(); 7 | 8 | if (!user) { 9 | return Response.json({ error: "Unauthorized" }, { status: 401 }); 10 | } 11 | 12 | await prisma.notification.updateMany({ 13 | where: { 14 | recipientId: user.id, 15 | read: false, 16 | }, 17 | data: { 18 | read: true, 19 | }, 20 | }); 21 | 22 | return new Response(); 23 | } catch (error) { 24 | console.error(error); 25 | return Response.json({ error: "Internal server error" }, { status: 500 }); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/app/api/notifications/route.ts: -------------------------------------------------------------------------------- 1 | import { validateRequest } from "@/auth"; 2 | import prisma from "@/lib/prisma"; 3 | import { notificationsInclude, NotificationsPage } from "@/lib/types"; 4 | import { NextRequest } from "next/server"; 5 | 6 | export async function GET(req: NextRequest) { 7 | try { 8 | const cursor = req.nextUrl.searchParams.get("cursor") || undefined; 9 | 10 | const pageSize = 10; 11 | 12 | const { user } = await validateRequest(); 13 | 14 | if (!user) { 15 | return Response.json({ error: "Unauthorized" }, { status: 401 }); 16 | } 17 | 18 | const notifications = await prisma.notification.findMany({ 19 | where: { 20 | recipientId: user.id, 21 | }, 22 | include: notificationsInclude, 23 | orderBy: { createdAt: "desc" }, 24 | take: pageSize + 1, 25 | cursor: cursor ? { id: cursor } : undefined, 26 | }); 27 | 28 | const nextCursor = 29 | notifications.length > pageSize ? notifications[pageSize].id : null; 30 | 31 | const data: NotificationsPage = { 32 | notifications: notifications.slice(0, pageSize), 33 | nextCursor, 34 | }; 35 | 36 | return Response.json(data); 37 | } catch (error) { 38 | console.error(error); 39 | return Response.json({ error: "Internal server error" }, { status: 500 }); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/app/api/notifications/unread-count/route.ts: -------------------------------------------------------------------------------- 1 | import { validateRequest } from "@/auth"; 2 | import prisma from "@/lib/prisma"; 3 | import { NotificationCountInfo } from "@/lib/types"; 4 | 5 | export async function GET() { 6 | try { 7 | const { user } = await validateRequest(); 8 | 9 | if (!user) { 10 | return Response.json({ error: "Unauthorized" }, { status: 401 }); 11 | } 12 | 13 | const unreadCount = await prisma.notification.count({ 14 | where: { 15 | recipientId: user.id, 16 | read: false, 17 | }, 18 | }); 19 | 20 | const data: NotificationCountInfo = { 21 | unreadCount, 22 | }; 23 | 24 | return Response.json(data); 25 | } catch (error) { 26 | console.error(error); 27 | return Response.json({ error: "Internal server error" }, { status: 500 }); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/app/api/posts/[postId]/bookmark/route.ts: -------------------------------------------------------------------------------- 1 | import { validateRequest } from "@/auth"; 2 | import prisma from "@/lib/prisma"; 3 | import { BookmarkInfo } from "@/lib/types"; 4 | 5 | export async function GET( 6 | req: Request, 7 | { params: { postId } }: { params: { postId: string } }, 8 | ) { 9 | try { 10 | const { user: loggedInUser } = await validateRequest(); 11 | 12 | if (!loggedInUser) { 13 | return Response.json({ error: "Unauthorized" }, { status: 401 }); 14 | } 15 | 16 | const bookmark = await prisma.bookmark.findUnique({ 17 | where: { 18 | userId_postId: { 19 | userId: loggedInUser.id, 20 | postId, 21 | }, 22 | }, 23 | }); 24 | 25 | const data: BookmarkInfo = { 26 | isBookmarkedByUser: !!bookmark, 27 | }; 28 | 29 | return Response.json(data); 30 | } catch (error) { 31 | console.error(error); 32 | return Response.json({ error: "Internal server error" }, { status: 500 }); 33 | } 34 | } 35 | 36 | export async function POST( 37 | req: Request, 38 | { params: { postId } }: { params: { postId: string } }, 39 | ) { 40 | try { 41 | const { user: loggedInUser } = await validateRequest(); 42 | 43 | if (!loggedInUser) { 44 | return Response.json({ error: "Unauthorized" }, { status: 401 }); 45 | } 46 | 47 | await prisma.bookmark.upsert({ 48 | where: { 49 | userId_postId: { 50 | userId: loggedInUser.id, 51 | postId, 52 | }, 53 | }, 54 | create: { 55 | userId: loggedInUser.id, 56 | postId, 57 | }, 58 | update: {}, 59 | }); 60 | 61 | return new Response(); 62 | } catch (error) { 63 | console.error(error); 64 | return Response.json({ error: "Internal server error" }, { status: 500 }); 65 | } 66 | } 67 | 68 | export async function DELETE( 69 | req: Request, 70 | { params: { postId } }: { params: { postId: string } }, 71 | ) { 72 | try { 73 | const { user: loggedInUser } = await validateRequest(); 74 | 75 | if (!loggedInUser) { 76 | return Response.json({ error: "Unauthorized" }, { status: 401 }); 77 | } 78 | 79 | await prisma.bookmark.deleteMany({ 80 | where: { 81 | userId: loggedInUser.id, 82 | postId, 83 | }, 84 | }); 85 | 86 | return new Response(); 87 | } catch (error) { 88 | console.error(error); 89 | return Response.json({ error: "Internal server error" }, { status: 500 }); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/app/api/posts/[postId]/comments/route.ts: -------------------------------------------------------------------------------- 1 | import { validateRequest } from "@/auth"; 2 | import prisma from "@/lib/prisma"; 3 | import { CommentsPage, getCommentDataInclude } from "@/lib/types"; 4 | import { NextRequest } from "next/server"; 5 | 6 | export async function GET( 7 | req: NextRequest, 8 | { params: { postId } }: { params: { postId: string } }, 9 | ) { 10 | try { 11 | const cursor = req.nextUrl.searchParams.get("cursor") || undefined; 12 | 13 | const pageSize = 5; 14 | 15 | const { user } = await validateRequest(); 16 | 17 | if (!user) { 18 | return Response.json({ error: "Unauthorized" }, { status: 401 }); 19 | } 20 | 21 | const comments = await prisma.comment.findMany({ 22 | where: { postId }, 23 | include: getCommentDataInclude(user.id), 24 | orderBy: { createdAt: "asc" }, 25 | take: -pageSize - 1, 26 | cursor: cursor ? { id: cursor } : undefined, 27 | }); 28 | 29 | const previousCursor = comments.length > pageSize ? comments[0].id : null; 30 | 31 | const data: CommentsPage = { 32 | comments: comments.length > pageSize ? comments.slice(1) : comments, 33 | previousCursor, 34 | }; 35 | 36 | return Response.json(data); 37 | } catch (error) { 38 | console.error(error); 39 | return Response.json({ error: "Internal server error" }, { status: 500 }); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/app/api/posts/[postId]/likes/route.ts: -------------------------------------------------------------------------------- 1 | import { validateRequest } from "@/auth"; 2 | import prisma from "@/lib/prisma"; 3 | import { LikeInfo } from "@/lib/types"; 4 | 5 | export async function GET( 6 | req: Request, 7 | { params: { postId } }: { params: { postId: string } }, 8 | ) { 9 | try { 10 | const { user: loggedInUser } = await validateRequest(); 11 | 12 | if (!loggedInUser) { 13 | return Response.json({ error: "Unauthorized" }, { status: 401 }); 14 | } 15 | 16 | const post = await prisma.post.findUnique({ 17 | where: { id: postId }, 18 | select: { 19 | likes: { 20 | where: { 21 | userId: loggedInUser.id, 22 | }, 23 | select: { 24 | userId: true, 25 | }, 26 | }, 27 | _count: { 28 | select: { 29 | likes: true, 30 | }, 31 | }, 32 | }, 33 | }); 34 | 35 | if (!post) { 36 | return Response.json({ error: "Post not found" }, { status: 404 }); 37 | } 38 | 39 | const data: LikeInfo = { 40 | likes: post._count.likes, 41 | isLikedByUser: !!post.likes.length, 42 | }; 43 | 44 | return Response.json(data); 45 | } catch (error) { 46 | console.error(error); 47 | return Response.json({ error: "Internal server error" }, { status: 500 }); 48 | } 49 | } 50 | 51 | export async function POST( 52 | req: Request, 53 | { params: { postId } }: { params: { postId: string } }, 54 | ) { 55 | try { 56 | const { user: loggedInUser } = await validateRequest(); 57 | 58 | if (!loggedInUser) { 59 | return Response.json({ error: "Unauthorized" }, { status: 401 }); 60 | } 61 | 62 | const post = await prisma.post.findUnique({ 63 | where: { id: postId }, 64 | select: { 65 | userId: true, 66 | }, 67 | }); 68 | 69 | if (!post) { 70 | return Response.json({ error: "Post not found" }, { status: 404 }); 71 | } 72 | 73 | await prisma.$transaction([ 74 | prisma.like.upsert({ 75 | where: { 76 | userId_postId: { 77 | userId: loggedInUser.id, 78 | postId, 79 | }, 80 | }, 81 | create: { 82 | userId: loggedInUser.id, 83 | postId, 84 | }, 85 | update: {}, 86 | }), 87 | ...(loggedInUser.id !== post.userId 88 | ? [ 89 | prisma.notification.create({ 90 | data: { 91 | issuerId: loggedInUser.id, 92 | recipientId: post.userId, 93 | postId, 94 | type: "LIKE", 95 | }, 96 | }), 97 | ] 98 | : []), 99 | ]); 100 | 101 | return new Response(); 102 | } catch (error) { 103 | console.error(error); 104 | return Response.json({ error: "Internal server error" }, { status: 500 }); 105 | } 106 | } 107 | 108 | export async function DELETE( 109 | req: Request, 110 | { params: { postId } }: { params: { postId: string } }, 111 | ) { 112 | try { 113 | const { user: loggedInUser } = await validateRequest(); 114 | 115 | if (!loggedInUser) { 116 | return Response.json({ error: "Unauthorized" }, { status: 401 }); 117 | } 118 | 119 | const post = await prisma.post.findUnique({ 120 | where: { id: postId }, 121 | select: { 122 | userId: true, 123 | }, 124 | }); 125 | 126 | if (!post) { 127 | return Response.json({ error: "Post not found" }, { status: 404 }); 128 | } 129 | 130 | await prisma.$transaction([ 131 | prisma.like.deleteMany({ 132 | where: { 133 | userId: loggedInUser.id, 134 | postId, 135 | }, 136 | }), 137 | prisma.notification.deleteMany({ 138 | where: { 139 | issuerId: loggedInUser.id, 140 | recipientId: post.userId, 141 | postId, 142 | type: "LIKE", 143 | }, 144 | }), 145 | ]); 146 | 147 | return new Response(); 148 | } catch (error) { 149 | console.error(error); 150 | return Response.json({ error: "Internal server error" }, { status: 500 }); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/app/api/posts/bookmarked/route.ts: -------------------------------------------------------------------------------- 1 | import { validateRequest } from "@/auth"; 2 | import prisma from "@/lib/prisma"; 3 | import { getPostDataInclude, PostsPage } from "@/lib/types"; 4 | import { NextRequest } from "next/server"; 5 | 6 | export async function GET(req: NextRequest) { 7 | try { 8 | const cursor = req.nextUrl.searchParams.get("cursor") || undefined; 9 | 10 | const pageSize = 10; 11 | 12 | const { user } = await validateRequest(); 13 | 14 | if (!user) { 15 | return Response.json({ error: "Unauthorized" }, { status: 401 }); 16 | } 17 | 18 | const bookmarks = await prisma.bookmark.findMany({ 19 | where: { 20 | userId: user.id, 21 | }, 22 | include: { 23 | post: { 24 | include: getPostDataInclude(user.id), 25 | }, 26 | }, 27 | orderBy: { 28 | createdAt: "desc", 29 | }, 30 | take: pageSize + 1, 31 | cursor: cursor ? { id: cursor } : undefined, 32 | }); 33 | 34 | const nextCursor = 35 | bookmarks.length > pageSize ? bookmarks[pageSize].id : null; 36 | 37 | const data: PostsPage = { 38 | posts: bookmarks.slice(0, pageSize).map((bookmark) => bookmark.post), 39 | nextCursor, 40 | }; 41 | 42 | return Response.json(data); 43 | } catch (error) { 44 | console.error(error); 45 | return Response.json({ error: "Internal server error" }, { status: 500 }); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/app/api/posts/following/route.ts: -------------------------------------------------------------------------------- 1 | import { validateRequest } from "@/auth"; 2 | import prisma from "@/lib/prisma"; 3 | import { getPostDataInclude, PostsPage } from "@/lib/types"; 4 | import { NextRequest } from "next/server"; 5 | 6 | export async function GET(req: NextRequest) { 7 | try { 8 | const cursor = req.nextUrl.searchParams.get("cursor") || undefined; 9 | 10 | const pageSize = 10; 11 | 12 | const { user } = await validateRequest(); 13 | 14 | if (!user) { 15 | return Response.json({ error: "Unauthorized" }, { status: 401 }); 16 | } 17 | 18 | const posts = await prisma.post.findMany({ 19 | where: { 20 | user: { 21 | followers: { 22 | some: { 23 | followerId: user.id, 24 | }, 25 | }, 26 | }, 27 | }, 28 | orderBy: { createdAt: "desc" }, 29 | take: pageSize + 1, 30 | cursor: cursor ? { id: cursor } : undefined, 31 | include: getPostDataInclude(user.id), 32 | }); 33 | 34 | const nextCursor = posts.length > pageSize ? posts[pageSize].id : null; 35 | 36 | const data: PostsPage = { 37 | posts: posts.slice(0, pageSize), 38 | nextCursor, 39 | }; 40 | 41 | return Response.json(data); 42 | } catch (error) { 43 | console.error(error); 44 | return Response.json({ error: "Internal server error" }, { status: 500 }); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/app/api/posts/for-you/route.ts: -------------------------------------------------------------------------------- 1 | import { validateRequest } from "@/auth"; 2 | import prisma from "@/lib/prisma"; 3 | import { getPostDataInclude, PostsPage } from "@/lib/types"; 4 | import { NextRequest } from "next/server"; 5 | 6 | export async function GET(req: NextRequest) { 7 | try { 8 | const cursor = req.nextUrl.searchParams.get("cursor") || undefined; 9 | 10 | const pageSize = 10; 11 | 12 | const { user } = await validateRequest(); 13 | 14 | if (!user) { 15 | return Response.json({ error: "Unauthorized" }, { status: 401 }); 16 | } 17 | 18 | const posts = await prisma.post.findMany({ 19 | include: getPostDataInclude(user.id), 20 | orderBy: { createdAt: "desc" }, 21 | take: pageSize + 1, 22 | cursor: cursor ? { id: cursor } : undefined, 23 | }); 24 | 25 | const nextCursor = posts.length > pageSize ? posts[pageSize].id : null; 26 | 27 | const data: PostsPage = { 28 | posts: posts.slice(0, pageSize), 29 | nextCursor, 30 | }; 31 | 32 | return Response.json(data); 33 | } catch (error) { 34 | console.error(error); 35 | return Response.json({ error: "Internal server error" }, { status: 500 }); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/app/api/search/route.ts: -------------------------------------------------------------------------------- 1 | import { validateRequest } from "@/auth"; 2 | import prisma from "@/lib/prisma"; 3 | import { getPostDataInclude, PostsPage } from "@/lib/types"; 4 | import { NextRequest } from "next/server"; 5 | 6 | export async function GET(req: NextRequest) { 7 | try { 8 | const q = req.nextUrl.searchParams.get("q") || ""; 9 | const cursor = req.nextUrl.searchParams.get("cursor") || undefined; 10 | 11 | const searchQuery = q.split(" ").join(" & "); 12 | 13 | const pageSize = 10; 14 | 15 | const { user } = await validateRequest(); 16 | 17 | if (!user) { 18 | return Response.json({ error: "Unauthorized" }, { status: 401 }); 19 | } 20 | 21 | const posts = await prisma.post.findMany({ 22 | where: { 23 | OR: [ 24 | { 25 | content: { 26 | search: searchQuery, 27 | }, 28 | }, 29 | { 30 | user: { 31 | displayName: { 32 | search: searchQuery, 33 | }, 34 | }, 35 | }, 36 | { 37 | user: { 38 | username: { 39 | search: searchQuery, 40 | }, 41 | }, 42 | }, 43 | ], 44 | }, 45 | include: getPostDataInclude(user.id), 46 | orderBy: { createdAt: "desc" }, 47 | take: pageSize + 1, 48 | cursor: cursor ? { id: cursor } : undefined, 49 | }); 50 | 51 | const nextCursor = posts.length > pageSize ? posts[pageSize].id : null; 52 | 53 | const data: PostsPage = { 54 | posts: posts.slice(0, pageSize), 55 | nextCursor, 56 | }; 57 | 58 | return Response.json(data); 59 | } catch (error) { 60 | console.error(error); 61 | return Response.json({ error: "Internal server error" }, { status: 500 }); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/app/api/uploadthing/core.ts: -------------------------------------------------------------------------------- 1 | import { validateRequest } from "@/auth"; 2 | import prisma from "@/lib/prisma"; 3 | import streamServerClient from "@/lib/stream"; 4 | import { createUploadthing, FileRouter } from "uploadthing/next"; 5 | import { UploadThingError, UTApi } from "uploadthing/server"; 6 | 7 | const f = createUploadthing(); 8 | 9 | export const fileRouter = { 10 | avatar: f({ 11 | image: { maxFileSize: "512KB" }, 12 | }) 13 | .middleware(async () => { 14 | const { user } = await validateRequest(); 15 | 16 | if (!user) throw new UploadThingError("Unauthorized"); 17 | 18 | return { user }; 19 | }) 20 | .onUploadComplete(async ({ metadata, file }) => { 21 | const oldAvatarUrl = metadata.user.avatarUrl; 22 | 23 | if (oldAvatarUrl) { 24 | const key = oldAvatarUrl.split( 25 | `/a/${process.env.NEXT_PUBLIC_UPLOADTHING_APP_ID}/`, 26 | )[1]; 27 | 28 | await new UTApi().deleteFiles(key); 29 | } 30 | 31 | const newAvatarUrl = file.url.replace( 32 | "/f/", 33 | `/a/${process.env.NEXT_PUBLIC_UPLOADTHING_APP_ID}/`, 34 | ); 35 | 36 | await Promise.all([ 37 | prisma.user.update({ 38 | where: { id: metadata.user.id }, 39 | data: { 40 | avatarUrl: newAvatarUrl, 41 | }, 42 | }), 43 | streamServerClient.partialUpdateUser({ 44 | id: metadata.user.id, 45 | set: { 46 | image: newAvatarUrl, 47 | }, 48 | }), 49 | ]); 50 | 51 | return { avatarUrl: newAvatarUrl }; 52 | }), 53 | attachment: f({ 54 | image: { maxFileSize: "4MB", maxFileCount: 5 }, 55 | video: { maxFileSize: "64MB", maxFileCount: 5 }, 56 | }) 57 | .middleware(async () => { 58 | const { user } = await validateRequest(); 59 | 60 | if (!user) throw new UploadThingError("Unauthorized"); 61 | 62 | return {}; 63 | }) 64 | .onUploadComplete(async ({ file }) => { 65 | const media = await prisma.media.create({ 66 | data: { 67 | url: file.url.replace( 68 | "/f/", 69 | `/a/${process.env.NEXT_PUBLIC_UPLOADTHING_APP_ID}/`, 70 | ), 71 | type: file.type.startsWith("image") ? "IMAGE" : "VIDEO", 72 | }, 73 | }); 74 | 75 | return { mediaId: media.id }; 76 | }), 77 | } satisfies FileRouter; 78 | 79 | export type AppFileRouter = typeof fileRouter; 80 | -------------------------------------------------------------------------------- /src/app/api/uploadthing/route.ts: -------------------------------------------------------------------------------- 1 | import { createRouteHandler } from "uploadthing/next"; 2 | import { fileRouter } from "./core"; 3 | 4 | export const { GET, POST } = createRouteHandler({ 5 | router: fileRouter, 6 | }); 7 | -------------------------------------------------------------------------------- /src/app/api/users/[userId]/followers/route.ts: -------------------------------------------------------------------------------- 1 | import { validateRequest } from "@/auth"; 2 | import prisma from "@/lib/prisma"; 3 | import { FollowerInfo } from "@/lib/types"; 4 | 5 | export async function GET( 6 | req: Request, 7 | { params: { userId } }: { params: { userId: string } }, 8 | ) { 9 | try { 10 | const { user: loggedInUser } = await validateRequest(); 11 | 12 | if (!loggedInUser) { 13 | return Response.json({ error: "Unauthorized" }, { status: 401 }); 14 | } 15 | 16 | const user = await prisma.user.findUnique({ 17 | where: { id: userId }, 18 | select: { 19 | followers: { 20 | where: { 21 | followerId: loggedInUser.id, 22 | }, 23 | select: { 24 | followerId: true, 25 | }, 26 | }, 27 | _count: { 28 | select: { 29 | followers: true, 30 | }, 31 | }, 32 | }, 33 | }); 34 | 35 | if (!user) { 36 | return Response.json({ error: "User not found" }, { status: 404 }); 37 | } 38 | 39 | const data: FollowerInfo = { 40 | followers: user._count.followers, 41 | isFollowedByUser: !!user.followers.length, 42 | }; 43 | 44 | return Response.json(data); 45 | } catch (error) { 46 | console.error(error); 47 | return Response.json({ error: "Internal server error" }, { status: 500 }); 48 | } 49 | } 50 | 51 | export async function POST( 52 | req: Request, 53 | { params: { userId } }: { params: { userId: string } }, 54 | ) { 55 | try { 56 | const { user: loggedInUser } = await validateRequest(); 57 | 58 | if (!loggedInUser) { 59 | return Response.json({ error: "Unauthorized" }, { status: 401 }); 60 | } 61 | 62 | await prisma.$transaction([ 63 | prisma.follow.upsert({ 64 | where: { 65 | followerId_followingId: { 66 | followerId: loggedInUser.id, 67 | followingId: userId, 68 | }, 69 | }, 70 | create: { 71 | followerId: loggedInUser.id, 72 | followingId: userId, 73 | }, 74 | update: {}, 75 | }), 76 | prisma.notification.create({ 77 | data: { 78 | issuerId: loggedInUser.id, 79 | recipientId: userId, 80 | type: "FOLLOW", 81 | }, 82 | }), 83 | ]); 84 | 85 | return new Response(); 86 | } catch (error) { 87 | console.error(error); 88 | return Response.json({ error: "Internal server error" }, { status: 500 }); 89 | } 90 | } 91 | 92 | export async function DELETE( 93 | req: Request, 94 | { params: { userId } }: { params: { userId: string } }, 95 | ) { 96 | try { 97 | const { user: loggedInUser } = await validateRequest(); 98 | 99 | if (!loggedInUser) { 100 | return Response.json({ error: "Unauthorized" }, { status: 401 }); 101 | } 102 | 103 | await prisma.$transaction([ 104 | prisma.follow.deleteMany({ 105 | where: { 106 | followerId: loggedInUser.id, 107 | followingId: userId, 108 | }, 109 | }), 110 | prisma.notification.deleteMany({ 111 | where: { 112 | issuerId: loggedInUser.id, 113 | recipientId: userId, 114 | type: "FOLLOW", 115 | }, 116 | }), 117 | ]); 118 | 119 | return new Response(); 120 | } catch (error) { 121 | console.error(error); 122 | return Response.json({ error: "Internal server error" }, { status: 500 }); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/app/api/users/[userId]/posts/route.ts: -------------------------------------------------------------------------------- 1 | import { validateRequest } from "@/auth"; 2 | import prisma from "@/lib/prisma"; 3 | import { getPostDataInclude, PostsPage } from "@/lib/types"; 4 | import { NextRequest } from "next/server"; 5 | 6 | export async function GET( 7 | req: NextRequest, 8 | { params: { userId } }: { params: { userId: string } }, 9 | ) { 10 | try { 11 | const cursor = req.nextUrl.searchParams.get("cursor") || undefined; 12 | 13 | const pageSize = 10; 14 | 15 | const { user } = await validateRequest(); 16 | 17 | if (!user) { 18 | return Response.json({ error: "Unauthorized" }, { status: 401 }); 19 | } 20 | 21 | const posts = await prisma.post.findMany({ 22 | where: { userId }, 23 | include: getPostDataInclude(user.id), 24 | orderBy: { createdAt: "desc" }, 25 | take: pageSize + 1, 26 | cursor: cursor ? { id: cursor } : undefined, 27 | }); 28 | 29 | const nextCursor = posts.length > pageSize ? posts[pageSize].id : null; 30 | 31 | const data: PostsPage = { 32 | posts: posts.slice(0, pageSize), 33 | nextCursor, 34 | }; 35 | 36 | return Response.json(data); 37 | } catch (error) { 38 | console.error(error); 39 | return Response.json({ error: "Internal server error" }, { status: 500 }); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/app/api/users/username/[username]/route.ts: -------------------------------------------------------------------------------- 1 | import { validateRequest } from "@/auth"; 2 | import prisma from "@/lib/prisma"; 3 | import { getUserDataSelect } from "@/lib/types"; 4 | 5 | export async function GET( 6 | req: Request, 7 | { params: { username } }: { params: { username: string } }, 8 | ) { 9 | try { 10 | const { user: loggedInUser } = await validateRequest(); 11 | 12 | if (!loggedInUser) { 13 | return Response.json({ error: "Unauthorized" }, { status: 401 }); 14 | } 15 | 16 | const user = await prisma.user.findFirst({ 17 | where: { 18 | username: { 19 | equals: username, 20 | mode: "insensitive", 21 | }, 22 | }, 23 | select: getUserDataSelect(loggedInUser.id), 24 | }); 25 | 26 | if (!user) { 27 | return Response.json({ error: "User not found" }, { status: 404 }); 28 | } 29 | 30 | return Response.json(user); 31 | } catch (error) { 32 | console.error(error); 33 | return Response.json({ error: "Internal server error" }, { status: 500 }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codinginflow/nextjs-15-social-media-app/2f407a5c8a6be333fdac54ac6d36b0c385348cb4/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/fonts/GeistMonoVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codinginflow/nextjs-15-social-media-app/2f407a5c8a6be333fdac54ac6d36b0c385348cb4/src/app/fonts/GeistMonoVF.woff -------------------------------------------------------------------------------- /src/app/fonts/GeistVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codinginflow/nextjs-15-social-media-app/2f407a5c8a6be333fdac54ac6d36b0c385348cb4/src/app/fonts/GeistVF.woff -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @import "~stream-chat-react/dist/css/v2/index.css"; 6 | 7 | @layer base { 8 | :root { 9 | --background: 224, 5%, 95%; 10 | --foreground: 240 10% 3.9%; 11 | 12 | --card: 0 0% 100%; 13 | --card-foreground: 240 10% 3.9%; 14 | 15 | --popover: 0 0% 100%; 16 | --popover-foreground: 240 10% 3.9%; 17 | 18 | --primary: 142.1 76.2% 36.3%; 19 | --primary-foreground: 355.7 100% 97.3%; 20 | 21 | --secondary: 240 4.8% 95.9%; 22 | --secondary-foreground: 240 5.9% 10%; 23 | 24 | --muted: 240 4.8% 95.9%; 25 | --muted-foreground: 240 3.8% 46.1%; 26 | 27 | --accent: 240 4.8% 95.9%; 28 | --accent-foreground: 240 5.9% 10%; 29 | 30 | --destructive: 0 84.2% 60.2%; 31 | --destructive-foreground: 0 0% 98%; 32 | 33 | --border: 240 5.9% 90%; 34 | --input: 240 5.9% 90%; 35 | --ring: 142.1 76.2% 36.3%; 36 | 37 | --radius: 1rem; 38 | } 39 | 40 | .dark { 41 | --background: 20 14.3% 4.1%; 42 | --foreground: 0 0% 95%; 43 | 44 | --card: 24 9.8% 10%; 45 | --card-foreground: 0 0% 95%; 46 | 47 | --popover: 0 0% 9%; 48 | --popover-foreground: 0 0% 95%; 49 | 50 | --primary: 142.1 70.6% 45.3%; 51 | --primary-foreground: 144.9 80.4% 10%; 52 | 53 | --secondary: 240 3.7% 15.9%; 54 | --secondary-foreground: 0 0% 98%; 55 | 56 | --muted: 0 0% 15%; 57 | --muted-foreground: 240 5% 64.9%; 58 | 59 | --accent: 12 6.5% 15.1%; 60 | --accent-foreground: 0 0% 98%; 61 | 62 | --destructive: 0 62.8% 50%; 63 | --destructive-foreground: 0 85.7% 97.3%; 64 | 65 | --border: 240 3.7% 15.9%; 66 | --input: 240 3.7% 15.9%; 67 | --ring: 142.4 71.8% 29.2%; 68 | } 69 | } 70 | 71 | @layer base { 72 | * { 73 | @apply border-border; 74 | } 75 | body { 76 | @apply bg-background text-foreground; 77 | } 78 | } 79 | 80 | .str-chat { 81 | --str-chat__font-family: inherit; 82 | --str-chat__primary-color: theme(colors.primary.DEFAULT); 83 | --str-chat__on-primary-color: theme(colors.primary.foreground); 84 | --str-chat__active-primary-color: theme(colors.primary.foreground); 85 | --str-chat__primary-color-low-emphasis: color-mix( 86 | in hsl, 87 | hsl(var(--primary)) 10%, 88 | transparent 89 | ); 90 | --str-chat__background-color: theme(colors.card.DEFAULT); 91 | --str-chat__secondary-background-color: theme(colors.card.DEFAULT); 92 | --str-chat__message-textarea-background-color: theme(colors.background); 93 | --str-chat__channel-preview-active-background-color: theme( 94 | colors.accent.DEFAULT 95 | ); 96 | --str-chat__channel-preview-hover-background-color: var( 97 | --str-chat__channel-preview-active-background-color 98 | ); 99 | --str-chat__secondary-surface-color: theme(colors.muted.DEFAULT); 100 | --str-chat__own-message-bubble-color: theme(colors.primary.foreground); 101 | --str-chat__primary-surface-color: theme(colors.primary.DEFAULT); 102 | --str-chat__primary-surface-color-low-emphasis: var( 103 | --str-chat__primary-color-low-emphasis 104 | ); 105 | --str-chat__disabled-color: theme(colors.muted.DEFAULT); 106 | --str-chat__cta-button-border-radius: var(--radius); 107 | } 108 | 109 | .str-chat-channel-list { 110 | border-right: none; 111 | } 112 | 113 | .str-chat__channel-list-react .str-chat__channel-list-messenger-react { 114 | padding-bottom: 0; 115 | } 116 | 117 | .str-chat__channel-search-bar-button--exit-search { 118 | display: none; 119 | } 120 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Toaster } from "@/components/ui/toaster"; 2 | import { NextSSRPlugin } from "@uploadthing/react/next-ssr-plugin"; 3 | import type { Metadata } from "next"; 4 | import { ThemeProvider } from "next-themes"; 5 | import localFont from "next/font/local"; 6 | import { extractRouterConfig } from "uploadthing/server"; 7 | import { fileRouter } from "./api/uploadthing/core"; 8 | import "./globals.css"; 9 | import ReactQueryProvider from "./ReactQueryProvider"; 10 | 11 | const geistSans = localFont({ 12 | src: "./fonts/GeistVF.woff", 13 | variable: "--font-geist-sans", 14 | }); 15 | const geistMono = localFont({ 16 | src: "./fonts/GeistMonoVF.woff", 17 | variable: "--font-geist-mono", 18 | }); 19 | 20 | export const metadata: Metadata = { 21 | title: { 22 | template: "%s | bugbook", 23 | default: "bugbook", 24 | }, 25 | description: "The social media app for powernerds", 26 | }; 27 | 28 | export default function RootLayout({ 29 | children, 30 | }: Readonly<{ 31 | children: React.ReactNode; 32 | }>) { 33 | return ( 34 | 35 | 36 | 37 | 38 | 44 | {children} 45 | 46 | 47 | 48 | 49 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/app/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Loader2 } from "lucide-react"; 2 | 3 | export default function Loading() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/assets/avatar-placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codinginflow/nextjs-15-social-media-app/2f407a5c8a6be333fdac54ac6d36b0c385348cb4/src/assets/avatar-placeholder.png -------------------------------------------------------------------------------- /src/assets/login-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codinginflow/nextjs-15-social-media-app/2f407a5c8a6be333fdac54ac6d36b0c385348cb4/src/assets/login-image.jpg -------------------------------------------------------------------------------- /src/assets/signup-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codinginflow/nextjs-15-social-media-app/2f407a5c8a6be333fdac54ac6d36b0c385348cb4/src/assets/signup-image.jpg -------------------------------------------------------------------------------- /src/auth.ts: -------------------------------------------------------------------------------- 1 | import { PrismaAdapter } from "@lucia-auth/adapter-prisma"; 2 | import { Google } from "arctic"; 3 | import { Lucia, Session, User } from "lucia"; 4 | import { cookies } from "next/headers"; 5 | import { cache } from "react"; 6 | import prisma from "./lib/prisma"; 7 | 8 | const adapter = new PrismaAdapter(prisma.session, prisma.user); 9 | 10 | export const lucia = new Lucia(adapter, { 11 | sessionCookie: { 12 | expires: false, 13 | attributes: { 14 | secure: process.env.NODE_ENV === "production", 15 | }, 16 | }, 17 | getUserAttributes(databaseUserAttributes) { 18 | return { 19 | id: databaseUserAttributes.id, 20 | username: databaseUserAttributes.username, 21 | displayName: databaseUserAttributes.displayName, 22 | avatarUrl: databaseUserAttributes.avatarUrl, 23 | googleId: databaseUserAttributes.googleId, 24 | }; 25 | }, 26 | }); 27 | 28 | declare module "lucia" { 29 | interface Register { 30 | Lucia: typeof lucia; 31 | DatabaseUserAttributes: DatabaseUserAttributes; 32 | } 33 | } 34 | 35 | interface DatabaseUserAttributes { 36 | id: string; 37 | username: string; 38 | displayName: string; 39 | avatarUrl: string | null; 40 | googleId: string | null; 41 | } 42 | 43 | export const google = new Google( 44 | process.env.GOOGLE_CLIENT_ID!, 45 | process.env.GOOGLE_CLIENT_SECRET!, 46 | `${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/callback/google`, 47 | ); 48 | 49 | export const validateRequest = cache( 50 | async (): Promise< 51 | { user: User; session: Session } | { user: null; session: null } 52 | > => { 53 | const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? null; 54 | 55 | if (!sessionId) { 56 | return { 57 | user: null, 58 | session: null, 59 | }; 60 | } 61 | 62 | const result = await lucia.validateSession(sessionId); 63 | 64 | try { 65 | if (result.session && result.session.fresh) { 66 | const sessionCookie = lucia.createSessionCookie(result.session.id); 67 | cookies().set( 68 | sessionCookie.name, 69 | sessionCookie.value, 70 | sessionCookie.attributes, 71 | ); 72 | } 73 | if (!result.session) { 74 | const sessionCookie = lucia.createBlankSessionCookie(); 75 | cookies().set( 76 | sessionCookie.name, 77 | sessionCookie.value, 78 | sessionCookie.attributes, 79 | ); 80 | } 81 | } catch {} 82 | 83 | return result; 84 | }, 85 | ); 86 | -------------------------------------------------------------------------------- /src/components/CropImageDialog.tsx: -------------------------------------------------------------------------------- 1 | import "cropperjs/dist/cropper.css"; 2 | import { useRef } from "react"; 3 | import { Cropper, ReactCropperElement } from "react-cropper"; 4 | import { Button } from "./ui/button"; 5 | import { 6 | Dialog, 7 | DialogContent, 8 | DialogFooter, 9 | DialogHeader, 10 | DialogTitle, 11 | } from "./ui/dialog"; 12 | 13 | interface CropImageDialogProps { 14 | src: string; 15 | cropAspectRatio: number; 16 | onCropped: (blob: Blob | null) => void; 17 | onClose: () => void; 18 | } 19 | 20 | export default function CropImageDialog({ 21 | src, 22 | cropAspectRatio, 23 | onCropped, 24 | onClose, 25 | }: CropImageDialogProps) { 26 | const cropperRef = useRef(null); 27 | 28 | function crop() { 29 | const cropper = cropperRef.current?.cropper; 30 | if (!cropper) return; 31 | cropper.getCroppedCanvas().toBlob((blob) => onCropped(blob), "image/webp"); 32 | onClose(); 33 | } 34 | 35 | return ( 36 | 37 | 38 | 39 | Crop image 40 | 41 | 49 | 50 | 53 | 54 | 55 | 56 | 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /src/components/FollowButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import useFollowerInfo from "@/hooks/useFollowerInfo"; 4 | import kyInstance from "@/lib/ky"; 5 | import { FollowerInfo } from "@/lib/types"; 6 | import { QueryKey, useMutation, useQueryClient } from "@tanstack/react-query"; 7 | import { Button } from "./ui/button"; 8 | import { useToast } from "./ui/use-toast"; 9 | 10 | interface FollowButtonProps { 11 | userId: string; 12 | initialState: FollowerInfo; 13 | } 14 | 15 | export default function FollowButton({ 16 | userId, 17 | initialState, 18 | }: FollowButtonProps) { 19 | const { toast } = useToast(); 20 | 21 | const queryClient = useQueryClient(); 22 | 23 | const { data } = useFollowerInfo(userId, initialState); 24 | 25 | const queryKey: QueryKey = ["follower-info", userId]; 26 | 27 | const { mutate } = useMutation({ 28 | mutationFn: () => 29 | data.isFollowedByUser 30 | ? kyInstance.delete(`/api/users/${userId}/followers`) 31 | : kyInstance.post(`/api/users/${userId}/followers`), 32 | onMutate: async () => { 33 | await queryClient.cancelQueries({ queryKey }); 34 | 35 | const previousState = queryClient.getQueryData(queryKey); 36 | 37 | queryClient.setQueryData(queryKey, () => ({ 38 | followers: 39 | (previousState?.followers || 0) + 40 | (previousState?.isFollowedByUser ? -1 : 1), 41 | isFollowedByUser: !previousState?.isFollowedByUser, 42 | })); 43 | 44 | return { previousState }; 45 | }, 46 | onError(error, variables, context) { 47 | queryClient.setQueryData(queryKey, context?.previousState); 48 | console.error(error); 49 | toast({ 50 | variant: "destructive", 51 | description: "Something went wrong. Please try again.", 52 | }); 53 | }, 54 | }); 55 | 56 | return ( 57 | 63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /src/components/FollowerCount.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import useFollowerInfo from "@/hooks/useFollowerInfo"; 4 | import { FollowerInfo } from "@/lib/types"; 5 | import { formatNumber } from "@/lib/utils"; 6 | 7 | interface FollowerCountProps { 8 | userId: string; 9 | initialState: FollowerInfo; 10 | } 11 | 12 | export default function FollowerCount({ 13 | userId, 14 | initialState, 15 | }: FollowerCountProps) { 16 | const { data } = useFollowerInfo(userId, initialState); 17 | 18 | return ( 19 | 20 | Followers:{" "} 21 | {formatNumber(data.followers)} 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/components/InfiniteScrollContainer.tsx: -------------------------------------------------------------------------------- 1 | import { useInView } from "react-intersection-observer"; 2 | 3 | interface InfiniteScrollContainerProps extends React.PropsWithChildren { 4 | onBottomReached: () => void; 5 | className?: string; 6 | } 7 | 8 | export default function InfiniteScrollContainer({ 9 | children, 10 | onBottomReached, 11 | className, 12 | }: InfiniteScrollContainerProps) { 13 | const { ref } = useInView({ 14 | rootMargin: "200px", 15 | onChange(inView) { 16 | if (inView) { 17 | onBottomReached(); 18 | } 19 | }, 20 | }); 21 | 22 | return ( 23 |
24 | {children} 25 |
26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/components/Linkify.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { LinkIt, LinkItUrl } from "react-linkify-it"; 3 | import UserLinkWithTooltip from "./UserLinkWithTooltip"; 4 | 5 | interface LinkifyProps { 6 | children: React.ReactNode; 7 | } 8 | 9 | export default function Linkify({ children }: LinkifyProps) { 10 | return ( 11 | 12 | 13 | {children} 14 | 15 | 16 | ); 17 | } 18 | 19 | function LinkifyUrl({ children }: LinkifyProps) { 20 | return ( 21 | {children} 22 | ); 23 | } 24 | 25 | function LinkifyUsername({ children }: LinkifyProps) { 26 | return ( 27 | ( 30 | 31 | {match} 32 | 33 | )} 34 | > 35 | {children} 36 | 37 | ); 38 | } 39 | 40 | function LinkifyHashtag({ children }: LinkifyProps) { 41 | return ( 42 | ( 45 | 50 | {match} 51 | 52 | )} 53 | > 54 | {children} 55 | 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /src/components/LoadingButton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { Loader2 } from "lucide-react"; 3 | import { Button, ButtonProps } from "./ui/button"; 4 | 5 | interface LoadingButtonProps extends ButtonProps { 6 | loading: boolean; 7 | } 8 | 9 | export default function LoadingButton({ 10 | loading, 11 | disabled, 12 | className, 13 | ...props 14 | }: LoadingButtonProps) { 15 | return ( 16 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/components/PasswordInput.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { Eye, EyeOff } from "lucide-react"; 3 | import React, { useState } from "react"; 4 | import { Input, InputProps } from "./ui/input"; 5 | 6 | const PasswordInput = React.forwardRef( 7 | ({ className, type, ...props }, ref) => { 8 | const [showPassword, setShowPassword] = useState(false); 9 | 10 | return ( 11 |
12 | 18 | 30 |
31 | ); 32 | }, 33 | ); 34 | 35 | PasswordInput.displayName = "PasswordInput"; 36 | 37 | export { PasswordInput }; 38 | -------------------------------------------------------------------------------- /src/components/SearchField.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { SearchIcon } from "lucide-react"; 4 | import { useRouter } from "next/navigation"; 5 | import { Input } from "./ui/input"; 6 | 7 | export default function SearchField() { 8 | const router = useRouter(); 9 | 10 | function handleSubmit(e: React.FormEvent) { 11 | e.preventDefault(); 12 | const form = e.currentTarget; 13 | const q = (form.q as HTMLInputElement).value.trim(); 14 | if (!q) return; 15 | router.push(`/search?q=${encodeURIComponent(q)}`); 16 | } 17 | 18 | return ( 19 |
20 |
21 | 22 | 23 |
24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/components/TrendsSidebar.tsx: -------------------------------------------------------------------------------- 1 | import { validateRequest } from "@/auth"; 2 | import prisma from "@/lib/prisma"; 3 | import { getUserDataSelect } from "@/lib/types"; 4 | import { formatNumber } from "@/lib/utils"; 5 | import { Loader2 } from "lucide-react"; 6 | import { unstable_cache } from "next/cache"; 7 | import Link from "next/link"; 8 | import { Suspense } from "react"; 9 | import FollowButton from "./FollowButton"; 10 | import UserAvatar from "./UserAvatar"; 11 | import UserTooltip from "./UserTooltip"; 12 | 13 | export default function TrendsSidebar() { 14 | return ( 15 |
16 | }> 17 | 18 | 19 | 20 |
21 | ); 22 | } 23 | 24 | async function WhoToFollow() { 25 | const { user } = await validateRequest(); 26 | 27 | if (!user) return null; 28 | 29 | const usersToFollow = await prisma.user.findMany({ 30 | where: { 31 | NOT: { 32 | id: user.id, 33 | }, 34 | followers: { 35 | none: { 36 | followerId: user.id, 37 | }, 38 | }, 39 | }, 40 | select: getUserDataSelect(user.id), 41 | take: 5, 42 | }); 43 | 44 | return ( 45 |
46 |
Who to follow
47 | {usersToFollow.map((user) => ( 48 |
49 | 50 | 54 | 55 |
56 |

57 | {user.displayName} 58 |

59 |

60 | @{user.username} 61 |

62 |
63 | 64 |
65 | followerId === user.id, 71 | ), 72 | }} 73 | /> 74 |
75 | ))} 76 |
77 | ); 78 | } 79 | 80 | const getTrendingTopics = unstable_cache( 81 | async () => { 82 | const result = await prisma.$queryRaw<{ hashtag: string; count: bigint }[]>` 83 | SELECT LOWER(unnest(regexp_matches(content, '#[[:alnum:]_]+', 'g'))) AS hashtag, COUNT(*) AS count 84 | FROM posts 85 | GROUP BY (hashtag) 86 | ORDER BY count DESC, hashtag ASC 87 | LIMIT 5 88 | `; 89 | 90 | return result.map((row) => ({ 91 | hashtag: row.hashtag, 92 | count: Number(row.count), 93 | })); 94 | }, 95 | ["trending_topics"], 96 | { 97 | revalidate: 3 * 60 * 60, 98 | }, 99 | ); 100 | 101 | async function TrendingTopics() { 102 | const trendingTopics = await getTrendingTopics(); 103 | 104 | return ( 105 |
106 |
Trending topics
107 | {trendingTopics.map(({ hashtag, count }) => { 108 | const title = hashtag.split("#")[1]; 109 | 110 | return ( 111 | 112 |

116 | {hashtag} 117 |

118 |

119 | {formatNumber(count)} {count === 1 ? "post" : "posts"} 120 |

121 | 122 | ); 123 | })} 124 |
125 | ); 126 | } 127 | -------------------------------------------------------------------------------- /src/components/UserAvatar.tsx: -------------------------------------------------------------------------------- 1 | import avatarPlaceholder from "@/assets/avatar-placeholder.png"; 2 | import { cn } from "@/lib/utils"; 3 | import Image from "next/image"; 4 | 5 | interface UserAvatarProps { 6 | avatarUrl: string | null | undefined; 7 | size?: number; 8 | className?: string; 9 | } 10 | 11 | export default function UserAvatar({ 12 | avatarUrl, 13 | size, 14 | className, 15 | }: UserAvatarProps) { 16 | return ( 17 | User avatar 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/components/UserButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { logout } from "@/app/(auth)/actions"; 4 | import { useSession } from "@/app/(main)/SessionProvider"; 5 | import { cn } from "@/lib/utils"; 6 | import { useQueryClient } from "@tanstack/react-query"; 7 | import { Check, LogOutIcon, Monitor, Moon, Sun, UserIcon } from "lucide-react"; 8 | import { useTheme } from "next-themes"; 9 | import Link from "next/link"; 10 | import { 11 | DropdownMenu, 12 | DropdownMenuContent, 13 | DropdownMenuItem, 14 | DropdownMenuLabel, 15 | DropdownMenuPortal, 16 | DropdownMenuSeparator, 17 | DropdownMenuSub, 18 | DropdownMenuSubContent, 19 | DropdownMenuSubTrigger, 20 | DropdownMenuTrigger, 21 | } from "./ui/dropdown-menu"; 22 | import UserAvatar from "./UserAvatar"; 23 | 24 | interface UserButtonProps { 25 | className?: string; 26 | } 27 | 28 | export default function UserButton({ className }: UserButtonProps) { 29 | const { user } = useSession(); 30 | 31 | const { theme, setTheme } = useTheme(); 32 | 33 | const queryClient = useQueryClient(); 34 | 35 | return ( 36 | 37 | 38 | 41 | 42 | 43 | Logged in as @{user.username} 44 | 45 | 46 | 47 | 48 | Profile 49 | 50 | 51 | 52 | 53 | 54 | Theme 55 | 56 | 57 | 58 | setTheme("system")}> 59 | 60 | System default 61 | {theme === "system" && } 62 | 63 | setTheme("light")}> 64 | 65 | Light 66 | {theme === "light" && } 67 | 68 | setTheme("dark")}> 69 | 70 | Dark 71 | {theme === "dark" && } 72 | 73 | 74 | 75 | 76 | 77 | { 79 | queryClient.clear(); 80 | logout(); 81 | }} 82 | > 83 | 84 | Logout 85 | 86 | 87 | 88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /src/components/UserLinkWithTooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import kyInstance from "@/lib/ky"; 4 | import { UserData } from "@/lib/types"; 5 | import { useQuery } from "@tanstack/react-query"; 6 | import { HTTPError } from "ky"; 7 | import Link from "next/link"; 8 | import { PropsWithChildren } from "react"; 9 | import UserTooltip from "./UserTooltip"; 10 | 11 | interface UserLinkWithTooltipProps extends PropsWithChildren { 12 | username: string; 13 | } 14 | 15 | export default function UserLinkWithTooltip({ 16 | children, 17 | username, 18 | }: UserLinkWithTooltipProps) { 19 | const { data } = useQuery({ 20 | queryKey: ["user-data", username], 21 | queryFn: () => 22 | kyInstance.get(`/api/users/username/${username}`).json(), 23 | retry(failureCount, error) { 24 | if (error instanceof HTTPError && error.response.status === 404) { 25 | return false; 26 | } 27 | return failureCount < 3; 28 | }, 29 | staleTime: Infinity, 30 | }); 31 | 32 | if (!data) { 33 | return ( 34 | 38 | {children} 39 | 40 | ); 41 | } 42 | 43 | return ( 44 | 45 | 49 | {children} 50 | 51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/components/UserTooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useSession } from "@/app/(main)/SessionProvider"; 4 | import { FollowerInfo, UserData } from "@/lib/types"; 5 | import Link from "next/link"; 6 | import { PropsWithChildren } from "react"; 7 | import FollowButton from "./FollowButton"; 8 | import FollowerCount from "./FollowerCount"; 9 | import Linkify from "./Linkify"; 10 | import { 11 | Tooltip, 12 | TooltipContent, 13 | TooltipProvider, 14 | TooltipTrigger, 15 | } from "./ui/tooltip"; 16 | import UserAvatar from "./UserAvatar"; 17 | 18 | interface UserTooltipProps extends PropsWithChildren { 19 | user: UserData; 20 | } 21 | 22 | export default function UserTooltip({ children, user }: UserTooltipProps) { 23 | const { user: loggedInUser } = useSession(); 24 | 25 | const followerState: FollowerInfo = { 26 | followers: user._count.followers, 27 | isFollowedByUser: !!user.followers.some( 28 | ({ followerId }) => followerId === loggedInUser.id, 29 | ), 30 | }; 31 | 32 | return ( 33 | 34 | 35 | {children} 36 | 37 |
38 |
39 | 40 | 41 | 42 | {loggedInUser.id !== user.id && ( 43 | 44 | )} 45 |
46 |
47 | 48 |
49 | {user.displayName} 50 |
51 |
@{user.username}
52 | 53 |
54 | {user.bio && ( 55 | 56 |
57 | {user.bio} 58 |
59 |
60 | )} 61 | 62 |
63 |
64 |
65 |
66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /src/components/comments/Comment.tsx: -------------------------------------------------------------------------------- 1 | import { useSession } from "@/app/(main)/SessionProvider"; 2 | import { CommentData } from "@/lib/types"; 3 | import { formatRelativeDate } from "@/lib/utils"; 4 | import Link from "next/link"; 5 | import UserAvatar from "../UserAvatar"; 6 | import UserTooltip from "../UserTooltip"; 7 | import CommentMoreButton from "./CommentMoreButton"; 8 | 9 | interface CommentProps { 10 | comment: CommentData; 11 | } 12 | 13 | export default function Comment({ comment }: CommentProps) { 14 | const { user } = useSession(); 15 | 16 | return ( 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 |
27 | 28 | 32 | {comment.user.displayName} 33 | 34 | 35 | 36 | {formatRelativeDate(comment.createdAt)} 37 | 38 |
39 |
{comment.content}
40 |
41 | {comment.user.id === user.id && ( 42 | 46 | )} 47 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/components/comments/CommentInput.tsx: -------------------------------------------------------------------------------- 1 | import { PostData } from "@/lib/types"; 2 | import { Loader2, SendHorizonal } from "lucide-react"; 3 | import { useState } from "react"; 4 | import { Button } from "../ui/button"; 5 | import { Input } from "../ui/input"; 6 | import { useSubmitCommentMutation } from "./mutations"; 7 | 8 | interface CommentInputProps { 9 | post: PostData; 10 | } 11 | 12 | export default function CommentInput({ post }: CommentInputProps) { 13 | const [input, setInput] = useState(""); 14 | 15 | const mutation = useSubmitCommentMutation(post.id); 16 | 17 | async function onSubmit(e: React.FormEvent) { 18 | e.preventDefault(); 19 | 20 | if (!input) return; 21 | 22 | mutation.mutate( 23 | { 24 | post, 25 | content: input, 26 | }, 27 | { 28 | onSuccess: () => setInput(""), 29 | }, 30 | ); 31 | } 32 | 33 | return ( 34 |
35 | setInput(e.target.value)} 39 | autoFocus 40 | /> 41 | 53 |
54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /src/components/comments/CommentMoreButton.tsx: -------------------------------------------------------------------------------- 1 | import { CommentData } from "@/lib/types"; 2 | import { MoreHorizontal, Trash2 } from "lucide-react"; 3 | import { useState } from "react"; 4 | import { Button } from "../ui/button"; 5 | import { 6 | DropdownMenu, 7 | DropdownMenuContent, 8 | DropdownMenuItem, 9 | DropdownMenuTrigger, 10 | } from "../ui/dropdown-menu"; 11 | import DeleteCommentDialog from "./DeleteCommentDialog"; 12 | 13 | interface CommentMoreButtonProps { 14 | comment: CommentData; 15 | className?: string; 16 | } 17 | 18 | export default function CommentMoreButton({ 19 | comment, 20 | className, 21 | }: CommentMoreButtonProps) { 22 | const [showDeleteDialog, setShowDeleteDialog] = useState(false); 23 | 24 | return ( 25 | <> 26 | 27 | 28 | 31 | 32 | 33 | setShowDeleteDialog(true)}> 34 | 35 | 36 | Delete 37 | 38 | 39 | 40 | 41 | setShowDeleteDialog(false)} 45 | /> 46 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /src/components/comments/Comments.tsx: -------------------------------------------------------------------------------- 1 | import kyInstance from "@/lib/ky"; 2 | import { CommentsPage, PostData } from "@/lib/types"; 3 | import { useInfiniteQuery } from "@tanstack/react-query"; 4 | import { Loader2 } from "lucide-react"; 5 | import { Button } from "../ui/button"; 6 | import Comment from "./Comment"; 7 | import CommentInput from "./CommentInput"; 8 | 9 | interface CommentsProps { 10 | post: PostData; 11 | } 12 | 13 | export default function Comments({ post }: CommentsProps) { 14 | const { data, fetchNextPage, hasNextPage, isFetching, status } = 15 | useInfiniteQuery({ 16 | queryKey: ["comments", post.id], 17 | queryFn: ({ pageParam }) => 18 | kyInstance 19 | .get( 20 | `/api/posts/${post.id}/comments`, 21 | pageParam ? { searchParams: { cursor: pageParam } } : {}, 22 | ) 23 | .json(), 24 | initialPageParam: null as string | null, 25 | getNextPageParam: (firstPage) => firstPage.previousCursor, 26 | select: (data) => ({ 27 | pages: [...data.pages].reverse(), 28 | pageParams: [...data.pageParams].reverse(), 29 | }), 30 | }); 31 | 32 | const comments = data?.pages.flatMap((page) => page.comments) || []; 33 | 34 | return ( 35 |
36 | 37 | {hasNextPage && ( 38 | 46 | )} 47 | {status === "pending" && } 48 | {status === "success" && !comments.length && ( 49 |

No comments yet.

50 | )} 51 | {status === "error" && ( 52 |

53 | An error occurred while loading comments. 54 |

55 | )} 56 |
57 | {comments.map((comment) => ( 58 | 59 | ))} 60 |
61 |
62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /src/components/comments/DeleteCommentDialog.tsx: -------------------------------------------------------------------------------- 1 | import { CommentData } from "@/lib/types"; 2 | import LoadingButton from "../LoadingButton"; 3 | import { Button } from "../ui/button"; 4 | import { 5 | Dialog, 6 | DialogContent, 7 | DialogDescription, 8 | DialogFooter, 9 | DialogHeader, 10 | DialogTitle, 11 | } from "../ui/dialog"; 12 | import { useDeleteCommentMutation } from "./mutations"; 13 | 14 | interface DeleteCommentDialogProps { 15 | comment: CommentData; 16 | open: boolean; 17 | onClose: () => void; 18 | } 19 | 20 | export default function DeleteCommentDialog({ 21 | comment, 22 | open, 23 | onClose, 24 | }: DeleteCommentDialogProps) { 25 | const mutation = useDeleteCommentMutation(); 26 | 27 | function handleOpenChange(open: boolean) { 28 | if (!open || !mutation.isPending) { 29 | onClose(); 30 | } 31 | } 32 | 33 | return ( 34 | 35 | 36 | 37 | Delete comment? 38 | 39 | Are you sure you want to delete this comment? This action cannot be 40 | undone. 41 | 42 | 43 | 44 | mutation.mutate(comment.id, { onSuccess: onClose })} 47 | loading={mutation.isPending} 48 | > 49 | Delete 50 | 51 | 58 | 59 | 60 | 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /src/components/comments/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { validateRequest } from "@/auth"; 4 | import prisma from "@/lib/prisma"; 5 | import { getCommentDataInclude, PostData } from "@/lib/types"; 6 | import { createCommentSchema } from "@/lib/validation"; 7 | 8 | export async function submitComment({ 9 | post, 10 | content, 11 | }: { 12 | post: PostData; 13 | content: string; 14 | }) { 15 | const { user } = await validateRequest(); 16 | 17 | if (!user) throw new Error("Unauthorized"); 18 | 19 | const { content: contentValidated } = createCommentSchema.parse({ content }); 20 | 21 | const [newComment] = await prisma.$transaction([ 22 | prisma.comment.create({ 23 | data: { 24 | content: contentValidated, 25 | postId: post.id, 26 | userId: user.id, 27 | }, 28 | include: getCommentDataInclude(user.id), 29 | }), 30 | ...(post.user.id !== user.id 31 | ? [ 32 | prisma.notification.create({ 33 | data: { 34 | issuerId: user.id, 35 | recipientId: post.user.id, 36 | postId: post.id, 37 | type: "COMMENT", 38 | }, 39 | }), 40 | ] 41 | : []), 42 | ]); 43 | 44 | return newComment; 45 | } 46 | 47 | export async function deleteComment(id: string) { 48 | const { user } = await validateRequest(); 49 | 50 | if (!user) throw new Error("Unauthorized"); 51 | 52 | const comment = await prisma.comment.findUnique({ 53 | where: { id }, 54 | }); 55 | 56 | if (!comment) throw new Error("Comment not found"); 57 | 58 | if (comment.userId !== user.id) throw new Error("Unauthorized"); 59 | 60 | const deletedComment = await prisma.comment.delete({ 61 | where: { id }, 62 | include: getCommentDataInclude(user.id), 63 | }); 64 | 65 | return deletedComment; 66 | } 67 | -------------------------------------------------------------------------------- /src/components/comments/mutations.ts: -------------------------------------------------------------------------------- 1 | import { CommentsPage } from "@/lib/types"; 2 | import { 3 | InfiniteData, 4 | QueryKey, 5 | useMutation, 6 | useQueryClient, 7 | } from "@tanstack/react-query"; 8 | import { useToast } from "../ui/use-toast"; 9 | import { deleteComment, submitComment } from "./actions"; 10 | 11 | export function useSubmitCommentMutation(postId: string) { 12 | const { toast } = useToast(); 13 | 14 | const queryClient = useQueryClient(); 15 | 16 | const mutation = useMutation({ 17 | mutationFn: submitComment, 18 | onSuccess: async (newComment) => { 19 | const queryKey: QueryKey = ["comments", postId]; 20 | 21 | await queryClient.cancelQueries({ queryKey }); 22 | 23 | queryClient.setQueryData>( 24 | queryKey, 25 | (oldData) => { 26 | const firstPage = oldData?.pages[0]; 27 | 28 | if (firstPage) { 29 | return { 30 | pageParams: oldData.pageParams, 31 | pages: [ 32 | { 33 | previousCursor: firstPage.previousCursor, 34 | comments: [...firstPage.comments, newComment], 35 | }, 36 | ...oldData.pages.slice(1), 37 | ], 38 | }; 39 | } 40 | }, 41 | ); 42 | 43 | queryClient.invalidateQueries({ 44 | queryKey, 45 | predicate(query) { 46 | return !query.state.data; 47 | }, 48 | }); 49 | 50 | toast({ 51 | description: "Comment created", 52 | }); 53 | }, 54 | onError(error) { 55 | console.error(error); 56 | toast({ 57 | variant: "destructive", 58 | description: "Failed to submit comment. Please try again.", 59 | }); 60 | }, 61 | }); 62 | 63 | return mutation; 64 | } 65 | 66 | export function useDeleteCommentMutation() { 67 | const { toast } = useToast(); 68 | 69 | const queryClient = useQueryClient(); 70 | 71 | const mutation = useMutation({ 72 | mutationFn: deleteComment, 73 | onSuccess: async (deletedComment) => { 74 | const queryKey: QueryKey = ["comments", deletedComment.postId]; 75 | 76 | await queryClient.cancelQueries({ queryKey }); 77 | 78 | queryClient.setQueryData>( 79 | queryKey, 80 | (oldData) => { 81 | if (!oldData) return; 82 | 83 | return { 84 | pageParams: oldData.pageParams, 85 | pages: oldData.pages.map((page) => ({ 86 | previousCursor: page.previousCursor, 87 | comments: page.comments.filter((c) => c.id !== deletedComment.id), 88 | })), 89 | }; 90 | }, 91 | ); 92 | 93 | toast({ 94 | description: "Comment deleted", 95 | }); 96 | }, 97 | onError(error) { 98 | console.error(error); 99 | toast({ 100 | variant: "destructive", 101 | description: "Failed to delete comment. Please try again.", 102 | }); 103 | }, 104 | }); 105 | 106 | return mutation; 107 | } 108 | -------------------------------------------------------------------------------- /src/components/posts/BookmarkButton.tsx: -------------------------------------------------------------------------------- 1 | import kyInstance from "@/lib/ky"; 2 | import { BookmarkInfo } from "@/lib/types"; 3 | import { cn } from "@/lib/utils"; 4 | import { 5 | QueryKey, 6 | useMutation, 7 | useQuery, 8 | useQueryClient, 9 | } from "@tanstack/react-query"; 10 | import { Bookmark } from "lucide-react"; 11 | import { useToast } from "../ui/use-toast"; 12 | 13 | interface BookmarkButtonProps { 14 | postId: string; 15 | initialState: BookmarkInfo; 16 | } 17 | 18 | export default function BookmarkButton({ 19 | postId, 20 | initialState, 21 | }: BookmarkButtonProps) { 22 | const { toast } = useToast(); 23 | 24 | const queryClient = useQueryClient(); 25 | 26 | const queryKey: QueryKey = ["bookmark-info", postId]; 27 | 28 | const { data } = useQuery({ 29 | queryKey, 30 | queryFn: () => 31 | kyInstance.get(`/api/posts/${postId}/bookmark`).json(), 32 | initialData: initialState, 33 | staleTime: Infinity, 34 | }); 35 | 36 | const { mutate } = useMutation({ 37 | mutationFn: () => 38 | data.isBookmarkedByUser 39 | ? kyInstance.delete(`/api/posts/${postId}/bookmark`) 40 | : kyInstance.post(`/api/posts/${postId}/bookmark`), 41 | onMutate: async () => { 42 | toast({ 43 | description: `Post ${data.isBookmarkedByUser ? "un" : ""}bookmarked`, 44 | }); 45 | 46 | await queryClient.cancelQueries({ queryKey }); 47 | 48 | const previousState = queryClient.getQueryData(queryKey); 49 | 50 | queryClient.setQueryData(queryKey, () => ({ 51 | isBookmarkedByUser: !previousState?.isBookmarkedByUser, 52 | })); 53 | 54 | return { previousState }; 55 | }, 56 | onError(error, variables, context) { 57 | queryClient.setQueryData(queryKey, context?.previousState); 58 | console.error(error); 59 | toast({ 60 | variant: "destructive", 61 | description: "Something went wrong. Please try again.", 62 | }); 63 | }, 64 | }); 65 | 66 | return ( 67 | 75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /src/components/posts/DeletePostDialog.tsx: -------------------------------------------------------------------------------- 1 | import { PostData } from "@/lib/types"; 2 | import LoadingButton from "../LoadingButton"; 3 | import { Button } from "../ui/button"; 4 | import { 5 | Dialog, 6 | DialogContent, 7 | DialogDescription, 8 | DialogFooter, 9 | DialogHeader, 10 | DialogTitle, 11 | } from "../ui/dialog"; 12 | import { useDeletePostMutation } from "./mutations"; 13 | 14 | interface DeletePostDialogProps { 15 | post: PostData; 16 | open: boolean; 17 | onClose: () => void; 18 | } 19 | 20 | export default function DeletePostDialog({ 21 | post, 22 | open, 23 | onClose, 24 | }: DeletePostDialogProps) { 25 | const mutation = useDeletePostMutation(); 26 | 27 | function handleOpenChange(open: boolean) { 28 | if (!open || !mutation.isPending) { 29 | onClose(); 30 | } 31 | } 32 | 33 | return ( 34 | 35 | 36 | 37 | Delete post? 38 | 39 | Are you sure you want to delete this post? This action cannot be 40 | undone. 41 | 42 | 43 | 44 | mutation.mutate(post.id, { onSuccess: onClose })} 47 | loading={mutation.isPending} 48 | > 49 | Delete 50 | 51 | 58 | 59 | 60 | 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /src/components/posts/LikeButton.tsx: -------------------------------------------------------------------------------- 1 | import kyInstance from "@/lib/ky"; 2 | import { LikeInfo } from "@/lib/types"; 3 | import { cn } from "@/lib/utils"; 4 | import { 5 | QueryKey, 6 | useMutation, 7 | useQuery, 8 | useQueryClient, 9 | } from "@tanstack/react-query"; 10 | import { Heart } from "lucide-react"; 11 | import { useToast } from "../ui/use-toast"; 12 | 13 | interface LikeButtonProps { 14 | postId: string; 15 | initialState: LikeInfo; 16 | } 17 | 18 | export default function LikeButton({ postId, initialState }: LikeButtonProps) { 19 | const { toast } = useToast(); 20 | 21 | const queryClient = useQueryClient(); 22 | 23 | const queryKey: QueryKey = ["like-info", postId]; 24 | 25 | const { data } = useQuery({ 26 | queryKey, 27 | queryFn: () => 28 | kyInstance.get(`/api/posts/${postId}/likes`).json(), 29 | initialData: initialState, 30 | staleTime: Infinity, 31 | }); 32 | 33 | const { mutate } = useMutation({ 34 | mutationFn: () => 35 | data.isLikedByUser 36 | ? kyInstance.delete(`/api/posts/${postId}/likes`) 37 | : kyInstance.post(`/api/posts/${postId}/likes`), 38 | onMutate: async () => { 39 | await queryClient.cancelQueries({ queryKey }); 40 | 41 | const previousState = queryClient.getQueryData(queryKey); 42 | 43 | queryClient.setQueryData(queryKey, () => ({ 44 | likes: 45 | (previousState?.likes || 0) + (previousState?.isLikedByUser ? -1 : 1), 46 | isLikedByUser: !previousState?.isLikedByUser, 47 | })); 48 | 49 | return { previousState }; 50 | }, 51 | onError(error, variables, context) { 52 | queryClient.setQueryData(queryKey, context?.previousState); 53 | console.error(error); 54 | toast({ 55 | variant: "destructive", 56 | description: "Something went wrong. Please try again.", 57 | }); 58 | }, 59 | }); 60 | 61 | return ( 62 | 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /src/components/posts/Post.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useSession } from "@/app/(main)/SessionProvider"; 4 | import { PostData } from "@/lib/types"; 5 | import { cn, formatRelativeDate } from "@/lib/utils"; 6 | import { Media } from "@prisma/client"; 7 | import { MessageSquare } from "lucide-react"; 8 | import Image from "next/image"; 9 | import Link from "next/link"; 10 | import { useState } from "react"; 11 | import Comments from "../comments/Comments"; 12 | import Linkify from "../Linkify"; 13 | import UserAvatar from "../UserAvatar"; 14 | import UserTooltip from "../UserTooltip"; 15 | import BookmarkButton from "./BookmarkButton"; 16 | import LikeButton from "./LikeButton"; 17 | import PostMoreButton from "./PostMoreButton"; 18 | 19 | interface PostProps { 20 | post: PostData; 21 | } 22 | 23 | export default function Post({ post }: PostProps) { 24 | const { user } = useSession(); 25 | 26 | const [showComments, setShowComments] = useState(false); 27 | 28 | return ( 29 |
30 |
31 |
32 | 33 | 34 | 35 | 36 | 37 |
38 | 39 | 43 | {post.user.displayName} 44 | 45 | 46 | 51 | {formatRelativeDate(post.createdAt)} 52 | 53 |
54 |
55 | {post.user.id === user.id && ( 56 | 60 | )} 61 |
62 | 63 |
{post.content}
64 |
65 | {!!post.attachments.length && ( 66 | 67 | )} 68 |
69 |
70 |
71 | like.userId === user.id), 76 | }} 77 | /> 78 | setShowComments(!showComments)} 81 | /> 82 |
83 | bookmark.userId === user.id, 88 | ), 89 | }} 90 | /> 91 |
92 | {showComments && } 93 |
94 | ); 95 | } 96 | 97 | interface MediaPreviewsProps { 98 | attachments: Media[]; 99 | } 100 | 101 | function MediaPreviews({ attachments }: MediaPreviewsProps) { 102 | return ( 103 |
1 && "sm:grid sm:grid-cols-2", 107 | )} 108 | > 109 | {attachments.map((m) => ( 110 | 111 | ))} 112 |
113 | ); 114 | } 115 | 116 | interface MediaPreviewProps { 117 | media: Media; 118 | } 119 | 120 | function MediaPreview({ media }: MediaPreviewProps) { 121 | if (media.type === "IMAGE") { 122 | return ( 123 | Attachment 130 | ); 131 | } 132 | 133 | if (media.type === "VIDEO") { 134 | return ( 135 |
136 |
142 | ); 143 | } 144 | 145 | return

Unsupported media type

; 146 | } 147 | 148 | interface CommentButtonProps { 149 | post: PostData; 150 | onClick: () => void; 151 | } 152 | 153 | function CommentButton({ post, onClick }: CommentButtonProps) { 154 | return ( 155 | 162 | ); 163 | } 164 | -------------------------------------------------------------------------------- /src/components/posts/PostMoreButton.tsx: -------------------------------------------------------------------------------- 1 | import { PostData } from "@/lib/types"; 2 | import { MoreHorizontal, Trash2 } from "lucide-react"; 3 | import { useState } from "react"; 4 | import { Button } from "../ui/button"; 5 | import { 6 | DropdownMenu, 7 | DropdownMenuContent, 8 | DropdownMenuItem, 9 | DropdownMenuTrigger, 10 | } from "../ui/dropdown-menu"; 11 | import DeletePostDialog from "./DeletePostDialog"; 12 | 13 | interface PostMoreButtonProps { 14 | post: PostData; 15 | className?: string; 16 | } 17 | 18 | export default function PostMoreButton({ 19 | post, 20 | className, 21 | }: PostMoreButtonProps) { 22 | const [showDeleteDialog, setShowDeleteDialog] = useState(false); 23 | 24 | return ( 25 | <> 26 | 27 | 28 | 31 | 32 | 33 | setShowDeleteDialog(true)}> 34 | 35 | 36 | Delete 37 | 38 | 39 | 40 | 41 | setShowDeleteDialog(false)} 45 | /> 46 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /src/components/posts/PostsLoadingSkeleton.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "../ui/skeleton"; 2 | 3 | export default function PostsLoadingSkeleton() { 4 | return ( 5 |
6 | 7 | 8 | 9 |
10 | ); 11 | } 12 | 13 | function PostLoadingSkeleton() { 14 | return ( 15 |
16 |
17 | 18 |
19 | 20 | 21 |
22 |
23 | 24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/components/posts/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { validateRequest } from "@/auth"; 4 | import prisma from "@/lib/prisma"; 5 | import { getPostDataInclude } from "@/lib/types"; 6 | 7 | export async function deletePost(id: string) { 8 | const { user } = await validateRequest(); 9 | 10 | if (!user) throw new Error("Unauthorized"); 11 | 12 | const post = await prisma.post.findUnique({ 13 | where: { id }, 14 | }); 15 | 16 | if (!post) throw new Error("Post not found"); 17 | 18 | if (post.userId !== user.id) throw new Error("Unauthorized"); 19 | 20 | const deletedPost = await prisma.post.delete({ 21 | where: { id }, 22 | include: getPostDataInclude(user.id), 23 | }); 24 | 25 | return deletedPost; 26 | } 27 | -------------------------------------------------------------------------------- /src/components/posts/editor/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { validateRequest } from "@/auth"; 4 | import prisma from "@/lib/prisma"; 5 | import { getPostDataInclude } from "@/lib/types"; 6 | import { createPostSchema } from "@/lib/validation"; 7 | 8 | export async function submitPost(input: { 9 | content: string; 10 | mediaIds: string[]; 11 | }) { 12 | const { user } = await validateRequest(); 13 | 14 | if (!user) throw new Error("Unauthorized"); 15 | 16 | const { content, mediaIds } = createPostSchema.parse(input); 17 | 18 | const newPost = await prisma.post.create({ 19 | data: { 20 | content, 21 | userId: user.id, 22 | attachments: { 23 | connect: mediaIds.map((id) => ({ id })), 24 | }, 25 | }, 26 | include: getPostDataInclude(user.id), 27 | }); 28 | 29 | return newPost; 30 | } 31 | -------------------------------------------------------------------------------- /src/components/posts/editor/mutations.ts: -------------------------------------------------------------------------------- 1 | import { useSession } from "@/app/(main)/SessionProvider"; 2 | import { useToast } from "@/components/ui/use-toast"; 3 | import { PostsPage } from "@/lib/types"; 4 | import { 5 | InfiniteData, 6 | QueryFilters, 7 | useMutation, 8 | useQueryClient, 9 | } from "@tanstack/react-query"; 10 | import { submitPost } from "./actions"; 11 | 12 | export function useSubmitPostMutation() { 13 | const { toast } = useToast(); 14 | 15 | const queryClient = useQueryClient(); 16 | 17 | const { user } = useSession(); 18 | 19 | const mutation = useMutation({ 20 | mutationFn: submitPost, 21 | onSuccess: async (newPost) => { 22 | const queryFilter = { 23 | queryKey: ["post-feed"], 24 | predicate(query) { 25 | return ( 26 | query.queryKey.includes("for-you") || 27 | (query.queryKey.includes("user-posts") && 28 | query.queryKey.includes(user.id)) 29 | ); 30 | }, 31 | } satisfies QueryFilters; 32 | 33 | await queryClient.cancelQueries(queryFilter); 34 | 35 | queryClient.setQueriesData>( 36 | queryFilter, 37 | (oldData) => { 38 | const firstPage = oldData?.pages[0]; 39 | 40 | if (firstPage) { 41 | return { 42 | pageParams: oldData.pageParams, 43 | pages: [ 44 | { 45 | posts: [newPost, ...firstPage.posts], 46 | nextCursor: firstPage.nextCursor, 47 | }, 48 | ...oldData.pages.slice(1), 49 | ], 50 | }; 51 | } 52 | }, 53 | ); 54 | 55 | queryClient.invalidateQueries({ 56 | queryKey: queryFilter.queryKey, 57 | predicate(query) { 58 | return queryFilter.predicate(query) && !query.state.data; 59 | }, 60 | }); 61 | 62 | toast({ 63 | description: "Post created", 64 | }); 65 | }, 66 | onError(error) { 67 | console.error(error); 68 | toast({ 69 | variant: "destructive", 70 | description: "Failed to post. Please try again.", 71 | }); 72 | }, 73 | }); 74 | 75 | return mutation; 76 | } 77 | -------------------------------------------------------------------------------- /src/components/posts/editor/styles.css: -------------------------------------------------------------------------------- 1 | .tiptap p.is-editor-empty:first-child::before { 2 | color: theme("colors.muted.foreground"); 3 | content: attr(data-placeholder); 4 | float: left; 5 | height: 0; 6 | pointer-events: none; 7 | } 8 | 9 | .tiptap.ProseMirror { 10 | outline: none; 11 | } 12 | -------------------------------------------------------------------------------- /src/components/posts/editor/useMediaUpload.ts: -------------------------------------------------------------------------------- 1 | import { useToast } from "@/components/ui/use-toast"; 2 | import { useUploadThing } from "@/lib/uploadthing"; 3 | import { useState } from "react"; 4 | 5 | export interface Attachment { 6 | file: File; 7 | mediaId?: string; 8 | isUploading: boolean; 9 | } 10 | 11 | export default function useMediaUpload() { 12 | const { toast } = useToast(); 13 | 14 | const [attachments, setAttachments] = useState([]); 15 | 16 | const [uploadProgress, setUploadProgress] = useState(); 17 | 18 | const { startUpload, isUploading } = useUploadThing("attachment", { 19 | onBeforeUploadBegin(files) { 20 | const renamedFiles = files.map((file) => { 21 | const extension = file.name.split(".").pop(); 22 | return new File( 23 | [file], 24 | `attachment_${crypto.randomUUID()}.${extension}`, 25 | { 26 | type: file.type, 27 | }, 28 | ); 29 | }); 30 | 31 | setAttachments((prev) => [ 32 | ...prev, 33 | ...renamedFiles.map((file) => ({ file, isUploading: true })), 34 | ]); 35 | 36 | return renamedFiles; 37 | }, 38 | onUploadProgress: setUploadProgress, 39 | onClientUploadComplete(res) { 40 | setAttachments((prev) => 41 | prev.map((a) => { 42 | const uploadResult = res.find((r) => r.name === a.file.name); 43 | 44 | if (!uploadResult) return a; 45 | 46 | return { 47 | ...a, 48 | mediaId: uploadResult.serverData.mediaId, 49 | isUploading: false, 50 | }; 51 | }), 52 | ); 53 | }, 54 | onUploadError(e) { 55 | setAttachments((prev) => prev.filter((a) => !a.isUploading)); 56 | toast({ 57 | variant: "destructive", 58 | description: e.message, 59 | }); 60 | }, 61 | }); 62 | 63 | function handleStartUpload(files: File[]) { 64 | if (isUploading) { 65 | toast({ 66 | variant: "destructive", 67 | description: "Please wait for the current upload to finish.", 68 | }); 69 | return; 70 | } 71 | 72 | if (attachments.length + files.length > 5) { 73 | toast({ 74 | variant: "destructive", 75 | description: "You can only upload up to 5 attachments per post.", 76 | }); 77 | return; 78 | } 79 | 80 | startUpload(files); 81 | } 82 | 83 | function removeAttachment(fileName: string) { 84 | setAttachments((prev) => prev.filter((a) => a.file.name !== fileName)); 85 | } 86 | 87 | function reset() { 88 | setAttachments([]); 89 | setUploadProgress(undefined); 90 | } 91 | 92 | return { 93 | startUpload: handleStartUpload, 94 | attachments, 95 | isUploading, 96 | uploadProgress, 97 | removeAttachment, 98 | reset, 99 | }; 100 | } 101 | -------------------------------------------------------------------------------- /src/components/posts/mutations.ts: -------------------------------------------------------------------------------- 1 | import { PostsPage } from "@/lib/types"; 2 | import { 3 | InfiniteData, 4 | QueryFilters, 5 | useMutation, 6 | useQueryClient, 7 | } from "@tanstack/react-query"; 8 | import { usePathname, useRouter } from "next/navigation"; 9 | import { useToast } from "../ui/use-toast"; 10 | import { deletePost } from "./actions"; 11 | 12 | export function useDeletePostMutation() { 13 | const { toast } = useToast(); 14 | 15 | const queryClient = useQueryClient(); 16 | 17 | const router = useRouter(); 18 | const pathname = usePathname(); 19 | 20 | const mutation = useMutation({ 21 | mutationFn: deletePost, 22 | onSuccess: async (deletedPost) => { 23 | const queryFilter: QueryFilters = { queryKey: ["post-feed"] }; 24 | 25 | await queryClient.cancelQueries(queryFilter); 26 | 27 | queryClient.setQueriesData>( 28 | queryFilter, 29 | (oldData) => { 30 | if (!oldData) return; 31 | 32 | return { 33 | pageParams: oldData.pageParams, 34 | pages: oldData.pages.map((page) => ({ 35 | nextCursor: page.nextCursor, 36 | posts: page.posts.filter((p) => p.id !== deletedPost.id), 37 | })), 38 | }; 39 | }, 40 | ); 41 | 42 | toast({ 43 | description: "Post deleted", 44 | }); 45 | 46 | if (pathname === `/posts/${deletedPost.id}`) { 47 | router.push(`/users/${deletedPost.user.username}`); 48 | } 49 | }, 50 | onError(error) { 51 | console.error(error); 52 | toast({ 53 | variant: "destructive", 54 | description: "Failed to delete post. Please try again.", 55 | }); 56 | }, 57 | }); 58 | 59 | return mutation; 60 | } 61 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { X } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = DialogPrimitive.Portal 14 | 15 | const DialogClose = DialogPrimitive.Close 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )) 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )) 54 | DialogContent.displayName = DialogPrimitive.Content.displayName 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes) => ( 60 |
67 | ) 68 | DialogHeader.displayName = "DialogHeader" 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes) => ( 74 |
81 | ) 82 | DialogFooter.displayName = "DialogFooter" 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 96 | )) 97 | DialogTitle.displayName = DialogPrimitive.Title.displayName 98 | 99 | const DialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | DialogDescription.displayName = DialogPrimitive.Description.displayName 110 | 111 | export { 112 | Dialog, 113 | DialogPortal, 114 | DialogOverlay, 115 | DialogClose, 116 | DialogTrigger, 117 | DialogContent, 118 | DialogHeader, 119 | DialogFooter, 120 | DialogTitle, 121 | DialogDescription, 122 | } 123 | -------------------------------------------------------------------------------- /src/components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { Slot } from "@radix-ui/react-slot" 6 | import { 7 | Controller, 8 | ControllerProps, 9 | FieldPath, 10 | FieldValues, 11 | FormProvider, 12 | useFormContext, 13 | } from "react-hook-form" 14 | 15 | import { cn } from "@/lib/utils" 16 | import { Label } from "@/components/ui/label" 17 | 18 | const Form = FormProvider 19 | 20 | type FormFieldContextValue< 21 | TFieldValues extends FieldValues = FieldValues, 22 | TName extends FieldPath = FieldPath 23 | > = { 24 | name: TName 25 | } 26 | 27 | const FormFieldContext = React.createContext( 28 | {} as FormFieldContextValue 29 | ) 30 | 31 | const FormField = < 32 | TFieldValues extends FieldValues = FieldValues, 33 | TName extends FieldPath = FieldPath 34 | >({ 35 | ...props 36 | }: ControllerProps) => { 37 | return ( 38 | 39 | 40 | 41 | ) 42 | } 43 | 44 | const useFormField = () => { 45 | const fieldContext = React.useContext(FormFieldContext) 46 | const itemContext = React.useContext(FormItemContext) 47 | const { getFieldState, formState } = useFormContext() 48 | 49 | const fieldState = getFieldState(fieldContext.name, formState) 50 | 51 | if (!fieldContext) { 52 | throw new Error("useFormField should be used within ") 53 | } 54 | 55 | const { id } = itemContext 56 | 57 | return { 58 | id, 59 | name: fieldContext.name, 60 | formItemId: `${id}-form-item`, 61 | formDescriptionId: `${id}-form-item-description`, 62 | formMessageId: `${id}-form-item-message`, 63 | ...fieldState, 64 | } 65 | } 66 | 67 | type FormItemContextValue = { 68 | id: string 69 | } 70 | 71 | const FormItemContext = React.createContext( 72 | {} as FormItemContextValue 73 | ) 74 | 75 | const FormItem = React.forwardRef< 76 | HTMLDivElement, 77 | React.HTMLAttributes 78 | >(({ className, ...props }, ref) => { 79 | const id = React.useId() 80 | 81 | return ( 82 | 83 |
84 | 85 | ) 86 | }) 87 | FormItem.displayName = "FormItem" 88 | 89 | const FormLabel = React.forwardRef< 90 | React.ElementRef, 91 | React.ComponentPropsWithoutRef 92 | >(({ className, ...props }, ref) => { 93 | const { error, formItemId } = useFormField() 94 | 95 | return ( 96 |