Skip to content

Commit 662cf38

Browse files
Merge pull request #19 from ARYPROGRAMMER/develop/home
feat: profile page ui
2 parents aefa7f4 + 467a87b commit 662cf38

File tree

7 files changed

+684
-4
lines changed

7 files changed

+684
-4
lines changed

.eslintrc.json

+7-3
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
{
2-
"extends": ["next/core-web-vitals",
3-
"eslint:recommended",
2+
"extends": [
3+
"next/core-web-vitals",
4+
"eslint:recommended",
45
"plugin:@typescript-eslint/recommended",
56
"prettier"
67
],
78
"parser": "@typescript-eslint/parser",
89
"plugins": ["@typescript-eslint"],
9-
"root": true
10+
"root": true,
11+
"rules": {
12+
"@next/next/no-img-element": "off"
13+
}
1014
}

convex/codeExecutions.ts

+83-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ConvexError, v } from "convex/values";
2-
import { mutation } from "./_generated/server";
2+
import { mutation, query } from "./_generated/server";
3+
import { paginationOptsValidator } from "convex/server";
34

45
export const saveCodeExecution = mutation({
56
args: {
@@ -33,3 +34,84 @@ export const saveCodeExecution = mutation({
3334
});
3435
},
3536
});
37+
38+
39+
export const getUserExecutions = query({
40+
args: {
41+
userId: v.string(),
42+
paginationOpts: paginationOptsValidator,
43+
},
44+
handler: async (ctx, args) => {
45+
return await ctx.db
46+
.query("codeExecutions")
47+
.withIndex("by_user_id")
48+
.filter((q) => q.eq(q.field("userId"), args.userId))
49+
.order("desc")
50+
.paginate(args.paginationOpts);
51+
},
52+
});
53+
54+
55+
export const getUserStats = query({
56+
args: { userId: v.string() },
57+
handler: async (ctx, args) => {
58+
const executions = await ctx.db
59+
.query("codeExecutions")
60+
.withIndex("by_user_id")
61+
.filter((q) => q.eq(q.field("userId"), args.userId))
62+
.collect();
63+
64+
// Get starred snippets
65+
const starredSnippets = await ctx.db
66+
.query("stars")
67+
.withIndex("by_user_id")
68+
.filter((q) => q.eq(q.field("userId"), args.userId))
69+
.collect();
70+
71+
// Get all starred snippet details to analyze languages
72+
const snippetIds = starredSnippets.map((star) => star.snippetId);
73+
const snippetDetails = await Promise.all(snippetIds.map((id) => ctx.db.get(id)));
74+
75+
// Calculate most starred language
76+
const starredLanguages = snippetDetails.filter(Boolean).reduce(
77+
(acc, curr) => {
78+
if (curr?.language) {
79+
acc[curr.language] = (acc[curr.language] || 0) + 1;
80+
}
81+
return acc;
82+
},
83+
{} as Record<string, number>
84+
);
85+
86+
const mostStarredLanguage =
87+
Object.entries(starredLanguages).sort(([, a], [, b]) => b - a)[0]?.[0] ?? "N/A";
88+
89+
// Calculate execution stats
90+
const last24Hours = executions.filter(
91+
(e) => e._creationTime > Date.now() - 24 * 60 * 60 * 1000
92+
).length;
93+
94+
const languageStats = executions.reduce(
95+
(acc, curr) => {
96+
acc[curr.language] = (acc[curr.language] || 0) + 1;
97+
return acc;
98+
},
99+
{} as Record<string, number>
100+
);
101+
102+
const languages = Object.keys(languageStats);
103+
const favoriteLanguage = languages.length
104+
? languages.reduce((a, b) => (languageStats[a] > languageStats[b] ? a : b))
105+
: "N/A";
106+
107+
return {
108+
totalExecutions: executions.length,
109+
languagesCount: languages.length,
110+
languages: languages,
111+
last24Hours,
112+
favoriteLanguage,
113+
languageStats,
114+
mostStarredLanguage,
115+
};
116+
},
117+
});

convex/snippets.ts

