migration of comments
This commit is contained in:
@@ -33,6 +33,7 @@
|
|||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Comments indicate what they are used for in vim/term
|
/* Comments indicate what they are used for in vim/term
|
||||||
|
|||||||
@@ -9,7 +9,16 @@ export interface ErrorBoundaryFallbackProps {
|
|||||||
export default function ErrorBoundaryFallback(
|
export default function ErrorBoundaryFallback(
|
||||||
props: ErrorBoundaryFallbackProps
|
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");
|
const [glitchText, setGlitchText] = createSignal("ERROR");
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function Typewriter(props: {
|
|||||||
const [isTyping, setIsTyping] = createSignal(false);
|
const [isTyping, setIsTyping] = createSignal(false);
|
||||||
const [isDelaying, setIsDelaying] = createSignal(delay > 0);
|
const [isDelaying, setIsDelaying] = createSignal(delay > 0);
|
||||||
const [keepAliveCountdown, setKeepAliveCountdown] = createSignal(
|
const [keepAliveCountdown, setKeepAliveCountdown] = createSignal(
|
||||||
typeof keepAlive === "number" ? keepAlive : -1,
|
typeof keepAlive === "number" ? keepAlive : -1
|
||||||
);
|
);
|
||||||
const resolved = children(() => props.children);
|
const resolved = children(() => props.children);
|
||||||
const { showSplash } = useSplash();
|
const { showSplash } = useSplash();
|
||||||
@@ -33,7 +33,7 @@ export function Typewriter(props: {
|
|||||||
textNodes.push({
|
textNodes.push({
|
||||||
node: node as Text,
|
node: node as Text,
|
||||||
text: text,
|
text: text,
|
||||||
startIndex: totalChars,
|
startIndex: totalChars
|
||||||
});
|
});
|
||||||
totalChars += text.length;
|
totalChars += text.length;
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ export function Typewriter(props: {
|
|||||||
charSpan.style.opacity = "0";
|
charSpan.style.opacity = "0";
|
||||||
charSpan.setAttribute(
|
charSpan.setAttribute(
|
||||||
"data-char-index",
|
"data-char-index",
|
||||||
String(totalChars - text.length + i),
|
String(totalChars - text.length + i)
|
||||||
);
|
);
|
||||||
span.appendChild(charSpan);
|
span.appendChild(charSpan);
|
||||||
});
|
});
|
||||||
@@ -60,7 +60,7 @@ export function Typewriter(props: {
|
|||||||
|
|
||||||
// Position cursor at the first character location
|
// Position cursor at the first character location
|
||||||
const firstChar = containerRef.querySelector(
|
const firstChar = containerRef.querySelector(
|
||||||
'[data-char-index="0"]',
|
'[data-char-index="0"]'
|
||||||
) as HTMLElement;
|
) as HTMLElement;
|
||||||
if (firstChar && cursorRef) {
|
if (firstChar && cursorRef) {
|
||||||
// Insert cursor before the first character
|
// Insert cursor before the first character
|
||||||
@@ -96,7 +96,7 @@ export function Typewriter(props: {
|
|||||||
const revealNextChar = () => {
|
const revealNextChar = () => {
|
||||||
if (currentIndex < totalChars) {
|
if (currentIndex < totalChars) {
|
||||||
const charSpan = containerRef?.querySelector(
|
const charSpan = containerRef?.querySelector(
|
||||||
`[data-char-index="${currentIndex}"]`,
|
`[data-char-index="${currentIndex}"]`
|
||||||
) as HTMLElement;
|
) as HTMLElement;
|
||||||
|
|
||||||
if (charSpan) {
|
if (charSpan) {
|
||||||
@@ -106,7 +106,7 @@ export function Typewriter(props: {
|
|||||||
if (cursorRef) {
|
if (cursorRef) {
|
||||||
charSpan.parentNode?.insertBefore(
|
charSpan.parentNode?.insertBefore(
|
||||||
cursorRef,
|
cursorRef,
|
||||||
charSpan.nextSibling,
|
charSpan.nextSibling
|
||||||
);
|
);
|
||||||
|
|
||||||
// Match the height of the current character
|
// 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: "Oldest" },
|
||||||
{ val: "Most Liked" },
|
{ val: "Most Liked" },
|
||||||
{ val: "Most Read" },
|
{ val: "Most Read" },
|
||||||
{ val: "Most Comments" },
|
{ val: "Most Comments" }
|
||||||
];
|
];
|
||||||
|
|
||||||
export interface PostSortingSelectProps {
|
export interface PostSortingSelectProps {
|
||||||
@@ -44,9 +44,9 @@ export default function PostSortingSelect(props: PostSortingSelectProps) {
|
|||||||
onClick={() => setIsOpen(!isOpen())}
|
onClick={() => setIsOpen(!isOpen())}
|
||||||
class={`${
|
class={`${
|
||||||
props.type === "project"
|
props.type === "project"
|
||||||
? "focus-visible:border-blue-600 focus-visible:ring-offset-blue-300"
|
? "focus-visible:border-blue focus-visible:ring-offset-blue"
|
||||||
: "focus-visible:border-orange-600 focus-visible:ring-offset-orange-300"
|
: "focus-visible:border-peach focus-visible:ring-offset-peach"
|
||||||
} 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`}
|
} 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="block truncate">{selected().val}</span>
|
||||||
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
<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}
|
strokeWidth={1.5}
|
||||||
height={24}
|
height={24}
|
||||||
width={24}
|
width={24}
|
||||||
class="fill-zinc-900 dark:fill-white"
|
class="fill-text"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Show when={isOpen()}>
|
<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}>
|
<For each={sorting}>
|
||||||
{(sort) => (
|
{(sort) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSelect(sort)}
|
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
|
selected().val === sort.val
|
||||||
? props.type === "project"
|
? props.type === "project"
|
||||||
? "bg-blue-100 text-blue-900"
|
? "bg-blue text-base brightness-75"
|
||||||
: "bg-orange-100 text-orange-900"
|
: "bg-peach text-base brightness-75"
|
||||||
: "text-zinc-900 dark:text-white hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
: "text-text hover:brightness-125"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -84,16 +84,14 @@ export default function PostSortingSelect(props: PostSortingSelectProps) {
|
|||||||
<Show when={selected().val === sort.val}>
|
<Show when={selected().val === sort.val}>
|
||||||
<span
|
<span
|
||||||
class={`${
|
class={`${
|
||||||
props.type === "project"
|
props.type === "project" ? "text-blue" : "text-peach"
|
||||||
? "text-blue-600"
|
|
||||||
: "text-orange-600"
|
|
||||||
} absolute inset-y-0 left-0 flex items-center pl-3`}
|
} absolute inset-y-0 left-0 flex items-center pl-3`}
|
||||||
>
|
>
|
||||||
<Check
|
<Check
|
||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
height={24}
|
height={24}
|
||||||
width={24}
|
width={24}
|
||||||
class="stroke-zinc-900 dark:stroke-white"
|
class="stroke-text"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</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) => {
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
if (
|
if (
|
||||||
buttonRef && menuRef &&
|
buttonRef &&
|
||||||
|
menuRef &&
|
||||||
!buttonRef.contains(e.target as Node) &&
|
!buttonRef.contains(e.target as Node) &&
|
||||||
!menuRef.contains(e.target as Node)
|
!menuRef.contains(e.target as Node)
|
||||||
) {
|
) {
|
||||||
@@ -30,7 +31,9 @@ export default function TagSelector(props: TagSelectorProps) {
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (showingMenu()) {
|
if (showingMenu()) {
|
||||||
document.addEventListener("click", handleClickOutside);
|
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) {
|
if (isChecked) {
|
||||||
const newFilters = searchParams.filter?.replace(filter + "|", "");
|
const newFilters = searchParams.filter?.replace(filter + "|", "");
|
||||||
if (newFilters && newFilters.length >= 1) {
|
if (newFilters && newFilters.length >= 1) {
|
||||||
navigate(`${location.pathname}?sort=${currentSort()}&filter=${newFilters}`);
|
navigate(
|
||||||
|
`${location.pathname}?sort=${currentSort()}&filter=${newFilters}`
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
navigate(`${location.pathname}?sort=${currentSort()}`);
|
navigate(`${location.pathname}?sort=${currentSort()}`);
|
||||||
}
|
}
|
||||||
@@ -50,9 +55,13 @@ export default function TagSelector(props: TagSelectorProps) {
|
|||||||
const currentFiltersStr = searchParams.filter;
|
const currentFiltersStr = searchParams.filter;
|
||||||
if (currentFiltersStr) {
|
if (currentFiltersStr) {
|
||||||
const newFilters = currentFiltersStr + filter + "|";
|
const newFilters = currentFiltersStr + filter + "|";
|
||||||
navigate(`${location.pathname}?sort=${currentSort()}&filter=${newFilters}`);
|
navigate(
|
||||||
|
`${location.pathname}?sort=${currentSort()}&filter=${newFilters}`
|
||||||
|
);
|
||||||
} else {
|
} 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}
|
onClick={toggleMenu}
|
||||||
class={`${
|
class={`${
|
||||||
props.category === "project"
|
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-blue bg-blue hover:brightness-125"
|
||||||
: "border-orange-500 bg-orange-400 hover:bg-orange-500 dark:border-orange-600 dark:bg-orange-600 dark:hover:bg-orange-700"
|
: "border-peach bg-peach hover:brightness-125"
|
||||||
} 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`}
|
} 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
|
Filters
|
||||||
</button>
|
</button>
|
||||||
<Show when={showingMenu()}>
|
<Show when={showingMenu()}>
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
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)}>
|
<For each={Object.entries(props.tagMap)}>
|
||||||
{([key, value]) => (
|
{([key, value]) => (
|
||||||
@@ -82,7 +91,9 @@ export default function TagSelector(props: TagSelectorProps) {
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={!currentFilters().includes(key.slice(1))}
|
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">
|
<div class="-mt-0.5 pl-1 text-sm font-normal">
|
||||||
{`${key.slice(1)} (${value}) `}
|
{`${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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,14 +7,11 @@ const SplashContext = createContext<{
|
|||||||
setShowSplash: (show: boolean) => void;
|
setShowSplash: (show: boolean) => void;
|
||||||
}>({
|
}>({
|
||||||
showSplash: () => true,
|
showSplash: () => true,
|
||||||
setShowSplash: () => {},
|
setShowSplash: () => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
export function useSplash() {
|
export function useSplash() {
|
||||||
const context = useContext(SplashContext);
|
const context = useContext(SplashContext);
|
||||||
if (!context) {
|
|
||||||
throw new Error("useSplash must be used within a SplashProvider");
|
|
||||||
}
|
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
255
src/lib/comment-utils.ts
Normal file
255
src/lib/comment-utils.ts
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
/**
|
||||||
|
* Comment System Utility Functions
|
||||||
|
*
|
||||||
|
* Shared utility functions for:
|
||||||
|
* - Date formatting
|
||||||
|
* - Comment sorting algorithms
|
||||||
|
* - Comment filtering and tree building
|
||||||
|
* - Debouncing
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Comment, CommentReaction, SortingMode } from "~/types/comment";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Date Utilities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats current date to match SQL datetime format
|
||||||
|
* Note: Adds 4 hours to match server timezone (EST)
|
||||||
|
* Returns format: YYYY-MM-DD HH:MM:SS
|
||||||
|
*/
|
||||||
|
export function getSQLFormattedDate(): string {
|
||||||
|
const date = new Date();
|
||||||
|
date.setHours(date.getHours() + 4);
|
||||||
|
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = `${date.getMonth() + 1}`.padStart(2, "0");
|
||||||
|
const day = `${date.getDate()}`.padStart(2, "0");
|
||||||
|
const hours = `${date.getHours()}`.padStart(2, "0");
|
||||||
|
const minutes = `${date.getMinutes()}`.padStart(2, "0");
|
||||||
|
const seconds = `${date.getSeconds()}`.padStart(2, "0");
|
||||||
|
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Comment Tree Utilities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all child comments for a given parent comment ID
|
||||||
|
*/
|
||||||
|
export function getChildComments(
|
||||||
|
parentCommentID: number,
|
||||||
|
allComments: Comment[] | undefined
|
||||||
|
): Comment[] | undefined {
|
||||||
|
if (!allComments) return undefined;
|
||||||
|
|
||||||
|
return allComments.filter(
|
||||||
|
(comment) => comment.parent_comment_id === parentCommentID
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts the total number of comments including all nested children
|
||||||
|
*/
|
||||||
|
export function getTotalCommentCount(
|
||||||
|
topLevelComments: Comment[],
|
||||||
|
allComments: Comment[]
|
||||||
|
): number {
|
||||||
|
return allComments.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the nesting level of a comment in the tree
|
||||||
|
* Top-level comments (parent_comment_id = -1 or null) are level 0
|
||||||
|
*/
|
||||||
|
export function getCommentLevel(
|
||||||
|
comment: Comment,
|
||||||
|
allComments: Comment[]
|
||||||
|
): number {
|
||||||
|
let level = 0;
|
||||||
|
let currentComment = comment;
|
||||||
|
|
||||||
|
while (
|
||||||
|
currentComment.parent_comment_id &&
|
||||||
|
currentComment.parent_comment_id !== -1
|
||||||
|
) {
|
||||||
|
level++;
|
||||||
|
const parent = allComments.find(
|
||||||
|
(c) => c.id === currentComment.parent_comment_id
|
||||||
|
);
|
||||||
|
if (!parent) break;
|
||||||
|
currentComment = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Comment Sorting Algorithms
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates "hot" score for a comment based on votes and time
|
||||||
|
* Uses logarithmic decay for older comments
|
||||||
|
*/
|
||||||
|
function calculateHotScore(
|
||||||
|
upvotes: number,
|
||||||
|
downvotes: number,
|
||||||
|
date: string
|
||||||
|
): number {
|
||||||
|
const score = upvotes - downvotes;
|
||||||
|
const now = new Date().getTime();
|
||||||
|
const commentTime = new Date(date).getTime();
|
||||||
|
const ageInHours = (now - commentTime) / (1000 * 60 * 60);
|
||||||
|
|
||||||
|
// Logarithmic decay: score / log(age + 2)
|
||||||
|
// Adding 2 prevents division by zero for very new comments
|
||||||
|
return score / Math.log10(ageInHours + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts upvotes for a comment from reaction map
|
||||||
|
*/
|
||||||
|
function getUpvoteCount(
|
||||||
|
commentID: number,
|
||||||
|
reactionMap: Map<number, CommentReaction[]>
|
||||||
|
): number {
|
||||||
|
const reactions = reactionMap.get(commentID) || [];
|
||||||
|
return reactions.filter(
|
||||||
|
(r) => r.type === "tears" || r.type === "heartEye" || r.type === "moneyEye"
|
||||||
|
).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts downvotes for a comment from reaction map
|
||||||
|
*/
|
||||||
|
function getDownvoteCount(
|
||||||
|
commentID: number,
|
||||||
|
reactionMap: Map<number, CommentReaction[]>
|
||||||
|
): number {
|
||||||
|
const reactions = reactionMap.get(commentID) || [];
|
||||||
|
return reactions.filter(
|
||||||
|
(r) => r.type === "angry" || r.type === "sick" || r.type === "worried"
|
||||||
|
).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sorts comments based on the selected sorting mode
|
||||||
|
*
|
||||||
|
* Modes:
|
||||||
|
* - newest: Most recent first
|
||||||
|
* - oldest: Oldest first
|
||||||
|
* - highest_rated: Most upvotes minus downvotes
|
||||||
|
* - hot: Combines votes and recency (Reddit-style)
|
||||||
|
*/
|
||||||
|
export function sortComments(
|
||||||
|
comments: Comment[],
|
||||||
|
mode: SortingMode,
|
||||||
|
reactionMap: Map<number, CommentReaction[]>
|
||||||
|
): Comment[] {
|
||||||
|
const sorted = [...comments];
|
||||||
|
|
||||||
|
switch (mode) {
|
||||||
|
case "newest":
|
||||||
|
return sorted.sort((a, b) => {
|
||||||
|
return new Date(b.date).getTime() - new Date(a.date).getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
case "oldest":
|
||||||
|
return sorted.sort((a, b) => {
|
||||||
|
return new Date(a.date).getTime() - new Date(b.date).getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
case "highest_rated":
|
||||||
|
return sorted.sort((a, b) => {
|
||||||
|
const upVotesA = getUpvoteCount(a.id, reactionMap);
|
||||||
|
const downVotesA = getDownvoteCount(a.id, reactionMap);
|
||||||
|
const upVotesB = getUpvoteCount(b.id, reactionMap);
|
||||||
|
const downVotesB = getDownvoteCount(b.id, reactionMap);
|
||||||
|
|
||||||
|
const scoreA = upVotesA - downVotesA;
|
||||||
|
const scoreB = upVotesB - downVotesB;
|
||||||
|
|
||||||
|
return scoreB - scoreA;
|
||||||
|
});
|
||||||
|
|
||||||
|
case "hot":
|
||||||
|
return sorted.sort((a, b) => {
|
||||||
|
const upVotesA = getUpvoteCount(a.id, reactionMap);
|
||||||
|
const downVotesA = getDownvoteCount(a.id, reactionMap);
|
||||||
|
const upVotesB = getUpvoteCount(b.id, reactionMap);
|
||||||
|
const downVotesB = getDownvoteCount(b.id, reactionMap);
|
||||||
|
|
||||||
|
const hotScoreA = calculateHotScore(upVotesA, downVotesA, a.date);
|
||||||
|
const hotScoreB = calculateHotScore(upVotesB, downVotesB, b.date);
|
||||||
|
|
||||||
|
return hotScoreB - hotScoreA;
|
||||||
|
});
|
||||||
|
|
||||||
|
default:
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Debounce Utility
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounces a function call to limit execution frequency
|
||||||
|
* Useful for window resize and scroll events
|
||||||
|
*/
|
||||||
|
export function debounce<T extends (...args: any[]) => any>(
|
||||||
|
func: T,
|
||||||
|
wait: number
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
return function executedFunction(...args: Parameters<T>) {
|
||||||
|
const later = () => {
|
||||||
|
timeout = null;
|
||||||
|
func(...args);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (timeout !== null) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
timeout = setTimeout(later, wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Validation Utilities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that a comment body meets requirements
|
||||||
|
*/
|
||||||
|
export function isValidCommentBody(body: string): boolean {
|
||||||
|
return body.trim().length > 0 && body.length <= 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a user can modify (edit/delete) a comment
|
||||||
|
*/
|
||||||
|
export function canModifyComment(
|
||||||
|
userID: string,
|
||||||
|
commenterID: string,
|
||||||
|
privilegeLevel: "admin" | "user" | "anonymous"
|
||||||
|
): boolean {
|
||||||
|
if (privilegeLevel === "admin") return true;
|
||||||
|
if (privilegeLevel === "anonymous") return false;
|
||||||
|
return userID === commenterID;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a user can delete with database-level deletion
|
||||||
|
*/
|
||||||
|
export function canDatabaseDelete(
|
||||||
|
privilegeLevel: "admin" | "user" | "anonymous"
|
||||||
|
): boolean {
|
||||||
|
return privilegeLevel === "admin";
|
||||||
|
}
|
||||||
@@ -23,15 +23,20 @@ export default function AccountPage() {
|
|||||||
|
|
||||||
// Form loading states
|
// Form loading states
|
||||||
const [emailButtonLoading, setEmailButtonLoading] = createSignal(false);
|
const [emailButtonLoading, setEmailButtonLoading] = createSignal(false);
|
||||||
const [displayNameButtonLoading, setDisplayNameButtonLoading] = createSignal(false);
|
const [displayNameButtonLoading, setDisplayNameButtonLoading] =
|
||||||
|
createSignal(false);
|
||||||
const [passwordChangeLoading, setPasswordChangeLoading] = createSignal(false);
|
const [passwordChangeLoading, setPasswordChangeLoading] = createSignal(false);
|
||||||
const [deleteAccountButtonLoading, setDeleteAccountButtonLoading] = createSignal(false);
|
const [deleteAccountButtonLoading, setDeleteAccountButtonLoading] =
|
||||||
const [profileImageSetLoading, setProfileImageSetLoading] = createSignal(false);
|
createSignal(false);
|
||||||
|
const [profileImageSetLoading, setProfileImageSetLoading] =
|
||||||
|
createSignal(false);
|
||||||
|
|
||||||
// Password state
|
// Password state
|
||||||
const [passwordsMatch, setPasswordsMatch] = createSignal(false);
|
const [passwordsMatch, setPasswordsMatch] = createSignal(false);
|
||||||
const [showPasswordLengthWarning, setShowPasswordLengthWarning] = createSignal(false);
|
const [showPasswordLengthWarning, setShowPasswordLengthWarning] =
|
||||||
const [passwordLengthSufficient, setPasswordLengthSufficient] = createSignal(false);
|
createSignal(false);
|
||||||
|
const [passwordLengthSufficient, setPasswordLengthSufficient] =
|
||||||
|
createSignal(false);
|
||||||
const [passwordBlurred, setPasswordBlurred] = createSignal(false);
|
const [passwordBlurred, setPasswordBlurred] = createSignal(false);
|
||||||
const [passwordError, setPasswordError] = createSignal(false);
|
const [passwordError, setPasswordError] = createSignal(false);
|
||||||
const [passwordDeletionError, setPasswordDeletionError] = createSignal(false);
|
const [passwordDeletionError, setPasswordDeletionError] = createSignal(false);
|
||||||
@@ -44,7 +49,8 @@ export default function AccountPage() {
|
|||||||
// Success messages
|
// Success messages
|
||||||
const [showImageSuccess, setShowImageSuccess] = createSignal(false);
|
const [showImageSuccess, setShowImageSuccess] = createSignal(false);
|
||||||
const [showEmailSuccess, setShowEmailSuccess] = createSignal(false);
|
const [showEmailSuccess, setShowEmailSuccess] = createSignal(false);
|
||||||
const [showDisplayNameSuccess, setShowDisplayNameSuccess] = createSignal(false);
|
const [showDisplayNameSuccess, setShowDisplayNameSuccess] =
|
||||||
|
createSignal(false);
|
||||||
const [showPasswordSuccess, setShowPasswordSuccess] = createSignal(false);
|
const [showPasswordSuccess, setShowPasswordSuccess] = createSignal(false);
|
||||||
|
|
||||||
// Form refs
|
// Form refs
|
||||||
@@ -59,7 +65,7 @@ export default function AccountPage() {
|
|||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/trpc/user.getProfile", {
|
const response = await fetch("/api/trpc/user.getProfile", {
|
||||||
method: "GET",
|
method: "GET"
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -99,7 +105,7 @@ export default function AccountPage() {
|
|||||||
const response = await fetch("/api/trpc/user.updateEmail", {
|
const response = await fetch("/api/trpc/user.updateEmail", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ email }),
|
body: JSON.stringify({ email })
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -128,7 +134,7 @@ export default function AccountPage() {
|
|||||||
const response = await fetch("/api/trpc/user.updateDisplayName", {
|
const response = await fetch("/api/trpc/user.updateDisplayName", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ displayName }),
|
body: JSON.stringify({ displayName })
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -176,7 +182,7 @@ export default function AccountPage() {
|
|||||||
const response = await fetch("/api/trpc/user.changePassword", {
|
const response = await fetch("/api/trpc/user.changePassword", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ oldPassword, newPassword }),
|
body: JSON.stringify({ oldPassword, newPassword })
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -221,7 +227,7 @@ export default function AccountPage() {
|
|||||||
const response = await fetch("/api/trpc/user.setPassword", {
|
const response = await fetch("/api/trpc/user.setPassword", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ password: newPassword }),
|
body: JSON.stringify({ password: newPassword })
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -262,7 +268,7 @@ export default function AccountPage() {
|
|||||||
const response = await fetch("/api/trpc/user.deleteAccount", {
|
const response = await fetch("/api/trpc/user.deleteAccount", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ password }),
|
body: JSON.stringify({ password })
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -289,7 +295,7 @@ export default function AccountPage() {
|
|||||||
await fetch("/api/trpc/auth.resendEmailVerification", {
|
await fetch("/api/trpc/auth.resendEmailVerification", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ email: currentUser.email }),
|
body: JSON.stringify({ email: currentUser.email })
|
||||||
});
|
});
|
||||||
alert("Verification email sent!");
|
alert("Verification email sent!");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -330,44 +336,54 @@ export default function AccountPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePasswordBlur = () => {
|
const handlePasswordBlur = () => {
|
||||||
if (!passwordLengthSufficient() && newPasswordRef && newPasswordRef.value !== "") {
|
if (
|
||||||
|
!passwordLengthSufficient() &&
|
||||||
|
newPasswordRef &&
|
||||||
|
newPasswordRef.value !== ""
|
||||||
|
) {
|
||||||
setShowPasswordLengthWarning(true);
|
setShowPasswordLengthWarning(true);
|
||||||
}
|
}
|
||||||
setPasswordBlurred(true);
|
setPasswordBlurred(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="mx-8 min-h-screen md:mx-24 lg:mx-36 bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800">
|
<div class="bg-base mx-8 min-h-screen md:mx-24 lg:mx-36">
|
||||||
<div class="pt-24">
|
<div class="pt-24">
|
||||||
<Show
|
<Show
|
||||||
when={!loading() && user()}
|
when={!loading() && user()}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="w-full mt-[35vh] flex justify-center">
|
<div class="mt-[35vh] flex w-full justify-center">
|
||||||
<div class="text-xl">Loading...</div>
|
<div class="text-text text-xl">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(currentUser) => (
|
{(currentUser) => (
|
||||||
<>
|
<>
|
||||||
<div class="text-center text-3xl font-bold mb-8 text-slate-800 dark:text-slate-100">
|
<div class="text-text mb-8 text-center text-3xl font-bold">
|
||||||
Account Settings
|
Account Settings
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email Section */}
|
{/* Email Section */}
|
||||||
<div class="mx-auto flex flex-col md:grid md:grid-cols-2 gap-6 max-w-4xl">
|
<div class="mx-auto flex max-w-4xl flex-col gap-6 md:grid md:grid-cols-2">
|
||||||
<div class="flex justify-center text-lg md:justify-normal items-center">
|
<div class="flex items-center justify-center text-lg md:justify-normal">
|
||||||
<div class="flex flex-col lg:flex-row">
|
<div class="flex flex-col lg:flex-row">
|
||||||
<div class="whitespace-nowrap pr-1 font-semibold">Current email:</div>
|
<div class="pr-1 font-semibold whitespace-nowrap">
|
||||||
|
Current email:
|
||||||
|
</div>
|
||||||
{currentUser().email ? (
|
{currentUser().email ? (
|
||||||
<span>{currentUser().email}</span>
|
<span>{currentUser().email}</span>
|
||||||
) : (
|
) : (
|
||||||
<span class="font-light italic underline underline-offset-4">None Set</span>
|
<span class="font-light italic underline underline-offset-4">
|
||||||
|
None Set
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Show when={currentUser().email && !currentUser().emailVerified}>
|
<Show
|
||||||
|
when={currentUser().email && !currentUser().emailVerified}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onClick={sendEmailVerification}
|
onClick={sendEmailVerification}
|
||||||
class="ml-2 text-red-500 hover:text-red-600 text-sm underline"
|
class="text-red ml-2 text-sm underline transition-all hover:brightness-125"
|
||||||
>
|
>
|
||||||
Verify Email
|
Verify Email
|
||||||
</button>
|
</button>
|
||||||
@@ -380,7 +396,11 @@ export default function AccountPage() {
|
|||||||
ref={emailRef}
|
ref={emailRef}
|
||||||
type="email"
|
type="email"
|
||||||
required
|
required
|
||||||
disabled={emailButtonLoading() || (currentUser().email !== null && !currentUser().emailVerified)}
|
disabled={
|
||||||
|
emailButtonLoading() ||
|
||||||
|
(currentUser().email !== null &&
|
||||||
|
!currentUser().emailVerified)
|
||||||
|
}
|
||||||
placeholder=" "
|
placeholder=" "
|
||||||
class="underlinedInput bg-transparent"
|
class="underlinedInput bg-transparent"
|
||||||
/>
|
/>
|
||||||
@@ -390,29 +410,41 @@ export default function AccountPage() {
|
|||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={emailButtonLoading() || (currentUser().email !== null && !currentUser().emailVerified)}
|
disabled={
|
||||||
|
emailButtonLoading() ||
|
||||||
|
(currentUser().email !== null &&
|
||||||
|
!currentUser().emailVerified)
|
||||||
|
}
|
||||||
class={`${
|
class={`${
|
||||||
emailButtonLoading() || (currentUser().email !== null && !currentUser().emailVerified)
|
emailButtonLoading() ||
|
||||||
? "bg-zinc-400 cursor-not-allowed"
|
(currentUser().email !== null &&
|
||||||
: "bg-blue-400 hover:bg-blue-500 active:scale-90 dark:bg-blue-600 dark:hover:bg-blue-700"
|
!currentUser().emailVerified)
|
||||||
} mt-2 flex justify-center rounded px-4 py-2 text-white transition-all duration-300 ease-out`}
|
? "bg-blue cursor-not-allowed brightness-50"
|
||||||
|
: "bg-blue hover:brightness-125 active:scale-90"
|
||||||
|
} mt-2 flex justify-center rounded px-4 py-2 text-base transition-all duration-300 ease-out`}
|
||||||
>
|
>
|
||||||
{emailButtonLoading() ? "Submitting..." : "Submit"}
|
{emailButtonLoading() ? "Submitting..." : "Submit"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Show when={showEmailSuccess()}>
|
<Show when={showEmailSuccess()}>
|
||||||
<div class="text-green-500 text-sm text-center mt-2">Email updated!</div>
|
<div class="text-green mt-2 text-center text-sm">
|
||||||
|
Email updated!
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Display Name Section */}
|
{/* Display Name Section */}
|
||||||
<div class="flex justify-center text-lg md:justify-normal items-center">
|
<div class="flex items-center justify-center text-lg md:justify-normal">
|
||||||
<div class="flex flex-col lg:flex-row">
|
<div class="flex flex-col lg:flex-row">
|
||||||
<div class="whitespace-nowrap pr-1 font-semibold">Display Name:</div>
|
<div class="pr-1 font-semibold whitespace-nowrap">
|
||||||
|
Display Name:
|
||||||
|
</div>
|
||||||
{currentUser().displayName ? (
|
{currentUser().displayName ? (
|
||||||
<span>{currentUser().displayName}</span>
|
<span>{currentUser().displayName}</span>
|
||||||
) : (
|
) : (
|
||||||
<span class="font-light italic underline underline-offset-4">None Set</span>
|
<span class="font-light italic underline underline-offset-4">
|
||||||
|
None Set
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -438,28 +470,35 @@ export default function AccountPage() {
|
|||||||
disabled={displayNameButtonLoading()}
|
disabled={displayNameButtonLoading()}
|
||||||
class={`${
|
class={`${
|
||||||
displayNameButtonLoading()
|
displayNameButtonLoading()
|
||||||
? "bg-zinc-400 cursor-not-allowed"
|
? "bg-blue cursor-not-allowed brightness-50"
|
||||||
: "bg-blue-400 hover:bg-blue-500 active:scale-90 dark:bg-blue-600 dark:hover:bg-blue-700"
|
: "bg-blue hover:brightness-125 active:scale-90"
|
||||||
} mt-2 flex justify-center rounded px-4 py-2 text-white transition-all duration-300 ease-out`}
|
} mt-2 flex justify-center rounded px-4 py-2 text-base transition-all duration-300 ease-out`}
|
||||||
>
|
>
|
||||||
{displayNameButtonLoading() ? "Submitting..." : "Submit"}
|
{displayNameButtonLoading() ? "Submitting..." : "Submit"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Show when={showDisplayNameSuccess()}>
|
<Show when={showDisplayNameSuccess()}>
|
||||||
<div class="text-green-500 text-sm text-center mt-2">Display name updated!</div>
|
<div class="text-green mt-2 text-center text-sm">
|
||||||
|
Display name updated!
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Password Change/Set Section */}
|
{/* Password Change/Set Section */}
|
||||||
<form onSubmit={handlePasswordSubmit} class="mt-8 flex w-full justify-center">
|
<form
|
||||||
<div class="flex flex-col justify-center max-w-md w-full">
|
onSubmit={handlePasswordSubmit}
|
||||||
<div class="text-center text-xl font-semibold mb-4">
|
class="mt-8 flex w-full justify-center"
|
||||||
{currentUser().hasPassword ? "Change Password" : "Set Password"}
|
>
|
||||||
|
<div class="flex w-full max-w-md flex-col justify-center">
|
||||||
|
<div class="mb-4 text-center text-xl font-semibold">
|
||||||
|
{currentUser().hasPassword
|
||||||
|
? "Change Password"
|
||||||
|
: "Set Password"}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={currentUser().hasPassword}>
|
<Show when={currentUser().hasPassword}>
|
||||||
<div class="input-group mx-4 relative mb-6">
|
<div class="input-group relative mx-4 mb-6">
|
||||||
<input
|
<input
|
||||||
ref={oldPasswordRef}
|
ref={oldPasswordRef}
|
||||||
type={showOldPasswordInput() ? "text" : "password"}
|
type={showOldPasswordInput() ? "text" : "password"}
|
||||||
@@ -472,8 +511,10 @@ export default function AccountPage() {
|
|||||||
<label class="underlinedInputLabel">Old Password</label>
|
<label class="underlinedInputLabel">Old Password</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowOldPasswordInput(!showOldPasswordInput())}
|
onClick={() =>
|
||||||
class="absolute right-0 top-2 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
|
setShowOldPasswordInput(!showOldPasswordInput())
|
||||||
|
}
|
||||||
|
class="text-subtext0 absolute top-2 right-0 transition-all hover:brightness-125"
|
||||||
>
|
>
|
||||||
<Show when={showOldPasswordInput()} fallback={<Eye />}>
|
<Show when={showOldPasswordInput()} fallback={<Eye />}>
|
||||||
<EyeSlash />
|
<EyeSlash />
|
||||||
@@ -482,7 +523,7 @@ export default function AccountPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="input-group mx-4 relative mb-2">
|
<div class="input-group relative mx-4 mb-2">
|
||||||
<input
|
<input
|
||||||
ref={newPasswordRef}
|
ref={newPasswordRef}
|
||||||
type={showPasswordInput() ? "text" : "password"}
|
type={showPasswordInput() ? "text" : "password"}
|
||||||
@@ -498,7 +539,7 @@ export default function AccountPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPasswordInput(!showPasswordInput())}
|
onClick={() => setShowPasswordInput(!showPasswordInput())}
|
||||||
class="absolute right-0 top-2 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
|
class="text-subtext0 absolute top-2 right-0 transition-all hover:brightness-125"
|
||||||
>
|
>
|
||||||
<Show when={showPasswordInput()} fallback={<Eye />}>
|
<Show when={showPasswordInput()} fallback={<Eye />}>
|
||||||
<EyeSlash />
|
<EyeSlash />
|
||||||
@@ -507,12 +548,12 @@ export default function AccountPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={showPasswordLengthWarning()}>
|
<Show when={showPasswordLengthWarning()}>
|
||||||
<div class="text-red-500 text-sm text-center mb-4">
|
<div class="text-red mb-4 text-center text-sm">
|
||||||
Password too short! Min Length: 8
|
Password too short! Min Length: 8
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="input-group mx-4 relative mb-2">
|
<div class="input-group relative mx-4 mb-2">
|
||||||
<input
|
<input
|
||||||
ref={newPasswordConfRef}
|
ref={newPasswordConfRef}
|
||||||
type={showPasswordConfInput() ? "text" : "password"}
|
type={showPasswordConfInput() ? "text" : "password"}
|
||||||
@@ -523,11 +564,15 @@ export default function AccountPage() {
|
|||||||
class="underlinedInput w-full bg-transparent pr-10"
|
class="underlinedInput w-full bg-transparent pr-10"
|
||||||
/>
|
/>
|
||||||
<span class="bar"></span>
|
<span class="bar"></span>
|
||||||
<label class="underlinedInputLabel">Password Confirmation</label>
|
<label class="underlinedInputLabel">
|
||||||
|
Password Confirmation
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPasswordConfInput(!showPasswordConfInput())}
|
onClick={() =>
|
||||||
class="absolute right-0 top-2 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
|
setShowPasswordConfInput(!showPasswordConfInput())
|
||||||
|
}
|
||||||
|
class="text-subtext0 absolute top-2 right-0 transition-all hover:brightness-125"
|
||||||
>
|
>
|
||||||
<Show when={showPasswordConfInput()} fallback={<Eye />}>
|
<Show when={showPasswordConfInput()} fallback={<Eye />}>
|
||||||
<EyeSlash />
|
<EyeSlash />
|
||||||
@@ -535,8 +580,15 @@ export default function AccountPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={!passwordsMatch() && passwordLengthSufficient() && newPasswordConfRef && newPasswordConfRef.value.length >= 6}>
|
<Show
|
||||||
<div class="text-red-500 text-sm text-center mb-4">
|
when={
|
||||||
|
!passwordsMatch() &&
|
||||||
|
passwordLengthSufficient() &&
|
||||||
|
newPasswordConfRef &&
|
||||||
|
newPasswordConfRef.value.length >= 6
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="text-red mb-4 text-center text-sm">
|
||||||
Passwords do not match!
|
Passwords do not match!
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -546,15 +598,15 @@ export default function AccountPage() {
|
|||||||
disabled={passwordChangeLoading() || !passwordsMatch()}
|
disabled={passwordChangeLoading() || !passwordsMatch()}
|
||||||
class={`${
|
class={`${
|
||||||
passwordChangeLoading() || !passwordsMatch()
|
passwordChangeLoading() || !passwordsMatch()
|
||||||
? "bg-zinc-400 cursor-not-allowed"
|
? "bg-blue cursor-not-allowed brightness-50"
|
||||||
: "bg-blue-400 hover:bg-blue-500 active:scale-90 dark:bg-blue-600 dark:hover:bg-blue-700"
|
: "bg-blue hover:brightness-125 active:scale-90"
|
||||||
} my-6 flex justify-center rounded px-4 py-2 text-white transition-all duration-300 ease-out`}
|
} my-6 flex justify-center rounded px-4 py-2 text-base transition-all duration-300 ease-out`}
|
||||||
>
|
>
|
||||||
{passwordChangeLoading() ? "Setting..." : "Set"}
|
{passwordChangeLoading() ? "Setting..." : "Set"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Show when={passwordError()}>
|
<Show when={passwordError()}>
|
||||||
<div class="text-red-500 text-sm text-center">
|
<div class="text-red text-center text-sm">
|
||||||
{currentUser().hasPassword
|
{currentUser().hasPassword
|
||||||
? "Password did not match record"
|
? "Password did not match record"
|
||||||
: "Error setting password"}
|
: "Error setting password"}
|
||||||
@@ -562,8 +614,9 @@ export default function AccountPage() {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={showPasswordSuccess()}>
|
<Show when={showPasswordSuccess()}>
|
||||||
<div class="text-green-500 text-sm text-center">
|
<div class="text-green text-center text-sm">
|
||||||
Password {currentUser().hasPassword ? "changed" : "set"} successfully!
|
Password {currentUser().hasPassword ? "changed" : "set"}{" "}
|
||||||
|
successfully!
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -572,11 +625,14 @@ export default function AccountPage() {
|
|||||||
<hr class="mt-8 mb-8" />
|
<hr class="mt-8 mb-8" />
|
||||||
|
|
||||||
{/* Delete Account Section */}
|
{/* Delete Account Section */}
|
||||||
<div class="py-8 max-w-2xl mx-auto">
|
<div class="mx-auto max-w-2xl py-8">
|
||||||
<div class="w-full rounded-md bg-red-300 px-6 pb-4 pt-8 shadow-md dark:bg-red-950">
|
<div class="bg-red w-full rounded-md px-6 pt-8 pb-4 shadow-md brightness-75">
|
||||||
<div class="pb-4 text-center text-xl font-semibold">Delete Account</div>
|
<div class="pb-4 text-center text-xl font-semibold">
|
||||||
<div class="text-center text-sm mb-4 text-red-700 dark:text-red-300">
|
Delete Account
|
||||||
Warning: This will delete all account information and is irreversible
|
</div>
|
||||||
|
<div class="text-crust mb-4 text-center text-sm">
|
||||||
|
Warning: This will delete all account information and is
|
||||||
|
irreversible
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={deleteAccountTrigger}>
|
<form onSubmit={deleteAccountTrigger}>
|
||||||
@@ -591,7 +647,9 @@ export default function AccountPage() {
|
|||||||
class="underlinedInput bg-transparent"
|
class="underlinedInput bg-transparent"
|
||||||
/>
|
/>
|
||||||
<span class="bar"></span>
|
<span class="bar"></span>
|
||||||
<label class="underlinedInputLabel">Enter Password</label>
|
<label class="underlinedInputLabel">
|
||||||
|
Enter Password
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -600,15 +658,17 @@ export default function AccountPage() {
|
|||||||
disabled={deleteAccountButtonLoading()}
|
disabled={deleteAccountButtonLoading()}
|
||||||
class={`${
|
class={`${
|
||||||
deleteAccountButtonLoading()
|
deleteAccountButtonLoading()
|
||||||
? "bg-zinc-400 cursor-not-allowed"
|
? "bg-red cursor-not-allowed brightness-50"
|
||||||
: "bg-red-500 hover:bg-red-600 active:scale-90 dark:bg-red-600 dark:hover:bg-red-700"
|
: "bg-red hover:brightness-125 active:scale-90"
|
||||||
} mx-auto mt-4 flex justify-center rounded px-4 py-2 text-white transition-all duration-300 ease-out`}
|
} mx-auto mt-4 flex justify-center rounded px-4 py-2 text-base transition-all duration-300 ease-out`}
|
||||||
>
|
>
|
||||||
{deleteAccountButtonLoading() ? "Deleting..." : "Delete Account"}
|
{deleteAccountButtonLoading()
|
||||||
|
? "Deleting..."
|
||||||
|
: "Delete Account"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Show when={passwordDeletionError()}>
|
<Show when={passwordDeletionError()}>
|
||||||
<div class="text-red-500 text-sm text-center mt-2">
|
<div class="text-red mt-2 text-center text-sm">
|
||||||
Password did not match record
|
Password did not match record
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -3,85 +3,119 @@ import { useParams, A } from "@solidjs/router";
|
|||||||
import { Title } from "@solidjs/meta";
|
import { Title } from "@solidjs/meta";
|
||||||
import { createAsync } from "@solidjs/router";
|
import { createAsync } from "@solidjs/router";
|
||||||
import { cache } from "@solidjs/router";
|
import { cache } from "@solidjs/router";
|
||||||
import { ConnectionFactory } from "~/server/utils";
|
import {
|
||||||
|
ConnectionFactory,
|
||||||
|
getUserID,
|
||||||
|
getPrivilegeLevel
|
||||||
|
} from "~/server/utils";
|
||||||
|
import { getRequestEvent } from "solid-js/web";
|
||||||
import { HttpStatusCode } from "@solidjs/start";
|
import { HttpStatusCode } from "@solidjs/start";
|
||||||
import SessionDependantLike from "~/components/blog/SessionDependantLike";
|
import SessionDependantLike from "~/components/blog/SessionDependantLike";
|
||||||
import CommentIcon from "~/components/icons/CommentIcon";
|
import CommentIcon from "~/components/icons/CommentIcon";
|
||||||
|
import CommentSectionWrapper from "~/components/blog/CommentSectionWrapper";
|
||||||
|
import type { Comment, CommentReaction, UserPublicData } from "~/types/comment";
|
||||||
|
|
||||||
// Server function to fetch post by title
|
// Server function to fetch post by title
|
||||||
const getPostByTitle = cache(async (title: string, privilegeLevel: string) => {
|
const getPostByTitle = cache(async (title: string) => {
|
||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
const event = getRequestEvent()!;
|
||||||
|
const privilegeLevel = await getPrivilegeLevel(event.nativeEvent);
|
||||||
|
const userID = await getUserID(event.nativeEvent);
|
||||||
const conn = ConnectionFactory();
|
const conn = ConnectionFactory();
|
||||||
|
|
||||||
let query = "SELECT * FROM Post WHERE title = ?";
|
let query = "SELECT * FROM Post WHERE title = ?";
|
||||||
if (privilegeLevel !== "admin") {
|
if (privilegeLevel !== "admin") {
|
||||||
query += ` AND published = TRUE`;
|
query += ` AND published = TRUE`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const postResults = await conn.execute({
|
const postResults = await conn.execute({
|
||||||
sql: query,
|
sql: query,
|
||||||
args: [decodeURIComponent(title)],
|
args: [decodeURIComponent(title)]
|
||||||
});
|
});
|
||||||
|
|
||||||
const post = postResults.rows[0] as any;
|
const post = postResults.rows[0] as any;
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
// Check if post exists but is unpublished
|
// Check if post exists but is unpublished
|
||||||
const existQuery = "SELECT id FROM Post WHERE title = ?";
|
const existQuery = "SELECT id FROM Post WHERE title = ?";
|
||||||
const existRes = await conn.execute({
|
const existRes = await conn.execute({
|
||||||
sql: existQuery,
|
sql: existQuery,
|
||||||
args: [decodeURIComponent(title)],
|
args: [decodeURIComponent(title)]
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existRes.rows[0]) {
|
if (existRes.rows[0]) {
|
||||||
return { post: null, exists: true, comments: [], likes: [], tags: [], userCommentMap: new Map() };
|
return {
|
||||||
|
post: null,
|
||||||
|
exists: true,
|
||||||
|
comments: [],
|
||||||
|
likes: [],
|
||||||
|
tags: [],
|
||||||
|
userCommentArray: [],
|
||||||
|
reactionArray: [],
|
||||||
|
privilegeLevel: "anonymous" as const,
|
||||||
|
userID: null
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { post: null, exists: false, comments: [], likes: [], tags: [], userCommentMap: new Map() };
|
return {
|
||||||
|
post: null,
|
||||||
|
exists: false,
|
||||||
|
comments: [],
|
||||||
|
likes: [],
|
||||||
|
tags: [],
|
||||||
|
userCommentArray: [],
|
||||||
|
reactionArray: [],
|
||||||
|
privilegeLevel: "anonymous" as const,
|
||||||
|
userID: null
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch comments
|
// Fetch comments
|
||||||
const commentQuery = "SELECT * FROM Comment WHERE post_id = ?";
|
const commentQuery = "SELECT * FROM Comment WHERE post_id = ?";
|
||||||
const comments = (await conn.execute({ sql: commentQuery, args: [post.id] })).rows;
|
const comments = (await conn.execute({ sql: commentQuery, args: [post.id] }))
|
||||||
|
.rows;
|
||||||
|
|
||||||
// Fetch likes
|
// Fetch likes
|
||||||
const likeQuery = "SELECT * FROM PostLike WHERE post_id = ?";
|
const likeQuery = "SELECT * FROM PostLike WHERE post_id = ?";
|
||||||
const likes = (await conn.execute({ sql: likeQuery, args: [post.id] })).rows;
|
const likes = (await conn.execute({ sql: likeQuery, args: [post.id] })).rows;
|
||||||
|
|
||||||
// Fetch tags
|
// Fetch tags
|
||||||
const tagQuery = "SELECT * FROM Tag WHERE post_id = ?";
|
const tagQuery = "SELECT * FROM Tag WHERE post_id = ?";
|
||||||
const tags = (await conn.execute({ sql: tagQuery, args: [post.id] })).rows;
|
const tags = (await conn.execute({ sql: tagQuery, args: [post.id] })).rows;
|
||||||
|
|
||||||
// Build commenter map
|
// Build commenter map
|
||||||
const commenterToCommentIDMap = new Map<string, number[]>();
|
const commenterToCommentIDMap = new Map<string, number[]>();
|
||||||
comments.forEach((comment: any) => {
|
comments.forEach((comment: any) => {
|
||||||
const prev = commenterToCommentIDMap.get(comment.commenter_id) || [];
|
const prev = commenterToCommentIDMap.get(comment.commenter_id) || [];
|
||||||
commenterToCommentIDMap.set(comment.commenter_id, [...prev, comment.id]);
|
commenterToCommentIDMap.set(comment.commenter_id, [...prev, comment.id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
const commenterQuery = "SELECT email, display_name, image FROM User WHERE id = ?";
|
const commenterQuery =
|
||||||
const commentIDToCommenterMap = new Map();
|
"SELECT email, display_name, image FROM User WHERE id = ?";
|
||||||
|
|
||||||
|
// Convert to serializable array format
|
||||||
|
const userCommentArray: Array<[UserPublicData, number[]]> = [];
|
||||||
|
|
||||||
for (const [key, value] of commenterToCommentIDMap.entries()) {
|
for (const [key, value] of commenterToCommentIDMap.entries()) {
|
||||||
const res = await conn.execute({ sql: commenterQuery, args: [key] });
|
const res = await conn.execute({ sql: commenterQuery, args: [key] });
|
||||||
const user = res.rows[0];
|
const user = res.rows[0];
|
||||||
if (user) {
|
if (user) {
|
||||||
commentIDToCommenterMap.set(user, value);
|
userCommentArray.push([user as UserPublicData, value]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get reaction map
|
// Get reaction map as serializable array
|
||||||
const reactionMap = new Map();
|
const reactionArray: Array<[number, CommentReaction[]]> = [];
|
||||||
for (const comment of comments) {
|
for (const comment of comments) {
|
||||||
const reactionQuery = "SELECT * FROM CommentReaction WHERE comment_id = ?";
|
const reactionQuery = "SELECT * FROM CommentReaction WHERE comment_id = ?";
|
||||||
const res = await conn.execute({
|
const res = await conn.execute({
|
||||||
sql: reactionQuery,
|
sql: reactionQuery,
|
||||||
args: [(comment as any).id],
|
args: [(comment as any).id]
|
||||||
});
|
});
|
||||||
reactionMap.set((comment as any).id, res.rows);
|
reactionArray.push([(comment as any).id, res.rows as CommentReaction[]]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
post,
|
post,
|
||||||
exists: true,
|
exists: true,
|
||||||
@@ -89,20 +123,18 @@ const getPostByTitle = cache(async (title: string, privilegeLevel: string) => {
|
|||||||
likes,
|
likes,
|
||||||
tags,
|
tags,
|
||||||
topLevelComments: comments.filter((c: any) => c.parent_comment_id == null),
|
topLevelComments: comments.filter((c: any) => c.parent_comment_id == null),
|
||||||
userCommentMap: commentIDToCommenterMap,
|
userCommentArray,
|
||||||
reactionMap,
|
reactionArray,
|
||||||
|
privilegeLevel,
|
||||||
|
userID
|
||||||
};
|
};
|
||||||
}, "post-by-title");
|
}, "post-by-title");
|
||||||
|
|
||||||
export default function PostPage() {
|
export default function PostPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|
||||||
// TODO: Get actual privilege level and user ID from session/auth
|
const data = createAsync(() => getPostByTitle(params.title));
|
||||||
const privilegeLevel = "anonymous";
|
|
||||||
const userID = null;
|
|
||||||
|
|
||||||
const data = createAsync(() => getPostByTitle(params.title, privilegeLevel));
|
|
||||||
|
|
||||||
const hasCodeBlock = (str: string): boolean => {
|
const hasCodeBlock = (str: string): boolean => {
|
||||||
return str.includes("<code") && str.includes("</code>");
|
return str.includes("<code") && str.includes("</code>");
|
||||||
};
|
};
|
||||||
@@ -117,154 +149,165 @@ export default function PostPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={data()}
|
when={data()?.post}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="w-full pt-[30vh]">
|
|
||||||
<HttpStatusCode code={404} />
|
|
||||||
<div class="text-center text-2xl">Post not found</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(postData) => (
|
|
||||||
<Show
|
<Show
|
||||||
when={postData().post}
|
when={data()?.exists}
|
||||||
fallback={
|
fallback={
|
||||||
<Show
|
<div class="w-full pt-[30vh]">
|
||||||
when={postData().exists}
|
<HttpStatusCode code={404} />
|
||||||
fallback={
|
<div class="text-center text-2xl">Post not found</div>
|
||||||
<div class="w-full pt-[30vh]">
|
</div>
|
||||||
<HttpStatusCode code={404} />
|
|
||||||
<div class="text-center text-2xl">Post not found</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="w-full pt-[30vh]">
|
|
||||||
<div class="text-center text-2xl">
|
|
||||||
That post is in the works! Come back soon!
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<A
|
|
||||||
href="/blog"
|
|
||||||
class="mt-4 rounded border border-orange-500 bg-orange-400 px-4 py-2 text-white shadow-md transition-all duration-300 ease-in-out hover:bg-orange-500 active:scale-90 dark:border-orange-700 dark:bg-orange-700 dark:hover:bg-orange-800"
|
|
||||||
>
|
|
||||||
Back to Posts
|
|
||||||
</A>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(post) => {
|
<div class="w-full pt-[30vh]">
|
||||||
const p = post().post;
|
<div class="text-center text-2xl">
|
||||||
return (
|
That post is in the works! Come back soon!
|
||||||
<>
|
</div>
|
||||||
<Title>{p.title.replaceAll("_", " ")} | Michael Freno</Title>
|
<div class="flex justify-center">
|
||||||
|
<A
|
||||||
<div class="select-none overflow-x-hidden">
|
href="/blog"
|
||||||
<div class="z-30">
|
class="border-peach bg-peach mt-4 rounded border px-4 py-2 text-base shadow-md transition-all duration-300 ease-in-out hover:brightness-125 active:scale-90"
|
||||||
<div class="page-fade-in z-20 mx-auto h-80 sm:h-96 md:h-[50vh]">
|
>
|
||||||
<div class="image-overlay fixed h-80 w-full brightness-75 sm:h-96 md:h-[50vh]">
|
Back to Posts
|
||||||
<img
|
</A>
|
||||||
src={p.banner_photo || "/blueprint.jpg"}
|
</div>
|
||||||
alt="post-cover"
|
</div>
|
||||||
class="h-80 w-full object-cover sm:h-96 md:h-[50vh]"
|
</Show>
|
||||||
/>
|
}
|
||||||
</div>
|
>
|
||||||
<div
|
{(p) => {
|
||||||
class="text-shadow fixed top-36 z-10 w-full select-text text-center tracking-widest text-white brightness-150 sm:top-44 md:top-[20vh]"
|
const postData = data()!;
|
||||||
style={{ "pointer-events": "none" }}
|
|
||||||
>
|
// Convert arrays back to Maps for component
|
||||||
<div class="z-10 text-3xl font-light tracking-widest">
|
const userCommentMap = new Map<UserPublicData, number[]>(
|
||||||
{p.title.replaceAll("_", " ")}
|
postData.userCommentArray || []
|
||||||
<div class="py-8 text-xl font-light tracking-widest">
|
);
|
||||||
{p.subtitle}
|
const reactionMap = new Map<number, CommentReaction[]>(
|
||||||
</div>
|
postData.reactionArray || []
|
||||||
</div>
|
);
|
||||||
</div>
|
|
||||||
</div>
|
return (
|
||||||
|
<>
|
||||||
|
<Title>{p().title.replaceAll("_", " ")} | Michael Freno</Title>
|
||||||
|
|
||||||
|
<div class="overflow-x-hidden select-none">
|
||||||
|
<div class="z-30">
|
||||||
|
<div class="page-fade-in z-20 mx-auto h-80 sm:h-96 md:h-[50vh]">
|
||||||
|
<div class="image-overlay fixed h-80 w-full brightness-75 sm:h-96 md:h-[50vh]">
|
||||||
|
<img
|
||||||
|
src={p().banner_photo || "/blueprint.jpg"}
|
||||||
|
alt="post-cover"
|
||||||
|
class="h-80 w-full object-cover sm:h-96 md:h-[50vh]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
<div class="relative z-40 bg-zinc-100 pb-24 dark:bg-zinc-800">
|
class="text-shadow fixed top-36 z-10 w-full text-center tracking-widest text-white brightness-150 select-text sm:top-44 md:top-[20vh]"
|
||||||
<div class="top-4 flex w-full flex-col justify-center md:absolute md:flex-row md:justify-between">
|
style={{ "pointer-events": "none" }}
|
||||||
<div class="">
|
>
|
||||||
<div class="flex justify-center italic md:justify-start md:pl-24">
|
<div class="z-10 text-3xl font-light tracking-widest">
|
||||||
<div>
|
{p().title.replaceAll("_", " ")}
|
||||||
Written {new Date(p.date).toDateString()}
|
<div class="py-8 text-xl font-light tracking-widest">
|
||||||
<br />
|
{p().subtitle}
|
||||||
By Michael Freno
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex max-w-[420px] flex-wrap justify-center italic md:justify-start md:pl-24">
|
|
||||||
<For each={postData().tags as any[]}>
|
|
||||||
{(tag) => (
|
|
||||||
<div class="group relative m-1 h-fit w-fit rounded-xl bg-purple-600 px-2 py-1 text-sm">
|
|
||||||
<div class="text-white">{tag.value}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-row justify-center pt-4 md:pr-8 md:pt-0">
|
|
||||||
<a href="#comments" class="mx-2">
|
|
||||||
<div class="tooltip flex flex-col">
|
|
||||||
<div class="mx-auto">
|
|
||||||
<CommentIcon strokeWidth={1} height={32} width={32} />
|
|
||||||
</div>
|
|
||||||
<div class="my-auto pl-2 pt-0.5 text-sm text-black dark:text-white">
|
|
||||||
{postData().comments.length}{" "}
|
|
||||||
{postData().comments.length === 1 ? "Comment" : "Comments"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div class="mx-2">
|
|
||||||
<SessionDependantLike
|
|
||||||
currentUserID={userID}
|
|
||||||
privilegeLevel={privilegeLevel}
|
|
||||||
likes={postData().likes as any[]}
|
|
||||||
type={p.category === "project" ? "project" : "blog"}
|
|
||||||
projectID={p.id}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Post body */}
|
|
||||||
<div class="mx-auto max-w-4xl px-4 pt-32 md:pt-40">
|
|
||||||
<div class="prose dark:prose-invert max-w-none" innerHTML={p.body} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={privilegeLevel === "admin"}>
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<A
|
|
||||||
class="z-100 h-fit rounded border border-blue-500 bg-blue-400 px-4 py-2 text-white shadow-md transition-all duration-300 ease-in-out hover:bg-blue-500 active:scale-90 dark:border-blue-700 dark:bg-blue-700 dark:hover:bg-blue-800"
|
|
||||||
href={`/blog/edit/${p.id}`}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</A>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Comments section */}
|
|
||||||
<div id="comments" class="mx-4 pb-12 pt-12 md:mx-8 lg:mx-12">
|
|
||||||
<div class="mb-8 text-center text-2xl font-semibold">Comments</div>
|
|
||||||
<div class="mx-auto max-w-2xl rounded-lg border border-zinc-300 bg-zinc-50 p-6 text-center dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<p class="mb-2 text-lg text-zinc-700 dark:text-zinc-300">
|
|
||||||
Comments coming soon!
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-zinc-500 dark:text-zinc-400">
|
|
||||||
We're working on implementing a comment system for this blog.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
|
||||||
}}
|
<div class="bg-surface0 relative z-40 pb-24">
|
||||||
</Show>
|
<div class="top-4 flex w-full flex-col justify-center md:absolute md:flex-row md:justify-between">
|
||||||
)}
|
<div class="">
|
||||||
|
<div class="flex justify-center italic md:justify-start md:pl-24">
|
||||||
|
<div>
|
||||||
|
Written {new Date(p().date).toDateString()}
|
||||||
|
<br />
|
||||||
|
By Michael Freno
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex max-w-[420px] flex-wrap justify-center italic md:justify-start md:pl-24">
|
||||||
|
<For each={postData.tags as any[]}>
|
||||||
|
{(tag) => (
|
||||||
|
<div class="group relative m-1 h-fit w-fit rounded-xl bg-purple-600 px-2 py-1 text-sm">
|
||||||
|
<div class="text-white">{tag.value}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-row justify-center pt-4 md:pt-0 md:pr-8">
|
||||||
|
<a href="#comments" class="mx-2">
|
||||||
|
<div class="tooltip flex flex-col">
|
||||||
|
<div class="mx-auto">
|
||||||
|
<CommentIcon
|
||||||
|
strokeWidth={1}
|
||||||
|
height={32}
|
||||||
|
width={32}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="text-text my-auto pt-0.5 pl-2 text-sm">
|
||||||
|
{postData.comments.length}{" "}
|
||||||
|
{postData.comments.length === 1
|
||||||
|
? "Comment"
|
||||||
|
: "Comments"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="mx-2">
|
||||||
|
<SessionDependantLike
|
||||||
|
currentUserID={postData.userID}
|
||||||
|
privilegeLevel={postData.privilegeLevel}
|
||||||
|
likes={postData.likes as any[]}
|
||||||
|
type={
|
||||||
|
p().category === "project" ? "project" : "blog"
|
||||||
|
}
|
||||||
|
projectID={p().id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Post body */}
|
||||||
|
<div class="mx-auto max-w-4xl px-4 pt-32 md:pt-40">
|
||||||
|
<div class="prose max-w-none" innerHTML={p().body} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={postData.privilegeLevel === "admin"}>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<A
|
||||||
|
class="border-blue bg-blue z-100 h-fit rounded border px-4 py-2 text-base shadow-md transition-all duration-300 ease-in-out hover:brightness-125 active:scale-90"
|
||||||
|
href={`/blog/edit/${p().id}`}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</A>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Comments section */}
|
||||||
|
<div
|
||||||
|
id="comments"
|
||||||
|
class="mx-4 pt-12 pb-12 md:mx-8 lg:mx-12"
|
||||||
|
>
|
||||||
|
<CommentSectionWrapper
|
||||||
|
privilegeLevel={postData.privilegeLevel}
|
||||||
|
allComments={postData.comments as Comment[]}
|
||||||
|
topLevelComments={
|
||||||
|
postData.topLevelComments as Comment[]
|
||||||
|
}
|
||||||
|
id={p().id}
|
||||||
|
type="blog"
|
||||||
|
reactionMap={reactionMap}
|
||||||
|
currentUserID={postData.userID || ""}
|
||||||
|
userCommentMap={userCommentMap}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ import { api } from "~/lib/api";
|
|||||||
export default function CreatePost() {
|
export default function CreatePost() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// TODO: Get actual privilege level from session/auth
|
// TODO: Get actual privilege level from session/auth
|
||||||
const privilegeLevel = "anonymous";
|
const privilegeLevel = "anonymous";
|
||||||
const userID = null;
|
const userID = null;
|
||||||
|
|
||||||
const category = () => searchParams.category === "project" ? "project" : "blog";
|
const category = () =>
|
||||||
|
searchParams.category === "project" ? "project" : "blog";
|
||||||
|
|
||||||
const [title, setTitle] = createSignal("");
|
const [title, setTitle] = createSignal("");
|
||||||
const [subtitle, setSubtitle] = createSignal("");
|
const [subtitle, setSubtitle] = createSignal("");
|
||||||
const [body, setBody] = createSignal("");
|
const [body, setBody] = createSignal("");
|
||||||
@@ -24,15 +25,15 @@ export default function CreatePost() {
|
|||||||
|
|
||||||
const handleSubmit = async (e: Event) => {
|
const handleSubmit = async (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!userID) {
|
if (!userID) {
|
||||||
setError("You must be logged in to create a post");
|
setError("You must be logged in to create a post");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await api.database.createPost.mutate({
|
const result = await api.database.createPost.mutate({
|
||||||
category: category(),
|
category: category(),
|
||||||
@@ -42,9 +43,9 @@ export default function CreatePost() {
|
|||||||
banner_photo: bannerPhoto() || null,
|
banner_photo: bannerPhoto() || null,
|
||||||
published: published(),
|
published: published(),
|
||||||
tags: tags().length > 0 ? tags() : null,
|
tags: tags().length > 0 ? tags() : null,
|
||||||
author_id: userID,
|
author_id: userID
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.data) {
|
if (result.data) {
|
||||||
// Redirect to the new post
|
// Redirect to the new post
|
||||||
navigate(`/blog/${encodeURIComponent(title())}`);
|
navigate(`/blog/${encodeURIComponent(title())}`);
|
||||||
@@ -59,29 +60,32 @@ export default function CreatePost() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Title>Create {category() === "project" ? "Project" : "Blog Post"} | Michael Freno</Title>
|
<Title>
|
||||||
|
Create {category() === "project" ? "Project" : "Blog Post"} | Michael
|
||||||
|
Freno
|
||||||
|
</Title>
|
||||||
|
|
||||||
<Show
|
<Show
|
||||||
when={privilegeLevel === "admin"}
|
when={privilegeLevel === "admin"}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="w-full pt-[30vh] text-center">
|
<div class="w-full pt-[30vh] text-center">
|
||||||
<div class="text-2xl">Unauthorized</div>
|
<div class="text-text text-2xl">Unauthorized</div>
|
||||||
<div class="text-gray-600 dark:text-gray-400 mt-4">
|
<div class="text-subtext0 mt-4">
|
||||||
You must be an admin to create posts.
|
You must be an admin to create posts.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="min-h-screen bg-white dark:bg-zinc-900 py-12 px-4">
|
<div class="bg-base min-h-screen px-4 py-12">
|
||||||
<div class="max-w-4xl mx-auto">
|
<div class="mx-auto max-w-4xl">
|
||||||
<h1 class="text-4xl font-bold text-center mb-8">
|
<h1 class="mb-8 text-center text-4xl font-bold">
|
||||||
Create {category() === "project" ? "Project" : "Blog Post"}
|
Create {category() === "project" ? "Project" : "Blog Post"}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} class="space-y-6">
|
<form onSubmit={handleSubmit} class="space-y-6">
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div>
|
<div>
|
||||||
<label for="title" class="block text-sm font-medium mb-2">
|
<label for="title" class="mb-2 block text-sm font-medium">
|
||||||
Title *
|
Title *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -90,14 +94,14 @@ export default function CreatePost() {
|
|||||||
required
|
required
|
||||||
value={title()}
|
value={title()}
|
||||||
onInput={(e) => setTitle(e.currentTarget.value)}
|
onInput={(e) => setTitle(e.currentTarget.value)}
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-md dark:bg-zinc-800 dark:border-zinc-700"
|
class="border-surface2 bg-surface0 w-full rounded-md border px-4 py-2"
|
||||||
placeholder="Enter post title"
|
placeholder="Enter post title"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subtitle */}
|
{/* Subtitle */}
|
||||||
<div>
|
<div>
|
||||||
<label for="subtitle" class="block text-sm font-medium mb-2">
|
<label for="subtitle" class="mb-2 block text-sm font-medium">
|
||||||
Subtitle
|
Subtitle
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -105,14 +109,14 @@ export default function CreatePost() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={subtitle()}
|
value={subtitle()}
|
||||||
onInput={(e) => setSubtitle(e.currentTarget.value)}
|
onInput={(e) => setSubtitle(e.currentTarget.value)}
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-md dark:bg-zinc-800 dark:border-zinc-700"
|
class="border-surface2 bg-surface0 w-full rounded-md border px-4 py-2"
|
||||||
placeholder="Enter post subtitle"
|
placeholder="Enter post subtitle"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div>
|
<div>
|
||||||
<label for="body" class="block text-sm font-medium mb-2">
|
<label for="body" class="mb-2 block text-sm font-medium">
|
||||||
Body (HTML)
|
Body (HTML)
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -120,14 +124,14 @@ export default function CreatePost() {
|
|||||||
rows={15}
|
rows={15}
|
||||||
value={body()}
|
value={body()}
|
||||||
onInput={(e) => setBody(e.currentTarget.value)}
|
onInput={(e) => setBody(e.currentTarget.value)}
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-md dark:bg-zinc-800 dark:border-zinc-700 font-mono text-sm"
|
class="w-full rounded-md border border-surface2 bg-surface0 px-4 py-2 font-mono text-sm"
|
||||||
placeholder="Enter post content (HTML)"
|
placeholder="Enter post content (HTML)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Banner Photo URL */}
|
{/* Banner Photo URL */}
|
||||||
<div>
|
<div>
|
||||||
<label for="banner" class="block text-sm font-medium mb-2">
|
<label for="banner" class="mb-2 block text-sm font-medium">
|
||||||
Banner Photo URL
|
Banner Photo URL
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -135,26 +139,33 @@ export default function CreatePost() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={bannerPhoto()}
|
value={bannerPhoto()}
|
||||||
onInput={(e) => setBannerPhoto(e.currentTarget.value)}
|
onInput={(e) => setBannerPhoto(e.currentTarget.value)}
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-md dark:bg-zinc-800 dark:border-zinc-700"
|
class="border-surface2 bg-surface0 w-full rounded-md border px-4 py-2"
|
||||||
placeholder="Enter banner photo URL"
|
placeholder="Enter banner photo URL"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
<div>
|
<div>
|
||||||
<label for="tags" class="block text-sm font-medium mb-2">
|
<label for="tags" class="mb-2 block text-sm font-medium">
|
||||||
Tags (comma-separated)
|
Tags (comma-separated)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="tags"
|
id="tags"
|
||||||
type="text"
|
type="text"
|
||||||
value={tags().join(", ")}
|
value={tags().join(", ")}
|
||||||
onInput={(e) => setTags(e.currentTarget.value.split(",").map(t => t.trim()).filter(Boolean))}
|
onInput={(e) =>
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-md dark:bg-zinc-800 dark:border-zinc-700"
|
setTags(
|
||||||
|
e.currentTarget.value
|
||||||
|
.split(",")
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
class="border-surface2 bg-surface0 w-full rounded-md border px-4 py-2"
|
||||||
placeholder="tag1, tag2, tag3"
|
placeholder="tag1, tag2, tag3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Published */}
|
{/* Published */}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
@@ -168,30 +179,30 @@ export default function CreatePost() {
|
|||||||
Publish immediately
|
Publish immediately
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error message */}
|
{/* Error message */}
|
||||||
<Show when={error()}>
|
<Show when={error()}>
|
||||||
<div class="text-red-500 text-sm">{error()}</div>
|
<div class="text-red text-sm">{error()}</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Submit button */}
|
{/* Submit button */}
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading()}
|
disabled={loading()}
|
||||||
class={`flex-1 px-6 py-3 rounded-md text-white transition-all ${
|
class={`flex-1 rounded-md px-6 py-3 text-base transition-all ${
|
||||||
loading()
|
loading()
|
||||||
? "bg-gray-400 cursor-not-allowed"
|
? "bg-blue cursor-not-allowed brightness-50"
|
||||||
: "bg-blue-500 hover:bg-blue-600 active:scale-95"
|
: "bg-blue hover:brightness-125 active:scale-95"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{loading() ? "Creating..." : "Create Post"}
|
{loading() ? "Creating..." : "Create Post"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate("/blog")}
|
onClick={() => navigate("/blog")}
|
||||||
class="px-6 py-3 rounded-md border border-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-all"
|
class="border-surface2 rounded-md border px-6 py-3 transition-all hover:brightness-125"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -9,36 +9,36 @@ import { ConnectionFactory } from "~/server/utils";
|
|||||||
// Server function to fetch post for editing
|
// Server function to fetch post for editing
|
||||||
const getPostForEdit = cache(async (id: string) => {
|
const getPostForEdit = cache(async (id: string) => {
|
||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
const conn = ConnectionFactory();
|
const conn = ConnectionFactory();
|
||||||
const query = `SELECT * FROM Post WHERE id = ?`;
|
const query = `SELECT * FROM Post WHERE id = ?`;
|
||||||
const results = await conn.execute({
|
const results = await conn.execute({
|
||||||
sql: query,
|
sql: query,
|
||||||
args: [id],
|
args: [id]
|
||||||
});
|
});
|
||||||
|
|
||||||
const tagQuery = `SELECT * FROM Tag WHERE post_id = ?`;
|
const tagQuery = `SELECT * FROM Tag WHERE post_id = ?`;
|
||||||
const tagRes = await conn.execute({
|
const tagRes = await conn.execute({
|
||||||
sql: tagQuery,
|
sql: tagQuery,
|
||||||
args: [id],
|
args: [id]
|
||||||
});
|
});
|
||||||
|
|
||||||
const post = results.rows[0];
|
const post = results.rows[0];
|
||||||
const tags = tagRes.rows;
|
const tags = tagRes.rows;
|
||||||
|
|
||||||
return { post, tags };
|
return { post, tags };
|
||||||
}, "post-for-edit");
|
}, "post-for-edit");
|
||||||
|
|
||||||
export default function EditPost() {
|
export default function EditPost() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// TODO: Get actual privilege level from session/auth
|
// TODO: Get actual privilege level from session/auth
|
||||||
const privilegeLevel = "anonymous";
|
const privilegeLevel = "anonymous";
|
||||||
const userID = null;
|
const userID = null;
|
||||||
|
|
||||||
const data = createAsync(() => getPostForEdit(params.id));
|
const data = createAsync(() => getPostForEdit(params.id));
|
||||||
|
|
||||||
const [title, setTitle] = createSignal("");
|
const [title, setTitle] = createSignal("");
|
||||||
const [subtitle, setSubtitle] = createSignal("");
|
const [subtitle, setSubtitle] = createSignal("");
|
||||||
const [body, setBody] = createSignal("");
|
const [body, setBody] = createSignal("");
|
||||||
@@ -47,7 +47,7 @@ export default function EditPost() {
|
|||||||
const [tags, setTags] = createSignal<string[]>([]);
|
const [tags, setTags] = createSignal<string[]>([]);
|
||||||
const [loading, setLoading] = createSignal(false);
|
const [loading, setLoading] = createSignal(false);
|
||||||
const [error, setError] = createSignal("");
|
const [error, setError] = createSignal("");
|
||||||
|
|
||||||
// Populate form when data loads
|
// Populate form when data loads
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const postData = data();
|
const postData = data();
|
||||||
@@ -58,9 +58,9 @@ export default function EditPost() {
|
|||||||
setBody(p.body || "");
|
setBody(p.body || "");
|
||||||
setBannerPhoto(p.banner_photo || "");
|
setBannerPhoto(p.banner_photo || "");
|
||||||
setPublished(p.published || false);
|
setPublished(p.published || false);
|
||||||
|
|
||||||
if (postData.tags) {
|
if (postData.tags) {
|
||||||
const tagValues = (postData.tags as any[]).map(t => t.value);
|
const tagValues = (postData.tags as any[]).map((t) => t.value);
|
||||||
setTags(tagValues);
|
setTags(tagValues);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,15 +68,15 @@ export default function EditPost() {
|
|||||||
|
|
||||||
const handleSubmit = async (e: Event) => {
|
const handleSubmit = async (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!userID) {
|
if (!userID) {
|
||||||
setError("You must be logged in to edit posts");
|
setError("You must be logged in to edit posts");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.database.updatePost.mutate({
|
await api.database.updatePost.mutate({
|
||||||
id: parseInt(params.id),
|
id: parseInt(params.id),
|
||||||
@@ -86,9 +86,9 @@ export default function EditPost() {
|
|||||||
banner_photo: bannerPhoto() || null,
|
banner_photo: bannerPhoto() || null,
|
||||||
published: published(),
|
published: published(),
|
||||||
tags: tags().length > 0 ? tags() : null,
|
tags: tags().length > 0 ? tags() : null,
|
||||||
author_id: userID,
|
author_id: userID
|
||||||
});
|
});
|
||||||
|
|
||||||
// Redirect to the post
|
// Redirect to the post
|
||||||
navigate(`/blog/${encodeURIComponent(title())}`);
|
navigate(`/blog/${encodeURIComponent(title())}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -102,13 +102,13 @@ export default function EditPost() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Title>Edit Post | Michael Freno</Title>
|
<Title>Edit Post | Michael Freno</Title>
|
||||||
|
|
||||||
<Show
|
<Show
|
||||||
when={privilegeLevel === "admin"}
|
when={privilegeLevel === "admin"}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="w-full pt-[30vh] text-center">
|
<div class="w-full pt-[30vh] text-center">
|
||||||
<div class="text-2xl">Unauthorized</div>
|
<div class="text-2xl">Unauthorized</div>
|
||||||
<div class="text-gray-600 dark:text-gray-400 mt-4">
|
<div class="text-subtext0 mt-4">
|
||||||
You must be an admin to edit posts.
|
You must be an admin to edit posts.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,14 +122,14 @@ export default function EditPost() {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="min-h-screen bg-white dark:bg-zinc-900 py-12 px-4">
|
<div class="bg-base min-h-screen px-4 py-12">
|
||||||
<div class="max-w-4xl mx-auto">
|
<div class="mx-auto max-w-4xl">
|
||||||
<h1 class="text-4xl font-bold text-center mb-8">Edit Post</h1>
|
<h1 class="mb-8 text-center text-4xl font-bold">Edit Post</h1>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} class="space-y-6">
|
<form onSubmit={handleSubmit} class="space-y-6">
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div>
|
<div>
|
||||||
<label for="title" class="block text-sm font-medium mb-2">
|
<label for="title" class="mb-2 block text-sm font-medium">
|
||||||
Title *
|
Title *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -138,14 +138,14 @@ export default function EditPost() {
|
|||||||
required
|
required
|
||||||
value={title()}
|
value={title()}
|
||||||
onInput={(e) => setTitle(e.currentTarget.value)}
|
onInput={(e) => setTitle(e.currentTarget.value)}
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-md dark:bg-zinc-800 dark:border-zinc-700"
|
class="w-full rounded-md border border-surface2 bg-surface0 px-4 py-2"
|
||||||
placeholder="Enter post title"
|
placeholder="Enter post title"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subtitle */}
|
{/* Subtitle */}
|
||||||
<div>
|
<div>
|
||||||
<label for="subtitle" class="block text-sm font-medium mb-2">
|
<label for="subtitle" class="mb-2 block text-sm font-medium">
|
||||||
Subtitle
|
Subtitle
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -153,14 +153,14 @@ export default function EditPost() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={subtitle()}
|
value={subtitle()}
|
||||||
onInput={(e) => setSubtitle(e.currentTarget.value)}
|
onInput={(e) => setSubtitle(e.currentTarget.value)}
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-md dark:bg-zinc-800 dark:border-zinc-700"
|
class="w-full rounded-md border border-surface2 bg-surface0 px-4 py-2"
|
||||||
placeholder="Enter post subtitle"
|
placeholder="Enter post subtitle"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div>
|
<div>
|
||||||
<label for="body" class="block text-sm font-medium mb-2">
|
<label for="body" class="mb-2 block text-sm font-medium">
|
||||||
Body (HTML)
|
Body (HTML)
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -168,14 +168,14 @@ export default function EditPost() {
|
|||||||
rows={15}
|
rows={15}
|
||||||
value={body()}
|
value={body()}
|
||||||
onInput={(e) => setBody(e.currentTarget.value)}
|
onInput={(e) => setBody(e.currentTarget.value)}
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-md dark:bg-zinc-800 dark:border-zinc-700 font-mono text-sm"
|
class="w-full rounded-md border border-surface2 bg-surface0 px-4 py-2 font-mono text-sm"
|
||||||
placeholder="Enter post content (HTML)"
|
placeholder="Enter post content (HTML)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Banner Photo URL */}
|
{/* Banner Photo URL */}
|
||||||
<div>
|
<div>
|
||||||
<label for="banner" class="block text-sm font-medium mb-2">
|
<label for="banner" class="mb-2 block text-sm font-medium">
|
||||||
Banner Photo URL
|
Banner Photo URL
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -183,26 +183,33 @@ export default function EditPost() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={bannerPhoto()}
|
value={bannerPhoto()}
|
||||||
onInput={(e) => setBannerPhoto(e.currentTarget.value)}
|
onInput={(e) => setBannerPhoto(e.currentTarget.value)}
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-md dark:bg-zinc-800 dark:border-zinc-700"
|
class="w-full rounded-md border border-surface2 bg-surface0 px-4 py-2"
|
||||||
placeholder="Enter banner photo URL"
|
placeholder="Enter banner photo URL"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
<div>
|
<div>
|
||||||
<label for="tags" class="block text-sm font-medium mb-2">
|
<label for="tags" class="mb-2 block text-sm font-medium">
|
||||||
Tags (comma-separated)
|
Tags (comma-separated)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="tags"
|
id="tags"
|
||||||
type="text"
|
type="text"
|
||||||
value={tags().join(", ")}
|
value={tags().join(", ")}
|
||||||
onInput={(e) => setTags(e.currentTarget.value.split(",").map(t => t.trim()).filter(Boolean))}
|
onInput={(e) =>
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-md dark:bg-zinc-800 dark:border-zinc-700"
|
setTags(
|
||||||
|
e.currentTarget.value
|
||||||
|
.split(",")
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
class="w-full rounded-md border border-surface2 bg-surface0 px-4 py-2"
|
||||||
placeholder="tag1, tag2, tag3"
|
placeholder="tag1, tag2, tag3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Published */}
|
{/* Published */}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
@@ -216,30 +223,32 @@ export default function EditPost() {
|
|||||||
Published
|
Published
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error message */}
|
{/* Error message */}
|
||||||
<Show when={error()}>
|
<Show when={error()}>
|
||||||
<div class="text-red-500 text-sm">{error()}</div>
|
<div class="text-red text-sm">{error()}</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Submit button */}
|
{/* Submit button */}
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading()}
|
disabled={loading()}
|
||||||
class={`flex-1 px-6 py-3 rounded-md text-white transition-all ${
|
class={`flex-1 rounded-md px-6 py-3 text-base transition-all ${
|
||||||
loading()
|
loading()
|
||||||
? "bg-gray-400 cursor-not-allowed"
|
? "bg-blue cursor-not-allowed brightness-50"
|
||||||
: "bg-blue-500 hover:bg-blue-600 active:scale-95"
|
: "bg-blue hover:brightness-125 active:scale-95"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{loading() ? "Saving..." : "Save Changes"}
|
{loading() ? "Saving..." : "Save Changes"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate(`/blog/${encodeURIComponent(title())}`)}
|
onClick={() =>
|
||||||
class="px-6 py-3 rounded-md border border-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-all"
|
navigate(`/blog/${encodeURIComponent(title())}`)
|
||||||
|
}
|
||||||
|
class="border-surface2 rounded-md border px-6 py-3 transition-all hover:brightness-125"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import PostSorting from "~/components/blog/PostSorting";
|
|||||||
// Server function to fetch posts
|
// Server function to fetch posts
|
||||||
const getPosts = cache(async (category: string, privilegeLevel: string) => {
|
const getPosts = cache(async (category: string, privilegeLevel: string) => {
|
||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
let query = `
|
let query = `
|
||||||
SELECT
|
SELECT
|
||||||
Post.id,
|
Post.id,
|
||||||
@@ -45,18 +45,19 @@ const getPosts = cache(async (category: string, privilegeLevel: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
query += ` GROUP BY Post.id, Post.title, Post.subtitle, Post.body, Post.banner_photo, Post.date, Post.published, Post.category, Post.author_id, Post.reads, Post.attachments ORDER BY Post.date DESC;`;
|
query += ` GROUP BY Post.id, Post.title, Post.subtitle, Post.body, Post.banner_photo, Post.date, Post.published, Post.category, Post.author_id, Post.reads, Post.attachments ORDER BY Post.date DESC;`;
|
||||||
|
|
||||||
const conn = ConnectionFactory();
|
const conn = ConnectionFactory();
|
||||||
const results = await conn.execute(query);
|
const results = await conn.execute(query);
|
||||||
const posts = results.rows;
|
const posts = results.rows;
|
||||||
|
|
||||||
const postIds = posts.map((post: any) => post.id);
|
const postIds = posts.map((post: any) => post.id);
|
||||||
const tagQuery = postIds.length > 0
|
const tagQuery =
|
||||||
? `SELECT * FROM Tag WHERE post_id IN (${postIds.join(", ")})`
|
postIds.length > 0
|
||||||
: "SELECT * FROM Tag WHERE 1=0";
|
? `SELECT * FROM Tag WHERE post_id IN (${postIds.join(", ")})`
|
||||||
|
: "SELECT * FROM Tag WHERE 1=0";
|
||||||
const tagResults = await conn.execute(tagQuery);
|
const tagResults = await conn.execute(tagQuery);
|
||||||
const tags = tagResults.rows;
|
const tags = tagResults.rows;
|
||||||
|
|
||||||
let tagMap: Record<string, number> = {};
|
let tagMap: Record<string, number> = {};
|
||||||
tags.forEach((tag: any) => {
|
tags.forEach((tag: any) => {
|
||||||
tagMap[tag.value] = (tagMap[tag.value] || 0) + 1;
|
tagMap[tag.value] = (tagMap[tag.value] || 0) + 1;
|
||||||
@@ -67,24 +68,32 @@ const getPosts = cache(async (category: string, privilegeLevel: string) => {
|
|||||||
|
|
||||||
export default function BlogIndex() {
|
export default function BlogIndex() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
// TODO: Get actual privilege level from session/auth
|
// TODO: Get actual privilege level from session/auth
|
||||||
const privilegeLevel = "anonymous";
|
const privilegeLevel = "anonymous";
|
||||||
|
|
||||||
const category = () => searchParams.category || "all";
|
const category = () => searchParams.category || "all";
|
||||||
const sort = () => searchParams.sort || "newest";
|
const sort = () => searchParams.sort || "newest";
|
||||||
const filters = () => searchParams.filter || "";
|
const filters = () => searchParams.filter || "";
|
||||||
|
|
||||||
const data = createAsync(() => getPosts(category(), privilegeLevel));
|
const data = createAsync(() => getPosts(category(), privilegeLevel));
|
||||||
|
|
||||||
const bannerImage = () => category() === "project" ? "/blueprint.jpg" : "/manhattan-night-skyline.jpg";
|
const bannerImage = () =>
|
||||||
const pageTitle = () => category() === "all" ? "Posts" : category() === "project" ? "Projects" : "Blog";
|
category() === "project"
|
||||||
|
? "/blueprint.jpg"
|
||||||
|
: "/manhattan-night-skyline.jpg";
|
||||||
|
const pageTitle = () =>
|
||||||
|
category() === "all"
|
||||||
|
? "Posts"
|
||||||
|
: category() === "project"
|
||||||
|
? "Projects"
|
||||||
|
: "Blog";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Title>{pageTitle()} | Michael Freno</Title>
|
<Title>{pageTitle()} | Michael Freno</Title>
|
||||||
|
|
||||||
<div class="min-h-screen overflow-x-hidden bg-white dark:bg-zinc-900">
|
<div class="bg-base min-h-screen overflow-x-hidden">
|
||||||
<div class="z-30">
|
<div class="z-30">
|
||||||
<div class="page-fade-in z-20 mx-auto h-80 sm:h-96 md:h-[30vh]">
|
<div class="page-fade-in z-20 mx-auto h-80 sm:h-96 md:h-[30vh]">
|
||||||
<div class="image-overlay fixed h-80 w-full brightness-75 sm:h-96 md:h-[50vh]">
|
<div class="image-overlay fixed h-80 w-full brightness-75 sm:h-96 md:h-[50vh]">
|
||||||
@@ -95,7 +104,7 @@ export default function BlogIndex() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="text-shadow fixed top-36 z-10 w-full select-text text-center tracking-widest text-white brightness-150 sm:top-44 md:top-[20vh]"
|
class="text-shadow fixed top-36 z-10 w-full text-center tracking-widest text-white brightness-150 select-text sm:top-44 md:top-[20vh]"
|
||||||
style={{ "pointer-events": "none" }}
|
style={{ "pointer-events": "none" }}
|
||||||
>
|
>
|
||||||
<div class="z-10 text-5xl font-light tracking-widest">
|
<div class="z-10 text-5xl font-light tracking-widest">
|
||||||
@@ -104,8 +113,8 @@ export default function BlogIndex() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative z-40 mx-auto -mt-16 min-h-screen w-11/12 rounded-t-lg bg-zinc-50 pb-24 pt-8 shadow-2xl dark:bg-zinc-800 sm:-mt-20 md:mt-0 md:w-5/6 lg:w-3/4">
|
<div class="bg-surface0 relative z-40 mx-auto -mt-16 min-h-screen w-11/12 rounded-t-lg pt-8 pb-24 shadow-2xl sm:-mt-20 md:mt-0 md:w-5/6 lg:w-3/4">
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div class="mx-auto pt-48">
|
<div class="mx-auto pt-48">
|
||||||
@@ -119,8 +128,8 @@ export default function BlogIndex() {
|
|||||||
href="/blog?category=all"
|
href="/blog?category=all"
|
||||||
class={`rounded border px-4 py-2 transition-all duration-300 ease-out active:scale-90 ${
|
class={`rounded border px-4 py-2 transition-all duration-300 ease-out active:scale-90 ${
|
||||||
category() === "all"
|
category() === "all"
|
||||||
? "border-orange-500 bg-orange-400 text-white dark:border-orange-700 dark:bg-orange-700"
|
? "border-peach bg-peach text-base"
|
||||||
: "border-zinc-800 hover:bg-zinc-200 dark:border-white dark:hover:bg-zinc-700"
|
: "border-text hover:brightness-125"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
All
|
All
|
||||||
@@ -129,8 +138,8 @@ export default function BlogIndex() {
|
|||||||
href="/blog?category=blog"
|
href="/blog?category=blog"
|
||||||
class={`rounded border px-4 py-2 transition-all duration-300 ease-out active:scale-90 ${
|
class={`rounded border px-4 py-2 transition-all duration-300 ease-out active:scale-90 ${
|
||||||
category() === "blog"
|
category() === "blog"
|
||||||
? "border-orange-500 bg-orange-400 text-white dark:border-orange-700 dark:bg-orange-700"
|
? "border-peach bg-peach text-base"
|
||||||
: "border-zinc-800 hover:bg-zinc-200 dark:border-white dark:hover:bg-zinc-700"
|
: "border-text hover:brightness-125"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Blog
|
Blog
|
||||||
@@ -139,28 +148,30 @@ export default function BlogIndex() {
|
|||||||
href="/blog?category=project"
|
href="/blog?category=project"
|
||||||
class={`rounded border px-4 py-2 transition-all duration-300 ease-out active:scale-90 ${
|
class={`rounded border px-4 py-2 transition-all duration-300 ease-out active:scale-90 ${
|
||||||
category() === "project"
|
category() === "project"
|
||||||
? "border-blue-500 bg-blue-400 text-white dark:border-blue-700 dark:bg-blue-700"
|
? "border-blue bg-blue text-base"
|
||||||
: "border-zinc-800 hover:bg-zinc-200 dark:border-white dark:hover:bg-zinc-700"
|
: "border-text hover:brightness-125"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Projects
|
Projects
|
||||||
</A>
|
</A>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PostSortingSelect type={category() === "project" ? "project" : "blog"} />
|
<PostSortingSelect
|
||||||
|
type={category() === "project" ? "project" : "blog"}
|
||||||
|
/>
|
||||||
|
|
||||||
<Show when={data() && Object.keys(data()!.tagMap).length > 0}>
|
<Show when={data() && Object.keys(data()!.tagMap).length > 0}>
|
||||||
<TagSelector
|
<TagSelector
|
||||||
tagMap={data()!.tagMap}
|
tagMap={data()!.tagMap}
|
||||||
category={category() === "project" ? "project" : "blog"}
|
category={category() === "project" ? "project" : "blog"}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={privilegeLevel === "admin"}>
|
<Show when={privilegeLevel === "admin"}>
|
||||||
<div class="mt-2 flex justify-center md:mt-0 md:justify-end">
|
<div class="mt-2 flex justify-center md:mt-0 md:justify-end">
|
||||||
<A
|
<A
|
||||||
href="/blog/create"
|
href="/blog/create"
|
||||||
class="rounded border border-zinc-800 px-4 py-2 transition-all duration-300 ease-out hover:bg-zinc-200 active:scale-90 dark:border-white dark:hover:bg-zinc-700 md:mr-4"
|
class="border-text rounded border px-4 py-2 transition-all duration-300 ease-out hover:brightness-125 active:scale-90 md:mr-4"
|
||||||
>
|
>
|
||||||
Create Post
|
Create Post
|
||||||
</A>
|
</A>
|
||||||
@@ -168,7 +179,7 @@ export default function BlogIndex() {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div class="mx-auto pt-48">
|
<div class="mx-auto pt-48">
|
||||||
@@ -178,7 +189,7 @@ export default function BlogIndex() {
|
|||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={data() && data()!.posts.length > 0}
|
when={data() && data()!.posts.length > 0}
|
||||||
fallback={<div class="text-center pt-12">No posts yet!</div>}
|
fallback={<div class="pt-12 text-center">No posts yet!</div>}
|
||||||
>
|
>
|
||||||
<div class="mx-auto flex w-11/12 flex-col pt-8">
|
<div class="mx-auto flex w-11/12 flex-col pt-8">
|
||||||
<PostSorting
|
<PostSorting
|
||||||
|
|||||||
@@ -21,36 +21,36 @@ export default function DownloadsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="pb-12 pt-[15vh] bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 min-h-screen">
|
<div class="bg-base min-h-screen pt-[15vh] pb-12">
|
||||||
<div class="text-center text-3xl tracking-widest dark:text-white">
|
<div class="text-text text-center text-3xl tracking-widest">
|
||||||
Downloads
|
Downloads
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pt-12">
|
<div class="pt-12">
|
||||||
<div class="text-center text-xl tracking-wide dark:text-white">
|
<div class="text-text text-center text-xl tracking-wide">
|
||||||
Life and Lineage
|
Life and Lineage
|
||||||
<br />
|
<br />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-evenly md:mx-[25vw]">
|
<div class="flex justify-evenly md:mx-[25vw]">
|
||||||
<div class="flex flex-col w-1/3">
|
<div class="flex w-1/3 flex-col">
|
||||||
<div class="text-center text-lg">Android (apk only)</div>
|
<div class="text-center text-lg">Android (apk only)</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => download("lineage")}
|
onClick={() => download("lineage")}
|
||||||
class="mt-2 rounded-md bg-blue-500 px-4 py-2 text-white shadow-lg transition-all duration-200 ease-out hover:opacity-90 active:scale-95 active:opacity-90"
|
class="bg-blue mt-2 rounded-md px-4 py-2 text-base shadow-lg transition-all duration-200 ease-out hover:brightness-125 active:scale-95"
|
||||||
>
|
>
|
||||||
Download APK
|
Download APK
|
||||||
</button>
|
</button>
|
||||||
<div class="text-center italic text-sm mt-2">
|
<div class="mt-2 text-center text-sm italic">
|
||||||
Note the android version is not well tested, and has performance
|
Note the android version is not well tested, and has performance
|
||||||
issues.
|
issues.
|
||||||
</div>
|
</div>
|
||||||
<div class="rule-around">Or</div>
|
<div class="rule-around">Or</div>
|
||||||
|
|
||||||
<div class="italic mx-auto">(Coming soon)</div>
|
<div class="mx-auto italic">(Coming soon)</div>
|
||||||
<button
|
<button
|
||||||
onClick={joinBetaPrompt}
|
onClick={joinBetaPrompt}
|
||||||
class="transition-all mx-auto duration-200 ease-out active:scale-95"
|
class="mx-auto transition-all duration-200 ease-out active:scale-95"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/google-play-badge.png"
|
src="/google-play-badge.png"
|
||||||
@@ -85,12 +85,12 @@ export default function DownloadsPage() {
|
|||||||
<div class="text-center text-lg">Android</div>
|
<div class="text-center text-lg">Android</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => download("shapes-with-abigail")}
|
onClick={() => download("shapes-with-abigail")}
|
||||||
class="mt-2 rounded-md bg-blue-500 px-4 py-2 text-white shadow-lg transition-all duration-200 ease-out hover:opacity-90 active:scale-95 active:opacity-90"
|
class="bg-blue mt-2 rounded-md px-4 py-2 text-base shadow-lg transition-all duration-200 ease-out hover:brightness-125 active:scale-95"
|
||||||
>
|
>
|
||||||
Download APK
|
Download APK
|
||||||
</button>
|
</button>
|
||||||
<div class="rule-around">Or</div>
|
<div class="rule-around">Or</div>
|
||||||
<div class="italic mx-auto">(Coming soon)</div>
|
<div class="mx-auto italic">(Coming soon)</div>
|
||||||
<button
|
<button
|
||||||
onClick={joinBetaPrompt}
|
onClick={joinBetaPrompt}
|
||||||
class="transition-all duration-200 ease-out active:scale-95"
|
class="transition-all duration-200 ease-out active:scale-95"
|
||||||
@@ -116,7 +116,7 @@ export default function DownloadsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pt-12">
|
<div class="pt-12">
|
||||||
<div class="text-center text-xl tracking-wide dark:text-white">
|
<div class="text-text text-center text-xl tracking-wide">
|
||||||
Cork
|
Cork
|
||||||
<br />
|
<br />
|
||||||
(macOS 13 Ventura or later)
|
(macOS 13 Ventura or later)
|
||||||
@@ -125,7 +125,7 @@ export default function DownloadsPage() {
|
|||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => download("cork")}
|
onClick={() => download("cork")}
|
||||||
class="my-2 rounded-md bg-blue-500 px-4 py-2 text-white shadow-lg transition-all duration-200 ease-out hover:opacity-90 active:scale-95 active:opacity-90"
|
class="bg-blue my-2 rounded-md px-4 py-2 text-base shadow-lg transition-all duration-200 ease-out hover:brightness-125 active:scale-95"
|
||||||
>
|
>
|
||||||
Download app
|
Download app
|
||||||
</button>
|
</button>
|
||||||
@@ -135,15 +135,15 @@ export default function DownloadsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="icons flex justify-center pb-6 pt-24 gap-4">
|
<ul class="icons flex justify-center gap-4 pt-24 pb-6">
|
||||||
<li>
|
<li>
|
||||||
<A
|
<A
|
||||||
href="https://github.com/MikeFreno/"
|
href="https://github.com/MikeFreno/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
class="shaker rounded-full border border-zinc-800 dark:border-zinc-300 inline-block hover:scale-110 transition-transform"
|
class="shaker border-text inline-block rounded-full border transition-transform hover:scale-110"
|
||||||
>
|
>
|
||||||
<span class="m-auto p-2 block">
|
<span class="m-auto block p-2">
|
||||||
<GitHub height={24} width={24} fill={undefined} />
|
<GitHub height={24} width={24} fill={undefined} />
|
||||||
</span>
|
</span>
|
||||||
</A>
|
</A>
|
||||||
@@ -153,9 +153,9 @@ export default function DownloadsPage() {
|
|||||||
href="https://www.linkedin.com/in/michael-freno-176001256/"
|
href="https://www.linkedin.com/in/michael-freno-176001256/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
class="shaker rounded-full border border-zinc-800 dark:border-zinc-300 inline-block hover:scale-110 transition-transform"
|
class="shaker border-text inline-block rounded-full border transition-transform hover:scale-110"
|
||||||
>
|
>
|
||||||
<span class="m-auto rounded-md p-2 block">
|
<span class="m-auto block rounded-md p-2">
|
||||||
<LinkedIn height={24} width={24} fill={undefined} />
|
<LinkedIn height={24} width={24} fill={undefined} />
|
||||||
</span>
|
</span>
|
||||||
</A>
|
</A>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createSignal, For, Show } from "solid-js";
|
import { createSignal, For, Show } from "solid-js";
|
||||||
|
import { api } from "~/lib/api";
|
||||||
|
|
||||||
type EndpointTest = {
|
type EndpointTest = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -105,7 +106,8 @@ const routerSections: RouterSection[] = [
|
|||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
token: "eyJhbGciOiJIUzI1NiJ9...",
|
token: "eyJhbGciOiJIUzI1NiJ9...",
|
||||||
rememberMe: true
|
rememberMe: true
|
||||||
}
|
},
|
||||||
|
requiresAuth: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Request Password Reset",
|
name: "Request Password Reset",
|
||||||
@@ -287,6 +289,14 @@ const routerSections: RouterSection[] = [
|
|||||||
published: true,
|
published: true,
|
||||||
author_id: "user_123"
|
author_id: "user_123"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Delete Post",
|
||||||
|
router: "database",
|
||||||
|
procedure: "deletePost",
|
||||||
|
method: "mutation",
|
||||||
|
description: "Delete a post and its associated data",
|
||||||
|
sampleInput: { id: 1 }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -597,8 +607,7 @@ const routerSections: RouterSection[] = [
|
|||||||
procedure: "emailLogin",
|
procedure: "emailLogin",
|
||||||
method: "mutation",
|
method: "mutation",
|
||||||
description: "Login with email/password (requires verified email)",
|
description: "Login with email/password (requires verified email)",
|
||||||
sampleInput: { email: "test@example.com", password: "password123" },
|
sampleInput: { email: "test@example.com", password: "password123" }
|
||||||
requiresAuth: true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Email Verification",
|
name: "Email Verification",
|
||||||
@@ -873,34 +882,33 @@ export default function TestPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let url = `/api/trpc/${endpoint.router}.${endpoint.procedure}`;
|
// Navigate the router path (handles nested routers like "lineage.auth")
|
||||||
const options: RequestInit = {
|
const routerParts = endpoint.router.split(".");
|
||||||
method: endpoint.method === "query" ? "GET" : "POST",
|
let currentRouter: any = api;
|
||||||
headers: {}
|
|
||||||
};
|
|
||||||
|
|
||||||
// For queries, input goes in URL parameter
|
for (const part of routerParts) {
|
||||||
if (endpoint.method === "query" && input !== undefined) {
|
currentRouter = currentRouter[part];
|
||||||
const encodedInput = encodeURIComponent(JSON.stringify(input));
|
if (!currentRouter) {
|
||||||
url += `?input=${encodedInput}`;
|
throw new Error(`Router path not found: ${endpoint.router}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For mutations, input goes in body
|
const procedure = currentRouter[endpoint.procedure];
|
||||||
if (endpoint.method === "mutation" && input !== undefined) {
|
if (!procedure) {
|
||||||
options.headers = { "Content-Type": "application/json" };
|
throw new Error(
|
||||||
options.body = JSON.stringify(input);
|
`Procedure not found: ${endpoint.router}.${endpoint.procedure}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(url, options);
|
// Call the tRPC procedure with proper method
|
||||||
|
const data =
|
||||||
|
endpoint.method === "query"
|
||||||
|
? await procedure.query(input)
|
||||||
|
: await procedure.mutate(input);
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
setResults({ ...results(), [key]: data });
|
setResults({ ...results(), [key]: data });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setErrors({ ...errors(), [key]: error.message });
|
setErrors({ ...errors(), [key]: error.message || String(error) });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading({ ...loading(), [key]: false });
|
setLoading({ ...loading(), [key]: false });
|
||||||
}
|
}
|
||||||
|
|||||||
234
src/types/comment.ts
Normal file
234
src/types/comment.ts
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
/**
|
||||||
|
* Comment System Type Definitions
|
||||||
|
*
|
||||||
|
* Types for the blog comment system including:
|
||||||
|
* - Comment and CommentReaction models
|
||||||
|
* - WebSocket message types
|
||||||
|
* - User data structures
|
||||||
|
* - Component prop interfaces
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Core Data Models
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface Comment {
|
||||||
|
id: number;
|
||||||
|
body: string;
|
||||||
|
post_id: number;
|
||||||
|
parent_comment_id: number | null;
|
||||||
|
commenter_id: string;
|
||||||
|
edited: boolean;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentReaction {
|
||||||
|
id: number;
|
||||||
|
comment_id: number;
|
||||||
|
user_id: string;
|
||||||
|
type: ReactionType;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReactionType =
|
||||||
|
| "tears"
|
||||||
|
| "blank"
|
||||||
|
| "tongue"
|
||||||
|
| "cry"
|
||||||
|
| "heartEye"
|
||||||
|
| "angry"
|
||||||
|
| "moneyEye"
|
||||||
|
| "sick"
|
||||||
|
| "upsideDown"
|
||||||
|
| "worried";
|
||||||
|
|
||||||
|
export interface UserPublicData {
|
||||||
|
email?: string;
|
||||||
|
display_name?: string;
|
||||||
|
image?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// WebSocket Message Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface WebSocketBroadcast {
|
||||||
|
action:
|
||||||
|
| "commentCreationBroadcast"
|
||||||
|
| "commentUpdateBroadcast"
|
||||||
|
| "commentDeletionBroadcast"
|
||||||
|
| "commentReactionBroadcast";
|
||||||
|
commentID: number;
|
||||||
|
commentBody?: string;
|
||||||
|
commenterID: string;
|
||||||
|
commentParent?: number | null;
|
||||||
|
reactionType?: ReactionType;
|
||||||
|
deletionType?: "user" | "admin" | "database";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupResponse {
|
||||||
|
commentID: number;
|
||||||
|
commentBody?: string;
|
||||||
|
commenterID: string;
|
||||||
|
commentParent?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Privilege and Sorting Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type PrivilegeLevel = "admin" | "user" | "anonymous";
|
||||||
|
|
||||||
|
export type PostType = "blog" | "project";
|
||||||
|
|
||||||
|
export type SortingMode = "newest" | "oldest" | "highest_rated" | "hot";
|
||||||
|
|
||||||
|
export type DeletionType = "user" | "admin" | "database";
|
||||||
|
|
||||||
|
export type ModificationType = "delete" | "edit";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component Props Interfaces
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface CommentSectionWrapperProps {
|
||||||
|
privilegeLevel: PrivilegeLevel;
|
||||||
|
allComments: Comment[];
|
||||||
|
topLevelComments: Comment[];
|
||||||
|
id: number;
|
||||||
|
type: PostType;
|
||||||
|
reactionMap: Map<number, CommentReaction[]>;
|
||||||
|
currentUserID: string;
|
||||||
|
userCommentMap: Map<UserPublicData, number[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentSectionProps {
|
||||||
|
privilegeLevel: PrivilegeLevel;
|
||||||
|
type: PostType;
|
||||||
|
postID: number;
|
||||||
|
allComments: Comment[];
|
||||||
|
topLevelComments: Comment[];
|
||||||
|
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 interface CommentBlockProps {
|
||||||
|
comment: Comment;
|
||||||
|
category: PostType;
|
||||||
|
projectID: number;
|
||||||
|
recursionCount: number;
|
||||||
|
allComments: Comment[] | undefined;
|
||||||
|
child_comments: Comment[] | undefined;
|
||||||
|
privilegeLevel: PrivilegeLevel;
|
||||||
|
currentUserID: string;
|
||||||
|
reactionMap: Map<number, CommentReaction[]>;
|
||||||
|
level: number;
|
||||||
|
socket: WebSocket | undefined;
|
||||||
|
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 interface CommentInputBlockProps {
|
||||||
|
isReply: boolean;
|
||||||
|
parent_id?: number;
|
||||||
|
privilegeLevel: PrivilegeLevel;
|
||||||
|
type: PostType;
|
||||||
|
post_id: number;
|
||||||
|
socket: WebSocket | undefined;
|
||||||
|
currentUserID: string;
|
||||||
|
newComment: (commentBody: string, parentCommentID?: number) => Promise<void>;
|
||||||
|
commentSubmitLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentSortingProps {
|
||||||
|
topLevelComments: Comment[];
|
||||||
|
privilegeLevel: PrivilegeLevel;
|
||||||
|
type: PostType;
|
||||||
|
postID: number;
|
||||||
|
allComments: Comment[];
|
||||||
|
reactionMap: Map<number, CommentReaction[]>;
|
||||||
|
currentUserID: string;
|
||||||
|
socket: WebSocket | undefined;
|
||||||
|
userCommentMap: Map<UserPublicData, number[]> | undefined;
|
||||||
|
newComment: (commentBody: string, parentCommentID?: number) => Promise<void>;
|
||||||
|
editComment: (body: string, comment_id: number) => Promise<void>;
|
||||||
|
toggleModification: (
|
||||||
|
commentID: number,
|
||||||
|
commenterID: string,
|
||||||
|
commentBody: string,
|
||||||
|
modificationType: ModificationType,
|
||||||
|
commenterImage?: string,
|
||||||
|
commenterEmail?: string,
|
||||||
|
commenterDisplayName?: string
|
||||||
|
) => void;
|
||||||
|
commentSubmitLoading: boolean;
|
||||||
|
selectedSorting: {
|
||||||
|
val: SortingMode;
|
||||||
|
};
|
||||||
|
commentReaction: (reactionType: ReactionType, commentID: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentSortingSelectProps {
|
||||||
|
selectedSorting: {
|
||||||
|
val: SortingMode;
|
||||||
|
};
|
||||||
|
setSorting: (mode: SortingMode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReactionBarProps {
|
||||||
|
currentUserID: string;
|
||||||
|
commentID: number;
|
||||||
|
reactions: CommentReaction[];
|
||||||
|
privilegeLevel: PrivilegeLevel;
|
||||||
|
showingReactionOptions: boolean;
|
||||||
|
commentReaction: (reactionType: ReactionType, commentID: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentDeletionPromptProps {
|
||||||
|
privilegeLevel: PrivilegeLevel;
|
||||||
|
commentID: number;
|
||||||
|
commenterID: string;
|
||||||
|
deleteComment: (
|
||||||
|
commentID: number,
|
||||||
|
commenterID: string,
|
||||||
|
deletionType: DeletionType
|
||||||
|
) => void;
|
||||||
|
commentDeletionLoading: boolean;
|
||||||
|
commenterImage?: string;
|
||||||
|
commenterEmail?: string;
|
||||||
|
commenterDisplayName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditCommentModalProps {
|
||||||
|
commentID: number;
|
||||||
|
commentBody: string;
|
||||||
|
editComment: (body: string, comment_id: number) => Promise<void>;
|
||||||
|
editCommentLoading: boolean;
|
||||||
|
commenterImage?: string;
|
||||||
|
commenterEmail?: string;
|
||||||
|
commenterDisplayName?: string;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user