diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 7c2cda78..9c974f79 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -9,7 +9,7 @@ const router = express.Router(); router.post("/signup", validateRequest(signupSchema), async (req, res) => { const { username, email, password } = req.body; - + try { const existingUser = await User.findOne({ $or: [{ email }, { username }], diff --git a/package.json b/package.json index 43ad31cc..413a84ae 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "postcss": "^8.4.47", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-github-calendar": "^5.0.6", "react-hot-toast": "^2.4.1", "react-icons": "^5.3.0", "react-router-dom": "^6.28.0", diff --git a/src/App.css b/src/App.css index b9d355df..1d145ec2 100644 --- a/src/App.css +++ b/src/App.css @@ -11,9 +11,11 @@ will-change: filter; transition: filter 300ms; } + .logo:hover { filter: drop-shadow(0 0 2em #646cffaa); } + .logo.react:hover { filter: drop-shadow(0 0 2em #61dafbaa); } @@ -22,6 +24,7 @@ from { transform: rotate(0deg); } + to { transform: rotate(360deg); } @@ -40,3 +43,11 @@ .read-the-docs { color: #888; } + +.calendar-container svg text { + fill: #1f2937 !important; +} + +.dark .calendar-container svg text { + fill: #d1d5db !important; +} \ No newline at end of file diff --git a/src/Routes/Router.tsx b/src/Routes/Router.tsx index 874ef7e7..f8ccf5d7 100644 --- a/src/Routes/Router.tsx +++ b/src/Routes/Router.tsx @@ -7,7 +7,9 @@ import Signup from "../pages/Signup/Signup.tsx"; import Login from "../pages/Login/Login.tsx"; import ContributorProfile from "../pages/ContributorProfile/ContributorProfile.tsx"; import Home from "../pages/Home/Home.tsx"; -import Activity from "../pages/Activity.tsx"; +import ProfilePage from "../pages/Profile/ProfilePage.tsx"; +import EditProfilePage from "../pages/Profile/EditProfilePage.tsx"; +import Activity from "../pages/Activity.tsx"; import PrivacyPolicy from "../pages/Privacy/PrivacyPolicy.tsx"; // ✅ Updated import path to match your new folder structure const Router = () => { @@ -21,6 +23,8 @@ const Router = () => { } /> } /> } /> + }> + }> } /> {/* Privacy Policy page route */} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index fd5eac86..f4a19bd4 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -1,6 +1,9 @@ import { NavLink, Link } from "react-router-dom"; import { useState, useContext } from "react"; import { ThemeContext } from "../context/ThemeContext"; +import ProfileDropDown from "./Profile/ProfileDropDown"; +import { logoutUser } from "../services/auth"; + import { Moon, Sun, Menu, X, Github } from "lucide-react"; const Navbar: React.FC = () => { @@ -11,12 +14,21 @@ const Navbar: React.FC = () => { if (!themeContext) return null; const { toggleTheme, mode } = themeContext; + const storedUser = localStorage.getItem("user"); + let user = null; + + try { + user = storedUser ? JSON.parse(storedUser) : null; + } catch (error) { + console.error("Invalid user data in local Storage"); + localStorage.removeItem("user"); + user = null; + } const navLinkStyles = ({ isActive }: { isActive: boolean }) => - `px-4 py-2 rounded-xl text-sm lg:text-base font-semibold transition-all duration-300 ${ - isActive - ? "text-blue-600 bg-blue-100 dark:bg-blue-900/40 shadow-sm" - : "text-slate-700 dark:text-gray-300 hover:text-blue-500" + `px-4 py-2 rounded-xl text-sm lg:text-base font-semibold transition-all duration-300 ${isActive + ? "text-blue-600 bg-blue-100 dark:bg-blue-900/40 shadow-sm" + : "text-slate-700 dark:text-gray-300 hover:text-blue-500" }`; const closeMenu = () => setIsOpen(false); @@ -52,11 +64,14 @@ const Navbar: React.FC = () => { Contributors - - + {!user && ( Login - + )} + {user && } {/* Theme Toggle */} + + )} - { + toggleTheme(); + setIsOpen(false); + }} + className="text-sm font-semibold px-3 py-1 rounded border border-gray-500 hover:text-gray-300 hover:border-gray-300 transition duration-200 w-full text-left" > - Login - + {mode === "dark" ? "🌞 Light" : "🌙 Dark"} + )} diff --git a/src/components/Profile/AchievementCard.tsx b/src/components/Profile/AchievementCard.tsx new file mode 100644 index 00000000..d27e79c8 --- /dev/null +++ b/src/components/Profile/AchievementCard.tsx @@ -0,0 +1,24 @@ +interface AchievementProps { + title: string + description: string +} + +const AchievementCard = ({ + title, + description +}: AchievementProps) => { + return ( +
+ +

+ {title} +

+ +

+ {description} +

+
+ ) +} + +export default AchievementCard \ No newline at end of file diff --git a/src/components/Profile/ContributionHeatmap.tsx b/src/components/Profile/ContributionHeatmap.tsx new file mode 100644 index 00000000..b2929b6a --- /dev/null +++ b/src/components/Profile/ContributionHeatmap.tsx @@ -0,0 +1,69 @@ +import { GitHubCalendar } from "react-github-calendar"; + +interface UsernameProps { + username: string; +} + +const calendarTheme = { + light: [ + "#ebedf0", + "#9be9a8", + "#40c463", + "#30a14e", + "#216e39", + ], + dark: [ + "#161b22", + "#0e4429", + "#006d32", + "#26a641", + "#39d353", + ], +}; + +const ContributionHeatmap = ({ + username +}: UsernameProps) => { + + if (!username) return null; + + const themeMode = + typeof window !== "undefined" + ? localStorage.getItem("theme") + : "light"; + + return ( + +
+ +

+ + Contributions + +

+ +
+ + + +
+ +
+ + ); +}; + +export default ContributionHeatmap; \ No newline at end of file diff --git a/src/components/Profile/LanguageChart.tsx b/src/components/Profile/LanguageChart.tsx new file mode 100644 index 00000000..c3e6bc0f --- /dev/null +++ b/src/components/Profile/LanguageChart.tsx @@ -0,0 +1,62 @@ +interface LanguageChartProps { + languages: Record; +} + +const LanguageChart = ({ languages }: LanguageChartProps) => { + const total = Object.values(languages).reduce((sum, count) => sum + count, 0); + return ( +
+ +

+ Languages Used +

+
+ + {Object.entries(languages) + .map(([language, count]) => { + + const percentage = + total > 0 + ? Math.round((count / total) * 100) + : 0; + + return ( + +
+ +
+ + + {language} + + + + {percentage}% + + +
+ +
+ +
+ +
+ +
+ + ); + + })} + +
+
+ ) +} + +export default LanguageChart \ No newline at end of file diff --git a/src/components/Profile/ProfileDropDown.tsx b/src/components/Profile/ProfileDropDown.tsx new file mode 100644 index 00000000..3f14aa04 --- /dev/null +++ b/src/components/Profile/ProfileDropDown.tsx @@ -0,0 +1,142 @@ +import { useEffect, useRef, useState } from "react"; +import { LogOut, Settings, User } from "lucide-react"; +import { Link } from "react-router-dom"; +import { logoutUser } from "../../services/auth"; +type UserType = { + id: string, + username: string, + email: string, +} + +interface UserProps { + user: UserType, +} +const ProfileDropDown = ({ user }: UserProps) => { + const [open, setOpen] = useState(false); + const dropdownRef = useRef(null); + const [avatar, setAvatar] = useState(""); + + useEffect(() => { + + try { + const dashboard = + JSON.parse( + localStorage.getItem( + "githubDashboard" + ) || "{}" + ); + setAvatar( + dashboard.profile?.avatar_url || "" + ); + } catch (error) { + + console.error( + "Failed to parse githubDashboard", + error + ); + setAvatar(""); + } + }, []); + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + return ( +
+ + {/* Profile Button */} + + + {/* Dropdown */} + {open && ( +
+ + {/* Top Section */} +
+
+ profile + +
+

+ {/* Deep Patel */} + {user.username} +

+ +

+ {/* deep@gmail.com */} + {user.email} +

+
+
+
+ + {/* Menu Items */} +
+ + setOpen(false)} + > + + My Profile + + + setOpen(false)} + > + + Edit Profile + + + +
+
+ )} +
+ ); +}; + +export default ProfileDropDown; \ No newline at end of file diff --git a/src/components/Profile/ProfileHeader.tsx b/src/components/Profile/ProfileHeader.tsx new file mode 100644 index 00000000..62c1b224 --- /dev/null +++ b/src/components/Profile/ProfileHeader.tsx @@ -0,0 +1,25 @@ +import { useNavigate } from "react-router-dom" + +const ProfileHeader = () => { + const navigate = useNavigate(); + return ( +
+ +

+ Track Your GitHub Journey +

+ +

+ Analyze repositories, commits and coding activity +

+ + +
+ ) +} + +export default ProfileHeader \ No newline at end of file diff --git a/src/components/Profile/ProfileSidebar.tsx b/src/components/Profile/ProfileSidebar.tsx new file mode 100644 index 00000000..b9dece10 --- /dev/null +++ b/src/components/Profile/ProfileSidebar.tsx @@ -0,0 +1,51 @@ +import { LocationIcon } from "@primer/octicons-react" +import { Files, User2Icon, Users } from "lucide-react" + +interface ProfileType { + name: string + bio: string + location: string + followers: number + imageUrl: string +} + +interface ProfileProps { + profile: ProfileType +} + +const ProfileSidebar = ({ profile }: ProfileProps) => { + return ( +
+ +
+ + {/* */} + User Avatar + +

+ {profile.name} +

+ +

+ {profile.bio} +

+
+ +
+ + +

{profile.location}

+
+ + + +

{profile.followers} followers

+
+
+
+ ) +} + +export default ProfileSidebar \ No newline at end of file diff --git a/src/components/Profile/RecentActivity.tsx b/src/components/Profile/RecentActivity.tsx new file mode 100644 index 00000000..94dfe0f8 --- /dev/null +++ b/src/components/Profile/RecentActivity.tsx @@ -0,0 +1,145 @@ +import { useState } from "react"; +interface ActivityItem { + id: string; + type: string; + repo: { + name: string; + }; + created_at: string; + payload: any; +} + +interface RecentActivityProps { + activities: ActivityItem[]; +} + +const RecentActivity = ({ + activities +}: RecentActivityProps) => { + + const [showAll, setShowAll] = useState(false); + const formatActivity = ( + activity: ActivityItem + ) => { + + switch (activity.type) { + + case "PushEvent": + return "Pushed commits"; + + case "PullRequestEvent": + return "Opened a pull request"; + + case "IssueCommentEvent": + return "Commented on an issue"; + + case "WatchEvent": + return "Starred a repository"; + + case "ForkEvent": + return "Forked a repository"; + + case "CreateEvent": + return "Created a repository"; + + default: + return activity.type; + } + }; + + const visibleActivities = showAll ? activities : activities.slice(0, 3); + + return ( + +
+ +

+ Recent Activity +

+ +
+ + {visibleActivities.map((activity) => ( + +
+ +
+ +
+ +

+ + {formatActivity(activity)} + +

+ +

+ + {activity.repo.name} + +

+ +
+ + + + { + new Date( + activity.created_at + ).toLocaleDateString() + } + + + +
+ +
+ + ))} + + { + activities.length > 3 && ( + + + ) + } + +
+ +
+ ); +}; + +export default RecentActivity; \ No newline at end of file diff --git a/src/components/Profile/RepoStats.tsx b/src/components/Profile/RepoStats.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/components/Profile/RepositoryList.tsx b/src/components/Profile/RepositoryList.tsx new file mode 100644 index 00000000..757f81c1 --- /dev/null +++ b/src/components/Profile/RepositoryList.tsx @@ -0,0 +1,54 @@ +import { Github } from "lucide-react" + +interface RepositoryItem { + name: string; + html_url: string; +} + +interface RepositoryListProps { + repositories: RepositoryItem[]; +} + +const RepositoryList = ({ repositories }: RepositoryListProps) => { + return ( +
+ +

+ Top Repositories +

+ +
+ + {repositories.map((repo, index) => ( +
+ +

+ {repo.name} +

+ + + + + + GitHub URL + + + + +
+ ))} +
+
+ ) +} + +export default RepositoryList \ No newline at end of file diff --git a/src/components/Profile/StatsCard.tsx b/src/components/Profile/StatsCard.tsx new file mode 100644 index 00000000..5c2b3951 --- /dev/null +++ b/src/components/Profile/StatsCard.tsx @@ -0,0 +1,25 @@ +interface StatsCardProps { + title: string + value: number +} + +const StatsCard = ({ + title, + value +}: StatsCardProps) => { + return ( +
+ +

+ {title} +

+ +

+ {value} +

+
+ ) +} + +export default StatsCard \ No newline at end of file diff --git a/src/hooks/useGithubActivity.ts b/src/hooks/useGithubActivity.ts new file mode 100644 index 00000000..d7feda1c --- /dev/null +++ b/src/hooks/useGithubActivity.ts @@ -0,0 +1,41 @@ +import { useState } from "react"; + +export const useGitHubActivity = ( + getOctokit: () => any +) => { + + const [activities, setActivities] = + useState([]); + + const fetchActivity = async ( + username: string + ) => { + const octokit = getOctokit(); + if (!octokit) { + console.error("Octokit client unavailable"); + setActivities([]); + return; + } + try { + const response = + await octokit.request( + "GET /users/{username}/events", + { + username, + per_page: 10, + } + ); + setActivities(response.data); + } catch (error) { + console.error( + "Failed to fetch GitHub activity", + error + ); + setActivities([]); + } + }; + return { + activities, + fetchActivity, + }; +}; \ No newline at end of file diff --git a/src/hooks/useGithubRepos.ts b/src/hooks/useGithubRepos.ts new file mode 100644 index 00000000..6f04c607 --- /dev/null +++ b/src/hooks/useGithubRepos.ts @@ -0,0 +1,125 @@ +import { useState, useCallback } from "react"; + +export const useGitHubRepositories = ( + getOctokit: () => any +) => { + + const [repos, setRepos] = useState([]); + + const [totalStars, setTotalStars] = + useState(0); + + const [totalForks, setTotalForks] = + useState(0); + + const [topRepositories, setTopRepositories] = + useState([]); + + const [languages, setLanguages] = + useState>({}); + + const [loading, setLoading] = + useState(false); + + const [error, setError] = + useState(""); + + const fetchRepositories = useCallback( + async (username: string) => { + + const octokit = getOctokit(); + + if (!octokit || !username) return; + + setLoading(true); + setError(""); + + try { + + const repositories = + await octokit.paginate( + "GET /users/{username}/repos", + { + username, + per_page: 100, + sort: "updated", + } + ); + + setRepos(repositories); + + // TOTAL STARS + const stars = + repositories.reduce( + (sum: number, repo: any) => + sum + repo.stargazers_count, + 0 + ); + + setTotalStars(stars); + + // TOTAL FORKS + const forks = + repositories.reduce( + (sum: number, repo: any) => + sum + repo.forks_count, + 0 + ); + + setTotalForks(forks); + + // TOP REPOSITORIES + const topRepos = + [...repositories] + .sort( + (a: any, b: any) => + b.stargazers_count - + a.stargazers_count + ) + .slice(0, 5); + + setTopRepositories(topRepos); + + // LANGUAGES + const languageMap: + Record = {}; + + repositories.forEach((repo: any) => { + + if (!repo.language) return; + + languageMap[repo.language] = + (languageMap[repo.language] || 0) + 1; + + }); + + setLanguages(languageMap); + + } catch (err: any) { + + setError( + err.message || + "Failed to fetch repositories" + ); + + } finally { + + setLoading(false); + + } + + }, + [getOctokit] + ); + + return { + repos, + totalStars, + totalForks, + topRepositories, + languages, + loading, + error, + fetchRepositories, + }; +}; \ No newline at end of file diff --git a/src/hooks/useProfileData.ts b/src/hooks/useProfileData.ts new file mode 100644 index 00000000..f9954f35 --- /dev/null +++ b/src/hooks/useProfileData.ts @@ -0,0 +1,62 @@ +import { useState, useCallback } from "react"; + +interface GitHubProfile { + login: string; + name: string; + avatar_url: string; + bio: string; + followers: number; + following: number; + public_repos: number; + location?: string; + company?: string; + blog?: string; + created_at: string; +} + +export const useGitHubProfile = ( + getOctokit: () => any +) => { + const [profile, setProfile] = + useState(null); + + const [loading, setLoading] = + useState(false); + + const [error, setError] = + useState(""); + + const fetchProfile = useCallback(async ( + username?: string + ) => { + const octokit = getOctokit(); + if (!octokit) return; + setLoading(true); + setError(""); + try { + const response = username + ? await octokit.request( + "GET /users/{username}", + { username } + ) + : await octokit.request( + "GET /user" + ); + setProfile(response.data); + } catch (err: any) { + setError( + err.message || + "Failed to fetch profile" + ); + } finally { + setLoading(false); + } + }, [getOctokit]); + + return { + profile, + loading, + error, + fetchProfile, + }; +}; \ No newline at end of file diff --git a/src/pages/Login/Login.tsx b/src/pages/Login/Login.tsx index 92b7073e..945d7cfe 100644 --- a/src/pages/Login/Login.tsx +++ b/src/pages/Login/Login.tsx @@ -34,6 +34,16 @@ const Login: React.FC = () => { setMessage(response.data.message); if (response.data.message === 'Login successful') { + try { + localStorage.setItem( + "user", + JSON.stringify(response.data.user) + ) + } catch (error) { + console.error("Failed to save user:", error) + } + + navigate("/home") navigate("/"); } } catch (error: unknown) { @@ -49,11 +59,10 @@ const Login: React.FC = () => { return (
{/* Animated background elements */}
@@ -70,11 +79,10 @@ const Login: React.FC = () => { Logo
-

+

GitHubTracker

@@ -98,11 +106,10 @@ const Login: React.FC = () => { onChange={handleChange} autoComplete="username" required - className={`w-full pl-4 pr-4 py-4 rounded-2xl focus:outline-none transition-all ${ - mode === "dark" - ? "bg-white/5 border border-white/10 text-white placeholder-slate-400 focus:ring-2 focus:ring-purple-500" - : "bg-gray-100 border border-gray-300 text-gray-900 placeholder-gray-500 focus:ring-2 focus:ring-purple-400" - }`} + className={`w-full pl-4 pr-4 py-4 rounded-2xl focus:outline-none transition-all ${mode === "dark" + ? "bg-white/5 border border-white/10 text-white placeholder-slate-400 focus:ring-2 focus:ring-purple-500" + : "bg-gray-100 border border-gray-300 text-gray-900 placeholder-gray-500 focus:ring-2 focus:ring-purple-400" + }`} />

@@ -115,11 +122,10 @@ const Login: React.FC = () => { value={formData.password} onChange={handleChange} required - className={`w-full pl-4 pr-4 py-4 rounded-2xl focus:outline-none transition-all ${ - mode === "dark" - ? "bg-white/5 border border-white/10 text-white placeholder-slate-400 focus:ring-2 focus:ring-purple-500" - : "bg-gray-100 border border-gray-300 text-gray-900 placeholder-gray-500 focus:ring-2 focus:ring-purple-400" - }`} + className={`w-full pl-4 pr-4 py-4 rounded-2xl focus:outline-none transition-all ${mode === "dark" + ? "bg-white/5 border border-white/10 text-white placeholder-slate-400 focus:ring-2 focus:ring-purple-500" + : "bg-gray-100 border border-gray-300 text-gray-900 placeholder-gray-500 focus:ring-2 focus:ring-purple-400" + }`} />
@@ -134,11 +140,10 @@ const Login: React.FC = () => { {/* Message */} {message && ( -
+
{message}
)} diff --git a/src/pages/Profile/EditProfilePage.tsx b/src/pages/Profile/EditProfilePage.tsx new file mode 100644 index 00000000..10796710 --- /dev/null +++ b/src/pages/Profile/EditProfilePage.tsx @@ -0,0 +1,371 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import ProfileSidebar from "../../components/Profile/ProfileSidebar"; + +const safeParseDashboard = () => { + try { + return JSON.parse( + localStorage.getItem( + "githubDashboard" + ) || "{}" + ); + } catch (error) { + console.error( + "Failed to parse githubDashboard", + error + ); + return {}; + } +}; + +const EditProfile = () => { + + const navigate = useNavigate(); + + const [formData, setFormData] = useState({ + username: "", + name: "", + bio: "", + location: "", + website: "", + imageUrl: "", + followers: 0, + repositories: 0, + }); + + useEffect(() => { + + const dashboard = safeParseDashboard(); + + if (dashboard.profile) { + + setFormData({ + username: dashboard.profile.login || "", + name: dashboard.profile.name || "", + bio: dashboard.profile.bio || "", + location: dashboard.profile.location || "", + website: dashboard.profile.blog || "", + imageUrl: dashboard.profile.avatar_url || "", + followers: dashboard.profile.followers || 0, + repositories: dashboard.profile.public_repos || 0, + }); + + } + + }, []); + + const handleChange = ( + e: React.ChangeEvent< + HTMLInputElement | + HTMLTextAreaElement + > + ) => { + + setFormData({ + ...formData, + [e.target.name]: e.target.value + }); + + }; + + const handleSave = () => { + + const dashboard = safeParseDashboard(); + + dashboard.profile = { + ...dashboard.profile, + name: formData.name, + bio: formData.bio, + location: formData.location, + blog: formData.website, + avatar_url: formData.imageUrl, + }; + + localStorage.setItem( + "githubDashboard", + JSON.stringify(dashboard) + ); + + navigate("/me"); + + }; + + return ( + +
+ +
+ + {/* Preview Section */} +
+ + + +
+ + {/* Edit Form */} +
+ +
+ +

+ Edit Profile +

+ +
+ + {/* Name */} +
+ + + + + +
+ + {/* Bio */} +
+ + + +