diff --git a/.eslintrc.json b/.eslintrc.json index 9ddb744..1c3838f 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,10 +1,14 @@ { - "extends": ["next/core-web-vitals", - "eslint:recommended", + "extends": [ + "next/core-web-vitals", + "eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier" ], "parser": "@typescript-eslint/parser", "plugins": ["@typescript-eslint"], - "root": true + "root": true, + "rules": { + "@next/next/no-img-element": "off" + } } diff --git a/convex/codeExecutions.ts b/convex/codeExecutions.ts index 4e3f371..00476dd 100644 --- a/convex/codeExecutions.ts +++ b/convex/codeExecutions.ts @@ -1,5 +1,6 @@ import { ConvexError, v } from "convex/values"; -import { mutation } from "./_generated/server"; +import { mutation, query } from "./_generated/server"; +import { paginationOptsValidator } from "convex/server"; export const saveCodeExecution = mutation({ args: { @@ -33,3 +34,84 @@ export const saveCodeExecution = mutation({ }); }, }); + + +export const getUserExecutions = query({ + args: { + userId: v.string(), + paginationOpts: paginationOptsValidator, + }, + handler: async (ctx, args) => { + return await ctx.db + .query("codeExecutions") + .withIndex("by_user_id") + .filter((q) => q.eq(q.field("userId"), args.userId)) + .order("desc") + .paginate(args.paginationOpts); + }, +}); + + +export const getUserStats = query({ + args: { userId: v.string() }, + handler: async (ctx, args) => { + const executions = await ctx.db + .query("codeExecutions") + .withIndex("by_user_id") + .filter((q) => q.eq(q.field("userId"), args.userId)) + .collect(); + + // Get starred snippets + const starredSnippets = await ctx.db + .query("stars") + .withIndex("by_user_id") + .filter((q) => q.eq(q.field("userId"), args.userId)) + .collect(); + + // Get all starred snippet details to analyze languages + const snippetIds = starredSnippets.map((star) => star.snippetId); + const snippetDetails = await Promise.all(snippetIds.map((id) => ctx.db.get(id))); + + // Calculate most starred language + const starredLanguages = snippetDetails.filter(Boolean).reduce( + (acc, curr) => { + if (curr?.language) { + acc[curr.language] = (acc[curr.language] || 0) + 1; + } + return acc; + }, + {} as Record + ); + + const mostStarredLanguage = + Object.entries(starredLanguages).sort(([, a], [, b]) => b - a)[0]?.[0] ?? "N/A"; + + // Calculate execution stats + const last24Hours = executions.filter( + (e) => e._creationTime > Date.now() - 24 * 60 * 60 * 1000 + ).length; + + const languageStats = executions.reduce( + (acc, curr) => { + acc[curr.language] = (acc[curr.language] || 0) + 1; + return acc; + }, + {} as Record + ); + + const languages = Object.keys(languageStats); + const favoriteLanguage = languages.length + ? languages.reduce((a, b) => (languageStats[a] > languageStats[b] ? a : b)) + : "N/A"; + + return { + totalExecutions: executions.length, + languagesCount: languages.length, + languages: languages, + last24Hours, + favoriteLanguage, + languageStats, + mostStarredLanguage, + }; + }, +}); diff --git a/convex/snippets.ts b/convex/snippets.ts index df953bc..0580a08 100644 --- a/convex/snippets.ts +++ b/convex/snippets.ts @@ -204,4 +204,21 @@ export const deleteComment = mutation({ await ctx.db.delete(args.commentId); }, +}); + +export const getStarredSnippets = query({ + handler: async (ctx) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) return []; + + const stars = await ctx.db + .query("stars") + .withIndex("by_user_id") + .filter((q) => q.eq(q.field("userId"), identity.subject)) + .collect(); + + const snippets = await Promise.all(stars.map((star) => ctx.db.get(star.snippetId))); + + return snippets.filter((snippet) => snippet !== null); + }, }); \ No newline at end of file diff --git a/src/app/profile/_components/CodeBlock.tsx b/src/app/profile/_components/CodeBlock.tsx new file mode 100644 index 0000000..7471d1f --- /dev/null +++ b/src/app/profile/_components/CodeBlock.tsx @@ -0,0 +1,53 @@ +"use client"; +import { ChevronDown, ChevronUp } from "lucide-react"; +import { useState } from "react"; +import SyntaxHighlighter from "react-syntax-highlighter"; +import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs"; + +interface CodeBlockProps { + code: string; + language: string; +} + +const CodeBlock = ({ code, language }: CodeBlockProps) => { + const [isExpanded, setIsExpanded] = useState(false); + const lines = code.split("\n"); + const displayCode = isExpanded ? code : lines.slice(0, 6).join("\n"); + + return ( +
+ + {displayCode} + + + {lines.length > 6 && ( + + )} +
+ ); +}; + +export default CodeBlock; \ No newline at end of file diff --git a/src/app/profile/_components/ProfileHeader.tsx b/src/app/profile/_components/ProfileHeader.tsx new file mode 100644 index 0000000..936e2a2 --- /dev/null +++ b/src/app/profile/_components/ProfileHeader.tsx @@ -0,0 +1,168 @@ +import { useQuery } from "convex/react"; +import { api } from "../../../../convex/_generated/api"; +import { Activity, Code2, Star, Timer, TrendingUp, Trophy, UserIcon, Zap } from "lucide-react"; +import { motion } from "framer-motion"; +import { Id } from "../../../../convex/_generated/dataModel"; + +import { UserResource } from "@clerk/types"; + +interface ProfileHeaderProps { + userStats: { + totalExecutions: number; + languagesCount: number; + languages: string[]; + last24Hours: number; + favoriteLanguage: string; + languageStats: Record; + mostStarredLanguage: string; + }; + userData: { + _id: Id<"users">; + _creationTime: number; + proSince?: number | undefined; + lemonSqueezyCustomerId?: string | undefined; + lemonSqueezyOrderId?: string | undefined; + name: string; + userId: string; + email: string; + isPro: boolean; + }; + user: UserResource; +} + +function ProfileHeader({ userStats, userData, user }: ProfileHeaderProps) { + const starredSnippets = useQuery(api.snippets.getStarredSnippets); + const STATS = [ + { + label: "Code Executions", + value: userStats?.totalExecutions ?? 0, + icon: Activity, + color: "from-blue-500 to-cyan-500", + gradient: "group-hover:via-blue-400", + description: "Total code runs", + metric: { + label: "Last 24h", + value: userStats?.last24Hours ?? 0, + icon: Timer, + }, + }, + { + label: "Starred Snippets", + value: starredSnippets?.length ?? 0, + icon: Star, + color: "from-yellow-500 to-orange-500", + gradient: "group-hover:via-yellow-400", + description: "Saved for later", + metric: { + label: "Most starred", + value: userStats?.mostStarredLanguage ?? "N/A", + icon: Trophy, + }, + }, + { + label: "Languages Used", + value: userStats?.languagesCount ?? 0, + icon: Code2, + color: "from-purple-500 to-pink-500", + gradient: "group-hover:via-purple-400", + description: "Different languages", + metric: { + label: "Most used", + value: userStats?.favoriteLanguage ?? "N/A", + icon: TrendingUp, + }, + }, + ]; + + return ( +
+
+
+
+
+ Profile + {userData.isPro && ( +
+ +
+ )} +
+
+
+

{userData.name}

+ {userData.isPro && ( + + Pro Member + + )} +
+

+ + {userData.email} +

+
+
+ + {/* Stats Cards */} +
+ {STATS.map((stat, index) => ( + + {/* Glow effect */} +
+ + {/* Content */} +
+
+
+
+ {stat.description} +
+

+ {typeof stat.value === "number" ? stat.value.toLocaleString() : stat.value} +

+

{stat.label}

+
+
+ +
+
+ + {/* Additional metric */} +
+ + {stat.metric.label}: + {stat.metric.value} +
+
+ + {/* Interactive hover effect */} +
+ + ))} +
+
+ ); +} +export default ProfileHeader; \ No newline at end of file diff --git a/src/app/profile/_components/ProfileHeaderSkeleton.tsx b/src/app/profile/_components/ProfileHeaderSkeleton.tsx new file mode 100644 index 0000000..1498527 --- /dev/null +++ b/src/app/profile/_components/ProfileHeaderSkeleton.tsx @@ -0,0 +1,59 @@ +function ProfileHeaderSkeleton() { + return ( +
+
+
+ {/* Avatar Skeleton */} +
+
+
+
+
+ + {/* User Info Skeleton */} +
+
+
+
+
+ + {/* Stats Grid */} +
+ {[1, 2, 3].map((i) => ( +
+
+
+ {/* Stat Header */} +
+
+
+
+
+
+
+
+ + {/* Stat Footer */} +
+
+
+
+
+
+
+ ))} +
+
+ ); + } + + export default ProfileHeaderSkeleton; \ No newline at end of file diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx new file mode 100644 index 0000000..6387d50 --- /dev/null +++ b/src/app/profile/page.tsx @@ -0,0 +1,297 @@ +"use client"; +import { useUser } from "@clerk/nextjs"; +import { usePaginatedQuery, useQuery } from "convex/react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { api } from "../../../convex/_generated/api"; +import { ChevronRight, Clock, Code, ListVideo, Loader2, Star } from "lucide-react"; +import { motion, AnimatePresence } from "framer-motion"; +import Image from "next/image"; +import Link from "next/link"; +import NavigationHeader from "@/components/ui/NavigationHeader"; +import ProfileHeader from "./_components/ProfileHeader"; +import ProfileHeaderSkeleton from "./_components/ProfileHeaderSkeleton"; +import CodeBlock from "./_components/CodeBlock"; +import StarButton from "@/components/ui/StarButton"; + + +const TABS = [ + { + id: "executions", + label: "Code Executions", + icon: ListVideo, + }, + { + id: "starred", + label: "Starred Snippets", + icon: Star, + }, +]; + +function ProfilePage() { + const { user, isLoaded } = useUser(); + const router = useRouter(); + const [activeTab, setActiveTab] = useState<"executions" | "starred">("executions"); + + const userStats = useQuery(api.codeExecutions.getUserStats, { + userId: user?.id ?? "", + }); + + const starredSnippets = useQuery(api.snippets.getStarredSnippets); + + const { + results: executions, + status: executionStatus, + isLoading: isLoadingExecutions, + loadMore, + } = usePaginatedQuery( + api.codeExecutions.getUserExecutions, + { + userId: user?.id ?? "", + }, + { initialNumItems: 5 } + ); + + const userData = useQuery(api.users.getUser, { userId: user?.id ?? "" }); + + const handleLoadMore = () => { + if (executionStatus === "CanLoadMore") loadMore(5); + }; + + if (!user && isLoaded) return router.push("/"); + + return ( +
+ + +
+ {/* Profile Header */} + + {userStats && userData && ( + + )} + + {(userStats === undefined || !isLoaded) && } + + {/* Main content */} +
+ {/* Tabs */} +
+
+ {TABS.map((tab) => ( + + ))} +
+
+ + {/* Tab content */} + + + {/* ACTIVE TAB IS EXECUTIONS: */} + {activeTab === "executions" && ( +
+ {executions?.map((execution) => ( +
+
+
+
+
+ +
+
+
+ + {execution.language.toUpperCase()} + + + + {new Date(execution._creationTime).toLocaleString()} + +
+
+ + {execution.error ? "Error" : "Success"} + +
+
+
+
+ +
+ + + {(execution.output || execution.error) && ( +
+

Output

+
+                              {execution.error || execution.output}
+                            
+
+ )} +
+
+ ))} + + {isLoadingExecutions ? ( +
+ +

+ Loading code executions... +

+
+ ) : ( + executions.length === 0 && ( +
+ +

+ No code executions yet +

+

Start coding to see your execution history!

+
+ ) + )} + + {/* Load More Button */} + {executionStatus === "CanLoadMore" && ( +
+ +
+ )} +
+ )} + + {/* ACTIVE TAB IS STARS: */} + {activeTab === "starred" && ( +
+ {starredSnippets?.map((snippet) => ( +
+ +
+
+
+
+
+
+ {`${snippet.language} +
+ + {snippet.language} + +
+
e.preventDefault()} + > + +
+
+

+ {snippet.title} +

+
+
+ + {new Date(snippet._creationTime).toLocaleDateString()} +
+ +
+
+
+
+
+                                {snippet.code}
+                              
+
+
+
+ +
+ ))} + + {(!starredSnippets || starredSnippets.length === 0) && ( +
+ +

+ No starred snippets yet +

+

+ Start exploring and star the snippets you find useful! +

+
+ )} +
+ )} + + +
+
+
+ ); +} +export default ProfilePage; \ No newline at end of file