Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

My focus in this code is to master Nested Comments, reply to a reply. I myself have tried main ways but I'm falling to add nested comments #3109

Open
evansmunsha opened this issue Sep 6, 2024 · 0 comments

Comments

@evansmunsha
Copy link

the code below is built in nextjs 15 using TypeScript
prisma

My focus in this code is to master Nested Comments, reply to a reply. I myself have tried main ways but I'm falling to add nested comments, so please how can I update this code and to make sure that I can comment on a post and also to leave a reply to a comment. any help, I will be thankful
This is my Prisma schema file

generator client {
provider = "prisma-client-js"
previewFeatures = ["fullTextSearch"]
}

datasource db {
provider = "postgresql"
url = env("POSTGRES_PRISMA_URL") // uses connection pooling
directUrl = env("POSTGRES_URL_NON_POOLING") // uses a direct connection
}

enum VoteType {
UP
DOWN
}

model User {
id String @id
username String @unique
displayName String
email String? @unique
passwordHash String?
googleId String? @unique
avatarUrl String?
bio String?
sessions Session[]
posts Post[]
following Follow[] @relation("Following")
followers Follow[] @relation("Followers")
likes Like[]
bookmarks Bookmark[]
comments Comment[]
votes CommentVote[]
reply Reply[]
receivedNotifications Notification[] @relation("Recipient")
issuedNotifications Notification[] @relation("Issuer")

createdAt DateTime @default(now())

@@Map("users")
}

model Session {
id String @id @default(cuid())
sessionToken String? @unique
userId String
expiresAt DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

@@Map("sessions")
}

model Follow {
followerId String
follower User @relation("Following", fields: [followerId], references: [id], onDelete: Cascade)
followingId String
following User @relation("Followers", fields: [followingId], references: [id], onDelete: Cascade)

@@unique([followerId, followingId])
@@Map("follows")
}

model Post {
id String @id @default(cuid())
content String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
attachments Media[]
likes Like[]
bookmarks Bookmark[]
comments Comment[]
linkedNotifications Notification[]

createdAt DateTime @default(now())

@@Map("posts")
}

model Media {
id String @id @default(cuid())
postId String?
post Post? @relation(fields: [postId], references: [id], onDelete: SetNull)
type MediaType
url String

createdAt DateTime @default(now())

@@Map("post_media")
}

enum MediaType {
IMAGE
VIDEO
}

model Comment {
id String @id @default(cuid())
content String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
postId String
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())

parentId String?
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id])
replies Comment[] @relation("CommentReplies")
@@Map("comments")
}

model CommentVote {
user User @relation(fields: [userId], references: [id])
userId String
commentId String
type VoteType

@@id([userId, commentId])
}

model Reply {
id String @id @default(cuid())
content String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
commentId String?
comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())

@@Map("reply")
}

model Like {
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
postId String
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)

@@unique([userId, postId])
@@Map("likes")
}

model Bookmark {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
postId String
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)

createdAt DateTime @default(now())

@@unique([userId, postId])
@@Map("bookmarks")
}

model Notification {
id String @id @default(cuid())
recipientId String
recipient User @relation("Recipient", fields: [recipientId], references: [id], onDelete: Cascade)
issuerId String
issuer User @relation("Issuer", fields: [issuerId], references: [id], onDelete: Cascade)
postId String?
post Post? @relation(fields: [postId], references: [id], onDelete: Cascade)
type NotificationType
read Boolean @default(false)

createdAt DateTime @default(now())

@@Map("notifications")
}

enum NotificationType {
LIKE
FOLLOW
COMMENT
}

src/lib/types.ts

import { Prisma } from "@prisma/client";

export function getUserDataSelect(loggedInUserId: string) {
return {
id: true,
username: true,
displayName: true,
avatarUrl: true,
bio: true,
createdAt: true,
followers: {
where: {
followerId: loggedInUserId,
},
select: {
followerId: true,
},
},
_count: {
select: {
posts: true,
followers: true,
},
},
} satisfies Prisma.UserSelect;
}

export type UserData = Prisma.UserGetPayload<{
select: ReturnType;
}>;

export function getPostDataInclude(loggedInUserId: string) {
return {
user: {
select: getUserDataSelect(loggedInUserId),
},
attachments: true,
likes: {
where: {
userId: loggedInUserId,
},
select: {
userId: true,
},
},
bookmarks: {
where: {
userId: loggedInUserId,
},
select: {
userId: true,
},
},
_count: {
select: {
likes: true,
comments: true,
},
},
} satisfies Prisma.PostInclude;
}

export type PostData = Prisma.PostGetPayload<{
include: ReturnType;
}>;

export interface PostsPage {
posts: PostData[];
nextCursor: string | null;
}

export function getCommentDataInclude(loggedInUserId: string) {
return {
user: {
select: getUserDataSelect(loggedInUserId),
},

} satisfies Prisma.CommentInclude;
}

export type CommentData = Prisma.CommentGetPayload<{
include: ReturnType;
}>;

