migration of comments

This commit is contained in:
Michael Freno
2025-12-17 22:47:19 -05:00
parent a092c57d36
commit 1bc57c61eb
37 changed files with 3022 additions and 442 deletions

BIN
bun.lockb

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,388 @@
import {
createSignal,
createEffect,
For,
Show,
onMount,
onCleanup
} from "solid-js";
import { useLocation } from "@solidjs/router";
import type {
CommentBlockProps,
CommentReaction,
UserPublicData
} from "~/types/comment";
import { debounce } from "~/lib/comment-utils";
import UserDefaultImage from "~/components/icons/UserDefaultImage";
import ReplyIcon from "~/components/icons/ReplyIcon";
import TrashIcon from "~/components/icons/TrashIcon";
import EditIcon from "~/components/icons/EditIcon";
import ThumbsUpEmoji from "~/components/icons/emojis/ThumbsUp";
import LoadingSpinner from "~/components/LoadingSpinner";
import CommentInputBlock from "./CommentInputBlock";
import ReactionBar from "./ReactionBar";
export default function CommentBlock(props: CommentBlockProps) {
const location = useLocation();
// State signals
const [commentCollapsed, setCommentCollapsed] = createSignal(false);
const [showingReactionOptions, setShowingReactionOptions] =
createSignal(false);
const [replyBoxShowing, setReplyBoxShowing] = createSignal(false);
const [toggleHeight, setToggleHeight] = createSignal(0);
const [reactions, setReactions] = createSignal<CommentReaction[]>([]);
const [windowWidth, setWindowWidth] = createSignal(0);
const [deletionLoading, setDeletionLoading] = createSignal(false);
const [userData, setUserData] = createSignal<UserPublicData | null>(null);
// Refs
let containerRef: HTMLDivElement | undefined;
let commentInputRef: HTMLDivElement | undefined;
// Auto-collapse at level 4+
createEffect(() => {
setCommentCollapsed(props.level >= 4);
});
// Window resize handler
onMount(() => {
const handleResize = debounce(() => {
setWindowWidth(window.innerWidth);
}, 200);
window.addEventListener("resize", handleResize);
onCleanup(() => {
window.removeEventListener("resize", handleResize);
});
});
// Find user data from comment map
createEffect(() => {
if (props.userCommentMap) {
props.userCommentMap.forEach((commentIds, user) => {
if (commentIds.includes(props.comment.id)) {
setUserData(user);
}
});
}
});
// Update toggle height based on container size
createEffect(() => {
if (containerRef) {
const correction = showingReactionOptions() ? 80 : 48;
setToggleHeight(containerRef.clientHeight + correction);
}
// Trigger on these dependencies
windowWidth();
showingReactionOptions();
});
// Update reactions from map
createEffect(() => {
setReactions(props.reactionMap.get(props.comment.id) || []);
});
// Event handlers
const collapseCommentToggle = (e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
setCommentCollapsed(!commentCollapsed());
};
const showingReactionOptionsToggle = (e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
setShowingReactionOptions(!showingReactionOptions());
};
const toggleCommentReplyBox = (e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
setReplyBoxShowing(!replyBoxShowing());
};
const deleteCommentTrigger = async (e: MouseEvent) => {
e.stopPropagation();
setDeletionLoading(true);
const user = userData();
props.toggleModification(
props.comment.id,
props.comment.commenter_id,
props.comment.body,
"delete",
user?.image,
user?.email,
user?.display_name
);
setDeletionLoading(false);
};
const editCommentTrigger = (e: MouseEvent) => {
e.stopPropagation();
const user = userData();
props.toggleModification(
props.comment.id,
props.comment.commenter_id,
props.comment.body,
"edit",
user?.image,
user?.email,
user?.display_name
);
};
// Computed values
const upvoteCount = () =>
reactions().filter((r) => r.type === "upVote").length;
const downvoteCount = () =>
reactions().filter((r) => r.type === "downVote").length;
const hasUpvoted = () =>
reactions().some(
(r) => r.type === "upVote" && r.user_id === props.currentUserID
);
const hasDownvoted = () =>
reactions().some(
(r) => r.type === "downVote" && r.user_id === props.currentUserID
);
const canDelete = () =>
props.currentUserID === props.comment.commenter_id ||
props.privilegeLevel === "admin";
const canEdit = () => props.currentUserID === props.comment.commenter_id;
const isAnonymous = () => props.privilegeLevel === "anonymous";
const replyIconColor = () =>
location.pathname.split("/")[1] === "blog" ? "#fb923c" : "#60a5fa";
return (
<>
{/* Collapsed state */}
<Show when={commentCollapsed()}>
<button
onClick={collapseCommentToggle}
class="ml-5 w-full px-2 lg:w-3/4"
>
<div class="my-auto mt-1 mr-2 h-8 border-l-2 border-black dark:border-white" />
</button>
</Show>
{/* Expanded state */}
<Show when={!commentCollapsed()}>
<div class="z-[500] transition-all duration-300 ease-in-out">
<div class="my-4 flex w-full overflow-x-hidden overflow-y-hidden lg:w-3/4">
{/* Vote buttons column */}
<div
class="flex flex-col justify-between"
style={{ height: `${toggleHeight()}px` }}
>
{/* Upvote */}
<button
onClick={() =>
props.commentReaction("upVote", props.comment.id)
}
>
<div
class={`h-5 w-5 ${
hasUpvoted()
? "fill-emerald-500"
: `fill-black hover:fill-emerald-500 dark:fill-white ${
isAnonymous() ? "tooltip z-50" : ""
}`
}`}
>
<ThumbsUpEmoji />
<Show when={isAnonymous()}>
<div class="tooltip-text -ml-16 w-32 text-white">
You must be logged in
</div>
</Show>
</div>
</button>
{/* Vote count */}
<div class="mx-auto">{upvoteCount() - downvoteCount()}</div>
{/* Downvote */}
<button
onClick={() =>
props.commentReaction("downVote", props.comment.id)
}
>
<div
class={`h-5 w-5 ${
hasDownvoted()
? "fill-rose-500"
: `fill-black hover:fill-rose-500 dark:fill-white ${
isAnonymous() ? "tooltip z-50" : ""
}`
}`}
>
<div class="rotate-180">
<ThumbsUpEmoji />
</div>
<Show when={isAnonymous()}>
<div class="tooltip-text -ml-16 w-32">
You must be logged in
</div>
</Show>
</div>
</button>
</div>
{/* Collapse toggle line */}
<button onClick={collapseCommentToggle} class="z-0 px-2">
<div
class="border-l-2 border-black transition-all duration-300 ease-in-out dark:border-white"
style={{ height: `${toggleHeight()}px` }}
/>
</button>
{/* Comment content */}
<div
class="w-3/4"
onClick={showingReactionOptionsToggle}
id={props.comment.id.toString()}
>
<div
ref={containerRef}
class="overflow-x-hidden overflow-y-hidden select-text"
>
<div class="max-w-[90%] md:max-w-[75%]">
{props.comment.body}
</div>
<Show when={props.comment.edited}>
<div class="pb-0.5 text-xs italic">Edited</div>
</Show>
</div>
{/* User info */}
<div class="flex pl-2">
<Show
when={userData()?.image}
fallback={
<UserDefaultImage strokeWidth={1} height={24} width={24} />
}
>
<img
src={userData()!.image}
height={24}
width={24}
alt="user-image"
class="h-6 w-6 rounded-full object-cover object-center"
/>
</Show>
<div class="px-1">
{userData()?.display_name || userData()?.email || "[removed]"}
</div>
{/* Delete button */}
<Show when={canDelete()}>
<button onClick={deleteCommentTrigger}>
<Show
when={!deletionLoading()}
fallback={<LoadingSpinner height={24} width={24} />}
>
<TrashIcon
height={24}
width={24}
stroke="red"
strokeWidth={1.5}
/>
</Show>
</button>
</Show>
</div>
{/* Edit and Reply buttons */}
<div class="absolute flex">
<Show when={canEdit()}>
<button onClick={editCommentTrigger} class="px-2">
<EditIcon strokeWidth={1} height={24} width={24} />
</button>
</Show>
<button onClick={toggleCommentReplyBox} class="z-30">
<ReplyIcon color={replyIconColor()} height={24} width={24} />
</button>
</div>
{/* Reaction bar */}
<div
class={`${
showingReactionOptions() || reactions().length > 0
? ""
: "opacity-0"
} ml-16`}
>
<ReactionBar
commentID={props.comment.id}
currentUserID={props.currentUserID}
reactions={reactions()}
showingReactionOptions={showingReactionOptions()}
privilegeLevel={props.privilegeLevel}
commentReaction={props.commentReaction}
/>
</div>
</div>
</div>
{/* Reply box */}
<Show when={replyBoxShowing()}>
<div
ref={commentInputRef}
class="fade-in lg:w-2/3"
style={{ "margin-left": `${-24 * props.recursionCount}px` }}
>
<CommentInputBlock
isReply={true}
privilegeLevel={props.privilegeLevel}
parent_id={props.comment.id}
type={props.category}
post_id={props.projectID}
currentUserID={props.currentUserID}
socket={props.socket}
newComment={props.newComment}
commentSubmitLoading={props.commentSubmitLoading}
/>
</div>
</Show>
{/* Recursive child comments */}
<div class="pl-2 sm:pl-4 md:pl-8 lg:pl-12">
<For each={props.child_comments}>
{(childComment) => (
<CommentBlock
comment={childComment}
category={props.category}
projectID={props.projectID}
recursionCount={1}
allComments={props.allComments}
child_comments={props.allComments?.filter(
(comment) => comment.parent_comment_id === childComment.id
)}
privilegeLevel={props.privilegeLevel}
currentUserID={props.currentUserID}
reactionMap={props.reactionMap}
level={props.level + 1}
socket={props.socket}
userCommentMap={props.userCommentMap}
toggleModification={props.toggleModification}
newComment={props.newComment}
commentSubmitLoading={props.commentSubmitLoading}
commentReaction={props.commentReaction}
/>
)}
</For>
</div>
</div>
</Show>
</>
);
}

View File

