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

@@ -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(() => {

View File

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

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

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

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

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

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

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

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

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

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

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

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

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