Skip to content

Commit

Permalink
Merge pull request #16 from ARYPROGRAMMER/develop/home
Browse files Browse the repository at this point in the history
feat: snippet page collection setup
  • Loading branch information
ARYPROGRAMMER authored Dec 20, 2024
2 parents dcb0631 + 5f4007f commit 9542f58
Show file tree
Hide file tree
Showing 7 changed files with 682 additions and 3 deletions.
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

0 comments on commit 9542f58

Please sign in to comment.