diff --git a/convex/schema.ts b/convex/schema.ts index 7ceb9b3..cb5ca2d 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -36,8 +36,8 @@ export default defineSchema({ content: v.string(), }).index("by_snippet_id", ["snippetId"]), - stars: defineTable({ - userId: v.id("users"), + stars: defineTable({ + userId: v.string(), snippetId: v.id("snippets"), }) .index("by_user_id", ["userId"]) diff --git a/convex/snippets.ts b/convex/snippets.ts index e33112c..b48f588 100644 --- a/convex/snippets.ts +++ b/convex/snippets.ts @@ -1,5 +1,5 @@ import { v } from "convex/values"; -import { mutation } from "./_generated/server"; +import { mutation, query } from "./_generated/server"; export const createSnippet = mutation({ args: { @@ -30,3 +30,112 @@ export const createSnippet = mutation({ return snippetId; }, }); + +export const getSnippets = query({ + handler: async (ctx) => { + const snippets = await ctx.db.query("snippets").order("desc").collect(); + return snippets; + } +}) + +export const isSnippetStarred = query({ + args: { + snippetId: v.id("snippets") + }, + handler: async (ctx, args) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) return false; + + const star = await ctx.db + .query("stars") + .withIndex("by_user_id_and_snippet_id") + .filter((q)=> q.eq(q.field("userId"),identity.subject) && q.eq(q.field("snippetId"),args.snippetId)) + .first(); + + return !!star; + } +}) + +export const getSnippetStarCount = query({ + args: {snippetId: v.id("snippets")}, + handler: async (ctx, args) => { + const stars = await ctx.db + .query("stars") + .withIndex("by_snippet_id") + .filter((q)=>q.eq(q.field("snippetId"),args.snippetId)) + .collect(); + + return stars.length; + + } +}) + + + +export const deleteSnippet = mutation({ + args: { + snippetId: v.id("snippets"), + }, + + handler: async (ctx, args) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) throw new Error("Not authenticated"); + + const snippet = await ctx.db.get(args.snippetId); + if (!snippet) throw new Error("Snippet not found"); + + if (snippet.userId !== identity.subject) { + throw new Error("Not authorized to delete this snippet"); + } + + const comments = await ctx.db + .query("snippetComments") + .withIndex("by_snippet_id") + .filter((q) => q.eq(q.field("snippetId"), args.snippetId)) + .collect(); + + for (const comment of comments) { + await ctx.db.delete(comment._id); + } + + const stars = await ctx.db + .query("stars") + .withIndex("by_snippet_id") + .filter((q) => q.eq(q.field("snippetId"), args.snippetId)) + .collect(); + + for (const star of stars) { + await ctx.db.delete(star._id); + } + + await ctx.db.delete(args.snippetId); + }, +}); + +export const starSnippet = mutation({ + args: { + snippetId: v.id("snippets"), + }, + handler: async (ctx, args) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) throw new Error("Not authenticated"); + + const existing = await ctx.db + .query("stars") + .withIndex("by_user_id_and_snippet_id") + .filter( + (q) => + q.eq(q.field("userId"), identity.subject) && q.eq(q.field("snippetId"), args.snippetId) + ) + .first(); + + if (existing) { + await ctx.db.delete(existing._id); + } else { + await ctx.db.insert("stars", { + userId: identity.subject, + snippetId: args.snippetId, + }); + } + }, +}); \ No newline at end of file diff --git a/src/app/snippets/_components/SnippetCard.tsx b/src/app/snippets/_components/SnippetCard.tsx new file mode 100644 index 0000000..a8f52c0 --- /dev/null +++ b/src/app/snippets/_components/SnippetCard.tsx @@ -0,0 +1,139 @@ +"use client"; +import { Snippet } from "@/types"; +import { useUser } from "@clerk/nextjs"; +import { useMutation } from "convex/react"; +import { api } from "../../../../convex/_generated/api"; +import { useState } from "react"; + +import { motion } from "framer-motion"; +import Link from "next/link"; +import { Clock, Trash2, User } from "lucide-react"; +import Image from "next/image"; +import toast from "react-hot-toast"; +import StarButton from "@/components/ui/StarButton"; + + +function SnippetCard({ snippet }: { snippet: Snippet }) { + const { user } = useUser(); + const deleteSnippet = useMutation(api.snippets.deleteSnippet); + const [isDeleting, setIsDeleting] = useState(false); + + const handleDelete = async () => { + setIsDeleting(true); + + try { + await deleteSnippet({ snippetId: snippet._id }); + } catch (error) { + console.log("Error deleting snippet:", error); + toast.error("Error deleting snippet"); + } finally { + setIsDeleting(false); + } + }; + + return ( + + +
+
+ {/* Header */} +
+
+
+
+
+ {`${snippet.language} +
+
+
+ + {snippet.language} + +
+ + {new Date(snippet._creationTime).toLocaleDateString()} +
+
+
+
e.preventDefault()} + > + + + {user?.id === snippet.userId && ( +
e.preventDefault()}> + +
+ )} +
+
+ + {/* Content */} +
+
+

+ {snippet.title} +

+
+
+
+ +
+ {snippet.userName} +
+
+
+ +
+
+
+                  {snippet.code}
+                
+
+
+
+
+ + + ); +} +export default SnippetCard; \ No newline at end of file diff --git a/src/app/snippets/_components/SnippetPageSkeleton.tsx b/src/app/snippets/_components/SnippetPageSkeleton.tsx new file mode 100644 index 0000000..9f1b15f --- /dev/null +++ b/src/app/snippets/_components/SnippetPageSkeleton.tsx @@ -0,0 +1,83 @@ +const CardSkeleton = () => ( +
+
+
+ {/* Header shimmer */} +
+
+
+
+
+
+
+
+
+
+ + {/* Title shimmer */} +
+
+
+
+ + {/* Code block shimmer */} +
+
+
+
+
+
+
+
+ ); + + export default function SnippetsPageSkeleton() { + return ( +
+ {/* Ambient background with loading pulse */} +
+
+
+
+ + {/* Hero Section Skeleton */} +
+
+
+
+
+
+ + {/* Search and Filters Skeleton */} +
+ {/* Search bar */} +
+
+
+ + {/* Language filters */} +
+ {[...Array(6)].map((_, i) => ( +
+ ))} +
+
+ + {/* Grid Skeleton */} +
+ {[...Array(6)].map((_, i) => ( +
+ +
+ ))} +
+
+
+ ); + } \ No newline at end of file diff --git a/src/app/snippets/page.tsx b/src/app/snippets/page.tsx new file mode 100644 index 0000000..5f237c3 --- /dev/null +++ b/src/app/snippets/page.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { useQuery } from "convex/react"; +import React from "react"; +import { api } from "../../../convex/_generated/api"; +import SnippetsPageSkeleton from "./_components/SnippetPageSkeleton"; +import NavigationHeader from "@/components/ui/NavigationHeader"; +import { AnimatePresence, motion } from "framer-motion"; +import { BookOpen, Code, Grid, Layers, Search, Tag, X } from "lucide-react"; +import SnippetCard from "./_components/SnippetCard"; + +function SnippetsPage() { + const snippets = useQuery(api.snippets.getSnippets); + const [searchQuery, setSearchQuery] = React.useState(""); + const [selectedLanguage, setSelectedLanguage] = React.useState( + null + ); + const [view, setView] = React.useState<"grid" | "list">("grid"); + + if (snippets === undefined) { + return ( +
+ + +
+ ); + } + + const languages = [...new Set(snippets.map((s) => s.language))]; + const popularLanguages = languages.slice(0, 5); + + const filteredSnippets = snippets.filter((snippet) => { + const matchesSearch = + snippet.title.toLowerCase().includes(searchQuery.toLowerCase()) || + snippet.language.toLowerCase().includes(searchQuery.toLowerCase()) || + snippet.userName.toLowerCase().includes(searchQuery.toLowerCase()); + + const matchesLanguage = + !selectedLanguage || snippet.language === selectedLanguage; + + return matchesSearch && matchesLanguage; + }); + + return ( +
+ +
+
+ + + Community Code Library + + + Discover & Share Code Snippets + + + Explore a curated collection of code snippets from the community + +
+ + +
+ {/* Search */} +
+
+
+ + setSearchQuery(e.target.value)} + placeholder="Search snippets by title, language, or author..." + className="w-full pl-12 pr-4 py-4 bg-[#1e1e2e]/80 hover:bg-[#1e1e2e] text-white + rounded-xl border border-[#313244] hover:border-[#414155] transition-all duration-200 + placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50" + /> +
+
+ + {/* Filters Bar */} +
+
+ + Languages: +
+ + {popularLanguages.map((lang) => ( + + ))} + + {selectedLanguage && ( + + )} + +
+ + {filteredSnippets.length} snippets found + + + {/* View Toggle */} +
+ + +
+
+
+
+ + + + {filteredSnippets.map((snippet) => ( + + ))} + + + + {/* edge case: empty state */} + {filteredSnippets.length === 0 && ( + +
+
+ +
+

No snippets found

+

+ {searchQuery || selectedLanguage + ? "Try adjusting your search query or filters" + : "Be the first to share a code snippet with the community"} +

+ + {(searchQuery || selectedLanguage) && ( + + )} +
+
+ )} +
+
+ ); +} + +export default SnippetsPage; diff --git a/src/components/ui/NavigationHeader.tsx b/src/components/ui/NavigationHeader.tsx new file mode 100644 index 0000000..b970c50 --- /dev/null +++ b/src/components/ui/NavigationHeader.tsx @@ -0,0 +1,82 @@ +import HeaderProfileBtn from "@/app/(home)/_components/HeaderProfileBtn"; +import { SignedOut } from "@clerk/nextjs"; +import { Blocks, Code2, Sparkles } from "lucide-react"; +import Link from "next/link"; + +function NavigationHeader() { + return ( +
+
+
+
+
+ {/* Logo */} + + {/* logo hover effect */} +
+ + {/* Logo */} +
+ +
+ +
+ + CodeCraft + + + Interactive Code Editor + +
+ + + {/* snippets Link */} + +
+ + + Snippets + + +
+ + {/* right rection */} +
+ + + + + Pro + + + + + {/* profile button */} + +
+
+
+
+ ); +} + +export default NavigationHeader; \ No newline at end of file diff --git a/src/components/ui/StarButton.tsx b/src/components/ui/StarButton.tsx new file mode 100644 index 0000000..f50c2a6 --- /dev/null +++ b/src/components/ui/StarButton.tsx @@ -0,0 +1,39 @@ +import { useAuth } from "@clerk/nextjs"; +import { useMutation, useQuery } from "convex/react"; +import { Star } from "lucide-react"; +import { api } from "../../../convex/_generated/api"; +import { Id } from "../../../convex/_generated/dataModel"; + +function StarButton({ snippetId }: { snippetId: Id<"snippets"> }) { + const { isSignedIn } = useAuth(); + + const isStarred = useQuery(api.snippets.isSnippetStarred, { snippetId }); + const starCount = useQuery(api.snippets.getSnippetStarCount, { snippetId }); + const star = useMutation(api.snippets.starSnippet); + + const handleStar = async () => { + if (!isSignedIn) return; + await star({ snippetId }); + }; + + return ( + + ); +} + +export default StarButton; \ No newline at end of file