Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
342 changes: 320 additions & 22 deletions apps/web/components/community/comment-section.tsx

Large diffs are not rendered by default.

221 changes: 187 additions & 34 deletions apps/web/components/community/comment.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"use client";

import { useContext, useState } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
ThumbsUp,
MessageSquare,
Expand All @@ -13,9 +14,15 @@ import {
CommunityComment,
CommunityCommentReply,
Constants,
CommunityMedia,
Membership,
TextEditorContent,
} from "@courselit/common-models";
import { formattedLocaleDate, hasCommunityPermission } from "@ui-lib/utils";
import {
formattedLocaleDate,
hasCommunityPermission,
isTextEditorNonEmpty,
} from "@ui-lib/utils";
import {
DropdownMenu,
DropdownMenuContent,
Expand All @@ -30,10 +37,22 @@ import {
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { isCommunityComment } from "./utils";
import { DELETED_COMMENT_PLACEHOLDER } from "@ui-config/strings";
import { useToast } from "@courselit/components-library";
import { FetchBuilder } from "@courselit/utils";
import { TextRenderer } from "@courselit/page-blocks";
import { MediaPreview } from "./media-preview";
import type { MediaItem } from "./media-item";
import { Editor, emptyDoc as TextEditorEmptyDoc } from "@courselit/text-editor";

const createClientId = () => {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
};

type CommentOrReply =
| CommunityComment
Expand All @@ -45,7 +64,8 @@ interface CommentProps {
onLike: (commentId: string, replyId?: string) => void;
onReply: (
commentId: string,
content: string,
content: TextEditorContent | string,
media: MediaItem[],
parentReplyId?: string,
) => void;
onDelete: (comment: CommentOrReply) => void;
Expand All @@ -65,7 +85,11 @@ export function Comment({
isPosting = false,
}: CommentProps) {
const [isReplying, setIsReplying] = useState(false);
const [replyContent, setReplyContent] = useState("");
const [replyContent, setReplyContent] = useState<TextEditorContent>(
TextEditorEmptyDoc as TextEditorContent,
);
const [replyMedia, setReplyMedia] = useState<MediaItem[]>([]);
const [replyEditorKey, setReplyEditorKey] = useState(0);
const [commentToDelete, setCommentToDelete] =
useState<CommentOrReply | null>(null);
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
Expand Down Expand Up @@ -172,6 +196,43 @@ export function Comment({
? comment.commentId
: (comment as CommunityCommentReply).replyId;

const renderContent = () => {
if (comment.deleted) {
return (
<span className="italic text-gray-500">
{DELETED_COMMENT_PLACEHOLDER}
</span>
);
}

const content = comment.content;
if (typeof content === "string") {
return (
<p className="text-sm mt-1 whitespace-pre-wrap">{content}</p>
);
}

return (
<div className="text-sm mt-1">
<TextRenderer json={content} />
</div>
);
};

const renderMedia = (media: CommunityMedia[] | undefined) => {
if (!media || media.length === 0) return null;

const mediaItems: MediaItem[] = media.map((m) => ({
...m,
}));

return (
<div className="mt-2 overflow-x-auto">
<MediaPreview items={mediaItems} onRemove={() => {}} />
</div>
);
};

return (
<div
id={itemId}
Expand Down Expand Up @@ -245,15 +306,12 @@ export function Comment({
</DropdownMenu>
)}
</div>
<p className="text-sm mt-1 whitespace-pre-wrap">
{comment.deleted ? (
<span className="italic text-gray-500">
{DELETED_COMMENT_PLACEHOLDER}
</span>
) : (
comment.content
)}
</p>
{renderContent()}
{renderMedia(
isCommunityComment(comment)
? (comment as CommunityComment).media
: (comment as CommunityCommentReply).media,
)}
<div className="flex items-center gap-4 mt-2">
<Button
variant="ghost"
Expand All @@ -278,38 +336,133 @@ export function Comment({
</div>
{isReplying && profile?.name && (
<div className="mt-2 space-y-2 p-1">
<Textarea
placeholder="Write a reply..."
value={replyContent}
onChange={(e) => setReplyContent(e.target.value)}
/>
<div className="flex justify-end gap-2">
<div className="rounded-md border border-input px-3 py-2">
<Editor
key={replyEditorKey}
url={address.backend}
initialContent={replyContent}
onChange={(value) =>
setReplyContent(value as TextEditorContent)
}
placeholder="Write a reply..."
showToolbar={false}
/>
{replyMedia.length > 0 && (
<div className="mt-2">
<MediaPreview
items={replyMedia}
onRemove={(index) =>
setReplyMedia((prev) => {
const toRemove = prev[index];
if (
toRemove?.file &&
toRemove.url?.startsWith(
"blob:",
)
) {
URL.revokeObjectURL(
toRemove.url,
);
}
return prev.filter(
(_, i) => i !== index,
);
})
}
/>
</div>
)}
</div>
<div className="flex justify-end gap-2 items-center">
<label className="cursor-pointer">
<input
type="file"
multiple
accept="image/*,video/*,application/pdf"
className="sr-only"
onChange={(e) => {
const files = e.target.files;
if (!files) return;
const nextItems: MediaItem[] = Array.from(
files,
).map((file) => {
const url = URL.createObjectURL(file);
const isPdf =
file.type === "application/pdf";
const isImage =
file.type.startsWith("image/");
const type = isPdf
? "pdf"
: isImage
? "image"
: "video";
return {
type,
url,
title: file.name,
file,
clientId: createClientId(),
fileSize: `${(file.size / (1024 * 1024)).toFixed(1)}mb`,
};
});
setReplyMedia((prev) => [
...prev,
...nextItems,
]);
e.target.value = "";
}}
/>
<span className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-8 px-3">
Attach
</span>
</label>
<Button
variant="outline"
size="sm"
onClick={() => setIsReplying(false)}
onClick={() => {
setIsReplying(false);
setReplyContent(
TextEditorEmptyDoc as TextEditorContent,
);
setReplyMedia([]);
setReplyEditorKey((k) => k + 1);
}}
>
Cancel
</Button>
<Button
size="sm"
onClick={() => {
if (replyContent.trim()) {
isCommunityComment(comment)
? onReply(
comment.commentId,
replyContent,
)
: onReply(
comment.commentId,
replyContent,
comment.replyId,
);
setReplyContent("");
setIsReplying(false);
const hasContent =
isTextEditorNonEmpty(replyContent);
const hasMedia = replyMedia.length > 0;
if (!hasContent && !hasMedia) return;
if (isCommunityComment(comment)) {
onReply(
comment.commentId,
replyContent,
replyMedia,
);
} else {
onReply(
comment.commentId,
replyContent,
replyMedia,
comment.replyId,
);
}
setReplyContent(
TextEditorEmptyDoc as TextEditorContent,
);
setReplyMedia([]);
setReplyEditorKey((k) => k + 1);
setIsReplying(false);
}}
disabled={isPosting}
disabled={
isPosting ||
(!isTextEditorNonEmpty(replyContent) &&
replyMedia.length === 0)
}
>
Reply
</Button>
Expand Down
8 changes: 4 additions & 4 deletions apps/web/graphql/communities/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export const formatComment = (comment: any, userId: string) => ({
postId: comment.postId,
userId: comment.userId,
commentId: comment.commentId,
content: comment.content,
content: normalizeTextEditorContent(comment.content),
hasLiked: comment.likes.includes(userId),
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
Expand All @@ -53,7 +53,7 @@ export const formatComment = (comment: any, userId: string) => ({
replies: comment.replies.map((reply) => ({
replyId: reply.replyId,
userId: reply.userId,
content: reply.content,
content: normalizeTextEditorContent(reply.content),
media: reply.media,
parentReplyId: reply.parentReplyId,
createdAt: reply.createdAt,
Expand Down Expand Up @@ -120,7 +120,7 @@ export async function getCommunityReportContent({
contentId: string;
contentParentId?: string;
}): Promise<{
content: string;
content: TextEditorContent;
id: string;
media: CommunityMedia[];
}> {
Expand Down Expand Up @@ -157,7 +157,7 @@ export async function getCommunityReportContent({
}

return {
content: content.content,
content: normalizeTextEditorContent(content.content),
id: contentId,
media: content.media,
};
Expand Down
2 changes: 1 addition & 1 deletion apps/web/graphql/communities/logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1630,7 +1630,7 @@ export async function postComment({
ctx: GQLContext;
communityId: string;
postId: string;
content: string;
content: TextEditorContent | string;
media?: CommunityMedia[];
parentCommentId?: string;
parentReplyId?: string;
Expand Down
4 changes: 2 additions & 2 deletions apps/web/graphql/communities/mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ const mutations = {
args: {
communityId: { type: new GraphQLNonNull(GraphQLString) },
postId: { type: new GraphQLNonNull(GraphQLString) },
content: { type: new GraphQLNonNull(GraphQLString) },
content: { type: new GraphQLNonNull(GraphQLJSONObject) },
media: { type: new GraphQLList(types.communityPostInputMedia) },
parentCommentId: { type: GraphQLString },
parentReplyId: { type: GraphQLString },
Expand All @@ -320,7 +320,7 @@ const mutations = {
}: {
communityId: string;
postId: string;
content: string;
content: TextEditorContent | string;
media?: CommunityMedia[];
parentCommentId?: string;
parentReplyId?: string;
Expand Down
6 changes: 3 additions & 3 deletions apps/web/graphql/communities/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ const communityCommentReply = new GraphQLObjectType({
name: "CommunityCommentReply",
fields: {
replyId: { type: new GraphQLNonNull(GraphQLString) },
content: { type: new GraphQLNonNull(GraphQLString) },
content: { type: new GraphQLNonNull(GraphQLJSONObject) },
user: {
type: userTypes.userType,
resolve: (reply, _, ctx: GQLContext, __) =>
Expand All @@ -163,7 +163,7 @@ const communityComment = new GraphQLObjectType({
communityId: { type: new GraphQLNonNull(GraphQLString) },
postId: { type: new GraphQLNonNull(GraphQLString) },
commentId: { type: new GraphQLNonNull(GraphQLString) },
content: { type: new GraphQLNonNull(GraphQLString) },
content: { type: new GraphQLNonNull(GraphQLJSONObject) },
user: {
type: userTypes.userType,
resolve: (comment, _, ctx: GQLContext, __) =>
Expand All @@ -182,7 +182,7 @@ const communityReportContent = new GraphQLObjectType({
name: "CommunityReportContent",
fields: {
id: { type: new GraphQLNonNull(GraphQLString) },
content: { type: new GraphQLNonNull(GraphQLString) },
content: { type: new GraphQLNonNull(GraphQLJSONObject) },
media: { type: new GraphQLList(communityPostMedia) },
},
});
Expand Down
Loading
Loading