migration of comments
This commit is contained in:
388
src/components/blog/CommentBlock.tsx
Normal file
388
src/components/blog/CommentBlock.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
146
src/components/blog/CommentDeletionPrompt.tsx
Normal file
146
src/components/blog/CommentDeletionPrompt.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { createSignal, Show } from "solid-js";
|
||||
import type { CommentDeletionPromptProps, DeletionType } from "~/types/comment";
|
||||
import UserDefaultImage from "~/components/icons/UserDefaultImage";
|
||||
import Xmark from "~/components/icons/Xmark";
|
||||
|
||||
export default function CommentDeletionPrompt(
|
||||
props: CommentDeletionPromptProps
|
||||
) {
|
||||
const [normalDeleteChecked, setNormalDeleteChecked] = createSignal(false);
|
||||
const [adminDeleteChecked, setAdminDeleteChecked] = createSignal(false);
|
||||
const [fullDeleteChecked, setFullDeleteChecked] = createSignal(false);
|
||||
|
||||
const handleNormalDeleteCheckbox = () => {
|
||||
setNormalDeleteChecked(!normalDeleteChecked());
|
||||
setFullDeleteChecked(false);
|
||||
setAdminDeleteChecked(false);
|
||||
};
|
||||
|
||||
const handleAdminDeleteCheckbox = () => {
|
||||
setAdminDeleteChecked(!adminDeleteChecked());
|
||||
setFullDeleteChecked(false);
|
||||
setNormalDeleteChecked(false);
|
||||
};
|
||||
|
||||
const handleFullDeleteCheckbox = () => {
|
||||
setFullDeleteChecked(!fullDeleteChecked());
|
||||
setNormalDeleteChecked(false);
|
||||
setAdminDeleteChecked(false);
|
||||
};
|
||||
|
||||
const deletionWrapper = () => {
|
||||
let deleteType: DeletionType = "user";
|
||||
if (normalDeleteChecked()) {
|
||||
deleteType = "user";
|
||||
} else if (adminDeleteChecked()) {
|
||||
deleteType = "admin";
|
||||
} else if (fullDeleteChecked()) {
|
||||
deleteType = "database";
|
||||
}
|
||||
props.deleteComment(props.commentID, props.commenterID, deleteType);
|
||||
};
|
||||
|
||||
const isDeleteEnabled = () =>
|
||||
normalDeleteChecked() || adminDeleteChecked() || fullDeleteChecked();
|
||||
|
||||
return (
|
||||
<div class="flex justify-center">
|
||||
<div class="fixed top-48 z-50 h-fit w-11/12 sm:w-4/5 md:w-2/3">
|
||||
<div
|
||||
id="delete_prompt"
|
||||
class="fade-in rounded-md bg-red-400 px-8 py-4 shadow-lg dark:bg-red-900"
|
||||
>
|
||||
<button class="absolute right-4" onClick={() => {}}>
|
||||
<Xmark strokeWidth={0.5} color="white" height={50} width={50} />
|
||||
</button>
|
||||
<div class="py-4 text-center text-3xl tracking-wide">
|
||||
Comment Deletion
|
||||
</div>
|
||||
<div class="mx-auto w-3/4 rounded bg-zinc-50 px-6 py-4 dark:bg-zinc-800">
|
||||
<div class="flex overflow-x-auto overflow-y-hidden select-text">
|
||||
{/* Comment body will be passed as prop */}
|
||||
</div>
|
||||
<div class="my-2 flex pl-2">
|
||||
<Show
|
||||
when={props.commenterImage}
|
||||
fallback={
|
||||
<UserDefaultImage strokeWidth={1} height={24} width={24} />
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={props.commenterImage}
|
||||
height={24}
|
||||
width={24}
|
||||
alt="user-image"
|
||||
class="h-6 w-6 rounded-full object-cover object-center"
|
||||
/>
|
||||
</Show>
|
||||
<div class="px-1">
|
||||
{props.commenterDisplayName ||
|
||||
props.commenterEmail ||
|
||||
"[removed]"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full justify-center">
|
||||
<div class="flex pt-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="my-auto"
|
||||
checked={normalDeleteChecked()}
|
||||
onChange={handleNormalDeleteCheckbox}
|
||||
/>
|
||||
<div class="my-auto px-2 text-sm font-normal">
|
||||
{props.privilegeLevel === "admin"
|
||||
? "Confirm User Delete?"
|
||||
: "Confirm Delete?"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={props.privilegeLevel === "admin"}>
|
||||
<div class="flex w-full justify-center">
|
||||
<div class="flex pt-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="my-auto"
|
||||
checked={adminDeleteChecked()}
|
||||
onChange={handleAdminDeleteCheckbox}
|
||||
/>
|
||||
<div class="my-auto px-2 text-sm font-normal">
|
||||
Confirm Admin Delete?
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full justify-center">
|
||||
<div class="flex pt-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="my-auto"
|
||||
checked={fullDeleteChecked()}
|
||||
onChange={handleFullDeleteCheckbox}
|
||||
/>
|
||||
<div class="my-auto px-2 text-sm font-normal">
|
||||
Confirm Full Delete (removal from database)?
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex w-full justify-center pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={deletionWrapper}
|
||||
disabled={props.commentDeletionLoading || !isDeleteEnabled()}
|
||||
class={`${
|
||||
props.commentDeletionLoading || !isDeleteEnabled()
|
||||
? "bg-zinc-400"
|
||||
: "border-orange-500 bg-orange-400 hover:bg-orange-500"
|
||||
} rounded border px-4 py-2 text-white shadow-md transition-all duration-300 ease-in-out active:scale-90`}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
src/components/blog/CommentInputBlock.tsx
Normal file
81
src/components/blog/CommentInputBlock.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { createEffect } from "solid-js";
|
||||
import type { CommentInputBlockProps } from "~/types/comment";
|
||||
|
||||
export default function CommentInputBlock(props: CommentInputBlockProps) {
|
||||
let bodyRef: HTMLTextAreaElement | undefined;
|
||||
|
||||
// Clear the textarea when comment is submitted
|
||||
createEffect(() => {
|
||||
if (!props.commentSubmitLoading && bodyRef) {
|
||||
bodyRef.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
const newCommentWrapper = (e: SubmitEvent) => {
|
||||
e.preventDefault();
|
||||
if (bodyRef && bodyRef.value.length > 0) {
|
||||
props.newComment(bodyRef.value, props.parent_id);
|
||||
}
|
||||
};
|
||||
|
||||
if (props.privilegeLevel === "user" || props.privilegeLevel === "admin") {
|
||||
return (
|
||||
<div class="flex w-full justify-center select-none">
|
||||
<div class="h-fit w-3/4 md:w-1/2">
|
||||
<form onSubmit={newCommentWrapper}>
|
||||
<div
|
||||
class={`textarea-group ${props.type === "blog" ? "blog" : ""}`}
|
||||
>
|
||||
<textarea
|
||||
ref={bodyRef}
|
||||
required
|
||||
name="message"
|
||||
placeholder=" "
|
||||
class="underlinedInput w-full bg-transparent select-text"
|
||||
rows={props.isReply ? 2 : 4}
|
||||
/>
|
||||
<span class="bar" />
|
||||
<label class="underlinedInputLabel">
|
||||
{`Enter your ${props.isReply ? "reply" : "message"}`}
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex justify-end pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={props.commentSubmitLoading}
|
||||
class={`${
|
||||
props.commentSubmitLoading
|
||||
? "bg-zinc-400"
|
||||
: props.type === "project"
|
||||
? "border-blue-500 bg-blue-400 hover:bg-blue-500 dark:border-blue-700 dark:bg-blue-700 dark:hover:bg-blue-800"
|
||||
: "border-orange-500 bg-orange-400 hover:bg-orange-500"
|
||||
} rounded border px-4 py-2 font-light text-white shadow-md transition-all duration-300 ease-in-out active:scale-90`}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div class="flex w-full justify-center">
|
||||
<div class={`textarea-group ${props.type === "blog" ? "blog" : ""}`}>
|
||||
<textarea
|
||||
required
|
||||
disabled
|
||||
name="message"
|
||||
placeholder=" "
|
||||
class="underlinedInput w-full bg-transparent"
|
||||
rows={4}
|
||||
/>
|
||||
<span class="bar" />
|
||||
<label class="underlinedInputLabel">
|
||||
{`You must be logged in to ${props.isReply ? "reply" : "comment"}`}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
112
src/components/blog/CommentSection.tsx
Normal file
112
src/components/blog/CommentSection.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { createSignal, Show } from "solid-js";
|
||||
import type {
|
||||
Comment,
|
||||
CommentReaction,
|
||||
UserPublicData,
|
||||
ReactionType,
|
||||
ModificationType,
|
||||
PostType,
|
||||
PrivilegeLevel,
|
||||
SortingMode
|
||||
} from "~/types/comment";
|
||||
import CommentInputBlock from "./CommentInputBlock";
|
||||
import CommentSortingSelect from "./CommentSortingSelect";
|
||||
import CommentSorting from "./CommentSorting";
|
||||
|
||||
const COMMENT_SORTING_OPTIONS: { val: SortingMode }[] = [
|
||||
{ val: "newest" },
|
||||
{ val: "oldest" },
|
||||
{ val: "highest_rated" },
|
||||
{ val: "hot" }
|
||||
];
|
||||
|
||||
interface CommentSectionProps {
|
||||
privilegeLevel: PrivilegeLevel;
|
||||
allComments: Comment[];
|
||||
topLevelComments: Comment[];
|
||||
type: PostType;
|
||||
postID: number;
|
||||
reactionMap: Map<number, CommentReaction[]>;
|
||||
currentUserID: string;
|
||||
userCommentMap: Map<UserPublicData, number[]> | undefined;
|
||||
newComment: (commentBody: string, parentCommentID?: number) => Promise<void>;
|
||||
commentSubmitLoading: boolean;
|
||||
toggleModification: (
|
||||
commentID: number,
|
||||
commenterID: string,
|
||||
commentBody: string,
|
||||
modificationType: ModificationType,
|
||||
commenterImage?: string,
|
||||
commenterEmail?: string,
|
||||
commenterDisplayName?: string
|
||||
) => void;
|
||||
commentReaction: (reactionType: ReactionType, commentID: number) => void;
|
||||
}
|
||||
|
||||
export default function CommentSection(props: CommentSectionProps) {
|
||||
const [selectedSorting, setSelectedSorting] = createSignal<SortingMode>(
|
||||
COMMENT_SORTING_OPTIONS[0].val
|
||||
);
|
||||
|
||||
const hasComments = () =>
|
||||
props.allComments &&
|
||||
props.allComments.length > 0 &&
|
||||
props.topLevelComments &&
|
||||
props.topLevelComments.length > 0;
|
||||
|
||||
return (
|
||||
<div class="w-full">
|
||||
<div
|
||||
class="text-center text-2xl font-light tracking-widest underline underline-offset-8"
|
||||
id="comments"
|
||||
>
|
||||
Comments
|
||||
</div>
|
||||
<div class="mb-1">
|
||||
<CommentInputBlock
|
||||
isReply={false}
|
||||
privilegeLevel={props.privilegeLevel}
|
||||
type={props.type}
|
||||
post_id={props.postID}
|
||||
socket={undefined}
|
||||
currentUserID={props.currentUserID}
|
||||
newComment={props.newComment}
|
||||
commentSubmitLoading={props.commentSubmitLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={hasComments()}
|
||||
fallback={
|
||||
<div class="pt-8 text-center text-xl font-thin tracking-wide italic">
|
||||
No Comments Yet
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CommentSortingSelect
|
||||
selectedSorting={{ val: selectedSorting() }}
|
||||
setSorting={setSelectedSorting}
|
||||
/>
|
||||
<div id="comments">
|
||||
<CommentSorting
|
||||
topLevelComments={props.topLevelComments}
|
||||
privilegeLevel={props.privilegeLevel}
|
||||
type={props.type}
|
||||
postID={props.postID}
|
||||
allComments={props.allComments}
|
||||
reactionMap={props.reactionMap}
|
||||
currentUserID={props.currentUserID}
|
||||
socket={undefined}
|
||||
userCommentMap={props.userCommentMap}
|
||||
newComment={props.newComment}
|
||||
editComment={async () => {}}
|
||||
toggleModification={props.toggleModification}
|
||||
commentSubmitLoading={props.commentSubmitLoading}
|
||||
selectedSorting={{ val: selectedSorting() }}
|
||||
commentReaction={props.commentReaction}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
555
src/components/blog/CommentSectionWrapper.tsx
Normal file
555
src/components/blog/CommentSectionWrapper.tsx
Normal file
@@ -0,0 +1,555 @@
|
||||
import { createSignal, createEffect, onCleanup, Show } from "solid-js";
|
||||
import type {
|
||||
Comment,
|
||||
CommentReaction,
|
||||
CommentSectionWrapperProps,
|
||||
WebSocketBroadcast,
|
||||
BackupResponse,
|
||||
UserPublicData,
|
||||
ReactionType,
|
||||
DeletionType
|
||||
} from "~/types/comment";
|
||||
import { getSQLFormattedDate } from "~/lib/comment-utils";
|
||||
import CommentSection from "./CommentSection";
|
||||
import CommentDeletionPrompt from "./CommentDeletionPrompt";
|
||||
import EditCommentModal from "./EditCommentModal";
|
||||
|
||||
const MAX_RETRIES = 12;
|
||||
const RETRY_INTERVAL = 5000;
|
||||
|
||||
export default function CommentSectionWrapper(
|
||||
props: CommentSectionWrapperProps
|
||||
) {
|
||||
// State signals
|
||||
const [allComments, setAllComments] = createSignal<Comment[]>(
|
||||
props.allComments
|
||||
);
|
||||
const [topLevelComments, setTopLevelComments] = createSignal<Comment[]>(
|
||||
props.topLevelComments
|
||||
);
|
||||
const [currentReactionMap, setCurrentReactionMap] = createSignal<
|
||||
Map<number, CommentReaction[]>
|
||||
>(props.reactionMap);
|
||||
const [commentSubmitLoading, setCommentSubmitLoading] =
|
||||
createSignal<boolean>(false);
|
||||
const [commentDeletionLoading, setCommentDeletionLoading] =
|
||||
createSignal<boolean>(false);
|
||||
const [editCommentLoading, setCommentEditLoading] =
|
||||
createSignal<boolean>(false);
|
||||
const [showingCommentEdit, setShowingCommentEdit] =
|
||||
createSignal<boolean>(false);
|
||||
const [showingDeletionPrompt, setShowingDeletionPrompt] =
|
||||
createSignal<boolean>(false);
|
||||
const [commentIDForModification, setCommentIDForModification] =
|
||||
createSignal<number>(-1);
|
||||
const [commenterForModification, setCommenterForModification] =
|
||||
createSignal<string>("");
|
||||
const [commenterImageForModification, setCommenterImageForModification] =
|
||||
createSignal<string | undefined>(undefined);
|
||||
const [commenterEmailForModification, setCommenterEmailForModification] =
|
||||
createSignal<string | undefined>(undefined);
|
||||
const [
|
||||
commenterDisplayNameForModification,
|
||||
setCommenterDisplayNameForModification
|
||||
] = createSignal<string | undefined>(undefined);
|
||||
const [commentBodyForModification, setCommentBodyForModification] =
|
||||
createSignal<string>("");
|
||||
|
||||
// Non-reactive refs (store without triggering reactivity)
|
||||
let userCommentMap: Map<UserPublicData, number[]> = props.userCommentMap;
|
||||
let deletePromptRef: HTMLDivElement | undefined;
|
||||
let modificationPromptRef: HTMLDivElement | undefined;
|
||||
let retryCount = 0;
|
||||
let socket: WebSocket | undefined;
|
||||
|
||||
// WebSocket connection effect
|
||||
createEffect(() => {
|
||||
const connect = () => {
|
||||
if (socket) return;
|
||||
if (retryCount > MAX_RETRIES) {
|
||||
console.error("Max retries exceeded!");
|
||||
return;
|
||||
}
|
||||
|
||||
const websocketUrl = import.meta.env.VITE_WEBSOCKET;
|
||||
if (!websocketUrl) {
|
||||
console.error("VITE_WEBSOCKET environment variable not set");
|
||||
return;
|
||||
}
|
||||
|
||||
const newSocket = new WebSocket(websocketUrl);
|
||||
|
||||
newSocket.onopen = () => {
|
||||
updateChannel();
|
||||
retryCount = 0;
|
||||
};
|
||||
|
||||
newSocket.onclose = () => {
|
||||
retryCount += 1;
|
||||
socket = undefined;
|
||||
setTimeout(connect, RETRY_INTERVAL);
|
||||
};
|
||||
|
||||
newSocket.onmessage = (messageEvent) => {
|
||||
try {
|
||||
const parsed = JSON.parse(messageEvent.data) as WebSocketBroadcast;
|
||||
switch (parsed.action) {
|
||||
case "commentCreationBroadcast":
|
||||
createCommentHandler(parsed);
|
||||
break;
|
||||
case "commentUpdateBroadcast":
|
||||
editCommentHandler(parsed);
|
||||
break;
|
||||
case "commentDeletionBroadcast":
|
||||
deleteCommentHandler(parsed);
|
||||
break;
|
||||
case "commentReactionBroadcast":
|
||||
commentReactionHandler(parsed);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
socket = newSocket;
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
// Cleanup on unmount
|
||||
onCleanup(() => {
|
||||
if (socket?.readyState === WebSocket.OPEN) {
|
||||
socket.close();
|
||||
socket = undefined;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
const updateChannel = () => {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
action: "channelUpdate",
|
||||
postType: props.type,
|
||||
postID: props.id,
|
||||
invoker_id: props.currentUserID
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Comment creation
|
||||
const newComment = async (commentBody: string, parentCommentID?: number) => {
|
||||
setCommentSubmitLoading(true);
|
||||
if (commentBody && socket) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
action: "commentCreation",
|
||||
commentBody: commentBody,
|
||||
postType: props.type,
|
||||
postID: props.id,
|
||||
parentCommentID: parentCommentID,
|
||||
invokerID: props.currentUserID
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Fallback to HTTP API if WebSocket unavailable
|
||||
const domain = import.meta.env.VITE_DOMAIN;
|
||||
const res = await fetch(
|
||||
`${domain}/api/database/comments/create/${props.type}/${props.id}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
body: commentBody,
|
||||
parentCommentID: parentCommentID,
|
||||
commenterID: props.currentUserID
|
||||
})
|
||||
}
|
||||
);
|
||||
if (res.status === 201) {
|
||||
const id = (await res.json()).data;
|
||||
createCommentHandler({
|
||||
commentBody: commentBody,
|
||||
commentID: id,
|
||||
commenterID: props.currentUserID,
|
||||
commentParent: parentCommentID
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const createCommentHandler = async (
|
||||
data: WebSocketBroadcast | BackupResponse
|
||||
) => {
|
||||
const body = data.commentBody;
|
||||
const commenterID = data.commenterID;
|
||||
const parentCommentID = data.commentParent;
|
||||
const id = data.commentID;
|
||||
|
||||
if (body && commenterID && parentCommentID !== undefined && id) {
|
||||
const domain = import.meta.env.VITE_DOMAIN;
|
||||
const res = await fetch(
|
||||
`${domain}/api/database/user/public-data/${commenterID}`
|
||||
);
|
||||
const userData = (await res.json()) as UserPublicData;
|
||||
|
||||
const comment_date = getSQLFormattedDate();
|
||||
const newComment: Comment = {
|
||||
id: id,
|
||||
body: body,
|
||||
post_id: props.id,
|
||||
parent_comment_id: parentCommentID,
|
||||
commenter_id: commenterID,
|
||||
edited: false,
|
||||
date: comment_date
|
||||
};
|
||||
|
||||
if (parentCommentID === -1 || parentCommentID === null) {
|
||||
setTopLevelComments((prevComments) => [
|
||||
...(prevComments || []),
|
||||
newComment
|
||||
]);
|
||||
}
|
||||
setAllComments((prevComments) => [...(prevComments || []), newComment]);
|
||||
|
||||
// Update user comment map
|
||||
const existingIDs = Array.from(userCommentMap.entries()).find(
|
||||
([key, _]) =>
|
||||
key.email === userData.email &&
|
||||
key.display_name === userData.display_name &&
|
||||
key.image === userData.image
|
||||
);
|
||||
|
||||
if (existingIDs) {
|
||||
const [key, ids] = existingIDs;
|
||||
userCommentMap.set(key, [...ids, id]);
|
||||
} else {
|
||||
userCommentMap.set(userData, [id]);
|
||||
}
|
||||
}
|
||||
setCommentSubmitLoading(false);
|
||||
};
|
||||
|
||||
// Comment updating
|
||||
const editComment = async (body: string, comment_id: number) => {
|
||||
setCommentEditLoading(true);
|
||||
if (socket) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
action: "commentUpdate",
|
||||
commentBody: body,
|
||||
postType: props.type,
|
||||
postID: props.id,
|
||||
commentID: comment_id,
|
||||
invokerID: props.currentUserID
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const editCommentHandler = (data: WebSocketBroadcast) => {
|
||||
setAllComments((prev) =>
|
||||
prev.map((comment) => {
|
||||
if (comment.id === data.commentID) {
|
||||
return {
|
||||
...comment,
|
||||
body: data.commentBody!,
|
||||
edited: true
|
||||
};
|
||||
}
|
||||
return comment;
|
||||
})
|
||||
);
|
||||
setTopLevelComments((prev) =>
|
||||
prev.map((comment) => {
|
||||
if (comment.id === data.commentID) {
|
||||
return {
|
||||
...comment,
|
||||
body: data.commentBody!,
|
||||
edited: true
|
||||
};
|
||||
}
|
||||
return comment;
|
||||
})
|
||||
);
|
||||
setCommentEditLoading(false);
|
||||
setTimeout(() => {
|
||||
setShowingCommentEdit(false);
|
||||
clearModificationPrompt();
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// Comment deletion
|
||||
const deleteComment = (
|
||||
commentID: number,
|
||||
commenterID: string,
|
||||
deletionType: DeletionType
|
||||
) => {
|
||||
setCommentDeletionLoading(true);
|
||||
if (socket) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
action: "commentDeletion",
|
||||
deleteType: deletionType,
|
||||
commentID: commentID,
|
||||
invokerID: props.currentUserID,
|
||||
postType: props.type,
|
||||
postID: props.id
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteCommentHandler = (data: WebSocketBroadcast) => {
|
||||
if (data.commentBody) {
|
||||
// Soft delete (replace body with deletion message)
|
||||
setAllComments((prev) =>
|
||||
prev.map((comment) => {
|
||||
if (comment.id === data.commentID) {
|
||||
return {
|
||||
...comment,
|
||||
body: data.commentBody!,
|
||||
commenter_id: "",
|
||||
edited: false
|
||||
};
|
||||
}
|
||||
return comment;
|
||||
})
|
||||
);
|
||||
|
||||
setTopLevelComments((prev) =>
|
||||
prev.map((comment) => {
|
||||
if (comment.id === data.commentID) {
|
||||
return {
|
||||
...comment,
|
||||
body: data.commentBody!,
|
||||
commenter_id: "",
|
||||
edited: false
|
||||
};
|
||||
}
|
||||
return comment;
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Hard delete (remove from list)
|
||||
setAllComments((prev) =>
|
||||
prev.filter((comment) => comment.id !== data.commentID)
|
||||
);
|
||||
setTopLevelComments((prev) =>
|
||||
prev.filter((comment) => comment.id !== data.commentID)
|
||||
);
|
||||
}
|
||||
setCommentDeletionLoading(false);
|
||||
setTimeout(() => {
|
||||
clearModificationPrompt();
|
||||
setShowingDeletionPrompt(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// Deletion/edit prompt toggle
|
||||
const toggleModification = (
|
||||
commentID: number,
|
||||
commenterID: string,
|
||||
commentBody: string,
|
||||
modificationType: "delete" | "edit",
|
||||
commenterImage?: string,
|
||||
commenterEmail?: string,
|
||||
commenterDisplayName?: string
|
||||
) => {
|
||||
if (commentID === commentIDForModification()) {
|
||||
if (modificationType === "delete") {
|
||||
setShowingDeletionPrompt(false);
|
||||
} else {
|
||||
setShowingCommentEdit(false);
|
||||
}
|
||||
clearModificationPrompt();
|
||||
} else {
|
||||
if (modificationType === "delete") {
|
||||
setShowingDeletionPrompt(true);
|
||||
} else {
|
||||
setShowingCommentEdit(true);
|
||||
}
|
||||
setCommentIDForModification(commentID);
|
||||
setCommenterForModification(commenterID);
|
||||
setCommenterImageForModification(commenterImage);
|
||||
setCommenterEmailForModification(commenterEmail);
|
||||
setCommenterDisplayNameForModification(commenterDisplayName);
|
||||
setCommentBodyForModification(commentBody);
|
||||
}
|
||||
};
|
||||
|
||||
const clearModificationPrompt = () => {
|
||||
setCommentIDForModification(-1);
|
||||
setCommenterForModification("");
|
||||
setCommenterImageForModification(undefined);
|
||||
setCommenterEmailForModification(undefined);
|
||||
setCommenterDisplayNameForModification(undefined);
|
||||
setCommentBodyForModification("");
|
||||
};
|
||||
|
||||
// Reaction handling
|
||||
const commentReaction = (reactionType: ReactionType, commentID: number) => {
|
||||
if (socket) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
action: "commentReaction",
|
||||
postType: props.type,
|
||||
postID: props.id,
|
||||
commentID: commentID,
|
||||
invokerID: props.currentUserID,
|
||||
reactionType: reactionType
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const commentReactionHandler = (data: any) => {
|
||||
switch (data.endEffect) {
|
||||
case "creation":
|
||||
if (data.commentID && data.reactionType && data.reactingUserID) {
|
||||
const newReaction: CommentReaction = {
|
||||
id: -1,
|
||||
type: data.reactionType,
|
||||
comment_id: data.commentID,
|
||||
user_id: data.reactingUserID,
|
||||
date: getSQLFormattedDate()
|
||||
};
|
||||
setCurrentReactionMap((prevMap) => {
|
||||
const entries = [
|
||||
...(prevMap.get(data.commentID!) || []),
|
||||
newReaction
|
||||
];
|
||||
return new Map([...prevMap, [data.commentID!, entries]]);
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "deletion":
|
||||
if (data.commentID) {
|
||||
setCurrentReactionMap((prevMap) => {
|
||||
const entries = (prevMap.get(data.commentID!) || []).filter(
|
||||
(reaction) =>
|
||||
reaction.user_id !== data.reactingUserID ||
|
||||
reaction.type !== data.reactionType
|
||||
);
|
||||
return new Map([...prevMap, [data.commentID!, entries]]);
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "inversion":
|
||||
// Only applies to upvotes/downvotes (vote inversion)
|
||||
if (
|
||||
data.commentID &&
|
||||
data.reactingUserID &&
|
||||
data.reactionType &&
|
||||
(data.reactionType === "upVote" || data.reactionType === "downVote")
|
||||
) {
|
||||
setCurrentReactionMap((prevMap) => {
|
||||
let entries = (prevMap.get(data.commentID!) || []).filter(
|
||||
(reaction) =>
|
||||
reaction.user_id !== data.reactingUserID ||
|
||||
reaction.type !==
|
||||
(data.reactionType === "upVote" ? "downVote" : "upVote")
|
||||
);
|
||||
const newReaction: CommentReaction = {
|
||||
id: -1,
|
||||
type: data.reactionType!,
|
||||
comment_id: data.commentID!,
|
||||
user_id: data.reactingUserID!,
|
||||
date: getSQLFormattedDate()
|
||||
};
|
||||
entries = entries.concat(newReaction);
|
||||
return new Map([...prevMap, [data.commentID!, entries]]);
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log("endEffect value unknown");
|
||||
}
|
||||
};
|
||||
|
||||
// Click outside handlers (SolidJS version)
|
||||
createEffect(() => {
|
||||
const handleClickOutsideDelete = (e: MouseEvent) => {
|
||||
if (
|
||||
deletePromptRef &&
|
||||
!deletePromptRef.contains(e.target as Node) &&
|
||||
showingDeletionPrompt()
|
||||
) {
|
||||
setShowingDeletionPrompt(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickOutsideEdit = (e: MouseEvent) => {
|
||||
if (
|
||||
modificationPromptRef &&
|
||||
!modificationPromptRef.contains(e.target as Node) &&
|
||||
showingCommentEdit()
|
||||
) {
|
||||
setShowingCommentEdit(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutsideDelete);
|
||||
document.addEventListener("mousedown", handleClickOutsideEdit);
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("mousedown", handleClickOutsideDelete);
|
||||
document.removeEventListener("mousedown", handleClickOutsideEdit);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommentSection
|
||||
privilegeLevel={props.privilegeLevel}
|
||||
allComments={allComments()}
|
||||
topLevelComments={topLevelComments()}
|
||||
type={props.type}
|
||||
postID={props.id}
|
||||
reactionMap={currentReactionMap()}
|
||||
currentUserID={props.currentUserID}
|
||||
userCommentMap={userCommentMap}
|
||||
newComment={newComment}
|
||||
commentSubmitLoading={commentSubmitLoading()}
|
||||
toggleModification={toggleModification}
|
||||
commentReaction={commentReaction}
|
||||
/>
|
||||
|
||||
<Show when={showingDeletionPrompt()}>
|
||||
<div ref={deletePromptRef}>
|
||||
<CommentDeletionPrompt
|
||||
commentID={commentIDForModification()}
|
||||
commenterID={commenterForModification()}
|
||||
commenterImage={commenterImageForModification()}
|
||||
commenterEmail={commenterEmailForModification()}
|
||||
commenterDisplayName={commenterDisplayNameForModification()}
|
||||
privilegeLevel={props.privilegeLevel}
|
||||
commentDeletionLoading={commentDeletionLoading()}
|
||||
deleteComment={deleteComment}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={showingCommentEdit()}>
|
||||
<div ref={modificationPromptRef}>
|
||||
<EditCommentModal
|
||||
commentID={commentIDForModification()}
|
||||
commentBody={commentBodyForModification()}
|
||||
commenterImage={commenterImageForModification()}
|
||||
commenterEmail={commenterEmailForModification()}
|
||||
commenterDisplayName={commenterDisplayNameForModification()}
|
||||
editCommentLoading={editCommentLoading()}
|
||||
editComment={editComment}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
}
|
||||
83
src/components/blog/CommentSorting.tsx
Normal file
83
src/components/blog/CommentSorting.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { createSignal, createEffect, For, Show, createMemo } from "solid-js";
|
||||
import type { CommentSortingProps } from "~/types/comment";
|
||||
import { sortComments } from "~/lib/comment-utils";
|
||||
import CommentBlock from "./CommentBlock";
|
||||
|
||||
export default function CommentSorting(props: CommentSortingProps) {
|
||||
const [clickedOnce, setClickedOnce] = createSignal(false);
|
||||
const [showingBlock, setShowingBlock] = createSignal<Map<number, boolean>>(
|
||||
new Map(props.topLevelComments?.map((comment) => [comment.id, true]))
|
||||
);
|
||||
|
||||
// Update showing block when top level comments change
|
||||
createEffect(() => {
|
||||
setShowingBlock(
|
||||
new Map(props.topLevelComments?.map((comment) => [comment.id, true]))
|
||||
);
|
||||
});
|
||||
|
||||
// Reset clickedOnce after timeout
|
||||
createEffect(() => {
|
||||
if (clickedOnce()) {
|
||||
setTimeout(() => setClickedOnce(false), 300);
|
||||
}
|
||||
});
|
||||
|
||||
const checkForDoubleClick = (id: number) => {
|
||||
if (clickedOnce()) {
|
||||
setShowingBlock((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(id, !prev.get(id));
|
||||
return newMap;
|
||||
});
|
||||
} else {
|
||||
setClickedOnce(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Memoized sorted comments
|
||||
const sortedComments = createMemo(() => {
|
||||
return sortComments(
|
||||
props.topLevelComments,
|
||||
props.selectedSorting.val,
|
||||
props.reactionMap
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<For each={sortedComments()}>
|
||||
{(topLevelComment) => (
|
||||
<div
|
||||
onClick={() => checkForDoubleClick(topLevelComment.id)}
|
||||
class="mt-4 max-w-full rounded bg-white py-2 pl-2 shadow select-none sm:pl-4 md:pl-8 lg:pl-12 dark:bg-zinc-900"
|
||||
>
|
||||
<Show
|
||||
when={showingBlock().get(topLevelComment.id)}
|
||||
fallback={<div class="h-4" />}
|
||||
>
|
||||
<CommentBlock
|
||||
comment={topLevelComment}
|
||||
category={props.type}
|
||||
projectID={props.postID}
|
||||
recursionCount={1}
|
||||
allComments={props.allComments}
|
||||
child_comments={props.allComments?.filter(
|
||||
(comment) => comment.parent_comment_id === topLevelComment.id
|
||||
)}
|
||||
privilegeLevel={props.privilegeLevel}
|
||||
currentUserID={props.currentUserID}
|
||||
reactionMap={props.reactionMap}
|
||||
level={0}
|
||||
socket={props.socket}
|
||||
userCommentMap={props.userCommentMap}
|
||||
toggleModification={props.toggleModification}
|
||||
newComment={props.newComment}
|
||||
commentSubmitLoading={props.commentSubmitLoading}
|
||||
commentReaction={props.commentReaction}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
);
|
||||
}
|
||||
89
src/components/blog/CommentSortingSelect.tsx
Normal file
89
src/components/blog/CommentSortingSelect.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { For, Show, createSignal } from "solid-js";
|
||||
import type { CommentSortingSelectProps, SortingMode } from "~/types/comment";
|
||||
import Check from "~/components/icons/Check";
|
||||
import UpDownArrows from "~/components/icons/UpDownArrows";
|
||||
|
||||
const SORTING_OPTIONS: { val: SortingMode; label: string }[] = [
|
||||
{ val: "newest", label: "Newest" },
|
||||
{ val: "oldest", label: "Oldest" },
|
||||
{ val: "highest_rated", label: "Highest Rated" },
|
||||
{ val: "hot", label: "Hot" }
|
||||
];
|
||||
|
||||
export default function CommentSortingSelect(props: CommentSortingSelectProps) {
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
|
||||
const selectedLabel = () => {
|
||||
const option = SORTING_OPTIONS.find(
|
||||
(opt) => opt.val === props.selectedSorting.val
|
||||
);
|
||||
return option?.label || "Newest";
|
||||
};
|
||||
|
||||
const handleSelect = (mode: SortingMode) => {
|
||||
props.setSorting(mode);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="mt-2 flex justify-center">
|
||||
<div class="w-72">
|
||||
<div class="relative z-40 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen())}
|
||||
class="focus-visible:ring-opacity-75 relative w-full cursor-default rounded-lg bg-white py-2 pr-10 pl-3 text-left shadow-md focus:outline-none focus-visible:border-orange-600 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-orange-300 sm:text-sm dark:bg-zinc-900"
|
||||
>
|
||||
<span class="block truncate">{selectedLabel()}</span>
|
||||
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<UpDownArrows
|
||||
strokeWidth={1.5}
|
||||
height={24}
|
||||
width={24}
|
||||
class="fill-zinc-900 dark:fill-white"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Show when={isOpen()}>
|
||||
<div class="ring-opacity-5 absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black transition duration-100 ease-in focus:outline-none sm:text-sm dark:bg-zinc-900">
|
||||
<For each={SORTING_OPTIONS}>
|
||||
{(sort) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelect(sort.val)}
|
||||
class={`relative w-full cursor-default py-2 pr-4 pl-10 text-left select-none ${
|
||||
props.selectedSorting.val === sort.val
|
||||
? "bg-orange-100 text-orange-900"
|
||||
: "text-zinc-900 hover:bg-orange-50 dark:text-white dark:hover:bg-zinc-800"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
class={`block truncate ${
|
||||
props.selectedSorting.val === sort.val
|
||||
? "font-medium"
|
||||
: "font-normal"
|
||||
}`}
|
||||
>
|
||||
{sort.label}
|
||||
</span>
|
||||
<Show when={props.selectedSorting.val === sort.val}>
|
||||
<span class="absolute inset-y-0 left-0 flex items-center pl-3 text-orange-600">
|
||||
<Check
|
||||
strokeWidth={1}
|
||||
height={24}
|
||||
width={24}
|
||||
class="stroke-zinc-900 dark:stroke-white"
|
||||
/>
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
src/components/blog/EditCommentModal.tsx
Normal file
70
src/components/blog/EditCommentModal.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { createSignal, Show } from "solid-js";
|
||||
import type { EditCommentModalProps } from "~/types/comment";
|
||||
import Xmark from "~/components/icons/Xmark";
|
||||
|
||||
export default function EditCommentModal(props: EditCommentModalProps) {
|
||||
let bodyRef: HTMLTextAreaElement | undefined;
|
||||
const [showNoChange, setShowNoChange] = createSignal(false);
|
||||
|
||||
const editCommentWrapper = (e: SubmitEvent) => {
|
||||
e.preventDefault();
|
||||
if (
|
||||
bodyRef &&
|
||||
bodyRef.value.length > 0 &&
|
||||
bodyRef.value !== props.commentBody
|
||||
) {
|
||||
setShowNoChange(false);
|
||||
props.editComment(bodyRef.value, props.commentID);
|
||||
} else {
|
||||
setShowNoChange(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="flex justify-center">
|
||||
<div class="fixed top-48 h-fit w-11/12 sm:w-4/5 md:w-2/3">
|
||||
<div
|
||||
id="edit_prompt"
|
||||
class="fade-in z-50 rounded-md bg-zinc-600 px-8 py-4 shadow-lg dark:bg-zinc-800"
|
||||
>
|
||||
<button class="absolute right-4" onClick={() => {}}>
|
||||
<Xmark strokeWidth={0.5} color="white" height={50} width={50} />
|
||||
</button>
|
||||
<div class="py-4 text-center text-3xl tracking-wide text-zinc-50">
|
||||
Edit Comment
|
||||
</div>
|
||||
<form onSubmit={editCommentWrapper}>
|
||||
<div class="textarea-group home">
|
||||
<textarea
|
||||
required
|
||||
ref={bodyRef}
|
||||
placeholder=" "
|
||||
value={props.commentBody}
|
||||
class="underlinedInput w-full bg-transparent text-blue-300"
|
||||
rows={4}
|
||||
/>
|
||||
<span class="bar" />
|
||||
<label class="underlinedInputLabel">Edit Comment</label>
|
||||
</div>
|
||||
<div class="flex justify-end pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={props.editCommentLoading}
|
||||
class={`${
|
||||
props.editCommentLoading ? "bg-zinc-400" : ""
|
||||
} rounded border px-4 py-2 text-white shadow-md transition-all duration-300 ease-in-out hover:border-blue-500 hover:bg-blue-400 active:scale-90`}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<Show when={showNoChange()}>
|
||||
<div class="text-center text-red-500 italic">
|
||||
No change detected
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ const sorting = [
|
||||
{ val: "Oldest" },
|
||||
{ val: "Most Liked" },
|
||||
{ val: "Most Read" },
|
||||
{ val: "Most Comments" },
|
||||
{ val: "Most Comments" }
|
||||
];
|
||||
|
||||
export interface PostSortingSelectProps {
|
||||
@@ -44,9 +44,9 @@ export default function PostSortingSelect(props: PostSortingSelectProps) {
|
||||
onClick={() => setIsOpen(!isOpen())}
|
||||
class={`${
|
||||
props.type === "project"
|
||||
? "focus-visible:border-blue-600 focus-visible:ring-offset-blue-300"
|
||||
: "focus-visible:border-orange-600 focus-visible:ring-offset-orange-300"
|
||||
} relative w-full cursor-default rounded-lg bg-white py-2 pl-3 pr-10 text-left shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 dark:bg-zinc-900 sm:text-sm`}
|
||||
? "focus-visible:border-blue focus-visible:ring-offset-blue"
|
||||
: "focus-visible:border-peach focus-visible:ring-offset-peach"
|
||||
} bg-surface0 focus-visible:ring-opacity-75 relative w-full cursor-default rounded-lg py-2 pr-10 pl-3 text-left shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 sm:text-sm`}
|
||||
>
|
||||
<span class="block truncate">{selected().val}</span>
|
||||
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
@@ -54,24 +54,24 @@ export default function PostSortingSelect(props: PostSortingSelectProps) {
|
||||
strokeWidth={1.5}
|
||||
height={24}
|
||||
width={24}
|
||||
class="fill-zinc-900 dark:fill-white"
|
||||
class="fill-text"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Show when={isOpen()}>
|
||||
<div class="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:bg-zinc-900 sm:text-sm">
|
||||
<div class="ring-opacity-5 bg-surface0 ring-overlay0 absolute mt-1 max-h-60 w-full overflow-auto rounded-md py-1 text-base shadow-lg ring-1 focus:outline-none sm:text-sm">
|
||||
<For each={sorting}>
|
||||
{(sort) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelect(sort)}
|
||||
class={`relative w-full cursor-default select-none py-2 pl-10 pr-4 text-left ${
|
||||
class={`relative w-full cursor-default py-2 pr-4 pl-10 text-left select-none ${
|
||||
selected().val === sort.val
|
||||
? props.type === "project"
|
||||
? "bg-blue-100 text-blue-900"
|
||||
: "bg-orange-100 text-orange-900"
|
||||
: "text-zinc-900 dark:text-white hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||
? "bg-blue text-base brightness-75"
|
||||
: "bg-peach text-base brightness-75"
|
||||
: "text-text hover:brightness-125"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
@@ -84,16 +84,14 @@ export default function PostSortingSelect(props: PostSortingSelectProps) {
|
||||
<Show when={selected().val === sort.val}>
|
||||
<span
|
||||
class={`${
|
||||
props.type === "project"
|
||||
? "text-blue-600"
|
||||
: "text-orange-600"
|
||||
props.type === "project" ? "text-blue" : "text-peach"
|
||||
} absolute inset-y-0 left-0 flex items-center pl-3`}
|
||||
>
|
||||
<Check
|
||||
strokeWidth={1}
|
||||
height={24}
|
||||
width={24}
|
||||
class="stroke-zinc-900 dark:stroke-white"
|
||||
class="stroke-text"
|
||||
/>
|
||||
</span>
|
||||
</Show>
|
||||
|
||||
77
src/components/blog/ReactionBar.tsx
Normal file
77
src/components/blog/ReactionBar.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { For, Show } from "solid-js";
|
||||
import type { ReactionBarProps, ReactionType } from "~/types/comment";
|
||||
import TearsEmoji from "~/components/icons/emojis/Tears";
|
||||
import BlankEmoji from "~/components/icons/emojis/Blank";
|
||||
import TongueEmoji from "~/components/icons/emojis/Tongue";
|
||||
import CryEmoji from "~/components/icons/emojis/Cry";
|
||||
import HeartEyeEmoji from "~/components/icons/emojis/HeartEye";
|
||||
import AngryEmoji from "~/components/icons/emojis/Angry";
|
||||
import MoneyEyeEmoji from "~/components/icons/emojis/MoneyEye";
|
||||
import SickEmoji from "~/components/icons/emojis/Sick";
|
||||
import UpsideDownEmoji from "~/components/icons/emojis/UpsideDown";
|
||||
import WorriedEmoji from "~/components/icons/emojis/Worried";
|
||||
|
||||
interface EmojiConfig {
|
||||
type: ReactionType;
|
||||
Component: any;
|
||||
}
|
||||
|
||||
const EMOJI_CONFIG: EmojiConfig[] = [
|
||||
{ type: "tears", Component: TearsEmoji },
|
||||
{ type: "blank", Component: BlankEmoji },
|
||||
{ type: "tongue", Component: TongueEmoji },
|
||||
{ type: "cry", Component: CryEmoji },
|
||||
{ type: "heartEye", Component: HeartEyeEmoji },
|
||||
{ type: "angry", Component: AngryEmoji },
|
||||
{ type: "moneyEye", Component: MoneyEyeEmoji },
|
||||
{ type: "sick", Component: SickEmoji },
|
||||
{ type: "upsideDown", Component: UpsideDownEmoji },
|
||||
{ type: "worried", Component: WorriedEmoji }
|
||||
];
|
||||
|
||||
export default function ReactionBar(props: ReactionBarProps) {
|
||||
const getReactionCount = (type: ReactionType) => {
|
||||
return props.reactions.filter((reaction) => reaction.type === type).length;
|
||||
};
|
||||
|
||||
const hasUserReacted = (type: ReactionType) => {
|
||||
return props.reactions.some(
|
||||
(reaction) =>
|
||||
reaction.type === type && reaction.user_id === props.currentUserID
|
||||
);
|
||||
};
|
||||
|
||||
const shouldShowEmoji = (type: ReactionType) => {
|
||||
return props.showingReactionOptions || getReactionCount(type) > 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
class={`${
|
||||
props.showingReactionOptions
|
||||
? "bg-zinc-50 px-2 py-4 shadow-inner dark:bg-zinc-700"
|
||||
: ""
|
||||
} fade-in scrollYDisabled ml-2 flex min-h-[1.5rem] w-48 max-w-[1/4] flex-row overflow-scroll rounded-md py-1 sm:w-56 md:w-fit md:overflow-hidden`}
|
||||
>
|
||||
<For each={EMOJI_CONFIG}>
|
||||
{({ type, Component }) => (
|
||||
<Show when={shouldShowEmoji(type)}>
|
||||
<div class="fade-in mx-1 flex">
|
||||
<div class={hasUserReacted(type) ? "text-green-500" : ""}>
|
||||
<Show when={getReactionCount(type) > 0}>
|
||||
{getReactionCount(type)}
|
||||
</Show>
|
||||
</div>
|
||||
<button
|
||||
class="h-6 w-6 pl-0.5"
|
||||
onClick={() => props.commentReaction(type, props.commentID)}
|
||||
>
|
||||
<Component />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,8 @@ export default function TagSelector(props: TagSelectorProps) {
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
buttonRef && menuRef &&
|
||||
buttonRef &&
|
||||
menuRef &&
|
||||
!buttonRef.contains(e.target as Node) &&
|
||||
!menuRef.contains(e.target as Node)
|
||||
) {
|
||||
@@ -30,7 +31,9 @@ export default function TagSelector(props: TagSelectorProps) {
|
||||
createEffect(() => {
|
||||
if (showingMenu()) {
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
onCleanup(() => document.removeEventListener("click", handleClickOutside));
|
||||
onCleanup(() =>
|
||||
document.removeEventListener("click", handleClickOutside)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -42,7 +45,9 @@ export default function TagSelector(props: TagSelectorProps) {
|
||||
if (isChecked) {
|
||||
const newFilters = searchParams.filter?.replace(filter + "|", "");
|
||||
if (newFilters && newFilters.length >= 1) {
|
||||
navigate(`${location.pathname}?sort=${currentSort()}&filter=${newFilters}`);
|
||||
navigate(
|
||||
`${location.pathname}?sort=${currentSort()}&filter=${newFilters}`
|
||||
);
|
||||
} else {
|
||||
navigate(`${location.pathname}?sort=${currentSort()}`);
|
||||
}
|
||||
@@ -50,9 +55,13 @@ export default function TagSelector(props: TagSelectorProps) {
|
||||
const currentFiltersStr = searchParams.filter;
|
||||
if (currentFiltersStr) {
|
||||
const newFilters = currentFiltersStr + filter + "|";
|
||||
navigate(`${location.pathname}?sort=${currentSort()}&filter=${newFilters}`);
|
||||
navigate(
|
||||
`${location.pathname}?sort=${currentSort()}&filter=${newFilters}`
|
||||
);
|
||||
} else {
|
||||
navigate(`${location.pathname}?sort=${currentSort()}&filter=${filter}|`);
|
||||
navigate(
|
||||
`${location.pathname}?sort=${currentSort()}&filter=${filter}|`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -65,16 +74,16 @@ export default function TagSelector(props: TagSelectorProps) {
|
||||
onClick={toggleMenu}
|
||||
class={`${
|
||||
props.category === "project"
|
||||
? "border-blue-500 bg-blue-400 hover:bg-blue-500 dark:border-blue-700 dark:bg-blue-700 dark:hover:bg-blue-800"
|
||||
: "border-orange-500 bg-orange-400 hover:bg-orange-500 dark:border-orange-600 dark:bg-orange-600 dark:hover:bg-orange-700"
|
||||
} mt-2 rounded border px-4 py-2 font-light text-white shadow-md transition-all duration-300 ease-in-out active:scale-90 md:mt-0`}
|
||||
? "border-blue bg-blue hover:brightness-125"
|
||||
: "border-peach bg-peach hover:brightness-125"
|
||||
} mt-2 rounded border px-4 py-2 text-base font-light shadow-md transition-all duration-300 ease-in-out active:scale-90 md:mt-0`}
|
||||
>
|
||||
Filters
|
||||
</button>
|
||||
<Show when={showingMenu()}>
|
||||
<div
|
||||
ref={menuRef}
|
||||
class="absolute z-50 mt-12 rounded-lg bg-zinc-100 py-2 pl-2 pr-4 shadow-lg dark:bg-zinc-900"
|
||||
class="bg-surface0 absolute z-50 mt-12 rounded-lg py-2 pr-4 pl-2 shadow-lg"
|
||||
>
|
||||
<For each={Object.entries(props.tagMap)}>
|
||||
{([key, value]) => (
|
||||
@@ -82,7 +91,9 @@ export default function TagSelector(props: TagSelectorProps) {
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!currentFilters().includes(key.slice(1))}
|
||||
onChange={(e) => handleCheck(key.slice(1), e.currentTarget.checked)}
|
||||
onChange={(e) =>
|
||||
handleCheck(key.slice(1), e.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
<div class="-mt-0.5 pl-1 text-sm font-normal">
|
||||
{`${key.slice(1)} (${value}) `}
|
||||
|
||||
Reference in New Issue
Block a user