1
mirror of https://github.com/jakejarvis/jarv.is.git synced 2026-04-17 10:28:46 -04:00

refactor: improve comment components and enhance styling

- Refactored CommentActions to use a state machine for managing action modes (replying, editing, deleting).
- Introduced CommentAvatar component for better avatar handling in comments.
- Updated CommentForm to use context for managing form state, allowing for better state sharing among components.
- Enhanced styling in next.config.ts for improved prose formatting.
- Adjusted CommentSingle to utilize the new CommentAvatar component for consistency.
This commit is contained in:
2026-01-29 21:18:24 -05:00
parent 189dcef673
commit 74be4382a9
8 changed files with 420 additions and 193 deletions

View File

@@ -1,9 +1,19 @@
import Link from "next/link";
import { LockIcon } from "lucide-react";
import { cn } from "@/lib/utils";
const Page = () => {
return (
<div className="prose prose-sm prose-neutral dark:prose-invert prose-headings:mt-0 prose-headings:mb-3 prose-p:my-3 prose-p:leading-[1.75] md:prose-p:leading-relaxed max-w-none">
<div
className={cn(
"prose prose-neutral dark:prose-invert prose-sm max-w-none",
"prose-headings:font-semibold prose-headings:text-primary prose-headings:tracking-tight prose-headings:mt-0 prose-headings:mb-3",
"prose-p:text-foreground/90 prose-p:my-3 prose-p:leading-[1.75] md:prose-p:leading-relaxed prose-strong:text-primary prose-li:text-foreground/80",
"prose-a:text-primary prose-a:font-medium prose-a:underline prose-a:underline-offset-4",
"prose-code:bg-muted prose-code:text-foreground prose-code:px-1 prose-code:py-0.5 prose-code:rounded-sm prose-code:text-[0.9em] prose-code:before:content-none prose-code:after:content-none",
"[&_table]:!border-[color:var(--border)] [&_td]:!border-[color:var(--border)] [&_th]:!border-[color:var(--border)]"
)}
>
<h1 className="text-2xl font-medium">
Hi there! I&rsquo;m Jake.{" "}
<span className="motion-safe:animate-wave ml-0.5 inline-block origin-[65%_80%] text-2xl">👋</span>

View File

@@ -3,6 +3,16 @@
import { useState } from "react";
import { toast } from "sonner";
import { ReplyIcon, EditIcon, Trash2Icon, EllipsisIcon, Loader2Icon } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -10,48 +20,57 @@ import {
DropdownMenuTrigger,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { CommentForm } from "./comment-form";
import { EditCommentForm, ReplyForm } from "./comment-form";
import { useSession } from "@/lib/auth-client";
import { deleteComment, type CommentWithUser } from "@/lib/server/comments";
type ActionMode =
| { type: "idle" }
| { type: "replying" }
| { type: "editing" }
| { type: "confirming-delete" }
| { type: "deleting" };
const CommentActions = ({ comment }: { comment: CommentWithUser }) => {
const [isReplying, setIsReplying] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [mode, setMode] = useState<ActionMode>({ type: "idle" });
const { data: session } = useSession();
if (!session) return null;
const handleDelete = async () => {
if (!confirm("Are you sure you want to delete this comment?")) return;
setIsDeleting(true);
setMode({ type: "deleting" });
try {
await deleteComment(comment.id);
toast.success("Your comment has been deleted successfully.");
setMode({ type: "idle" });
} catch (error) {
console.error("Error deleting comment:", error);
toast.error("Failed to delete comment. Please try again.");
} finally {
setIsDeleting(false);
setMode({ type: "idle" });
}
};
const isDeleting = mode.type === "deleting";
return (
<div className="mt-4">
{isEditing ? (
<CommentForm
{mode.type === "editing" ? (
<EditCommentForm
slug={comment.pageSlug}
initialContent={comment.content}
commentId={comment.id}
onCancel={() => setIsEditing(false)}
onSuccess={() => setIsEditing(false)}
initialContent={comment.content}
onCancel={() => setMode({ type: "idle" })}
onSuccess={() => setMode({ type: "idle" })}
/>
) : (
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setIsReplying(!isReplying)}>
<Button
variant="outline"
size="sm"
onClick={() => setMode(mode.type === "replying" ? { type: "idle" } : { type: "replying" })}
>
<ReplyIcon />
Reply
</Button>
@@ -65,11 +84,15 @@ const CommentActions = ({ comment }: { comment: CommentWithUser }) => {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => setIsEditing(!isEditing)}>
<DropdownMenuItem onClick={() => setMode({ type: "editing" })}>
<EditIcon />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={handleDelete} disabled={isDeleting} variant="destructive">
<DropdownMenuItem
onClick={() => setMode({ type: "confirming-delete" })}
disabled={isDeleting}
variant="destructive"
>
{isDeleting ? <Loader2Icon className="animate-spin" /> : <Trash2Icon />}
Delete
</DropdownMenuItem>
@@ -79,16 +102,31 @@ const CommentActions = ({ comment }: { comment: CommentWithUser }) => {
</div>
)}
{isReplying && (
{mode.type === "replying" && (
<div className="mt-4">
<CommentForm
<ReplyForm
slug={comment.pageSlug}
parentId={comment.id}
onCancel={() => setIsReplying(false)}
onSuccess={() => setIsReplying(false)}
onCancel={() => setMode({ type: "idle" })}
onSuccess={() => setMode({ type: "idle" })}
/>
</div>
)}
<AlertDialog open={mode.type === "confirming-delete"} onOpenChange={(open) => !open && setMode({ type: "idle" })}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Delete comment?</AlertDialogTitle>
<AlertDialogDescription>This action cannot be undone.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={handleDelete}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};

View File

@@ -0,0 +1,31 @@
import { getImageProps } from "next/image";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
type CommentAvatarProps = {
name: string;
image?: string | null;
className?: string;
};
const CommentAvatar = ({ name, image, className }: CommentAvatarProps) => {
return (
<Avatar className={cn("size-10", className)}>
{image && (
<AvatarImage
{...getImageProps({
src: image,
alt: `@${name}'s avatar`,
width: 40,
height: 40,
}).props}
width={undefined}
height={undefined}
/>
)}
<AvatarFallback>{name.charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
);
};
export { CommentAvatar };

View File

@@ -1,39 +1,191 @@
"use client";
import { useState, useTransition } from "react";
import { getImageProps } from "next/image";
import { createContext, useContext, useState, useTransition } from "react";
import { toast } from "sonner";
import { InfoIcon, Loader2Icon } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { MarkdownIcon } from "@/components/icons";
import { CommentAvatar } from "./comment-avatar";
import { useSession } from "@/lib/auth-client";
import { createComment, updateComment } from "@/lib/server/comments";
const CommentForm = ({
slug,
parentId,
commentId,
// Context for lifting form state to parent components
type CommentFormContextValue = {
content: string;
setContent: (value: string) => void;
isPending: boolean;
startTransition: React.TransitionStartFunction;
};
const CommentFormContext = createContext<CommentFormContextValue | null>(null);
// Provider for sharing form state with sibling components (preview, character counter, etc.)
const CommentFormProvider = ({
children,
initialContent = "",
onCancel,
onSuccess,
}: {
slug: string;
parentId?: string;
commentId?: string;
children: React.ReactNode;
initialContent?: string;
onCancel?: () => void;
onSuccess?: () => void;
}) => {
const [content, setContent] = useState(initialContent);
const [isPending, startTransition] = useTransition();
const isEditing = !!commentId;
const isReplying = !!parentId;
return (
<CommentFormContext.Provider value={{ content, setContent, isPending, startTransition }}>
{children}
</CommentFormContext.Provider>
);
};
// Hook to access form state from context (for sibling components like preview panels)
const useCommentForm = () => {
const context = useContext(CommentFormContext);
if (!context) {
throw new Error("useCommentForm must be used within a CommentFormProvider");
}
return context;
};
// Internal hook - uses context if available, otherwise creates local state
const useCommentFormState = (initialContent: string = "") => {
const context = useContext(CommentFormContext);
const [localContent, setLocalContent] = useState(initialContent);
const [localIsPending, localStartTransition] = useTransition();
// If wrapped in provider, use context; otherwise use local state
if (context) {
return context;
}
return {
content: localContent,
setContent: setLocalContent,
isPending: localIsPending,
startTransition: localStartTransition,
};
};
// Shared textarea component
const CommentTextarea = ({
content,
setContent,
isPending,
placeholder,
}: {
content: string;
setContent: (value: string) => void;
isPending: boolean;
placeholder: string;
}) => (
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={placeholder}
className="min-h-[4lh] w-full"
disabled={isPending}
/>
);
// Current user's avatar (uses session)
const CurrentUserAvatar = () => {
const { data: session } = useSession();
if (!session?.user) return null;
return (
<div className="shrink-0">
<CommentAvatar name={session.user.name} image={session.user.image} />
</div>
);
};
// Submit button with pending state
const SubmitButton = ({
isPending,
disabled,
pendingLabel,
children,
}: {
isPending: boolean;
disabled?: boolean;
pendingLabel: string;
children: React.ReactNode;
}) => (
<Button type="submit" disabled={isPending || disabled}>
{isPending ? (
<>
<Loader2Icon className="animate-spin" />
{pendingLabel}
</>
) : (
children
)}
</Button>
);
// Markdown help popover (only shown for new comments)
const MarkdownHelp = () => (
<p className="text-muted-foreground text-[0.8rem] leading-relaxed">
<MarkdownIcon className="mr-1.5 inline-block size-4 align-text-top" />
<span className="max-md:hidden">Basic&nbsp;</span>
<Popover>
<PopoverTrigger>
<span className="text-primary decoration-primary/40 cursor-pointer font-semibold no-underline decoration-2 underline-offset-4 hover:underline">
<span>Markdown</span>
<span className="max-md:hidden">&nbsp;syntax</span>
</span>
</PopoverTrigger>
<PopoverContent align="start">
<p className="text-sm leading-loose">
<InfoIcon className="mr-1.5 inline size-4.5 align-text-top" />
Examples:
</p>
<ul className="[&>li::marker]:text-muted-foreground my-2 list-inside list-disc pl-1 text-sm [&>li]:my-1.5 [&>li]:pl-1 [&>li]:text-nowrap [&>li::marker]:font-normal">
<li>
<span className="font-bold">**bold**</span>
</li>
<li>
<span className="italic">_italics_</span>
</li>
<li>
[
<a href="https://jarv.is" target="_blank" rel="noopener" className="hover:no-underline">
links
</a>
](https://jarv.is)
</li>
<li>
<span className="bg-muted rounded-sm px-[0.3rem] py-[0.2rem] font-mono text-sm font-medium">`code`</span>
</li>
<li>
~~<span className="line-through">strikethrough</span>~~
</li>
</ul>
<p className="text-sm leading-loose">
<a
href="https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax"
target="_blank"
rel="noopener noreferrer"
>
Learn more.
</a>
</p>
</PopoverContent>
</Popover>
<span>&nbsp;is supported</span>
<span className="max-md:hidden">&nbsp;here</span>
<span>.</span>
</p>
);
// New comment form - for creating top-level comments
const NewCommentForm = ({ slug }: { slug: string }) => {
const { content, setContent, isPending, startTransition } = useCommentFormState();
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
@@ -44,24 +196,69 @@ const CommentForm = ({
startTransition(async () => {
try {
if (isEditing) {
await updateComment(commentId, content);
toast.success("Comment updated!");
} else {
await createComment({
content,
parentId,
pageSlug: slug,
});
toast.success("Comment posted!");
}
await createComment({ content, pageSlug: slug });
toast.success("Comment posted!");
setContent("");
} catch (error) {
console.error("Error submitting comment:", error);
toast.error("Failed to submit comment. Please try again.");
}
});
};
// Reset form if not editing
if (!isEditing) {
setContent("");
}
return (
<form onSubmit={handleSubmit} className="space-y-4" data-intent="create">
<div className="flex gap-4">
<CurrentUserAvatar />
// Call success callback if provided
<div className="min-w-0 flex-1 space-y-4">
<CommentTextarea
content={content}
setContent={setContent}
isPending={isPending}
placeholder="Write your thoughts..."
/>
<div className="flex justify-between gap-4">
<MarkdownHelp />
<SubmitButton isPending={isPending} disabled={!content.trim()} pendingLabel="Posting...">
Comment
</SubmitButton>
</div>
</div>
</div>
</form>
);
};
// Reply form - for replying to existing comments
const ReplyForm = ({
slug,
parentId,
onCancel,
onSuccess,
}: {
slug: string;
parentId: string;
onCancel: () => void;
onSuccess?: () => void;
}) => {
const { content, setContent, isPending, startTransition } = useCommentFormState();
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!content.trim()) {
toast.error("Comment cannot be empty.");
return;
}
startTransition(async () => {
try {
await createComment({ content, parentId, pageSlug: slug });
toast.success("Comment posted!");
setContent("");
onSuccess?.();
} catch (error) {
console.error("Error submitting comment:", error);
@@ -71,121 +268,26 @@ const CommentForm = ({
};
return (
<form onSubmit={handleSubmit} className="space-y-4" data-intent={isEditing ? "edit" : "create"}>
<form onSubmit={handleSubmit} className="space-y-4" data-intent="create">
<div className="flex gap-4">
{!isEditing && (
<div className="shrink-0">
<Avatar className="size-10">
{session?.user.image && (
<AvatarImage
{...getImageProps({
src: session.user.image,
alt: `@${session.user.name}'s avatar`,
width: 40,
height: 40,
}).props}
width={undefined}
height={undefined}
/>
)}
<AvatarFallback>{session?.user.name.charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
</div>
)}
<CurrentUserAvatar />
<div className="min-w-0 flex-1 space-y-4">
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={isReplying ? "Reply to this comment..." : "Write your thoughts..."}
className="min-h-[4lh] w-full"
disabled={isPending}
<CommentTextarea
content={content}
setContent={setContent}
isPending={isPending}
placeholder="Reply to this comment..."
/>
<div className="flex justify-between gap-4">
<div>
{/* Only show the markdown help text if the comment is new */}
{!isEditing && !isReplying && (
<p className="text-muted-foreground text-[0.8rem] leading-relaxed">
<MarkdownIcon className="mr-1.5 inline-block size-4 align-text-top" />
<span className="max-md:hidden">Basic&nbsp;</span>
<Popover>
<PopoverTrigger>
<span className="text-primary decoration-primary/40 cursor-pointer font-semibold no-underline decoration-2 underline-offset-4 hover:underline">
<span>Markdown</span>
<span className="max-md:hidden">&nbsp;syntax</span>
</span>
</PopoverTrigger>
<PopoverContent align="start">
<p className="text-sm leading-loose">
<InfoIcon className="mr-1.5 inline size-4.5 align-text-top" />
Examples:
</p>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel} disabled={isPending}>
Cancel
</Button>
<ul className="[&>li::marker]:text-muted-foreground my-2 list-inside list-disc pl-1 text-sm [&>li]:my-1.5 [&>li]:pl-1 [&>li]:text-nowrap [&>li::marker]:font-normal">
<li>
<span className="font-bold">**bold**</span>
</li>
<li>
<span className="italic">_italics_</span>
</li>
<li>
[
<a href="https://jarv.is" target="_blank" rel="noopener" className="hover:no-underline">
links
</a>
](https://jarv.is)
</li>
<li>
<span className="bg-muted rounded-sm px-[0.3rem] py-[0.2rem] font-mono text-sm font-medium">
`code`
</span>
</li>
<li>
~~<span className="line-through">strikethrough</span>~~
</li>
</ul>
<p className="text-sm leading-loose">
<a
href="https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax"
target="_blank"
rel="noopener noreferrer"
>
Learn more.
</a>
</p>
</PopoverContent>
</Popover>
<span>&nbsp;is supported</span>
<span className="max-md:hidden">&nbsp;here</span>
<span>.</span>
</p>
)}
</div>
<div className="flex justify-end gap-2">
{(onCancel || isEditing) && (
<Button type="button" variant="outline" onClick={onCancel} disabled={isPending}>
Cancel
</Button>
)}
<Button type="submit" disabled={isPending || !content.trim()}>
{isPending ? (
<>
<Loader2Icon className="animate-spin" />
{isEditing ? "Updating..." : "Posting..."}
</>
) : isEditing ? (
"Edit"
) : isReplying ? (
"Reply"
) : (
"Comment"
)}
</Button>
</div>
<SubmitButton isPending={isPending} disabled={!content.trim()} pendingLabel="Posting...">
Reply
</SubmitButton>
</div>
</div>
</div>
@@ -193,4 +295,64 @@ const CommentForm = ({
);
};
export { CommentForm };
// Edit comment form - for editing existing comments
const EditCommentForm = ({
slug,
commentId,
initialContent,
onCancel,
onSuccess,
}: {
slug: string;
commentId: string;
initialContent: string;
onCancel: () => void;
onSuccess?: () => void;
}) => {
const { content, setContent, isPending, startTransition } = useCommentFormState(initialContent);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!content.trim()) {
toast.error("Comment cannot be empty.");
return;
}
startTransition(async () => {
try {
await updateComment(commentId, content);
toast.success("Comment updated!");
onSuccess?.();
} catch (error) {
console.error("Error updating comment:", error);
toast.error("Failed to update comment. Please try again.");
}
});
};
return (
<form onSubmit={handleSubmit} className="space-y-4" data-intent="edit" data-slug={slug}>
<div className="min-w-0 flex-1 space-y-4">
<CommentTextarea
content={content}
setContent={setContent}
isPending={isPending}
placeholder="Edit your comment..."
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel} disabled={isPending}>
Cancel
</Button>
<SubmitButton isPending={isPending} disabled={!content.trim()} pendingLabel="Updating...">
Edit
</SubmitButton>
</div>
</div>
</form>
);
};
export { NewCommentForm, ReplyForm, EditCommentForm, CommentFormProvider, useCommentForm };

View File

@@ -1,8 +1,7 @@
import { getImageProps } from "next/image";
import Link from "next/link";
import Markdown from "react-markdown";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { RelativeTime } from "@/components/relative-time";
import { CommentAvatar } from "./comment-avatar";
import { CommentActions } from "./comment-actions";
import { remarkGfm, remarkSmartypants } from "@/lib/remark";
import { rehypeExternalLinks } from "@/lib/rehype";
@@ -16,21 +15,7 @@ const CommentSingle = ({ comment }: { comment: CommentWithUser }) => {
<div className="group scroll-mt-4" id={divId}>
<div className="flex gap-4">
<div className="shrink-0">
<Avatar className="size-8 md:size-10">
{comment.user.image && (
<AvatarImage
{...getImageProps({
src: comment.user.image,
alt: `@${comment.user.name}'s avatar`,
width: 40,
height: 40,
}).props}
width={undefined}
height={undefined}
/>
)}
<AvatarFallback>{comment.user.name.charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
<CommentAvatar name={comment.user.name} image={comment.user.image} className="size-8 md:size-10" />
</div>
<div className="min-w-0 flex-1">

View File

@@ -2,6 +2,9 @@ import { CommentSingle } from "./comment-single";
import { cn } from "@/lib/utils";
import type { CommentWithUser } from "@/lib/server/comments";
/** Maximum nesting depth for comment threads (0-indexed, so 2 = 3 levels deep) */
const MAX_NESTING_LEVEL = 2;
const CommentThread = ({
comment,
replies,
@@ -13,15 +16,12 @@ const CommentThread = ({
allComments: Record<string, CommentWithUser[]>;
level?: number;
}) => {
// Limit nesting to 3 levels
const maxLevel = 2;
return (
<>
<CommentSingle comment={comment} />
{replies.length > 0 && (
<div className={cn("mt-6 space-y-6", level < maxLevel && "ml-6 border-l-2 pl-6")}>
<div className={cn("mt-6 space-y-6", level < MAX_NESTING_LEVEL && "ml-6 border-l-2 pl-6")}>
{replies.map((reply) => (
<CommentThread
key={reply.id}

View File

@@ -1,5 +1,5 @@
import { headers } from "next/headers";
import { CommentForm } from "./comment-form";
import { NewCommentForm } from "./comment-form";
import { CommentThread } from "./comment-thread";
import { SignIn } from "./sign-in";
import { auth } from "@/lib/auth";
@@ -29,7 +29,7 @@ const Comments = async ({ slug }: { slug: string }) => {
return (
<>
{session ? (
<CommentForm slug={slug} />
<NewCommentForm slug={slug} />
) : (
<div className="bg-muted/40 flex flex-col items-center justify-center gap-y-4 rounded-lg p-6">
<p className="text-center font-medium">Join the discussion by signing in:</p>

View File

@@ -139,12 +139,13 @@ const nextPlugins: Array<
"rehype-wrapper",
{
className: [
"prose prose-sm prose-neutral dark:prose-invert",
"prose-headings:font-semibold prose-headings:tracking-tight",
"prose-h2:border-b prose-h2:pb-2",
"prose-a:underline-offset-4",
"prose-blockquote:**:before:content-none prose-blockquote:**:after:content-none prose-blockquote:text-(--tw-prose-body)",
"max-w-none",
"prose prose-neutral dark:prose-invert prose-sm max-w-none",
"prose-headings:font-semibold prose-headings:text-primary prose-headings:tracking-tight",
"prose-p:text-foreground/90 prose-strong:text-primary prose-li:text-foreground/80",
"prose-a:text-primary prose-a:font-medium prose-a:underline prose-a:underline-offset-4",
"prose-blockquote:[&_p]:text-foreground/75 prose-blockquote:*:before:content-none prose-blockquote:*:after:content-none",
"prose-code:bg-muted prose-code:text-foreground prose-code:px-1 prose-code:py-0.5 prose-code:rounded-sm prose-code:text-[0.9em] prose-code:before:content-none prose-code:after:content-none",
"[&_table]:!border-[color:var(--border)] [&_td]:!border-[color:var(--border)] [&_th]:!border-[color:var(--border)]",
].join(" "),
},
],