From ecbc9c627dfc1d252b1aac167cc269d0b6a174dd Mon Sep 17 00:00:00 2001 From: Nicholai Date: Thu, 22 Jan 2026 03:18:10 -0700 Subject: [PATCH] feat(wishlist): add comment delete, voting, and replies - Add parentId column to wishlist_comments for reply threading - Create wishlist_comment_votes table for up/down voting - Add deleteComment action (owner-only, cascades to replies) - Add toggleCommentVote action with toggle behavior - Update getItemWithComments to return nested CommentWithMeta - Create CommentItem component with voting UI and reply support - One level of reply depth enforced (can't reply to replies) --- drizzle/0001_modern_mad_thinker.sql | 10 + drizzle/meta/0001_snapshot.json | 282 ++++++++++ drizzle/meta/_journal.json | 7 + src/app/actions/wishlist.ts | 191 ++++++- .../wishlist/wishlist-item-detail.tsx | 512 ++++++++++++------ src/db/schema.ts | 12 + 6 files changed, 857 insertions(+), 157 deletions(-) create mode 100644 drizzle/0001_modern_mad_thinker.sql create mode 100644 drizzle/meta/0001_snapshot.json diff --git a/drizzle/0001_modern_mad_thinker.sql b/drizzle/0001_modern_mad_thinker.sql new file mode 100644 index 0000000..0c3b41d --- /dev/null +++ b/drizzle/0001_modern_mad_thinker.sql @@ -0,0 +1,10 @@ +CREATE TABLE `wishlist_comment_votes` ( + `id` text PRIMARY KEY NOT NULL, + `comment_id` text NOT NULL, + `user_id` text NOT NULL, + `vote_type` text NOT NULL, + `created_at` text NOT NULL, + FOREIGN KEY (`comment_id`) REFERENCES `wishlist_comments`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +ALTER TABLE `wishlist_comments` ADD `parent_id` text; \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..bcf2ed9 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,282 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "3dcddf65-76e4-48ab-94a5-fe7cbd75a846", + "prevId": "fa1075ec-1dde-40b2-8532-a06170b3b554", + "tables": { + "wishlist_comment_votes": { + "name": "wishlist_comment_votes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "comment_id": { + "name": "comment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vote_type": { + "name": "vote_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "wishlist_comment_votes_comment_id_wishlist_comments_id_fk": { + "name": "wishlist_comment_votes_comment_id_wishlist_comments_id_fk", + "tableFrom": "wishlist_comment_votes", + "tableTo": "wishlist_comments", + "columnsFrom": [ + "comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "wishlist_comments": { + "name": "wishlist_comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "wishlist_comments_item_id_wishlist_items_id_fk": { + "name": "wishlist_comments_item_id_wishlist_items_id_fk", + "tableFrom": "wishlist_comments", + "tableTo": "wishlist_items", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "wishlist_items": { + "name": "wishlist_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "estimated_cost": { + "name": "estimated_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "submitted_by": { + "name": "submitted_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "submitted_by_name": { + "name": "submitted_by_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "wishlist_votes": { + "name": "wishlist_votes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "wishlist_votes_item_id_wishlist_items_id_fk": { + "name": "wishlist_votes_item_id_wishlist_items_id_fk", + "tableFrom": "wishlist_votes", + "tableTo": "wishlist_items", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 5c9475f..4de8810 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1769063383310, "tag": "0000_flawless_paladin", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1769076224750, + "tag": "0001_modern_mad_thinker", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/actions/wishlist.ts b/src/app/actions/wishlist.ts index a3ecd4d..da08791 100644 --- a/src/app/actions/wishlist.ts +++ b/src/app/actions/wishlist.ts @@ -2,7 +2,12 @@ import { getCloudflareContext } from "@opennextjs/cloudflare" import { getDb } from "@/db" -import { wishlistItems, wishlistVotes, wishlistComments } from "@/db/schema" +import { + wishlistItems, + wishlistVotes, + wishlistComments, + wishlistCommentVotes, +} from "@/db/schema" import { eq, asc, and } from "drizzle-orm" import { revalidatePath } from "next/cache" @@ -186,15 +191,32 @@ export async function addComment( itemId: string, userId: string, userName: string, - content: string + content: string, + parentId?: string ): Promise<{ success: boolean; error?: string }> { try { const { env } = await getCloudflareContext() const db = getDb(env.DB) + if (parentId) { + const parent = await db + .select() + .from(wishlistComments) + .where(eq(wishlistComments.id, parentId)) + .limit(1) + + if (parent.length === 0) { + return { success: false, error: "Parent comment not found" } + } + if (parent[0].parentId !== null) { + return { success: false, error: "Cannot reply to a reply" } + } + } + await db.insert(wishlistComments).values({ id: generateId(), itemId, + parentId: parentId ?? null, userId, userName, content, @@ -209,6 +231,134 @@ export async function addComment( } } +export async function deleteComment( + commentId: string, + userId: string +): Promise<{ success: boolean; error?: string }> { + try { + const { env } = await getCloudflareContext() + const db = getDb(env.DB) + + const comment = await db + .select() + .from(wishlistComments) + .where(eq(wishlistComments.id, commentId)) + .limit(1) + + if (comment.length === 0) { + return { success: false, error: "Comment not found" } + } + + if (comment[0].userId !== userId) { + return { success: false, error: "You can only delete your own comments" } + } + + await db + .delete(wishlistComments) + .where(eq(wishlistComments.parentId, commentId)) + await db.delete(wishlistComments).where(eq(wishlistComments.id, commentId)) + + revalidatePath("/dashboard/wishlist") + return { success: true } + } catch (error) { + console.error("Failed to delete comment:", error) + return { success: false, error: "Failed to delete comment" } + } +} + +export type VoteType = "up" | "down" + +export interface ToggleVoteResult { + success: boolean + upvotes: number + downvotes: number + userVote: VoteType | null + error?: string +} + +export async function toggleCommentVote( + commentId: string, + userId: string, + voteType: VoteType +): Promise { + try { + const { env } = await getCloudflareContext() + const db = getDb(env.DB) + + const existingVote = await db + .select() + .from(wishlistCommentVotes) + .where( + and( + eq(wishlistCommentVotes.commentId, commentId), + eq(wishlistCommentVotes.userId, userId) + ) + ) + .limit(1) + + if (existingVote.length > 0) { + if (existingVote[0].voteType === voteType) { + await db + .delete(wishlistCommentVotes) + .where(eq(wishlistCommentVotes.id, existingVote[0].id)) + } else { + await db + .update(wishlistCommentVotes) + .set({ voteType }) + .where(eq(wishlistCommentVotes.id, existingVote[0].id)) + } + } else { + await db.insert(wishlistCommentVotes).values({ + id: generateId(), + commentId, + userId, + voteType, + createdAt: new Date().toISOString(), + }) + } + + const allVotes = await db + .select() + .from(wishlistCommentVotes) + .where(eq(wishlistCommentVotes.commentId, commentId)) + + const upvotes = allVotes.filter((v) => v.voteType === "up").length + const downvotes = allVotes.filter((v) => v.voteType === "down").length + const currentUserVote = allVotes.find((v) => v.userId === userId) + + revalidatePath("/dashboard/wishlist") + return { + success: true, + upvotes, + downvotes, + userVote: currentUserVote ? (currentUserVote.voteType as VoteType) : null, + } + } catch (error) { + console.error("Failed to toggle comment vote:", error) + return { + success: false, + upvotes: 0, + downvotes: 0, + userVote: null, + error: "Failed to toggle vote", + } + } +} + +export interface CommentWithMeta { + id: string + itemId: string + parentId: string | null + userId: string + userName: string + content: string + createdAt: string + upvotes: number + downvotes: number + userVote: VoteType | null + replies: CommentWithMeta[] +} + export async function getItemWithComments(itemId: string, userId: string) { const { env } = await getCloudflareContext() const db = getDb(env.DB) @@ -228,17 +378,50 @@ export async function getItemWithComments(itemId: string, userId: string) { .from(wishlistVotes) .where(eq(wishlistVotes.itemId, itemId)) - const comments = await db + const rawComments = await db .select() .from(wishlistComments) .where(eq(wishlistComments.itemId, itemId)) .orderBy(asc(wishlistComments.createdAt)) + const commentIds = rawComments.map((c) => c.id) + const commentVotes = + commentIds.length > 0 + ? await db.select().from(wishlistCommentVotes) + : [] + + const commentsWithMeta: CommentWithMeta[] = rawComments.map((comment) => { + const votesForComment = commentVotes.filter( + (v) => v.commentId === comment.id + ) + const upvotes = votesForComment.filter((v) => v.voteType === "up").length + const downvotes = votesForComment.filter((v) => v.voteType === "down").length + const userVoteRecord = votesForComment.find((v) => v.userId === userId) + + return { + ...comment, + upvotes, + downvotes, + userVote: userVoteRecord ? (userVoteRecord.voteType as VoteType) : null, + replies: [], + } + }) + + const topLevel = commentsWithMeta.filter((c) => c.parentId === null) + const replies = commentsWithMeta.filter((c) => c.parentId !== null) + + for (const reply of replies) { + const parent = topLevel.find((c) => c.id === reply.parentId) + if (parent) { + parent.replies.push(reply) + } + } + return { ...item[0], voteCount: votes.length, hasVoted: votes.some((v) => v.userId === userId), - comments, + comments: topLevel, } } diff --git a/src/components/wishlist/wishlist-item-detail.tsx b/src/components/wishlist/wishlist-item-detail.tsx index 6cd42bf..c7a6df0 100644 --- a/src/components/wishlist/wishlist-item-detail.tsx +++ b/src/components/wishlist/wishlist-item-detail.tsx @@ -4,24 +4,26 @@ import { useState, useTransition, useEffect } from "react" import { IconThumbUp, IconThumbUpFilled, + IconThumbDown, + IconThumbDownFilled, IconExternalLink, IconSend, + IconCornerDownRight, + IconTrash, + IconX, } from "@tabler/icons-react" import { formatDistanceToNow } from "date-fns" import { toast } from "sonner" -import { useIsMobile } from "@/hooks/use-mobile" import { Avatar, AvatarFallback } from "@/components/ui/avatar" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { - Drawer, - DrawerClose, - DrawerContent, - DrawerFooter, - DrawerHeader, - DrawerTitle, -} from "@/components/ui/drawer" + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" import { ScrollArea } from "@/components/ui/scroll-area" import { Separator } from "@/components/ui/separator" import { Textarea } from "@/components/ui/textarea" @@ -29,9 +31,12 @@ import { getItemWithComments, toggleVote, addComment, + deleteComment, + toggleCommentVote, type WishlistItemWithMeta, + type CommentWithMeta, + type VoteType, } from "@/app/actions/wishlist" -import type { WishlistComment } from "@/db/schema" const priorityColors: Record = { critical: "bg-red-500/10 text-red-500 border-red-500/20", @@ -40,6 +45,135 @@ const priorityColors: Record = { low: "bg-green-500/10 text-green-500 border-green-500/20", } +interface CommentItemProps { + comment: CommentWithMeta + userId: string + isReply?: boolean + onReply: (comment: CommentWithMeta) => void + onDelete: (commentId: string, parentId: string | null) => void + onVote: (commentId: string, voteType: VoteType, parentId: string | null) => void + disabled?: boolean +} + +function CommentItem({ + comment, + userId, + isReply = false, + onReply, + onDelete, + onVote, + disabled, +}: CommentItemProps) { + const score = comment.upvotes - comment.downvotes + const isOwner = comment.userId === userId + + return ( +
+
+ + + {comment.userName + .split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2)} + + +
+
+ {comment.userName} + + {formatDistanceToNow(new Date(comment.createdAt), { + addSuffix: true, + })} + +
+