@@ -0,0 +1,146 @@
import { createSignal, Show } from "solid-js";
import type { CommentDeletionPromptProps, DeletionType } from "~/types/comment";
import UserDefaultImage from "~/components/icons/UserDefaultImage";
import Xmark from "~/components/icons/Xmark";
export default function CommentDeletionPrompt(
props: CommentDeletionPromptProps
) {
const [normalDeleteChecked, setNormalDeleteChecked] = createSignal(false);
const [adminDeleteChecked, setAdminDeleteChecked] = createSignal(false);
const [fullDeleteChecked, setFullDeleteChecked] = createSignal(false);
const handleNormalDeleteCheckbox = () => {
setNormalDeleteChecked(!normalDeleteChecked());
setFullDeleteChecked(false);
setAdminDeleteChecked(false);
};
const handleAdminDeleteCheckbox = () => {
setAdminDeleteChecked(!adminDeleteChecked());
setFullDeleteChecked(false);
setNormalDeleteChecked(false);
};
const handleFullDeleteCheckbox = () => {
setFullDeleteChecked(!fullDeleteChecked());
setNormalDeleteChecked(false);
setAdminDeleteChecked(false);
};
const deletionWrapper = () => {
let deleteType: DeletionType = "user";
if (normalDeleteChecked()) {
deleteType = "user";
} else if (adminDeleteChecked()) {
deleteType = "admin";
} else if (fullDeleteChecked()) {
deleteType = "database";
}
props.deleteComment(props.commentID, props.commenterID, deleteType);
};
const isDeleteEnabled = () =>
normalDeleteChecked() || adminDeleteChecked() || fullDeleteChecked();
return (
<div class="flex justify-center">
<div class="fixed top-48 z-50 h-fit w-11/12 sm:w-4/5 md:w-2/3">
<div
id="delete_prompt"
class="fade-in rounded-md bg-red-400 px-8 py-4 shadow-lg dark:bg-red-900"
>
<button class="absolute right-4" onClick={() => {}}>
<Xmark strokeWidth={0.5} color="white" height={50} width={50} />
</button>
<div class="py-4 text-center text-3xl tracking-wide">
Comment Deletion
</div>
<div class="mx-auto w-3/4 rounded bg-zinc-50 px-6 py-4 dark:bg-zinc-800">
<div class="flex overflow-x-auto overflow-y-hidden select-text">
{/* Comment body will be passed as prop */}
</div>
<div class="my-2 flex pl-2">
<Show
when={props.commenterImage}
fallback={
<UserDefaultImage strokeWidth={1} height={24} width={24} />
}
>
<img
src={props.commenterImage}
height={24}
width={24}
alt="user-image"
class="h-6 w-6 rounded-full object-cover object-center"
/>
</Show>
<div class="px-1">
{props.commenterDisplayName ||
props.commenterEmail ||
"[removed]"}
</div>
</div>
</div>
<div class="flex w-full justify-center">
<div class="flex pt-4">
<input
type="checkbox"
class="my-auto"
checked={normalDeleteChecked()}
onChange={handleNormalDeleteCheckbox}
/>
<div class="my-auto px-2 text-sm font-normal">
{props.privilegeLevel === "admin"
? "Confirm User Delete?"
: "Confirm Delete?"}
</div>
</div>
</div>
<Show when={props.privilegeLevel === "admin"}>
<div class="flex w-full justify-center">
<div class="flex pt-4">
<input
type="checkbox"
class="my-auto"
checked={adminDeleteChecked()}
onChange={handleAdminDeleteCheckbox}
/>
<div class="my-auto px-2 text-sm font-normal">
Confirm Admin Delete?
</div>
</div>
</div>
<div class="flex w-full justify-center">
<div class="flex pt-4">
<input
type="checkbox"
class="my-auto"
checked={fullDeleteChecked()}
onChange={handleFullDeleteCheckbox}
/>
<div class="my-auto px-2 text-sm font-normal">
Confirm Full Delete (removal from database)?
</div>
</div>
</div>
</Show>
<div class="flex w-full justify-center pt-2">
<button
type="button"
onClick={deletionWrapper}
disabled={props.commentDeletionLoading || !isDeleteEnabled()}
class={`${
props.commentDeletionLoading || !isDeleteEnabled()
? "bg-zinc-400"
: "border-orange-500 bg-orange-400 hover:bg-orange-500"
} rounded border px-4 py-2 text-white shadow-md transition-all duration-300 ease-in-out active:scale-90`}
>
Delete
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,81 @@
import { createEffect } from "solid-js";
import type { CommentInputBlockProps } from "~/types/comment";
export default function CommentInputBlock(props: CommentInputBlockProps) {
let bodyRef: HTMLTextAreaElement | undefined;
// Clear the textarea when comment is submitted
createEffect(() => {
if (!props.commentSubmitLoading && bodyRef) {
bodyRef.value = "";
}
});
const newCommentWrapper = (e: SubmitEvent) => {
e.preventDefault();
if (bodyRef && bodyRef.value.length > 0) {
props.newComment(bodyRef.value, props.parent_id);
}
};
if (props.privilegeLevel === "user" || props.privilegeLevel === "admin") {
return (
<div class="flex w-full justify-center select-none">
<div class="h-fit w-3/4 md:w-1/2">
<form onSubmit={newCommentWrapper}>
<div
class={`textarea-group ${props.type === "blog" ? "blog" : ""}`}
>
<textarea
ref={bodyRef}
required
name="message"
placeholder=" "
class="underlinedInput w-full bg-transparent select-text"
rows={props.isReply ? 2 : 4}
/>
<span class="bar" />
<label class="underlinedInputLabel">
{`Enter your ${props.isReply ? "reply" : "message"}`}
</label>
</div>
<div class="flex justify-end pt-2">
<button
type="submit"
disabled={props.commentSubmitLoading}
class={`${
props.commentSubmitLoading
? "bg-zinc-400"
: props.type === "project"
? "border-blue-500 bg-blue-400 hover:bg-blue-500 dark:border-blue-700 dark:bg-blue-700 dark:hover:bg-blue-800"
: "border-orange-500 bg-orange-400 hover:bg-orange-500"
} rounded border px-4 py-2 font-light text-white shadow-md transition-all duration-300 ease-in-out active:scale-90`}
>
Submit
</button>
</div>
</form>
</div>
</div>
);
} else {
return (
<div class="flex w-full justify-center">
<div class={`textarea-group ${props.type === "blog" ? "blog" : ""}`}>
<textarea
required
disabled
name="message"
placeholder=" "
class="underlinedInput w-full bg-transparent"
rows={4}
/>
<span class="bar" />
<label class="underlinedInputLabel">
{`You must be logged in to ${props.isReply ? "reply" : "comment"}`}
</label>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,112 @@
import { createSignal, Show } from "solid-js";
import type {
Comment,
CommentReaction,
UserPublicData,
ReactionType,
ModificationType,
PostType,
PrivilegeLevel,
SortingMode
} from "~/types/comment";
import CommentInputBlock from "./CommentInputBlock";
import CommentSortingSelect from "./CommentSortingSelect";
import CommentSorting from "./CommentSorting";
const COMMENT_SORTING_OPTIONS: { val: SortingMode }[] = [
{ val: "newest" },
{ val: "oldest" },
{ val: "highest_rated" },
{ val: "hot" }
];
interface CommentSectionProps {
privilegeLevel: PrivilegeLevel;
allComments: Comment[];
topLevelComments: Comment[];
type: PostType;
postID: number;
reactionMap: Map<number, CommentReaction[]>;
currentUserID: string;
userCommentMap: Map<UserPublicData, number[]> | undefined;
newComment: (commentBody: string, parentCommentID?: number) => Promise<void>;
commentSubmitLoading: boolean;
toggleModification: (
commentID: number,
commenterID: string,
commentBody: string,
modificationType: ModificationType,
commenterImage?: string,
commenterEmail?: string,
commenterDisplayName?: string
) => void;
commentReaction: (reactionType: ReactionType, commentID: number) => void;
}
export default function CommentSection(props: CommentSectionProps) {
const [selectedSorting, setSelectedSorting] = createSignal<SortingMode>(
COMMENT_SORTING_OPTIONS[0].val
);
const hasComments = () =>
props.allComments &&
props.allComments.length > 0 &&
props.topLevelComments &&
props.topLevelComments.length > 0;
return (
<div class="w-full">
<div
class="text-center text-2xl font-light tracking-widest underline underline-offset-8"
id="comments"
>
Comments
</div>
<div class="mb-1">
<CommentInputBlock
isReply={false}
privilegeLevel={props.privilegeLevel}
type={props.type}
post_id={props.postID}
socket={undefined}
currentUserID={props.currentUserID}
newComment={props.newComment}
commentSubmitLoading={props.commentSubmitLoading}
/>
</div>
<Show
when={hasComments()}
fallback={
<div class="pt-8 text-center text-xl font-thin tracking-wide italic">
No Comments Yet
</div>
}
>
<CommentSortingSelect
selectedSorting={{ val: selectedSorting() }}
setSorting={setSelectedSorting}
/>
<div id="comments">
<CommentSorting
topLevelComments={props.topLevelComments}
privilegeLevel={props.privilegeLevel}
type={props.type}
postID={props.postID}
allComments={props.allComments}
reactionMap={props.reactionMap}
currentUserID={props.currentUserID}
socket={undefined}
userCommentMap={props.userCommentMap}
newComment={props.newComment}
editComment={async () => {}}
toggleModification={props.toggleModification}
commentSubmitLoading={props.commentSubmitLoading}
selectedSorting={{ val: selectedSorting() }}
commentReaction={props.commentReaction}
/>
</div>
</Show>
</div>
);
}

View File

@@ -0,0 +1,555 @@
import { createSignal, createEffect, onCleanup, Show } from "solid-js";
import type {
Comment,
CommentReaction,
CommentSectionWrapperProps,
WebSocketBroadcast,
BackupResponse,
UserPublicData,
ReactionType,
DeletionType
} from "~/types/comment";
import { getSQLFormattedDate } from "~/lib/comment-utils";
import CommentSection from "./CommentSection";
import CommentDeletionPrompt from "./CommentDeletionPrompt";
import EditCommentModal from "./EditCommentModal";
const MAX_RETRIES = 12;
const RETRY_INTERVAL = 5000;
export default function CommentSectionWrapper(
props: CommentSectionWrapperProps
) {
// State signals
const [allComments, setAllComments] = createSignal<Comment[]>(
props.allComments
);
const [topLevelComments, setTopLevelComments] = createSignal<Comment[]>(
props.topLevelComments
);
const [currentReactionMap, setCurrentReactionMap] = createSignal<
Map<number, CommentReaction[]>
>(props.reactionMap);
const [commentSubmitLoading, setCommentSubmitLoading] =
createSignal<boolean>(false);
const [commentDeletionLoading, setCommentDeletionLoading] =
createSignal<boolean>(false);
const [editCommentLoading, setCommentEditLoading] =
createSignal<boolean>(false);
const [showingCommentEdit, setShowingCommentEdit] =
createSignal<boolean>(false);
const [showingDeletionPrompt, setShowingDeletionPrompt] =
createSignal<boolean>(false);
const [commentIDForModification, setCommentIDForModification] =
createSignal<number>(-1);
const [commenterForModification, setCommenterForModification] =
createSignal<string>("");
const [commenterImageForModification, setCommenterImageForModification] =
createSignal<string | undefined>(undefined);
const [commenterEmailForModification, setCommenterEmailForModification] =
createSignal<string | undefined>(undefined);
const [
commenterDisplayNameForModification,
setCommenterDisplayNameForModification
] = createSignal<string | undefined>(undefined);
const [commentBodyForModification, setCommentBodyForModification] =
createSignal<string>("");
// Non-reactive refs (store without triggering reactivity)
let userCommentMap: Map<UserPublicData, number[]> = props.userCommentMap;
let deletePromptRef: HTMLDivElement | undefined;
let modificationPromptRef: HTMLDivElement | undefined;
let retryCount = 0;
let socket: WebSocket | undefined;
// WebSocket connection effect
createEffect(() => {
const connect = () => {
if (socket) return;
if (retryCount > MAX_RETRIES) {
console.error("Max retries exceeded!");
return;
}
const websocketUrl = import.meta.env.VITE_WEBSOCKET;
if (!websocketUrl) {
console.error("VITE_WEBSOCKET environment variable not set");
return;
}
const newSocket = new WebSocket(websocketUrl);
newSocket.onopen = () => {
updateChannel();
retryCount = 0;
};
newSocket.onclose = () => {
retryCount += 1;
socket = undefined;
setTimeout(connect, RETRY_INTERVAL);
};
newSocket.onmessage = (messageEvent) => {
try {
const parsed = JSON.parse(messageEvent.data) as WebSocketBroadcast;
switch (parsed.action) {
case "commentCreationBroadcast":
createCommentHandler(parsed);
break;
case "commentUpdateBroadcast":
editCommentHandler(parsed);
break;
case "commentDeletionBroadcast":
deleteCommentHandler(parsed);
break;
case "commentReactionBroadcast":
commentReactionHandler(parsed);
break;
default:
break;
}
} catch (e) {
console.error(e);
}
};
socket = newSocket;
};
connect();
// Cleanup on unmount
onCleanup(() => {
if (socket?.readyState === WebSocket.OPEN) {
socket.close();
socket = undefined;
}
});
});
// Helper functions
const updateChannel = () => {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(
JSON.stringify({
action: "channelUpdate",
postType: props.type,
postID: props.id,
invoker_id: props.currentUserID
})
);
}
};
// Comment creation
const newComment = async (commentBody: string, parentCommentID?: number) => {
setCommentSubmitLoading(true);
if (commentBody && socket) {
socket.send(
JSON.stringify({
action: "commentCreation",
commentBody: commentBody,
postType: props.type,
postID: props.id,
parentCommentID: parentCommentID,
invokerID: props.currentUserID
})
);
} else {
// Fallback to HTTP API if WebSocket unavailable
const domain = import.meta.env.VITE_DOMAIN;
const res = await fetch(
`${domain}/api/database/comments/create/${props.type}/${props.id}`,
{
method: "POST",
body: JSON.stringify({
body: commentBody,
parentCommentID: parentCommentID,
commenterID: props.currentUserID
})
}
);
if (res.status === 201) {
const id = (await res.json()).data;
createCommentHandler({
commentBody: commentBody,
commentID: id,
commenterID: props.currentUserID,
commentParent: parentCommentID
});
}
}
};
const createCommentHandler = async (
data: WebSocketBroadcast | BackupResponse
) => {
const body = data.commentBody;
const commenterID = data.commenterID;
const parentCommentID = data.commentParent;
const id = data.commentID;
if (body && commenterID && parentCommentID !== undefined && id) {
const domain = import.meta.env.VITE_DOMAIN;
const res = await fetch(
`${domain}/api/database/user/public-data/${commenterID}`
);
const userData = (await res.json()) as UserPublicData;
const comment_date = getSQLFormattedDate();
const newComment: Comment = {
id: id,
body: body,
post_id: props.id,
parent_comment_id: parentCommentID,
commenter_id: commenterID,
edited: false,
date: comment_date
};
if (parentCommentID === -1 || parentCommentID === null) {
setTopLevelComments((prevComments) => [
...(prevComments || []),
newComment
]);
}
setAllComments((prevComments) => [...(prevComments || []), newComment]);
// Update user comment map
const existingIDs = Array.from(userCommentMap.entries()).find(
([key, _]) =>
key.email === userData.email &&
key.display_name === userData.display_name &&
key.image === userData.image
);
if (existingIDs) {
const [key, ids] = existingIDs;
userCommentMap.set(key, [...ids, id]);
} else {
userCommentMap.set(userData, [id]);
}
}
setCommentSubmitLoading(false);
};
// Comment updating
const editComment = async (body: string, comment_id: number) => {
setCommentEditLoading(true);
if (socket) {
socket.send(
JSON.stringify({
action: "commentUpdate",
commentBody: body,
postType: props.type,
postID: props.id,
commentID: comment_id,
invokerID: props.currentUserID
})
);
}
};
const editCommentHandler = (data: WebSocketBroadcast) => {
setAllComments((prev) =>
prev.map((comment) => {
if (comment.id === data.commentID) {
return {
...comment,
body: data.commentBody!,
edited: true
};
}
return comment;
})
);
setTopLevelComments((prev) =>
prev.map((comment) => {
if (comment.id === data.commentID) {
return {
...comment,
body: data.commentBody!,
edited: true
};
}
return comment;
})
);
setCommentEditLoading(false);
setTimeout(() => {
setShowingCommentEdit(false);
clearModificationPrompt();
}, 300);
};
// Comment deletion
const deleteComment = (
commentID: number,
commenterID: string,
deletionType: DeletionType
) => {
setCommentDeletionLoading(true);
if (socket) {
socket.send(
JSON.stringify({
action: "commentDeletion",
deleteType: deletionType,
commentID: commentID,
invokerID: props.currentUserID,
postType: props.type,
postID: props.id
})
);
}
};
const deleteCommentHandler = (data: WebSocketBroadcast) => {
if (data.commentBody) {
// Soft delete (replace body with deletion message)
setAllComments((prev) =>
prev.map((comment) => {
if (comment.id === data.commentID) {
return {
...comment,
body: data.commentBody!,
commenter_id: "",
edited: false
};
}
return comment;
})
);
setTopLevelComments((prev) =>
prev.map((comment) => {
if (comment.id === data.commentID) {
return {
...comment,
body: data.commentBody!,
commenter_id: "",
edited: false
};
}
return comment;
})
);
} else {
// Hard delete (remove from list)
setAllComments((prev) =>
prev.filter((comment) => comment.id !== data.commentID)
);
setTopLevelComments((prev) =>
prev.filter((comment) => comment.id !== data.commentID)
);
}
setCommentDeletionLoading(false);
setTimeout(() => {
clearModificationPrompt();
setShowingDeletionPrompt(false);
}, 300);
};
// Deletion/edit prompt toggle
const toggleModification = (
commentID: number,
commenterID: string,
commentBody: string,
modificationType: "delete" | "edit",
commenterImage?: string,
commenterEmail?: string,
commenterDisplayName?: string
) => {
if (commentID === commentIDForModification()) {
if (modificationType === "delete") {
setShowingDeletionPrompt(false);
} else {
setShowingCommentEdit(false);
}
clearModificationPrompt();
} else {
if (modificationType === "delete") {
setShowingDeletionPrompt(true);
} else {
setShowingCommentEdit(true);
}
setCommentIDForModification(commentID);
setCommenterForModification(commenterID);
setCommenterImageForModification(commenterImage);
setCommenterEmailForModification(commenterEmail);
setCommenterDisplayNameForModification(commenterDisplayName);
setCommentBodyForModification(commentBody);
}
};
const clearModificationPrompt = () => {
setCommentIDForModification(-1);
setCommenterForModification("");
setCommenterImageForModification(undefined);
setCommenterEmailForModification(undefined);
setCommenterDisplayNameForModification(undefined);
setCommentBodyForModification("");
};
// Reaction handling
const commentReaction = (reactionType: ReactionType, commentID: number) => {
if (socket) {
socket.send(
JSON.stringify({
action: "commentReaction",
postType: props.type,
postID: props.id,
commentID: commentID,
invokerID: props.currentUserID,
reactionType: reactionType
})
);
}
};
const commentReactionHandler = (data: any) => {
switch (data.endEffect) {
case "creation":
if (data.commentID && data.reactionType && data.reactingUserID) {
const newReaction: CommentReaction = {
id: -1,
type: data.reactionType,
comment_id: data.commentID,
user_id: data.reactingUserID,
date: getSQLFormattedDate()
};
setCurrentReactionMap((prevMap) => {
const entries = [
...(prevMap.get(data.commentID!) || []),
newReaction
];
return new Map([...prevMap, [data.commentID!, entries]]);
});
}
break;
case "deletion":
if (data.commentID) {
setCurrentReactionMap((prevMap) => {
const entries = (prevMap.get(data.commentID!) || []).filter(
(reaction) =>
reaction.user_id !== data.reactingUserID ||
reaction.type !== data.reactionType
);
return new Map([...prevMap, [data.commentID!, entries]]);
});
}
break;
case "inversion":
// Only applies to upvotes/downvotes (vote inversion)
if (
data.commentID &&
data.reactingUserID &&
data.reactionType &&
(data.reactionType === "upVote" || data.reactionType === "downVote")
) {
setCurrentReactionMap((prevMap) => {
let entries = (prevMap.get(data.commentID!) || []).filter(
(reaction) =>
reaction.user_id !== data.reactingUserID ||
reaction.type !==
(data.reactionType === "upVote" ? "downVote" : "upVote")
);
const newReaction: CommentReaction = {
id: -1,
type: data.reactionType!,
comment_id: data.commentID!,
user_id: data.reactingUserID!,
date: getSQLFormattedDate()
};
entries = entries.concat(newReaction);
return new Map([...prevMap, [data.commentID!, entries]]);
});
}
break;
default:
console.log("endEffect value unknown");
}
};
// Click outside handlers (SolidJS version)
createEffect(() => {
const handleClickOutsideDelete = (e: MouseEvent) => {
if (
deletePromptRef &&
!deletePromptRef.contains(e.target as Node) &&
showingDeletionPrompt()
) {
setShowingDeletionPrompt(false);
}
};
const handleClickOutsideEdit = (e: MouseEvent) => {
if (
modificationPromptRef &&
!modificationPromptRef.contains(e.target as Node) &&
showingCommentEdit()
) {
setShowingCommentEdit(false);
}
};
document.addEventListener("mousedown", handleClickOutsideDelete);
document.addEventListener("mousedown", handleClickOutsideEdit);
onCleanup(() => {
document.removeEventListener("mousedown", handleClickOutsideDelete);
document.removeEventListener("mousedown", handleClickOutsideEdit);
});
});
return (
<>
<CommentSection
privilegeLevel={props.privilegeLevel}
allComments={allComments()}
topLevelComments={topLevelComments()}
type={props.type}
postID={props.id}
reactionMap={currentReactionMap()}
currentUserID={props.currentUserID}
userCommentMap={userCommentMap}
newComment={newComment}
commentSubmitLoading={commentSubmitLoading()}
toggleModification={toggleModification}
commentReaction={commentReaction}
/>
<Show when={showingDeletionPrompt()}>
<div ref={deletePromptRef}>
<CommentDeletionPrompt
commentID={commentIDForModification()}
commenterID={commenterForModification()}
commenterImage={commenterImageForModification()}
commenterEmail={commenterEmailForModification()}
commenterDisplayName={commenterDisplayNameForModification()}
privilegeLevel={props.privilegeLevel}
commentDeletionLoading={commentDeletionLoading()}
deleteComment={deleteComment}
/>
</div>
</Show>
<Show when={showingCommentEdit()}>
<div ref={modificationPromptRef}>
<EditCommentModal
commentID={commentIDForModification()}
commentBody={commentBodyForModification()}
commenterImage={commenterImageForModification()}
commenterEmail={commenterEmailForModification()}
commenterDisplayName={commenterDisplayNameForModification()}
editCommentLoading={editCommentLoading()}
editComment={editComment}
/>
</div>
</Show>
</>
);
}

View File

@@ -0,0 +1,83 @@
import { createSignal, createEffect, For, Show, createMemo } from "solid-js";
import type { CommentSortingProps } from "~/types/comment";
import { sortComments } from "~/lib/comment-utils";
import CommentBlock from "./CommentBlock";
export default function CommentSorting(props: CommentSortingProps) {
const [clickedOnce, setClickedOnce] = createSignal(false);
const [showingBlock, setShowingBlock] = createSignal<Map<number, boolean>>(
new Map(props.topLevelComments?.map((comment) => [comment.id, true]))
);
// Update showing block when top level comments change
createEffect(() => {
setShowingBlock(
new Map(props.topLevelComments?.map((comment) => [comment.id, true]))
);
});
// Reset clickedOnce after timeout
createEffect(() => {
if (clickedOnce()) {
setTimeout(() => setClickedOnce(false), 300);
}
});
const checkForDoubleClick = (id: number) => {
if (clickedOnce()) {
setShowingBlock((prev) => {
const newMap = new Map(prev);
newMap.set(id, !prev.get(id));
return newMap;
});
} else {
setClickedOnce(true);
}
};
// Memoized sorted comments
const sortedComments = createMemo(() => {
return sortComments(
props.topLevelComments,
props.selectedSorting.val,
props.reactionMap
);
});
return (
<For each={sortedComments()}>
{(topLevelComment) => (
<div
onClick={() => checkForDoubleClick(topLevelComment.id)}
class="mt-4 max-w-full rounded bg-white py-2 pl-2 shadow select-none sm:pl-4 md:pl-8 lg:pl-12 dark:bg-zinc-900"
>
<Show
when={showingBlock().get(topLevelComment.id)}
fallback={<div class="h-4" />}
>
<CommentBlock
comment={topLevelComment}
category={props.type}
projectID={props.postID}
recursionCount={1}
allComments={props.allComments}
child_comments={props.allComments?.filter(
(comment) => comment.parent_comment_id === topLevelComment.id
)}
privilegeLevel={props.privilegeLevel}
currentUserID={props.currentUserID}
reactionMap={props.reactionMap}
level={0}
socket={props.socket}
userCommentMap={props.userCommentMap}
toggleModification={props.toggleModification}
newComment={props.newComment}
commentSubmitLoading={props.commentSubmitLoading}
commentReaction={props.commentReaction}
/>
</Show>
</div>
)}
</For>
);
}

View File

@@ -0,0 +1,89 @@
import { For, Show, createSignal } from "solid-js";
import type { CommentSortingSelectProps, SortingMode } from "~/types/comment";
import Check from "~/components/icons/Check";
import UpDownArrows from "~/components/icons/UpDownArrows";
const SORTING_OPTIONS: { val: SortingMode; label: string }[] = [
{ val: "newest", label: "Newest" },
{ val: "oldest", label: "Oldest" },
{ val: "highest_rated", label: "Highest Rated" },
{ val: "hot", label: "Hot" }
];
export default function CommentSortingSelect(props: CommentSortingSelectProps) {
const [isOpen, setIsOpen] = createSignal(false);
const selectedLabel = () => {
const option = SORTING_OPTIONS.find(
(opt) => opt.val === props.selectedSorting.val
);
return option?.label || "Newest";
};
const handleSelect = (mode: SortingMode) => {
props.setSorting(mode);
setIsOpen(false);
};
return (
<div class="mt-2 flex justify-center">
<div class="w-72">
<div class="relative z-40 mt-1">
<button
type="button"
onClick={() => setIsOpen(!isOpen())}
class="focus-visible:ring-opacity-75 relative w-full cursor-default rounded-lg bg-white py-2 pr-10 pl-3 text-left shadow-md focus:outline-none focus-visible:border-orange-600 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-orange-300 sm:text-sm dark:bg-zinc-900"
>
<span class="block truncate">{selectedLabel()}</span>
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<UpDownArrows
strokeWidth={1.5}
height={24}
width={24}
class="fill-zinc-900 dark:fill-white"
/>
</span>
</button>
<Show when={isOpen()}>
<div class="ring-opacity-5 absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black transition duration-100 ease-in focus:outline-none sm:text-sm dark:bg-zinc-900">
<For each={SORTING_OPTIONS}>
{(sort) => (
<button
type="button"
onClick={() => handleSelect(sort.val)}
class={`relative w-full cursor-default py-2 pr-4 pl-10 text-left select-none ${
props.selectedSorting.val === sort.val
? "bg-orange-100 text-orange-900"
: "text-zinc-900 hover:bg-orange-50 dark:text-white dark:hover:bg-zinc-800"
}`}
>
<span
class={`block truncate ${
props.selectedSorting.val === sort.val
? "font-medium"
: "font-normal"
}`}
>
{sort.label}
</span>
<Show when={props.selectedSorting.val === sort.val}>
<span class="absolute inset-y-0 left-0 flex items-center pl-3 text-orange-600">
<Check
strokeWidth={1}
height={24}
width={24}
class="stroke-zinc-900 dark:stroke-white"
/>
</span>
</Show>
</button>
)}
</For>
</div>
</Show>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,70 @@
import { createSignal, Show } from "solid-js";
import type { EditCommentModalProps } from "~/types/comment";
import Xmark from "~/components/icons/Xmark";
export default function EditCommentModal(props: EditCommentModalProps) {
let bodyRef: HTMLTextAreaElement | undefined;
const [showNoChange, setShowNoChange] = createSignal(false);
const editCommentWrapper = (e: SubmitEvent) => {
e.preventDefault();
if (
bodyRef &&
bodyRef.value.length > 0 &&
bodyRef.value !== props.commentBody
) {
setShowNoChange(false);
props.editComment(bodyRef.value, props.commentID);
} else {
setShowNoChange(true);
}
};
return (
<div class="flex justify-center">
<div class="fixed top-48 h-fit w-11/12 sm:w-4/5 md:w-2/3">
<div
id="edit_prompt"
class="fade-in z-50 rounded-md bg-zinc-600 px-8 py-4 shadow-lg dark:bg-zinc-800"
>
<button class="absolute right-4" onClick={() => {}}>
<Xmark strokeWidth={0.5} color="white" height={50} width={50} />
</button>
<div class="py-4 text-center text-3xl tracking-wide text-zinc-50">
Edit Comment
</div>
<form onSubmit={editCommentWrapper}>
<div class="textarea-group home">
<textarea
required
ref={bodyRef}
placeholder=" "
value={props.commentBody}
class="underlinedInput w-full bg-transparent text-blue-300"
rows={4}
/>
<span class="bar" />
<label class="underlinedInputLabel">Edit Comment</label>
</div>
<div class="flex justify-end pt-2">
<button
type="submit"
disabled={props.editCommentLoading}
class={`${
props.editCommentLoading ? "bg-zinc-400" : ""
} rounded border px-4 py-2 text-white shadow-md transition-all duration-300 ease-in-out hover:border-blue-500 hover:bg-blue-400 active:scale-90`}
>
Submit
</button>
</div>
</form>
<Show when={showNoChange()}>
<div class="text-center text-red-500 italic">
No change detected
</div>
</Show>
</div>
</div>
</div>
);
}

View File

@@ -8,7 +8,7 @@ const sorting = [
{ val: "Oldest" }, { val: "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>

View File

@@ -0,0 +1,77 @@
import { For, Show } from "solid-js";
import type { ReactionBarProps, ReactionType } from "~/types/comment";
import TearsEmoji from "~/components/icons/emojis/Tears";
import BlankEmoji from "~/components/icons/emojis/Blank";
import TongueEmoji from "~/components/icons/emojis/Tongue";
import CryEmoji from "~/components/icons/emojis/Cry";
import HeartEyeEmoji from "~/components/icons/emojis/HeartEye";
import AngryEmoji from "~/components/icons/emojis/Angry";
import MoneyEyeEmoji from "~/components/icons/emojis/MoneyEye";
import SickEmoji from "~/components/icons/emojis/Sick";
import UpsideDownEmoji from "~/components/icons/emojis/UpsideDown";
import WorriedEmoji from "~/components/icons/emojis/Worried";
interface EmojiConfig {
type: ReactionType;
Component: any;
}
const EMOJI_CONFIG: EmojiConfig[] = [
{ type: "tears", Component: TearsEmoji },
{ type: "blank", Component: BlankEmoji },
{ type: "tongue", Component: TongueEmoji },
{ type: "cry", Component: CryEmoji },
{ type: "heartEye", Component: HeartEyeEmoji },
{ type: "angry", Component: AngryEmoji },
{ type: "moneyEye", Component: MoneyEyeEmoji },
{ type: "sick", Component: SickEmoji },
{ type: "upsideDown", Component: UpsideDownEmoji },
{ type: "worried", Component: WorriedEmoji }
];
export default function ReactionBar(props: ReactionBarProps) {
const getReactionCount = (type: ReactionType) => {
return props.reactions.filter((reaction) => reaction.type === type).length;
};
const hasUserReacted = (type: ReactionType) => {
return props.reactions.some(
(reaction) =>
reaction.type === type && reaction.user_id === props.currentUserID
);
};
const shouldShowEmoji = (type: ReactionType) => {
return props.showingReactionOptions || getReactionCount(type) > 0;
};
return (
<div
class={`${
props.showingReactionOptions
? "bg-zinc-50 px-2 py-4 shadow-inner dark:bg-zinc-700"
: ""
} fade-in scrollYDisabled ml-2 flex min-h-[1.5rem] w-48 max-w-[1/4] flex-row overflow-scroll rounded-md py-1 sm:w-56 md:w-fit md:overflow-hidden`}
>
<For each={EMOJI_CONFIG}>
{({ type, Component }) => (
<Show when={shouldShowEmoji(type)}>
<div class="fade-in mx-1 flex">
<div class={hasUserReacted(type) ? "text-green-500" : ""}>
<Show when={getReactionCount(type) > 0}>
{getReactionCount(type)}
</Show>
</div>
<button
class="h-6 w-6 pl-0.5"
onClick={() => props.commentReaction(type, props.commentID)}
>
<Component />
</button>
</div>
</Show>
)}
</For>
</div>
);
}

View File

@@ -19,7 +19,8 @@ export default function TagSelector(props: TagSelectorProps) {
const handleClickOutside = (e: MouseEvent) => { 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}) `}

View File

@@ -0,0 +1,26 @@
export default function Angry() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<defs>
<style>{`.a{fill:#e7253e;}.b{fill:#b51f36;}.c{fill:#90192d;}`}</style>
</defs>
<rect class="a" x="1" y="1" width="22" height="22" rx="7.656" />
<path
class="b"
d="M23,13.938a14.69,14.69,0,0,1-12.406,6.531c-5.542,0-6.563-1-9.142-2.529A7.66,7.66,0,0,0,8.656,23h6.688A7.656,7.656,0,0,0,23,15.344Z"
/>
<path
class="c"
d="M9.565,8.513A16.121,16.121,0,0,1,5.63,6.328a.52.52,0,0,0-.832.4c-.048,1.583.151,4.483,2.258,4.483A2.6,2.6,0,0,0,9.9,9.1.517.517,0,0,0,9.565,8.513Z"
/>
<path
class="c"
d="M19.2,6.731a.521.521,0,0,0-.832-.4,16.121,16.121,0,0,1-3.935,2.185A.517.517,0,0,0,14.1,9.1a2.6,2.6,0,0,0,2.84,2.11C19.051,11.214,19.25,8.314,19.2,6.731Z"
/>
<path
class="c"
d="M14,13.937a.318.318,0,0,1-.313-.326,2.105,2.105,0,0,0-.5-1.331A1.6,1.6,0,0,0,12,11.847a1.6,1.6,0,0,0-1.187.43,2.092,2.092,0,0,0-.5,1.335.32.32,0,0,1-.64.012,2.715,2.715,0,0,1,.679-1.792A2.211,2.211,0,0,1,12,11.207a2.211,2.211,0,0,1,1.647.625,2.721,2.721,0,0,1,.679,1.792A.321.321,0,0,1,14,13.937Z"
/>
</svg>
);
}

View File

@@ -0,0 +1,22 @@
export default function Blank() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<defs>
<style>{`.a{fill:#f8de40;}.b{fill:#864e20;}.c{fill:#e7c930;}`}</style>
</defs>
<rect class="a" x="1" y="1" width="22" height="22" rx="7.656" />
<path
class="b"
d="M7.055,7.313A1.747,1.747,0,1,0,8.8,9.059,1.747,1.747,0,0,0,7.055,7.313Z"
/>
<path
class="b"
d="M16.958,7.313A1.747,1.747,0,1,0,18.7,9.059,1.747,1.747,0,0,0,16.958,7.313Z"
/>
<path
class="c"
d="M23,13.938a14.688,14.688,0,0,1-12.406,6.531c-5.542,0-6.563-1-9.142-2.529A7.66,7.66,0,0,0,8.656,23h6.688A7.656,7.656,0,0,0,23,15.344Z"
/>
</svg>
);
}

View File

@@ -0,0 +1,38 @@
export default function Cry() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<defs>
<style>{`.a{fill:#f8de40;}.b{fill:#e7c930;}.c{fill:#864e20;}.d{fill:#26a9e0;}`}</style>
</defs>
<rect class="a" x="1" y="1" width="22" height="22" rx="7.656" />
<path
class="b"
d="M23,13.938a14.688,14.688,0,0,1-12.406,6.531c-5.542,0-6.563-1-9.142-2.529A7.66,7.66,0,0,0,8.656,23h6.688A7.656,7.656,0,0,0,23,15.344Z"
/>
<path
class="c"
d="M17.2,12.746c-1.221-1.647-3.789-2.231-5.2-2.231s-3.984.584-5.2,2.231-.188,3.4,1.128,3.293,3.546-.364,4.077-.364,2.762.258,4.077.364S18.427,14.392,17.2,12.746Z"
/>
<path
class="b"
d="M14.505,17.022A12.492,12.492,0,0,0,12,16.638a12.457,12.457,0,0,0-2.5.384c-.376.076-.39.384,0,.332s2.5-.166,2.5-.166,2.119.115,2.505.166S14.88,17.1,14.505,17.022Z"
/>
<path
class="c"
d="M8.907,9.844a.182.182,0,0,1-.331.1,2.016,2.016,0,0,0-.569-.567,1.731,1.731,0,0,0-1.915,0,2.016,2.016,0,0,0-.571.569.182.182,0,0,1-.331-.1,1.632,1.632,0,0,1,.346-1.023,1.927,1.927,0,0,1,3.026,0A1.64,1.64,0,0,1,8.907,9.844Z"
/>
<path
class="c"
d="M18.81,9.844a.182.182,0,0,1-.331.1,2.026,2.026,0,0,0-.568-.567,1.732,1.732,0,0,0-1.916,0,2.016,2.016,0,0,0-.571.569.182.182,0,0,1-.331-.1,1.632,1.632,0,0,1,.346-1.023,1.927,1.927,0,0,1,3.026,0A1.64,1.64,0,0,1,18.81,9.844Z"
/>
<path
class="d"
d="M8.576,9.946a2.016,2.016,0,0,0-.569-.567,1.731,1.731,0,0,0-1.915,0,2.016,2.016,0,0,0-.571.569.175.175,0,0,1-.214.063v11.24A1.747,1.747,0,0,0,7.054,23h0A1.748,1.748,0,0,0,8.8,21.253V10.005A.176.176,0,0,1,8.576,9.946Z"
/>
<path
class="d"
d="M18.473,9.946a2.026,2.026,0,0,0-.568-.567,1.732,1.732,0,0,0-1.916,0,2.016,2.016,0,0,0-.571.569.175.175,0,0,1-.214.063v11.24A1.748,1.748,0,0,0,16.952,23h0A1.747,1.747,0,0,0,18.7,21.253V10.005A.176.176,0,0,1,18.473,9.946Z"
/>
</svg>
);
}

View File

@@ -0,0 +1,30 @@
export default function HeartEye() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<defs>
<style>{`.a{fill:#f8de40;}.b{fill:#e7c930;}.c{fill:#f06880;}.d{fill:#864e20;}`}</style>
</defs>
<rect class="a" x="1" y="1" width="22" height="22" rx="7.656" />
<path
class="b"
d="M23,13.938a14.69,14.69,0,0,1-12.406,6.531c-5.542,0-6.563-1-9.142-2.529A7.66,7.66,0,0,0,8.656,23h6.688A7.656,7.656,0,0,0,23,15.344Z"
/>
<path
class="c"
d="M9.58,6.983A1.528,1.528,0,0,0,7.5,7.1l-.449.45L6.6,7.1a1.529,1.529,0,0,0-2.083-.113,1.472,1.472,0,0,0-.058,2.136L6.68,11.34a.518.518,0,0,0,.737,0l2.22-2.221A1.471,1.471,0,0,0,9.58,6.983Z"
/>
<path
class="c"
d="M19.483,6.983A1.528,1.528,0,0,0,17.4,7.1l-.449.45L16.5,7.1a1.529,1.529,0,0,0-2.083-.113,1.471,1.471,0,0,0-.057,2.136l2.221,2.221a.517.517,0,0,0,.736,0l2.221-2.221A1.472,1.472,0,0,0,19.483,6.983Z"
/>
<path
class="d"
d="M16.666,12.583H7.334a.493.493,0,0,0-.492.544c.123,1.175.875,3.842,5.158,3.842s5.035-2.667,5.158-3.842A.493.493,0,0,0,16.666,12.583Z"
/>
<path
class="c"
d="M12,16.969a6.538,6.538,0,0,0,2.959-.6,1.979,1.979,0,0,0-1.209-.853c-1.344-.3-1.75.109-1.75.109s-.406-.406-1.75-.109a1.979,1.979,0,0,0-1.209.853A6.538,6.538,0,0,0,12,16.969Z"
/>
</svg>
);
}

View File

@@ -0,0 +1,30 @@
export default function MoneyEye() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<defs>
<style>{`.a{fill:#f8de40;}.b{fill:#e7c930;}.c{fill:#864e20;}.d{fill:#f06880;}.e{fill:#009345;}`}</style>
</defs>
<rect class="a" x="1" y="1" width="22" height="22" rx="7.656" />
<path
class="b"
d="M23,13.938a14.69,14.69,0,0,1-12.406,6.531c-5.542,0-6.563-1-9.142-2.529A7.66,7.66,0,0,0,8.656,23h6.688A7.656,7.656,0,0,0,23,15.344Z"
/>
<path
class="c"
d="M16.666,12.583H7.334a.493.493,0,0,0-.492.544c.123,1.175.875,3.842,5.158,3.842s5.035-2.667,5.158-3.842A.493.493,0,0,0,16.666,12.583Z"
/>
<path
class="d"
d="M12,16.969a6.538,6.538,0,0,0,2.959-.6,1.979,1.979,0,0,0-1.209-.853c-1.344-.3-1.75.109-1.75.109s-.406-.406-1.75-.109a1.979,1.979,0,0,0-1.209.853A6.538,6.538,0,0,0,12,16.969Z"
/>
<path
class="e"
d="M8.82,8.767h0a1.18,1.18,0,0,0-.378-.414A1.946,1.946,0,0,0,7.906,8.1,4.37,4.37,0,0,0,7.462,8a.094.094,0,0,1-.079-.09V6.692a.1.1,0,0,1,.036-.074A.081.081,0,0,1,7.488,6.6a.882.882,0,0,1,.375.173.579.579,0,0,1,.189.385.344.344,0,0,0,.337.3H8.5a.342.342,0,0,0,.256-.116.336.336,0,0,0,.084-.262,1.536,1.536,0,0,0-.1-.407A1.22,1.22,0,0,0,8.4,6.208a1.457,1.457,0,0,0-.5-.273,2.3,2.3,0,0,0-.44-.092.09.09,0,0,1-.077-.089V5.508a.341.341,0,0,0-.682,0v.248a.094.094,0,0,1-.086.091,1.848,1.848,0,0,0-.975.381,1.251,1.251,0,0,0-.415,1.024,1.365,1.365,0,0,0,.146.624,1.185,1.185,0,0,0,.364.409,1.711,1.711,0,0,0,.506.238c.123.036.253.069.385.1a.091.091,0,0,1,.075.088V9.944a.092.092,0,0,1-.034.072.088.088,0,0,1-.073.019,1.089,1.089,0,0,1-.45-.189.575.575,0,0,1-.209-.39A.339.339,0,0,0,5.6,9.163H5.482a.342.342,0,0,0-.34.383,1.254,1.254,0,0,0,.426.853,1.934,1.934,0,0,0,1.056.385.092.092,0,0,1,.077.093v.234a.341.341,0,0,0,.682,0v-.246a.094.094,0,0,1,.088-.091,2.069,2.069,0,0,0,1.02-.358,1.208,1.208,0,0,0,.467-1.03A1.29,1.29,0,0,0,8.82,8.767Zm-.659.611a.663.663,0,0,1-.064.306.515.515,0,0,1-.175.2.941.941,0,0,1-.287.125c-.048.013-.1.023-.148.032a.084.084,0,0,1-.07-.019.1.1,0,0,1-.034-.072V8.855a.091.091,0,0,1,.034-.07.093.093,0,0,1,.059-.021l.02,0,.125.028a.1.1,0,0,1,.076.084v.024a.092.092,0,0,1-.023.075.1.1,0,0,1-.073.031c-.094,0-.139-.036-.159-.055a.125.125,0,0,1-.033-.073.074.074,0,0,0-.073-.071H7.17a.073.073,0,0,0-.073.081.272.272,0,0,0,.091.2.449.449,0,0,0,.315.089.271.271,0,0,0,.284-.257.266.266,0,0,0-.076-.2A.254.254,0,0,0,7.534,8.7C7.519,8.7,7.5,8.7,7.488,8.7a.1.1,0,0,1-.066-.026.091.091,0,0,1-.029-.062V8.545a.091.091,0,0,1,.095-.092c.185,0,.244.085.244.144a.072.072,0,0,0,.072.073h.17a.072.072,0,0,0,.072-.082A.331.331,0,0,0,8,8.4a.575.575,0,0,0-.364-.153.1.1,0,0,1-.09-.095V7.92a.092.092,0,0,1,.027-.068.085.085,0,0,1,.063-.022.6.6,0,0,1,.217.084.361.361,0,0,1,.123.22.073.073,0,0,0,.073.063h.057a.074.074,0,0,0,.053-.025.072.072,0,0,0,.018-.056.482.482,0,0,0-.023-.115.527.527,0,0,0-.19-.281.662.662,0,0,0-.277-.12.1.1,0,0,1-.077-.093V7.3a.072.072,0,0,0-.145,0v.206a.1.1,0,0,1-.083.094.847.847,0,0,0-.506.214.62.62,0,0,0-.206.505.676.676,0,0,0,.073.31.585.585,0,0,0,.181.2.892.892,0,0,0,.261.124l.2.052a.091.091,0,0,1,.075.087v.148a.1.1,0,0,1-.027.068.092.092,0,0,1-.064.019.5.5,0,0,1-.247-.1.349.349,0,0,1-.131-.22.072.072,0,0,0-.071-.06H6.2a.073.073,0,0,0-.072.081.625.625,0,0,0,.212.426.975.975,0,0,0,.537.2.1.1,0,0,1,.086.1V10.2a.073.073,0,0,0,.145,0V10a.093.093,0,0,1,.082-.094,1.036,1.036,0,0,0,.517-.188.6.6,0,0,0,.232-.512A.645.645,0,0,0,8.161,9.378Z"
/>
<path
class="e"
d="M18.679,8.767h0a1.18,1.18,0,0,0-.378-.414A1.946,1.946,0,0,0,17.765,8.1,4.37,4.37,0,0,0,17.321,8a.094.094,0,0,1-.079-.09V6.692a.1.1,0,0,1,.036-.074.081.081,0,0,1,.069-.018.882.882,0,0,1,.375.173.579.579,0,0,1,.189.385.344.344,0,0,0,.337.3h.111a.342.342,0,0,0,.256-.116.336.336,0,0,0,.084-.262,1.536,1.536,0,0,0-.1-.407,1.22,1.22,0,0,0-.344-.465,1.457,1.457,0,0,0-.5-.273,2.3,2.3,0,0,0-.44-.092.09.09,0,0,1-.077-.089V5.508a.341.341,0,0,0-.682,0v.248a.094.094,0,0,1-.086.091,1.848,1.848,0,0,0-.975.381,1.251,1.251,0,0,0-.415,1.024,1.365,1.365,0,0,0,.146.624,1.185,1.185,0,0,0,.364.409,1.711,1.711,0,0,0,.506.238c.123.036.253.069.385.1a.091.091,0,0,1,.075.088V9.944a.092.092,0,0,1-.034.072.088.088,0,0,1-.073.019,1.089,1.089,0,0,1-.45-.189.575.575,0,0,1-.209-.39.339.339,0,0,0-.333-.293h-.121a.342.342,0,0,0-.34.383,1.254,1.254,0,0,0,.426.853,1.934,1.934,0,0,0,1.056.385.092.092,0,0,1,.077.093v.234a.341.341,0,0,0,.682,0v-.246a.094.094,0,0,1,.088-.091,2.069,2.069,0,0,0,1.02-.358,1.208,1.208,0,0,0,.467-1.03A1.29,1.29,0,0,0,18.679,8.767Zm-.659.611a.663.663,0,0,1-.064.306.515.515,0,0,1-.175.2.941.941,0,0,1-.287.125c-.048.013-.1.023-.148.032a.084.084,0,0,1-.07-.019.1.1,0,0,1-.034-.072V8.855a.091.091,0,0,1,.034-.07.093.093,0,0,1,.059-.021l.02,0,.125.028a.1.1,0,0,1,.076.084v.024a.092.092,0,0,1-.023.075.1.1,0,0,1-.073.031c-.094,0-.139-.036-.159-.055a.125.125,0,0,1-.033-.073.074.074,0,0,0-.073-.071h-.057a.073.073,0,0,0-.073.081.272.272,0,0,0,.091.2.449.449,0,0,0,.315.089.271.271,0,0,0,.284-.257.266.266,0,0,0-.076-.2.254.254,0,0,0-.177-.074c-.015,0-.033,0-.045,0a.1.1,0,0,1-.066-.026.091.091,0,0,1-.029-.062V8.545a.091.091,0,0,1,.095-.092c.185,0,.244.085.244.144a.072.072,0,0,0,.072.073h.17a.072.072,0,0,0,.072-.082.331.331,0,0,0-.045-.188.575.575,0,0,0-.364-.153.1.1,0,0,1-.09-.095V7.92a.092.092,0,0,1,.027-.068.085.085,0,0,1,.063-.022.6.6,0,0,1,.217.084.361.361,0,0,1,.123.22.073.073,0,0,0,.073.063h.057a.074.074,0,0,0,.053-.025.072.072,0,0,0,.018-.056.482.482,0,0,0-.023-.115.527.527,0,0,0-.19-.281.662.662,0,0,0-.277-.12.1.1,0,0,1-.077-.093V7.3a.072.072,0,0,0-.145,0v.206a.1.1,0,0,1-.083.094.847.847,0,0,0-.506.214.62.62,0,0,0-.206.505.676.676,0,0,0,.073.31.585.585,0,0,0,.181.2.892.892,0,0,0,.261.124l.2.052a.091.091,0,0,1,.075.087v.148a.1.1,0,0,1-.027.068.092.092,0,0,1-.064.019.5.5,0,0,1-.247-.1.349.349,0,0,1-.131-.22.072.072,0,0,0-.071-.06h-.057a.073.073,0,0,0-.072.081.625.625,0,0,0,.212.426.975.975,0,0,0,.537.2.1.1,0,0,1,.086.1V10.2a.073.073,0,0,0,.145,0V10a.093.093,0,0,1,.082-.094,1.036,1.036,0,0,0,.517-.188.6.6,0,0,0,.232-.512A.645.645,0,0,0,18.02,9.378Z"
/>
</svg>
);
}

