📃 Post Detail
Build the UI for the post details
Post.tsx:
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { UserContext } from "./App";
import { castVote } from "./cast-vote";
import { GetSinglePostWithCommentResponse } from "./database.types";
import { supaClient } from "./supa-client";
import { timeAgo } from "./time-ago";
import { UpVote } from "./UpVote";
import { usePostScore } from "./use-post-score";
import { SupashipUserInfo } from "./use-session";
export interface Post {
id: string;
author_name: string;
title: string;
content: string;
score: number;
created_at: string;
path: string;
}
export interface Comment {
id: string;
author_name: string;
content: string;
score: number;
created_at: string;
path: string;
comments: Comment[];
}
interface PostDetailData {
post: GetSinglePostWithCommentResponse | null;
comments: GetSinglePostWithCommentResponse[];
myVotes?: Record<string, "up" | "down" | undefined>;
}
export async function getPostDetails({
params: { postId },
userContext,
}: {
params: { postId: string };
userContext: SupashipUserInfo;
}): Promise<PostDetailData | undefined> {
const { data, error } = await supaClient
.rpc("get_single_post_with_comments", { post_id: postId })
.select("*");
if (error || !data || data.length === 0) {
throw new Error("Post not found");
}
const postMap = (data as GetSinglePostWithCommentResponse[]).reduce(
(acc, post) => {
acc[post.id] = post;
return acc;
},
{} as Record<string, Post>
);
const post = postMap[postId];
const comments = (data as GetSinglePostWithCommentResponse[]).filter(
(x) => x.id !== postId
);
if (!userContext.session?.user) {
return { post, comments };
}
const { data: votesData } = await supaClient
.from("post_votes")
.select("*")
.eq("user_id", userContext.session?.user.id);
if (!votesData) {
return;
}
const votes = votesData.reduce((acc, vote) => {
acc[vote.post_id] = vote.vote_type as any;
return acc;
}, {} as Record<string, "up" | "down" | undefined>);
return { post, comments, myVotes: votes };
}
export function PostView() {
const userContext = useContext(UserContext);
const params = useParams() as { postId: string };
const [postDetailData, setPostDetailData] = useState<PostDetailData>({
post: null,
comments: [],
});
const [bumper, setBumper] = useState(0);
useEffect(() => {
getPostDetails({ params, userContext }).then((newPostDetailData) => {
if (newPostDetailData) {
setPostDetailData(newPostDetailData);
}
});
}, [userContext, params, bumper]);
const nestedComments = useMemo(
() => unsortedCommentsToNested(postDetailData.comments),
[postDetailData]
);
return (
<PostPresentation
postDetailData={postDetailData}
userContext={userContext}
setBumper={setBumper}
bumper={bumper}
nestedComments={nestedComments}
/>
);
}
function PostPresentation({
postDetailData,
userContext,
setBumper,
bumper,
nestedComments,
}: {
postDetailData: PostDetailData;
userContext: SupashipUserInfo;
setBumper: (x: number) => void;
bumper: number;
nestedComments: Comment[];
}) {
const score = usePostScore(
postDetailData.post?.id || "",
postDetailData.post?.score
);
return (
<div className="post-detail-outer-container">
<div className="post-detail-inner-container">
<div className="post-detail-upvote-container">
<UpVote
direction="up"
filled={
postDetailData.myVotes &&
postDetailData.post &&
postDetailData.myVotes[postDetailData.post.id] === "up"
}
enabled={!!userContext.session}
onClick={async () => {
if (!postDetailData.post) {
return;
}
await castVote({
postId: postDetailData.post.id,
userId: userContext.session?.user.id as string,
voteType: "up",
onSuccess: () => {
setBumper(bumper + 1);
},
});
}}
/>
<p className="text-center" data-e2e="upvote-count">
{score}
</p>
<UpVote
direction="down"
filled={
postDetailData.myVotes &&
postDetailData.post &&
postDetailData.myVotes[postDetailData.post.id] === "down"
}
enabled={!!userContext.session}
onClick={async () => {
if (!postDetailData.post) {
return;
}
await castVote({
postId: postDetailData.post.id,
userId: userContext.session?.user.id as string,
voteType: "down",
onSuccess: () => {
setBumper(bumper + 1);
},
});
}}
/>
</div>
<div className="post-detail-body">
<p>
Posted By {postDetailData.post?.author_name}{" "}
{postDetailData.post &&
`${timeAgo(postDetailData.post?.created_at)} ago`}
</p>
<h3 className="text-2xl">{postDetailData.post?.title}</h3>
<p className="post-detail-content" data-e2e="post-content">
{postDetailData.post?.content}
</p>
{userContext.session && postDetailData.post && (
<CreateComment
parent={postDetailData.post}
onSuccess={() => {
setBumper(bumper + 1);
}}
/>
)}
{nestedComments.map((comment) => (
<CommentView
key={comment.id}
comment={comment}
myVotes={postDetailData.myVotes}
onVoteSuccess={() => {
setBumper(bumper + 1);
}}
/>
))}
</div>
</div>
</div>
);
}
function CommentView({
comment,
myVotes,
onVoteSuccess,
}: {
comment: Comment;
myVotes: Record<string, "up" | "down" | undefined> | undefined;
onVoteSuccess: () => void;
}) {
const score = usePostScore(comment.id, comment.score);
const [commenting, setCommenting] = useState(false);
const { session } = useContext(UserContext);
return (
<>
<div
className="post-detail-comment-container"
data-e2e={`comment-${comment.id}`}
>
<div className="post-detail-comment-inner-container">
<div className="post-detail-comment-upvote-container">
<UpVote
direction="up"
filled={myVotes?.[comment.id] === "up"}
enabled={!!session}
onClick={async () => {
await castVote({
postId: comment.id,
userId: session?.user.id as string,
voteType: "up",
onSuccess: () => {
onVoteSuccess();
},
});
}}
/>
<p className="text-center" data-e2e="upvote-count">
{score}
</p>
<UpVote
direction="down"
filled={myVotes?.[comment.id] === "down"}
enabled={!!session}
onClick={async () => {
await castVote({
postId: comment.id,
userId: session?.user.id as string,
voteType: "down",
onSuccess: () => {
onVoteSuccess();
},
});
}}
/>
</div>
<div className="post-detail-comment-body">
<p>
{comment.author_name} - {timeAgo(comment.created_at)} ago
</p>
<p
className="post-detail-comment-content"
data-e2e="comment-content"
>
{comment.content}
</p>
{commenting && (
<CreateComment
parent={comment}
onCancel={() => setCommenting(false)}
onSuccess={() => {
onVoteSuccess();
setCommenting(false);
}}
/>
)}
{!commenting && (
<div className="ml-4">
<button
onClick={() => setCommenting(!commenting)}
disabled={!session}
>
{commenting ? "Cancel" : "Reply"}
</button>
</div>
)}
{comment.comments.map((childComment) => (
<CommentView
key={childComment.id}
comment={childComment}
myVotes={myVotes}
onVoteSuccess={() => onVoteSuccess()}
/>
))}
</div>
</div>
</div>
</>
);
}
function CreateComment({
parent,
onCancel,
onSuccess,
}: {
parent: Comment | GetSinglePostWithCommentResponse;
onCancel?: () => void;
onSuccess: () => void;
}) {
const user = useContext(UserContext);
const [comment, setComment] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
return (
<>
<form
className="post-detail-create-comment-form"
data-e2e="create-comment-form"
onSubmit={(event) => {
event.preventDefault();
supaClient
.rpc("create_new_comment", {
user_id: user.session?.user.id || "",
content: comment,
path: `${parent.path}.${parent.id.replaceAll("-", "_")}`,
})
.then(({ error }) => {
if (error) {
console.log(error);
} else {
onSuccess();
textareaRef.current?.value != null &&
(textareaRef.current.value = "");
}
});
}}
>
<h3>Add a New Comment</h3>
<textarea
ref={textareaRef}
name="comment"
placeholder="Your comment here"
className="post-detail-create-comment-form-content"
onChange={({ target: { value } }) => {
setComment(value);
}}
/>
<div className="flex gap-2">
<button
type="submit"
className="post-detail-create-comment-form-submit-button"
disabled={!comment}
>
Submit
</button>
{onCancel && (
<button
type="button"
className="post-detail-create-comment-form-cancel-button"
onClick={() => onCancel()}
>
Cancel
</button>
)}
</div>
</form>
</>
);
}
function unsortedCommentsToNested(
comments: GetSinglePostWithCommentResponse[]
): Comment[] {
const commentMap = comments.reduce((acc, comment) => {
acc[comment.id] = {
...comment,
comments: [],
};
return acc;
}, {} as Record<string, Comment>);
const result: Comment[] = [];
const sortedByDepthThenCreationTime = [...Object.values(commentMap)].sort(
(a, b) => {
const aDepth = getDepth(a.path);
const bDepth = getDepth(b.path);
return aDepth > bDepth
? 1
: aDepth < bDepth
? -1
: new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
}
);
for (const post of sortedByDepthThenCreationTime) {
if (getDepth(post.path) === 1) {
result.push(post);
} else {
const parentNode = getParent(commentMap, post.path);
parentNode.comments.push(post);
}
}
return result;
}
function getParent(map: Record<string, Comment>, path: string): Comment {
const parentId = path.replace("root.", "").split(".").slice(-1)[0];
const parent = map[convertToUuid(parentId)];
if (!parent) {
throw new Error(`Parent not found at ${parentId}`);
}
return parent;
}
function convertToUuid(path: string): string {
return path.replaceAll("_", "-");
}
function getDepth(path: string): number {
const rootless = path.replace(".", "");
return rootless.split(".").filter((x) => !!x).length;
}