Merge pull request #2 from NicholaiVogel/feat/comment-enhancements

feat(wishlist): add comment delete, voting, and replies
This commit is contained in:
Nicholai 2026-01-22 03:20:40 -07:00 committed by GitHub
commit 6740e7e03c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 857 additions and 157 deletions

View File

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

View File

@ -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": {}
}
}

View File

@ -8,6 +8,13 @@
"when": 1769063383310, "when": 1769063383310,
"tag": "0000_flawless_paladin", "tag": "0000_flawless_paladin",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1769076224750,
"tag": "0001_modern_mad_thinker",
"breakpoints": true
} }
] ]
} }

View File

@ -2,7 +2,12 @@
import { getCloudflareContext } from "@opennextjs/cloudflare" import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getDb } from "@/db" 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 { eq, asc, and } from "drizzle-orm"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
@ -186,15 +191,32 @@ export async function addComment(
itemId: string, itemId: string,
userId: string, userId: string,
userName: string, userName: string,
content: string content: string,
parentId?: string
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
try { try {
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) 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({ await db.insert(wishlistComments).values({
id: generateId(), id: generateId(),
itemId, itemId,
parentId: parentId ?? null,
userId, userId,
userName, userName,
content, 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<ToggleVoteResult> {
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) { export async function getItemWithComments(itemId: string, userId: string) {
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
const db = getDb(env.DB) const db = getDb(env.DB)
@ -228,17 +378,50 @@ export async function getItemWithComments(itemId: string, userId: string) {
.from(wishlistVotes) .from(wishlistVotes)
.where(eq(wishlistVotes.itemId, itemId)) .where(eq(wishlistVotes.itemId, itemId))
const comments = await db const rawComments = await db
.select() .select()
.from(wishlistComments) .from(wishlistComments)
.where(eq(wishlistComments.itemId, itemId)) .where(eq(wishlistComments.itemId, itemId))
.orderBy(asc(wishlistComments.createdAt)) .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 { return {
...item[0], ...item[0],
voteCount: votes.length, voteCount: votes.length,
hasVoted: votes.some((v) => v.userId === userId), hasVoted: votes.some((v) => v.userId === userId),
comments, comments: topLevel,
} }
} }

View File

@ -4,24 +4,26 @@ import { useState, useTransition, useEffect } from "react"
import { import {
IconThumbUp, IconThumbUp,
IconThumbUpFilled, IconThumbUpFilled,
IconThumbDown,
IconThumbDownFilled,
IconExternalLink, IconExternalLink,
IconSend, IconSend,
IconCornerDownRight,
IconTrash,
IconX,
} from "@tabler/icons-react" } from "@tabler/icons-react"
import { formatDistanceToNow } from "date-fns" import { formatDistanceToNow } from "date-fns"
import { toast } from "sonner" import { toast } from "sonner"
import { useIsMobile } from "@/hooks/use-mobile"
import { Avatar, AvatarFallback } from "@/components/ui/avatar" import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Drawer, Dialog,
DrawerClose, DialogContent,
DrawerContent, DialogHeader,
DrawerFooter, DialogTitle,
DrawerHeader, } from "@/components/ui/dialog"
DrawerTitle,
} from "@/components/ui/drawer"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
@ -29,9 +31,12 @@ import {
getItemWithComments, getItemWithComments,
toggleVote, toggleVote,
addComment, addComment,
deleteComment,
toggleCommentVote,
type WishlistItemWithMeta, type WishlistItemWithMeta,
type CommentWithMeta,
type VoteType,
} from "@/app/actions/wishlist" } from "@/app/actions/wishlist"
import type { WishlistComment } from "@/db/schema"
const priorityColors: Record<string, string> = { const priorityColors: Record<string, string> = {
critical: "bg-red-500/10 text-red-500 border-red-500/20", critical: "bg-red-500/10 text-red-500 border-red-500/20",
@ -40,6 +45,135 @@ const priorityColors: Record<string, string> = {
low: "bg-green-500/10 text-green-500 border-green-500/20", 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 (
<div className={isReply ? "ml-6 border-l-2 border-muted pl-4" : ""}>
<div className="flex gap-3">
<Avatar className="size-7">
<AvatarFallback className="text-xs">
{comment.userName
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2)}
</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{comment.userName}</span>
<span className="text-muted-foreground text-xs">
{formatDistanceToNow(new Date(comment.createdAt), {
addSuffix: true,
})}
</span>
</div>
<p className="text-sm">{comment.content}</p>
<div className="flex items-center gap-1 pt-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => onVote(comment.id, "up", comment.parentId)}
disabled={disabled}
>
{comment.userVote === "up" ? (
<IconThumbUpFilled className="size-3.5 text-green-500" />
) : (
<IconThumbUp className="size-3.5" />
)}
</Button>
<span
className={`min-w-[1.5rem] text-center text-xs font-medium ${
score > 0
? "text-green-500"
: score < 0
? "text-red-500"
: "text-muted-foreground"
}`}
>
{score}
</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => onVote(comment.id, "down", comment.parentId)}
disabled={disabled}
>
{comment.userVote === "down" ? (
<IconThumbDownFilled className="size-3.5 text-red-500" />
) : (
<IconThumbDown className="size-3.5" />
)}
</Button>
{!isReply && (
<Button
variant="ghost"
size="sm"
className="ml-2 h-6 gap-1 px-2 text-xs"
onClick={() => onReply(comment)}
disabled={disabled}
>
<IconCornerDownRight className="size-3" />
Reply
</Button>
)}
{isOwner && (
<Button
variant="ghost"
size="sm"
className="ml-auto h-6 w-6 p-0 text-destructive hover:text-destructive"
onClick={() => onDelete(comment.id, comment.parentId)}
disabled={disabled}
>
<IconTrash className="size-3.5" />
</Button>
)}
</div>
</div>
</div>
{comment.replies.length > 0 && (
<div className="mt-3 space-y-3">
{comment.replies.map((reply) => (
<CommentItem
key={reply.id}
comment={reply}
userId={userId}
isReply
onReply={onReply}
onDelete={onDelete}
onVote={onVote}
disabled={disabled}
/>
))}
</div>
)}
</div>
)
}
interface WishlistItemDetailProps { interface WishlistItemDetailProps {
item: WishlistItemWithMeta | null item: WishlistItemWithMeta | null
open: boolean open: boolean
@ -55,10 +189,10 @@ export function WishlistItemDetail({
userId, userId,
userName, userName,
}: WishlistItemDetailProps) { }: WishlistItemDetailProps) {
const isMobile = useIsMobile()
const [isPending, startTransition] = useTransition() const [isPending, startTransition] = useTransition()
const [comments, setComments] = useState<WishlistComment[]>([]) const [comments, setComments] = useState<CommentWithMeta[]>([])
const [newComment, setNewComment] = useState("") const [newComment, setNewComment] = useState("")
const [replyingTo, setReplyingTo] = useState<CommentWithMeta | null>(null)
const [voteState, setVoteState] = useState({ const [voteState, setVoteState] = useState({
hasVoted: item?.hasVoted ?? false, hasVoted: item?.hasVoted ?? false,
voteCount: item?.voteCount ?? 0, voteCount: item?.voteCount ?? 0,
@ -94,164 +228,236 @@ export function WishlistItemDetail({
} }
const handleAddComment = () => { const handleAddComment = () => {
if (!newComment.trim()) return if (!newComment.trim() || isPending) return
const parentId = replyingTo?.id
startTransition(async () => { 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) { if (result.success) {
setComments([ const newCommentData: CommentWithMeta = {
...comments, id: crypto.randomUUID(),
{ itemId: item.id,
id: crypto.randomUUID(), parentId: parentId ?? null,
itemId: item.id, userId,
userId, userName,
userName, content: newComment.trim(),
content: newComment.trim(), createdAt: new Date().toISOString(),
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("") setNewComment("")
toast.success("Comment added") setReplyingTo(null)
toast.success(parentId ? "Reply added" : "Comment added")
} else { } else {
toast.error(result.error || "Failed to add comment") toast.error(result.error || "Failed to add comment")
} }
}) })
} }
const Content = ( const handleDeleteComment = (commentId: string, parentId: string | null) => {
<> startTransition(async () => {
<div className="space-y-4 px-4"> const result = await deleteComment(commentId, userId)
<div className="flex flex-wrap items-center gap-2"> if (result.success) {
<Badge variant="secondary">{item.category}</Badge> if (parentId) {
<Badge variant="outline" className={priorityColors[item.priority]}> setComments(
{item.priority} comments.map((c) =>
</Badge> c.id === parentId
{item.estimatedCost && ( ? { ...c, replies: c.replies.filter((r) => r.id !== commentId) }
<Badge variant="outline">~${item.estimatedCost.toLocaleString()}</Badge> : c
)} )
</div> )
} else {
setComments(comments.filter((c) => c.id !== commentId))
}
toast.success("Comment deleted")
} else {
toast.error(result.error || "Failed to delete comment")
}
})
}
<p className="text-muted-foreground text-sm">{item.description}</p> 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 && ( if (parentId) {
<a setComments(
href={item.link} comments.map((c) =>
target="_blank" c.id === parentId
rel="noopener noreferrer" ? { ...c, replies: c.replies.map(updateComment) }
className="text-primary inline-flex items-center gap-1 text-sm hover:underline" : c
> )
<IconExternalLink className="size-3.5" /> )
View Link } else {
</a> setComments(comments.map(updateComment))
)} }
} else {
<div className="flex items-center justify-between"> toast.error(result.error || "Failed to vote")
<span className="text-muted-foreground text-xs"> }
Submitted by {item.submittedByName} &middot;{" "} })
{formatDistanceToNow(new Date(item.createdAt), { addSuffix: true })} }
</span>
<Button
variant={voteState.hasVoted ? "default" : "outline"}
size="sm"
className="gap-1"
onClick={handleVote}
disabled={isPending}
>
{voteState.hasVoted ? (
<IconThumbUpFilled className="size-4" />
) : (
<IconThumbUp className="size-4" />
)}
{voteState.voteCount}
</Button>
</div>
<Separator />
<div className="space-y-3">
<h4 className="text-sm font-medium">
Comments ({comments.length})
</h4>
<ScrollArea className="h-[200px]">
{comments.length === 0 ? (
<p className="text-muted-foreground py-4 text-center text-sm">
No comments yet. Be the first to comment!
</p>
) : (
<div className="space-y-3 pr-4">
{comments.map((comment) => (
<div key={comment.id} className="flex gap-3">
<Avatar className="size-8">
<AvatarFallback className="text-xs">
{comment.userName
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2)}
</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{comment.userName}
</span>
<span className="text-muted-foreground text-xs">
{formatDistanceToNow(new Date(comment.createdAt), {
addSuffix: true,
})}
</span>
</div>
<p className="text-sm">{comment.content}</p>
</div>
</div>
))}
</div>
)}
</ScrollArea>
</div>
</div>
</>
)
return ( return (
<Drawer <Dialog open={open} onOpenChange={onOpenChange}>
open={open} <DialogContent className="max-h-[85vh] overflow-hidden sm:max-w-lg">
onOpenChange={onOpenChange} <DialogHeader>
direction={isMobile ? "bottom" : "right"} <DialogTitle>{item.name}</DialogTitle>
> </DialogHeader>
<DrawerContent>
<DrawerHeader> <div className="space-y-4">
<DrawerTitle>{item.name}</DrawerTitle> <div className="flex flex-wrap items-center gap-2">
</DrawerHeader> <Badge variant="secondary">{item.category}</Badge>
{Content} <Badge variant="outline" className={priorityColors[item.priority]}>
<DrawerFooter> {item.priority}
<div className="flex gap-2"> </Badge>
<Textarea {item.estimatedCost && (
placeholder="Add a comment..." <Badge variant="outline">~${item.estimatedCost.toLocaleString()}</Badge>
value={newComment} )}
onChange={(e) => setNewComment(e.target.value)} </div>
className="min-h-[60px] flex-1"
onKeyDown={(e) => { <p className="text-muted-foreground text-sm">{item.description}</p>
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault() {item.link && (
handleAddComment() <a
} href={item.link}
}} target="_blank"
/> rel="noopener noreferrer"
<Button className="text-primary inline-flex items-center gap-1 text-sm hover:underline"
size="icon"
onClick={handleAddComment}
disabled={!newComment.trim() || isPending}
> >
<IconSend className="size-4" /> <IconExternalLink className="size-3.5" />
View Link
</a>
)}
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
Submitted by {item.submittedByName} &middot;{" "}
{formatDistanceToNow(new Date(item.createdAt), { addSuffix: true })}
</span>
<Button
variant={voteState.hasVoted ? "default" : "outline"}
size="sm"
className="gap-1"
onClick={handleVote}
disabled={isPending}
>
{voteState.hasVoted ? (
<IconThumbUpFilled className="size-4" />
) : (
<IconThumbUp className="size-4" />
)}
{voteState.voteCount}
</Button> </Button>
</div> </div>
<DrawerClose asChild>
<Button variant="outline">Close</Button> <Separator />
</DrawerClose>
</DrawerFooter> <div className="space-y-3">
</DrawerContent> <h4 className="text-sm font-medium">
</Drawer> Comments ({comments.length + comments.reduce((acc, c) => acc + c.replies.length, 0)})
</h4>
<ScrollArea className="h-[200px]">
{comments.length === 0 ? (
<p className="text-muted-foreground py-4 text-center text-sm">
No comments yet. Be the first to comment!
</p>
) : (
<div className="space-y-4 pr-4">
{comments.map((comment) => (
<CommentItem
key={comment.id}
comment={comment}
userId={userId}
onReply={setReplyingTo}
onDelete={handleDeleteComment}
onVote={handleCommentVote}
disabled={isPending}
/>
))}
</div>
)}
</ScrollArea>
</div>
<div className="space-y-2">
{replyingTo && (
<div className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2 text-sm">
<IconCornerDownRight className="size-4 text-muted-foreground" />
<span className="text-muted-foreground">
Replying to{" "}
<span className="font-medium text-foreground">
{replyingTo.userName}
</span>
</span>
<Button
variant="ghost"
size="sm"
className="ml-auto h-6 w-6 p-0"
onClick={() => setReplyingTo(null)}
>
<IconX className="size-4" />
</Button>
</div>
)}
<div className="flex gap-2">
<Textarea
placeholder={replyingTo ? "Write a reply..." : "Add a comment..."}
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
className="min-h-[60px] flex-1"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleAddComment()
}
}}
/>
<Button
size="icon"
onClick={handleAddComment}
disabled={!newComment.trim() || isPending}
className="self-end"
>
<IconSend className="size-4" />
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
) )
} }