View File

@@ -0,0 +1,26 @@
export default function Sick() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<defs>
<style>{`.a{fill:#009345;}.b{fill:#017b3f;}.c{fill:#08512a;}`}</style>
</defs>
<rect class="a" x="1" y="1" width="22" height="22" rx="7.656" />
<path
class="b"
d="M23,13.938a14.69,14.69,0,0,1-12.406,6.531c-5.542,0-6.563-1-9.142-2.529A7.66,7.66,0,0,0,8.656,23h6.688A7.656,7.656,0,0,0,23,15.344Z"
/>
<path
class="c"
d="M9.6,8.833,9.021,8.6c-.35-.144-.7-.283-1.058-.412s-.716-.251-1.08-.362-.731-.212-1.1-.3l-.012,0a.246.246,0,0,0-.186.448l.01.006c.325.2.656.392.991.573q.281.15.564.291a.245.245,0,0,1,0,.439q-.285.141-.564.292c-.335.18-.667.369-.992.573l-.016.01a.246.246,0,0,0,.187.447l.018,0c.374-.088.741-.19,1.105-.3s.723-.234,1.079-.362c.179-.064.355-.134.532-.2l.526-.213.573-.232A.246.246,0,0,0,9.6,8.833Z"
/>
<path
class="c"
d="M14.405,8.833l.574-.235c.35-.144.7-.283,1.058-.412s.716-.251,1.08-.362.731-.212,1.1-.3l.012,0a.246.246,0,0,1,.186.448l-.01.006c-.325.2-.656.392-.991.573q-.28.15-.564.291a.245.245,0,0,0,0,.439q.285.141.564.292c.335.18.667.369.992.573l.016.01a.246.246,0,0,1-.187.447l-.018,0c-.374-.088-.741-.19-1.105-.3s-.723-.234-1.079-.362c-.179-.064-.355-.134-.532-.2l-.526-.213-.573-.232A.246.246,0,0,1,14.405,8.833Z"
/>
<path
class="c"
d="M17.051,13.32c-.139-.122-.276-.247-.418-.366-.28-.241-.566-.476-.852-.708h0a.216.216,0,0,0-.245-.019l-.034.019c-.319.185-.636.372-.951.564-.2.119-.394.241-.59.364a.218.218,0,0,1-.243-.009l-.134-.1c-.149-.109-.3-.214-.449-.322-.3-.214-.6-.422-.9-.632l-.106-.074a.217.217,0,0,0-.248,0l-.106.074c-.3.21-.606.417-.9.632-.15.107-.3.212-.449.321l-.134.1a.218.218,0,0,1-.243.008c-.2-.122-.391-.244-.589-.363-.157-.1-.316-.19-.474-.285s-.319-.186-.478-.279l-.033-.019a.214.214,0,0,0-.245.019h0c-.287.232-.572.467-.853.709-.141.119-.278.244-.418.366s-.275.249-.41.377c.168-.08.335-.161.5-.247s.33-.169.492-.258c.228-.121.453-.247.677-.374a.217.217,0,0,1,.242.02l.2.16c.145.113.289.228.436.339.291.225.587.446.882.665l.074.055a.215.215,0,0,0,.246.008l.1-.063.464-.3c.155-.1.307-.2.461-.3.19-.124.371-.262.558-.394l.006,0a.216.216,0,0,1,.245-.019l.034.018c.158.093.318.184.476.279s.318.186.476.283c.2.121.4.243.595.368a.218.218,0,0,0,.243-.006l.134-.1c.149-.109.3-.214.449-.322.3-.214.6-.422.9-.632l.106-.074a.217.217,0,0,0,.248,0l.106.074c.3.21.606.417.9.632.15.107.3.212.449.321l.134.1a.219.219,0,0,0,.243.006c.2-.125.4-.246.6-.368.158-.1.316-.19.474-.283s.318-.186.476-.279l.034-.018a.216.216,0,0,1,.245.019l.006,0c.187.132.368.27.558.394.154.1.306.2.461.3l.464.3.1.063a.215.215,0,0,0,.246-.008l.074-.055c.295-.219.591-.44.882-.665.147-.111.291-.226.436-.339l.2-.16a.217.217,0,0,1,.242-.02c.224.127.449.253.677.374.162.089.329.173.492.258s.335.167.5.247C17.326,13.569,17.19,13.444,17.051,13.32Z"
/>
</svg>
);
}

View File

@@ -0,0 +1,40 @@
export default function Tears() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<defs>
<style>
{`.a{fill:#f8de40;}.b{fill:#864e20;}.c{fill:#e7c930;}.d{fill:#f06880;}.e{fill:#26a9e0;}`}
</style>
</defs>
<rect class="a" x="1" y="1" width="22" height="22" rx="7.656" />
<path
class="b"
d="M8.907,9.844a.182.182,0,0,1-.331.1,2.016,2.016,0,0,0-.569-.567,1.731,1.731,0,0,0-1.915,0,2.016,2.016,0,0,0-.571.569.182.182,0,0,1-.331-.1,1.632,1.632,0,0,1,.346-1.023,1.927,1.927,0,0,1,3.026,0A1.64,1.64,0,0,1,8.907,9.844Z"
/>
<path
class="b"
d="M18.81,9.844a.182.182,0,0,1-.331.1,2.026,2.026,0,0,0-.568-.567,1.732,1.732,0,0,0-1.916,0,2.016,2.016,0,0,0-.571.569.182.182,0,0,1-.331-.1,1.632,1.632,0,0,1,.346-1.023,1.927,1.927,0,0,1,3.026,0A1.64,1.64,0,0,1,18.81,9.844Z"
/>
<path
class="c"
d="M23,13.938a14.688,14.688,0,0,1-12.406,6.531c-5.542,0-6.563-1-9.142-2.529A7.66,7.66,0,0,0,8.656,23h6.688A7.656,7.656,0,0,0,23,15.344Z"
/>
<path
class="b"
d="M16.666,12.583H7.334a.493.493,0,0,0-.492.544c.123,1.175.875,3.842,5.158,3.842s5.035-2.667,5.158-3.842A.493.493,0,0,0,16.666,12.583Z"
/>
<path
class="d"
d="M12,16.969a6.538,6.538,0,0,0,2.959-.6,1.979,1.979,0,0,0-1.209-.853c-1.344-.3-1.75.109-1.75.109s-.406-.406-1.75-.109a1.979,1.979,0,0,0-1.209.853A6.538,6.538,0,0,0,12,16.969Z"
/>
<path
class="e"
d="M5.117,15.682a6.6,6.6,0,0,0,1.311-4.356,6.6,6.6,0,0,0-4.357,1.31c-1.77,1.523-.92,3.011-.442,3.489S3.594,17.453,5.117,15.682Z"
/>
<path
class="e"
d="M18.883,15.682a6.6,6.6,0,0,1-1.311-4.356,6.6,6.6,0,0,1,4.357,1.31c1.77,1.523.92,3.011.442,3.489S20.406,17.453,18.883,15.682Z"
/>
</svg>
);
}