export interface CommentsPage {
comments: CommentData[];
previousCursor: string | null;
}

export const notificationsInclude = {
issuer: {
select: {
username: true,
displayName: true,
avatarUrl: true,
},
},
post: {
select: {
content: true,
},
},
} satisfies Prisma.NotificationInclude;

export type NotificationData = Prisma.NotificationGetPayload<{
include: typeof notificationsInclude;
}>;

export interface NotificationsPage {
notifications: NotificationData[];
nextCursor: string | null;
}

export interface FollowerInfo {
followers: number;
isFollowedByUser: boolean;
}

export interface LikeInfo {
likes: number;
isLikedByUser: boolean;
}

export interface BookmarkInfo {
isBookmarkedByUser: boolean;
}

export interface NotificationCountInfo {
unreadCount: number;
}

export interface MessageCountInfo {
unreadCount: number;
}

src/app/api/posts/[postId]/comments/route.ts

import { validateRequest } from "@/auth";
import prisma from "@/lib/prisma";
import { CommentsPage, getCommentDataInclude } from "@/lib/types";
import { NextRequest } from "next/server";

export async function GET(
req: NextRequest,
{ params: { postId } }: { params: { postId: string } },
) {
try {
const cursor = req.nextUrl.searchParams.get("cursor") || undefined;

const pageSize = 5;

const { user } = await validateRequest();

if (!user) {
  return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const comments = await prisma.comment.findMany({
  where: { postId },
  include: getCommentDataInclude(user.id),
  orderBy: { createdAt: "asc" },
  take: -pageSize - 1,
  cursor: cursor ? { id: cursor } : undefined,
});

const previousCursor = comments.length > pageSize ? comments[0].id : null;

const data: CommentsPage = {
  comments: comments.length > pageSize ? comments.slice(1) : comments,
  previousCursor,
};

return Response.json(data);

} catch (error) {
console.error(error);
return Response.json({ error: "Internal server error" }, { status: 500 });
}
}

src/lib/validation.ts

import { z } from "zod";

const requiredString = z.string().trim().min(1, "Required");

export const signUpSchema = z.object({
email: requiredString.email("Invalid email address"),
username: requiredString.regex(
/^[a-zA-Z0-9_-]+$/,
"Only letters, numbers, - and _ allowed",
),
password: requiredString.min(8, "Must be at least 8 characters"),
});

export type SignUpValues = z.infer;

export const loginSchema = z.object({
username: requiredString,
password: requiredString,
});

export type LoginValues = z.infer;

export const createPostSchema = z.object({
content: requiredString,
mediaIds: z.array(z.string()).max(5, "Cannot have more than 5 attachments"),
});

export const updateUserProfileSchema = z.object({
displayName: requiredString,
bio: z.string().max(1000, "Must be at most 1000 characters"),
});

export type UpdateUserProfileValues = z.infer;

export const createCommentSchema = z.object({
content: requiredString,

});

src/lib/validation.ts

export const CommentVoteValidator = z.object({
commentId: z.string(),
voteType: z.enum(['UP', 'DOWN']),
})

export type CommentVoteRequest = z.infer

src/components/comments/actions.ts

"use server";

import { validateRequest } from "@/auth";
import prisma from "@/lib/prisma";
import { getCommentDataInclude, PostData } from "@/lib/types";
import { createCommentSchema } from "@/lib/validation";

export async function submitComment({
post,
content,
}: {
post: PostData;
content: string;
}) {
const { user } = await validateRequest();

if (!user) throw new Error("Unauthorized");

const { content: contentValidated } = createCommentSchema.parse({ content });

const [newComment] = await prisma.$transaction([
prisma.comment.create({
data: {
content: contentValidated,
postId: post.id,
userId: user.id,
},
include: getCommentDataInclude(user.id),
}),
...(post.user.id !== user.id
? [
prisma.notification.create({
data: {
issuerId: user.id,
recipientId: post.user.id,
postId: post.id,
type: "COMMENT",
},
}),
]
: []),
]);

return newComment;
}

export async function deleteComment(id: string) {
const { user } = await validateRequest();

if (!user) throw new Error("Unauthorized");

const comment = await prisma.comment.findUnique({
where: { id },
});

if (!comment) throw new Error("Comment not found");

if (comment.userId !== user.id) throw new Error("Unauthorized");

const deletedComment = await prisma.comment.delete({
where: { id },
include: getCommentDataInclude(user.id),
});

return deletedComment;
}

src/components/comments/mutations.ts

import { CommentsPage } from "@/lib/types";
import {
InfiniteData,
QueryKey,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import { useToast } from "../ui/use-toast";
import { deleteComment, submitComment } from "./actions";

export function useSubmitCommentMutation(postId: string) {
const { toast } = useToast();

const queryClient = useQueryClient();

const mutation = useMutation({
mutationFn: submitComment,
onSuccess: async (newComment) => {
const queryKey: QueryKey = ["comments", postId];

  await queryClient.cancelQueries({ queryKey });

  queryClient.setQueryData<InfiniteData<CommentsPage, string | null>>(
    queryKey,
    (oldData) => {
      const firstPage = oldData?.pages[0];

      if (firstPage) {
        return {
          pageParams: oldData.pageParams,
          pages: [
            {
              previousCursor: firstPage.previousCursor,
              comments: [...firstPage.comments, newComment],
            },
            ...oldData.pages.slice(1),
          ],
        };
      }
    },
  );

  queryClient.invalidateQueries({
    queryKey,
    predicate(query) {
      return !query.state.data;
    },
  });

  toast({
    description: "Comment created",
  });
},
onError(error) {
  console.error(error);
  toast({
    variant: "destructive",
    description: "Failed to submit comment. Please try again.",
  });
},

});

return mutation;
}

export function useDeleteCommentMutation() {
const { toast } = useToast();

const queryClient = useQueryClient();

const mutation = useMutation({
mutationFn: deleteComment,
onSuccess: async (deletedComment) => {
const queryKey: QueryKey = ["comments", deletedComment.postId];

  await queryClient.cancelQueries({ queryKey });

  queryClient.setQueryData<InfiniteData<CommentsPage, string | null>>(
    queryKey,
    (oldData) => {
      if (!oldData) return;

      return {
        pageParams: oldData.pageParams,
        pages: oldData.pages.map((page) => ({
          previousCursor: page.previousCursor,
          comments: page.comments.filter((c) => c.id !== deletedComment.id),
        })),
      };
    },
  );

  toast({
    description: "Comment deleted",
  });
},
onError(error) {
  console.error(error);
  toast({
    variant: "destructive",
    description: "Failed to delete comment. Please try again.",
  });
},

});

return mutation;
}

src/components/comments/DeleteCommentDialog.tsx

import { CommentData } from "@/lib/types";
import LoadingButton from "../LoadingButton";
import { Button } from "../ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
import { useDeleteCommentMutation } from "./mutations";

interface DeleteCommentDialogProps {
comment: CommentData;
open: boolean;
onClose: () => void;
}

export default function DeleteCommentDialog({
comment,
open,
onClose,
}: DeleteCommentDialogProps) {
const mutation = useDeleteCommentMutation();

function handleOpenChange(open: boolean) {
if (!open || !mutation.isPending) {
onClose();
}
}

return (



Delete comment?

Are you sure you want to delete this comment? This action cannot be
undone.



<LoadingButton
variant="destructive"
onClick={() => mutation.mutate(comment.id, { onSuccess: onClose })}
loading={mutation.isPending}
>
Delete


Cancel




);
}

src/components/comments/Comment.tsx

import { useSession } from "@/app/(main)/SessionProvider";
import { CommentData } from "@/lib/types";
import { formatRelativeDate } from "@/lib/utils";
import Link from "next/link";
import UserAvatar from "../UserAvatar";
import UserTooltip from "../UserTooltip";
import CommentMoreButton from "./CommentMoreButton";

interface CommentProps {
comment: CommentData;
}

export default function Comment({ comment }: CommentProps) {
const { user } = useSession();

return (




<Link href={/users/${comment.user.username}}>







<Link
href={/users/${comment.user.username}}
className="font-medium hover:underline"
>
{comment.user.displayName}



{formatRelativeDate(comment.createdAt)}


{comment.content}


{comment.user.id === user.id && (

)}

);
}

src/components/comments/CommentInput.tsx

import { PostData } from "@/lib/types";
import { Loader2, SendHorizonal } from "lucide-react";
import { useState } from "react";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { useSubmitCommentMutation } from "./mutations";

interface CommentInputProps {
post: PostData;
}

export default function CommentInput({ post }: CommentInputProps) {
const [input, setInput] = useState("");

const mutation = useSubmitCommentMutation(post.id);

async function onSubmit(e: React.FormEvent) {
e.preventDefault();

if (!input) return;

mutation.mutate(
  {
    post,
    content: input,
  },
  {
    onSuccess: () => setInput(""),
  },
);

}

return (


<Input
placeholder="Write a comment..."
value={input}
onChange={(e) => setInput(e.target.value)}
autoFocus
/>
<Button
type="submit"
variant="ghost"
size="icon"
disabled={!input.trim() || mutation.isPending}
>
{!mutation.isPending ? (

) : (

)}


);
}

src/components/comments/CommentMoreButton.tsx

import { CommentData } from "@/lib/types";
import { MoreHorizontal, Trash2 } from "lucide-react";
import { useState } from "react";
import { Button } from "../ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import DeleteCommentDialog from "./DeleteCommentDialog";

interface CommentMoreButtonProps {
comment: CommentData;
className?: string;
}

export default function CommentMoreButton({
comment,
className,
}: CommentMoreButtonProps) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);

return (
<>







<DropdownMenuItem onClick={() => setShowDeleteDialog(true)}>


Delete




<DeleteCommentDialog
comment={comment}
open={showDeleteDialog}
onClose={() => setShowDeleteDialog(false)}
/>
</>
);
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant