mirror of
https://github.com/NicholaiVogel/dashore-incubator.git
synced 2026-03-30 22:38:56 +00:00
Merge pull request #2 from NicholaiVogel/feat/comment-enhancements
feat(wishlist): add comment delete, voting, and replies
This commit is contained in:
commit
6740e7e03c
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,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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} ·{" "}
|
})
|
||||||
{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} ·{" "}
|
||||||
|
{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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user