View File

@@ -0,0 +1,17 @@
export default function ThumbsUp() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<style>{`.fa-secondary{opacity:.4}`}</style>
</defs>
<path
d="M512 224.112C512 197.608 490.516 176.133 464 176.133H317.482C340.25 138.226 352.005 95.257 352.005 80.11C352.005 56.523 333.495 32 302.54 32C239.411 32 276.176 108.148 194.312 173.618L178.016 186.644C166.23 196.06 160.285 209.903 160.215 223.897C160.191 223.921 160 224.112 160 224.112V384.042C160 399.146 167.113 413.368 179.198 422.427L213.336 448.02C241.027 468.779 274.702 480 309.309 480H368C394.516 480 416 458.525 416 432.021C416 428.386 415.52 424.878 414.754 421.475C434 415.228 448 397.37 448 376.045C448 366.897 445.303 358.438 440.861 351.164C463.131 347.002 480 327.547 480 304.077C480 291.577 475.107 280.298 467.275 271.761C492.234 270.051 512 249.495 512 224.112Z"
class="fa-secondary"
/>
<path
d="M128 448V224C128 206.328 113.674 192 96 192H32C14.326 192 0 206.328 0 224V448C0 465.674 14.326 480 32 480H96C113.674 480 128 465.674 128 448Z"
class="fa-primary"
/>
</svg>
);
}

