Skip to content

Commit 9542f58

Browse files
Merge pull request #16 from ARYPROGRAMMER/develop/home
feat: snippet page collection setup
2 parents dcb0631 + 5f4007f commit 9542f58

File tree

7 files changed

+682
-3
lines changed

7 files changed

+682
-3
lines changed

convex/schema.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ export default defineSchema({
3636
content: v.string(),
3737
}).index("by_snippet_id", ["snippetId"]),
3838

39-
stars: defineTable({
40-
userId: v.id("users"),
39+
stars: defineTable({
40+
userId: v.string(),
4141
snippetId: v.id("snippets"),
4242
})
4343
.index("by_user_id", ["userId"])

convex/snippets.ts

+110-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { v } from "convex/values";
2-
import { mutation } from "./_generated/server";
2+
import { mutation, query } from "./_generated/server";
33

44
export const createSnippet = mutation({
55
args: {
@@ -30,3 +30,112 @@ export const createSnippet = mutation({
3030
return snippetId;
3131
},
3232
});
33+
34+
export const getSnippets = query({
35+
handler: async (ctx) => {
36+
const snippets = await ctx.db.query("snippets").order("desc").collect();
37+
return snippets;
38+
}
39+
})
40+
41+
export const isSnippetStarred = query({
42+
args: {
43+
snippetId: v.id("snippets")
44+
},
45+
handler: async (ctx, args) => {
46+
const identity = await ctx.auth.getUserIdentity();
47+
if (!identity) return false;
48+
49+
const star = await ctx.db
50+
.query("stars")
51+
.withIndex("by_user_id_and_snippet_id")
52+
.filter((q)=> q.eq(q.field("userId"),identity.subject) && q.eq(q.field("snippetId"),args.snippetId))
53+
.first();
54+
55+
return !!star;
56+
}
57+
})
58+
59+
export const getSnippetStarCount = query({
60+
args: {snippetId: v.id("snippets")},
61+
handler: async (ctx, args) => {
62+
const stars = await ctx.db
63+
.query("stars")
64+
.withIndex("by_snippet_id")
65+
.filter((q)=>q.eq(q.field("snippetId"),args.snippetId))
66+
.collect();
67+
68+
return stars.length;
69+
70+
}
71+
})
72+
73+
74+
75+
export const deleteSnippet = mutation({
76+
args: {
77+
snippetId: v.id("snippets"),
78+
},
79+
80+
handler: async (ctx, args) => {
81+
const identity = await ctx.auth.getUserIdentity();
82+
if (!identity) throw new Error("Not authenticated");
83+
84+
const snippet = await ctx.db.get(args.snippetId);
85+
if (!snippet) throw new Error("Snippet not found");
86+
87+
if (snippet.userId !== identity.subject) {
88+
throw new Error("Not authorized to delete this snippet");
89+
}
90+
91+
const comments = await ctx.db
92+
.query("snippetComments")
93+
.withIndex("by_snippet_id")
94+
.filter((q) => q.eq(q.field("snippetId"), args.snippetId))
95+
.collect();
96+
97+
for (const comment of comments) {
98+
await ctx.db.delete(comment._id);
99+
}
100+
101+
const stars = await ctx.db
102+
.query("stars")
103+
.withIndex("by_snippet_id")
104+
.filter((q) => q.eq(q.field("snippetId"), args.snippetId))
105+
.collect();
106+
107+
for (const star of stars) {
108+
await ctx.db.delete(star._id);
109+
}
110+
111+
await ctx.db.delete(args.snippetId);
112+
},
113+
});
114+
115+
export const starSnippet = mutation({
116+
args: {
117+
snippetId: v.id("snippets"),
118+
},
119+
handler: async (ctx, args) => {
120+
const identity = await ctx.auth.getUserIdentity();
121+
if (!identity) throw new Error("Not authenticated");
122+
123+
const existing = await ctx.db
124+
.query("stars")
125+
.withIndex("by_user_id_and_snippet_id")
126+
.filter(
127+
(q) =>
128+
q.eq(q.field("userId"), identity.subject) && q.eq(q.field("snippetId"), args.snippetId)
129+
)
130+
.first();
131+
132+
if (existing) {
133+
await ctx.db.delete(existing._id);
134+
} else {
135+
await ctx.db.insert("stars", {
136+
userId: identity.subject,
137+
snippetId: args.snippetId,
138+
});
139+
}
140+
},
141+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
"use client";
2+
import { Snippet } from "@/types";
3+
import { useUser } from "@clerk/nextjs";
4+
import { useMutation } from "convex/react";
5+
import { api } from "../../../../convex/_generated/api";
6+
import { useState } from "react";
7+
8+
import { motion } from "framer-motion";
9+
import Link from "next/link";
10+
import { Clock, Trash2, User } from "lucide-react";
11+
import Image from "next/image";
12+
import toast from "react-hot-toast";
13+
import StarButton from "@/components/ui/StarButton";
14+
15+
16+
function SnippetCard({ snippet }: { snippet: Snippet }) {
17+
const { user } = useUser();
18+
const deleteSnippet = useMutation(api.snippets.deleteSnippet);
19+
const [isDeleting, setIsDeleting] = useState(false);
20+
21+
const handleDelete = async () => {
22+
setIsDeleting(true);
23+
24+
try {
25+
await deleteSnippet({ snippetId: snippet._id });
26+
} catch (error) {
27+
console.log("Error deleting snippet:", error);
28+
toast.error("Error deleting snippet");
29+
} finally {
30+
setIsDeleting(false);
31+
}
32+
};
33+
34+
return (
35+
<motion.div
36+
layout
37+
className="group relative"
38+
whileHover={{ y: -2 }}
39+
transition={{ duration: 0.2 }}
40+
>
41+
<Link href={`/snippets/${snippet._id}`} className="h-full block">
42+
<div
43+
className="relative h-full bg-[#1e1e2e]/80 backdrop-blur-sm rounded-xl
44+
border border-[#313244]/50 hover:border-[#313244]
45+
transition-all duration-300 overflow-hidden"
46+
>
47+
<div className="p-6">
48+
{/* Header */}
49+
<div className="flex items-start justify-between mb-4">
50+
<div className="flex items-center gap-3">
51+
<div className="relative">
52+
<div
53+
className="absolute inset-0 bg-gradient-to-r from-blue-500 to-purple-500 rounded-lg blur opacity-20
54+
group-hover:opacity-30 transition-all duration-500"
55+
area-hidden="true"
56+
/>
57+
<div
58+
className="relative p-2 rounded-lg bg-gradient-to-br from-blue-500/10 to-purple-500/10 group-hover:from-blue-500/20
59+
group-hover:to-purple-500/20 transition-all duration-500"
60+
>
61+
<Image
62+
src={`/${snippet.language}.png`}
63+
alt={`${snippet.language} logo`}
64+
className="w-6 h-6 object-contain relative z-10"
65+
width={24}
66+
height={24}
67+
/>
68+
</div>
69+
</div>
70+
<div className="space-y-1">
71+
<span className="px-3 py-1 bg-blue-500/10 text-blue-400 rounded-lg text-xs font-medium">
72+
{snippet.language}
73+
</span>
74+
<div className="flex items-center gap-2 text-xs text-gray-500">
75+
<Clock className="size-3" />
76+
{new Date(snippet._creationTime).toLocaleDateString()}
77+
</div>
78+
</div>
79+
</div>
80+
<div
81+
className="absolute top-5 right-5 z-10 flex gap-4 items-center"
82+
onClick={(e) => e.preventDefault()}
83+
>
84+
<StarButton snippetId={snippet._id} />
85+
86+
{user?.id === snippet.userId && (
87+
<div className="z-10" onClick={(e) => e.preventDefault()}>
88+
<button
89+
onClick={handleDelete}
90+
disabled={isDeleting}
91+
className={`group flex items-center gap-1.5 px-3 py-1.5 rounded-lg transition-all duration-200
92+
${
93+
isDeleting
94+
? "bg-red-500/20 text-red-400 cursor-not-allowed"
95+
: "bg-gray-500/10 text-gray-400 hover:bg-red-500/10 hover:text-red-400"
96+
}
97+
`}
98+
>
99+
{isDeleting ? (
100+
<div className="size-3.5 border-2 border-red-400/30 border-t-red-400 rounded-full animate-spin" />
101+
) : (
102+
<Trash2 className="size-3.5" />
103+
)}
104+
</button>
105+
</div>
106+
)}
107+
</div>
108+
</div>
109+
110+
{/* Content */}
111+
<div className="space-y-4">
112+
<div>
113+
<h2 className="text-xl font-semibold text-white mb-2 line-clamp-1 group-hover:text-blue-400 transition-colors">
114+
{snippet.title}
115+
</h2>
116+
<div className="flex items-center gap-3 text-sm text-gray-400">
117+
<div className="flex items-center gap-2">
118+
<div className="p-1 rounded-md bg-gray-800/50">
119+
<User className="size-3" />
120+
</div>
121+
<span className="truncate max-w-[150px]">{snippet.userName}</span>
122+
</div>
123+
</div>
124+
</div>
125+
126+
<div className="relative group/code">
127+
<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" />
128+
<pre className="relative bg-black/30 rounded-lg p-4 overflow-hidden text-sm text-gray-300 font-mono line-clamp-3">
129+
{snippet.code}
130+
</pre>
131+
</div>
132+
</div>
133+
</div>
134+
</div>
135+
</Link>
136+
</motion.div>
137+
);
138+
}
139+
export default SnippetCard;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
const CardSkeleton = () => (
2+
<div className="relative group">
3+
<div className="bg-[#1e1e2e]/80 rounded-xl border border-[#313244]/50 overflow-hidden h-[280px]">
4+
<div className="p-6 space-y-4">
5+
{/* Header shimmer */}
6+
<div className="flex items-start justify-between">
7+
<div className="flex items-center gap-3">
8+
<div className="w-10 h-10 rounded-lg bg-gray-800 animate-pulse" />
9+
<div className="space-y-2">
10+
<div className="w-24 h-6 bg-gray-800 rounded-lg animate-pulse" />
11+
<div className="w-20 h-4 bg-gray-800 rounded-lg animate-pulse" />
12+
</div>
13+
</div>
14+
<div className="w-16 h-8 bg-gray-800 rounded-lg animate-pulse" />
15+
</div>
16+
17+
{/* Title shimmer */}
18+
<div className="space-y-2">
19+
<div className="w-3/4 h-7 bg-gray-800 rounded-lg animate-pulse" />
20+
<div className="w-1/2 h-5 bg-gray-800 rounded-lg animate-pulse" />
21+
</div>
22+
23+
{/* Code block shimmer */}
24+
<div className="space-y-2 bg-black/30 rounded-lg p-4">
25+
<div className="w-full h-4 bg-gray-800 rounded animate-pulse" />
26+
<div className="w-3/4 h-4 bg-gray-800 rounded animate-pulse" />
27+
<div className="w-1/2 h-4 bg-gray-800 rounded animate-pulse" />
28+
</div>
29+
</div>
30+
</div>
31+
</div>
32+
);
33+
34+
export default function SnippetsPageSkeleton() {
35+
return (
36+
<div className="min-h-screen bg-[#0a0a0f]">
37+
{/* Ambient background with loading pulse */}
38+
<div className="fixed inset-0 flex items-center justify-center pointer-events-none overflow-hidden">
39+
<div className="absolute top-[20%] -left-1/4 w-96 h-96 bg-blue-500/20 rounded-full blur-3xl" />
40+
<div className="absolute top-[20%] -right-1/4 w-96 h-96 bg-purple-500/20 rounded-full blur-3xl" />
41+
</div>
42+
43+
{/* Hero Section Skeleton */}
44+
<div className="relative max-w-7xl mx-auto px-4 py-12">
45+
<div className="text-center max-w-3xl mx-auto mb-16 space-y-6">
46+
<div className="w-48 h-8 bg-gray-800 rounded-full mx-auto animate-pulse" />
47+
<div className="w-96 h-12 bg-gray-800 rounded-xl mx-auto animate-pulse" />
48+
<div className="w-72 h-6 bg-gray-800 rounded-lg mx-auto animate-pulse" />
49+
</div>
50+
51+
{/* Search and Filters Skeleton */}
52+
<div className="max-w-5xl mx-auto mb-12 space-y-6">
53+
{/* Search bar */}
54+
<div className="relative">
55+
<div className="w-full h-14 bg-[#1e1e2e]/80 rounded-xl border border-[#313244] animate-pulse" />
56+
</div>
57+
58+
{/* Language filters */}
59+
<div className="flex flex-wrap gap-2">
60+
{[...Array(6)].map((_, i) => (
61+
<div
62+
key={i}
63+
className="w-24 h-8 bg-gray-800 rounded-lg animate-pulse"
64+
style={{
65+
animationDelay: `${i * 100}ms`,
66+
}}
67+
/>
68+
))}
69+
</div>
70+
</div>
71+
72+
{/* Grid Skeleton */}
73+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
74+
{[...Array(6)].map((_, i) => (
75+
<div key={i}>
76+
<CardSkeleton />
77+
</div>
78+
))}
79+
</div>
80+
</div>
81+
</div>
82+
);
83+
}

0 commit comments

Comments
 (0)