Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
111 changes: 110 additions & 1 deletion convex/snippets.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down Expand Up @@ -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,
});
}
},
});
139 changes: 139 additions & 0 deletions src/app/snippets/_components/SnippetCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<motion.div
layout
className="group relative"
whileHover={{ y: -2 }}
transition={{ duration: 0.2 }}
>
<Link href={`/snippets/${snippet._id}`} className="h-full block">
<div
className="relative h-full bg-[#1e1e2e]/80 backdrop-blur-sm rounded-xl
border border-[#313244]/50 hover:border-[#313244]
transition-all duration-300 overflow-hidden"
>
<div className="p-6">
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="relative">
<div
className="absolute inset-0 bg-gradient-to-r from-blue-500 to-purple-500 rounded-lg blur opacity-20
group-hover:opacity-30 transition-all duration-500"
area-hidden="true"
/>
<div
className="relative p-2 rounded-lg bg-gradient-to-br from-blue-500/10 to-purple-500/10 group-hover:from-blue-500/20
group-hover:to-purple-500/20 transition-all duration-500"
>
<Image
src={`/${snippet.language}.png`}
alt={`${snippet.language} logo`}
className="w-6 h-6 object-contain relative z-10"
width={24}
height={24}
/>
</div>
</div>
<div className="space-y-1">
<span className="px-3 py-1 bg-blue-500/10 text-blue-400 rounded-lg text-xs font-medium">
{snippet.language}
</span>
<div className="flex items-center gap-2 text-xs text-gray-500">
<Clock className="size-3" />
{new Date(snippet._creationTime).toLocaleDateString()}
</div>
</div>
</div>
<div
className="absolute top-5 right-5 z-10 flex gap-4 items-center"
onClick={(e) => e.preventDefault()}
>
<StarButton snippetId={snippet._id} />

{user?.id === snippet.userId && (
<div className="z-10" onClick={(e) => e.preventDefault()}>
<button
onClick={handleDelete}
disabled={isDeleting}
className={`group flex items-center gap-1.5 px-3 py-1.5 rounded-lg transition-all duration-200
${
isDeleting
? "bg-red-500/20 text-red-400 cursor-not-allowed"
: "bg-gray-500/10 text-gray-400 hover:bg-red-500/10 hover:text-red-400"
}
`}
>
{isDeleting ? (
<div className="size-3.5 border-2 border-red-400/30 border-t-red-400 rounded-full animate-spin" />
) : (
<Trash2 className="size-3.5" />
)}
</button>
</div>
)}
</div>
</div>

{/* Content */}
<div className="space-y-4">
<div>
<h2 className="text-xl font-semibold text-white mb-2 line-clamp-1 group-hover:text-blue-400 transition-colors">
{snippet.title}
</h2>
<div className="flex items-center gap-3 text-sm text-gray-400">
<div className="flex items-center gap-2">
<div className="p-1 rounded-md bg-gray-800/50">
<User className="size-3" />
</div>
<span className="truncate max-w-[150px]">{snippet.userName}</span>
</div>
</div>
</div>

<div className="relative group/code">
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/15 to-purple-500/5 rounded-lg opacity-0 group-hover/code:opacity-100 transition-all" />
<pre className="relative bg-black/30 rounded-lg p-4 overflow-hidden text-sm text-gray-300 font-mono line-clamp-3">
{snippet.code}
</pre>
</div>
</div>
</div>
</div>
</Link>
</motion.div>
);
}
export default SnippetCard;
83 changes: 83 additions & 0 deletions src/app/snippets/_components/SnippetPageSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
const CardSkeleton = () => (
<div className="relative group">
<div className="bg-[#1e1e2e]/80 rounded-xl border border-[#313244]/50 overflow-hidden h-[280px]">
<div className="p-6 space-y-4">
{/* Header shimmer */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gray-800 animate-pulse" />
<div className="space-y-2">
<div className="w-24 h-6 bg-gray-800 rounded-lg animate-pulse" />
<div className="w-20 h-4 bg-gray-800 rounded-lg animate-pulse" />
</div>
</div>
<div className="w-16 h-8 bg-gray-800 rounded-lg animate-pulse" />
</div>

{/* Title shimmer */}
<div className="space-y-2">
<div className="w-3/4 h-7 bg-gray-800 rounded-lg animate-pulse" />
<div className="w-1/2 h-5 bg-gray-800 rounded-lg animate-pulse" />
</div>

{/* Code block shimmer */}
<div className="space-y-2 bg-black/30 rounded-lg p-4">
<div className="w-full h-4 bg-gray-800 rounded animate-pulse" />
<div className="w-3/4 h-4 bg-gray-800 rounded animate-pulse" />
<div className="w-1/2 h-4 bg-gray-800 rounded animate-pulse" />
</div>
</div>
</div>
</div>
);

export default function SnippetsPageSkeleton() {
return (
<div className="min-h-screen bg-[#0a0a0f]">
{/* Ambient background with loading pulse */}
<div className="fixed inset-0 flex items-center justify-center pointer-events-none overflow-hidden">
<div className="absolute top-[20%] -left-1/4 w-96 h-96 bg-blue-500/20 rounded-full blur-3xl" />
<div className="absolute top-[20%] -right-1/4 w-96 h-96 bg-purple-500/20 rounded-full blur-3xl" />
</div>

{/* Hero Section Skeleton */}
<div className="relative max-w-7xl mx-auto px-4 py-12">
<div className="text-center max-w-3xl mx-auto mb-16 space-y-6">
<div className="w-48 h-8 bg-gray-800 rounded-full mx-auto animate-pulse" />
<div className="w-96 h-12 bg-gray-800 rounded-xl mx-auto animate-pulse" />
<div className="w-72 h-6 bg-gray-800 rounded-lg mx-auto animate-pulse" />
</div>

{/* Search and Filters Skeleton */}
<div className="max-w-5xl mx-auto mb-12 space-y-6">
{/* Search bar */}
<div className="relative">
<div className="w-full h-14 bg-[#1e1e2e]/80 rounded-xl border border-[#313244] animate-pulse" />
</div>

{/* Language filters */}
<div className="flex flex-wrap gap-2">
{[...Array(6)].map((_, i) => (
<div
key={i}
className="w-24 h-8 bg-gray-800 rounded-lg animate-pulse"
style={{
animationDelay: `${i * 100}ms`,
}}
/>
))}
</div>
</div>

{/* Grid Skeleton */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => (
<div key={i}>
<CardSkeleton />
</div>
))}
</div>
</div>
</div>
);
}
Loading
Loading