{comment.content}

+
+ + 0 + ? "text-green-500" + : score < 0 + ? "text-red-500" + : "text-muted-foreground" + }`} + > + {score} + + + {!isReply && ( + + )} + {isOwner && ( + + )} +
+
+
+ {comment.replies.length > 0 && ( +
+ {comment.replies.map((reply) => ( + + ))} +
+ )} +
+ ) +} + interface WishlistItemDetailProps { item: WishlistItemWithMeta | null open: boolean @@ -55,10 +189,10 @@ export function WishlistItemDetail({ userId, userName, }: WishlistItemDetailProps) { - const isMobile = useIsMobile() const [isPending, startTransition] = useTransition() - const [comments, setComments] = useState([]) + const [comments, setComments] = useState([]) const [newComment, setNewComment] = useState("") + const [replyingTo, setReplyingTo] = useState(null) const [voteState, setVoteState] = useState({ hasVoted: item?.hasVoted ?? false, voteCount: item?.voteCount ?? 0, @@ -94,164 +228,236 @@ export function WishlistItemDetail({ } const handleAddComment = () => { - if (!newComment.trim()) return + if (!newComment.trim() || isPending) return + const parentId = replyingTo?.id startTransition(async () => { - const result = await addComment(item.id, userId, userName, newComment.trim()) + const result = await addComment( + item.id, + userId, + userName, + newComment.trim(), + parentId + ) if (result.success) { - setComments([ - ...comments, - { - id: crypto.randomUUID(), - itemId: item.id, - userId, - userName, - content: newComment.trim(), - createdAt: new Date().toISOString(), - }, - ]) + const newCommentData: CommentWithMeta = { + id: crypto.randomUUID(), + itemId: item.id, + parentId: parentId ?? null, + userId, + userName, + content: newComment.trim(), + createdAt: new Date().toISOString(), + upvotes: 0, + downvotes: 0, + userVote: null, + replies: [], + } + + if (parentId) { + setComments( + comments.map((c) => + c.id === parentId + ? { ...c, replies: [...c.replies, newCommentData] } + : c + ) + ) + } else { + setComments([...comments, newCommentData]) + } setNewComment("") - toast.success("Comment added") + setReplyingTo(null) + toast.success(parentId ? "Reply added" : "Comment added") } else { toast.error(result.error || "Failed to add comment") } }) } - const Content = ( - <> -
-
- {item.category} - - {item.priority} - - {item.estimatedCost && ( - ~${item.estimatedCost.toLocaleString()} - )} -
+ const handleDeleteComment = (commentId: string, parentId: string | null) => { + startTransition(async () => { + const result = await deleteComment(commentId, userId) + if (result.success) { + if (parentId) { + setComments( + comments.map((c) => + c.id === parentId + ? { ...c, replies: c.replies.filter((r) => r.id !== commentId) } + : c + ) + ) + } else { + setComments(comments.filter((c) => c.id !== commentId)) + } + toast.success("Comment deleted") + } else { + toast.error(result.error || "Failed to delete comment") + } + }) + } -

{item.description}

+ const handleCommentVote = ( + commentId: string, + voteType: VoteType, + parentId: string | null + ) => { + startTransition(async () => { + const result = await toggleCommentVote(commentId, userId, voteType) + if (result.success) { + const updateComment = (c: CommentWithMeta): CommentWithMeta => + c.id === commentId + ? { + ...c, + upvotes: result.upvotes, + downvotes: result.downvotes, + userVote: result.userVote, + } + : c - {item.link && ( - - - View Link - - )} - -
- - Submitted by {item.submittedByName} ·{" "} - {formatDistanceToNow(new Date(item.createdAt), { addSuffix: true })} - - -
- - - -
-

- Comments ({comments.length}) -

- - {comments.length === 0 ? ( -

- No comments yet. Be the first to comment! -

- ) : ( -
- {comments.map((comment) => ( -
- - - {comment.userName - .split(" ") - .map((n) => n[0]) - .join("") - .toUpperCase() - .slice(0, 2)} - - -
-
- - {comment.userName} - - - {formatDistanceToNow(new Date(comment.createdAt), { - addSuffix: true, - })} - -
-

{comment.content}

-
-
- ))} -
- )} -
-
-
- - ) + if (parentId) { + setComments( + comments.map((c) => + c.id === parentId + ? { ...c, replies: c.replies.map(updateComment) } + : c + ) + ) + } else { + setComments(comments.map(updateComment)) + } + } else { + toast.error(result.error || "Failed to vote") + } + }) + } return ( - - - - {item.name} - - {Content} - -
-