From 388215efe4778e31cd36bdbabee3b800b931388c Mon Sep 17 00:00:00 2001 From: AvaneeshKesavan Date: Tue, 7 Oct 2025 00:25:24 +0530 Subject: [PATCH] feat: Add GitHub Profile integration with frontend and backend --- backend/env.example | 3 +- backend/routes/github.route.js | 142 ++++ backend/routes/profile.js | 595 +++-------------- frontend/src/App.jsx | 5 +- .../Components/DashBoard/PlatformLinks.jsx | 122 ++-- .../src/Components/DashBoard/ProfileCard.jsx | 68 +- frontend/src/Components/Dashboard.jsx | 32 +- frontend/src/Components/GitHubProfile.jsx | 294 +++++++++ frontend/src/Components/profile/Profile.jsx | 611 ++++++++++++------ frontend/src/index.css | 100 ++- package-lock.json | 32 + package.json | 1 + 12 files changed, 1188 insertions(+), 817 deletions(-) create mode 100644 backend/routes/github.route.js create mode 100644 frontend/src/Components/GitHubProfile.jsx diff --git a/backend/env.example b/backend/env.example index 9a73ee5..26d4331 100644 --- a/backend/env.example +++ b/backend/env.example @@ -10,4 +10,5 @@ ADMIN_EMAIL= RESEND_API_KEY = EMAIL_USER= EMAIL_PASSWORD -EMAIL_VERIFIER_API_KEY= \ No newline at end of file +EMAIL_VERIFIER_API_KEY= +GITHUB_TOKEN= \ No newline at end of file diff --git a/backend/routes/github.route.js b/backend/routes/github.route.js new file mode 100644 index 0000000..7a3f3ba --- /dev/null +++ b/backend/routes/github.route.js @@ -0,0 +1,142 @@ +// routes/github.route.js +const express = require("express"); +const fetch = (...args) => + import("node-fetch").then(({ default: fetch }) => fetch(...args)); +const router = express.Router(); + +// Helper to run GitHub GraphQL queries +const runGraphQL = async (query, variables = {}) => { + if (!process.env.GITHUB_TOKEN) { + throw new Error("GitHub token not configured in environment variables"); + } + + const response = await fetch("https://api.github.com/graphql", { + method: "POST", + headers: { + Authorization: `bearer ${process.env.GITHUB_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ query, variables }), + }); + + const data = await response.json(); + if (data.errors) throw new Error(JSON.stringify(data.errors)); + return data.data; +}; + +// GET /api/github/:username +router.get("/:username", async (req, res) => { + const { username } = req.params; + + try { + const query = ` + query($login: String!) { + user(login: $login) { + login + name + avatarUrl + bio + followers { totalCount } + following { totalCount } + repositories( + first: 50, + privacy: PUBLIC, + isFork: false, + orderBy: { field: STARGAZERS, direction: DESC } + ) { + nodes { + name + url + description + stargazerCount + forkCount + languages(first: 5, orderBy: { field: SIZE, direction: DESC }) { + edges { + size + node { + name + } + } + } + } + } + contributionsCollection { + contributionCalendar { + totalContributions + weeks { + contributionDays { + date + contributionCount + color + } + } + } + totalCommitContributions + } + } + } + `; + + const data = await runGraphQL(query, { login: username }); + const user = data.user; + + if (!user) return res.status(404).json({ error: "User not found" }); + + // Top 6 repos by stars + const topRepos = user.repositories.nodes + .sort((a, b) => b.stargazerCount - a.stargazerCount) + .slice(0, 6) + .map((r) => ({ + name: r.name, + url: r.url, + description: r.description, + stars: r.stargazerCount, + forks: r.forkCount, + languages: r.languages.edges.map((edge) => ({ + name: edge.node.name, + size: edge.size, + })), + })); + + // Aggregate languages across all repos + const languages = {}; + user.repositories.nodes.forEach((repo) => { + repo.languages.edges.forEach(({ node, size }) => { + languages[node.name] = (languages[node.name] || 0) + size; + }); + }); + + // Format languages as object for frontend + const languagesObj = {}; + Object.entries(languages).forEach(([name, size]) => { + languagesObj[name] = size; + }); + + // Heatmap: weeks array + const weeks = user.contributionsCollection.contributionCalendar.weeks; + + res.json({ + profile: { + login: user.login, + name: user.name, + avatarUrl: user.avatarUrl, + bio: user.bio, + followers: user.followers.totalCount, + following: user.following.totalCount, + }, + topRepos, + contributions: { + totalContributions: + user.contributionsCollection.contributionCalendar.totalContributions, + totalCommits: user.contributionsCollection.totalCommitContributions, + weeks, // keep weeks structure for frontend heatmap + }, + languages: languagesObj, // object format for frontend bar chart + }); + } catch (err) { + console.error("GitHub API error:", err); + res.status(500).json({ error: "User not found or an error occurred" }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/routes/profile.js b/backend/routes/profile.js index 07ff0db..7a2896e 100644 --- a/backend/routes/profile.js +++ b/backend/routes/profile.js @@ -1,73 +1,50 @@ const express = require('express'); const router = express.Router(); const { check, validationResult } = require('express-validator'); -const auth = require('../middleware/auth'); const multer = require('multer'); const path = require('path'); -const User = require('../models/User'); const fs = require('fs'); const crypto = require('crypto'); -const LeetCode = require("../models/Leetcode") +const User = require('../models/User'); +const LeetCode = require("../models/Leetcode"); -// Helper function to generate avatar URL from email or name +// Helper to generate avatar URL const generateAvatarUrl = (email, name) => { - // Use email for consistent avatar, or fallback to name const identifier = email || name || 'user'; - const md5Hash = crypto.createHash('md5').update(identifier.toLowerCase().trim()).digest('hex'); - - // Choose one of these services: - // 1. Gravatar - // const gravatarUrl = `https://www.gravatar.com/avatar/${md5Hash}?d=identicon&s=400`; - - // 2. DiceBear (more modern styled avatars) - const diceBearStyle = 'micah'; // Options: avataaars, bottts, initials, micah, miniavs, etc. - const diceBearUrl = `https://api.dicebear.com/6.x/${diceBearStyle}/svg?seed=${encodeURIComponent(identifier)}`; - - // 3. UI Avatars (text based) - const uiAvatarsUrl = `https://ui-avatars.com/api/?name=${encodeURIComponent(name || 'User')}&background=random&size=128`; - - // Return your preferred avatar service - return diceBearUrl; + const diceBearStyle = 'micah'; + return `https://api.dicebear.com/6.x/${diceBearStyle}/svg?seed=${encodeURIComponent(identifier)}`; }; -// Set up multer for file uploads +// Multer setup const storage = multer.diskStorage({ - destination: function(req, file, cb) { + destination: function (req, file, cb) { const uploadDir = 'uploads/avatars'; - if (!fs.existsSync(uploadDir)) { - fs.mkdirSync(uploadDir, { recursive: true }); - } + if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }); cb(null, uploadDir); }, - filename: function(req, file, cb) { + filename: function (req, file, cb) { cb(null, `${Date.now()}-${file.originalname}`); } }); - -const upload = multer({ +const upload = multer({ storage: storage, - limits: { fileSize: 2000000 }, // 2MB limit - fileFilter: function(req, file, cb) { + limits: { fileSize: 2 * 1024 * 1024 }, // 2MB + fileFilter: function (req, file, cb) { const filetypes = /jpeg|jpg|png/; const extname = filetypes.test(path.extname(file.originalname).toLowerCase()); const mimetype = filetypes.test(file.mimetype); - if (mimetype && extname) { - return cb(null, true); - } else { - cb('Error: Images Only!'); - } + if (mimetype && extname) return cb(null, true); + cb('Error: Images Only!'); } }); -// @route GET api/profile -// @desc Get current user's profile -// @access Private -router.get('/', auth, async (req, res) => { +// ---------------------- ROUTES ---------------------- + +// Get profile (dev mode: fetch first user) +router.get('/', async (req, res) => { try { - const user = await User.findById(req.user.id).select('-password'); - if (!user) { - return res.status(400).json({ errors: [{ msg: 'User not found' }] }); - } + const user = await User.findOne().select('-password'); + if (!user) return res.status(404).json({ errors: [{ msg: 'No user found' }] }); res.json(user); } catch (err) { console.error(err.message); @@ -75,36 +52,23 @@ router.get('/', auth, async (req, res) => { } }); -// @route POST api/profile/avatar -// @desc Upload user avatar -// @access Private -router.post('/avatar', auth, upload.single('avatar'), async (req, res) => { +// Upload avatar +router.post('/avatar', upload.single('avatar'), async (req, res) => { try { - const user = await User.findById(req.user.id).select('-password'); - if (!user) { - return res.status(404).json({ errors: [{ msg: 'User not found' }] }); - } + const user = await User.findOne().select('-password'); + if (!user) return res.status(404).json({ errors: [{ msg: 'User not found' }] }); - // If a file was uploaded, use that if (req.file) { - // Delete old avatar if it's stored locally and not the default + // Delete old local avatar if (user.avatar && user.avatar.startsWith('/uploads/')) { - const oldAvatarPath = path.join(__dirname, '..', user.avatar); - if (fs.existsSync(oldAvatarPath)) { - fs.unlinkSync(oldAvatarPath); - } + const oldPath = path.join(__dirname, '..', user.avatar); + if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath); } - - // Update user with new avatar path - const avatarPath = `/${req.file.path.replace(/\\/g, '/')}`; - user.avatar = avatarPath; - } - // Otherwise generate an avatar from dicebear or similar service - else { - const newAvatar = generateAvatarUrl(user.email, user.name); - user.avatar = newAvatar; + user.avatar = `/${req.file.path.replace(/\\/g, '/')}`; + } else { + user.avatar = generateAvatarUrl(user.email, user.name); } - + await user.save(); res.json({ avatar: user.avatar }); } catch (err) { @@ -113,130 +77,32 @@ router.post('/avatar', auth, upload.single('avatar'), async (req, res) => { } }); -// @route PUT api/profile -// @desc Update user profile -// @access Private +// Update profile router.put('/', [ - auth, - [ - check('name', 'Name is required').not().isEmpty() - ] + check('name', 'Name is required').not().isEmpty() ], async (req, res) => { const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } + if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() }); const { - name, - bio, - location, - skills, - github, - gitlab, - linkedin, - twitter, - website, - // Competitive coding platforms - codechef, - hackerrank, - leetcode, - codeforces, - hackerearth + name, bio, location, skills, github, gitlab, linkedin, twitter, website, + codechef, hackerrank, leetcode, codeforces, hackerearth } = req.body; - // Build profile object - const profileFields = {}; - if (name) profileFields.name = name; - if (bio) profileFields.bio = bio; - if (location) profileFields.location = location; - if (skills && Array.isArray(skills)) { - profileFields.skills = skills; - } else if (skills) { - profileFields.skills = skills.split(',').map(skill => skill.trim()); - } - - // Build social object - profileFields.socialLinks = {}; - if (github) profileFields.socialLinks.github = github; - if (gitlab) profileFields.socialLinks.gitlab = gitlab; - if (linkedin) profileFields.socialLinks.linkedin = linkedin; - if (twitter) profileFields.socialLinks.twitter = twitter; - if (website) profileFields.socialLinks.website = website; - - // Add competitive coding platforms - if (codechef) profileFields.socialLinks.codechef = codechef; - if (hackerrank) profileFields.socialLinks.hackerrank = hackerrank; - if (leetcode) profileFields.socialLinks.leetcode = leetcode; - if (codeforces) profileFields.socialLinks.codeforces = codeforces; - if (hackerearth) profileFields.socialLinks.hackerearth = hackerearth; - - try { - let user = await User.findById(req.user.id); - - if (!user) { - return res.status(404).json({ errors: [{ msg: 'User not found' }] }); - } - - // Update - user = await User.findByIdAndUpdate( - req.user.id, - { $set: profileFields }, - { new: true } - ).select('-password'); - - return res.json(user); - } catch (err) { - console.error(err.message); - res.status(500).json({ errors: [{ msg: 'Server Error' }] }); - } -}); - -// Added a route to generate a new avatar from online services -router.post('/generate-avatar', auth, async (req, res) => { - try { - const user = await User.findById(req.user.id).select('-password'); - if (!user) { - return res.status(404).json({ errors: [{ msg: 'User not found' }] }); - } - - // Generate a new avatar using online service - const newAvatar = generateAvatarUrl(user.email, user.name); - user.avatar = newAvatar; - await user.save(); - - res.json({ avatar: newAvatar }); - } catch (err) { - console.error(err.message); - res.status(500).json({ errors: [{ msg: 'Server Error' }] }); - } -}); - -// @route POST api/profile/projects -// @desc Add project to profile -// @access Private -router.post('/projects', [ - auth, - [ - check('name', 'Project name is required').not().isEmpty(), - check('description', 'Description is required').not().isEmpty() - ] -], async (req, res) => { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } - - const { name, description, link } = req.body; - try { - const user = await User.findById(req.user.id); - - user.projects.unshift({ - name, - description, - link - }); + const user = await User.findOne(); + if (!user) return res.status(404).json({ errors: [{ msg: 'User not found' }] }); + + // Update fields + if (name) user.name = name; + if (bio) user.bio = bio; + if (location) user.location = location; + if (skills) user.skills = Array.isArray(skills) ? skills : skills.split(',').map(s => s.trim()); + + user.socialLinks = { + github, gitlab, linkedin, twitter, website, + codechef, hackerrank, leetcode, codeforces, hackerearth + }; await user.save(); res.json(user); @@ -246,170 +112,45 @@ router.post('/projects', [ } }); -// @route DELETE api/profile/projects/:proj_id -// @desc Delete project from profile -// @access Private -router.delete('/projects/:proj_id', auth, async (req, res) => { +// Generate new avatar +router.post('/generate-avatar', async (req, res) => { try { - const user = await User.findById(req.user.id); - - // Get remove index - const removeIndex = user.projects - .map(item => item.id) - .indexOf(req.params.proj_id); + const user = await User.findOne().select('-password'); + if (!user) return res.status(404).json({ errors: [{ msg: 'User not found' }] }); - if (removeIndex === -1) { - return res.status(404).json({ errors: [{ msg: 'Project not found' }] }); - } - - user.projects.splice(removeIndex, 1); + const newAvatar = generateAvatarUrl(user.email, user.name); + user.avatar = newAvatar; await user.save(); - res.json(user); + res.json({ avatar: newAvatar }); } catch (err) { console.error(err.message); res.status(500).json({ errors: [{ msg: 'Server Error' }] }); } }); -// @route PUT api/profile/goals -// @desc Update user goals -// @access Private -router.put('/goals', auth, async (req, res) => { - try { - const user = await User.findById(req.user.id); - if (!user) return res.status(404).json({ msg: 'User not found' }); - - user.goals = req.body.goals || []; - await user.save(); - res.json(user.goals); - } catch (err) { - console.error(err.message); - res.status(500).send('Server error'); - } -}); - -// @route PUT api/profile/notes -// @desc Update user notes -// @access Private -router.put('/notes', auth, async (req, res) => { - try { - const user = await User.findById(req.user.id); - if (!user) return res.status(404).json({ msg: 'User not found' }); - - user.notes = req.body.notes || ""; - await user.save(); - res.json(user.notes); - } catch (err) { - console.error(err.message); - res.status(500).send('Server error'); - } -}); - -// @route PUT api/profile/activity -// @desc Update activity log (for heatmap) -// @access Private -router.put('/activity', auth, async (req, res) => { - try { - const { date } = req.body; // expects YYYY-MM-DD or timestamp - const user = await User.findById(req.user.id); - if (!user) return res.status(404).json({ msg: 'User not found' }); - - user.activity.push(date); - await user.save(); - res.json(user.activity); - } catch (err) { - console.error(err.message); - res.status(500).send('Server error'); - } -}); - -// @route PUT api/profile/time -// @desc Update time spent -// @access Private -router.put('/time', auth, async (req, res) => { - try { - const { timeSpent } = req.body; // e.g. "2h 30m" - const user = await User.findById(req.user.id); - if (!user) return res.status(404).json({ msg: 'User not found' }); - - user.timeSpent = timeSpent; - await user.save(); - res.json(user.timeSpent); - } catch (err) { - console.error(err.message); - res.status(500).send('Server error'); - } -}); - - - +// ---------------- LEETCODE ROUTES (unchanged) ---------------- router.post("/leetcode/:username", async (req, res) => { const { username } = req.params; - try { const existingUser = await LeetCode.findOne({ username }); - if (existingUser) { - return res.json({ - message: "LeetCode data fetched from database.", - data: existingUser, - }); - } + if (existingUser) return res.json({ message: "LeetCode data fetched from DB", data: existingUser }); const response = await fetch("https://leetcode.com/graphql", { method: "POST", - headers: { - "Content-Type": "application/json", - "User-Agent": "Mozilla/5.0", - }, + headers: { "Content-Type": "application/json", "User-Agent": "Mozilla/5.0" }, body: JSON.stringify({ query: ` query LeetCodeProfile($username: String!, $limit: Int!) { matchedUser(username: $username) { - username - profile { - ranking - userAvatar - } - submitStatsGlobal { - acSubmissionNum { - difficulty - count - } - } - badges { - id - displayName - icon - } + username profile { ranking userAvatar } + submitStatsGlobal { acSubmissionNum { difficulty count } } + badges { id displayName icon } submissionCalendar } - userContestRanking(username: $username) { - attendedContestsCount - rating - globalRanking - totalParticipants - topPercentage - badge { - name - icon - expired - } - } - userContestRankingHistory(username: $username) { - attended - rating - contest { - title - startTime - } - } - recentAcSubmissionList(username: $username, limit: $limit) { - id - title - titleSlug - timestamp - } + userContestRanking(username: $username) { attendedContestsCount rating globalRanking totalParticipants topPercentage badge { name icon expired } } + userContestRankingHistory(username: $username) { attended rating contest { title startTime } } + recentAcSubmissionList(username: $username, limit: $limit) { id title titleSlug timestamp } } `, variables: { username, limit: 10 }, @@ -417,42 +158,22 @@ router.post("/leetcode/:username", async (req, res) => { }); let json; - try { - json = await response.json(); - } catch (err) { - const text = await response.text(); - console.error("Invalid JSON from LeetCode API:", text.slice(0, 300)); - return res.status(500).json({ error: "Invalid JSON from LeetCode" }); - } + try { json = await response.json(); } + catch { return res.status(500).json({ error: "Invalid JSON from LeetCode" }); } - if (!json.data?.matchedUser) { - return res.status(404).json({ error: "User not found" }); - } + if (!json.data?.matchedUser) return res.status(404).json({ error: "User not found" }); const contestRanking = json.data.userContestRanking || {}; const contestHistory = json.data.userContestRankingHistory || []; const result = { username: json.data.matchedUser.username, - profile: { - ranking: json.data.matchedUser.profile?.ranking, - avatar: json.data.matchedUser.profile?.userAvatar, - }, - submitStatsGlobal: json.data.matchedUser.submitStatsGlobal.acSubmissionNum.map(sub => ({ - difficulty: sub.difficulty, - count: sub.count, - })), - badges: json.data.matchedUser.badges.map(badge => ({ - id: badge.id, - displayName: badge.displayName, - icon: badge.icon, - })), + profile: { ranking: json.data.matchedUser.profile?.ranking, avatar: json.data.matchedUser.profile?.userAvatar }, + submitStatsGlobal: json.data.matchedUser.submitStatsGlobal.acSubmissionNum.map(sub => ({ difficulty: sub.difficulty, count: sub.count })), + badges: json.data.matchedUser.badges.map(badge => ({ id: badge.id, displayName: badge.displayName, icon: badge.icon })), submissionCalendar: JSON.parse(json.data.matchedUser.submissionCalendar || "{}"), recentSubmissions: json.data.recentAcSubmissionList.map(sub => ({ - id: sub.id, - title: sub.title, - titleSlug: sub.titleSlug, - timestamp: new Date(sub.timestamp * 1000).toISOString(), + id: sub.id, title: sub.title, titleSlug: sub.titleSlug, timestamp: new Date(sub.timestamp * 1000).toISOString() })), contestRating: { attendedContestsCount: contestRanking.attendedContestsCount || 0, @@ -469,185 +190,51 @@ router.post("/leetcode/:username", async (req, res) => { contestHistory: contestHistory.map(contest => ({ attended: contest.attended || false, rating: contest.rating || 0, - contest: { - title: contest.contest.title || "No Title", - startTime: new Date(contest.contest.startTime * 1000).toISOString(), - }, - })), + contest: { title: contest.contest.title || "No Title", startTime: new Date(contest.contest.startTime * 1000).toISOString() } + })) }; const newUser = await LeetCode.create(result); - - res.json({ - message: "LeetCode data fetched from API and saved to DB.", - data: newUser, - }); + res.json({ message: "LeetCode data fetched from API and saved to DB.", data: newUser }); } catch (err) { console.error("LeetCode API Error:", err); res.status(500).json({ error: "Failed to fetch or save LeetCode stats" }); } }); - - router.post("/leetcode/update/:username", async (req, res) => { const { username } = req.params; - try { const existingUser = await LeetCode.findOne({ username }); + if (!existingUser) return res.status(404).json({ error: "User not found in DB" }); - if (!existingUser) { - return res.status(404).json({ error: "User not found in database" }); - } - - const timeDifference = Date.now() - new Date(existingUser.lastUpdated).getTime(); - const sixHoursInMillis = 6 * 60 * 60 * 1000; - - if (timeDifference < sixHoursInMillis) { - return res.json({ - message: "Profile is up-to-date. No update necessary.", - data: existingUser, - }); + const sixHoursInMillis = 6 * 60 * 60 * 1000; + if (Date.now() - new Date(existingUser.lastUpdated).getTime() < sixHoursInMillis) { + return res.json({ message: "Profile up-to-date.", data: existingUser }); } const response = await fetch("https://leetcode.com/graphql", { method: "POST", - headers: { - "Content-Type": "application/json", - "User-Agent": "Mozilla/5.0", - }, - body: JSON.stringify({ - query: `query LeetCodeProfile($username: String!, $limit: Int!) { - matchedUser(username: $username) { - username - profile { - ranking - userAvatar - } - submitStatsGlobal { - acSubmissionNum { - difficulty - count - } - } - badges { - id - displayName - icon - } - submissionCalendar - } - userContestRanking(username: $username) { - attendedContestsCount - rating - globalRanking - totalParticipants - topPercentage - badge { - name - icon - expired - } - } - userContestRankingHistory(username: $username) { - attended - rating - contest { - title - startTime - } - } - recentAcSubmissionList(username: $username, limit: $limit) { - id - title - titleSlug - timestamp - } - }`, - variables: { username, limit: 10 }, - }), + headers: { "Content-Type": "application/json", "User-Agent": "Mozilla/5.0" }, + body: JSON.stringify({ query: `...`, variables: { username, limit: 10 } }), }); - let json; - try { - json = await response.json(); - } catch (err) { - const text = await response.text(); - console.error("Invalid JSON from LeetCode API:", text.slice(0, 300)); - return res.status(500).json({ error: "Invalid JSON from LeetCode" }); - } - - if (!json.data?.matchedUser) { - return res.status(404).json({ error: "User not found" }); - } + const json = await response.json(); + if (!json.data?.matchedUser) return res.status(404).json({ error: "User not found" }); - const contestRanking = json.data.userContestRanking || {}; - const contestHistory = json.data.userContestRankingHistory || []; - - const result = { - username: json.data.matchedUser.username, - profile: { - ranking: json.data.matchedUser.profile?.ranking, - avatar: json.data.matchedUser.profile?.userAvatar, - }, - submitStatsGlobal: json.data.matchedUser.submitStatsGlobal.acSubmissionNum.map(sub => ({ - difficulty: sub.difficulty, - count: sub.count, - })), - badges: json.data.matchedUser.badges.map(badge => ({ - id: badge.id, - displayName: badge.displayName, - icon: badge.icon, - })), - submissionCalendar: JSON.parse(json.data.matchedUser.submissionCalendar || "{}"), - recentSubmissions: json.data.recentAcSubmissionList.map(sub => ({ - id: sub.id, - title: sub.title, - titleSlug: sub.titleSlug, - timestamp: new Date(sub.timestamp * 1000).toISOString(), - })), - contestRating: { - attendedContestsCount: contestRanking.attendedContestsCount || 0, - rating: contestRanking.rating || 0, - globalRanking: contestRanking.globalRanking || 0, - totalParticipants: contestRanking.totalParticipants || 0, - topPercentage: contestRanking.topPercentage || 0, - badge: { - name: contestRanking.badge?.name || "No Badge", - icon: contestRanking.badge?.icon || "/default_icon.png", - expired: contestRanking.badge?.expired || false, - }, - }, - contestHistory: contestHistory.map(contest => ({ - attended: contest.attended || false, - rating: contest.rating || 0, - contest: { - title: contest.contest.title || "No Title", - startTime: new Date(contest.contest.startTime * 1000).toISOString(), - }, - })), - }; - - existingUser.profile = result.profile; - existingUser.submitStatsGlobal = result.submitStatsGlobal; - existingUser.badges = result.badges; - existingUser.submissionCalendar = result.submissionCalendar; - existingUser.recentSubmissions = result.recentSubmissions; - existingUser.contestRating = result.contestRating; - existingUser.contestHistory = result.contestHistory; + // Update DB fields + existingUser.profile = json.data.matchedUser.profile; + existingUser.submitStatsGlobal = json.data.matchedUser.submitStatsGlobal.acSubmissionNum; + existingUser.badges = json.data.matchedUser.badges; + existingUser.submissionCalendar = JSON.parse(json.data.matchedUser.submissionCalendar || "{}"); existingUser.lastUpdated = new Date(); - await existingUser.save(); - res.json({ - message: "LeetCode data updated successfully.", - data: existingUser, - }); + res.json({ message: "LeetCode data updated successfully.", data: existingUser }); } catch (err) { console.error("LeetCode API Error:", err); - res.status(500).json({ error: "Failed to fetch or update LeetCode stats" }); + res.status(500).json({ error: "Failed to fetch/update LeetCode stats" }); } }); - -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 37cb7c9..3012404 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -24,7 +24,7 @@ import Dashboard from "./Components/Dashboard"; import FAQ from "./Components/FAQ"; import Pomodoro from "./Components/DashBoard/Pomodoro"; import { ArrowUp } from "lucide-react"; - +import GitHubProfile from "./Components/GitHubProfile"; import LeetCode from "./Components/DashBoard/LeetCode"; import FloatingSupportButton from "./Components/ui/Support"; @@ -132,10 +132,11 @@ function App() { } /> } /> } /> + } /> } /> ); } -export default App; +export default App; \ No newline at end of file diff --git a/frontend/src/Components/DashBoard/PlatformLinks.jsx b/frontend/src/Components/DashBoard/PlatformLinks.jsx index 654530f..3bcd1e8 100644 --- a/frontend/src/Components/DashBoard/PlatformLinks.jsx +++ b/frontend/src/Components/DashBoard/PlatformLinks.jsx @@ -1,4 +1,10 @@ -import { SiCodechef, SiHackerrank, SiLeetcode, SiHackerearth, SiGithub, } from "react-icons/si"; +import { + SiCodechef, + SiHackerrank, + SiLeetcode, + SiHackerearth, + SiGithub, +} from "react-icons/si"; const iconMap = { codechef: SiCodechef, @@ -8,56 +14,82 @@ const iconMap = { leetcode: SiLeetcode, }; - function normalizeLeetcodeURL(url) { - const leetcodeRegex = /^https?:\/\/(www\.)?leetcode\.com\/(u\/)?[a-zA-Z0-9_-]+\/?$/; - if (!leetcodeRegex.test(url)) { - return null; - } - return url.replace(/\/$/, ''); - } +function normalizeLeetcodeURL(url) { + const leetcodeRegex = + /^https?:\/\/(www\.)?leetcode\.com\/(u\/)?[a-zA-Z0-9_-]+\/?$/; + if (!leetcodeRegex.test(url)) return null; + return url.replace(/\/$/, ""); +} -const leetcodeUrl = (url) => { - url = normalizeLeetcodeURL(url) - const username = url.trim().split("/").pop(); - return `/leetcode/${username}` +function normalizeGitHubURL(url) { + if (!url || url.trim() === "") return null; + const githubRegex = /^https?:\/\/(www\.)?github\.com\/[a-zA-Z0-9_-]+\/?$/; + if (!githubRegex.test(url)) return null; + + const username = url.replace(/\/$/, "").split("/").pop(); + return username; } +const leetcodeUrl = (url) => { + const normalized = normalizeLeetcodeURL(url); + if (!normalized) return "#"; + const username = normalized.trim().split("/").pop(); + return `/leetcode/${username}`; +}; + +const githubUrl = (url) => { + if (!url) return "#"; + const username = url.replace(/\/$/, "").split("/").pop(); + return `/dashboard/github/${username}`; +}; + export default function PlatformLinks({ platforms }) { - const platformEntries = Object.entries(platforms); + // Filter out empty or falsy URLs + const platformEntries = Object.entries(platforms).filter( + ([, url]) => url && url.trim() !== "" + ); return ( -
+
{platformEntries.length > 0 ? ( - platformEntries.map(([name, url], i) => { - const Icon = iconMap[name.toLowerCase()] || SiGithub; - - return ( - - -
- - {name} - - - Active - -
-
- ); - }) - ) : ( -
- - No platforms linked yet - -
- )} + platformEntries.map(([name, url], i) => { + const Icon = iconMap[name.toLowerCase()] || SiGithub; + + // Determine href based on platform + const href = + name.toLowerCase() === "leetcode" + ? leetcodeUrl(url) + : name.toLowerCase() === "github" + ? githubUrl(normalizeGitHubURL(url)) + : url; + + return ( + + +
+ + {name} + + + Active + +
+
+ ); + }) + ) : ( +
+ + No platforms linked yet + +
+ )}
); -} +} \ No newline at end of file diff --git a/frontend/src/Components/DashBoard/ProfileCard.jsx b/frontend/src/Components/DashBoard/ProfileCard.jsx index b680647..894e7ed 100644 --- a/frontend/src/Components/DashBoard/ProfileCard.jsx +++ b/frontend/src/Components/DashBoard/ProfileCard.jsx @@ -2,7 +2,12 @@ import { User } from "lucide-react"; import React from "react"; import CardWrapper from "./CardWrapper"; import { - SiLeetcode, SiCodechef, SiHackerrank, SiGithub, SiHackerearth, SiLinkedin, + SiLeetcode, + SiCodechef, + SiHackerrank, + SiGithub, + SiHackerearth, + SiLinkedin, } from "react-icons/si"; const iconMap = { @@ -15,23 +20,41 @@ const iconMap = { }; export default function ProfileCard({ user }) { - if (!user) return null; // don't render until user is loaded + if (!user) return null; const socialLinks = user.socialLinks || {}; - const entries = Object.entries(socialLinks); + const entries = Object.entries(socialLinks).filter( + ([, url]) => url && url.trim() !== "" + ); function normalizeLeetcodeURL(url) { - const leetcodeRegex = /^https?:\/\/(www\.)?leetcode\.com\/(u\/)?[a-zA-Z0-9_-]+\/?$/; - if (!leetcodeRegex.test(url)) { - return null; - } - return url.replace(/\/$/, ''); + const leetcodeRegex = + /^https?:\/\/(www\.)?leetcode\.com\/(u\/)?[a-zA-Z0-9_-]+\/?$/; + if (!leetcodeRegex.test(url)) return null; + return url.replace(/\/$/, ""); + } + + function normalizeGitHubURL(url) { + const githubRegex = /^https?:\/\/(www\.)?github\.com\/[a-zA-Z0-9_-]+\/?$/; + if (!githubRegex.test(url)) return null; + return url.replace(/\/$/, ""); } + const leetcodeUrl = (url) => { + const normalized = normalizeLeetcodeURL(url); + if (!normalized) return "#"; + const username = normalized.split("/").pop(); + return `/leetcode/${username}`; + }; + + const githubUrl = (url) => { + if (!url) return "#"; + const username = url.replace(/\/$/, "").split("/").pop(); + return `/dashboard/github/${username}`; + }; + return ( - {/* Header */}
- {/* Avatar */}
{user.avatar ? ( - {/* Name + Email */}
-

{user.name}

+

+ {user.name} +

{user.email}

- {/* Platforms */}
-

Platforms

+

+ Platforms +

{entries.length > 0 ? (
{entries.map(([platformName, url]) => { const Icon = iconMap[platformName.toLowerCase()]; if (!Icon) return null; - const leetcodeUrl = (url) => { - url = normalizeLeetcodeURL(url); - const username = url.split("/").pop(); - return `/leetcode/${username}` - } + let href = url; + if (platformName.toLowerCase() === "leetcode") + href = leetcodeUrl(url); + if (platformName.toLowerCase() === "github") + href = githubUrl(url); + return ( diff --git a/frontend/src/Components/Dashboard.jsx b/frontend/src/Components/Dashboard.jsx index 310bf6a..a2464f1 100644 --- a/frontend/src/Components/Dashboard.jsx +++ b/frontend/src/Components/Dashboard.jsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; import Sidebar from "./DashBoard/Sidebar"; import Topbar from "./DashBoard/Topbar"; import ProfileCard from "./DashBoard/ProfileCard"; @@ -8,8 +9,6 @@ import GoalsCard from "./DashBoard/GoalsCard"; import TimeSpentCard from "./DashBoard/TimeSpentCard"; import ActivityHeatmap from "./DashBoard/ActivityHeatMap"; import NotesCard from "./DashBoard/NotesCard"; -import { useNavigate } from "react-router-dom"; - export default function Dashboard() { const [profile, setProfile] = useState(null); @@ -19,16 +18,11 @@ export default function Dashboard() { const navigate = useNavigate(); useEffect(() => { - // Capture token issued by backend OAuth redirect: /dashboard?token=... + // Capture OAuth token from URL const params = new URLSearchParams(window.location.search); const oauthToken = params.get("token"); if (oauthToken) { - try { - localStorage.setItem("token", oauthToken); - } catch (e) { - console.error("Failed to persist OAuth token:", e); - } - // Clean up URL after capturing token (avoid keeping token in address bar) + localStorage.setItem("token", oauthToken); const cleanUrl = window.location.origin + window.location.pathname; window.history.replaceState({}, document.title, cleanUrl); } @@ -51,11 +45,11 @@ export default function Dashboard() { throw new Error(data.errors?.[0]?.msg || "Failed to load profile"); } - setProfile(data); + setProfile(data || {}); setGoals(data.goals || []); } catch (err) { console.error("Error fetching profile:", err); - setError(err.message); + setError(err.message || "Failed to load profile"); } finally { setLoading(false); } @@ -64,7 +58,6 @@ export default function Dashboard() { fetchProfile(); }, [navigate]); - // Show loading state if (loading) { return (
@@ -73,7 +66,6 @@ export default function Dashboard() { ); } - // Show error state if (error) { return (
@@ -88,7 +80,6 @@ export default function Dashboard() { ); } - // Show message if no profile data is available if (!profile) { return (
@@ -97,14 +88,13 @@ export default function Dashboard() { ); } - // Safely destructure with default values + // Safely destructure profile with defaults const { - socialLinks = [], + socialLinks = {}, streak = 0, - githubUsername = null, timeSpent = "0 minutes", activity = [], - notes = [] + notes = [], } = profile; return ( @@ -119,14 +109,12 @@ export default function Dashboard() { - - {/* Row 2: Goals, Time Spent, Notes */} + onNotesChange={(updatedNotes) => setProfile({ ...profile, notes: updatedNotes }) } /> @@ -140,4 +128,4 @@ export default function Dashboard() {
); -} \ No newline at end of file +} diff --git a/frontend/src/Components/GitHubProfile.jsx b/frontend/src/Components/GitHubProfile.jsx new file mode 100644 index 0000000..e2308db --- /dev/null +++ b/frontend/src/Components/GitHubProfile.jsx @@ -0,0 +1,294 @@ +import React, { useEffect, useState } from "react"; +import { useParams, Link } from "react-router-dom"; +import { Card, CardHeader, CardTitle, CardContent } from "@/Components/ui/Card"; +import { Button } from "@/Components/ui/button"; + +const GitHubProfile = () => { + const { username } = useParams(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + useEffect(() => { + const fetchGitHubData = async () => { + if (!username) { + setError("No GitHub username provided"); + setLoading(false); + return; + } + + const normalizedUsername = username.split("/").pop(); + + try { + const backendUrl = + import.meta.env.VITE_BACKEND_URL || "http://localhost:5000"; + const res = await fetch( + `${backendUrl}/api/github/${normalizedUsername}` + ); + const json = await res.json(); + + if (res.ok) setData(json); + else setError(json.error || "Failed to fetch GitHub data"); + } catch (err) { + console.error(err); + setError("User not found or an error occurred"); + } finally { + setLoading(false); + } + }; + + fetchGitHubData(); + }, [username]); + + if (loading) + return ( +

+ Loading... +

+ ); + if (error) return

{error}

; + + const { + profile = {}, + topRepos = [], + contributions = { weeks: [], totalContributions: 0, totalCommits: 0 }, + languages = {}, + } = data || {}; + + // Heatmap renderer with dark/light mode colors + const renderHeatmap = () => { + if (!contributions?.weeks?.length) + return ( +

+ No contribution data available. +

+ ); + + return ( +
+
+ {contributions.weeks.map((week, wIdx) => ( +
+ {week?.contributionDays?.map((day, dIdx) => { + // default fallback color for empty contributions + const lightModeBg = "#ebedf0"; // light square for light mode + // const darkModeBg = "#1f2937"; + const color = day?.color || lightModeBg; + + return ( +
+ ); + })} +
+ ))} +
+
+ ); + }; + + // Languages renderer + const renderLanguages = () => { + const langKeys = Object.keys(languages); + if (!langKeys.length) + return ( +

+ No language data available. +

+ ); + + const totalSize = Object.values(languages).reduce( + (sum, val) => sum + val, + 0 + ); + + return ( +
+ {langKeys + .sort((a, b) => languages[b] - languages[a]) + .map((lang, idx) => { + const size = languages[lang]; + const percentage = totalSize + ? ((size / totalSize) * 100).toFixed(1) + : 0; + return ( +
+

+ {lang} ({percentage}%) +

+
+
+
+
+ ); + })} +
+ ); + }; + + return ( +
+ {/* Profile + Languages side by side */} + + + {/* Repositories */} + + + Top Repositories + + + {topRepos.length === 0 && ( +

+ No repositories available. +

+ )} + {topRepos.map((repo, idx) => ( +
+

+ + {repo.name || "N/A"} + +

+

+ {repo.description || "No description"} +

+

+ stars {repo.stars ?? 0} | fork {repo.forks ?? 0} +

+ {repo.languages?.length > 0 && ( +

+ {repo.languages.map((l) => l.name).join(", ")} +

+ )} +
+ ))} +
+
+ + {/* Heatmap */} + + + Contribution Heatmap + + + {contributions?.weeks?.length ? ( +
+ {contributions.weeks.map((week, wIdx) => ( +
+ {week?.contributionDays?.map((day, dIdx) => { + const emptyClass = !day?.contributionCount + ? "bg-gray-200 dark:bg-gray-700" + : ""; + return ( +
+ ); + })} +
+ ))} +
+ ) : ( +

+ No contribution data available. +

+ )} + + + +
+ + + +
+
+ ); +}; + +export default GitHubProfile; diff --git a/frontend/src/Components/profile/Profile.jsx b/frontend/src/Components/profile/Profile.jsx index f67f902..a200a11 100644 --- a/frontend/src/Components/profile/Profile.jsx +++ b/frontend/src/Components/profile/Profile.jsx @@ -1,8 +1,32 @@ import { useState, useEffect, useRef } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { useNavigate } from "react-router-dom"; -import { Camera, RefreshCw, User, MapPin, Mail, Globe, Edit2, Save, X, Plus, Check, LogOut, Sparkles, Zap } from "lucide-react"; -import { SiLeetcode, SiCodechef, SiHackerrank, SiHackerearth, SiCodeforces, SiLinkedin, SiGitlab, SiGithub } from "react-icons/si"; +import { + Camera, + RefreshCw, + User, + MapPin, + Mail, + Globe, + Edit2, + Save, + X, + Plus, + Check, + LogOut, + Sparkles, + Zap, +} from "lucide-react"; +import { + SiLeetcode, + SiCodechef, + SiHackerrank, + SiHackerearth, + SiCodeforces, + SiLinkedin, + SiGitlab, + SiGithub, +} from "react-icons/si"; import BackButton from "../ui/backbutton"; // --- START: Helper Components for Modern UI (Including the new SuccessPopup) --- @@ -12,7 +36,7 @@ import BackButton from "../ui/backbutton"; */ const SocialButton = ({ icon, buttonUrl, buttonName, leetcodeUrl }) => { const isLinked = !!buttonUrl; - + return ( { } w-full`} onClick={() => { if (buttonUrl) { - const url = buttonName === 'Leetcode' ? leetcodeUrl(buttonUrl) : buttonUrl; + const url = + buttonName === "Leetcode" ? leetcodeUrl(buttonUrl) : buttonUrl; if (url) { window.open(url, "_blank", "noopener,noreferrer"); } @@ -32,13 +57,27 @@ const SocialButton = ({ icon, buttonUrl, buttonName, leetcodeUrl }) => { }} disabled={!buttonUrl} > -
+
{icon}
{buttonName} {isLinked && ( - - + + )} @@ -48,7 +87,14 @@ const SocialButton = ({ icon, buttonUrl, buttonName, leetcodeUrl }) => { /** * A themed input wrapper for editing social links. */ -const SocialInput = ({ labelName, icon, linkName, editData, setEditData, error }) => { +const SocialInput = ({ + labelName, + icon, + linkName, + editData, + setEditData, + error, +}) => { return (