migration of comments
This commit is contained in:
@@ -9,7 +9,16 @@ export interface ErrorBoundaryFallbackProps {
|
||||
export default function ErrorBoundaryFallback(
|
||||
props: ErrorBoundaryFallbackProps
|
||||
) {
|
||||
const navigate = useNavigate();
|
||||
// Try to get navigate, but handle case where we're outside router context
|
||||
let navigate: ((path: string) => void) | undefined;
|
||||
try {
|
||||
navigate = useNavigate();
|
||||
} catch (e) {
|
||||
// If we're outside router context, fallback to window.location
|
||||
navigate = (path: string) => {
|
||||
window.location.href = path;
|
||||
};
|
||||
}
|
||||
const [glitchText, setGlitchText] = createSignal("ERROR");
|
||||
|
||||
createEffect(() => {
|
||||
|
||||
@@ -14,7 +14,7 @@ export function Typewriter(props: {
|
||||
const [isTyping, setIsTyping] = createSignal(false);
|
||||
const [isDelaying, setIsDelaying] = createSignal(delay > 0);
|
||||
const [keepAliveCountdown, setKeepAliveCountdown] = createSignal(
|
||||
typeof keepAlive === "number" ? keepAlive : -1,
|
||||
typeof keepAlive === "number" ? keepAlive : -1
|
||||
);
|
||||
const resolved = children(() => props.children);
|
||||
const { showSplash } = useSplash();
|
||||
@@ -33,7 +33,7 @@ export function Typewriter(props: {
|
||||
textNodes.push({
|
||||
node: node as Text,
|
||||
text: text,
|
||||
startIndex: totalChars,
|
||||
startIndex: totalChars
|
||||
});
|
||||
totalChars += text.length;
|
||||
|
||||
@@ -45,7 +45,7 @@ export function Typewriter(props: {
|
||||
charSpan.style.opacity = "0";
|
||||
charSpan.setAttribute(
|
||||
"data-char-index",
|
||||
String(totalChars - text.length + i),
|
||||
String(totalChars - text.length + i)
|
||||
);
|
||||
span.appendChild(charSpan);
|
||||
});
|
||||
@@ -60,7 +60,7 @@ export function Typewriter(props: {
|
||||
|
||||
// Position cursor at the first character location
|
||||
const firstChar = containerRef.querySelector(
|
||||
'[data-char-index="0"]',
|
||||
'[data-char-index="0"]'
|
||||
) as HTMLElement;
|
||||
if (firstChar && cursorRef) {
|
||||
// Insert cursor before the first character
|
||||
@@ -96,7 +96,7 @@ export function Typewriter(props: {
|
||||
const revealNextChar = () => {
|
||||
if (currentIndex < totalChars) {
|
||||
const charSpan = containerRef?.querySelector(
|
||||
`[data-char-index="${currentIndex}"]`,
|
||||
`[data-char-index="${currentIndex}"]`
|
||||
) as HTMLElement;
|
||||
|
||||
if (charSpan) {
|
||||
@@ -106,7 +106,7 @@ export function Typewriter(props: {
|
||||
if (cursorRef) {
|
||||
charSpan.parentNode?.insertBefore(
|
||||
cursorRef,
|
||||
charSpan.nextSibling,
|
||||
charSpan.nextSibling
|
||||
);
|
||||
|
||||
// Match the height of the current character
|
||||
|
||||
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}) `}
|
||||
|
||||
26
src/components/icons/emojis/Angry.tsx
Normal file
26
src/components/icons/emojis/Angry.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
export default function Angry() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<defs>
|
||||
<style>{`.a{fill:#e7253e;}.b{fill:#b51f36;}.c{fill:#90192d;}`}</style>
|
||||
</defs>
|
||||
<rect class="a" x="1" y="1" width="22" height="22" rx="7.656" />
|
||||
<path
|
||||
class="b"
|
||||
d="M23,13.938a14.69,14.69,0,0,1-12.406,6.531c-5.542,0-6.563-1-9.142-2.529A7.66,7.66,0,0,0,8.656,23h6.688A7.656,7.656,0,0,0,23,15.344Z"
|
||||
/>
|
||||
<path
|
||||
class="c"
|
||||
d="M9.565,8.513A16.121,16.121,0,0,1,5.63,6.328a.52.52,0,0,0-.832.4c-.048,1.583.151,4.483,2.258,4.483A2.6,2.6,0,0,0,9.9,9.1.517.517,0,0,0,9.565,8.513Z"
|
||||
/>
|
||||
<path
|
||||
class="c"
|
||||
d="M19.2,6.731a.521.521,0,0,0-.832-.4,16.121,16.121,0,0,1-3.935,2.185A.517.517,0,0,0,14.1,9.1a2.6,2.6,0,0,0,2.84,2.11C19.051,11.214,19.25,8.314,19.2,6.731Z"
|
||||
/>
|
||||
<path
|
||||
class="c"
|
||||
d="M14,13.937a.318.318,0,0,1-.313-.326,2.105,2.105,0,0,0-.5-1.331A1.6,1.6,0,0,0,12,11.847a1.6,1.6,0,0,0-1.187.43,2.092,2.092,0,0,0-.5,1.335.32.32,0,0,1-.64.012,2.715,2.715,0,0,1,.679-1.792A2.211,2.211,0,0,1,12,11.207a2.211,2.211,0,0,1,1.647.625,2.721,2.721,0,0,1,.679,1.792A.321.321,0,0,1,14,13.937Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
22
src/components/icons/emojis/Blank.tsx
Normal file
22
src/components/icons/emojis/Blank.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
export default function Blank() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<defs>
|
||||
<style>{`.a{fill:#f8de40;}.b{fill:#864e20;}.c{fill:#e7c930;}`}</style>
|
||||
</defs>
|
||||
<rect class="a" x="1" y="1" width="22" height="22" rx="7.656" />
|
||||
<path
|
||||
class="b"
|
||||
d="M7.055,7.313A1.747,1.747,0,1,0,8.8,9.059,1.747,1.747,0,0,0,7.055,7.313Z"
|
||||
/>
|
||||
<path
|
||||
class="b"
|
||||
d="M16.958,7.313A1.747,1.747,0,1,0,18.7,9.059,1.747,1.747,0,0,0,16.958,7.313Z"
|
||||
/>
|
||||
<path
|
||||
class="c"
|
||||
d="M23,13.938a14.688,14.688,0,0,1-12.406,6.531c-5.542,0-6.563-1-9.142-2.529A7.66,7.66,0,0,0,8.656,23h6.688A7.656,7.656,0,0,0,23,15.344Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
38
src/components/icons/emojis/Cry.tsx
Normal file
38
src/components/icons/emojis/Cry.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
export default function Cry() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<defs>
|
||||
<style>{`.a{fill:#f8de40;}.b{fill:#e7c930;}.c{fill:#864e20;}.d{fill:#26a9e0;}`}</style>
|
||||
</defs>
|
||||
<rect class="a" x="1" y="1" width="22" height="22" rx="7.656" />
|
||||
<path
|
||||
class="b"
|
||||
d="M23,13.938a14.688,14.688,0,0,1-12.406,6.531c-5.542,0-6.563-1-9.142-2.529A7.66,7.66,0,0,0,8.656,23h6.688A7.656,7.656,0,0,0,23,15.344Z"
|
||||
/>
|
||||
<path
|
||||
class="c"
|
||||
d="M17.2,12.746c-1.221-1.647-3.789-2.231-5.2-2.231s-3.984.584-5.2,2.231-.188,3.4,1.128,3.293,3.546-.364,4.077-.364,2.762.258,4.077.364S18.427,14.392,17.2,12.746Z"
|
||||
/>
|
||||
<path
|
||||
class="b"
|
||||
d="M14.505,17.022A12.492,12.492,0,0,0,12,16.638a12.457,12.457,0,0,0-2.5.384c-.376.076-.39.384,0,.332s2.5-.166,2.5-.166,2.119.115,2.505.166S14.88,17.1,14.505,17.022Z"
|
||||
/>
|
||||
<path
|
||||
class="c"
|
||||
d="M8.907,9.844a.182.182,0,0,1-.331.1,2.016,2.016,0,0,0-.569-.567,1.731,1.731,0,0,0-1.915,0,2.016,2.016,0,0,0-.571.569.182.182,0,0,1-.331-.1,1.632,1.632,0,0,1,.346-1.023,1.927,1.927,0,0,1,3.026,0A1.64,1.64,0,0,1,8.907,9.844Z"
|
||||
/>
|
||||
<path
|
||||
class="c"
|
||||
d="M18.81,9.844a.182.182,0,0,1-.331.1,2.026,2.026,0,0,0-.568-.567,1.732,1.732,0,0,0-1.916,0,2.016,2.016,0,0,0-.571.569.182.182,0,0,1-.331-.1,1.632,1.632,0,0,1,.346-1.023,1.927,1.927,0,0,1,3.026,0A1.64,1.64,0,0,1,18.81,9.844Z"
|
||||
/>
|
||||
<path
|
||||
class="d"
|
||||
d="M8.576,9.946a2.016,2.016,0,0,0-.569-.567,1.731,1.731,0,0,0-1.915,0,2.016,2.016,0,0,0-.571.569.175.175,0,0,1-.214.063v11.24A1.747,1.747,0,0,0,7.054,23h0A1.748,1.748,0,0,0,8.8,21.253V10.005A.176.176,0,0,1,8.576,9.946Z"
|
||||
/>
|
||||
<path
|
||||
class="d"
|
||||
d="M18.473,9.946a2.026,2.026,0,0,0-.568-.567,1.732,1.732,0,0,0-1.916,0,2.016,2.016,0,0,0-.571.569.175.175,0,0,1-.214.063v11.24A1.748,1.748,0,0,0,16.952,23h0A1.747,1.747,0,0,0,18.7,21.253V10.005A.176.176,0,0,1,18.473,9.946Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
30
src/components/icons/emojis/HeartEye.tsx
Normal file
30
src/components/icons/emojis/HeartEye.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
export default function HeartEye() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<defs>
|
||||
<style>{`.a{fill:#f8de40;}.b{fill:#e7c930;}.c{fill:#f06880;}.d{fill:#864e20;}`}</style>
|
||||
</defs>
|
||||
<rect class="a" x="1" y="1" width="22" height="22" rx="7.656" />
|
||||
<path
|
||||
class="b"
|
||||
d="M23,13.938a14.69,14.69,0,0,1-12.406,6.531c-5.542,0-6.563-1-9.142-2.529A7.66,7.66,0,0,0,8.656,23h6.688A7.656,7.656,0,0,0,23,15.344Z"
|
||||
/>
|
||||
<path
|
||||
class="c"
|
||||
d="M9.58,6.983A1.528,1.528,0,0,0,7.5,7.1l-.449.45L6.6,7.1a1.529,1.529,0,0,0-2.083-.113,1.472,1.472,0,0,0-.058,2.136L6.68,11.34a.518.518,0,0,0,.737,0l2.22-2.221A1.471,1.471,0,0,0,9.58,6.983Z"
|
||||
/>
|
||||
<path
|
||||
class="c"
|
||||
d="M19.483,6.983A1.528,1.528,0,0,0,17.4,7.1l-.449.45L16.5,7.1a1.529,1.529,0,0,0-2.083-.113,1.471,1.471,0,0,0-.057,2.136l2.221,2.221a.517.517,0,0,0,.736,0l2.221-2.221A1.472,1.472,0,0,0,19.483,6.983Z"
|
||||
/>
|
||||
<path
|
||||
class="d"
|
||||
d="M16.666,12.583H7.334a.493.493,0,0,0-.492.544c.123,1.175.875,3.842,5.158,3.842s5.035-2.667,5.158-3.842A.493.493,0,0,0,16.666,12.583Z"
|
||||
/>
|
||||
<path
|
||||
class="c"
|
||||
d="M12,16.969a6.538,6.538,0,0,0,2.959-.6,1.979,1.979,0,0,0-1.209-.853c-1.344-.3-1.75.109-1.75.109s-.406-.406-1.75-.109a1.979,1.979,0,0,0-1.209.853A6.538,6.538,0,0,0,12,16.969Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
30
src/components/icons/emojis/MoneyEye.tsx
Normal file
30
src/components/icons/emojis/MoneyEye.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
export default function MoneyEye() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<defs>
|
||||
<style>{`.a{fill:#f8de40;}.b{fill:#e7c930;}.c{fill:#864e20;}.d{fill:#f06880;}.e{fill:#009345;}`}</style>
|
||||
</defs>
|
||||
<rect class="a" x="1" y="1" width="22" height="22" rx="7.656" />
|
||||
<path
|
||||
class="b"
|
||||
d="M23,13.938a14.69,14.69,0,0,1-12.406,6.531c-5.542,0-6.563-1-9.142-2.529A7.66,7.66,0,0,0,8.656,23h6.688A7.656,7.656,0,0,0,23,15.344Z"
|
||||
/>
|
||||
<path
|
||||
class="c"
|
||||
d="M16.666,12.583H7.334a.493.493,0,0,0-.492.544c.123,1.175.875,3.842,5.158,3.842s5.035-2.667,5.158-3.842A.493.493,0,0,0,16.666,12.583Z"
|
||||
/>
|
||||
<path
|
||||
class="d"
|
||||
d="M12,16.969a6.538,6.538,0,0,0,2.959-.6,1.979,1.979,0,0,0-1.209-.853c-1.344-.3-1.75.109-1.75.109s-.406-.406-1.75-.109a1.979,1.979,0,0,0-1.209.853A6.538,6.538,0,0,0,12,16.969Z"
|
||||
/>
|
||||
<path
|
||||
class="e"
|
||||
d="M8.82,8.767h0a1.18,1.18,0,0,0-.378-.414A1.946,1.946,0,0,0,7.906,8.1,4.37,4.37,0,0,0,7.462,8a.094.094,0,0,1-.079-.09V6.692a.1.1,0,0,1,.036-.074A.081.081,0,0,1,7.488,6.6a.882.882,0,0,1,.375.173.579.579,0,0,1,.189.385.344.344,0,0,0,.337.3H8.5a.342.342,0,0,0,.256-.116.336.336,0,0,0,.084-.262,1.536,1.536,0,0,0-.1-.407A1.22,1.22,0,0,0,8.4,6.208a1.457,1.457,0,0,0-.5-.273,2.3,2.3,0,0,0-.44-.092.09.09,0,0,1-.077-.089V5.508a.341.341,0,0,0-.682,0v.248a.094.094,0,0,1-.086.091,1.848,1.848,0,0,0-.975.381,1.251,1.251,0,0,0-.415,1.024,1.365,1.365,0,0,0,.146.624,1.185,1.185,0,0,0,.364.409,1.711,1.711,0,0,0,.506.238c.123.036.253.069.385.1a.091.091,0,0,1,.075.088V9.944a.092.092,0,0,1-.034.072.088.088,0,0,1-.073.019,1.089,1.089,0,0,1-.45-.189.575.575,0,0,1-.209-.39A.339.339,0,0,0,5.6,9.163H5.482a.342.342,0,0,0-.34.383,1.254,1.254,0,0,0,.426.853,1.934,1.934,0,0,0,1.056.385.092.092,0,0,1,.077.093v.234a.341.341,0,0,0,.682,0v-.246a.094.094,0,0,1,.088-.091,2.069,2.069,0,0,0,1.02-.358,1.208,1.208,0,0,0,.467-1.03A1.29,1.29,0,0,0,8.82,8.767Zm-.659.611a.663.663,0,0,1-.064.306.515.515,0,0,1-.175.2.941.941,0,0,1-.287.125c-.048.013-.1.023-.148.032a.084.084,0,0,1-.07-.019.1.1,0,0,1-.034-.072V8.855a.091.091,0,0,1,.034-.07.093.093,0,0,1,.059-.021l.02,0,.125.028a.1.1,0,0,1,.076.084v.024a.092.092,0,0,1-.023.075.1.1,0,0,1-.073.031c-.094,0-.139-.036-.159-.055a.125.125,0,0,1-.033-.073.074.074,0,0,0-.073-.071H7.17a.073.073,0,0,0-.073.081.272.272,0,0,0,.091.2.449.449,0,0,0,.315.089.271.271,0,0,0,.284-.257.266.266,0,0,0-.076-.2A.254.254,0,0,0,7.534,8.7C7.519,8.7,7.5,8.7,7.488,8.7a.1.1,0,0,1-.066-.026.091.091,0,0,1-.029-.062V8.545a.091.091,0,0,1,.095-.092c.185,0,.244.085.244.144a.072.072,0,0,0,.072.073h.17a.072.072,0,0,0,.072-.082A.331.331,0,0,0,8,8.4a.575.575,0,0,0-.364-.153.1.1,0,0,1-.09-.095V7.92a.092.092,0,0,1,.027-.068.085.085,0,0,1,.063-.022.6.6,0,0,1,.217.084.361.361,0,0,1,.123.22.073.073,0,0,0,.073.063h.057a.074.074,0,0,0,.053-.025.072.072,0,0,0,.018-.056.482.482,0,0,0-.023-.115.527.527,0,0,0-.19-.281.662.662,0,0,0-.277-.12.1.1,0,0,1-.077-.093V7.3a.072.072,0,0,0-.145,0v.206a.1.1,0,0,1-.083.094.847.847,0,0,0-.506.214.62.62,0,0,0-.206.505.676.676,0,0,0,.073.31.585.585,0,0,0,.181.2.892.892,0,0,0,.261.124l.2.052a.091.091,0,0,1,.075.087v.148a.1.1,0,0,1-.027.068.092.092,0,0,1-.064.019.5.5,0,0,1-.247-.1.349.349,0,0,1-.131-.22.072.072,0,0,0-.071-.06H6.2a.073.073,0,0,0-.072.081.625.625,0,0,0,.212.426.975.975,0,0,0,.537.2.1.1,0,0,1,.086.1V10.2a.073.073,0,0,0,.145,0V10a.093.093,0,0,1,.082-.094,1.036,1.036,0,0,0,.517-.188.6.6,0,0,0,.232-.512A.645.645,0,0,0,8.161,9.378Z"
|
||||
/>
|
||||
<path
|
||||
class="e"
|
||||
d="M18.679,8.767h0a1.18,1.18,0,0,0-.378-.414A1.946,1.946,0,0,0,17.765,8.1,4.37,4.37,0,0,0,17.321,8a.094.094,0,0,1-.079-.09V6.692a.1.1,0,0,1,.036-.074.081.081,0,0,1,.069-.018.882.882,0,0,1,.375.173.579.579,0,0,1,.189.385.344.344,0,0,0,.337.3h.111a.342.342,0,0,0,.256-.116.336.336,0,0,0,.084-.262,1.536,1.536,0,0,0-.1-.407,1.22,1.22,0,0,0-.344-.465,1.457,1.457,0,0,0-.5-.273,2.3,2.3,0,0,0-.44-.092.09.09,0,0,1-.077-.089V5.508a.341.341,0,0,0-.682,0v.248a.094.094,0,0,1-.086.091,1.848,1.848,0,0,0-.975.381,1.251,1.251,0,0,0-.415,1.024,1.365,1.365,0,0,0,.146.624,1.185,1.185,0,0,0,.364.409,1.711,1.711,0,0,0,.506.238c.123.036.253.069.385.1a.091.091,0,0,1,.075.088V9.944a.092.092,0,0,1-.034.072.088.088,0,0,1-.073.019,1.089,1.089,0,0,1-.45-.189.575.575,0,0,1-.209-.39.339.339,0,0,0-.333-.293h-.121a.342.342,0,0,0-.34.383,1.254,1.254,0,0,0,.426.853,1.934,1.934,0,0,0,1.056.385.092.092,0,0,1,.077.093v.234a.341.341,0,0,0,.682,0v-.246a.094.094,0,0,1,.088-.091,2.069,2.069,0,0,0,1.02-.358,1.208,1.208,0,0,0,.467-1.03A1.29,1.29,0,0,0,18.679,8.767Zm-.659.611a.663.663,0,0,1-.064.306.515.515,0,0,1-.175.2.941.941,0,0,1-.287.125c-.048.013-.1.023-.148.032a.084.084,0,0,1-.07-.019.1.1,0,0,1-.034-.072V8.855a.091.091,0,0,1,.034-.07.093.093,0,0,1,.059-.021l.02,0,.125.028a.1.1,0,0,1,.076.084v.024a.092.092,0,0,1-.023.075.1.1,0,0,1-.073.031c-.094,0-.139-.036-.159-.055a.125.125,0,0,1-.033-.073.074.074,0,0,0-.073-.071h-.057a.073.073,0,0,0-.073.081.272.272,0,0,0,.091.2.449.449,0,0,0,.315.089.271.271,0,0,0,.284-.257.266.266,0,0,0-.076-.2.254.254,0,0,0-.177-.074c-.015,0-.033,0-.045,0a.1.1,0,0,1-.066-.026.091.091,0,0,1-.029-.062V8.545a.091.091,0,0,1,.095-.092c.185,0,.244.085.244.144a.072.072,0,0,0,.072.073h.17a.072.072,0,0,0,.072-.082.331.331,0,0,0-.045-.188.575.575,0,0,0-.364-.153.1.1,0,0,1-.09-.095V7.92a.092.092,0,0,1,.027-.068.085.085,0,0,1,.063-.022.6.6,0,0,1,.217.084.361.361,0,0,1,.123.22.073.073,0,0,0,.073.063h.057a.074.074,0,0,0,.053-.025.072.072,0,0,0,.018-.056.482.482,0,0,0-.023-.115.527.527,0,0,0-.19-.281.662.662,0,0,0-.277-.12.1.1,0,0,1-.077-.093V7.3a.072.072,0,0,0-.145,0v.206a.1.1,0,0,1-.083.094.847.847,0,0,0-.506.214.62.62,0,0,0-.206.505.676.676,0,0,0,.073.31.585.585,0,0,0,.181.2.892.892,0,0,0,.261.124l.2.052a.091.091,0,0,1,.075.087v.148a.1.1,0,0,1-.027.068.092.092,0,0,1-.064.019.5.5,0,0,1-.247-.1.349.349,0,0,1-.131-.22.072.072,0,0,0-.071-.06h-.057a.073.073,0,0,0-.072.081.625.625,0,0,0,.212.426.975.975,0,0,0,.537.2.1.1,0,0,1,.086.1V10.2a.073.073,0,0,0,.145,0V10a.093.093,0,0,1,.082-.094,1.036,1.036,0,0,0,.517-.188.6.6,0,0,0,.232-.512A.645.645,0,0,0,18.02,9.378Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
26
src/components/icons/emojis/Sick.tsx
Normal file
26
src/components/icons/emojis/Sick.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
export default function Sick() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<defs>
|
||||
<style>{`.a{fill:#009345;}.b{fill:#017b3f;}.c{fill:#08512a;}`}</style>
|
||||
</defs>
|
||||
<rect class="a" x="1" y="1" width="22" height="22" rx="7.656" />
|
||||
<path
|
||||
class="b"
|
||||
d="M23,13.938a14.69,14.69,0,0,1-12.406,6.531c-5.542,0-6.563-1-9.142-2.529A7.66,7.66,0,0,0,8.656,23h6.688A7.656,7.656,0,0,0,23,15.344Z"
|
||||
/>
|
||||
<path
|
||||
class="c"
|
||||
d="M9.6,8.833,9.021,8.6c-.35-.144-.7-.283-1.058-.412s-.716-.251-1.08-.362-.731-.212-1.1-.3l-.012,0a.246.246,0,0,0-.186.448l.01.006c.325.2.656.392.991.573q.281.15.564.291a.245.245,0,0,1,0,.439q-.285.141-.564.292c-.335.18-.667.369-.992.573l-.016.01a.246.246,0,0,0,.187.447l.018,0c.374-.088.741-.19,1.105-.3s.723-.234,1.079-.362c.179-.064.355-.134.532-.2l.526-.213.573-.232A.246.246,0,0,0,9.6,8.833Z"
|
||||
/>
|
||||
<path
|
||||
class="c"
|
||||
d="M14.405,8.833l.574-.235c.35-.144.7-.283,1.058-.412s.716-.251,1.08-.362.731-.212,1.1-.3l.012,0a.246.246,0,0,1,.186.448l-.01.006c-.325.2-.656.392-.991.573q-.28.15-.564.291a.245.245,0,0,0,0,.439q.285.141.564.292c.335.18.667.369.992.573l.016.01a.246.246,0,0,1-.187.447l-.018,0c-.374-.088-.741-.19-1.105-.3s-.723-.234-1.079-.362c-.179-.064-.355-.134-.532-.2l-.526-.213-.573-.232A.246.246,0,0,1,14.405,8.833Z"
|
||||
/>
|
||||
<path
|
||||
class="c"
|
||||
d="M17.051,13.32c-.139-.122-.276-.247-.418-.366-.28-.241-.566-.476-.852-.708h0a.216.216,0,0,0-.245-.019l-.034.019c-.319.185-.636.372-.951.564-.2.119-.394.241-.59.364a.218.218,0,0,1-.243-.009l-.134-.1c-.149-.109-.3-.214-.449-.322-.3-.214-.6-.422-.9-.632l-.106-.074a.217.217,0,0,0-.248,0l-.106.074c-.3.21-.606.417-.9.632-.15.107-.3.212-.449.321l-.134.1a.218.218,0,0,1-.243.008c-.2-.122-.391-.244-.589-.363-.157-.1-.316-.19-.474-.285s-.319-.186-.478-.279l-.033-.019a.214.214,0,0,0-.245.019h0c-.287.232-.572.467-.853.709-.141.119-.278.244-.418.366s-.275.249-.41.377c.168-.08.335-.161.5-.247s.33-.169.492-.258c.228-.121.453-.247.677-.374a.217.217,0,0,1,.242.02l.2.16c.145.113.289.228.436.339.291.225.587.446.882.665l.074.055a.215.215,0,0,0,.246.008l.1-.063.464-.3c.155-.1.307-.2.461-.3.19-.124.371-.262.558-.394l.006,0a.216.216,0,0,1,.245-.019l.034.018c.158.093.318.184.476.279s.318.186.476.283c.2.121.4.243.595.368a.218.218,0,0,0,.243-.006l.134-.1c.149-.109.3-.214.449-.322.3-.214.6-.422.9-.632l.106-.074a.217.217,0,0,0,.248,0l.106.074c.3.21.606.417.9.632.15.107.3.212.449.321l.134.1a.219.219,0,0,0,.243.006c.2-.125.4-.246.6-.368.158-.1.316-.19.474-.283s.318-.186.476-.279l.034-.018a.216.216,0,0,1,.245.019l.006,0c.187.132.368.27.558.394.154.1.306.2.461.3l.464.3.1.063a.215.215,0,0,0,.246-.008l.074-.055c.295-.219.591-.44.882-.665.147-.111.291-.226.436-.339l.2-.16a.217.217,0,0,1,.242-.02c.224.127.449.253.677.374.162.089.329.173.492.258s.335.167.5.247C17.326,13.569,17.19,13.444,17.051,13.32Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
40
src/components/icons/emojis/Tears.tsx
Normal file
40
src/components/icons/emojis/Tears.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
export default function Tears() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<defs>
|
||||
<style>
|
||||
{`.a{fill:#f8de40;}.b{fill:#864e20;}.c{fill:#e7c930;}.d{fill:#f06880;}.e{fill:#26a9e0;}`}
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="a" x="1" y="1" width="22" height="22" rx="7.656" />
|
||||
<path
|
||||
class="b"
|
||||
d="M8.907,9.844a.182.182,0,0,1-.331.1,2.016,2.016,0,0,0-.569-.567,1.731,1.731,0,0,0-1.915,0,2.016,2.016,0,0,0-.571.569.182.182,0,0,1-.331-.1,1.632,1.632,0,0,1,.346-1.023,1.927,1.927,0,0,1,3.026,0A1.64,1.64,0,0,1,8.907,9.844Z"
|
||||
/>
|
||||
<path
|
||||
class="b"
|
||||
d="M18.81,9.844a.182.182,0,0,1-.331.1,2.026,2.026,0,0,0-.568-.567,1.732,1.732,0,0,0-1.916,0,2.016,2.016,0,0,0-.571.569.182.182,0,0,1-.331-.1,1.632,1.632,0,0,1,.346-1.023,1.927,1.927,0,0,1,3.026,0A1.64,1.64,0,0,1,18.81,9.844Z"
|
||||
/>
|
||||
<path
|
||||
class="c"
|
||||
d="M23,13.938a14.688,14.688,0,0,1-12.406,6.531c-5.542,0-6.563-1-9.142-2.529A7.66,7.66,0,0,0,8.656,23h6.688A7.656,7.656,0,0,0,23,15.344Z"
|
||||
/>
|
||||
<path
|
||||
class="b"
|
||||
d="M16.666,12.583H7.334a.493.493,0,0,0-.492.544c.123,1.175.875,3.842,5.158,3.842s5.035-2.667,5.158-3.842A.493.493,0,0,0,16.666,12.583Z"
|
||||
/>
|
||||
<path
|
||||
class="d"
|
||||
d="M12,16.969a6.538,6.538,0,0,0,2.959-.6,1.979,1.979,0,0,0-1.209-.853c-1.344-.3-1.75.109-1.75.109s-.406-.406-1.75-.109a1.979,1.979,0,0,0-1.209.853A6.538,6.538,0,0,0,12,16.969Z"
|
||||
/>
|
||||
<path
|
||||
class="e"
|
||||
d="M5.117,15.682a6.6,6.6,0,0,0,1.311-4.356,6.6,6.6,0,0,0-4.357,1.31c-1.77,1.523-.92,3.011-.442,3.489S3.594,17.453,5.117,15.682Z"
|
||||
/>
|
||||
<path
|
||||
class="e"
|
||||
d="M18.883,15.682a6.6,6.6,0,0,1-1.311-4.356,6.6,6.6,0,0,1,4.357,1.31c1.77,1.523.92,3.011.442,3.489S20.406,17.453,18.883,15.682Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
17
src/components/icons/emojis/ThumbsUp.tsx
Normal file
17
src/components/icons/emojis/ThumbsUp.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
export default function ThumbsUp() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<style>{`.fa-secondary{opacity:.4}`}</style>
|
||||
</defs>
|
||||
<path
|
||||
d="M512 224.112C512 197.608 490.516 176.133 464 176.133H317.482C340.25 138.226 352.005 95.257 352.005 80.11C352.005 56.523 333.495 32 302.54 32C239.411 32 276.176 108.148 194.312 173.618L178.016 186.644C166.23 196.06 160.285 209.903 160.215 223.897C160.191 223.921 160 224.112 160 224.112V384.042C160 399.146 167.113 413.368 179.198 422.427L213.336 448.02C241.027 468.779 274.702 480 309.309 480H368C394.516 480 416 458.525 416 432.021C416 428.386 415.52 424.878 414.754 421.475C434 415.228 448 397.37 448 376.045C448 366.897 445.303 358.438 440.861 351.164C463.131 347.002 480 327.547 480 304.077C480 291.577 475.107 280.298 467.275 271.761C492.234 270.051 512 249.495 512 224.112Z"
|
||||
class="fa-secondary"
|
||||
/>
|
||||
<path
|
||||
d="M128 448V224C128 206.328 113.674 192 96 192H32C14.326 192 0 206.328 0 224V448C0 465.674 14.326 480 32 480H96C113.674 480 128 465.674 128 448Z"
|
||||
class="fa-primary"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
34
src/components/icons/emojis/Tongue.tsx
Normal file
34
src/components/icons/emojis/Tongue.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
export default function Tongue() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<defs>
|
||||
<style>{`.a{fill:#f8de40;}.b{fill:#e7c930;}.c{fill:#864e20;}.d{fill:#f06880;}.e{fill:#cd4b68;}`}</style>
|
||||
</defs>
|
||||
<rect class="a" x="1" y="1" width="22" height="22" rx="7.656" />
|
||||
<path
|
||||
class="b"
|
||||
d="M23,13.938a14.69,14.69,0,0,1-12.406,6.531c-5.542,0-6.563-1-9.142-2.529A7.66,7.66,0,0,0,8.656,23h6.688A7.656,7.656,0,0,0,23,15.344Z"
|
||||
/>
|
||||
<path
|
||||
class="c"
|
||||
d="M16.666,12.583H7.334a.493.493,0,0,0-.492.544c.123,1.175.875,3.842,5.158,3.842s5.035-2.667,5.158-3.842A.493.493,0,0,0,16.666,12.583Z"
|
||||
/>
|
||||
<path
|
||||
class="d"
|
||||
d="M15.405,15.136a2.463,2.463,0,0,0-1.655-1.87c-1.344-.3-1.75.109-1.75.109s-.406-.406-1.75-.109A2.463,2.463,0,0,0,8.6,15.136a8.449,8.449,0,0,0,0,2.723c.264,1.172,1.061,1.61,3.4,1.61s3.141-.438,3.4-1.61A8.449,8.449,0,0,0,15.405,15.136Z"
|
||||
/>
|
||||
<path
|
||||
class="e"
|
||||
d="M12.188,15.688a5.582,5.582,0,0,1,.624-2.529,1.228,1.228,0,0,0-.812.216,1.228,1.228,0,0,0-.812-.216,5.582,5.582,0,0,1,.624,2.529C11.771,17,12,17,12,17S12.229,17,12.188,15.688Z"
|
||||
/>
|
||||
<path
|
||||
class="c"
|
||||
d="M9.6,8.833,9.021,8.6c-.35-.144-.7-.283-1.058-.412s-.716-.251-1.08-.362-.731-.212-1.1-.3l-.012,0a.246.246,0,0,0-.186.448l.01.006c.325.2.656.392.991.573q.281.15.564.291a.245.245,0,0,1,0,.439q-.285.141-.564.292c-.335.18-.667.369-.992.573l-.016.01a.246.246,0,0,0,.187.447l.018,0c.374-.088.741-.19,1.105-.3s.723-.234,1.079-.362c.179-.064.355-.134.532-.2l.526-.213.573-.232A.246.246,0,0,0,9.6,8.833Z"
|
||||
/>
|
||||
<path
|
||||
class="c"
|
||||
d="M14.405,8.833l.574-.235c.35-.144.7-.283,1.058-.412s.716-.251,1.08-.362.731-.212,1.1-.3l.012,0a.246.246,0,0,1,.186.448l-.01.006c-.325.2-.656.392-.991.573q-.28.15-.564.291a.245.245,0,0,0,0,.439q.285.141.564.292c.335.18.667.369.992.573l.016.01a.246.246,0,0,1-.187.447l-.018,0c-.374-.088-.741-.19-1.105-.3s-.723-.234-1.079-.362c-.179-.064-.355-.134-.532-.2l-.526-.213-.573-.232A.246.246,0,0,1,14.405,8.833Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
26
src/components/icons/emojis/UpsideDown.tsx
Normal file
26
src/components/icons/emojis/UpsideDown.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
export default function UpsideDown() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<defs>
|
||||
<style>{`.a{fill:#f8de40;}.b{fill:#864e20;}.c{fill:#e7c930;}`}</style>
|
||||
</defs>
|
||||
<rect class="a" x="1" y="1" width="22" height="22" rx="7.656" />
|
||||
<path
|
||||
class="b"
|
||||
d="M7.055,16.688A1.747,1.747,0,1,1,8.8,14.941,1.748,1.748,0,0,1,7.055,16.688Z"
|
||||
/>
|
||||
<path
|
||||
class="b"
|
||||
d="M16.958,16.688A1.747,1.747,0,1,1,18.7,14.941,1.748,1.748,0,0,1,16.958,16.688Z"
|
||||
/>
|
||||
<path
|
||||
class="b"
|
||||
d="M14,12.793a.32.32,0,0,1-.313-.327,2.1,2.1,0,0,0-.5-1.33A1.593,1.593,0,0,0,12,10.7a1.6,1.6,0,0,0-1.187.43,2.088,2.088,0,0,0-.5,1.334.32.32,0,1,1-.64.012,2.712,2.712,0,0,1,.679-1.791A2.211,2.211,0,0,1,12,10.063a2.211,2.211,0,0,1,1.647.625,2.718,2.718,0,0,1,.679,1.791A.322.322,0,0,1,14,12.793Z"
|
||||
/>
|
||||
<path
|
||||
class="c"
|
||||
d="M23,13.938a14.69,14.69,0,0,1-12.406,6.531c-5.542,0-6.563-1-9.142-2.529A7.66,7.66,0,0,0,8.656,23h6.688A7.656,7.656,0,0,0,23,15.344Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
42
src/components/icons/emojis/Worried.tsx
Normal file
42
src/components/icons/emojis/Worried.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
export default function Worried() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<defs>
|
||||
<style>{`.a{fill:#f8de40;}.b{fill:#864e20;}.c{fill:#e7c930;}.d{fill:#f06880;}`}</style>
|
||||
</defs>
|
||||
<rect class="a" x="1" y="1" width="22" height="22" rx="7.656" />
|
||||
<path
|
||||
class="b"
|
||||
d="M7.055,7.313A1.747,1.747,0,1,0,8.8,9.059,1.747,1.747,0,0,0,7.055,7.313Z"
|
||||
/>
|
||||
<path
|
||||
class="b"
|
||||
d="M16.958,7.313A1.747,1.747,0,1,0,18.7,9.059,1.747,1.747,0,0,0,16.958,7.313Z"
|
||||
/>
|
||||
<path
|
||||
class="c"
|
||||
d="M23,13.938a14.69,14.69,0,0,1-12.406,6.531c-5.542,0-6.563-1-9.142-2.529A7.66,7.66,0,0,0,8.656,23h6.688A7.656,7.656,0,0,0,23,15.344Z"
|
||||
/>
|
||||
<path
|
||||
class="b"
|
||||
d="M16.083,12.556A5.487,5.487,0,0,0,12,10.806a5.487,5.487,0,0,0-4.083,1.75c-.959,1.292-.147,2.667.885,2.583s2.781-.285,3.2-.285,2.167.2,3.2.285S17.042,13.848,16.083,12.556Z"
|
||||
/>
|
||||
<path
|
||||
class="d"
|
||||
d="M13.75,13.266c-1.344-.3-1.75.109-1.75.109s-.406-.406-1.75-.109A2.463,2.463,0,0,0,8.6,15.136a1.1,1.1,0,0,0,.207,0c1.031-.083,2.781-.285,3.2-.285s2.167.2,3.2.285a1.1,1.1,0,0,0,.207,0A2.463,2.463,0,0,0,13.75,13.266Z"
|
||||
/>
|
||||
<path
|
||||
class="c"
|
||||
d="M13.965,15.91a9.842,9.842,0,0,0-1.965-.3,9.842,9.842,0,0,0-1.965.3c-.294.061-.3.3,0,.261S12,16.041,12,16.041s1.663.09,1.965.13S14.259,15.971,13.965,15.91Z"
|
||||
/>
|
||||
<path
|
||||
class="b"
|
||||
d="M19.686,6.658l0,0a2.954,2.954,0,0,0-.228-.385,4.467,4.467,0,0,0-.576-.675c-.108-.1-.217-.205-.332-.3s-.242-.174-.364-.26A3.4,3.4,0,0,0,17.8,4.8c-.134-.066-.263-.143-.4-.2a4.857,4.857,0,0,0-1.743-.4,3.732,3.732,0,0,0-1.334.177.174.174,0,0,0,.007.327c.406.139.784.271,1.157.41.494.184.973.367,1.442.576.121.043.233.107.351.158l.178.076c.06.025.114.059.174.085.116.054.23.112.35.161l.011,0c.114.06.229.119.348.175.247.105.476.244.735.355.128.06.254.124.386.186A.173.173,0,0,0,19.686,6.658Z"
|
||||
/>
|
||||
<path
|
||||
class="b"
|
||||
d="M9.691,4.38A3.729,3.729,0,0,0,8.357,4.2a4.862,4.862,0,0,0-1.743.4c-.139.055-.269.132-.4.2a3.4,3.4,0,0,0-.384.231c-.122.086-.246.169-.363.26s-.224.2-.332.3a4.474,4.474,0,0,0-.577.675,2.948,2.948,0,0,0-.227.385l0,0a.173.173,0,0,0,.227.236c.131-.062.258-.126.386-.186.259-.111.487-.25.734-.355.119-.056.235-.115.349-.175l.011,0c.119-.049.233-.107.349-.161.06-.026.114-.06.174-.085l.178-.076c.118-.051.23-.115.351-.158.469-.209.947-.392,1.441-.576.373-.139.752-.271,1.157-.41a.174.174,0,0,0,.007-.327Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user