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..2df497a12 --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/[postId]/community-post-page.tsx @@ -0,0 +1,816 @@ +"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 (!community || !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. + +