├── .eslintrc.json ├── prettier.config.js ├── src ├── app │ ├── favicon.ico │ ├── fonts │ │ ├── GeistVF.woff │ │ └── GeistMonoVF.woff │ ├── loading.tsx │ ├── (main) │ │ ├── loading.tsx │ │ ├── messages │ │ │ ├── page.tsx │ │ │ ├── ChatChannel.tsx │ │ │ ├── useInitializeChatClient.ts │ │ │ ├── Chat.tsx │ │ │ └── ChatSidebar.tsx │ │ ├── not-found.tsx │ │ ├── Navbar.tsx │ │ ├── bookmarks │ │ │ ├── page.tsx │ │ │ └── Bookmarks.tsx │ │ ├── notifications │ │ │ ├── page.tsx │ │ │ ├── Notification.tsx │ │ │ └── Notifications.tsx │ │ ├── users │ │ │ └── [username] │ │ │ │ ├── EditProfileButton.tsx │ │ │ │ ├── actions.ts │ │ │ │ ├── UserPosts.tsx │ │ │ │ ├── mutations.ts │ │ │ │ └── page.tsx │ │ ├── SessionProvider.tsx │ │ ├── search │ │ │ ├── page.tsx │ │ │ └── SearchResults.tsx │ │ ├── layout.tsx │ │ ├── page.tsx │ │ ├── MessagesButton.tsx │ │ ├── NotificationsButton.tsx │ │ ├── MenuBar.tsx │ │ ├── ForYouFeed.tsx │ │ ├── FollowingFeed.tsx │ │ └── posts │ │ │ └── [postId] │ │ │ └── page.tsx │ ├── api │ │ ├── uploadthing │ │ │ ├── route.ts │ │ │ └── core.ts │ │ ├── notifications │ │ │ ├── mark-as-read │ │ │ │ └── route.ts │ │ │ ├── unread-count │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── messages │ │ │ └── unread-count │ │ │ │ └── route.ts │ │ ├── get-token │ │ │ └── route.ts │ │ ├── users │ │ │ ├── username │ │ │ │ └── [username] │ │ │ │ │ └── route.ts │ │ │ └── [userId] │ │ │ │ ├── posts │ │ │ │ └── route.ts │ │ │ │ └── followers │ │ │ │ └── route.ts │ │ ├── posts │ │ │ ├── for-you │ │ │ │ └── route.ts │ │ │ ├── [postId] │ │ │ │ ├── comments │ │ │ │ │ └── route.ts │ │ │ │ ├── bookmark │ │ │ │ │ └── route.ts │ │ │ │ └── likes │ │ │ │ │ └── route.ts │ │ │ ├── following │ │ │ │ └── route.ts │ │ │ └── bookmarked │ │ │ │ └── route.ts │ │ ├── clear-uploads │ │ │ └── route.ts │ │ ├── search │ │ │ └── route.ts │ │ └── auth │ │ │ └── callback │ │ │ └── google │ │ │ └── route.ts │ ├── (auth) │ │ ├── layout.tsx │ │ ├── actions.ts │ │ ├── login │ │ │ ├── google │ │ │ │ ├── route.ts │ │ │ │ └── GoogleSignInButton.tsx │ │ │ ├── page.tsx │ │ │ ├── actions.ts │ │ │ └── LoginForm.tsx │ │ └── signup │ │ │ ├── page.tsx │ │ │ ├── actions.ts │ │ │ └── SignUpForm.tsx │ ├── ReactQueryProvider.tsx │ ├── layout.tsx │ └── globals.css ├── assets │ ├── login-image.jpg │ ├── signup-image.jpg │ └── avatar-placeholder.png ├── lib │ ├── stream.ts │ ├── uploadthing.ts │ ├── ky.ts │ ├── prisma.ts │ ├── utils.ts │ ├── validation.ts │ └── types.ts ├── components │ ├── posts │ │ ├── editor │ │ │ ├── styles.css │ │ │ ├── actions.ts │ │ │ ├── mutations.ts │ │ │ └── useMediaUpload.ts │ │ ├── actions.ts │ │ ├── PostsLoadingSkeleton.tsx │ │ ├── PostMoreButton.tsx │ │ ├── DeletePostDialog.tsx │ │ ├── mutations.ts │ │ ├── BookmarkButton.tsx │ │ ├── LikeButton.tsx │ │ └── Post.tsx │ ├── ui │ │ ├── skeleton.tsx │ │ ├── label.tsx │ │ ├── textarea.tsx │ │ ├── input.tsx │ │ ├── toaster.tsx │ │ ├── tooltip.tsx │ │ ├── button.tsx │ │ ├── tabs.tsx │ │ ├── dialog.tsx │ │ ├── use-toast.ts │ │ └── form.tsx │ ├── FollowerCount.tsx │ ├── LoadingButton.tsx │ ├── InfiniteScrollContainer.tsx │ ├── UserAvatar.tsx │ ├── SearchField.tsx │ ├── PasswordInput.tsx │ ├── UserLinkWithTooltip.tsx │ ├── Linkify.tsx │ ├── comments │ │ ├── CommentInput.tsx │ │ ├── CommentMoreButton.tsx │ │ ├── Comment.tsx │ │ ├── DeleteCommentDialog.tsx │ │ ├── actions.ts │ │ ├── Comments.tsx │ │ └── mutations.ts │ ├── CropImageDialog.tsx │ ├── FollowButton.tsx │ ├── UserTooltip.tsx │ ├── UserButton.tsx │ └── TrendsSidebar.tsx ├── hooks │ ├── useDebounce.ts │ └── useFollowerInfo.ts └── auth.ts ├── vercel.json ├── postcss.config.mjs ├── README.md ├── public ├── vercel.svg ├── file-text.svg ├── window.svg ├── next.svg └── globe.svg ├── components.json ├── .gitignore ├── next.config.mjs ├── tsconfig.json ├── package.json ├── tailwind.config.ts └── prisma └── schema.prisma /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "prettier"] 3 | } 4 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: ["prettier-plugin-tailwindcss"], 3 | }; 4 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codinginflow/nextjs-15-social-media-app/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/fonts/GeistVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codinginflow/nextjs-15-social-media-app/HEAD/src/app/fonts/GeistVF.woff -------------------------------------------------------------------------------- /src/assets/login-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codinginflow/nextjs-15-social-media-app/HEAD/src/assets/login-image.jpg -------------------------------------------------------------------------------- /src/assets/signup-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codinginflow/nextjs-15-social-media-app/HEAD/src/assets/signup-image.jpg -------------------------------------------------------------------------------- /src/app/fonts/GeistMonoVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codinginflow/nextjs-15-social-media-app/HEAD/src/app/fonts/GeistMonoVF.woff -------------------------------------------------------------------------------- /src/assets/avatar-placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codinginflow/nextjs-15-social-media-app/HEAD/src/assets/avatar-placeholder.png -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "crons": [ 3 | { 4 | "path": "/api/clear-uploads", 5 | "schedule": "0 2 * * *" 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /src/app/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Loader2 } from "lucide-react"; 2 | 3 | export default function Loading() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/app/(main)/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Loader2 } from "lucide-react"; 2 | 3 | export default function Loading() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /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/lib/stream.ts: -------------------------------------------------------------------------------- 1 | import { StreamChat } from "stream-chat"; 2 | 3 | const streamServerClient = StreamChat.getInstance( 4 | process.env.NEXT_PUBLIC_STREAM_KEY!, 5 | process.env.STREAM_SECRET, 6 | ); 7 | 8 | export default streamServerClient; 9 | -------------------------------------------------------------------------------- /src/lib/uploadthing.ts: -------------------------------------------------------------------------------- 1 | import { AppFileRouter } from "@/app/api/uploadthing/core"; 2 | import { generateReactHelpers } from "@uploadthing/react"; 3 | 4 | export const { useUploadThing, uploadFiles } = 5 | generateReactHelpers(); 6 | -------------------------------------------------------------------------------- /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/lib/ky.ts: -------------------------------------------------------------------------------- 1 | import ky from "ky"; 2 | 3 | const kyInstance = ky.create({ 4 | parseJson: (text) => 5 | JSON.parse(text, (key, value) => { 6 | if (key.endsWith("At")) return new Date(value); 7 | return value; 8 | }), 9 | }); 10 | 11 | export default kyInstance; 12 | -------------------------------------------------------------------------------- /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/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/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
12 | ) 13 | } 14 | 15 | export { Skeleton } 16 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /src/lib/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | const prismaClientSingleton = () => { 4 | return new PrismaClient(); 5 | }; 6 | 7 | declare global { 8 | var prismaGlobal: undefined | ReturnType; 9 | } 10 | 11 | const prisma = globalThis.prismaGlobal ?? prismaClientSingleton(); 12 | 13 | export default prisma; 14 | 15 | if (process.env.NODE_ENV !== "production") globalThis.prismaGlobal = prisma; 16 | -------------------------------------------------------------------------------- /src/hooks/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export default function useDebounce(value: T, delay: number = 250): T { 4 | const [debouncedValue, setDebouncedValue] = useState(value); 5 | 6 | useEffect(() => { 7 | const handler = setTimeout(() => { 8 | setDebouncedValue(value); 9 | }, delay); 10 | 11 | return () => clearTimeout(handler); 12 | }, [value, delay]); 13 | 14 | return debouncedValue; 15 | } 16 | -------------------------------------------------------------------------------- /src/hooks/useFollowerInfo.ts: -------------------------------------------------------------------------------- 1 | import kyInstance from "@/lib/ky"; 2 | import { FollowerInfo } from "@/lib/types"; 3 | import { useQuery } from "@tanstack/react-query"; 4 | 5 | export default function useFollowerInfo( 6 | userId: string, 7 | initialState: FollowerInfo, 8 | ) { 9 | const query = useQuery({ 10 | queryKey: ["follower-info", userId], 11 | queryFn: () => 12 | kyInstance.get(`/api/users/${userId}/followers`).json(), 13 | initialData: initialState, 14 | staleTime: Infinity, 15 | }); 16 | 17 | return query; 18 | } 19 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/(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 | -------------------------------------------------------------------------------- /public/file-text.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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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)/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/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/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 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /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/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)/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/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/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/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/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/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /src/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/components/ui/textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface TextareaProps 6 | extends React.TextareaHTMLAttributes {} 7 | 8 | const Textarea = React.forwardRef( 9 | ({ className, ...props }, ref) => { 10 | return ( 11 |