mirror of
https://github.com/jakejarvis/jarv.is.git
synced 2026-06-05 20:15:31 -04:00
homebrew comments system
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ReplyIcon, EditIcon, Trash2Icon, EllipsisIcon, Loader2Icon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import Button from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import Form from "./comment-form";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import { deleteComment, type CommentWithUser } from "@/lib/server/comments";
|
||||
|
||||
const CommentActions = ({ comment }: { comment: CommentWithUser }) => {
|
||||
const [isReplying, setIsReplying] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
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);
|
||||
|
||||
try {
|
||||
await deleteComment(comment.id);
|
||||
toast.success("Your comment has been deleted successfully.");
|
||||
} catch (error) {
|
||||
console.error("Error deleting comment:", error);
|
||||
toast.error("Failed to delete comment. Please try again.");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
{isEditing ? (
|
||||
<Form
|
||||
slug={comment.pageSlug}
|
||||
initialContent={comment.content}
|
||||
commentId={comment.id}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
onSuccess={() => setIsEditing(false)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setIsReplying(!isReplying)} className="h-8 px-2">
|
||||
<ReplyIcon className="mr-1 h-3.5 w-3.5" />
|
||||
Reply
|
||||
</Button>
|
||||
|
||||
{session.user.id === comment.user.id && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-8 px-2 text-xs">
|
||||
<EllipsisIcon />
|
||||
<span className="sr-only">Actions Menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => setIsEditing(!isEditing)}>
|
||||
<EditIcon />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleDelete} disabled={isDeleting} variant="destructive">
|
||||
{isDeleting ? <Loader2Icon className="animate-spin" /> : <Trash2Icon />}
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isReplying && (
|
||||
<div className="mt-4">
|
||||
<Form
|
||||
slug={comment.pageSlug}
|
||||
parentId={comment.id}
|
||||
onCancel={() => setIsReplying(false)}
|
||||
onSuccess={() => setIsReplying(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentActions;
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { getImageProps } from "next/image";
|
||||
import { InfoIcon, Loader2Icon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import Button from "@/components/ui/button";
|
||||
import Textarea from "@/components/ui/textarea";
|
||||
import Link from "@/components/link";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { MarkdownIcon } from "@/components/icons";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import { createComment, updateComment } from "@/lib/server/comments";
|
||||
import type { FormEvent } from "react";
|
||||
|
||||
const CommentForm = ({
|
||||
slug,
|
||||
parentId,
|
||||
commentId,
|
||||
initialContent = "",
|
||||
onCancel,
|
||||
onSuccess,
|
||||
}: {
|
||||
slug: string;
|
||||
parentId?: string;
|
||||
commentId?: string;
|
||||
initialContent?: string;
|
||||
onCancel?: () => void;
|
||||
onSuccess?: () => void;
|
||||
}) => {
|
||||
const [content, setContent] = useState(initialContent);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const isEditing = !!commentId;
|
||||
const isReplying = !!parentId;
|
||||
|
||||
const { data: session } = useSession();
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!content.trim()) {
|
||||
toast.error("Comment cannot be empty.");
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
if (isEditing) {
|
||||
await updateComment(commentId, content);
|
||||
toast.success("Comment updated!");
|
||||
} else {
|
||||
await createComment({
|
||||
content,
|
||||
parentId,
|
||||
pageSlug: slug,
|
||||
});
|
||||
toast.success("Comment posted!");
|
||||
}
|
||||
|
||||
// Reset form if not editing
|
||||
if (!isEditing) {
|
||||
setContent("");
|
||||
}
|
||||
|
||||
// Call success callback if provided
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
console.error("Error submitting comment:", error);
|
||||
toast.error("Failed to submit comment. Please try again.");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4" data-intent={isEditing ? "edit" : "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>
|
||||
)}
|
||||
|
||||
<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}
|
||||
/>
|
||||
|
||||
<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 </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"> 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>
|
||||
[
|
||||
<Link href="https://jarv.is" className="hover:no-underline">
|
||||
links
|
||||
</Link>
|
||||
](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">
|
||||
<Link href="https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax">
|
||||
Learn more.
|
||||
</Link>
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<span> is supported</span>
|
||||
<span className="max-md:hidden"> 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="mr-2 h-4 w-4 animate-spin" />
|
||||
{isEditing ? "Updating..." : "Posting..."}
|
||||
</>
|
||||
) : isEditing ? (
|
||||
"Edit"
|
||||
) : isReplying ? (
|
||||
"Reply"
|
||||
) : (
|
||||
"Comment"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentForm;
|
||||
@@ -0,0 +1,71 @@
|
||||
import { getImageProps } from "next/image";
|
||||
import Markdown from "react-markdown";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import Link from "@/components/link";
|
||||
import RelativeTime from "@/components/relative-time";
|
||||
import Actions from "./comment-actions";
|
||||
import { remarkGfm, remarkSmartypants } from "@/lib/remark";
|
||||
import { rehypeExternalLinks } from "@/lib/rehype";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CommentWithUser } from "@/lib/server/comments";
|
||||
|
||||
const CommentSingle = ({ comment }: { comment: CommentWithUser }) => {
|
||||
const divId = `comment-${comment.id.substring(0, 8)}`;
|
||||
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<Link href={`https://github.com/${comment.user.name}`} className="font-medium hover:no-underline">
|
||||
@{comment.user.name}
|
||||
</Link>
|
||||
<Link href={`#${divId}`} className="text-muted-foreground text-xs leading-none hover:no-underline">
|
||||
<RelativeTime date={comment.createdAt} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"isolate max-w-none text-[0.875rem] leading-relaxed",
|
||||
"[&_p]:my-5 [&_p]:first:mt-0 [&_p]:last:mb-0",
|
||||
"[&_a]:text-primary [&_a]:decoration-primary/40 [&_a]:no-underline [&_a]:decoration-2 [&_a]:underline-offset-4 [&_a]:hover:underline",
|
||||
"[&_code]:bg-muted [&_code]:rounded-sm [&_code]:px-[0.3rem] [&_code]:py-[0.2rem] [&_code]:font-medium",
|
||||
"group-has-data-[intent=edit]:hidden" // hides the rendered comment when its own edit form is active
|
||||
)}
|
||||
>
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm, remarkSmartypants]}
|
||||
rehypePlugins={[[rehypeExternalLinks, { target: "_blank", rel: "noopener noreferrer nofollow" }]]}
|
||||
allowedElements={["p", "a", "em", "strong", "code", "pre", "blockquote", "del"]}
|
||||
>
|
||||
{comment.content}
|
||||
</Markdown>
|
||||
</div>
|
||||
|
||||
<Actions comment={comment} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentSingle;
|
||||
@@ -0,0 +1,40 @@
|
||||
import Single from "./comment-single";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CommentWithUser } from "@/lib/server/comments";
|
||||
|
||||
const CommentThread = ({
|
||||
comment,
|
||||
replies,
|
||||
allComments,
|
||||
level = 0,
|
||||
}: {
|
||||
comment: CommentWithUser;
|
||||
replies: CommentWithUser[];
|
||||
allComments: Record<string, CommentWithUser[]>;
|
||||
level?: number;
|
||||
}) => {
|
||||
// Limit nesting to 3 levels
|
||||
const maxLevel = 2;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Single comment={comment} />
|
||||
|
||||
{replies.length > 0 && (
|
||||
<div className={cn("mt-6 space-y-6", level < maxLevel && "ml-6 border-l-2 pl-6")}>
|
||||
{replies.map((reply) => (
|
||||
<CommentThread
|
||||
key={reply.id}
|
||||
comment={reply}
|
||||
replies={allComments[reply.id] || []}
|
||||
allComments={allComments}
|
||||
level={level + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentThread;
|
||||
@@ -0,0 +1,26 @@
|
||||
import Skeleton from "@/components/ui/skeleton";
|
||||
|
||||
const CommentsSkeleton = () => {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Skeleton className="size-8 rounded-full md:size-10" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentsSkeleton;
|
||||
@@ -0,0 +1,64 @@
|
||||
import { headers } from "next/headers";
|
||||
import Form from "./comment-form";
|
||||
import Thread from "./comment-thread";
|
||||
import SignIn from "./sign-in";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getComments, type CommentWithUser } from "@/lib/server/comments";
|
||||
|
||||
const Comments = async ({ slug, closed = false }: { slug: string; closed?: boolean }) => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
const comments = await getComments(slug);
|
||||
|
||||
const commentsByParentId = comments.reduce(
|
||||
(acc, comment) => {
|
||||
const parentId = comment.parentId || "root";
|
||||
if (!acc[parentId]) {
|
||||
acc[parentId] = [];
|
||||
}
|
||||
acc[parentId].push(comment);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, CommentWithUser[]>
|
||||
);
|
||||
|
||||
const rootComments = commentsByParentId["root"] || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{closed ? (
|
||||
<div className="bg-muted/40 flex min-h-32 items-center justify-center rounded-lg p-6">
|
||||
<p className="text-center font-medium">Comments are closed for this post.</p>
|
||||
</div>
|
||||
) : !session ? (
|
||||
<div className="bg-muted/40 flex flex-col items-center justify-center rounded-lg p-6">
|
||||
<p className="mb-4 text-center font-medium">Join the discussion by signing in:</p>
|
||||
<SignIn callbackPath={`/${slug}#comments`} />
|
||||
</div>
|
||||
) : (
|
||||
<Form slug={slug} />
|
||||
)}
|
||||
|
||||
{!closed && rootComments.length === 0 ? (
|
||||
<div className="text-foreground/80 py-8 text-center text-lg font-medium tracking-tight">
|
||||
Be the first to comment!
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{rootComments.map((comment: CommentWithUser) => (
|
||||
<Thread
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
replies={commentsByParentId[comment.id] || []}
|
||||
allComments={commentsByParentId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Comments;
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { env } from "@/lib/env";
|
||||
import { useState } from "react";
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
import Button from "@/components/ui/button";
|
||||
import { GitHubIcon } from "@/components/icons";
|
||||
import { signIn } from "@/lib/auth-client";
|
||||
|
||||
const SignIn = ({ callbackPath }: { callbackPath?: string }) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSignIn = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await signIn.social({
|
||||
provider: "github",
|
||||
callbackURL: `${env.NEXT_PUBLIC_BASE_URL}${callbackPath ? callbackPath : "/"}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error signing in:", error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button onClick={handleSignIn} disabled={isLoading} size="lg" variant="outline">
|
||||
{isLoading ? <Loader2Icon className="animate-spin" /> : <GitHubIcon />}
|
||||
Sign in with GitHub
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignIn;
|
||||
Reference in New Issue
Block a user