View File

@@ -0,0 +1,34 @@
export default function Tongue() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<defs>
<style>{`.a{fill:#f8de40;}.b{fill:#e7c930;}.c{fill:#864e20;}.d{fill:#f06880;}.e{fill:#cd4b68;}`}</style>
</defs>
<rect class="a" x="1" y="1" width="22" height="22" rx="7.656" />
<path
class="b"
d="M23,13.938a14.69,14.69,0,0,1-12.406,6.531c-5.542,0-6.563-1-9.142-2.529A7.66,7.66,0,0,0,8.656,23h6.688A7.656,7.656,0,0,0,23,15.344Z"
/>
<path
class="c"
d="M16.666,12.583H7.334a.493.493,0,0,0-.492.544c.123,1.175.875,3.842,5.158,3.842s5.035-2.667,5.158-3.842A.493.493,0,0,0,16.666,12.583Z"
/>
<path
class="d"
d="M15.405,15.136a2.463,2.463,0,0,0-1.655-1.87c-1.344-.3-1.75.109-1.75.109s-.406-.406-1.75-.109A2.463,2.463,0,0,0,8.6,15.136a8.449,8.449,0,0,0,0,2.723c.264,1.172,1.061,1.61,3.4,1.61s3.141-.438,3.4-1.61A8.449,8.449,0,0,0,15.405,15.136Z"
/>
<path
class="e"
d="M12.188,15.688a5.582,5.582,0,0,1,.624-2.529,1.228,1.228,0,0,0-.812.216,1.228,1.228,0,0,0-.812-.216,5.582,5.582,0,0,1,.624,2.529C11.771,17,12,17,12,17S12.229,17,12.188,15.688Z"
/>
<path
class="c"
d="M9.6,8.833,9.021,8.6c-.35-.144-.7-.283-1.058-.412s-.716-.251-1.08-.362-.731-.212-1.1-.3l-.012,0a.246.246,0,0,0-.186.448l.01.006c.325.2.656.392.991.573q.281.15.564.291a.245.245,0,0,1,0,.439q-.285.141-.564.292c-.335.18-.667.369-.992.573l-.016.01a.246.246,0,0,0,.187.447l.018,0c.374-.088.741-.19,1.105-.3s.723-.234,1.079-.362c.179-.064.355-.134.532-.2l.526-.213.573-.232A.246.246,0,0,0,9.6,8.833Z"
/>
<path
class="c"
d="M14.405,8.833l.574-.235c.35-.144.7-.283,1.058-.412s.716-.251,1.08-.362.731-.212,1.1-.3l.012,0a.246.246,0,0,1,.186.448l-.01.006c-.325.2-.656.392-.991.573q-.28.15-.564.291a.245.245,0,0,0,0,.439q.285.141.564.292c.335.18.667.369.992.573l.016.01a.246.246,0,0,1-.187.447l-.018,0c-.374-.088-.741-.19-1.105-.3s-.723-.234-1.079-.362c-.179-.064-.355-.134-.532-.2l-.526-.213-.573-.232A.246.246,0,0,1,14.405,8.833Z"
/>
</svg>
);
}

