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>
</>
);
}

View 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>
);
}

View 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>
);
}
}

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>

View 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>
);
}

View File

@@ -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}) `}