migration of comments

This commit is contained in:
Michael Freno
2025-12-17 22:47:19 -05:00
parent a092c57d36
commit 1bc57c61eb
37 changed files with 3022 additions and 442 deletions

View File

@@ -0,0 +1,388 @@
import {
createSignal,
createEffect,
For,
Show,
onMount,
onCleanup
} from "solid-js";
import { useLocation } from "@solidjs/router";
import type {
CommentBlockProps,
CommentReaction,
UserPublicData
} from "~/types/comment";
import { debounce } from "~/lib/comment-utils";
import UserDefaultImage from "~/components/icons/UserDefaultImage";
import ReplyIcon from "~/components/icons/ReplyIcon";
import TrashIcon from "~/components/icons/TrashIcon";
import EditIcon from "~/components/icons/EditIcon";
import ThumbsUpEmoji from "~/components/icons/emojis/ThumbsUp";
import LoadingSpinner from "~/components/LoadingSpinner";
import CommentInputBlock from "./CommentInputBlock";
import ReactionBar from "./ReactionBar";
export default function CommentBlock(props: CommentBlockProps) {
const location = useLocation();
// State signals
const [commentCollapsed, setCommentCollapsed] = createSignal(false);
const [showingReactionOptions, setShowingReactionOptions] =
createSignal(false);
const [replyBoxShowing, setReplyBoxShowing] = createSignal(false);
const [toggleHeight, setToggleHeight] = createSignal(0);
const [reactions, setReactions] = createSignal<CommentReaction[]>([]);
const [windowWidth, setWindowWidth] = createSignal(0);
const [deletionLoading, setDeletionLoading] = createSignal(false);
const [userData, setUserData] = createSignal<UserPublicData | null>(null);
// Refs
let containerRef: HTMLDivElement | undefined;
let commentInputRef: HTMLDivElement | undefined;
// Auto-collapse at level 4+
createEffect(() => {
setCommentCollapsed(props.level >= 4);
});
// Window resize handler
onMount(() => {
const handleResize = debounce(() => {
setWindowWidth(window.innerWidth);
}, 200);
window.addEventListener("resize", handleResize);
onCleanup(() => {
window.removeEventListener("resize", handleResize);
});
});
// Find user data from comment map
createEffect(() => {
if (props.userCommentMap) {
props.userCommentMap.forEach((commentIds, user) => {
if (commentIds.includes(props.comment.id)) {
setUserData(user);
}
});
}
});
// Update toggle height based on container size
createEffect(() => {
if (containerRef) {
const correction = showingReactionOptions() ? 80 : 48;
setToggleHeight(containerRef.clientHeight + correction);
}
// Trigger on these dependencies
windowWidth();
showingReactionOptions();
});
// Update reactions from map
createEffect(() => {
setReactions(props.reactionMap.get(props.comment.id) || []);
});
// Event handlers
const collapseCommentToggle = (e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
setCommentCollapsed(!commentCollapsed());
};
const showingReactionOptionsToggle = (e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
setShowingReactionOptions(!showingReactionOptions());
};
const toggleCommentReplyBox = (e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
setReplyBoxShowing(!replyBoxShowing());
};
const deleteCommentTrigger = async (e: MouseEvent) => {
e.stopPropagation();
setDeletionLoading(true);
const user = userData();
props.toggleModification(
props.comment.id,
props.comment.commenter_id,
props.comment.body,
"delete",
user?.image,
user?.email,
user?.display_name
);
setDeletionLoading(false);
};
const editCommentTrigger = (e: MouseEvent) => {
e.stopPropagation();
const user = userData();
props.toggleModification(
props.comment.id,
props.comment.commenter_id,
props.comment.body,
"edit",
user?.image,
user?.email,
user?.display_name
);
};
// Computed values
const upvoteCount = () =>
reactions().filter((r) => r.type === "upVote").length;
const downvoteCount = () =>
reactions().filter((r) => r.type === "downVote").length;
const hasUpvoted = () =>
reactions().some(
(r) => r.type === "upVote" && r.user_id === props.currentUserID
);
const hasDownvoted = () =>
reactions().some(
(r) => r.type === "downVote" && r.user_id === props.currentUserID
);
const canDelete = () =>
props.currentUserID === props.comment.commenter_id ||
props.privilegeLevel === "admin";
const canEdit = () => props.currentUserID === props.comment.commenter_id;
const isAnonymous = () => props.privilegeLevel === "anonymous";
const replyIconColor = () =>
location.pathname.split("/")[1] === "blog" ? "#fb923c" : "#60a5fa";
return (
<>
{/* Collapsed state */}
<Show when={commentCollapsed()}>
<button
onClick={collapseCommentToggle}
class="ml-5 w-full px-2 lg:w-3/4"
>
<div class="my-auto mt-1 mr-2 h-8 border-l-2 border-black dark:border-white" />
</button>
</Show>
{/* Expanded state */}
<Show when={!commentCollapsed()}>
<div class="z-[500] transition-all duration-300 ease-in-out">
<div class="my-4 flex w-full overflow-x-hidden overflow-y-hidden lg:w-3/4">
{/* Vote buttons column */}
<div
class="flex flex-col justify-between"
style={{ height: `${toggleHeight()}px` }}
>
{/* Upvote */}
<button
onClick={() =>
props.commentReaction("upVote", props.comment.id)
}
>
<div
class={`h-5 w-5 ${
hasUpvoted()
? "fill-emerald-500"
: `fill-black hover:fill-emerald-500 dark:fill-white ${
isAnonymous() ? "tooltip z-50" : ""
}`
}`}
>
<ThumbsUpEmoji />
<Show when={isAnonymous()}>
<div class="tooltip-text -ml-16 w-32 text-white">
You must be logged in
</div>
</Show>
</div>
</button>
{/* Vote count */}
<div class="mx-auto">{upvoteCount() - downvoteCount()}</div>
{/* Downvote */}
<button
onClick={() =>
props.commentReaction("downVote", props.comment.id)
}
>
<div
class={`h-5 w-5 ${
hasDownvoted()
? "fill-rose-500"
: `fill-black hover:fill-rose-500 dark:fill-white ${
isAnonymous() ? "tooltip z-50" : ""
}`
}`}
>
<div class="rotate-180">
<ThumbsUpEmoji />
</div>
<Show when={isAnonymous()}>
<div class="tooltip-text -ml-16 w-32">
You must be logged in
</div>
</Show>
</div>
</button>
</div>
{/* Collapse toggle line */}
<button onClick={collapseCommentToggle} class="z-0 px-2">
<div
class="border-l-2 border-black transition-all duration-300 ease-in-out dark:border-white"
style={{ height: `${toggleHeight()}px` }}
/>
</button>
{/* Comment content */}
<div
class="w-3/4"
onClick={showingReactionOptionsToggle}
id={props.comment.id.toString()}
>
<div
ref={containerRef}
class="overflow-x-hidden overflow-y-hidden select-text"
>
<div class="max-w-[90%] md:max-w-[75%]">
{props.comment.body}
</div>
<Show when={props.comment.edited}>
<div class="pb-0.5 text-xs italic">Edited</div>
</Show>
</div>
{/* User info */}
<div class="flex pl-2">
<Show
when={userData()?.image}
fallback={
<UserDefaultImage strokeWidth={1} height={24} width={24} />
}
>
<img
src={userData()!.image}
height={24}
width={24}
alt="user-image"
class="h-6 w-6 rounded-full object-cover object-center"
/>
</Show>
<div class="px-1">
{userData()?.display_name || userData()?.email || "[removed]"}
</div>
{/* Delete button */}
<Show when={canDelete()}>
<button onClick={deleteCommentTrigger}>
<Show
when={!deletionLoading()}
fallback={<LoadingSpinner height={24} width={24} />}
>
<TrashIcon
height={24}
width={24}
stroke="red"
strokeWidth={1.5}
/>
</Show>
</button>
</Show>
</div>
{/* Edit and Reply buttons */}
<div class="absolute flex">
<Show when={canEdit()}>
<button onClick={editCommentTrigger} class="px-2">
<EditIcon strokeWidth={1} height={24} width={24} />
</button>
</Show>
<button onClick={toggleCommentReplyBox} class="z-30">
<ReplyIcon color={replyIconColor()} height={24} width={24} />
</button>
</div>
{/* Reaction bar */}
<div
class={`${
showingReactionOptions() || reactions().length > 0
? ""
: "opacity-0"
} ml-16`}
>
<ReactionBar
commentID={props.comment.id}
currentUserID={props.currentUserID}
reactions={reactions()}
showingReactionOptions={showingReactionOptions()}
privilegeLevel={props.privilegeLevel}
commentReaction={props.commentReaction}
/>
</div>
</div>
</div>
{/* Reply box */}
<Show when={replyBoxShowing()}>
<div
ref={commentInputRef}
class="fade-in lg:w-2/3"
style={{ "margin-left": `${-24 * props.recursionCount}px` }}
>
<CommentInputBlock
isReply={true}
privilegeLevel={props.privilegeLevel}
parent_id={props.comment.id}
type={props.category}
post_id={props.projectID}
currentUserID={props.currentUserID}
socket={props.socket}
newComment={props.newComment}
commentSubmitLoading={props.commentSubmitLoading}
/>
</div>
</Show>
{/* Recursive child comments */}
<div class="pl-2 sm:pl-4 md:pl-8 lg:pl-12">
<For each={props.child_comments}>
{(childComment) => (
<CommentBlock
comment={childComment}
category={props.category}
projectID={props.projectID}
recursionCount={1}
allComments={props.allComments}
child_comments={props.allComments?.filter(
(comment) => comment.parent_comment_id === childComment.id
)}
privilegeLevel={props.privilegeLevel}
currentUserID={props.currentUserID}
reactionMap={props.reactionMap}
level={props.level + 1}
socket={props.socket}
userCommentMap={props.userCommentMap}
toggleModification={props.toggleModification}
newComment={props.newComment}
commentSubmitLoading={props.commentSubmitLoading}
commentReaction={props.commentReaction}
/>
)}
</For>
</div>
</div>
</Show>
</>
);
}