feat: comments

This commit is contained in:
2025-01-11 00:38:55 +01:00
parent ef58db01de
commit ab5357c827
12 changed files with 225 additions and 14 deletions

View 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;

View File

@@ -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[];

View File

@@ -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)
}

View File

@@ -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>
))}

View 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;
}

View File

@@ -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}

View File

@@ -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;
};

View 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>
);
}

View File

@@ -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: {

View File

@@ -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);
}

View File

@@ -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' };
}
}

View File

@@ -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),
});