From 9013429f01b017d69ffe2b2ec114e47de4c5ec78 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 30 Aug 2025 17:58:35 +0000 Subject: [PATCH] Add ratings, comments, and discussion features to rules Co-authored-by: raavtube --- app/api/comments/route.ts | 173 + app/api/feed/stats/route.ts | 78 + app/api/ratings/route.ts | 132 + app/api/reports/route.ts | 60 + app/feed/page.tsx | 35 +- app/rule/[slug]/page.tsx | 4 + components/discussion/comment-form.tsx | 121 + components/discussion/comment-item.tsx | 249 + components/discussion/discussion-section.tsx | 300 + components/rating/star-rating.tsx | 125 + components/ui/avatar.tsx | 49 + components/ui/dropdown-menu.tsx | 199 + drizzle/0005_mighty_valeria_richards.sql | 24 + drizzle/0006_clumsy_phantom_reporter.sql | 14 + drizzle/meta/0005_snapshot.json | 695 ++ drizzle/meta/0006_snapshot.json | 797 ++ drizzle/meta/_journal.json | 14 + lib/schema.ts | 30 + package-lock.json | 7669 ++++++++++++++++++ 19 files changed, 10766 insertions(+), 2 deletions(-) create mode 100644 app/api/comments/route.ts create mode 100644 app/api/feed/stats/route.ts create mode 100644 app/api/ratings/route.ts create mode 100644 app/api/reports/route.ts create mode 100644 components/discussion/comment-form.tsx create mode 100644 components/discussion/comment-item.tsx create mode 100644 components/discussion/discussion-section.tsx create mode 100644 components/rating/star-rating.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/dropdown-menu.tsx create mode 100644 drizzle/0005_mighty_valeria_richards.sql create mode 100644 drizzle/0006_clumsy_phantom_reporter.sql create mode 100644 drizzle/meta/0005_snapshot.json create mode 100644 drizzle/meta/0006_snapshot.json create mode 100644 package-lock.json diff --git a/app/api/comments/route.ts b/app/api/comments/route.ts new file mode 100644 index 0000000..1ae1d40 --- /dev/null +++ b/app/api/comments/route.ts @@ -0,0 +1,173 @@ +import { NextRequest, NextResponse } from "next/server" +import { auth } from "@/lib/auth" +import { db } from "@/lib/db" +import { comment, user } from "@/lib/schema" +import { nanoid } from "nanoid" +import { eq, and, desc, isNull } from "drizzle-orm" +import { track } from "@vercel/analytics/server" + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const ruleId = searchParams.get("ruleId") + + if (!ruleId) { + return NextResponse.json({ error: "Rule ID is required" }, { status: 400 }) + } + + // Get all comments for the rule with user information + const comments = await db + .select({ + id: comment.id, + content: comment.content, + parentId: comment.parentId, + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + user: { + id: user.id, + name: user.name, + image: user.image, + }, + }) + .from(comment) + .leftJoin(user, eq(comment.userId, user.id)) + .where(eq(comment.ruleId, ruleId)) + .orderBy(desc(comment.createdAt)) + + // Organize comments into threads (parent comments with their replies) + const parentComments = comments.filter(c => !c.parentId) + const replies = comments.filter(c => c.parentId) + + const threaded = parentComments.map(parent => ({ + ...parent, + replies: replies.filter(reply => reply.parentId === parent.id) + .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) + })) + + return NextResponse.json(threaded) + } catch (error) { + console.error("Error fetching comments:", error) + return NextResponse.json({ error: "Internal server error" }, { status: 500 }) + } +} + +export async function POST(request: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: request.headers + }) + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const body = await request.json() + const { ruleId, content, parentId } = body + + if (!ruleId || !content?.trim()) { + return NextResponse.json({ + error: "Rule ID and content are required" + }, { status: 400 }) + } + + // Validate parentId if provided (ensure it exists and belongs to the same rule) + if (parentId) { + const parentComment = await db + .select() + .from(comment) + .where(and( + eq(comment.id, parentId), + eq(comment.ruleId, ruleId) + )) + .limit(1) + + if (parentComment.length === 0) { + return NextResponse.json({ + error: "Parent comment not found" + }, { status: 400 }) + } + } + + const id = nanoid() + const now = new Date() + + const [newComment] = await db.insert(comment).values({ + id, + userId: session.user.id, + ruleId, + parentId: parentId || null, + content: content.trim(), + createdAt: now, + updatedAt: now, + }).returning() + + // Get the comment with user info for response + const commentWithUser = await db + .select({ + id: comment.id, + content: comment.content, + parentId: comment.parentId, + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + user: { + id: user.id, + name: user.name, + image: user.image, + }, + }) + .from(comment) + .leftJoin(user, eq(comment.userId, user.id)) + .where(eq(comment.id, id)) + .limit(1) + + await track('Comment Posted', { + ruleId, + commentId: id, + isReply: Boolean(parentId) + }) + + return NextResponse.json(commentWithUser[0]) + } catch (error) { + console.error("Error creating comment:", error) + return NextResponse.json({ error: "Internal server error" }, { status: 500 }) + } +} + +export async function DELETE(request: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: request.headers + }) + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { commentId } = await request.json() + + if (!commentId) { + return NextResponse.json({ error: "Comment ID is required" }, { status: 400 }) + } + + // Delete the comment (only if it belongs to the authenticated user) + const deletedComment = await db + .delete(comment) + .where(and( + eq(comment.id, commentId), + eq(comment.userId, session.user.id) + )) + .returning() + + if (deletedComment.length === 0) { + return NextResponse.json({ + error: "Comment not found or unauthorized" + }, { status: 404 }) + } + + await track('Comment Deleted', { commentId }) + return NextResponse.json({ message: "Comment deleted successfully" }) + } catch (error) { + console.error("Error deleting comment:", error) + return NextResponse.json({ error: "Internal server error" }, { status: 500 }) + } +} \ No newline at end of file diff --git a/app/api/feed/stats/route.ts b/app/api/feed/stats/route.ts new file mode 100644 index 0000000..4ded31a --- /dev/null +++ b/app/api/feed/stats/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from "next/server" +import { db } from "@/lib/db" +import { rating, comment } from "@/lib/schema" +import { eq, avg, count, sql } from "drizzle-orm" + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const ruleIds = searchParams.get("ruleIds")?.split(",") || [] + + if (ruleIds.length === 0) { + return NextResponse.json({}) + } + + // Get rating stats for all rules + const ratingStats = await db + .select({ + ruleId: rating.ruleId, + averageRating: avg(rating.rating), + totalRatings: count(rating.id), + }) + .from(rating) + .where(sql`${rating.ruleId} = ANY(${ruleIds})`) + .groupBy(rating.ruleId) + + // Get comment counts for all rules + const commentStats = await db + .select({ + ruleId: comment.ruleId, + totalComments: count(comment.id), + }) + .from(comment) + .where(sql`${comment.ruleId} = ANY(${ruleIds})`) + .groupBy(comment.ruleId) + + // Combine the stats + const stats: Record = {} + + // Initialize all rule IDs with zero stats + ruleIds.forEach(ruleId => { + stats[ruleId] = { + averageRating: 0, + totalRatings: 0, + totalComments: 0 + } + }) + + // Add rating stats + ratingStats.forEach(stat => { + if (stat.ruleId) { + stats[stat.ruleId] = { + ...stats[stat.ruleId], + averageRating: Number(stat.averageRating) || 0, + totalRatings: stat.totalRatings || 0, + } + } + }) + + // Add comment stats + commentStats.forEach(stat => { + if (stat.ruleId) { + stats[stat.ruleId] = { + ...stats[stat.ruleId], + totalComments: stat.totalComments || 0, + } + } + }) + + return NextResponse.json(stats) + } catch (error) { + console.error("Error fetching feed stats:", error) + return NextResponse.json({ error: "Internal server error" }, { status: 500 }) + } +} \ No newline at end of file diff --git a/app/api/ratings/route.ts b/app/api/ratings/route.ts new file mode 100644 index 0000000..7332de5 --- /dev/null +++ b/app/api/ratings/route.ts @@ -0,0 +1,132 @@ +import { NextRequest, NextResponse } from "next/server" +import { auth } from "@/lib/auth" +import { db } from "@/lib/db" +import { rating, user } from "@/lib/schema" +import { nanoid } from "nanoid" +import { eq, and, avg, count } from "drizzle-orm" +import { track } from "@vercel/analytics/server" + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const ruleId = searchParams.get("ruleId") + + if (!ruleId) { + return NextResponse.json({ error: "Rule ID is required" }, { status: 400 }) + } + + // Get average rating and count + const ratingStats = await db + .select({ + averageRating: avg(rating.rating), + totalRatings: count(rating.id), + }) + .from(rating) + .where(eq(rating.ruleId, ruleId)) + + // Get user's rating if authenticated + let userRating = null + try { + const session = await auth.api.getSession({ + headers: request.headers + }) + + if (session) { + const userRatingResult = await db + .select({ + rating: rating.rating, + }) + .from(rating) + .where(and( + eq(rating.ruleId, ruleId), + eq(rating.userId, session.user.id) + )) + .limit(1) + + userRating = userRatingResult[0]?.rating || null + } + } catch (error) { + // Continue without user rating if auth fails + } + + return NextResponse.json({ + averageRating: ratingStats[0]?.averageRating ? Number(ratingStats[0].averageRating) : 0, + totalRatings: ratingStats[0]?.totalRatings || 0, + userRating, + }) + } catch (error) { + console.error("Error fetching ratings:", error) + return NextResponse.json({ error: "Internal server error" }, { status: 500 }) + } +} + +export async function POST(request: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: request.headers + }) + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const body = await request.json() + const { ruleId, rating: ratingValue } = body + + if (!ruleId || !ratingValue || ratingValue < 1 || ratingValue > 5) { + return NextResponse.json({ + error: "Rule ID and rating (1-5) are required" + }, { status: 400 }) + } + + const id = nanoid() + const now = new Date() + + // Check if user already rated this rule + const existingRating = await db + .select() + .from(rating) + .where(and( + eq(rating.ruleId, ruleId), + eq(rating.userId, session.user.id) + )) + .limit(1) + + let result + if (existingRating.length > 0) { + // Update existing rating + [result] = await db + .update(rating) + .set({ + rating: ratingValue, + updatedAt: now, + }) + .where(and( + eq(rating.ruleId, ruleId), + eq(rating.userId, session.user.id) + )) + .returning() + } else { + // Create new rating + [result] = await db.insert(rating).values({ + id, + userId: session.user.id, + ruleId, + rating: ratingValue, + createdAt: now, + updatedAt: now, + }).returning() + } + + await track('Rating Submitted', { + ruleId, + rating: ratingValue, + isUpdate: existingRating.length > 0 + }) + + return NextResponse.json(result) + } catch (error) { + console.error("Error creating/updating rating:", error) + return NextResponse.json({ error: "Internal server error" }, { status: 500 }) + } +} \ No newline at end of file diff --git a/app/api/reports/route.ts b/app/api/reports/route.ts new file mode 100644 index 0000000..6cbe0c6 --- /dev/null +++ b/app/api/reports/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from "next/server" +import { auth } from "@/lib/auth" +import { db } from "@/lib/db" +import { report } from "@/lib/schema" +import { nanoid } from "nanoid" +import { track } from "@vercel/analytics/server" + +export async function POST(request: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: request.headers + }) + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const body = await request.json() + const { commentId, ruleId, reason, description } = body + + if (!reason || (!commentId && !ruleId)) { + return NextResponse.json({ + error: "Reason and either commentId or ruleId are required" + }, { status: 400 }) + } + + const validReasons = ['spam', 'inappropriate', 'harassment', 'misinformation', 'other'] + if (!validReasons.includes(reason)) { + return NextResponse.json({ + error: "Invalid reason" + }, { status: 400 }) + } + + const id = nanoid() + const now = new Date() + + const [newReport] = await db.insert(report).values({ + id, + reporterId: session.user.id, + commentId: commentId || null, + ruleId: ruleId || null, + reason, + description: description || null, + status: "pending", + createdAt: now, + }).returning() + + await track('Content Reported', { + reportId: id, + reason, + hasComment: Boolean(commentId), + hasRule: Boolean(ruleId) + }) + + return NextResponse.json(newReport) + } catch (error) { + console.error("Error creating report:", error) + return NextResponse.json({ error: "Internal server error" }, { status: 500 }) + } +} \ No newline at end of file diff --git a/app/feed/page.tsx b/app/feed/page.tsx index 790f1d8..365103d 100644 --- a/app/feed/page.tsx +++ b/app/feed/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react" import { Header } from "@/components/header" import { Card } from "@/components/ui/card" -import { Eye, Clock } from "lucide-react" +import { Eye, Clock, Star, MessageSquare } from "lucide-react" import { toast } from "sonner" import { track } from "@vercel/analytics" import { AddToListButton } from "@/components/lists/add-to-list-button" @@ -114,6 +114,11 @@ export default function FeedPage() { const [newRules, setNewRules] = useState([]) const [loading, setLoading] = useState(true) const [actionStates, setActionStates] = useState<{[key: string]: boolean}>({}) + const [ruleStats, setRuleStats] = useState>({}) // Helper function to show checkmark feedback const showActionFeedback = (actionKey: string) => { @@ -142,14 +147,27 @@ export default function FeedPage() { fetch('/api/feed/new') ]) + let allRules: CursorRule[] = [] if (hotResponse.ok) { const hotData = await hotResponse.json() setHotRules(hotData) + allRules = [...allRules, ...hotData] } if (newResponse.ok) { const newData = await newResponse.json() setNewRules(newData) + allRules = [...allRules, ...newData] + } + + // Fetch stats for all rules + if (allRules.length > 0) { + const ruleIds = Array.from(new Set(allRules.map(rule => rule.id))) + const statsResponse = await fetch(`/api/feed/stats?ruleIds=${ruleIds.join(',')}`) + if (statsResponse.ok) { + const statsData = await statsResponse.json() + setRuleStats(statsData) + } } } catch (error) { console.error('Error fetching feed rules:', error) @@ -288,13 +306,26 @@ export default function FeedPage() {

