mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2025-07-28 12:43:12 +08:00
[resumes][feat] replying comments (#401)
* [resumes][feat] add resume comment parent * [resumes][refactor] Abstract comment edit form and votes to their components * [resumes][feat] Add reply form * [resumes][feat] Render replies * [resumes][feat] add collapsible comments * [resumes][chore] remove comment Co-authored-by: Terence Ho <>
This commit is contained in:
@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ResumesComment" ADD COLUMN "parentId" TEXT;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ResumesComment" ADD CONSTRAINT "ResumesComment_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "ResumesComment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
@ -140,6 +140,7 @@ model ResumesComment {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
resumeId String
|
||||
parentId String?
|
||||
description String @db.Text
|
||||
section ResumesSection
|
||||
createdAt DateTime @default(now())
|
||||
@ -147,6 +148,8 @@ model ResumesComment {
|
||||
resume ResumesResume @relation(fields: [resumeId], references: [id], onDelete: Cascade)
|
||||
votes ResumesCommentVote[]
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
parent ResumesComment? @relation("parentComment", fields: [parentId], references: [id])
|
||||
children ResumesComment[] @relation("parentComment")
|
||||
}
|
||||
|
||||
enum ResumesSection {
|
||||
|
@ -1,18 +1,11 @@
|
||||
import clsx from 'clsx';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useState } from 'react';
|
||||
import type { SubmitHandler } from 'react-hook-form';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
ArrowDownCircleIcon,
|
||||
ArrowUpCircleIcon,
|
||||
} from '@heroicons/react/20/solid';
|
||||
import { ChevronUpIcon } from '@heroicons/react/20/solid';
|
||||
import { FaceSmileIcon } from '@heroicons/react/24/outline';
|
||||
import { Vote } from '@prisma/client';
|
||||
import { Button, TextArea } from '@tih/ui';
|
||||
|
||||
import { trpc } from '~/utils/trpc';
|
||||
|
||||
import ResumeCommentEditForm from './comment/ResumeCommentEditForm';
|
||||
import ResumeCommentReplyForm from './comment/ResumeCommentReplyForm';
|
||||
import ResumeCommentVoteButtons from './comment/ResumeCommentVoteButtons';
|
||||
import ResumeUserBadges from '../badges/ResumeUserBadges';
|
||||
import ResumeExpandableText from '../shared/ResumeExpandableText';
|
||||
|
||||
@ -23,141 +16,55 @@ type ResumeCommentListItemProps = {
|
||||
userId: string | undefined;
|
||||
};
|
||||
|
||||
type ICommentInput = {
|
||||
description: string;
|
||||
};
|
||||
|
||||
export default function ResumeCommentListItem({
|
||||
comment,
|
||||
userId,
|
||||
}: ResumeCommentListItemProps) {
|
||||
const isCommentOwner = userId === comment.user.userId;
|
||||
const [isEditingComment, setIsEditingComment] = useState(false);
|
||||
|
||||
const [upvoteAnimation, setUpvoteAnimation] = useState(false);
|
||||
const [downvoteAnimation, setDownvoteAnimation] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors, isDirty },
|
||||
reset,
|
||||
} = useForm<ICommentInput>({
|
||||
defaultValues: {
|
||||
description: comment.description,
|
||||
},
|
||||
});
|
||||
|
||||
const trpcContext = trpc.useContext();
|
||||
const commentUpdateMutation = trpc.useMutation(
|
||||
'resumes.comments.user.update',
|
||||
{
|
||||
onSuccess: () => {
|
||||
// Comment updated, invalidate query to trigger refetch
|
||||
trpcContext.invalidateQueries(['resumes.comments.list']);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// COMMENT VOTES
|
||||
const commentVotesQuery = trpc.useQuery([
|
||||
'resumes.comments.votes.list',
|
||||
{ commentId: comment.id },
|
||||
]);
|
||||
const commentVotesUpsertMutation = trpc.useMutation(
|
||||
'resumes.comments.votes.user.upsert',
|
||||
{
|
||||
onSuccess: () => {
|
||||
// Comment updated, invalidate query to trigger refetch
|
||||
trpcContext.invalidateQueries(['resumes.comments.votes.list']);
|
||||
},
|
||||
},
|
||||
);
|
||||
const commentVotesDeleteMutation = trpc.useMutation(
|
||||
'resumes.comments.votes.user.delete',
|
||||
{
|
||||
onSuccess: () => {
|
||||
// Comment updated, invalidate query to trigger refetch
|
||||
trpcContext.invalidateQueries(['resumes.comments.votes.list']);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// FORM ACTIONS
|
||||
const onCancel = () => {
|
||||
reset({ description: comment.description });
|
||||
setIsEditingComment(false);
|
||||
};
|
||||
|
||||
const onSubmit: SubmitHandler<ICommentInput> = async (data) => {
|
||||
const { id } = comment;
|
||||
return commentUpdateMutation.mutate(
|
||||
{
|
||||
id,
|
||||
...data,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsEditingComment(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const setFormValue = (value: string) => {
|
||||
setValue('description', value.trim(), { shouldDirty: true });
|
||||
};
|
||||
|
||||
const onVote = async (
|
||||
value: Vote,
|
||||
setAnimation: Dispatch<SetStateAction<boolean>>,
|
||||
) => {
|
||||
setAnimation(true);
|
||||
|
||||
if (commentVotesQuery.data?.userVote?.value === value) {
|
||||
return commentVotesDeleteMutation.mutate(
|
||||
{
|
||||
commentId: comment.id,
|
||||
},
|
||||
{
|
||||
onSettled: async () => setAnimation(false),
|
||||
},
|
||||
);
|
||||
}
|
||||
return commentVotesUpsertMutation.mutate(
|
||||
{
|
||||
commentId: comment.id,
|
||||
value,
|
||||
},
|
||||
{
|
||||
onSettled: async () => setAnimation(false),
|
||||
},
|
||||
);
|
||||
};
|
||||
const [isReplyingComment, setIsReplyingComment] = useState(false);
|
||||
const [showReplies, setShowReplies] = useState(true);
|
||||
|
||||
return (
|
||||
<div className="border-primary-300 w-11/12 min-w-fit rounded-md border-2 bg-white p-2 drop-shadow-md">
|
||||
<div
|
||||
className={clsx(
|
||||
'min-w-fit rounded-md bg-white ',
|
||||
!comment.parentId &&
|
||||
'w-11/12 border-2 border-indigo-300 p-2 drop-shadow-md',
|
||||
)}>
|
||||
<div className="flex flex-row space-x-2 p-1 align-top">
|
||||
{/* Image Icon */}
|
||||
{comment.user.image ? (
|
||||
<img
|
||||
alt={comment.user.name ?? 'Reviewer'}
|
||||
className="mt-1 h-8 w-8 rounded-full"
|
||||
className={clsx(
|
||||
'mt-1 rounded-full',
|
||||
comment.parentId ? 'h-6 w-6' : 'h-8 w-8 ',
|
||||
)}
|
||||
src={comment.user.image!}
|
||||
/>
|
||||
) : (
|
||||
<FaceSmileIcon className="h-8 w-8 rounded-full" />
|
||||
<FaceSmileIcon
|
||||
className={clsx(
|
||||
'mt-1 rounded-full',
|
||||
comment.parentId ? 'h-6 w-6' : 'h-8 w-8 ',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex w-full flex-col space-y-1">
|
||||
{/* Name and creation time */}
|
||||
<div className="flex flex-row justify-between">
|
||||
<div className="flex flex-row items-center space-x-1">
|
||||
<p className="font-medium">
|
||||
<p
|
||||
className={clsx(
|
||||
'font-medium text-black',
|
||||
!!comment.parentId && 'text-sm',
|
||||
)}>
|
||||
{comment.user.name ?? 'Reviewer ABC'}
|
||||
</p>
|
||||
|
||||
<p className="text-primary-800 text-xs font-medium">
|
||||
<p className="text-xs font-medium text-indigo-800">
|
||||
{isCommentOwner ? '(Me)' : ''}
|
||||
</p>
|
||||
|
||||
@ -174,112 +81,78 @@ export default function ResumeCommentListItem({
|
||||
|
||||
{/* Description */}
|
||||
{isEditingComment ? (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="flex-column mt-1 space-y-2">
|
||||
<TextArea
|
||||
{...(register('description', {
|
||||
required: 'Comments cannot be empty!',
|
||||
}),
|
||||
{})}
|
||||
defaultValue={comment.description}
|
||||
disabled={commentUpdateMutation.isLoading}
|
||||
errorMessage={errors.description?.message}
|
||||
label=""
|
||||
placeholder="Leave your comment here"
|
||||
onChange={setFormValue}
|
||||
/>
|
||||
|
||||
<div className="flex-row space-x-2">
|
||||
<Button
|
||||
disabled={commentUpdateMutation.isLoading}
|
||||
label="Cancel"
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
onClick={onCancel}
|
||||
/>
|
||||
|
||||
<Button
|
||||
disabled={!isDirty || commentUpdateMutation.isLoading}
|
||||
isLoading={commentUpdateMutation.isLoading}
|
||||
label="Confirm"
|
||||
size="sm"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<ResumeCommentEditForm
|
||||
comment={comment}
|
||||
setIsEditingComment={setIsEditingComment}
|
||||
/>
|
||||
) : (
|
||||
<ResumeExpandableText text={comment.description} />
|
||||
)}
|
||||
|
||||
{/* Upvote and edit */}
|
||||
<div className="flex flex-row space-x-1 pt-1 align-middle">
|
||||
<button
|
||||
disabled={
|
||||
!userId ||
|
||||
commentVotesQuery.isLoading ||
|
||||
commentVotesUpsertMutation.isLoading ||
|
||||
commentVotesDeleteMutation.isLoading
|
||||
}
|
||||
type="button"
|
||||
onClick={() => onVote(Vote.UPVOTE, setUpvoteAnimation)}>
|
||||
<ArrowUpCircleIcon
|
||||
className={clsx(
|
||||
'h-4 w-4',
|
||||
commentVotesQuery.data?.userVote?.value === Vote.UPVOTE ||
|
||||
upvoteAnimation
|
||||
? 'fill-indigo-500'
|
||||
: 'fill-gray-400',
|
||||
userId &&
|
||||
!downvoteAnimation &&
|
||||
!upvoteAnimation &&
|
||||
'hover:fill-indigo-500',
|
||||
upvoteAnimation &&
|
||||
'animate-[bounce_0.5s_infinite] cursor-default',
|
||||
<ResumeCommentVoteButtons commentId={comment.id} userId={userId} />
|
||||
|
||||
{/* Action buttons; only present when not editing/replying */}
|
||||
{isCommentOwner && !isEditingComment && !isReplyingComment && (
|
||||
<>
|
||||
<button
|
||||
className="px-1 text-xs text-indigo-800 hover:text-indigo-400"
|
||||
type="button"
|
||||
onClick={() => setIsEditingComment(true)}>
|
||||
Edit
|
||||
</button>
|
||||
|
||||
{!comment.parentId && (
|
||||
<button
|
||||
className="px-1 text-xs text-indigo-800 hover:text-indigo-400"
|
||||
type="button"
|
||||
onClick={() => setIsReplyingComment(true)}>
|
||||
Reply
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div className="text-xs">
|
||||
{commentVotesQuery.data?.numVotes ?? 0}
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled={
|
||||
!userId ||
|
||||
commentVotesQuery.isLoading ||
|
||||
commentVotesUpsertMutation.isLoading ||
|
||||
commentVotesDeleteMutation.isLoading
|
||||
}
|
||||
type="button"
|
||||
onClick={() => onVote(Vote.DOWNVOTE, setDownvoteAnimation)}>
|
||||
<ArrowDownCircleIcon
|
||||
className={clsx(
|
||||
'h-4 w-4',
|
||||
commentVotesQuery.data?.userVote?.value === Vote.DOWNVOTE ||
|
||||
downvoteAnimation
|
||||
? 'fill-red-500'
|
||||
: 'fill-gray-400',
|
||||
userId &&
|
||||
!downvoteAnimation &&
|
||||
!upvoteAnimation &&
|
||||
'hover:fill-red-500',
|
||||
downvoteAnimation &&
|
||||
'animate-[bounce_0.5s_infinite] cursor-default',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isCommentOwner && !isEditingComment && (
|
||||
<button
|
||||
className="text-primary-800 hover:text-primary-400 px-1 text-xs"
|
||||
type="button"
|
||||
onClick={() => setIsEditingComment(true)}>
|
||||
Edit
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reply Form */}
|
||||
{isReplyingComment && (
|
||||
<ResumeCommentReplyForm
|
||||
parentId={comment.id}
|
||||
resumeId={comment.resumeId}
|
||||
section={comment.section}
|
||||
setIsReplyingComment={setIsReplyingComment}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Replies */}
|
||||
{comment.children.length > 0 && (
|
||||
<div className="min-w-fit space-y-1 pt-2">
|
||||
<button
|
||||
className="flex items-center space-x-1 rounded-md text-xs font-medium text-indigo-800 hover:text-indigo-300"
|
||||
type="button"
|
||||
onClick={() => setShowReplies(!showReplies)}>
|
||||
<ChevronUpIcon
|
||||
className={clsx(
|
||||
'h-5 w-5 ',
|
||||
!showReplies && 'rotate-180 transform',
|
||||
)}
|
||||
/>
|
||||
<span>{showReplies ? 'Hide replies' : 'Show replies'}</span>
|
||||
</button>
|
||||
|
||||
{showReplies &&
|
||||
comment.children.map((child) => {
|
||||
return (
|
||||
<ResumeCommentListItem
|
||||
key={child.id}
|
||||
comment={child}
|
||||
userId={userId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -0,0 +1,106 @@
|
||||
import type { SubmitHandler } from 'react-hook-form';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Button, TextArea } from '@tih/ui';
|
||||
|
||||
import { trpc } from '~/utils/trpc';
|
||||
|
||||
import type { ResumeComment } from '~/types/resume-comments';
|
||||
|
||||
type ResumeCommentEditFormProps = {
|
||||
comment: ResumeComment;
|
||||
setIsEditingComment: (value: boolean) => void;
|
||||
};
|
||||
|
||||
type ICommentInput = {
|
||||
description: string;
|
||||
};
|
||||
|
||||
export default function ResumeCommentEditForm({
|
||||
comment,
|
||||
setIsEditingComment,
|
||||
}: ResumeCommentEditFormProps) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors, isDirty },
|
||||
reset,
|
||||
} = useForm<ICommentInput>({
|
||||
defaultValues: {
|
||||
description: comment.description,
|
||||
},
|
||||
});
|
||||
|
||||
const trpcContext = trpc.useContext();
|
||||
const commentUpdateMutation = trpc.useMutation(
|
||||
'resumes.comments.user.update',
|
||||
{
|
||||
onSuccess: () => {
|
||||
// Comment updated, invalidate query to trigger refetch
|
||||
trpcContext.invalidateQueries(['resumes.comments.list']);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const onCancel = () => {
|
||||
reset({ description: comment.description });
|
||||
setIsEditingComment(false);
|
||||
};
|
||||
|
||||
const onSubmit: SubmitHandler<ICommentInput> = async (data) => {
|
||||
const { id } = comment;
|
||||
return commentUpdateMutation.mutate(
|
||||
{
|
||||
id,
|
||||
...data,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsEditingComment(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const setFormValue = (value: string) => {
|
||||
setValue('description', value.trim(), { shouldDirty: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="flex-column mt-1 space-y-2">
|
||||
<TextArea
|
||||
{...(register('description', {
|
||||
required: 'Comments cannot be empty!',
|
||||
}),
|
||||
{})}
|
||||
defaultValue={comment.description}
|
||||
disabled={commentUpdateMutation.isLoading}
|
||||
errorMessage={errors.description?.message}
|
||||
label=""
|
||||
placeholder="Leave your comment here"
|
||||
onChange={setFormValue}
|
||||
/>
|
||||
|
||||
<div className="flex-row space-x-2">
|
||||
<Button
|
||||
disabled={commentUpdateMutation.isLoading}
|
||||
label="Cancel"
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
onClick={onCancel}
|
||||
/>
|
||||
|
||||
<Button
|
||||
disabled={!isDirty || commentUpdateMutation.isLoading}
|
||||
isLoading={commentUpdateMutation.isLoading}
|
||||
label="Confirm"
|
||||
size="sm"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
@ -0,0 +1,107 @@
|
||||
import type { SubmitHandler } from 'react-hook-form';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { ResumesSection } from '@prisma/client';
|
||||
import { Button, TextArea } from '@tih/ui';
|
||||
|
||||
import { trpc } from '~/utils/trpc';
|
||||
|
||||
type ResumeCommentEditFormProps = {
|
||||
parentId: string;
|
||||
resumeId: string;
|
||||
section: ResumesSection;
|
||||
setIsReplyingComment: (value: boolean) => void;
|
||||
};
|
||||
|
||||
type IReplyInput = {
|
||||
description: string;
|
||||
};
|
||||
|
||||
export default function ResumeCommentReplyForm({
|
||||
parentId,
|
||||
setIsReplyingComment,
|
||||
resumeId,
|
||||
section,
|
||||
}: ResumeCommentEditFormProps) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors, isDirty },
|
||||
reset,
|
||||
} = useForm<IReplyInput>({
|
||||
defaultValues: {
|
||||
description: '',
|
||||
},
|
||||
});
|
||||
|
||||
const trpcContext = trpc.useContext();
|
||||
const commentReplyMutation = trpc.useMutation('resumes.comments.user.reply', {
|
||||
onSuccess: () => {
|
||||
// Comment updated, invalidate query to trigger refetch
|
||||
trpcContext.invalidateQueries(['resumes.comments.list']);
|
||||
},
|
||||
});
|
||||
|
||||
const onCancel = () => {
|
||||
reset({ description: '' });
|
||||
setIsReplyingComment(false);
|
||||
};
|
||||
|
||||
const onSubmit: SubmitHandler<IReplyInput> = async (data) => {
|
||||
return commentReplyMutation.mutate(
|
||||
{
|
||||
parentId,
|
||||
resumeId,
|
||||
section,
|
||||
...data,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsReplyingComment(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const setFormValue = (value: string) => {
|
||||
setValue('description', value.trim(), { shouldDirty: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="flex-column space-y-2 pt-2">
|
||||
<TextArea
|
||||
{...(register('description', {
|
||||
required: 'Reply cannot be empty!',
|
||||
}),
|
||||
{})}
|
||||
defaultValue=""
|
||||
disabled={commentReplyMutation.isLoading}
|
||||
errorMessage={errors.description?.message}
|
||||
label=""
|
||||
placeholder="Leave your reply here"
|
||||
onChange={setFormValue}
|
||||
/>
|
||||
|
||||
<div className="flex-row space-x-2">
|
||||
<Button
|
||||
disabled={commentReplyMutation.isLoading}
|
||||
label="Cancel"
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
onClick={onCancel}
|
||||
/>
|
||||
|
||||
<Button
|
||||
disabled={!isDirty || commentReplyMutation.isLoading}
|
||||
isLoading={commentReplyMutation.isLoading}
|
||||
label="Confirm"
|
||||
size="sm"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
@ -0,0 +1,129 @@
|
||||
import clsx from 'clsx';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ArrowDownCircleIcon,
|
||||
ArrowUpCircleIcon,
|
||||
} from '@heroicons/react/20/solid';
|
||||
import { Vote } from '@prisma/client';
|
||||
|
||||
import { trpc } from '~/utils/trpc';
|
||||
|
||||
type ResumeCommentVoteButtonsProps = {
|
||||
commentId: string;
|
||||
userId: string | undefined;
|
||||
};
|
||||
|
||||
export default function ResumeCommentVoteButtons({
|
||||
commentId,
|
||||
userId,
|
||||
}: ResumeCommentVoteButtonsProps) {
|
||||
const [upvoteAnimation, setUpvoteAnimation] = useState(false);
|
||||
const [downvoteAnimation, setDownvoteAnimation] = useState(false);
|
||||
|
||||
const trpcContext = trpc.useContext();
|
||||
|
||||
// COMMENT VOTES
|
||||
const commentVotesQuery = trpc.useQuery([
|
||||
'resumes.comments.votes.list',
|
||||
{ commentId },
|
||||
]);
|
||||
const commentVotesUpsertMutation = trpc.useMutation(
|
||||
'resumes.comments.votes.user.upsert',
|
||||
{
|
||||
onSuccess: () => {
|
||||
// Comment updated, invalidate query to trigger refetch
|
||||
trpcContext.invalidateQueries(['resumes.comments.votes.list']);
|
||||
},
|
||||
},
|
||||
);
|
||||
const commentVotesDeleteMutation = trpc.useMutation(
|
||||
'resumes.comments.votes.user.delete',
|
||||
{
|
||||
onSuccess: () => {
|
||||
// Comment updated, invalidate query to trigger refetch
|
||||
trpcContext.invalidateQueries(['resumes.comments.votes.list']);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const onVote = async (value: Vote, setAnimation: (_: boolean) => void) => {
|
||||
setAnimation(true);
|
||||
|
||||
if (commentVotesQuery.data?.userVote?.value === value) {
|
||||
return commentVotesDeleteMutation.mutate(
|
||||
{
|
||||
commentId,
|
||||
},
|
||||
{
|
||||
onSettled: async () => setAnimation(false),
|
||||
},
|
||||
);
|
||||
}
|
||||
return commentVotesUpsertMutation.mutate(
|
||||
{
|
||||
commentId,
|
||||
value,
|
||||
},
|
||||
{
|
||||
onSettled: async () => setAnimation(false),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
disabled={
|
||||
!userId ||
|
||||
commentVotesQuery.isLoading ||
|
||||
commentVotesUpsertMutation.isLoading ||
|
||||
commentVotesDeleteMutation.isLoading
|
||||
}
|
||||
type="button"
|
||||
onClick={() => onVote(Vote.UPVOTE, setUpvoteAnimation)}>
|
||||
<ArrowUpCircleIcon
|
||||
className={clsx(
|
||||
'h-4 w-4',
|
||||
commentVotesQuery.data?.userVote?.value === Vote.UPVOTE ||
|
||||
upvoteAnimation
|
||||
? 'fill-indigo-500'
|
||||
: 'fill-gray-400',
|
||||
userId &&
|
||||
!downvoteAnimation &&
|
||||
!upvoteAnimation &&
|
||||
'hover:fill-indigo-500',
|
||||
upvoteAnimation && 'animate-[bounce_0.5s_infinite] cursor-default',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div className="text-xs">{commentVotesQuery.data?.numVotes ?? 0}</div>
|
||||
|
||||
<button
|
||||
disabled={
|
||||
!userId ||
|
||||
commentVotesQuery.isLoading ||
|
||||
commentVotesUpsertMutation.isLoading ||
|
||||
commentVotesDeleteMutation.isLoading
|
||||
}
|
||||
type="button"
|
||||
onClick={() => onVote(Vote.DOWNVOTE, setDownvoteAnimation)}>
|
||||
<ArrowDownCircleIcon
|
||||
className={clsx(
|
||||
'h-4 w-4',
|
||||
commentVotesQuery.data?.userVote?.value === Vote.DOWNVOTE ||
|
||||
downvoteAnimation
|
||||
? 'fill-red-500'
|
||||
: 'fill-gray-400',
|
||||
userId &&
|
||||
!downvoteAnimation &&
|
||||
!upvoteAnimation &&
|
||||
'hover:fill-red-500',
|
||||
downvoteAnimation &&
|
||||
'animate-[bounce_0.5s_infinite] cursor-default',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
@ -15,6 +15,19 @@ export const resumeCommentsRouter = createRouter().query('list', {
|
||||
// The user's name and image to render
|
||||
const comments = await ctx.prisma.resumesComment.findMany({
|
||||
include: {
|
||||
children: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
image: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
image: true,
|
||||
@ -26,15 +39,35 @@ export const resumeCommentsRouter = createRouter().query('list', {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
where: {
|
||||
resumeId,
|
||||
AND: [{ resumeId }, { parentId: null }],
|
||||
},
|
||||
});
|
||||
|
||||
return comments.map((data) => {
|
||||
const children: Array<ResumeComment> = data.children.map((child) => {
|
||||
return {
|
||||
children: [],
|
||||
createdAt: child.createdAt,
|
||||
description: child.description,
|
||||
id: child.id,
|
||||
parentId: data.id,
|
||||
resumeId: child.resumeId,
|
||||
section: child.section,
|
||||
updatedAt: child.updatedAt,
|
||||
user: {
|
||||
image: child.user.image,
|
||||
name: child.user.name,
|
||||
userId: child.userId,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const comment: ResumeComment = {
|
||||
children,
|
||||
createdAt: data.createdAt,
|
||||
description: data.description,
|
||||
id: data.id,
|
||||
parentId: data.parentId,
|
||||
resumeId: data.resumeId,
|
||||
section: data.section,
|
||||
updatedAt: data.updatedAt,
|
||||
|
@ -67,4 +67,26 @@ export const resumesCommentsUserRouter = createProtectedRouter()
|
||||
},
|
||||
});
|
||||
},
|
||||
})
|
||||
.mutation('reply', {
|
||||
input: z.object({
|
||||
description: z.string(),
|
||||
parentId: z.string(),
|
||||
resumeId: z.string(),
|
||||
section: z.nativeEnum(ResumesSection),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const userId = ctx.session.user.id;
|
||||
const { description, parentId, resumeId, section } = input;
|
||||
|
||||
return await ctx.prisma.resumesComment.create({
|
||||
data: {
|
||||
description,
|
||||
parentId,
|
||||
resumeId,
|
||||
section,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
2
apps/portal/src/types/resume-comments.d.ts
vendored
2
apps/portal/src/types/resume-comments.d.ts
vendored
@ -5,9 +5,11 @@ import type { ResumesCommentVote, ResumesSection } from '@prisma/client';
|
||||
* frontend-friendly representation of the query
|
||||
*/
|
||||
export type ResumeComment = Readonly<{
|
||||
children: Array<ResumeComment>;
|
||||
createdAt: Date;
|
||||
description: string;
|
||||
id: string;
|
||||
parentId: string?;
|
||||
resumeId: string;
|
||||
section: ResumesSection;
|
||||
updatedAt: Date;
|
||||
|
Reference in New Issue
Block a user