View File

@@ -0,0 +1,26 @@
export default function UpsideDown() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<defs>
<style>{`.a{fill:#f8de40;}.b{fill:#864e20;}.c{fill:#e7c930;}`}</style>
</defs>
<rect class="a" x="1" y="1" width="22" height="22" rx="7.656" />
<path
class="b"
d="M7.055,16.688A1.747,1.747,0,1,1,8.8,14.941,1.748,1.748,0,0,1,7.055,16.688Z"
/>
<path
class="b"
d="M16.958,16.688A1.747,1.747,0,1,1,18.7,14.941,1.748,1.748,0,0,1,16.958,16.688Z"
/>
<path
class="b"
d="M14,12.793a.32.32,0,0,1-.313-.327,2.1,2.1,0,0,0-.5-1.33A1.593,1.593,0,0,0,12,10.7a1.6,1.6,0,0,0-1.187.43,2.088,2.088,0,0,0-.5,1.334.32.32,0,1,1-.64.012,2.712,2.712,0,0,1,.679-1.791A2.211,2.211,0,0,1,12,10.063a2.211,2.211,0,0,1,1.647.625,2.718,2.718,0,0,1,.679,1.791A.322.322,0,0,1,14,12.793Z"
/>
<path
class="c"
d="M23,13.938a14.69,14.69,0,0,1-12.406,6.531c-5.542,0-6.563-1-9.142-2.529A7.66,7.66,0,0,0,8.656,23h6.688A7.656,7.656,0,0,0,23,15.344Z"
/>
</svg>
);
}