+17
Original file line numberDiff line numberDiff line change
@@ -204,4 +204,21 @@ export const deleteComment = mutation({
204204

205205
await ctx.db.delete(args.commentId);
206206
},
207+
});
208+
209+
export const getStarredSnippets = query({
210+
handler: async (ctx) => {
211+
const identity = await ctx.auth.getUserIdentity();
212+
if (!identity) return [];
213+
214+
const stars = await ctx.db
215+
.query("stars")
216+
.withIndex("by_user_id")
217+
.filter((q) => q.eq(q.field("userId"), identity.subject))
218+
.collect();
219+
220+
const snippets = await Promise.all(stars.map((star) => ctx.db.get(star.snippetId)));
221+
222+
return snippets.filter((snippet) => snippet !== null);
223+
},
207224
});
+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"use client";
2+
import { ChevronDown, ChevronUp } from "lucide-react";
3+
import { useState } from "react";
4+
import SyntaxHighlighter from "react-syntax-highlighter";
5+
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
6+
7+
interface CodeBlockProps {
8+
code: string;
9+
language: string;
10+
}
11+
12+
const CodeBlock = ({ code, language }: CodeBlockProps) => {
13+
const [isExpanded, setIsExpanded] = useState(false);
14+
const lines = code.split("\n");
15+
const displayCode = isExpanded ? code : lines.slice(0, 6).join("\n");
16+
17+
return (
18+
<div className="relative">
19+
<SyntaxHighlighter
20+
language={language.toLowerCase()}
21+
style={atomOneDark}
22+
customStyle={{
23+
padding: "1rem",
24+
borderRadius: "0.5rem",
25+
background: "rgba(0, 0, 0, 0.4)",
26+
margin: 0,
27+
}}
28+
>
29+
{displayCode}
30+
</SyntaxHighlighter>
31+
32+
{lines.length > 6 && (
33+
<button
34+
onClick={() => setIsExpanded(!isExpanded)}
35+
className="absolute bottom-2 right-2 px-2 py-1 bg-blue-500/20 text-blue-400 rounded text-xs flex items-center
36+
gap-1 hover:bg-blue-500/30 transition-colors"
37+
>
38+
{isExpanded ? (
39+
<>
40+
Show Less <ChevronUp className="w-3 h-3" />
41+
</>
42+
) : (
43+
<>
44+
Show More <ChevronDown className="w-3 h-3" />
45+
</>
46+
)}
47+
</button>
48+
)}
49+
</div>
50+
);
51+
};
52+
53+
export default CodeBlock;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { useQuery } from "convex/react";
2+
import { api } from "../../../../convex/_generated/api";
3+
import { Activity, Code2, Star, Timer, TrendingUp, Trophy, UserIcon, Zap } from "lucide-react";
4+
import { motion } from "framer-motion";
5+
import { Id } from "../../../../convex/_generated/dataModel";
6+
7+
import { UserResource } from "@clerk/types";
8+
9+
interface ProfileHeaderProps {
10+
userStats: {
11+
totalExecutions: number;
12+
languagesCount: number;
13+
languages: string[];
14+
last24Hours: number;
15+
favoriteLanguage: string;
16+
languageStats: Record<string, number>;
17+
mostStarredLanguage: string;
18+
};
19+
userData: {
20+
_id: Id<"users">;
21+
_creationTime: number;
22+
proSince?: number | undefined;
23+
lemonSqueezyCustomerId?: string | undefined;
24+
lemonSqueezyOrderId?: string | undefined;
25+
name: string;
26+
userId: string;
27+
email: string;
28+
isPro: boolean;
29+
};
30+
user: UserResource;
31+
}
32+
33+
function ProfileHeader({ userStats, userData, user }: ProfileHeaderProps) {
34+
const starredSnippets = useQuery(api.snippets.getStarredSnippets);
35+
const STATS = [
36+
{
37+
label: "Code Executions",
38+
value: userStats?.totalExecutions ?? 0,
39+
icon: Activity,
40+
color: "from-blue-500 to-cyan-500",
41+
gradient: "group-hover:via-blue-400",
42+
description: "Total code runs",
43+
metric: {
44+
label: "Last 24h",
45+
value: userStats?.last24Hours ?? 0,
46+
icon: Timer,
47+
},
48+
},
49+
{
50+
label: "Starred Snippets",
51+
value: starredSnippets?.length ?? 0,
52+
icon: Star,
53+
color: "from-yellow-500 to-orange-500",
54+
gradient: "group-hover:via-yellow-400",
55+
description: "Saved for later",
56+
metric: {
57+
label: "Most starred",
58+
value: userStats?.mostStarredLanguage ?? "N/A",
59+
icon: Trophy,
60+
},
61+
},
62+
{
63+
label: "Languages Used",
64+
value: userStats?.languagesCount ?? 0,
65+
icon: Code2,
66+
color: "from-purple-500 to-pink-500",
67+
gradient: "group-hover:via-purple-400",
68+
description: "Different languages",
69+
metric: {
70+
label: "Most used",
71+
value: userStats?.favoriteLanguage ?? "N/A",
72+
icon: TrendingUp,
73+
},
74+
},
75+
];
76+
77+
return (
78+
<div
79+
className="relative mb-8 bg-gradient-to-br from-[#12121a] to-[#1a1a2e] rounded-2xl p-8 border
80+
border-gray-800/50 overflow-hidden"
81+
>
82+
<div className="absolute inset-0 bg-grid-white/[0.02] bg-[size:32px]" />
83+
<div className="relative flex items-center gap-8">
84+
<div className="relative group">
85+
<div
86+
className="absolute inset-0 bg-gradient-to-r from-blue-500 to-purple-600 rounded-full
87+
blur-xl opacity-50 group-hover:opacity-75 transition-opacity"
88+
/>
89+
<img
90+
src={user.imageUrl}
91+
alt="Profile"
92+
className="w-24 h-24 rounded-full border-4 border-gray-800/50 relative z-10 group-hover:scale-105 transition-transform"
93+
/>
94+
{userData.isPro && (
95+
<div
96+
className="absolute -top-2 -right-2 bg-gradient-to-r from-purple-500 to-purple-600 p-2
97+
rounded-full z-20 shadow-lg animate-pulse"
98+
>
99+
<Zap className="w-4 h-4 text-white" />
100+
</div>
101+
)}
102+
</div>
103+
<div>
104+
<div className="flex items-center gap-3 mb-2">
105+
<h1 className="text-3xl font-bold text-white">{userData.name}</h1>
106+
{userData.isPro && (
107+
<span className="px-3 py-1 bg-purple-500/10 text-purple-400 rounded-full text-sm font-medium">
108+
Pro Member
109+
</span>
110+
)}
111+
</div>
112+
<p className="text-gray-400 flex items-center gap-2">
113+
<UserIcon className="w-4 h-4" />
114+
{userData.email}
115+
</p>
116+
</div>
117+
</div>
118+
119+
{/* Stats Cards */}
120+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mt-8">
121+
{STATS.map((stat, index) => (
122+
<motion.div
123+
initial={{ opacity: 0, y: 20 }}
124+
animate={{ opacity: 1, y: 0 }}
125+
transition={{ duration: 0.5, delay: index * 0.1 }}
126+
key={index}
127+
className="group relative bg-gradient-to-br from-black/40 to-black/20 rounded-2xl overflow-hidden"
128+
>
129+
{/* Glow effect */}
130+
<div
131+
className={`absolute inset-0 bg-gradient-to-r ${stat.color} opacity-0 group-hover:opacity-10 transition-all
132+
duration-500 ${stat.gradient}`}
133+
/>
134+
135+
{/* Content */}
136+
<div className="relative p-6">
137+
<div className="flex items-start justify-between mb-4">
138+
<div>
139+
<div className="flex items-center gap-2 mb-1">
140+
<span className="text-sm font-medium text-gray-400">{stat.description}</span>
141+
</div>
142+
<h3 className="text-2xl font-bold text-white tracking-tight">
143+
{typeof stat.value === "number" ? stat.value.toLocaleString() : stat.value}
144+
</h3>
145+
<p className="text-sm text-gray-400 mt-1">{stat.label}</p>
146+
</div>
147+
<div className={`p-3 rounded-xl bg-gradient-to-br ${stat.color} bg-opacity-10`}>
148+
<stat.icon className="w-5 h-5 text-white" />
149+
</div>
150+
</div>
151+
152+
{/* Additional metric */}
153+
<div className="flex items-center gap-2 pt-4 border-t border-gray-800/50">
154+
<stat.metric.icon className="w-4 h-4 text-gray-500" />
155+
<span className="text-sm text-gray-400">{stat.metric.label}:</span>
156+
<span className="text-sm font-medium text-white">{stat.metric.value}</span>
157+
</div>
158+
</div>
159+
160+
{/* Interactive hover effect */}
161+
<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" />
162+
</motion.div>
163+
))}
164+
</div>
165+
</div>
166+
);
167+
}
168+
export default ProfileHeader;

0 commit comments

Comments
 (0)