mirror of
https://github.com/NicholaiVogel/dashore-incubator.git
synced 2026-03-30 22:38:56 +00:00
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)
This commit is contained in:
parent
b3d626beb0
commit
ecbc9c627d
10
drizzle/0001_modern_mad_thinker.sql
Normal file
10
drizzle/0001_modern_mad_thinker.sql
Normal 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;
|
||||
282
drizzle/meta/0001_snapshot.json
Normal file
282
drizzle/meta/0001_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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<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) {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<string, string> = {
|
||||
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",
|
||||
}
|
||||
|
||||
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 {
|
||||
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<WishlistComment[]>([])
|
||||
const [comments, setComments] = useState<CommentWithMeta[]>([])
|
||||
const [newComment, setNewComment] = useState("")
|
||||
const [replyingTo, setReplyingTo] = useState<CommentWithMeta | null>(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 = (
|
||||
<>
|
||||
<div className="space-y-4 px-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">{item.category}</Badge>
|
||||
<Badge variant="outline" className={priorityColors[item.priority]}>
|
||||
{item.priority}
|
||||
</Badge>
|
||||
{item.estimatedCost && (
|
||||
<Badge variant="outline">~${item.estimatedCost.toLocaleString()}</Badge>
|
||||
)}
|
||||
</div>
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
<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 && (
|
||||
<a
|
||||
href={item.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary inline-flex items-center gap-1 text-sm hover:underline"
|
||||
>
|
||||
<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} ·{" "}
|
||||
{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>
|
||||
</>
|
||||
)
|
||||
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 (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
direction={isMobile ? "bottom" : "right"}
|
||||
>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{item.name}</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
{Content}
|
||||
<DrawerFooter>
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
placeholder="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}
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[85vh] overflow-hidden sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{item.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">{item.category}</Badge>
|
||||
<Badge variant="outline" className={priorityColors[item.priority]}>
|
||||
{item.priority}
|
||||
</Badge>
|
||||
{item.estimatedCost && (
|
||||
<Badge variant="outline">~${item.estimatedCost.toLocaleString()}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-sm">{item.description}</p>
|
||||
|
||||
{item.link && (
|
||||
<a
|
||||
href={item.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary inline-flex items-center gap-1 text-sm hover:underline"
|
||||
>
|
||||
<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} ·{" "}
|
||||
{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>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -27,12 +27,24 @@ export const wishlistComments = sqliteTable("wishlist_comments", {
|
||||
itemId: text("item_id")
|
||||
.notNull()
|
||||
.references(() => wishlistItems.id, { onDelete: "cascade" }),
|
||||
parentId: text("parent_id"),
|
||||
userId: text("user_id").notNull(),
|
||||
userName: text("user_name").notNull(),
|
||||
content: text("content").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 WishlistVote = typeof wishlistVotes.$inferSelect
|
||||
export type WishlistComment = typeof wishlistComments.$inferSelect
|
||||
export type WishlistCommentVote = typeof wishlistCommentVotes.$inferSelect
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user