View File

@@ -0,0 +1,42 @@
export default function Worried() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<defs>
<style>{`.a{fill:#f8de40;}.b{fill:#864e20;}.c{fill:#e7c930;}.d{fill:#f06880;}`}</style>
</defs>
<rect class="a" x="1" y="1" width="22" height="22" rx="7.656" />
<path
class="b"
d="M7.055,7.313A1.747,1.747,0,1,0,8.8,9.059,1.747,1.747,0,0,0,7.055,7.313Z"
/>
<path
class="b"
d="M16.958,7.313A1.747,1.747,0,1,0,18.7,9.059,1.747,1.747,0,0,0,16.958,7.313Z"
/>
<path
class="c"
d="M23,13.938a14.69,14.69,0,0,1-12.406,6.531c-5.542,0-6.563-1-9.142-2.529A7.66,7.66,0,0,0,8.656,23h6.688A7.656,7.656,0,0,0,23,15.344Z"
/>
<path
class="b"
d="M16.083,12.556A5.487,5.487,0,0,0,12,10.806a5.487,5.487,0,0,0-4.083,1.75c-.959,1.292-.147,2.667.885,2.583s2.781-.285,3.2-.285,2.167.2,3.2.285S17.042,13.848,16.083,12.556Z"
/>
<path
class="d"
d="M13.75,13.266c-1.344-.3-1.75.109-1.75.109s-.406-.406-1.75-.109A2.463,2.463,0,0,0,8.6,15.136a1.1,1.1,0,0,0,.207,0c1.031-.083,2.781-.285,3.2-.285s2.167.2,3.2.285a1.1,1.1,0,0,0,.207,0A2.463,2.463,0,0,0,13.75,13.266Z"
/>
<path
class="c"
d="M13.965,15.91a9.842,9.842,0,0,0-1.965-.3,9.842,9.842,0,0,0-1.965.3c-.294.061-.3.3,0,.261S12,16.041,12,16.041s1.663.09,1.965.13S14.259,15.971,13.965,15.91Z"
/>
<path
class="b"
d="M19.686,6.658l0,0a2.954,2.954,0,0,0-.228-.385,4.467,4.467,0,0,0-.576-.675c-.108-.1-.217-.205-.332-.3s-.242-.174-.364-.26A3.4,3.4,0,0,0,17.8,4.8c-.134-.066-.263-.143-.4-.2a4.857,4.857,0,0,0-1.743-.4,3.732,3.732,0,0,0-1.334.177.174.174,0,0,0,.007.327c.406.139.784.271,1.157.41.494.184.973.367,1.442.576.121.043.233.107.351.158l.178.076c.06.025.114.059.174.085.116.054.23.112.35.161l.011,0c.114.06.229.119.348.175.247.105.476.244.735.355.128.06.254.124.386.186A.173.173,0,0,0,19.686,6.658Z"
/>
<path
class="b"
d="M9.691,4.38A3.729,3.729,0,0,0,8.357,4.2a4.862,4.862,0,0,0-1.743.4c-.139.055-.269.132-.4.2a3.4,3.4,0,0,0-.384.231c-.122.086-.246.169-.363.26s-.224.2-.332.3a4.474,4.474,0,0,0-.577.675,2.948,2.948,0,0,0-.227.385l0,0a.173.173,0,0,0,.227.236c.131-.062.258-.126.386-.186.259-.111.487-.25.734-.355.119-.056.235-.115.349-.175l.011,0c.119-.049.233-.107.349-.161.06-.026.114-.06.174-.085l.178-.076c.118-.051.23-.115.351-.158.469-.209.947-.392,1.441-.576.373-.139.752-.271,1.157-.41a.174.174,0,0,0,.007-.327Z"
/>
</svg>
);
}

View File

@@ -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
View 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";
}

View File

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

View File

@@ -3,15 +3,25 @@ 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 = ?";
@@ -21,7 +31,7 @@ const getPostByTitle = cache(async (title: string, privilegeLevel: string) => {
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;
@@ -31,19 +41,40 @@ const getPostByTitle = cache(async (title: string, privilegeLevel: string) => {
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 = ?";
@@ -60,26 +91,29 @@ const getPostByTitle = cache(async (title: string, privilegeLevel: string) => {
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 {
@@ -89,19 +123,17 @@ 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
href="/blog"
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"
>
Back to Posts
</A>
</div>
</div>
</Show>
}
>
{(p) => {
const postData = data()!;
<div class="select-none overflow-x-hidden"> // Convert arrays back to Maps for component
<div class="z-30"> const userCommentMap = new Map<UserPublicData, number[]>(
<div class="page-fade-in z-20 mx-auto h-80 sm:h-96 md:h-[50vh]"> postData.userCommentArray || []
<div class="image-overlay fixed h-80 w-full brightness-75 sm:h-96 md:h-[50vh]"> );
<img const reactionMap = new Map<number, CommentReaction[]>(
src={p.banner_photo || "/blueprint.jpg"} postData.reactionArray || []
alt="post-cover" );
class="h-80 w-full object-cover sm:h-96 md:h-[50vh]"
/> return (
</div> <>
<div <Title>{p().title.replaceAll("_", " ")} | Michael Freno</Title>
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]"
style={{ "pointer-events": "none" }} <div class="overflow-x-hidden select-none">
> <div class="z-30">
<div class="z-10 text-3xl font-light tracking-widest"> <div class="page-fade-in z-20 mx-auto h-80 sm:h-96 md:h-[50vh]">
{p.title.replaceAll("_", " ")} <div class="image-overlay fixed h-80 w-full brightness-75 sm:h-96 md:h-[50vh]">
<div class="py-8 text-xl font-light tracking-widest"> <img
{p.subtitle} src={p().banner_photo || "/blueprint.jpg"}
</div> alt="post-cover"
</div> class="h-80 w-full object-cover sm:h-96 md:h-[50vh]"
</div> />
</div>
</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>
</> </>

View File

@@ -11,7 +11,8 @@ export default function CreatePost() {
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("");
@@ -42,7 +43,7 @@ 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) {
@@ -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,22 +139,29 @@ 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>
@@ -171,7 +182,7 @@ export default function CreatePost() {
{/* 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 */}
@@ -179,10 +190,10 @@ export default function CreatePost() {
<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"}
@@ -191,7 +202,7 @@ export default function CreatePost() {
<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>

View File

@@ -14,13 +14,13 @@ const getPostForEdit = cache(async (id: string) => {
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];
@@ -60,7 +60,7 @@ export default function EditPost() {
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);
} }
} }
@@ -86,7 +86,7 @@ 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
@@ -108,7 +108,7 @@ export default function EditPost() {
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,22 +183,29 @@ 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>
@@ -219,7 +226,7 @@ export default function EditPost() {
{/* 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 */}
@@ -227,10 +234,10 @@ export default function EditPost() {
<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"}
@@ -238,8 +245,10 @@ export default function EditPost() {
<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>

View File

@@ -51,9 +51,10 @@ const getPosts = cache(async (category: string, privilegeLevel: string) => {
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;
@@ -77,14 +78,22 @@ export default function BlogIndex() {
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">
@@ -105,7 +114,7 @@ export default function BlogIndex() {
</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,15 +148,17 @@ 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
@@ -160,7 +171,7 @@ export default function BlogIndex() {
<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>
@@ -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

View File

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

View File

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