diff --git a/bun.lockb b/bun.lockb index 4de0424..4dd853e 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index c1041dc..c3b2979 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "node": ">=22" }, "devDependencies": { + "@tailwindcss/typography": "^0.5.19", "@types/bcrypt": "^6.0.0", "prettier": "^3.7.4", "prettier-plugin-tailwindcss": "^0.7.2", diff --git a/src/app.css b/src/app.css index b4147e5..0181aa0 100644 --- a/src/app.css +++ b/src/app.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@plugin "@tailwindcss/typography"; :root { /* Comments indicate what they are used for in vim/term diff --git a/src/components/ErrorBoundaryFallback.tsx b/src/components/ErrorBoundaryFallback.tsx index a77cadc..ed313b1 100644 --- a/src/components/ErrorBoundaryFallback.tsx +++ b/src/components/ErrorBoundaryFallback.tsx @@ -9,7 +9,16 @@ export interface ErrorBoundaryFallbackProps { export default function ErrorBoundaryFallback( props: ErrorBoundaryFallbackProps ) { - const navigate = useNavigate(); + // Try to get navigate, but handle case where we're outside router context + let navigate: ((path: string) => void) | undefined; + try { + navigate = useNavigate(); + } catch (e) { + // If we're outside router context, fallback to window.location + navigate = (path: string) => { + window.location.href = path; + }; + } const [glitchText, setGlitchText] = createSignal("ERROR"); createEffect(() => { diff --git a/src/components/Typewriter.tsx b/src/components/Typewriter.tsx index 9efe76b..6b062cc 100644 --- a/src/components/Typewriter.tsx +++ b/src/components/Typewriter.tsx @@ -14,7 +14,7 @@ export function Typewriter(props: { const [isTyping, setIsTyping] = createSignal(false); const [isDelaying, setIsDelaying] = createSignal(delay > 0); const [keepAliveCountdown, setKeepAliveCountdown] = createSignal( - typeof keepAlive === "number" ? keepAlive : -1, + typeof keepAlive === "number" ? keepAlive : -1 ); const resolved = children(() => props.children); const { showSplash } = useSplash(); @@ -33,7 +33,7 @@ export function Typewriter(props: { textNodes.push({ node: node as Text, text: text, - startIndex: totalChars, + startIndex: totalChars }); totalChars += text.length; @@ -45,7 +45,7 @@ export function Typewriter(props: { charSpan.style.opacity = "0"; charSpan.setAttribute( "data-char-index", - String(totalChars - text.length + i), + String(totalChars - text.length + i) ); span.appendChild(charSpan); }); @@ -60,7 +60,7 @@ export function Typewriter(props: { // Position cursor at the first character location const firstChar = containerRef.querySelector( - '[data-char-index="0"]', + '[data-char-index="0"]' ) as HTMLElement; if (firstChar && cursorRef) { // Insert cursor before the first character @@ -96,7 +96,7 @@ export function Typewriter(props: { const revealNextChar = () => { if (currentIndex < totalChars) { const charSpan = containerRef?.querySelector( - `[data-char-index="${currentIndex}"]`, + `[data-char-index="${currentIndex}"]` ) as HTMLElement; if (charSpan) { @@ -106,7 +106,7 @@ export function Typewriter(props: { if (cursorRef) { charSpan.parentNode?.insertBefore( cursorRef, - charSpan.nextSibling, + charSpan.nextSibling ); // Match the height of the current character diff --git a/src/components/blog/CommentBlock.tsx b/src/components/blog/CommentBlock.tsx new file mode 100644 index 0000000..a6f6357 --- /dev/null +++ b/src/components/blog/CommentBlock.tsx @@ -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([]); + const [windowWidth, setWindowWidth] = createSignal(0); + const [deletionLoading, setDeletionLoading] = createSignal(false); + const [userData, setUserData] = createSignal(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 */} + + + + + {/* Expanded state */} + +
+
+ {/* Vote buttons column */} +
+ {/* Upvote */} + + + {/* Vote count */} +
{upvoteCount() - downvoteCount()}
+ + {/* Downvote */} + +
+ + {/* Collapse toggle line */} + + + {/* Comment content */} +
+
+
+ {props.comment.body} +
+ +
Edited
+
+
+ + {/* User info */} +
+ + } + > + user-image + +
+ {userData()?.display_name || userData()?.email || "[removed]"} +
+ + {/* Delete button */} + + + +
+ + {/* Edit and Reply buttons */} +
+ + + + +
+ + {/* Reaction bar */} +
0 + ? "" + : "opacity-0" + } ml-16`} + > + +
+
+
+ + {/* Reply box */} + +
+ +
+
+ + {/* Recursive child comments */} +
+ + {(childComment) => ( + 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} + /> + )} + +
+
+
+ + ); +} diff --git a/src/components/blog/CommentDeletionPrompt.tsx b/src/components/blog/CommentDeletionPrompt.tsx new file mode 100644 index 0000000..f6da23d --- /dev/null +++ b/src/components/blog/CommentDeletionPrompt.tsx @@ -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 ( +
+
+
+ +
+ Comment Deletion +
+
+
+ {/* Comment body will be passed as prop */} +
+
+ + } + > + user-image + +
+ {props.commenterDisplayName || + props.commenterEmail || + "[removed]"} +
+
+
+
+
+ +
+ {props.privilegeLevel === "admin" + ? "Confirm User Delete?" + : "Confirm Delete?"} +
+
+
+ +
+
+ +
+ Confirm Admin Delete? +
+
+
+
+
+ +
+ Confirm Full Delete (removal from database)? +
+
+
+
+
+ +
+
+
+
+ ); +} diff --git a/src/components/blog/CommentInputBlock.tsx b/src/components/blog/CommentInputBlock.tsx new file mode 100644 index 0000000..5119d0e --- /dev/null +++ b/src/components/blog/CommentInputBlock.tsx @@ -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 ( +
+
+
+
+