From eb084edbe5f7fbb052889cec60a30eba50502ea0 Mon Sep 17 00:00:00 2001 From: Rajat Date: Sun, 3 May 2026 20:58:51 +0530 Subject: [PATCH 1/5] Feed + Communities overhaul --- AGENTS.md | 1 + .../communities/communities-list.tsx | 1 + .../communities/content-card.tsx | 3 +- .../[id]/[postId]/community-post-page.tsx | 819 ++++++++++++++++++ .../community/[id]/[postId]/page.tsx | 61 ++ .../(sidebar)/community/[id]/page.tsx | 14 +- .../__tests__/my-content-tabs.test.ts | 100 +++ .../(sidebar)/my-content/feed/page.tsx | 375 ++++++++ .../dashboard/(sidebar)/my-content/layout.tsx | 29 +- .../(sidebar)/my-content/my-content-tabs.tsx | 63 ++ .../(sidebar)/my-content/my-content-view.tsx | 114 +++ .../dashboard/(sidebar)/my-content/page.tsx | 176 +--- .../(sidebar)/my-content/products/page.tsx | 6 + .../admin/dashboard-skeleton/app-sidebar.tsx | 2 +- apps/web/components/admin/empty-state.tsx | 6 +- .../__tests__/create-post-dialog.test.tsx | 4 - apps/web/components/community/banner.tsx | 8 +- .../components/community/comment-section.tsx | 82 +- apps/web/components/community/comment.tsx | 9 +- .../community/create-post-dialog.tsx | 106 +-- .../web/components/community/emoji-picker.tsx | 124 --- apps/web/components/community/index.tsx | 658 +------------- apps/web/components/community/info.tsx | 3 +- .../components/community/loading-skeleton.tsx | 23 +- .../components/community/media-preview.tsx | 10 +- .../community/post-card-skeleton.tsx | 28 + apps/web/components/community/post-card.tsx | 162 ++++ .../community/post-media-preview.tsx | 203 +++++ apps/web/components/notifications-viewer.tsx | 22 +- .../public/lesson-viewer/embed-viewer.tsx | 9 +- .../__tests__/create-community-post.test.ts | 39 +- .../communities/__tests__/logic.test.ts | 347 +++++++- .../__tests__/update-community-post.test.ts | 21 +- apps/web/graphql/communities/helpers.ts | 10 +- apps/web/graphql/communities/logic.ts | 148 +++- apps/web/graphql/communities/mutation.ts | 10 +- apps/web/graphql/communities/query.ts | 46 +- apps/web/graphql/communities/types.ts | 12 +- apps/web/hooks/use-communities.ts | 15 +- apps/web/hooks/use-enabled-communities.ts | 61 ++ apps/web/models/CommunityPost.ts | 28 +- apps/web/ui-config/strings.ts | 14 + .../get-notification-message-and-href.ts | 12 +- packages/common-models/src/community-post.ts | 3 +- .../orm-models/src/models/community-post.ts | 28 +- .../src/blocks/embed/admin-widget.tsx | 22 +- .../src/blocks/header/widget/link.tsx | 6 +- packages/page-blocks/src/components/index.ts | 1 + .../src/components/text-renderer.tsx | 4 +- .../truncate-text-editor-content.ts | 134 +++ .../src/components/video-with-preview.tsx | 59 +- .../page-primitives/src/themes/classic.ts | 2 +- packages/utils/src/extract-video-id.ts | 96 ++ packages/utils/src/index.ts | 2 + .../src/normalize-text-editor-content.ts | 50 ++ 55 files changed, 3211 insertions(+), 1180 deletions(-) create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/[postId]/community-post-page.tsx create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/[postId]/page.tsx create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/my-content/__tests__/my-content-tabs.test.ts create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/my-content/feed/page.tsx create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/my-content/my-content-tabs.tsx create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/my-content/my-content-view.tsx create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/my-content/products/page.tsx delete mode 100644 apps/web/components/community/emoji-picker.tsx create mode 100644 apps/web/components/community/post-card-skeleton.tsx create mode 100644 apps/web/components/community/post-card.tsx create mode 100644 apps/web/components/community/post-media-preview.tsx create mode 100644 apps/web/hooks/use-enabled-communities.ts create mode 100644 packages/page-blocks/src/components/truncate-text-editor-content.ts create mode 100644 packages/utils/src/extract-video-id.ts create mode 100644 packages/utils/src/normalize-text-editor-content.ts diff --git a/AGENTS.md b/AGENTS.md index 232173a69..9897c1029 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,7 @@ - Ensure **Idempotency** (safe to re-run) by using upserts or `$setOnInsert` where applicable. - When making changes to the structure of the Course, consider how it affects its representation on its public page (`apps/web/app/(with-contexts)/(with-layout)/p/[id]/page.tsx`) and the course viewer (`apps/web/app/(with-contexts)/course/[slug]/[id]/page.tsx`). - `apps/web` is a multi-tenant app. +- Refrain from adding new GraphQL query/mutation unless required. If an existing query/mutation can be modified to implement the feature without making the query's/mutation's boundaries blurry, extend those. ### Workspace map (core modules): diff --git a/apps/web/app/(with-contexts)/(with-layout)/communities/communities-list.tsx b/apps/web/app/(with-contexts)/(with-layout)/communities/communities-list.tsx index db857777c..2e31a744d 100644 --- a/apps/web/app/(with-contexts)/(with-layout)/communities/communities-list.tsx +++ b/apps/web/app/(with-contexts)/(with-layout)/communities/communities-list.tsx @@ -26,6 +26,7 @@ export function CommunitiesList({ const { communities, loading, totalPages } = useCommunities( page, itemsPerPage, + publicView, ); const { theme } = useContext(ThemeContext); diff --git a/apps/web/app/(with-contexts)/(with-layout)/communities/content-card.tsx b/apps/web/app/(with-contexts)/(with-layout)/communities/content-card.tsx index e2d4dbc34..55d16e631 100644 --- a/apps/web/app/(with-contexts)/(with-layout)/communities/content-card.tsx +++ b/apps/web/app/(with-contexts)/(with-layout)/communities/content-card.tsx @@ -16,6 +16,7 @@ import { TooltipProvider, TooltipTrigger, } from "@components/ui/tooltip"; +import { truncate } from "@courselit/utils"; export function CommunityContentCard({ community, @@ -51,7 +52,7 @@ export function CommunityContentCard({ /> - {community.name} + {truncate(community.name, 50)}
diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/[postId]/community-post-page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/[postId]/community-post-page.tsx new file mode 100644 index 000000000..97f04a476 --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/[postId]/community-post-page.tsx @@ -0,0 +1,819 @@ +"use client"; + +import { useCallback, useContext, useEffect, useState } from "react"; +import { + CommunityMedia, + CommunityPost, + Constants, + Media, +} from "@courselit/common-models"; +import { + AddressContext, + ProfileContext, + ThemeContext, +} from "@components/contexts"; +import { FetchBuilder } from "@courselit/utils"; +import { formattedLocaleDate, hasCommunityPermission } from "@ui-lib/utils"; +import LoadingSkeleton from "@components/community/loading-skeleton"; +import { useCommunity } from "@/hooks/use-community"; +import { useMembership } from "@/hooks/use-membership"; +import NotFound from "@components/admin/not-found"; +import { CommunityInfo } from "@components/community/info"; +import MembershipStatus from "@components/community/membership-status"; +import CommentSection from "@components/community/comment-section"; +import dynamic from "next/dynamic"; +import { useMediaLit, useToast } from "@courselit/components-library"; +import { + MANAGE_LINK_TEXT, + TOAST_TITLE_ERROR, + TOAST_TITLE_SUCCESS, +} from "@ui-config/strings"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { + MoreVertical, + Trash, + FlagTriangleRight, + MessageSquare, + ThumbsUp, +} from "lucide-react"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogTitle, +} from "@/components/ui/dialog"; +import { MediaItem } from "@components/community/media-item"; +import { Textarea } from "@/components/ui/textarea"; +import { TextRenderer } from "@courselit/page-blocks"; +import CommunityPostMediaPreview from "@components/community/post-media-preview"; + +const CreatePostDialog = dynamic( + () => import("@components/community/create-post-dialog"), +); + +export default function CommunityPostPage({ + communityId, + postId, +}: { + communityId: string; + postId: string; +}) { + const address = useContext(AddressContext); + const { profile } = useContext(ProfileContext); + const { toast } = useToast(); + const { community, loaded } = useCommunity(communityId); + const { membership, setMembership } = useMembership(communityId); + const [post, setPost] = useState(null); + const [postLoaded, setPostLoaded] = useState(false); + const [fullscreenMedia, setFullscreenMedia] = + useState(null); + const [postToDelete, setPostToDelete] = useState( + null, + ); + const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false); + const [showReportConfirmation, setShowReportConfirmation] = useState(false); + const [reportReason, setReportReason] = useState(""); + const [postToReport, setPostToReport] = useState( + null, + ); + const [postToEdit, setPostToEdit] = useState(null); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [refreshCommunityStatus, setRefreshCommunityStatus] = useState(0); + const { isUploading, uploadProgress, uploadFile } = useMediaLit({ + signatureEndpoint: `${address.backend}/api/media/presigned`, + access: "public", + }); + const [fileBeingUploadedNumber, setFileBeingUploadedNumber] = useState(0); + const { theme } = useContext(ThemeContext); + + const formatTimestamp = (value?: string) => formattedLocaleDate(value); + + useEffect(() => { + if (membership) { + setRefreshCommunityStatus((prev) => prev + 1); + } + }, [membership]); + + const loadPost = useCallback(async () => { + const query = ` + query ($communityId: String!, $postId: String!) { + post: getPost(communityId: $communityId, postId: $postId) { + communityId + postId + title + content + category + media { + type + title + url + media { + mediaId + file + thumbnail + originalFileName + size + } + } + likesCount + commentsCount + updatedAt + hasLiked + user { + userId + name + email + avatar { + mediaId + file + thumbnail + } + } + pinned + } + } + `; + + try { + const fetch = new FetchBuilder() + .setUrl(`${address.backend}/api/graph`) + .setPayload({ + query, + variables: { communityId, postId }, + }) + .setIsGraphQLEndpoint(true) + .build(); + const response = await fetch.exec(); + setPost(response.post || null); + } catch (err: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } finally { + setPostLoaded(true); + } + }, [address.backend, communityId, postId, toast]); + + useEffect(() => { + loadPost(); + }, [loadPost]); + + const handleLike = async (targetPostId: string) => { + setPost((prev) => + prev && prev.postId === targetPostId + ? { + ...prev, + likesCount: prev.hasLiked + ? prev.likesCount - 1 + : prev.likesCount + 1, + hasLiked: !prev.hasLiked, + } + : prev, + ); + + const query = ` + mutation ($communityId: String!, $postId: String!) { + togglePostLike(communityId: $communityId, postId: $postId) { + postId + } + } + `; + + try { + const fetch = new FetchBuilder() + .setUrl(`${address.backend}/api/graph`) + .setPayload({ + query, + variables: { communityId, postId: targetPostId }, + }) + .setIsGraphQLEndpoint(true) + .build(); + await fetch.exec(); + } catch (err: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + loadPost(); + } + }; + + const uploadAttachments = useCallback( + async (media: MediaItem[]) => { + for (const i in media) { + const m = media[i]; + if (m.file) { + setFileBeingUploadedNumber(+i + 1); + const uploadedMedia = (await uploadFile( + m.file, + )) as unknown as Media; + m.media = uploadedMedia; + m.file = undefined; + m.url = undefined; + } + } + return media; + }, + [uploadFile], + ); + + const createPost = useCallback( + async ( + newPost: Pick & { + media: MediaItem[]; + }, + ) => { + try { + if (newPost.media.length > 0) { + newPost.media = await uploadAttachments(newPost.media); + } + + const query = ` + mutation ($communityId: String!, $postId: String!, $title: String, $content: JSONObject, $category: String, $media: [CommunityPostInputMedia]) { + post: updateCommunityPost( + communityId: $communityId + postId: $postId + title: $title + content: $content + category: $category + media: $media + ) { + communityId + postId + title + content + category + media { + type + title + url + media { + mediaId + file + thumbnail + originalFileName + size + } + } + likesCount + commentsCount + updatedAt + hasLiked + user { + userId + name + email + avatar { + mediaId + file + thumbnail + } + } + pinned + } + } + `; + + const fetch = new FetchBuilder() + .setUrl(`${address.backend}/api/graph`) + .setPayload({ + query, + variables: { + communityId, + postId, + content: newPost.content, + category: newPost.category, + title: newPost.title, + media: newPost.media.map((m) => ({ + type: m.type, + title: m.title, + url: m.url, + media: m.media, + })), + }, + }) + .setIsGraphQLEndpoint(true) + .build(); + + const response = await fetch.exec(); + if (response.post) { + setPost(response.post); + setPostToEdit(null); + setIsEditModalOpen(false); + } + } catch (error: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: error.message, + variant: "destructive", + }); + } + }, + [address.backend, communityId, postId, toast, uploadAttachments], + ); + + const handleDeletePost = (targetPost: CommunityPost) => { + setPostToDelete(targetPost); + setShowDeleteConfirmation(true); + }; + + const confirmDeletePost = async () => { + if (!postToDelete) return; + + const query = ` + mutation ($communityId: String!, $postId: String!) { + post: deleteCommunityPost(communityId: $communityId, postId: $postId) { + communityId + postId + } + } + `; + + try { + const fetch = new FetchBuilder() + .setUrl(`${address.backend}/api/graph`) + .setPayload({ + query, + variables: { communityId, postId: postToDelete.postId }, + }) + .setIsGraphQLEndpoint(true) + .build(); + const response = await fetch.exec(); + if (response.post) { + window.location.assign(`/dashboard/community/${communityId}`); + } + } catch (err: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } + }; + + const handleReportPost = (targetPost: CommunityPost) => { + setPostToReport(targetPost); + setShowReportConfirmation(true); + }; + + const confirmReportPost = async () => { + if (!postToReport || !reportReason.trim()) return; + + const query = ` + mutation ($communityId: String!, $contentId: String!, $type: CommunityReportContentType!, $reason: String!) { + report: reportCommunityContent(communityId: $communityId, contentId: $contentId, type: $type, reason: $reason) { + reportId + } + } + `; + try { + const fetch = new FetchBuilder() + .setUrl(`${address.backend}/api/graph`) + .setPayload({ + query, + variables: { + communityId, + contentId: postToReport.postId, + type: Constants.CommunityReportType.POST.toUpperCase(), + reason: reportReason, + }, + }) + .setIsGraphQLEndpoint(true) + .build(); + await fetch.exec(); + toast({ + title: "Reported", + description: "Post has been reported", + }); + setShowReportConfirmation(false); + setPostToReport(null); + setReportReason(""); + } catch (err: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } + }; + + const handleJoin = async (joiningReason?: string) => { + const query = ` + mutation JoinCommunity($id: String!, $joiningReason: String!) { + communityMembershipStatus: joinCommunity(id: $id, joiningReason: $joiningReason) { + status + rejectionReason + role + } + } + `; + try { + const fetchRequest = new FetchBuilder() + .setUrl(`${address.backend}/api/graph`) + .setPayload({ + query, + variables: { id: communityId, joiningReason }, + }) + .setIsGraphQLEndpoint(true) + .build(); + const response = await fetchRequest.exec(); + setMembership(response.communityMembershipStatus); + setRefreshCommunityStatus((prev) => prev + 1); + if (response.communityMembershipStatus) { + toast({ + title: TOAST_TITLE_SUCCESS, + description: "Your request to join has been sent.", + }); + } + } catch (error: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: error.message, + variant: "destructive", + }); + } + }; + + const handleLeave = async () => { + const query = ` + mutation LeaveCommunity($id: String!) { + communityMembershipStatus: leaveCommunity(id: $id) { + status + } + } + `; + try { + const fetchRequest = new FetchBuilder() + .setUrl(`${address.backend}/api/graph`) + .setPayload({ + query, + variables: { id: communityId }, + }) + .setIsGraphQLEndpoint(true) + .build(); + const response = await fetchRequest.exec(); + if (response.communityMembershipStatus) { + setMembership(undefined); + toast({ + title: TOAST_TITLE_SUCCESS, + description: "You have left the community.", + }); + } + } catch (error: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: error.message, + variant: "destructive", + }); + } + }; + + if (!loaded || !profile || !postLoaded) { + return ; + } + + if ((loaded && !community) || (postLoaded && !post)) { + return ( + + ); + } + + const currentPost = post!; + + return ( +
+ {!community?.enabled && ( +
+ This community is not enabled. It is not visible to your + audience (including moderators).{" "} + + {MANAGE_LINK_TEXT} + +
+ )} +
+
+ {profile.name && + membership?.status.toLowerCase() === + Constants.MembershipStatus.ACTIVE ? null : ( + + plan.planId === + community?.defaultPaymentPlan, + )} + /> + )} + +
+
+
+ + + + {currentPost.user.name && + currentPost.user.name + .split(" ") + .map((n) => n[0]) + .join("")} + + +
+
+ {currentPost.user.name || + currentPost.user.email} +
+
+ {formatTimestamp(currentPost.updatedAt)}{" "} + • {currentPost.category} +
+
+
+ + + + + + {profile.userId !== + currentPost.user.userId && ( + + handleReportPost(currentPost) + } + > + + Report + + )} + {profile.userId === + currentPost.user.userId && ( + { + setPostToEdit(currentPost); + setIsEditModalOpen(true); + }} + > + + Edit + + )} + {((membership && + hasCommunityPermission( + membership, + Constants.MembershipRole.MODERATE, + )) || + currentPost.user.userId === + profile.userId) && ( + + handleDeletePost(currentPost) + } + > + + Delete + + )} + + +
+
+

+ {currentPost.title} +

+
+ +
+
+ {currentPost.media && ( +
+ {currentPost.media.map((media, index) => ( +
+ +
+ ))} +
+ )} +
+ + +
+ {membership && ( + { + setPost((prev) => + prev && prev.postId === targetPostId + ? { + ...prev, + commentsCount: count, + } + : prev, + ); + }} + /> + )} +
+ + + + Delete Post + + Are you sure you want to delete this post? + + + + + + + + + + + Report Post + + Please provide a reason for reporting this post. + +