mirror of
https://github.com/SrIzan10/nextbooru.git
synced 2026-06-06 00:57:02 +00:00
feat: comments
This commit is contained in:
18
prisma/migrations/20250110222317_add_comments/migration.sql
Normal file
18
prisma/migrations/20250110222317_add_comments/migration.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Comment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"postId" TEXT NOT NULL,
|
||||
"authorId" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"upvotes" INTEGER NOT NULL,
|
||||
"downvotes" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "Comment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `downvotes` on the `Comment` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Comment" DROP COLUMN "downvotes",
|
||||
ADD COLUMN "votedBy" TEXT[];
|
||||
@@ -20,6 +20,7 @@ model User {
|
||||
likedPosts String[]
|
||||
sessions Session[]
|
||||
posts Post[]
|
||||
comments Comment[]
|
||||
}
|
||||
|
||||
model Session {
|
||||
@@ -38,4 +39,18 @@ model Post {
|
||||
authorId String
|
||||
author User @relation(references: [id], fields: [authorId], onDelete: Cascade)
|
||||
previewHash String
|
||||
|
||||
comments Comment[]
|
||||
}
|
||||
|
||||
model Comment {
|
||||
id String @id @default(cuid())
|
||||
postId String
|
||||
authorId String
|
||||
content String
|
||||
createdAt DateTime @default(now())
|
||||
upvotes Int
|
||||
votedBy String[]
|
||||
post Post @relation(references: [id], fields: [postId], onDelete: Cascade)
|
||||
author User @relation(references: [id], fields: [authorId], onDelete: Cascade)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import Comments from '@/components/app/Comments/Comments';
|
||||
import LikePost from '@/components/app/LikePost/LikePost';
|
||||
import RelatedImages, { RelatedImagesSkeleton } from '@/components/app/RelatedImages/RelatedImages';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -8,6 +9,7 @@ import { validateRequest } from '@/lib/auth';
|
||||
import prisma from '@/lib/db';
|
||||
import { Download, Flag, MessageSquare, User, Calendar } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
||||
@@ -46,14 +48,18 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
|
||||
<Flag className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button>
|
||||
<MessageSquare className="h-4 w-4 mr-2" />
|
||||
Comments
|
||||
</Button>
|
||||
<Link href={'#comments'}>
|
||||
<Button>
|
||||
<MessageSquare className="h-4 w-4 mr-2" />
|
||||
Comments
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<p>comments here</p>
|
||||
<Suspense fallback={<div>Loading comments...</div>}>
|
||||
<Comments id={id} />
|
||||
</Suspense>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-xl font-bold">Image information</h3>
|
||||
@@ -75,11 +81,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
|
||||
<h3 className="text-xl font-bold">Tags</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{post.tags.map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant={'secondary'}
|
||||
className="select-none cursor-pointer"
|
||||
>
|
||||
<Badge key={tag} variant={'secondary'} className="select-none cursor-pointer">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
54
src/components/app/Comments/Comments.tsx
Normal file
54
src/components/app/Comments/Comments.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import prisma from '@/lib/db';
|
||||
import { UniversalForm } from '../UniversalForm/UniversalForm';
|
||||
import { createComment, upvoteComment } from '@/lib/form/actions';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowUp } from 'lucide-react';
|
||||
import UpvoteComment from '../UpvoteComment/UpvoteComment';
|
||||
import { validateRequest } from '@/lib/auth';
|
||||
|
||||
export default async function Comments(props: Props) {
|
||||
const { user } = await validateRequest();
|
||||
const comments = await prisma.comment.findMany({
|
||||
where: { postId: props.id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: { author: true },
|
||||
});
|
||||
|
||||
return (
|
||||
<div id="comments">
|
||||
<div className="space-y-4 mb-6">
|
||||
{comments.map((comment) => (
|
||||
<div key={comment.id} className="flex space-x-4">
|
||||
<Avatar>
|
||||
<AvatarFallback>{comment.author.username[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-semibold">{comment.author.username}</p>
|
||||
<p>{comment.content}</p>
|
||||
</div>
|
||||
<div className='flex-grow'></div>
|
||||
<div className='flex space-x-4'>
|
||||
<p>{comment.upvotes}</p>
|
||||
<UpvoteComment {...comment} userVoted={comment.votedBy.includes(user?.id!)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<UniversalForm
|
||||
schemaName="comment"
|
||||
fields={[
|
||||
{ name: 'postId', type: 'hidden', value: props.id, label: 'Post ID' },
|
||||
{ name: 'content', label: 'Comment', textArea: true, textAreaRows: 3, required: true },
|
||||
]}
|
||||
action={createComment}
|
||||
submitText="Post comment"
|
||||
resetFormOnSubmit
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
}
|
||||
@@ -19,12 +19,13 @@ import React from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { accountSchema, uploadSchema } from '@/lib/form/zod';
|
||||
import { accountSchema, commentSchema, uploadSchema } from '@/lib/form/zod';
|
||||
|
||||
export const schemaDb = [
|
||||
{ name: 'login', zod: accountSchema },
|
||||
{ name: 'register', zod: accountSchema },
|
||||
{ name: 'upload', zod: uploadSchema },
|
||||
{ name: 'comment', zod: commentSchema },
|
||||
] as const;
|
||||
|
||||
export function UniversalForm<T extends z.ZodType>({
|
||||
@@ -37,10 +38,12 @@ export function UniversalForm<T extends z.ZodType>({
|
||||
submitClassname,
|
||||
otherSubmitButton,
|
||||
submitButtonDivClassname,
|
||||
resetFormOnSubmit,
|
||||
}: UniversalFormProps<T>) {
|
||||
// @ts-ignore idk why this error is happening, first apprearing on the react 19 update.
|
||||
const [state, formAction] = useActionState<{ success: boolean; error?: string }>(action, null);
|
||||
const schema = schemaDb.find((s) => s.name === schemaName)?.zod;
|
||||
const formRef = React.useRef<HTMLFormElement>(null);
|
||||
|
||||
if (!schema) {
|
||||
throw new Error(`Schema "${schemaName}" not found`);
|
||||
@@ -60,6 +63,12 @@ export function UniversalForm<T extends z.ZodType>({
|
||||
defaultValues: initialValues as z.infer<T>,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (state?.success && resetFormOnSubmit) {
|
||||
form.reset(initialValues as z.infer<T>);
|
||||
}
|
||||
}, [state, resetFormOnSubmit, form, initialValues]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (state && !state.success) {
|
||||
toast.error(state.error);
|
||||
@@ -71,7 +80,7 @@ export function UniversalForm<T extends z.ZodType>({
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form action={formAction} className="space-y-2">
|
||||
<form action={formAction} className="space-y-2" ref={formRef}>
|
||||
{fields.map((field) => (
|
||||
<FormField
|
||||
key={field.name}
|
||||
@@ -90,6 +99,7 @@ export function UniversalForm<T extends z.ZodType>({
|
||||
{...formField}
|
||||
value={formField.value ?? ''}
|
||||
rows={field.textAreaRows ?? 5}
|
||||
required={field.required}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
@@ -97,6 +107,7 @@ export function UniversalForm<T extends z.ZodType>({
|
||||
placeholder={field.placeholder}
|
||||
{...formField}
|
||||
value={formField.value ?? ''}
|
||||
required={field.required}
|
||||
/>
|
||||
)}
|
||||
{field.customComponent && field.customComponent}
|
||||
|
||||
@@ -13,6 +13,7 @@ export type FormFieldConfig = {
|
||||
value?: string;
|
||||
textArea?: boolean;
|
||||
textAreaRows?: number;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
export type UniversalFormProps<T extends z.ZodType> = {
|
||||
@@ -25,4 +26,5 @@ export type UniversalFormProps<T extends z.ZodType> = {
|
||||
submitClassname?: string;
|
||||
otherSubmitButton?: React.ReactNode;
|
||||
submitButtonDivClassname?: string;
|
||||
resetFormOnSubmit?: boolean;
|
||||
};
|
||||
31
src/components/app/UpvoteComment/UpvoteComment.tsx
Normal file
31
src/components/app/UpvoteComment/UpvoteComment.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { upvoteComment } from '@/lib/form/actions';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Comment } from '@prisma/client';
|
||||
import { ArrowUp } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function UpvoteComment(comment: Comment & { userVoted: boolean }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [voted, setVoted] = useState(comment.userVoted);
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="smicon"
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setLoading(true);
|
||||
upvoteComment(comment.id).then((res) => {
|
||||
if (res.success) {
|
||||
setVoted(res.action === 'up' ? true : false);
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ArrowUp className={cn(voted ? 'text-primary' : '', "h-4 w-4")} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,8 @@ const buttonVariants = cva(
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10"
|
||||
icon: "h-10 w-10",
|
||||
smicon: "h-8 w-8",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -83,6 +83,7 @@ export async function register() {
|
||||
}
|
||||
}
|
||||
|
||||
await ephemeralStorage.clear('tag')
|
||||
for (const [tag, count] of Object.entries(occurrences)) {
|
||||
await ephemeralStorage.set(`tag:${tag}`, count);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
'use server';
|
||||
|
||||
import zodVerify from '../zodVerify';
|
||||
import { uploadSchema } from './zod';
|
||||
import { commentSchema, uploadSchema } from './zod';
|
||||
import prisma from '../db';
|
||||
import { validateRequest } from '../auth';
|
||||
import hashImage from '../hashImage';
|
||||
import minio from '../services/minio';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
export async function create(prev: any, formData: FormData) {
|
||||
const { user } = await validateRequest();
|
||||
@@ -45,3 +46,64 @@ export async function create(prev: any, formData: FormData) {
|
||||
});
|
||||
return { success: true, id: dbCreate.id };
|
||||
}
|
||||
|
||||
export async function createComment(prev: any, formData: FormData) {
|
||||
const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
return { success: false, message: 'not logged in' };
|
||||
}
|
||||
const zod = await zodVerify(commentSchema, formData);
|
||||
if (!zod.success) {
|
||||
return zod;
|
||||
}
|
||||
|
||||
await prisma.comment.create({
|
||||
data: {
|
||||
content: zod.data.content,
|
||||
author: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
post: {
|
||||
connect: {
|
||||
id: zod.data.postId,
|
||||
},
|
||||
},
|
||||
upvotes: 0,
|
||||
},
|
||||
});
|
||||
revalidatePath(`/post/${zod.data.postId}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function upvoteComment(commentId: string) {
|
||||
const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
return { success: false, message: 'not logged in' };
|
||||
}
|
||||
|
||||
const comment = await prisma.comment.findUnique({ where: { id: commentId } });
|
||||
if (!comment) {
|
||||
return { success: false, message: 'comment not found' };
|
||||
}
|
||||
|
||||
if (comment.votedBy.includes(user.id)) {
|
||||
await prisma.comment.update({
|
||||
where: { id: commentId },
|
||||
data: {
|
||||
upvotes: comment.upvotes - 1,
|
||||
votedBy: { set: comment.votedBy.filter((id) => id !== user.id) },
|
||||
},
|
||||
});
|
||||
revalidatePath(`/post/${comment.postId}`);
|
||||
return { success: true, action: 'down' };
|
||||
} else {
|
||||
await prisma.comment.update({
|
||||
where: { id: commentId },
|
||||
data: { upvotes: comment.upvotes + 1, votedBy: { push: user.id } },
|
||||
});
|
||||
revalidatePath(`/post/${comment.postId}`);
|
||||
return { success: true, action: 'up' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,3 +20,8 @@ export const uploadSchema = z.object({
|
||||
caption: z.string().min(1),
|
||||
tags: z.string().min(1),
|
||||
});
|
||||
|
||||
export const commentSchema = z.object({
|
||||
postId: z.string().min(1),
|
||||
content: z.string().min(1),
|
||||
});
|
||||
Reference in New Issue
Block a user