mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2025-07-29 21:23:14 +08:00
[resumes][fix] Fix bugs in comments section (#363)
* [resumes][fix] left-align all comments * [resumes][fix] add comment owner OP tag * [resumes][fix] render multi-line text in comments * [resumes][feat] add see more/less for overflow comments * [resumes][refactor] prefix comments section with Resume * [resumes][refactor] Refactor routers from reviews -> comments * [resumes][refactor] use Vote enum in ResumesCommentVote * [resumes][refactor] add comment count to tabs * [resumes][refactor] combine resume-card and resume-body into resume-list-item Co-authored-by: Terence Ho <>
This commit is contained in:
@ -0,0 +1,9 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Changed the type of `value` on the `ResumesCommentVote` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ResumesCommentVote" DROP COLUMN "value",
|
||||||
|
ADD COLUMN "value" "Vote" NOT NULL;
|
@ -159,7 +159,7 @@ model ResumesCommentVote {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
commentId String
|
commentId String
|
||||||
value Int
|
value Vote
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
comment ResumesComment @relation(fields: [commentId], references: [id], onDelete: Cascade)
|
comment ResumesComment @relation(fields: [commentId], references: [id], onDelete: Cascade)
|
||||||
|
@ -1,35 +0,0 @@
|
|||||||
import { useSession } from 'next-auth/react';
|
|
||||||
import { Spinner } from '@tih/ui';
|
|
||||||
|
|
||||||
import Comment from './comment/Comment';
|
|
||||||
|
|
||||||
import type { ResumeComment } from '~/types/resume-comments';
|
|
||||||
|
|
||||||
type Props = Readonly<{
|
|
||||||
comments: Array<ResumeComment>;
|
|
||||||
isLoading: boolean;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export default function CommentListItems({ comments, isLoading }: Props) {
|
|
||||||
const { data: session } = useSession();
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="col-span-10 pt-4">
|
|
||||||
<Spinner display="block" size="lg" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="m-2 flow-root h-[calc(100vh-20rem)] w-full flex-col space-y-3 overflow-y-auto">
|
|
||||||
{comments.map((comment) => (
|
|
||||||
<Comment
|
|
||||||
key={comment.id}
|
|
||||||
comment={comment}
|
|
||||||
userId={session?.user?.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,54 +0,0 @@
|
|||||||
import { useSession } from 'next-auth/react';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Tabs } from '@tih/ui';
|
|
||||||
import { Button } from '@tih/ui';
|
|
||||||
|
|
||||||
import { trpc } from '~/utils/trpc';
|
|
||||||
|
|
||||||
import CommentListItems from './CommentListItems';
|
|
||||||
import { COMMENTS_SECTIONS } from './constants';
|
|
||||||
import ResumeSignInButton from '../shared/ResumeSignInButton';
|
|
||||||
|
|
||||||
type CommentsListProps = Readonly<{
|
|
||||||
resumeId: string;
|
|
||||||
setShowCommentsForm: (show: boolean) => void;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export default function CommentsList({
|
|
||||||
resumeId,
|
|
||||||
setShowCommentsForm,
|
|
||||||
}: CommentsListProps) {
|
|
||||||
const { data: sessionData } = useSession();
|
|
||||||
const [tab, setTab] = useState(COMMENTS_SECTIONS[0].value);
|
|
||||||
|
|
||||||
const commentsQuery = trpc.useQuery(['resumes.reviews.list', { resumeId }]);
|
|
||||||
const renderButton = () => {
|
|
||||||
if (sessionData === null) {
|
|
||||||
return <ResumeSignInButton text="to join discussion" />;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
display="block"
|
|
||||||
label="Add your review"
|
|
||||||
variant="tertiary"
|
|
||||||
onClick={() => setShowCommentsForm(true)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{renderButton()}
|
|
||||||
<Tabs
|
|
||||||
label="comments"
|
|
||||||
tabs={COMMENTS_SECTIONS}
|
|
||||||
value={tab}
|
|
||||||
onChange={(value) => setTab(value)}
|
|
||||||
/>
|
|
||||||
<CommentListItems
|
|
||||||
comments={commentsQuery.data?.filter((c) => c.section === tab) ?? []}
|
|
||||||
isLoading={commentsQuery.isFetching}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
ArrowDownCircleIcon,
|
||||||
|
ArrowUpCircleIcon,
|
||||||
|
} from '@heroicons/react/20/solid';
|
||||||
|
import { FaceSmileIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
import ResumeExpandableText from '../shared/ResumeExpandableText';
|
||||||
|
|
||||||
|
import type { ResumeComment } from '~/types/resume-comments';
|
||||||
|
|
||||||
|
type ResumeCommentListItemProps = {
|
||||||
|
comment: ResumeComment;
|
||||||
|
userId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ResumeCommentListItem({
|
||||||
|
comment,
|
||||||
|
userId,
|
||||||
|
}: ResumeCommentListItemProps) {
|
||||||
|
const isCommentOwner = userId === comment.user.userId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-primary-300 w-3/4 rounded-md border-2 bg-white p-2 drop-shadow-md">
|
||||||
|
<div className="flex w-full flex-row space-x-2 p-1 align-top">
|
||||||
|
{comment.user.image ? (
|
||||||
|
<img
|
||||||
|
alt={comment.user.name ?? 'Reviewer'}
|
||||||
|
className="mt-1 h-8 w-8 rounded-full"
|
||||||
|
src={comment.user.image!}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FaceSmileIcon className="h-8 w-8 rounded-full" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<div className="font-medium">
|
||||||
|
{comment.user.name ?? 'Reviewer ABC'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-primary-800 text-xs font-medium">
|
||||||
|
{isCommentOwner ? '(Me)' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-gray-600">
|
||||||
|
{comment.createdAt.toLocaleString('en-US', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<ResumeExpandableText>{comment.description}</ResumeExpandableText>
|
||||||
|
|
||||||
|
{/* Upvote and edit */}
|
||||||
|
<div className="flex flex-row space-x-1 pt-1 align-middle">
|
||||||
|
{/* TODO: Implement upvote */}
|
||||||
|
<ArrowUpCircleIcon className="h-4 w-4 fill-gray-400" />
|
||||||
|
<div className="text-xs">{comment.numVotes}</div>
|
||||||
|
<ArrowDownCircleIcon className="h-4 w-4 fill-gray-400" />
|
||||||
|
|
||||||
|
{/* TODO: Implement edit */}
|
||||||
|
{isCommentOwner ? (
|
||||||
|
<div className="text-primary-800 hover:text-primary-400 px-1 text-xs">
|
||||||
|
Edit
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -5,7 +5,7 @@ import { Button, Dialog, TextArea } from '@tih/ui';
|
|||||||
|
|
||||||
import { trpc } from '~/utils/trpc';
|
import { trpc } from '~/utils/trpc';
|
||||||
|
|
||||||
type CommentsFormProps = Readonly<{
|
type ResumeCommentsFormProps = Readonly<{
|
||||||
resumeId: string;
|
resumeId: string;
|
||||||
setShowCommentsForm: (show: boolean) => void;
|
setShowCommentsForm: (show: boolean) => void;
|
||||||
}>;
|
}>;
|
||||||
@ -20,10 +20,10 @@ type IFormInput = {
|
|||||||
|
|
||||||
type InputKeys = keyof IFormInput;
|
type InputKeys = keyof IFormInput;
|
||||||
|
|
||||||
export default function CommentsForm({
|
export default function ResumeCommentsForm({
|
||||||
resumeId,
|
resumeId,
|
||||||
setShowCommentsForm,
|
setShowCommentsForm,
|
||||||
}: CommentsFormProps) {
|
}: ResumeCommentsFormProps) {
|
||||||
const [showDialog, setShowDialog] = useState(false);
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@ -41,16 +41,19 @@ export default function CommentsForm({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const trpcContext = trpc.useContext();
|
const trpcContext = trpc.useContext();
|
||||||
const reviewCreateMutation = trpc.useMutation('resumes.reviews.user.create', {
|
const commentCreateMutation = trpc.useMutation(
|
||||||
onSuccess: () => {
|
'resumes.comments.user.create',
|
||||||
// New review added, invalidate query to trigger refetch
|
{
|
||||||
trpcContext.invalidateQueries(['resumes.reviews.list']);
|
onSuccess: () => {
|
||||||
|
// New Comment added, invalidate query to trigger refetch
|
||||||
|
trpcContext.invalidateQueries(['resumes.comments.list']);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
|
|
||||||
// TODO: Give a feedback to the user if the action succeeds/fails
|
// TODO: Give a feedback to the user if the action succeeds/fails
|
||||||
const onSubmit: SubmitHandler<IFormInput> = async (data) => {
|
const onSubmit: SubmitHandler<IFormInput> = async (data) => {
|
||||||
return await reviewCreateMutation.mutate(
|
return await commentCreateMutation.mutate(
|
||||||
{
|
{
|
||||||
resumeId,
|
resumeId,
|
||||||
...data,
|
...data,
|
||||||
@ -89,7 +92,7 @@ export default function CommentsForm({
|
|||||||
<div className="mt-4 space-y-4">
|
<div className="mt-4 space-y-4">
|
||||||
<TextArea
|
<TextArea
|
||||||
{...(register('general'), {})}
|
{...(register('general'), {})}
|
||||||
disabled={reviewCreateMutation.isLoading}
|
disabled={commentCreateMutation.isLoading}
|
||||||
label="General"
|
label="General"
|
||||||
placeholder="General comments about the resume"
|
placeholder="General comments about the resume"
|
||||||
onChange={(value) => onValueChange('general', value)}
|
onChange={(value) => onValueChange('general', value)}
|
||||||
@ -97,7 +100,7 @@ export default function CommentsForm({
|
|||||||
|
|
||||||
<TextArea
|
<TextArea
|
||||||
{...(register('education'), {})}
|
{...(register('education'), {})}
|
||||||
disabled={reviewCreateMutation.isLoading}
|
disabled={commentCreateMutation.isLoading}
|
||||||
label="Education"
|
label="Education"
|
||||||
placeholder="Comments about the Education section"
|
placeholder="Comments about the Education section"
|
||||||
onChange={(value) => onValueChange('education', value)}
|
onChange={(value) => onValueChange('education', value)}
|
||||||
@ -105,7 +108,7 @@ export default function CommentsForm({
|
|||||||
|
|
||||||
<TextArea
|
<TextArea
|
||||||
{...(register('experience'), {})}
|
{...(register('experience'), {})}
|
||||||
disabled={reviewCreateMutation.isLoading}
|
disabled={commentCreateMutation.isLoading}
|
||||||
label="Experience"
|
label="Experience"
|
||||||
placeholder="Comments about the Experience section"
|
placeholder="Comments about the Experience section"
|
||||||
onChange={(value) => onValueChange('experience', value)}
|
onChange={(value) => onValueChange('experience', value)}
|
||||||
@ -113,7 +116,7 @@ export default function CommentsForm({
|
|||||||
|
|
||||||
<TextArea
|
<TextArea
|
||||||
{...(register('projects'), {})}
|
{...(register('projects'), {})}
|
||||||
disabled={reviewCreateMutation.isLoading}
|
disabled={commentCreateMutation.isLoading}
|
||||||
label="Projects"
|
label="Projects"
|
||||||
placeholder="Comments about the Projects section"
|
placeholder="Comments about the Projects section"
|
||||||
onChange={(value) => onValueChange('projects', value)}
|
onChange={(value) => onValueChange('projects', value)}
|
||||||
@ -121,7 +124,7 @@ export default function CommentsForm({
|
|||||||
|
|
||||||
<TextArea
|
<TextArea
|
||||||
{...(register('skills'), {})}
|
{...(register('skills'), {})}
|
||||||
disabled={reviewCreateMutation.isLoading}
|
disabled={commentCreateMutation.isLoading}
|
||||||
label="Skills"
|
label="Skills"
|
||||||
placeholder="Comments about the Skills section"
|
placeholder="Comments about the Skills section"
|
||||||
onChange={(value) => onValueChange('skills', value)}
|
onChange={(value) => onValueChange('skills', value)}
|
||||||
@ -130,7 +133,7 @@ export default function CommentsForm({
|
|||||||
|
|
||||||
<div className="flex justify-end space-x-2 pt-4">
|
<div className="flex justify-end space-x-2 pt-4">
|
||||||
<Button
|
<Button
|
||||||
disabled={reviewCreateMutation.isLoading}
|
disabled={commentCreateMutation.isLoading}
|
||||||
label="Cancel"
|
label="Cancel"
|
||||||
type="button"
|
type="button"
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
@ -138,8 +141,8 @@ export default function CommentsForm({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
disabled={!isDirty || reviewCreateMutation.isLoading}
|
disabled={!isDirty || commentCreateMutation.isLoading}
|
||||||
isLoading={reviewCreateMutation.isLoading}
|
isLoading={commentCreateMutation.isLoading}
|
||||||
label="Submit"
|
label="Submit"
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="primary"
|
variant="primary"
|
@ -0,0 +1,86 @@
|
|||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Spinner, Tabs } from '@tih/ui';
|
||||||
|
import { Button } from '@tih/ui';
|
||||||
|
|
||||||
|
import { trpc } from '~/utils/trpc';
|
||||||
|
|
||||||
|
import { RESUME_COMMENTS_SECTIONS } from './resumeCommentConstants';
|
||||||
|
import ResumeCommentListItem from './ResumeCommentListItem';
|
||||||
|
import ResumeSignInButton from '../shared/ResumeSignInButton';
|
||||||
|
|
||||||
|
import type { ResumeComment } from '~/types/resume-comments';
|
||||||
|
|
||||||
|
type ResumeCommentsListProps = Readonly<{
|
||||||
|
resumeId: string;
|
||||||
|
setShowCommentsForm: (show: boolean) => void;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export default function ResumeCommentsList({
|
||||||
|
resumeId,
|
||||||
|
setShowCommentsForm,
|
||||||
|
}: ResumeCommentsListProps) {
|
||||||
|
const { data: sessionData } = useSession();
|
||||||
|
const [tab, setTab] = useState(RESUME_COMMENTS_SECTIONS[0].value);
|
||||||
|
const [tabs, setTabs] = useState(RESUME_COMMENTS_SECTIONS);
|
||||||
|
|
||||||
|
const commentsQuery = trpc.useQuery(['resumes.comments.list', { resumeId }], {
|
||||||
|
onSuccess: (data: Array<ResumeComment>) => {
|
||||||
|
const updatedTabs = RESUME_COMMENTS_SECTIONS.map(({ label, value }) => {
|
||||||
|
const count = data.filter(({ section }) => section === value).length;
|
||||||
|
const updatedLabel = count > 0 ? `${label} (${count})` : label;
|
||||||
|
return {
|
||||||
|
label: updatedLabel,
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setTabs(updatedTabs);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderButton = () => {
|
||||||
|
if (sessionData === null) {
|
||||||
|
return <ResumeSignInButton text="to join discussion" />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
display="block"
|
||||||
|
label="Add your review"
|
||||||
|
variant="tertiary"
|
||||||
|
onClick={() => setShowCommentsForm(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{renderButton()}
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
label="comments"
|
||||||
|
tabs={tabs}
|
||||||
|
value={tab}
|
||||||
|
onChange={(value) => setTab(value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{commentsQuery.isFetching ? (
|
||||||
|
<div className="col-span-10 pt-4">
|
||||||
|
<Spinner display="block" size="lg" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="m-2 flow-root h-[calc(100vh-20rem)] w-full flex-col space-y-3 overflow-y-auto">
|
||||||
|
{(commentsQuery.data?.filter((c) => c.section === tab) ?? []).map(
|
||||||
|
(comment) => (
|
||||||
|
<ResumeCommentListItem
|
||||||
|
key={comment.id}
|
||||||
|
comment={comment}
|
||||||
|
userId={sessionData?.user?.id}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,13 +1,15 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import CommentsForm from './CommentsForm';
|
import ResumeCommentsForm from './ResumeCommentsForm';
|
||||||
import CommentsList from './CommentsList';
|
import ResumeCommentsList from './ResumeCommentsList';
|
||||||
|
|
||||||
type ICommentsSectionProps = {
|
type CommentsSectionProps = {
|
||||||
resumeId: string;
|
resumeId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CommentsSection({ resumeId }: ICommentsSectionProps) {
|
export default function ResumeCommentsSection({
|
||||||
|
resumeId,
|
||||||
|
}: CommentsSectionProps) {
|
||||||
const [showCommentsForm, setShowCommentsForm] = useState(false);
|
const [showCommentsForm, setShowCommentsForm] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -18,17 +20,17 @@ export default function CommentsSection({ resumeId }: ICommentsSectionProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="relative flex justify-center">
|
<div className="relative flex justify-center">
|
||||||
<span className="bg-gray-50 px-3 text-lg font-medium text-gray-900">
|
<span className="bg-gray-50 px-3 text-lg font-medium text-gray-900">
|
||||||
Comments
|
Reviews
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showCommentsForm ? (
|
{showCommentsForm ? (
|
||||||
<CommentsForm
|
<ResumeCommentsForm
|
||||||
resumeId={resumeId}
|
resumeId={resumeId}
|
||||||
setShowCommentsForm={setShowCommentsForm}
|
setShowCommentsForm={setShowCommentsForm}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CommentsList
|
<ResumeCommentsList
|
||||||
resumeId={resumeId}
|
resumeId={resumeId}
|
||||||
setShowCommentsForm={setShowCommentsForm}
|
setShowCommentsForm={setShowCommentsForm}
|
||||||
/>
|
/>
|
@ -1,18 +0,0 @@
|
|||||||
import CommentBody from './CommentBody';
|
|
||||||
import CommentCard from './CommentCard';
|
|
||||||
|
|
||||||
import type { ResumeComment } from '~/types/resume-comments';
|
|
||||||
|
|
||||||
type CommentProps = {
|
|
||||||
comment: ResumeComment;
|
|
||||||
userId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Comment({ comment, userId }: CommentProps) {
|
|
||||||
const isCommentOwner = userId === comment.user.userId;
|
|
||||||
return (
|
|
||||||
<CommentCard isCommentOwner={isCommentOwner}>
|
|
||||||
<CommentBody comment={comment} isCommentOwner={isCommentOwner} />
|
|
||||||
</CommentCard>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,64 +0,0 @@
|
|||||||
import {
|
|
||||||
ArrowDownCircleIcon,
|
|
||||||
ArrowUpCircleIcon,
|
|
||||||
} from '@heroicons/react/20/solid';
|
|
||||||
import { FaceSmileIcon } from '@heroicons/react/24/outline';
|
|
||||||
|
|
||||||
import type { ResumeComment } from '~/types/resume-comments';
|
|
||||||
|
|
||||||
type CommentBodyProps = {
|
|
||||||
comment: ResumeComment;
|
|
||||||
isCommentOwner?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function CommentBody({
|
|
||||||
comment,
|
|
||||||
isCommentOwner,
|
|
||||||
}: CommentBodyProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex w-full flex-row space-x-2 p-1 align-top">
|
|
||||||
{comment.user.image ? (
|
|
||||||
<img
|
|
||||||
alt={comment.user.name ?? 'Reviewer'}
|
|
||||||
className="mt-1 h-8 w-8 rounded-full"
|
|
||||||
src={comment.user.image!}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<FaceSmileIcon className="h-8 w-8 rounded-full" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex w-full flex-col space-y-1">
|
|
||||||
{/* Name and creation time */}
|
|
||||||
<div className="flex flex-row justify-between">
|
|
||||||
<div className="font-medium">
|
|
||||||
{comment.user.name ?? 'Reviewer ABC'}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-600">
|
|
||||||
{comment.createdAt.toLocaleString('en-US', {
|
|
||||||
dateStyle: 'medium',
|
|
||||||
timeStyle: 'short',
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<div className="text-sm">{comment.description}</div>
|
|
||||||
|
|
||||||
{/* Upvote and edit */}
|
|
||||||
<div className="flex flex-row space-x-1 pt-1 align-middle">
|
|
||||||
{/* TODO: Implement upvote */}
|
|
||||||
<ArrowUpCircleIcon className="h-4 w-4 fill-gray-400" />
|
|
||||||
<div className="text-xs">{comment.numVotes}</div>
|
|
||||||
<ArrowDownCircleIcon className="h-4 w-4 fill-gray-400" />
|
|
||||||
|
|
||||||
{/* TODO: Implement edit */}
|
|
||||||
{isCommentOwner ? (
|
|
||||||
<div className="text-primary-800 hover:text-primary-400 px-1 text-xs">
|
|
||||||
Edit
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,22 +0,0 @@
|
|||||||
import type { ReactNode } from 'react';
|
|
||||||
|
|
||||||
type CommentCardProps = {
|
|
||||||
children: ReactNode;
|
|
||||||
isCommentOwner?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function CommentCard({
|
|
||||||
isCommentOwner,
|
|
||||||
children,
|
|
||||||
}: CommentCardProps) {
|
|
||||||
// Used two different <div> to allow customisation of owner comments
|
|
||||||
return isCommentOwner ? (
|
|
||||||
<div className="border-primary-300 float-right w-3/4 rounded-md border-2 bg-white p-2 drop-shadow-md">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="border-primary-300 float-left w-3/4 rounded-md border-2 bg-white p-2 drop-shadow-md">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,6 +1,6 @@
|
|||||||
import { ResumesSection } from '@prisma/client';
|
import { ResumesSection } from '@prisma/client';
|
||||||
|
|
||||||
export const COMMENTS_SECTIONS = [
|
export const RESUME_COMMENTS_SECTIONS = [
|
||||||
{
|
{
|
||||||
label: 'General',
|
label: 'General',
|
||||||
value: ResumesSection.GENERAL,
|
value: ResumesSection.GENERAL,
|
@ -0,0 +1,48 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
type ResumeExpandableTextProps = Readonly<{
|
||||||
|
children: ReactNode;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export default function ResumeExpandableText({
|
||||||
|
children,
|
||||||
|
}: ResumeExpandableTextProps) {
|
||||||
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
|
||||||
|
const [descriptionOverflow, setDescriptionOverflow] = useState(false);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (ref.current && ref.current.clientHeight < ref.current.scrollHeight) {
|
||||||
|
setDescriptionOverflow(true);
|
||||||
|
}
|
||||||
|
}, [ref]);
|
||||||
|
|
||||||
|
const onSeeActionClicked = () => {
|
||||||
|
setDescriptionExpanded(!descriptionExpanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
ref={ref}
|
||||||
|
className={clsx(
|
||||||
|
'whitespace-pre-wrap text-sm',
|
||||||
|
'line-clamp-3',
|
||||||
|
descriptionExpanded ? 'line-clamp-none' : '',
|
||||||
|
)}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
{descriptionOverflow && (
|
||||||
|
<div className="flex flex-row">
|
||||||
|
<div
|
||||||
|
className="text-xs text-indigo-500 hover:text-indigo-300"
|
||||||
|
onClick={onSeeActionClicked}>
|
||||||
|
{descriptionExpanded ? 'See Less' : 'See More'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -15,7 +15,7 @@ import {
|
|||||||
} from '@heroicons/react/20/solid';
|
} from '@heroicons/react/20/solid';
|
||||||
import { Spinner } from '@tih/ui';
|
import { Spinner } from '@tih/ui';
|
||||||
|
|
||||||
import CommentsSection from '~/components/resumes/comments/CommentsSection';
|
import ResumeCommentsSection from '~/components/resumes/comments/ResumeCommentsSection';
|
||||||
import ResumePdf from '~/components/resumes/ResumePdf';
|
import ResumePdf from '~/components/resumes/ResumePdf';
|
||||||
|
|
||||||
import { trpc } from '~/utils/trpc';
|
import { trpc } from '~/utils/trpc';
|
||||||
@ -174,11 +174,11 @@ export default function ResumeReviewPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex w-full flex-col py-4 lg:flex-row">
|
<div className="flex w-full flex-col py-4 lg:flex-row">
|
||||||
<div className="w-full lg:w-[800px]">
|
<div className="w-full lg:w-[780px]">
|
||||||
<ResumePdf url={detailsQuery.data.url} />
|
<ResumePdf url={detailsQuery.data.url} />
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-8 grow">
|
<div className="mx-8 grow">
|
||||||
<CommentsSection resumeId={resumeId as string} />
|
<ResumeCommentsSection resumeId={resumeId as string} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
@ -9,10 +9,10 @@ import { questionsAnswerCommentRouter } from './questions-answer-comment-router'
|
|||||||
import { questionsAnswerRouter } from './questions-answer-router';
|
import { questionsAnswerRouter } from './questions-answer-router';
|
||||||
import { questionsQuestionCommentRouter } from './questions-question-comment-router';
|
import { questionsQuestionCommentRouter } from './questions-question-comment-router';
|
||||||
import { questionsQuestionRouter } from './questions-question-router';
|
import { questionsQuestionRouter } from './questions-question-router';
|
||||||
|
import { resumeCommentsRouter } from './resumes/resumes-comments-router';
|
||||||
|
import { resumesCommentsUserRouter } from './resumes/resumes-comments-user-router';
|
||||||
import { resumesRouter } from './resumes/resumes-resume-router';
|
import { resumesRouter } from './resumes/resumes-resume-router';
|
||||||
import { resumesResumeUserRouter } from './resumes/resumes-resume-user-router';
|
import { resumesResumeUserRouter } from './resumes/resumes-resume-user-router';
|
||||||
import { resumeReviewsRouter } from './resumes/resumes-reviews-router';
|
|
||||||
import { resumesReviewsUserRouter } from './resumes/resumes-reviews-user-router';
|
|
||||||
import { resumesStarUserRouter } from './resumes/resumes-star-user-router';
|
import { resumesStarUserRouter } from './resumes/resumes-star-user-router';
|
||||||
import { todosRouter } from './todos';
|
import { todosRouter } from './todos';
|
||||||
import { todosUserRouter } from './todos-user-router';
|
import { todosUserRouter } from './todos-user-router';
|
||||||
@ -29,8 +29,8 @@ export const appRouter = createRouter()
|
|||||||
.merge('resumes.resume.', resumesRouter)
|
.merge('resumes.resume.', resumesRouter)
|
||||||
.merge('resumes.resume.user.', resumesResumeUserRouter)
|
.merge('resumes.resume.user.', resumesResumeUserRouter)
|
||||||
.merge('resumes.resume.', resumesStarUserRouter)
|
.merge('resumes.resume.', resumesStarUserRouter)
|
||||||
.merge('resumes.reviews.', resumeReviewsRouter)
|
.merge('resumes.comments.', resumeCommentsRouter)
|
||||||
.merge('resumes.reviews.user.', resumesReviewsUserRouter)
|
.merge('resumes.comments.user.', resumesCommentsUserRouter)
|
||||||
.merge('questions.answers.comments.', questionsAnswerCommentRouter)
|
.merge('questions.answers.comments.', questionsAnswerCommentRouter)
|
||||||
.merge('questions.answers.', questionsAnswerRouter)
|
.merge('questions.answers.', questionsAnswerRouter)
|
||||||
.merge('questions.questions.comments.', questionsQuestionCommentRouter)
|
.merge('questions.questions.comments.', questionsQuestionCommentRouter)
|
||||||
|
@ -4,7 +4,7 @@ import { createRouter } from '../context';
|
|||||||
|
|
||||||
import type { ResumeComment } from '~/types/resume-comments';
|
import type { ResumeComment } from '~/types/resume-comments';
|
||||||
|
|
||||||
export const resumeReviewsRouter = createRouter().query('list', {
|
export const resumeCommentsRouter = createRouter().query('list', {
|
||||||
input: z.object({
|
input: z.object({
|
||||||
resumeId: z.string(),
|
resumeId: z.string(),
|
||||||
}),
|
}),
|
@ -3,14 +3,14 @@ import { ResumesSection } from '@prisma/client';
|
|||||||
|
|
||||||
import { createProtectedRouter } from '../context';
|
import { createProtectedRouter } from '../context';
|
||||||
|
|
||||||
type IResumeCommentInput = Readonly<{
|
type ResumeCommentInput = Readonly<{
|
||||||
description: string;
|
description: string;
|
||||||
resumeId: string;
|
resumeId: string;
|
||||||
section: ResumesSection;
|
section: ResumesSection;
|
||||||
userId: string;
|
userId: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export const resumesReviewsUserRouter = createProtectedRouter().mutation(
|
export const resumesCommentsUserRouter = createProtectedRouter().mutation(
|
||||||
'create',
|
'create',
|
||||||
{
|
{
|
||||||
input: z.object({
|
input: z.object({
|
||||||
@ -27,7 +27,7 @@ export const resumesReviewsUserRouter = createProtectedRouter().mutation(
|
|||||||
input;
|
input;
|
||||||
|
|
||||||
// For each section, convert them into ResumesComment model if provided
|
// For each section, convert them into ResumesComment model if provided
|
||||||
const comments: Array<IResumeCommentInput> = [
|
const comments: Array<ResumeCommentInput> = [
|
||||||
{ description: education, section: ResumesSection.EDUCATION },
|
{ description: education, section: ResumesSection.EDUCATION },
|
||||||
{ description: experience, section: ResumesSection.EXPERIENCE },
|
{ description: experience, section: ResumesSection.EXPERIENCE },
|
||||||
{ description: general, section: ResumesSection.GENERAL },
|
{ description: general, section: ResumesSection.GENERAL },
|
6
apps/portal/src/types/resume-comments.d.ts
vendored
6
apps/portal/src/types/resume-comments.d.ts
vendored
@ -1,10 +1,10 @@
|
|||||||
import type { ResumesSection } from '@prisma/client';
|
import type { ResumesSection } from '@prisma/client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returned by `resumeReviewsRouter` (query for 'resumes.reviews.list') and received as prop by `Comment` in `CommentsList`
|
* Returned by `resumeCommentsRouter` (query for 'resumes.comments.list') and received as prop by `Comment` in `CommentsList`
|
||||||
* frontend-friendly representation of the query
|
* frontend-friendly representation of the query
|
||||||
*/
|
*/
|
||||||
export type ResumeComment = {
|
export type ResumeComment = Readonly<{
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
description: string;
|
description: string;
|
||||||
hasVoted: boolean;
|
hasVoted: boolean;
|
||||||
@ -18,4 +18,4 @@ export type ResumeComment = {
|
|||||||
name: string?;
|
name: string?;
|
||||||
userId: string;
|
userId: string;
|
||||||
};
|
};
|
||||||
};
|
}>;
|
||||||
|
Reference in New Issue
Block a user