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
10 changes: 7 additions & 3 deletions .eslintrc.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
84 changes: 83 additions & 1 deletion convex/codeExecutions.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down Expand Up @@ -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<string, number>
);

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<string, number>
);

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,
};
},
});
17 changes: 17 additions & 0 deletions convex/snippets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
});
53 changes: 53 additions & 0 deletions src/app/profile/_components/CodeBlock.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="relative">
<SyntaxHighlighter
language={language.toLowerCase()}
style={atomOneDark}
customStyle={{
padding: "1rem",
borderRadius: "0.5rem",
background: "rgba(0, 0, 0, 0.4)",
margin: 0,
}}
>
{displayCode}
</SyntaxHighlighter>

{lines.length > 6 && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="absolute bottom-2 right-2 px-2 py-1 bg-blue-500/20 text-blue-400 rounded text-xs flex items-center
gap-1 hover:bg-blue-500/30 transition-colors"
>
{isExpanded ? (
<>
Show Less <ChevronUp className="w-3 h-3" />
</>
) : (
<>
Show More <ChevronDown className="w-3 h-3" />
</>
)}
</button>
)}
</div>
);
};

export default CodeBlock;
168 changes: 168 additions & 0 deletions src/app/profile/_components/ProfileHeader.tsx
Original file line number Diff line number Diff line change
@@ -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<string, number>;
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 (
<div
className="relative mb-8 bg-gradient-to-br from-[#12121a] to-[#1a1a2e] rounded-2xl p-8 border
border-gray-800/50 overflow-hidden"
>
<div className="absolute inset-0 bg-grid-white/[0.02] bg-[size:32px]" />
<div className="relative flex items-center gap-8">
<div className="relative group">
<div
className="absolute inset-0 bg-gradient-to-r from-blue-500 to-purple-600 rounded-full
blur-xl opacity-50 group-hover:opacity-75 transition-opacity"
/>
<img
src={user.imageUrl}
alt="Profile"
className="w-24 h-24 rounded-full border-4 border-gray-800/50 relative z-10 group-hover:scale-105 transition-transform"
/>
{userData.isPro && (
<div
className="absolute -top-2 -right-2 bg-gradient-to-r from-purple-500 to-purple-600 p-2
rounded-full z-20 shadow-lg animate-pulse"
>
<Zap className="w-4 h-4 text-white" />
</div>
)}
</div>
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-3xl font-bold text-white">{userData.name}</h1>
{userData.isPro && (
<span className="px-3 py-1 bg-purple-500/10 text-purple-400 rounded-full text-sm font-medium">
Pro Member
</span>
)}
</div>
<p className="text-gray-400 flex items-center gap-2">
<UserIcon className="w-4 h-4" />
{userData.email}
</p>
</div>
</div>

{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mt-8">
{STATS.map((stat, index) => (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: index * 0.1 }}
key={index}
className="group relative bg-gradient-to-br from-black/40 to-black/20 rounded-2xl overflow-hidden"
>
{/* Glow effect */}
<div
className={`absolute inset-0 bg-gradient-to-r ${stat.color} opacity-0 group-hover:opacity-10 transition-all
duration-500 ${stat.gradient}`}
/>

{/* Content */}
<div className="relative p-6">
<div className="flex items-start justify-between mb-4">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-gray-400">{stat.description}</span>
</div>
<h3 className="text-2xl font-bold text-white tracking-tight">
{typeof stat.value === "number" ? stat.value.toLocaleString() : stat.value}
</h3>
<p className="text-sm text-gray-400 mt-1">{stat.label}</p>
</div>
<div className={`p-3 rounded-xl bg-gradient-to-br ${stat.color} bg-opacity-10`}>
<stat.icon className="w-5 h-5 text-white" />
</div>
</div>

{/* Additional metric */}
<div className="flex items-center gap-2 pt-4 border-t border-gray-800/50">
<stat.metric.icon className="w-4 h-4 text-gray-500" />
<span className="text-sm text-gray-400">{stat.metric.label}:</span>
<span className="text-sm font-medium text-white">{stat.metric.value}</span>
</div>
</div>

{/* Interactive hover effect */}
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/5 to-transparent -translate-x-full group-hover:translate-x-full duration-1000 transition-transform" />
</motion.div>
))}
</div>
</div>
);
}
export default ProfileHeader;
Loading
Loading