[web] workspace/discussions: workspace comments (#1689)

This commit is contained in:
Nitesh Seram 2025-09-12 08:21:36 +05:30 committed by GitHub
parent 961f01b453
commit 25e658c0a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1653 additions and 3 deletions

View File

@ -0,0 +1,95 @@
-- CreateEnum
CREATE TYPE "InterviewsDiscussionCommentDomain" AS ENUM ('QUESTION', 'OFFICIAL_SOLUTION');
-- CreateEnum
CREATE TYPE "InterviewsActivityCategory" AS ENUM ('DISCUSSION', 'DISCUSSION_UPVOTE');
-- CreateTable
CREATE TABLE "InterviewsDiscussionComment" (
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
"entityId" TEXT NOT NULL,
"domain" "InterviewsDiscussionCommentDomain" NOT NULL,
"body" TEXT NOT NULL,
"profileId" UUID NOT NULL,
"createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"parentCommentId" UUID,
"repliedToId" UUID,
CONSTRAINT "InterviewsDiscussionComment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "InterviewsDiscussionCommentVote" (
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
"commentId" UUID NOT NULL,
"profileId" UUID NOT NULL,
"createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "InterviewsDiscussionCommentVote_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "InterviewsActivity" (
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
"actorId" UUID NOT NULL,
"recipientId" UUID,
"createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"category" "InterviewsActivityCategory" NOT NULL,
"read" BOOLEAN NOT NULL DEFAULT false,
"commentId" UUID,
"voteId" UUID,
CONSTRAINT "InterviewsActivity_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "InterviewsDiscussionComment_entityId_idx" ON "InterviewsDiscussionComment"("entityId");
-- CreateIndex
CREATE INDEX "InterviewsDiscussionComment_profileId_idx" ON "InterviewsDiscussionComment"("profileId");
-- CreateIndex
CREATE INDEX "InterviewsDiscussionCommentVote_commentId_idx" ON "InterviewsDiscussionCommentVote"("commentId");
-- CreateIndex
CREATE INDEX "InterviewsDiscussionCommentVote_profileId_idx" ON "InterviewsDiscussionCommentVote"("profileId");
-- CreateIndex
CREATE UNIQUE INDEX "InterviewsDiscussionCommentVote_commentId_profileId_key" ON "InterviewsDiscussionCommentVote"("commentId", "profileId");
-- CreateIndex
CREATE UNIQUE INDEX "InterviewsActivity_voteId_key" ON "InterviewsActivity"("voteId");
-- CreateIndex
CREATE INDEX "InterviewsActivity_actorId_idx" ON "InterviewsActivity"("actorId");
-- CreateIndex
CREATE INDEX "InterviewsActivity_recipientId_idx" ON "InterviewsActivity"("recipientId");
-- AddForeignKey
ALTER TABLE "InterviewsDiscussionComment" ADD CONSTRAINT "InterviewsDiscussionComment_parentCommentId_fkey" FOREIGN KEY ("parentCommentId") REFERENCES "InterviewsDiscussionComment"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "InterviewsDiscussionComment" ADD CONSTRAINT "InterviewsDiscussionComment_repliedToId_fkey" FOREIGN KEY ("repliedToId") REFERENCES "InterviewsDiscussionComment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "InterviewsDiscussionComment" ADD CONSTRAINT "InterviewsDiscussionComment_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "Profile"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "InterviewsDiscussionCommentVote" ADD CONSTRAINT "InterviewsDiscussionCommentVote_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "InterviewsDiscussionComment"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "InterviewsDiscussionCommentVote" ADD CONSTRAINT "InterviewsDiscussionCommentVote_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "Profile"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "InterviewsActivity" ADD CONSTRAINT "InterviewsActivity_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "InterviewsDiscussionComment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "InterviewsActivity" ADD CONSTRAINT "InterviewsActivity_voteId_fkey" FOREIGN KEY ("voteId") REFERENCES "InterviewsDiscussionCommentVote"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "InterviewsActivity" ADD CONSTRAINT "InterviewsActivity_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "Profile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "InterviewsActivity" ADD CONSTRAINT "InterviewsActivity_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "Profile"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -75,6 +75,10 @@ model Profile {
projectsProfile ProjectsProfile?
rewardsTaskCompletions RewardsTaskCompletion[]
sponsorsAdRequestReview SponsorsAdRequestReview[]
discussionComments InterviewsDiscussionComment[]
discussionCommentVotes InterviewsDiscussionCommentVote[]
activitiesGiven InterviewsActivity[] @relation("ActivityActor")
activitiesReceived InterviewsActivity[] @relation("ActivityRecipient")
@@index([username])
@@index([stripeCustomer])
@ -276,6 +280,74 @@ model LearningSessionProgress {
@@index([sessionId])
}
enum InterviewsDiscussionCommentDomain {
QUESTION
OFFICIAL_SOLUTION
}
model InterviewsDiscussionComment {
id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
entityId String
domain InterviewsDiscussionCommentDomain
body String
profileId String @db.Uuid
createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt
parentCommentId String? @db.Uuid // For replying to a comment
parentComment InterviewsDiscussionComment? @relation("DiscussionThread", fields: [parentCommentId], references: [id], onDelete: Cascade, onUpdate: NoAction)
replies InterviewsDiscussionComment[] @relation("DiscussionThread")
repliedToId String? @db.Uuid // For replying to a reply
repliedTo InterviewsDiscussionComment? @relation("DirectReply", fields: [repliedToId], references: [id], onDelete: Cascade)
directReplies InterviewsDiscussionComment[] @relation("DirectReply")
author Profile @relation(fields: [profileId], references: [id], onDelete: Cascade, onUpdate: NoAction)
votes InterviewsDiscussionCommentVote[]
activities InterviewsActivity[]
@@index([entityId])
@@index([profileId])
}
model InterviewsDiscussionCommentVote {
id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
commentId String @db.Uuid
profileId String @db.Uuid
createdAt DateTime @default(now()) @db.Timestamptz(6)
comment InterviewsDiscussionComment @relation(fields: [commentId], references: [id], onDelete: Cascade, onUpdate: NoAction)
author Profile @relation(fields: [profileId], references: [id], onDelete: Cascade, onUpdate: NoAction)
activity InterviewsActivity?
@@unique([commentId, profileId])
@@index([commentId])
@@index([profileId])
}
enum InterviewsActivityCategory {
DISCUSSION
DISCUSSION_UPVOTE
}
model InterviewsActivity {
id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
actorId String @db.Uuid // who performed the action
recipientId String? @db.Uuid // who receives the notification
createdAt DateTime @default(now()) @db.Timestamptz(6)
category InterviewsActivityCategory
read Boolean @default(false)
commentId String? @db.Uuid // For "discussion comment"
voteId String? @unique @db.Uuid // For "discussion comment upvote"
comment InterviewsDiscussionComment? @relation(fields: [commentId], references: [id], onDelete: Cascade)
vote InterviewsDiscussionCommentVote? @relation(fields: [voteId], references: [id], onDelete: Cascade)
actor Profile @relation("ActivityActor", fields: [actorId], references: [id], onDelete: Cascade)
recipient Profile? @relation("ActivityRecipient", fields: [recipientId], references: [id], onDelete: Cascade)
@@index([actorId])
@@index([recipientId])
}
enum ProjectsSubscriptionPlan {
MONTH
ANNUAL

View File

@ -55,6 +55,7 @@ export default function InterviewsMarketingEmbedJavaScriptQuestion({
<JavaScriptCodingWorkspaceDescription
canViewPremiumContent={false}
description={javaScriptEmbedExample.description}
environment="embed"
metadata={javaScriptEmbedExample.metadata}
nextQuestions={[]}
showAd={false}

View File

@ -110,7 +110,7 @@ export default function EmptyState({
<Icon
aria-hidden="true"
className={clsx(
'size-10 mx-auto shrink-0',
'mx-auto size-10 shrink-0',
iconClassName,
colors[variant],
)}
@ -121,13 +121,14 @@ export default function EmptyState({
size: titleSize,
weight: 'medium',
})}
level="custom">
level="custom"
weight="medium">
{title}
</Heading>
<Section>
{subtitle && (
<Text
className="text-pretty mt-1 block"
className="mt-1 block text-pretty"
color="secondary"
size={subtitleSize}>
{subtitle}

View File

@ -1,13 +1,21 @@
import { INTERVIEWS_DISCUSSIONS_IS_LIVE } from '~/data/FeatureFlags';
import type { QuestionMetadata } from '~/components/interviews/questions/common/QuestionsTypes';
import QuestionNextQuestions from '~/components/interviews/questions/content/QuestionNextQuestions';
import QuestionSimilarQuestions from '~/components/interviews/questions/content/QuestionSimilarQuestions';
import type { SponsorsAdFormatInContentPlacement } from '~/components/sponsors/ads/SponsorsAdFormatInContent';
import SponsorsAdFormatInContentContainer from '~/components/sponsors/ads/SponsorsAdFormatInContentContainer';
import Divider from '~/components/ui/Divider';
import CodingWorkspaceDiscussionsSection from '~/components/workspace/common/discussions/CodingWorkspaceDiscussionsSection';
import { hashQuestion } from '~/db/QuestionsUtils';
type Props = Readonly<{
adPlacement: SponsorsAdFormatInContentPlacement;
className?: string;
contentType: 'description' | 'solution';
environment?: 'embed' | 'workspace';
metadata: QuestionMetadata;
nextQuestions: ReadonlyArray<QuestionMetadata>;
showAd?: boolean;
similarQuestions: ReadonlyArray<QuestionMetadata>;
@ -16,12 +24,26 @@ type Props = Readonly<{
export default function CodingWorkspaceDescriptionAddOnItems({
adPlacement,
className,
contentType,
environment = 'workspace',
metadata,
nextQuestions,
showAd = true,
similarQuestions,
}: Props) {
return (
<div className={className}>
{INTERVIEWS_DISCUSSIONS_IS_LIVE && environment === 'workspace' && (
<div className="space-y-6">
<Divider />
<CodingWorkspaceDiscussionsSection
domain={
contentType === 'solution' ? 'OFFICIAL_SOLUTION' : 'QUESTION'
}
entityId={hashQuestion(metadata)}
/>
</div>
)}
{nextQuestions.length > 0 || similarQuestions.length > 0 ? (
<div>
<Divider className="mb-3" />

View File

@ -0,0 +1,100 @@
import { createHeadlessEditor } from '@lexical/headless';
import { $getRoot } from 'lexical';
import { z } from 'zod';
import type { IntlShape } from '~/components/intl';
import { useIntl } from '~/components/intl';
import { RichTextEditorConfig } from '~/components/ui/RichTextEditor/RichTextEditorConfig';
const MIN_LENGTH = 6;
const MAX_LENGTH = 1000;
const editor = createHeadlessEditor(RichTextEditorConfig);
function discussionsCommentBodySchema(options?: {
maxMessage: string;
minMessage: string;
}) {
const { maxMessage, minMessage } = options ?? {};
return z
.string()
.trim()
.refine(
(value) => {
const editorState = editor.parseEditorState(value);
const text = editorState.read(() => $getRoot().getTextContent());
return text.length >= MIN_LENGTH;
},
{
message: minMessage,
},
)
.refine(
(value) => {
const editorState = editor.parseEditorState(value);
const text = editorState.read(() => $getRoot().getTextContent());
return text.length <= MAX_LENGTH;
},
{
message: maxMessage,
},
);
}
// TODO: Figure out how to reuse intl strings for the server.
export const discussionsCommentBodySchemaServer = discussionsCommentBodySchema({
maxMessage: `At most ${MAX_LENGTH} character(s).`,
minMessage: `At least ${MIN_LENGTH} character(s).`,
});
export function getDiscussionsCommentBodyAttributes(intl: IntlShape) {
const placeholder = intl.formatMessage({
defaultMessage: 'Type comment here...',
description: 'Placeholder for discussion post input text area',
id: '5fQIye',
});
const maxMessage = intl.formatMessage(
{
defaultMessage: 'Character limit exceeded',
description: 'Error message',
id: 'NGLLNl',
},
{
maxLength: MAX_LENGTH,
},
);
const minMessage = intl.formatMessage(
{
defaultMessage: 'At least {minLength} character(s).',
description: 'Error message',
id: 'Vp6BGE',
},
{
minLength: MIN_LENGTH,
},
);
return {
placeholder,
validation: {
maxLength: MAX_LENGTH,
maxMessage,
minLength: MIN_LENGTH,
minMessage,
required: true,
},
} as const;
}
export function useDiscussionsCommentBodySchema() {
const intl = useIntl();
const intlStrings = getDiscussionsCommentBodyAttributes(intl);
return discussionsCommentBodySchema({
maxMessage: intlStrings.validation.maxMessage,
minMessage: intlStrings.validation.minMessage,
});
}

View File

@ -0,0 +1,175 @@
import { useUser } from '@supabase/auth-helpers-react';
import clsx from 'clsx';
import { useState } from 'react';
import {
RiArrowDownSLine,
RiArrowUpSLine,
RiPencilLine,
RiReplyLine,
} from 'react-icons/ri';
import RelativeTimestamp from '~/components/common/datetime/RelativeTimestamp';
import { useIntl } from '~/components/intl';
import UserProfileDisplayName from '~/components/profile/info/UserProfileDisplayName';
import Avatar from '~/components/ui/Avatar';
import Button from '~/components/ui/Button';
import RichText from '~/components/ui/RichTextEditor/RichText';
import Text from '~/components/ui/Text';
import { themeTextSecondaryColor } from '~/components/ui/theme';
import CodingWorkspaceDiscussionsCommentDeleteButton from './CodingWorkspaceDiscussionsCommentDeleteButton';
import CodingWorkspaceDiscussionsCommentEditInput from './CodingWorkspaceDiscussionsCommentEditInput';
import CodingWorkspaceDiscussionsCommentVoteButton from './CodingWorkspaceDiscussionsCommentVoteButton';
import CodingWorkspaceDiscussionsReplyInput from './CodingWorkspaceDiscussionsReplyInput';
import type { CodingWorkspaceDiscussionsCommentItem } from './types';
type Props = Readonly<{
comment: CodingWorkspaceDiscussionsCommentItem;
}>;
export default function CodingWorkspaceDiscussionsComment({ comment }: Props) {
const intl = useIntl();
const user = useUser();
const isLoggedIn = user != null;
const {
_count: { votes: votesCount },
author,
body,
createdAt,
updatedAt,
} = comment;
const isUserOwnComment = user?.id === author.id;
const replyCount = comment.replies?.length ?? 0;
const hasReplies = replyCount > 0;
const [mode, setMode] = useState<'delete' | 'edit' | 'reply' | null>(null);
const [showReplies, setShowReplies] = useState(false);
return (
<div className="py-2">
<div className="flex gap-3">
<Avatar
alt={author.name ?? author.username}
size="xs"
src={author.avatarUrl ?? ''}
/>{' '}
<div>
<Text color="secondary" size="body2">
<Text color="default" size="inherit">
<UserProfileDisplayName userProfile={author} />
</Text>
{' · '}
<RelativeTimestamp timestamp={createdAt} />
</Text>
<div className="mt-1.5">
{mode === 'edit' ? (
<CodingWorkspaceDiscussionsCommentEditInput
comment={comment}
onCancel={() => {
setMode(null);
}}
/>
) : (
<RichText
key={updatedAt.getTime()}
color="body"
size="sm"
value={body}
/>
)}
</div>
<div className="-ml-2 mt-3">
<CodingWorkspaceDiscussionsCommentVoteButton
comment={comment}
count={votesCount}
/>
{isLoggedIn && (
<Button
addonPosition="start"
className={themeTextSecondaryColor}
icon={RiReplyLine}
label={intl.formatMessage({
defaultMessage: 'Reply',
description: 'Label for reply button',
id: 'do3v9Q',
})}
size="xs"
variant="tertiary"
onClick={() => setMode(mode === 'reply' ? null : 'reply')}
/>
)}
{isUserOwnComment && (
<>
<Button
addonPosition="start"
className={themeTextSecondaryColor}
icon={RiPencilLine}
label={intl.formatMessage({
defaultMessage: 'Edit',
description: 'Edit button label',
id: '2rcoOT',
})}
size="xs"
variant="tertiary"
onClick={() => setMode(mode === 'edit' ? null : 'edit')}
/>
<CodingWorkspaceDiscussionsCommentDeleteButton
comment={comment}
dialogShown={mode === 'delete'}
onClick={() => setMode('delete')}
onDismiss={() => setMode(null)}
/>
</>
)}
</div>
</div>
</div>
{mode === 'reply' && user != null && (
<div className="ml-9 mt-2">
<CodingWorkspaceDiscussionsReplyInput
author={author}
parentComment={comment}
onCancel={() => {
setMode(null);
}}
/>
</div>
)}
{hasReplies && (
<div className="ml-9">
{showReplies && (
<div className="mt-1.5">
{comment.replies?.map((reply) => (
<CodingWorkspaceDiscussionsComment
key={reply.id}
comment={reply}
/>
))}
</div>
)}
<Button
addonPosition="end"
className={clsx('-ml-2', showReplies ? 'mt-1' : 'mt-3')}
icon={showReplies ? RiArrowDownSLine : RiArrowUpSLine}
label={
showReplies
? intl.formatMessage({
defaultMessage: 'Hide replies',
description: 'Label for hide replies button',
id: 'aUHfy4',
})
: intl.formatMessage({
defaultMessage: 'See more replies',
description: 'Label for see more replies button',
id: 'xufXOh',
})
}
size="xs"
variant="tertiary"
onClick={() => setShowReplies(!showReplies)}
/>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,87 @@
import { RiDeleteBinLine } from 'react-icons/ri';
import { trpc } from '~/hooks/trpc';
import ConfirmationDialog from '~/components/common/ConfirmationDialog';
import { FormattedMessage, useIntl } from '~/components/intl';
import Button from '~/components/ui/Button';
import { themeTextSecondaryColor } from '~/components/ui/theme';
import type { CodingWorkspaceDiscussionsCommentItem } from './types';
type Props = Readonly<{
comment: CodingWorkspaceDiscussionsCommentItem;
dialogShown: boolean;
onClick: () => void;
onDismiss: () => void;
}>;
export default function CodingWorkspaceDiscussionsCommentDeleteButton({
comment,
dialogShown,
onClick,
onDismiss,
}: Props) {
const intl = useIntl();
const trpcUtils = trpc.useUtils();
const deleteCommentMutation = trpc.questionComments.delete.useMutation({
onSuccess: () => {
trpcUtils.questionComments.list.invalidate({
domain: comment.domain,
entityId: comment.entityId,
});
},
});
return (
<ConfirmationDialog
confirmButtonLabel={intl.formatMessage({
defaultMessage: 'Delete',
description: 'Delete button label',
id: 'WodcPq',
})}
confirmButtonVariant="danger"
isDisabled={deleteCommentMutation.isLoading}
isLoading={deleteCommentMutation.isLoading}
isShown={dialogShown}
title={intl.formatMessage({
defaultMessage: 'Delete comment?',
description: 'Delete comment confirmation dialog title',
id: 'hm0ODb',
})}
trigger={
<Button
addonPosition="start"
className={themeTextSecondaryColor}
icon={RiDeleteBinLine}
label={intl.formatMessage({
defaultMessage: 'Delete',
description: 'Delete button label',
id: 'WodcPq',
})}
size="xs"
variant="tertiary"
onClick={onClick}
/>
}
onCancel={onDismiss}
onConfirm={() => {
deleteCommentMutation.mutate(
{
commentId: comment.id,
},
{
onSuccess: () => {
onDismiss();
},
},
);
}}>
<FormattedMessage
defaultMessage="Are you sure want to delete your comment? There is no undo for this action."
description="Confirmation text for deleting a comment"
id="GkLfUb"
/>
</ConfirmationDialog>
);
}

View File

@ -0,0 +1,103 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { trpc } from '~/hooks/trpc';
import { useIntl } from '~/components/intl';
import Button from '~/components/ui/Button';
import { useDiscussionsCommentBodySchema } from './CodingWorkspaceDiscussionsComentBodySchema';
import CodingWorkspaceDiscussionsCommentEditor from './CodingWorkspaceDiscussionsCommentEditor';
import type { CodingWorkspaceDiscussionsCommentItem } from './types';
type Props = Readonly<{
comment: CodingWorkspaceDiscussionsCommentItem;
onCancel: () => void;
}>;
type CommentFormInput = Readonly<{
body: string;
}>;
export default function CodingWorkspaceDiscussionsCommentEditInput({
comment,
onCancel,
}: Props) {
const intl = useIntl();
const trpcUtils = trpc.useUtils();
const updateCommentMutation = trpc.questionComments.update.useMutation({
onSuccess: () => {
trpcUtils.questionComments.list.invalidate({
domain: comment.domain,
entityId: comment.entityId,
});
},
});
const discussionsCommentBodySchema = useDiscussionsCommentBodySchema();
const { control, handleSubmit } = useForm<CommentFormInput>({
defaultValues: {
body: comment.body,
},
mode: 'onSubmit',
resolver: zodResolver(
z.object({
body: discussionsCommentBodySchema,
}),
),
});
function onSubmit(data: CommentFormInput) {
updateCommentMutation.mutate(
{
body: data.body,
commentId: comment.id,
},
{
onSuccess: () => {
onCancel();
},
},
);
}
return (
<form
className="flex w-full grow flex-col"
onSubmit={handleSubmit(onSubmit)}>
<div className="mt-2">
<CodingWorkspaceDiscussionsCommentEditor
control={control}
isLoading={updateCommentMutation.isLoading}
/>
</div>
<div className="flex items-center gap-3">
<Button
className="w-20"
isDisabled={updateCommentMutation.isLoading}
label={intl.formatMessage({
defaultMessage: 'Cancel',
description: 'Cancel button label',
id: '0GT0SI',
})}
variant="secondary"
onClick={onCancel}
/>
<Button
className="w-20"
isDisabled={updateCommentMutation.isLoading}
isLoading={updateCommentMutation.isLoading}
label={intl.formatMessage({
defaultMessage: 'Save',
description: 'Save update button label',
id: 'aYJLMU',
})}
type="submit"
variant="primary"
/>
</div>
</form>
);
}

View File

@ -0,0 +1,53 @@
import type { Control } from 'react-hook-form';
import { useController } from 'react-hook-form';
import { useIntl } from '~/components/intl';
import RichTextEditor from '~/components/ui/RichTextEditor';
import { getDiscussionsCommentBodyAttributes } from './CodingWorkspaceDiscussionsComentBodySchema';
type Props = Readonly<{
control: Control<{ body: string }>;
editorRerenderKey?: number;
isLoading: boolean;
}>;
const fieldName = 'body';
export default function CodingWorkspaceDiscussionsCommentEditor({
control,
editorRerenderKey,
isLoading,
}: Props) {
const intl = useIntl();
const attrs = getDiscussionsCommentBodyAttributes(intl);
const { field, formState } = useController({
control,
name: fieldName,
rules: { required: true },
});
return (
<RichTextEditor
key={editorRerenderKey}
disabled={isLoading}
errorMessage={
formState.dirtyFields.body || formState.submitCount > 0
? formState.errors.body?.message
: undefined
}
isLabelHidden={true}
label={intl.formatMessage({
defaultMessage: 'Discussion post comment',
description: 'Label for discussion post input textarea',
id: 'NA1S3Z',
})}
maxLength={attrs.validation.maxLength}
minHeight="100px"
placeholder={attrs.placeholder}
{...field}
required={true}
/>
);
}

View File

@ -0,0 +1,127 @@
import type { InterviewsDiscussionCommentDomain } from '@prisma/client';
import clsx from 'clsx';
import { useEffect } from 'react';
import { trpc } from '~/hooks/trpc';
import usePagination from '~/hooks/usePagination';
import { useIntl } from '~/components/intl';
import Divider from '~/components/ui/Divider';
import EmptyState from '~/components/ui/EmptyState';
import Pagination from '~/components/ui/Pagination';
import Spinner from '~/components/ui/Spinner';
import Text from '~/components/ui/Text';
import CodingWorkspaceDiscussionsComment from './CodingWorkspaceDiscussionsComment';
import type { CodingWorkspaceDiscussionsCommentSortField } from './types';
type Props = Readonly<{
domain: InterviewsDiscussionCommentDomain;
entityId: string;
onUpdateCommentsCount: (value: number) => void;
sort: {
field: CodingWorkspaceDiscussionsCommentSortField;
isAscendingOrder: boolean;
};
}>;
const ITEMS_PER_PAGE = 10;
export default function CodingWorkspaceDiscussionsCommentList({
domain,
entityId,
onUpdateCommentsCount,
sort,
}: Props) {
const intl = useIntl();
// Pagination
const { currentPage, setCurrentPage } = usePagination({
deps: [],
itemsPerPage: ITEMS_PER_PAGE,
page: 1,
});
const { data, isLoading } = trpc.questionComments.list.useQuery(
{
domain,
entityId,
pagination: {
limit: ITEMS_PER_PAGE,
page: currentPage,
},
sort,
},
{
keepPreviousData: true,
},
);
const { comments, count } = data ?? {};
// Update the comments count when data changes
useEffect(() => {
if (count != null) {
onUpdateCommentsCount(count);
}
}, [count, onUpdateCommentsCount]);
if (isLoading && comments?.length !== 0) {
return (
<div className="p-18 w-full">
<Spinner display="block" />
</div>
);
}
if (comments?.length === 0 || !comments) {
return (
<EmptyState
title={intl.formatMessage({
defaultMessage: 'No comments yet',
description: 'No comment title',
id: '9QBgga',
})}
/>
);
}
const totalPages = Math.ceil((count ?? 0) / ITEMS_PER_PAGE);
return (
<div>
{comments.map((comment, index) => (
<div key={comment.id}>
<CodingWorkspaceDiscussionsComment comment={comment} />
{index !== comments.length - 1 && <Divider className="mb-1.5" />}
</div>
))}
{(count ?? 0) > 0 && (
<div
className={clsx('flex items-center justify-between gap-1', 'mt-2.5')}>
<Text color="secondary" size="body3">
{intl.formatMessage(
{
defaultMessage:
'Showing {from} to {to} comments out of {totalCount} discussions',
description: 'Comments count',
id: 'zJP+bP',
},
{
from: (currentPage - 1) * ITEMS_PER_PAGE + 1,
to: Math.min(currentPage * ITEMS_PER_PAGE, count ?? 0),
totalCount: count ?? 0,
},
)}
</Text>
{totalPages > 1 && (
<Pagination
count={totalPages}
page={currentPage}
onPageChange={(value) => {
setCurrentPage(value);
}}
/>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,98 @@
import { RiSortDesc } from 'react-icons/ri';
import { useIntl } from '~/components/intl';
import DropdownMenu from '~/components/ui/DropdownMenu';
import type { CodingWorkspaceDiscussionsCommentSortField } from './types';
type Props = Readonly<{
isAscendingOrder: boolean;
setIsAscendingOrder: (value: boolean) => void;
setSortField: (value: CodingWorkspaceDiscussionsCommentSortField) => void;
sortField: CodingWorkspaceDiscussionsCommentSortField | null;
}>;
export default function CodingWorkspaceDiscussionsCommentSort({
isAscendingOrder,
setIsAscendingOrder,
setSortField,
sortField,
}: Props) {
const intl = useIntl();
function makeDropdownItemProps(
label: string,
itemField: CodingWorkspaceDiscussionsCommentSortField,
isItemAscendingOrder: boolean,
) {
return {
isSelected:
sortField === itemField && isAscendingOrder === isItemAscendingOrder,
label,
onClick: () => {
setSortField(itemField);
setIsAscendingOrder(isItemAscendingOrder);
},
};
}
return (
<div>
<DropdownMenu
align="end"
icon={RiSortDesc}
label={intl.formatMessage({
defaultMessage: 'Sort by',
description:
'Label for sort button for projects discussion post list',
id: 'NjnYqU',
})}
size="xs">
{[
makeDropdownItemProps(
intl.formatMessage({
defaultMessage: 'Popularity: Most to least upvotes',
description:
'Sorting option for discussions comment - popularity',
id: 'ooGnHf',
}),
'votes',
false,
),
makeDropdownItemProps(
intl.formatMessage({
defaultMessage: 'Popularity: Least to most upvotes',
description:
'Sorting option for discussions comment - popularity',
id: 'L33SIl',
}),
'votes',
true,
),
makeDropdownItemProps(
intl.formatMessage({
defaultMessage: 'Created: Newest to oldest',
description:
'Sorting option for discussions comment - sort by created',
id: '87c2KI',
}),
'createdAt',
false,
),
makeDropdownItemProps(
intl.formatMessage({
defaultMessage: 'Created: Oldest to newest',
description:
'Sorting option for discussions comment - sort by created',
id: 'G3byny',
}),
'createdAt',
true,
),
].map((props) => (
<DropdownMenu.Item key={props.label} {...props} />
))}
</DropdownMenu>
</div>
);
}

View File

@ -0,0 +1,89 @@
import { useUser } from '@supabase/auth-helpers-react';
import clsx from 'clsx';
import { BiSolidUpvote, BiUpvote } from 'react-icons/bi';
import { trpc } from '~/hooks/trpc';
import { useIntl } from '~/components/intl';
import Button from '~/components/ui/Button';
import { themeTextColor, themeTextSecondaryColor } from '~/components/ui/theme';
import type { CodingWorkspaceDiscussionsCommentItem } from './types';
type Props = Readonly<{
comment: CodingWorkspaceDiscussionsCommentItem;
count: number;
}>;
export default function CodingWorkspaceDiscussionsCommentVoteButton({
comment,
count,
}: Props) {
const intl = useIntl();
const user = useUser();
const trpcUtils = trpc.useUtils();
const voteCommentMutation = trpc.questionComments.vote.useMutation({
onSuccess: () => {
trpcUtils.questionComments.list.invalidate({
domain: comment.domain,
entityId: comment.entityId,
});
trpcUtils.questionComments.liked.invalidate({ commentId: comment.id });
},
});
const unvoteCommentMutation = trpc.questionComments.unvote.useMutation({
onSuccess: () => {
trpcUtils.questionComments.list.invalidate({
domain: comment.domain,
entityId: comment.entityId,
});
trpcUtils.questionComments.liked.invalidate({ commentId: comment.id });
},
});
const { data: hasLiked } = trpc.questionComments.liked.useQuery({
commentId: comment.id,
});
const isLoggedIn = user != null;
const hasVoted = hasLiked != null;
const actionLabel =
isLoggedIn && hasVoted
? intl.formatMessage({
defaultMessage: 'Unvote',
description: 'Vote button label',
id: 'pjndkX',
})
: intl.formatMessage({
defaultMessage: 'Upvote',
description: 'Vote button label',
id: 'bj5dJV',
});
const Icon = hasVoted ? BiSolidUpvote : BiUpvote;
return (
<Button
addonPosition="start"
aria-label={actionLabel}
className={clsx(themeTextSecondaryColor, !isLoggedIn && 'cursor-text')}
icon={Icon}
iconClassName={hasVoted ? themeTextColor : themeTextSecondaryColor}
label={String(count)}
size="xs"
tooltip={actionLabel}
variant="tertiary"
onClick={
isLoggedIn
? () => {
hasVoted
? unvoteCommentMutation.mutate({
commentId: comment.id,
})
: voteCommentMutation.mutate({
commentId: comment.id,
});
}
: undefined
}
/>
);
}

View File

@ -0,0 +1,90 @@
import { zodResolver } from '@hookform/resolvers/zod';
import type { InterviewsDiscussionCommentDomain } from '@prisma/client';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { trpc } from '~/hooks/trpc';
import { useIntl } from '~/components/intl';
import Button from '~/components/ui/Button';
import { useDiscussionsCommentBodySchema } from './CodingWorkspaceDiscussionsComentBodySchema';
import CodingWorkspaceDiscussionsCommentEditor from './CodingWorkspaceDiscussionsCommentEditor';
type CommentFormInput = Readonly<{
body: string;
}>;
type Props = Readonly<{
domain: InterviewsDiscussionCommentDomain;
entityId: string;
}>;
export default function CodingWorkspaceDiscussionsNewComment({
domain,
entityId,
}: Props) {
const intl = useIntl();
const trpcUtils = trpc.useUtils();
const [editorRerenderKey, setEditorRerenderKey] = useState(0);
const createCommentMutation = trpc.questionComments.create.useMutation({
onSuccess: () => {
trpcUtils.questionComments.list.invalidate({
domain,
entityId,
});
},
});
const discussionsCommentBodySchema = useDiscussionsCommentBodySchema();
const { control, handleSubmit, reset } = useForm<CommentFormInput>({
defaultValues: {
body: '',
},
mode: 'onTouched',
resolver: zodResolver(
z.object({
body: discussionsCommentBodySchema,
}),
),
});
function onSubmit(data: CommentFormInput) {
return createCommentMutation.mutate(
{
body: data.body,
domain,
entityId,
},
{
onSuccess: () => {
setEditorRerenderKey((prevKey) => prevKey + 1);
reset();
},
},
);
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<CodingWorkspaceDiscussionsCommentEditor
control={control}
editorRerenderKey={editorRerenderKey}
isLoading={createCommentMutation.isLoading}
/>
<Button
className="w-20"
isDisabled={createCommentMutation.isLoading}
isLoading={createCommentMutation.isLoading}
label={intl.formatMessage({
defaultMessage: 'Post',
description: 'Label for post button on project discussions page',
id: 'bnqijt',
})}
type="submit"
variant="primary"
/>
</form>
);
}

View File

@ -0,0 +1,131 @@
import { zodResolver } from '@hookform/resolvers/zod';
import clsx from 'clsx';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { trpc } from '~/hooks/trpc';
import { FormattedMessage, useIntl } from '~/components/intl';
import Avatar from '~/components/ui/Avatar';
import Button from '~/components/ui/Button';
import Text from '~/components/ui/Text';
import { useDiscussionsCommentBodySchema } from './CodingWorkspaceDiscussionsComentBodySchema';
import CodingWorkspaceDiscussionsCommentEditor from './CodingWorkspaceDiscussionsCommentEditor';
import type { CodingWorkspaceDiscussionsCommentItem } from './types';
type Props = Readonly<{
author: {
avatarUrl: string | null;
id: string;
name: string | null;
username: string;
};
onCancel: () => void;
parentComment: CodingWorkspaceDiscussionsCommentItem;
}>;
type CommentFormInput = Readonly<{
body: string;
}>;
export default function CodingWorkspaceDiscussionsReplyInput({
author,
onCancel,
parentComment,
}: Props) {
const intl = useIntl();
const trpcUtils = trpc.useUtils();
const createReplyMutation = trpc.questionComments.reply.useMutation({
onSuccess: () => {
trpcUtils.questionComments.list.invalidate({
domain: parentComment.domain,
entityId: parentComment.entityId,
});
},
});
const discussionsCommentBodySchema = useDiscussionsCommentBodySchema();
const { control, handleSubmit } = useForm<CommentFormInput>({
defaultValues: {
body: '',
},
mode: 'onSubmit',
resolver: zodResolver(
z.object({
body: discussionsCommentBodySchema,
}),
),
});
const isReplyingToAReply = parentComment.parentCommentId != null;
function onSubmit(data: CommentFormInput) {
createReplyMutation.mutate(
{
body: data.body,
domain: parentComment.domain,
entityId: parentComment.entityId,
parentCommentId: isReplyingToAReply
? parentComment.parentCommentId
: parentComment.id,
repliedToId: isReplyingToAReply ? parentComment.id : undefined,
},
{
onSuccess: () => {
onCancel();
},
},
);
}
return (
<div className={clsx('space-y-3')}>
<div className="flex items-center gap-3">
<Avatar
alt={author.name ?? author.username}
size="xs"
src={author.avatarUrl ?? ''}
/>
<Text size="body2">
<FormattedMessage
defaultMessage="You are replying"
description="Label for replying to discussions comment"
id="Cuf2Et"
/>
</Text>
</div>
<form className="ml-9" onSubmit={handleSubmit(onSubmit)}>
<CodingWorkspaceDiscussionsCommentEditor
control={control}
isLoading={false}
/>
<div className="flex items-center gap-3">
<Button
className="w-20"
isDisabled={createReplyMutation.isLoading}
label={intl.formatMessage({
defaultMessage: 'Cancel',
description:
'Label for cancel reply button on workspace discussions page',
id: 'y6al2b',
})}
variant="secondary"
onClick={onCancel}
/>
<Button
className="w-20"
isDisabled={createReplyMutation.isLoading}
isLoading={createReplyMutation.isLoading}
label={intl.formatMessage({
defaultMessage: 'Post',
description:
'Label for post reply button on workspace discussions page',
id: 'X143ov',
})}
type="submit"
variant="primary"
/>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,106 @@
import type { InterviewsDiscussionCommentDomain } from '@prisma/client';
import { useUser } from '@supabase/auth-helpers-react';
import clsx from 'clsx';
import { useCallback, useState } from 'react';
import { RiAddLine } from 'react-icons/ri';
import { useEnterViewport } from '~/hooks/useEnterViewport';
import { useAuthSignInUp } from '~/hooks/user/useAuthFns';
import { useIntl } from '~/components/intl';
import Button from '~/components/ui/Button';
import Text from '~/components/ui/Text';
import CodingWorkspaceDiscussionsCommentList from './CodingWorkspaceDiscussionsCommentList';
import CodingWorkspaceDiscussionsCommentSort from './CodingWorkspaceDiscussionsCommentSort';
import CodingWorkspaceDiscussionsNewComment from './CodingWorkspaceDiscussionsNewComment';
import type { CodingWorkspaceDiscussionsCommentSortField } from './types';
type Props = Readonly<{
className?: string;
domain: InterviewsDiscussionCommentDomain;
entityId: string;
}>;
export default function CodingWorkspaceDiscussionsSection({
className,
domain,
entityId,
}: Props) {
const intl = useIntl();
const user = useUser();
const [isAscendingOrder, setIsAscendingOrder] = useState(false);
const [sortField, setSortField] =
useState<CodingWorkspaceDiscussionsCommentSortField>('createdAt');
const [commentsCount, setCommentsCount] = useState(0);
const [isVisible, setIsVisible] = useState(false);
const handleVisibilityChange = useCallback((inView: boolean) => {
if (inView) {
setIsVisible(true);
}
}, []);
const ref = useEnterViewport(handleVisibilityChange);
return (
<div ref={ref} className={clsx('space-y-3', className)}>
<div className="flex items-center justify-between">
<Text size="body1" weight="bold">
{intl.formatMessage({
defaultMessage: 'Discussions',
description: 'Discussions section title',
id: '6RewiX',
})}
{commentsCount > 0 && ` (${commentsCount})`}
</Text>
<CodingWorkspaceDiscussionsCommentSort
isAscendingOrder={isAscendingOrder}
setIsAscendingOrder={setIsAscendingOrder}
setSortField={setSortField}
sortField={sortField}
/>
</div>
<div className="space-y-6">
{user ? (
<CodingWorkspaceDiscussionsNewComment
domain={domain}
entityId={entityId}
/>
) : (
<AddCommentButton />
)}
{isVisible && (
<CodingWorkspaceDiscussionsCommentList
domain={domain}
entityId={entityId}
sort={{
field: sortField,
isAscendingOrder,
}}
onUpdateCommentsCount={setCommentsCount}
/>
)}
</div>
</div>
);
}
function AddCommentButton() {
const intl = useIntl();
const { signInUpHref } = useAuthSignInUp();
return (
<Button
addonPosition="start"
href={signInUpHref()}
icon={RiAddLine}
label={intl.formatMessage({
defaultMessage: 'Add a comment',
description: 'Button label for adding a comment',
id: 'AzTlm9',
})}
variant="primary"
/>
);
}

View File

@ -0,0 +1,26 @@
import type { InterviewsDiscussionCommentDomain } from '@prisma/client';
export type CodingWorkspaceDiscussionsCommentAuthor = Readonly<{
avatarUrl: string | null;
id: string;
name: string | null;
username: string;
}>;
export type CodingWorkspaceDiscussionsCommentItem = Readonly<{
_count: {
votes: number;
};
author: CodingWorkspaceDiscussionsCommentAuthor;
body: string;
createdAt: Date;
domain: InterviewsDiscussionCommentDomain;
entityId: string;
id: string;
parentCommentId: string | null;
profileId: string;
replies?: ReadonlyArray<CodingWorkspaceDiscussionsCommentItem>;
updatedAt: Date;
}>;
export type CodingWorkspaceDiscussionsCommentSortField = 'createdAt' | 'votes';

View File

@ -230,6 +230,7 @@ export default function JavaScriptCodingWorkspaceAboveMobile({
<JavaScriptCodingWorkspaceDescription
canViewPremiumContent={canViewPremiumContent}
description={description}
environment={embed ? 'embed' : 'workspace'}
metadata={metadata}
nextQuestions={nextQuestions}
showAd={!embed}
@ -271,6 +272,7 @@ export default function JavaScriptCodingWorkspaceAboveMobile({
contents: (
<JavaScriptCodingWorkspaceSolution
canViewPremiumContent={canViewPremiumContent}
environment={embed ? 'embed' : 'workspace'}
metadata={metadata}
nextQuestions={nextQuestions}
showLanguageSelector={embed}
@ -441,6 +443,8 @@ export default function JavaScriptCodingWorkspaceAboveMobile({
<CodingWorkspaceDescriptionAddOnItems
adPlacement="questions_js"
className={clsx('lg:hidden', 'space-y-3', 'px-3 pt-2')}
contentType="description"
metadata={metadata}
nextQuestions={nextQuestions}
showAd={true}
similarQuestions={similarQuestions}

View File

@ -23,6 +23,7 @@ import JavaScriptCodingWorkspaceLanguageDropdown from './language/JavaScriptCodi
type Props = Readonly<{
canViewPremiumContent: boolean;
description: string | null;
environment?: 'embed' | 'workspace';
metadata: QuestionMetadata;
nextQuestions: ReadonlyArray<QuestionMetadata>;
showAd: boolean;
@ -34,6 +35,7 @@ type Props = Readonly<{
export default function JavaScriptCodingWorkspaceDescription({
canViewPremiumContent,
description,
environment = 'workspace',
metadata,
nextQuestions,
showAd,
@ -102,6 +104,9 @@ export default function JavaScriptCodingWorkspaceDescription({
<CodingWorkspaceDescriptionAddOnItems
adPlacement="questions_js"
className="space-y-3 max-lg:hidden"
contentType="description"
environment={environment}
metadata={metadata}
nextQuestions={nextQuestions}
showAd={showAd}
similarQuestions={similarQuestions}

View File

@ -188,6 +188,8 @@ export default function JavaScriptCodingWorkspaceMobile({
<CodingWorkspaceDescriptionAddOnItems
adPlacement="questions_js"
className={clsx('space-y-3', 'px-3 pb-6')}
contentType={mode === 'solution' ? 'solution' : 'description'}
metadata={metadata}
nextQuestions={nextQuestions}
showAd={true}
similarQuestions={similarQuestions}

View File

@ -26,6 +26,7 @@ import { useQueryQuestionProgress } from '~/db/QuestionsProgressClient';
type Props = Readonly<{
canViewPremiumContent: boolean;
environment?: 'embed' | 'workspace';
isMobile?: boolean;
metadata: QuestionMetadata;
nextQuestions: ReadonlyArray<QuestionMetadata>;
@ -38,6 +39,7 @@ type Props = Readonly<{
export default function JavaScriptCodingWorkspaceSolution({
canViewPremiumContent,
environment = 'workspace',
isMobile,
metadata,
nextQuestions,
@ -142,6 +144,9 @@ export default function JavaScriptCodingWorkspaceSolution({
<CodingWorkspaceDescriptionAddOnItems
adPlacement="questions_js"
className="space-y-3 max-lg:hidden"
contentType="solution"
environment={environment}
metadata={metadata}
nextQuestions={nextQuestions}
similarQuestions={similarQuestions}
/>

View File

@ -286,6 +286,7 @@ export default function UserInterfaceCodingWorkspaceAboveMobile({
<UserInterfaceCodingWorkspaceWriteup
canViewPremiumContent={canViewPremiumContent}
contentType="description"
environment={embed ? 'embed' : 'workspace'}
framework={framework}
metadata={{ ...question.metadata, author: questionAuthor }}
nextQuestions={nextQuestions}
@ -336,6 +337,7 @@ export default function UserInterfaceCodingWorkspaceAboveMobile({
<UserInterfaceCodingWorkspaceWriteup
canViewPremiumContent={canViewPremiumContent}
contentType="solution"
environment={embed ? 'embed' : 'workspace'}
framework={framework}
metadata={{ ...question.metadata, author: solutionAuthor }}
nextQuestions={nextQuestions}
@ -522,6 +524,8 @@ export default function UserInterfaceCodingWorkspaceAboveMobile({
<CodingWorkspaceDescriptionAddOnItems
adPlacement="questions_ui"
className={clsx('lg:hidden', 'space-y-3', 'px-3 pt-2')}
contentType="description"
metadata={metadata}
nextQuestions={nextQuestions}
showAd={true}
similarQuestions={similarQuestions}

View File

@ -261,6 +261,8 @@ export default function UserInterfaceCodingWorkspaceMobile({
<CodingWorkspaceDescriptionAddOnItems
adPlacement="questions_ui"
className={clsx('space-y-3', 'px-3 pb-6')}
contentType={mode === 'solution' ? 'solution' : 'description'}
metadata={metadata}
nextQuestions={nextQuestions}
showAd={true}
similarQuestions={similarQuestions}

View File

@ -196,6 +196,9 @@ export default function UserInterfaceCodingWorkspaceWriteup({
<CodingWorkspaceDescriptionAddOnItems
adPlacement="questions_ui"
className="space-y-3 max-lg:hidden"
contentType={contentType}
environment={environment}
metadata={metadata}
nextQuestions={nextQuestions}
showAd={showAd}
similarQuestions={similarQuestions}

View File

@ -5,6 +5,8 @@ export const INTERVIEWS_JS_COMMUNITY_SOLUTIONS_IS_LIVE =
export const INTERVIEWS_UI_COMMUNITY_SOLUTIONS_IS_LIVE =
process.env.NEXT_PUBLIC_VERCEL_ENV !== 'production';
export const INTERVIEWS_TAZAPAY_IS_LIVE = true;
export const INTERVIEWS_DISCUSSIONS_IS_LIVE =
process.env.NEXT_PUBLIC_VERCEL_ENV !== 'production';
export const PROJECTS_NOTIFICATION_AVAILABLE =
process.env.NEXT_PUBLIC_VERCEL_ENV !== 'production';

View File

@ -10,6 +10,7 @@ import { projectsRouter } from './projects';
import { promotionsRouter } from './promotions';
import { purchasesRouter } from './purchases';
import { questionBookmarkRouter } from './question-bookmark';
import { questionCommentsRouter } from './question-comments';
import { questionCommunitySolutionRouter } from './question-community-solution';
import { questionListsRouter } from './question-lists';
import { questionProgressRouter } from './question-progress';
@ -31,6 +32,7 @@ export const appRouter = router({
projects: projectsRouter,
promotions: promotionsRouter,
purchases: purchasesRouter,
questionComments: questionCommentsRouter,
questionCommunitySolution: questionCommunitySolutionRouter,
questionLists: questionListsRouter,
questionProgress: questionProgressRouter,

View File

@ -0,0 +1,245 @@
import { InterviewsDiscussionCommentDomain, Prisma } from '@prisma/client';
import { z } from 'zod';
import { discussionsCommentBodySchemaServer } from '~/components/workspace/common/discussions/CodingWorkspaceDiscussionsComentBodySchema';
import prisma from '~/server/prisma';
import { publicProcedure, router, userProcedure } from '../trpc';
export const questionCommentsRouter = router({
create: userProcedure
.input(
z.object({
body: discussionsCommentBodySchemaServer,
domain: z.nativeEnum(InterviewsDiscussionCommentDomain),
entityId: z.string(),
}),
)
.mutation(
async ({ ctx: { viewer }, input: { body, domain, entityId } }) => {
const comment = await prisma.interviewsDiscussionComment.create({
data: {
body,
domain,
entityId,
profileId: viewer.id,
},
});
// TODO(discussions): Add activity triggers
return comment;
},
),
delete: userProcedure
.input(
z.object({
commentId: z.string().uuid(),
}),
)
.mutation(async ({ ctx: { viewer }, input: { commentId } }) => {
return await prisma.interviewsDiscussionComment.delete({
where: {
id: commentId,
profileId: viewer.id,
},
});
}),
liked: userProcedure
.input(
z.object({
commentId: z.string().uuid(),
}),
)
.query(async ({ ctx: { viewer }, input: { commentId } }) => {
return await prisma.interviewsDiscussionCommentVote.findFirst({
select: {
commentId: true,
},
where: {
author: {
id: viewer.id,
},
comment: {
id: commentId,
},
},
});
}),
list: publicProcedure
.input(
z.object({
domain: z.nativeEnum(InterviewsDiscussionCommentDomain),
entityId: z.string(),
pagination: z.object({
limit: z
.number()
.int()
.positive()
.transform((val) => Math.min(30, val)),
page: z.number().int().positive(),
}),
sort: z.object({
field: z.enum([
Prisma.InterviewsDiscussionCommentScalarFieldEnum.createdAt,
'votes',
]),
isAscendingOrder: z.boolean(),
}),
}),
)
.query(async ({ input: { domain, entityId, pagination, sort } }) => {
const { limit, page } = pagination;
const commentIncludeFields = {
_count: {
select: {
votes: true,
},
},
author: {
select: {
avatarUrl: true,
id: true,
name: true,
username: true,
},
},
};
const sortBy =
sort.field === 'votes'
? ({
votes: {
_count: sort.isAscendingOrder ? 'asc' : 'desc',
},
} as const)
: ({
createdAt: sort.isAscendingOrder ? 'asc' : 'desc',
} as const);
const [count, comments] = await Promise.all([
prisma.interviewsDiscussionComment.count({
where: {
domain,
entityId,
parentCommentId: null, // Fetch top-level comments only.
},
}),
prisma.interviewsDiscussionComment.findMany({
include: {
replies: {
include: commentIncludeFields,
orderBy: { createdAt: 'asc' },
},
...commentIncludeFields,
},
orderBy: sortBy,
skip: (page - 1) * limit,
take: limit,
where: {
domain,
entityId,
parentCommentId: null, // Fetch top-level comments only.
},
}),
]);
return {
comments,
count,
};
}),
reply: userProcedure
.input(
z.object({
body: discussionsCommentBodySchemaServer,
domain: z.nativeEnum(InterviewsDiscussionCommentDomain),
entityId: z.string(),
parentCommentId: z.string().uuid(),
repliedToId: z.string().uuid().optional(),
}),
)
.mutation(
async ({
ctx: { viewer },
input: { body, domain, entityId, parentCommentId, repliedToId },
}) => {
const comment = await prisma.interviewsDiscussionComment.create({
data: {
body,
domain,
entityId,
parentCommentId,
profileId: viewer.id,
repliedToId,
},
});
// TODO(discussions): Add activity triggers
return comment;
},
),
unvote: userProcedure
.input(
z.object({
commentId: z.string().uuid(),
}),
)
.mutation(async ({ ctx: { viewer }, input: { commentId } }) => {
return await prisma.interviewsDiscussionCommentVote.delete({
where: {
commentId_profileId: {
commentId,
profileId: viewer.id,
},
},
});
}),
update: userProcedure
.input(
z.object({
body: discussionsCommentBodySchemaServer,
commentId: z.string().uuid(),
}),
)
.mutation(async ({ ctx: { viewer }, input: { body, commentId } }) => {
return await prisma.interviewsDiscussionComment.update({
data: {
body,
},
where: {
id: commentId,
profileId: viewer.id,
},
});
}),
vote: userProcedure
.input(
z.object({
commentId: z.string().uuid(),
}),
)
.mutation(async ({ ctx: { viewer }, input: { commentId } }) => {
try {
return await prisma.interviewsDiscussionCommentVote.create({
data: {
commentId,
profileId: viewer.id,
},
});
// TODO(discussions): Add activity triggers
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
// Ignore duplicate upvote.
error.code === 'P2002'
) {
// No-op.
return;
}
throw error;
}
}),
});