{firstLine(rule.content)}

-
+
by {rule.user?.name || 'Anonymous'} {formatRelativeTime(rule.createdAt)}
{rule.views.toLocaleString()} views
+ {ruleStats[rule.id]?.totalRatings > 0 && ( +
+ + {ruleStats[rule.id].averageRating.toFixed(1)} + ({ruleStats[rule.id].totalRatings}) +
+ )} + {ruleStats[rule.id]?.totalComments > 0 && ( +
+ + {ruleStats[rule.id].totalComments} +
+ )} {rule.ruleType}
diff --git a/app/rule/[slug]/page.tsx b/app/rule/[slug]/page.tsx index 1ae2839..c1d7582 100644 --- a/app/rule/[slug]/page.tsx +++ b/app/rule/[slug]/page.tsx @@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button" import { track } from "@vercel/analytics" import { AddToListButton } from "@/components/lists/add-to-list-button" import { useSession } from "@/lib/auth-client" +import { DiscussionSection } from "@/components/discussion/discussion-section" interface CursorRule { id: string @@ -303,6 +304,9 @@ export default function PublicRulePage() { {rule.content.length} chars • {tokenCount.toLocaleString()} tokens
+ + {/* Discussion and Rating Section */} + diff --git a/components/discussion/comment-form.tsx b/components/discussion/comment-form.tsx new file mode 100644 index 0000000..0331e56 --- /dev/null +++ b/components/discussion/comment-form.tsx @@ -0,0 +1,121 @@ +"use client" + +import { useState } from "react" +import { useSession } from "@/lib/auth-client" +import { Button } from "@/components/ui/button" +import { Textarea } from "@/components/ui/textarea" +import { toast } from "sonner" +import { track } from "@vercel/analytics" + +interface CommentFormProps { + ruleId: string + parentId?: string + placeholder?: string + onCommentAdded?: (comment: any) => void + onCancel?: () => void + compact?: boolean +} + +export function CommentForm({ + ruleId, + parentId, + placeholder = "Share your thoughts...", + onCommentAdded, + onCancel, + compact = false +}: CommentFormProps) { + const { data: session } = useSession() + const [content, setContent] = useState("") + const [isSubmitting, setIsSubmitting] = useState(false) + + if (!session) { + return ( +
+ Please sign in to join the discussion +
+ ) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!content.trim()) { + toast.error("Please enter a comment") + return + } + + if (isSubmitting) return + + setIsSubmitting(true) + try { + const response = await fetch('/api/comments', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + ruleId, + content: content.trim(), + parentId, + }), + }) + + if (!response.ok) { + throw new Error('Failed to post comment') + } + + const newComment = await response.json() + setContent("") + onCommentAdded?.(newComment) + track("Comment Posted", { ruleId, isReply: Boolean(parentId) }) + toast.success(parentId ? "Reply posted!" : "Comment posted!") + } catch (error) { + console.error('Error posting comment:', error) + toast.error("Failed to post comment. Please try again.") + } finally { + setIsSubmitting(false) + } + } + + return ( +
+