View File

@ -27,12 +27,24 @@ export const wishlistComments = sqliteTable("wishlist_comments", {
itemId: text("item_id") itemId: text("item_id")
.notNull() .notNull()
.references(() => wishlistItems.id, { onDelete: "cascade" }), .references(() => wishlistItems.id, { onDelete: "cascade" }),
parentId: text("parent_id"),
userId: text("user_id").notNull(), userId: text("user_id").notNull(),
userName: text("user_name").notNull(), userName: text("user_name").notNull(),
content: text("content").notNull(), content: text("content").notNull(),
createdAt: text("created_at").notNull(), createdAt: text("created_at").notNull(),
}) })
export const wishlistCommentVotes = sqliteTable("wishlist_comment_votes", {
id: text("id").primaryKey(),
commentId: text("comment_id")
.notNull()
.references(() => wishlistComments.id, { onDelete: "cascade" }),
userId: text("user_id").notNull(),
voteType: text("vote_type").notNull(), // "up" | "down"
createdAt: text("created_at").notNull(),
})
export type WishlistItem = typeof wishlistItems.$inferSelect export type WishlistItem = typeof wishlistItems.$inferSelect
export type WishlistVote = typeof wishlistVotes.$inferSelect export type WishlistVote = typeof wishlistVotes.$inferSelect
export type WishlistComment = typeof wishlistComments.$inferSelect export type WishlistComment = typeof wishlistComments.$inferSelect
export type WishlistCommentVote = typeof wishlistCommentVotes.$inferSelect