From dc7f73ed4feff7197f5d33b36891031c45f5cc8a Mon Sep 17 00:00:00 2001 From: ashish-choudhari-git Date: Sun, 27 Jul 2025 16:58:50 +0530 Subject: [PATCH 1/2] Github Profile Data analysis- rankings, highlights and more --- .env.example | 1 + .github/ISSUE_TEMPLATE/bug_report.yml | 50 ++ .github/ISSUE_TEMPLATE/feature_request.yml | 31 ++ .github/pull_request_template.md | 27 ++ .github/workflows/auto-label-gssoc.yml | 28 ++ .gitignore | 74 +++ CODE_OF_CODUCT.md | 29 ++ CONTRIBUTING.md | 130 +++++ LICENSE | 21 + README.md | 159 ++++++ backend/.env.example | 3 + backend/config/passportConfig.js | 45 ++ backend/middlewares/authenticateGitHub.js | 15 + backend/models/User.js | 44 ++ backend/package.json | 29 ++ backend/routes/auth.js | 42 ++ backend/routes/details.js | 272 +++++++++++ backend/server.js | 43 ++ eslint.config.js | 28 ++ index.html | 13 + package.json | 52 ++ postcss.config.cjs | 6 + public/crl-icon.png | Bin 0 -> 38255 bytes public/crl.png | Bin 0 -> 6501 bytes public/vite.svg | 1 + spec/auth.routes.spec.cjs | 96 ++++ spec/support/jasmine.mjs | 15 + spec/user.model.spec.cjs | 50 ++ src/App.css | 42 ++ src/App.tsx | 50 ++ src/Routes/Router.tsx | 29 ++ src/assets/react.svg | 1 + src/components/Footer.tsx | 39 ++ src/components/Navbar.tsx | 115 +++++ src/components/ScrollProgressBar.tsx | 84 ++++ .../UserAnalyticsComp/ContributionStats.tsx | 50 ++ .../UserAnalyticsComp/LanguageStats.tsx | 55 +++ .../UserAnalyticsComp/RepositoryTable.tsx | 82 ++++ src/components/UserAnalyticsComp/UserFrom.tsx | 64 +++ .../UserAnalyticsComp/UserProfile.tsx | 51 ++ .../UserAnalyticsComp/UserStats.tsx | 58 +++ src/hooks/useGitHubAuth.ts | 23 + src/hooks/useGitHubData.ts | 74 +++ src/hooks/usePagination.ts | 21 + src/index.css | 21 + src/main.tsx | 13 + src/pages/About/About.tsx | 55 +++ src/pages/Contact/Contact.tsx | 211 ++++++++ src/pages/Contributors/Contributors.tsx | 173 +++++++ src/pages/Home/Home.tsx | 451 ++++++++++++++++++ src/pages/Login/Login.tsx | 161 +++++++ src/pages/Signup/Signup.tsx | 163 +++++++ src/pages/UserAnalytics/UserAnalytics.tsx | 131 +++++ .../UserAnalyticsComp/ContributionStats.tsx | 210 ++++++++ .../UserAnalyticsComp/LanguageStats.tsx | 209 ++++++++ .../UserAnalyticsComp/RepositoryTable.tsx | 321 +++++++++++++ .../components/UserAnalyticsComp/UserForm.tsx | 181 +++++++ .../UserAnalyticsComp/UserProfile.tsx | 142 ++++++ .../UserAnalyticsComp/UserStats.tsx | 142 ++++++ src/pages/UserProfile/UserProfile.tsx | 57 +++ src/vite-env.d.ts | 11 + tailwind.config.js | 11 + tsconfig.app.json | 26 + tsconfig.json | 7 + tsconfig.node.json | 24 + vite.config.ts | 7 + 66 files changed, 4869 insertions(+) create mode 100644 .env.example create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/auto-label-gssoc.yml create mode 100644 .gitignore create mode 100644 CODE_OF_CODUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 backend/.env.example create mode 100644 backend/config/passportConfig.js create mode 100644 backend/middlewares/authenticateGitHub.js create mode 100644 backend/models/User.js create mode 100644 backend/package.json create mode 100644 backend/routes/auth.js create mode 100644 backend/routes/details.js create mode 100644 backend/server.js create mode 100644 eslint.config.js create mode 100644 index.html create mode 100644 package.json create mode 100644 postcss.config.cjs create mode 100644 public/crl-icon.png create mode 100644 public/crl.png create mode 100644 public/vite.svg create mode 100644 spec/auth.routes.spec.cjs create mode 100644 spec/support/jasmine.mjs create mode 100644 spec/user.model.spec.cjs create mode 100644 src/App.css create mode 100644 src/App.tsx create mode 100644 src/Routes/Router.tsx create mode 100644 src/assets/react.svg create mode 100644 src/components/Footer.tsx create mode 100644 src/components/Navbar.tsx create mode 100644 src/components/ScrollProgressBar.tsx create mode 100644 src/components/UserAnalyticsComp/ContributionStats.tsx create mode 100644 src/components/UserAnalyticsComp/LanguageStats.tsx create mode 100644 src/components/UserAnalyticsComp/RepositoryTable.tsx create mode 100644 src/components/UserAnalyticsComp/UserFrom.tsx create mode 100644 src/components/UserAnalyticsComp/UserProfile.tsx create mode 100644 src/components/UserAnalyticsComp/UserStats.tsx create mode 100644 src/hooks/useGitHubAuth.ts create mode 100644 src/hooks/useGitHubData.ts create mode 100644 src/hooks/usePagination.ts create mode 100644 src/index.css create mode 100644 src/main.tsx create mode 100644 src/pages/About/About.tsx create mode 100644 src/pages/Contact/Contact.tsx create mode 100644 src/pages/Contributors/Contributors.tsx create mode 100644 src/pages/Home/Home.tsx create mode 100644 src/pages/Login/Login.tsx create mode 100644 src/pages/Signup/Signup.tsx create mode 100644 src/pages/UserAnalytics/UserAnalytics.tsx create mode 100644 src/pages/UserAnalytics/components/UserAnalyticsComp/ContributionStats.tsx create mode 100644 src/pages/UserAnalytics/components/UserAnalyticsComp/LanguageStats.tsx create mode 100644 src/pages/UserAnalytics/components/UserAnalyticsComp/RepositoryTable.tsx create mode 100644 src/pages/UserAnalytics/components/UserAnalyticsComp/UserForm.tsx create mode 100644 src/pages/UserAnalytics/components/UserAnalyticsComp/UserProfile.tsx create mode 100644 src/pages/UserAnalytics/components/UserAnalyticsComp/UserStats.tsx create mode 100644 src/pages/UserProfile/UserProfile.tsx create mode 100644 src/vite-env.d.ts create mode 100644 tailwind.config.js create mode 100644 tsconfig.app.json create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bbf3c1e --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +VITE_BACKEND_URL=http://localhost:5000 \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..11ecc8b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,50 @@ +name: "πŸ› Bug Report" +description: "Submit a bug report to help us improve" +title: "πŸ› Bug Report: " +labels: ["type: bug"] +body: + - type: markdown + attributes: + value: We value your time and your efforts to submit this bug report is appreciated. πŸ™ + + - type: textarea + id: description + validations: + required: true + attributes: + label: "πŸ“œ Description" + description: "A clear and concise description of what the bug is." + placeholder: "It bugs out when ..." + + - type: dropdown + id: operating-system + attributes: + label: "πŸ’» Operating system" + description: "What OS is your app running on?" + options: + - Linux + - MacOS + - Windows + - Something else + validations: + required: true + + - type: dropdown + id: browsers + attributes: + label: What browsers are you seeing the problem on? + multiple: true + options: + - Firefox + - Chrome + - Safari + - Microsoft Edge + - Something else + + - type: textarea + id: screenshots + validations: + required: false + attributes: + label: "πŸ“ƒ Relevant Screenshots (Links)" + description: "Screenshot" diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..e417ebf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,31 @@ +name: πŸš€ Feature +description: "Submit a proposal for a new feature" +title: "πŸš€ Feature: " +labels: [feature] +body: + - type: markdown + attributes: + value: We value your time and your efforts to submit this bug report is appreciated. πŸ™ + - type: textarea + id: feature-description + validations: + required: true + attributes: + label: "πŸ”– Feature description" + description: "A clear and concise description of what the feature is." + placeholder: "You should add ..." + - type: textarea + id: screenshot + validations: + required: false + attributes: + label: "🎀 Screenshot" + description: "Add screenshot if applicable." + - type: textarea + id: alternative + validations: + required: false + attributes: + label: "πŸ”„οΈ Additional Information" + description: "A clear and concise description of any alternative solutions or additional solutions you've considered." + placeholder: "I tried, ..." diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..07b73e1 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,27 @@ +### Related Issue +- Closes: # + +--- + +### Description + + +--- + +### How Has This Been Tested? + + +--- + +### Screenshots (if applicable) + + +--- + +### Type of Change + +- [ ] Bug fix +- [ ] New feature +- [ ] Code style update +- [ ] Breaking change +- [ ] Documentation update diff --git a/.github/workflows/auto-label-gssoc.yml b/.github/workflows/auto-label-gssoc.yml new file mode 100644 index 0000000..ce15d99 --- /dev/null +++ b/.github/workflows/auto-label-gssoc.yml @@ -0,0 +1,28 @@ +name: Auto Label Issues and PRs + +on: + issues: + types: [opened] + pull_request_target: # Correct indentation here + types: [opened] + +jobs: + add-labels: + runs-on: ubuntu-latest + + steps: + - name: Add labels to new issues + if: github.event_name == 'issues' + uses: actions-ecosystem/action-add-labels@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} # Use GITHUB_TOKEN for issues + labels: | + gssoc2025 + + - name: Add labels to new pull requests + if: github.event_name == 'pull_request_target' + uses: actions-ecosystem/action-add-labels@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} # Use GITHUB_TOKEN for PRs + labels: | + gssoc2025 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..14256c2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,74 @@ +# Node modules +node_modules/ +package-lock.json +# Logs +logs +*.log +npm-debug.log* + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Build output +dist/ +build/ + +# Dependency directories +jspm_packages/ + +# Optional npm cache directory +.npm + +# Yarn integrity file +.yarn-integrity + +# Editor and IDE specific files +.idea/ +.vscode/ +*.sublime-workspace +*.sublime-project + +# OS-specific files +.DS_Store +Thumbs.db +*.swp + +# Temporary files +tmp/ +temp/ + +# Coverage directory +coverage/ + +# TypeScript cache +*.tsbuildinfo + + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/CODE_OF_CODUCT.md b/CODE_OF_CODUCT.md new file mode 100644 index 0000000..bcdc41e --- /dev/null +++ b/CODE_OF_CODUCT.md @@ -0,0 +1,29 @@ +## Code of Conduct + +## 1. Purpose +We aim to create an open and welcoming environment where contributors from all backgrounds feel valued and respected. +This code of conduct outlines expectations for behavior to ensure everyone can contribute in a harassment-free environment. + +## 2. Scope +This Code of Conduct applies to all contributors, including maintainers and users. It applies to both project spaces and public spaces where an individual represents the project. + +## 3. Our Standards +In a welcoming environment, contributors should: + +Be kind and respectful to others. +Collaborate with others in a constructive manner. +Provide feedback in a respectful and considerate way. +Be open to differing viewpoints and experiences. +Show empathy and understanding towards others. +Unacceptable behaviors include, but are not limited to: + +Use of derogatory comments, insults, or personal attacks. +Harassment of any kind, including but not limited to: offensive comments related to gender, race, religion, or any other personal characteristics. +The publication of private information without consent. +Any behavior that could be perceived as discriminatory, intimidating, or threatening. + +## 4. Enforcement +Instances of unacceptable behavior may be reported to the project team. All complaints will be reviewed and investigated and will result in appropriate action. + +## 5. Acknowledgment +By contributing to Scribbie, you agree to adhere to this Code of Conduct and help create a safe, productive, and inclusive environment for all. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b61d616 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,130 @@ +# 🌟 Contributing to GitHub Tracker + +Thank you for showing interest in **GitHub Tracker**! πŸš€ +Whether you're here to fix a bug, propose an enhancement, or add a new feature, we’re thrilled to welcome you aboard. Let’s build something awesome together! + +
+ +## πŸ§‘β€βš–οΈ Code of Conduct + +Please make sure to read and adhere to our [Code of Conduct](https://github.com/GitMetricsLab/github_tracker/CODE_OF_CONDUCT.md) before contributing. We aim to foster a respectful and inclusive environment for everyone. + +
+ +## πŸ›  Project Structure + +```bash +github_tracker/ +β”œβ”€β”€ backend/ # Node.js + Express backend +β”‚ β”œβ”€β”€ routes/ # API routes +β”‚ β”œβ”€β”€ controllers/ # Logic handlers +β”‚ └── index.js # Entry point for server +β”‚ +β”œβ”€β”€ frontend/ # React + Vite frontend +β”‚ β”œβ”€β”€ components/ # Reusable UI components +β”‚ β”œβ”€β”€ pages/ # Main pages/routes +β”‚ └── main.jsx # Root file +β”‚ +β”œβ”€β”€ public/ # Static assets like images +β”‚ +β”œβ”€β”€ .gitignore +β”œβ”€β”€ README.md +β”œβ”€β”€ package.json +β”œβ”€β”€ tailwind.config.js +└── CONTRIBUTING.md +``` + +--- + +## 🀝 How to Contribute + +### 🧭 First-Time Contribution Steps + +1. **Fork the Repository** 🍴 + Click "Fork" to create your own copy under your GitHub account. + +2. **Clone Your Fork** πŸ“₯ + ```bash + git clone https://github.com//github_tracker.git + ``` + +3. **Navigate to the Project Folder** πŸ“ + ```bash + cd github_tracker + ``` + +4. **Create a New Branch** 🌿 + ```bash + git checkout -b your-feature-name + ``` + +5. **Make Your Changes** ✍ + After modifying files, stage and commit: + + ```bash + git add . + git commit -m "✨ Added [feature/fix]: your message" + ``` + +6. **Push Your Branch to GitHub** πŸš€ + ```bash + git push origin your-feature-name + ``` + +7. **Open a Pull Request** πŸ” + Go to the original repo and click **Compare & pull request**. + +--- + +## 🚦 Pull Request Guidelines + +### **Split Big Changes into Multiple Commits** +- When making large or complex changes, break them into smaller, logical commits. +- Each commit should represent a single purpose or unit of change (e.g. refactoring, adding a feature, fixing a bug). +--- +- βœ… Ensure your code builds and runs without errors. +- πŸ§ͺ Include tests where applicable. +- πŸ’¬ Add comments if the logic is non-trivial. +- πŸ“Έ Attach screenshots for UI-related changes. +- πŸ”– Use meaningful commit messages and titles. + +--- + +## 🐞 Reporting Issues + +If you discover a bug or have a suggestion: + +➑️ [Open an Issue](https://github.com/GitMetricsLab/github_tracker/issues/new/choose) + +Please include: + +- **Steps to Reproduce** +- **Expected vs. Actual Behavior** +- **Screenshots/Logs (if any)** + +--- + +## 🧠 Good Coding Practices + +1. **Consistent Style** + Stick to the project's linting and formatting conventions (e.g., ESLint, Prettier, Tailwind classes). + +2. **Meaningful Naming** + Use self-explanatory names for variables and functions. + +3. **Avoid Duplication** + Keep your code DRY (Don't Repeat Yourself). + +4. **Testing** + Add unit or integration tests for any new logic. + +5. **Review Others’ PRs** + Help others by reviewing their PRs too! + +--- + +## πŸ™Œ Thank You! + +We’re so glad you’re here. Your time and effort are deeply appreciated. Feel free to reach out via Issues or Discussions if you need any help. + +**Happy Coding!** πŸ’»πŸš€ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..382aae0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Mehul Prajapati + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d5b55a0 --- /dev/null +++ b/README.md @@ -0,0 +1,159 @@ +# 🌟 **GitHub Tracker** 🌟 + +**Track Activity of Users on GitHub** + +Welcome to **GitHub Tracker**, a web app designed to help you monitor and analyze the activity of GitHub users. Whether you’re a developer, a project manager, or just curious, this tool simplifies tracking contributions and activity across repositories! πŸš€πŸ‘©β€πŸ’» + +

+ github-tracker +

+ + + + + + + + + + + + + + + + + + + +
🌟 Stars🍴 ForksπŸ› IssuesπŸ”” Open PRsπŸ”• Close PRs
StarsForksIssuesOpen Pull RequestsClosed Pull Requests
+ +--- + +## πŸ“Š What is GitHub Tracker? +GitHub Tracker is a platform for tracking user activity on GitHub, allowing you to see contributions, repository interactions, and much more. Stay informed about your favorite projects and contributors with ease! + +--- + +## πŸ”‘ Key Features + +1. **πŸ“… User Activity Feed**: View a comprehensive feed of user activities across repositories. +2. **πŸ“ˆ Contribution Graph**: Analyze contribution trends over time. +3. **πŸ” Repository Insights**: Explore detailed statistics for any GitHub repository. + +--- + +## πŸ› οΈ Tech Stack + +GitHub Tracker is built using a modern tech stack for optimal performance and user experience: + +- **Frontend**: React.js + Vite +- **Styling**: TailwindCSS + Material UI +- **Data Fetching**: Axios + React Query +- **Backend**: Node.js + Express + +--- + +## πŸš€ Setup Guide + +To set up and run **GitHub Tracker** locally, follow these steps: + +### πŸ—‚οΈ Setting Up GitHub Tracker Repository + +1. Clone the repository to your local machine: +```bash +$ git clone https://github.com/yourusername/github-tracker.git +``` + +2. Navigate to the project directory: +```bash +$ cd github-tracker +``` + +3. Run the frontend +```bash +$ npm i +$ npm run dev +``` + +4. Run the backend +```bash +$ npm i +$ npm start +``` + +--- + +### 🌟 Coming Soon +- Add options to track stars, followers, following +- Add options to track engagements (e.g. comments, closing, opening and merging PRs) +- **πŸ‘₯ Team Monitoring**: Track activities of your team members in one place. +- **πŸ“Š Custom Dashboards**: Create personalized dashboards to visualize the data that matters to you. + +--- + +# πŸ‘€ Our Contributors + +- We extend our heartfelt gratitude for your invaluable contribution to our project. +- Make sure you show some love by giving ⭐ to our repository. + +
+ + + +
+ +## πŸ§ͺ Backend Unit & Integration Testing with Jasmine + +This project uses the Jasmine framework for backend unit and integration tests. The tests cover: +- User model (password hashing, schema, password comparison) +- Authentication routes (signup, login, logout) +- Passport authentication logic (via integration tests) + +### Prerequisites +- **Node.js** and **npm** installed +- **MongoDB** running locally (default: `mongodb://127.0.0.1:27017`) + +### Installation +Install all required dependencies: +```sh +npm install +npm install --save-dev jasmine @types/jasmine supertest express-session passport passport-local bcryptjs +``` + +### Running the Tests +1. **Start MongoDB** (if not already running): + ```sh + mongod + ``` +2. **Run Jasmine tests:** + ```sh + npx jasmine + ``` + +### Test Files +- `spec/user.model.spec.cjs` β€” Unit tests for the User model +- `spec/auth.routes.spec.cjs` β€” Integration tests for authentication routes + +### Jasmine Configuration +The Jasmine config (`spec/support/jasmine.mjs`) is set to recognize `.cjs`, `.js`, and `.mjs` test files: +```js +spec_files: [ + "**/*[sS]pec.?(m)js", + "**/*[sS]pec.cjs" +] +``` + +### Troubleshooting +- **No specs found:** Ensure your test files have the correct extension and are in the `spec/` directory. +- **MongoDB connection errors:** Make sure MongoDB is running and accessible. +- **Missing modules:** Install any missing dev dependencies with `npm install --save-dev `. + +### What Was Covered +- Jasmine is set up and configured for backend testing. +- All major backend modules are covered by unit/integration tests. +- Tests are passing and verified. + +--- + +For any questions or to add more tests (including frontend), see the contribution guidelines or open an issue. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..98f9688 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,3 @@ +PORT=5000 +MONGO_URI=mongodb://localhost:27017/githubTracker +SESSION_SECRET=your-secret-key diff --git a/backend/config/passportConfig.js b/backend/config/passportConfig.js new file mode 100644 index 0000000..842f50c --- /dev/null +++ b/backend/config/passportConfig.js @@ -0,0 +1,45 @@ +const passport = require("passport"); +const LocalStrategy = require('passport-local').Strategy; +const User = require("../models/User"); + +passport.use( + new LocalStrategy( + { usernameField: "email" }, + async (email, password, done) => { + try { + const user = await User.findOne( {email} ); + if (!user) { + return done(null, false, { message: 'Email is invalid '}); + } + + const isMatch = await user.comparePassword(password); + if (!isMatch) { + return done(null, false, { message: 'Invalid password' }); + } + + return done(null, { + id : user._id.toString(), + username: user.username, + email: user.email + }); + } catch (err) { + return done(err); + } + } + ) +); + +// Serialize user (store user info in session) +passport.serializeUser((user, done) => { + done(null, user.id); +}); + +// Deserialize user (retrieve user from session) +passport.deserializeUser(async (id, done) => { + try { + const user = await User.findById(id); + done(null, user); + } catch (err) { + done(err, null); + } +}); diff --git a/backend/middlewares/authenticateGitHub.js b/backend/middlewares/authenticateGitHub.js new file mode 100644 index 0000000..86287a7 --- /dev/null +++ b/backend/middlewares/authenticateGitHub.js @@ -0,0 +1,15 @@ +const {Octokit} = require("@octokit/rest"); +//ashish-choudhari-git Code +const authenticateGitHub = (req,res,next)=>{ + const {username,token} = req.body; + + if(!username || !token) { + return res.status(400).json({ message : 'Username and token are required'}); + } + + req.octokit = new Octokit({auth:token}); + req.githubUsername = username; + next(); +} + +module.exports = authenticateGitHub; \ No newline at end of file diff --git a/backend/models/User.js b/backend/models/User.js new file mode 100644 index 0000000..b667987 --- /dev/null +++ b/backend/models/User.js @@ -0,0 +1,44 @@ +const mongoose = require("mongoose"); +const bcrypt = require("bcryptjs"); + +const UserSchema = new mongoose.Schema({ + username: { + type: String, + required: true, + unique: true, + }, + email: { + type: String, + required: true, + unique: true, + }, + password: { + type: String, + required: true, + }, +}); + + + +UserSchema.pre('save', async function (next) { + + if (!this.isModified('password')) + return next(); + + try { + const salt = await bcrypt.genSalt(10); + this.password = await bcrypt.hash(this.password, salt); + next(); + } catch (err) { + return next(err); + } +}); + + + +// Compare passwords during login +UserSchema.methods.comparePassword = async function (enteredPassword) { + return await bcrypt.compare(enteredPassword, this.password); +}; + +module.exports = mongoose.model("User", UserSchema); diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..4e60955 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,29 @@ +{ + "name": "backend", + "version": "1.0.0", + "main": "server.js", + "scripts": { + "start": "nodemon server.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "@octokit/rest": "^22.0.0", + "axios": "^1.11.0", + "bcryptjs": "^2.4.3", + "body-parser": "^1.20.3", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.1", + "express-session": "^1.18.1", + "mongoose": "^8.8.2", + "passport": "^0.7.0", + "passport-local": "^1.0.0" + }, + "devDependencies": { + "nodemon": "^3.1.9" + } +} diff --git a/backend/routes/auth.js b/backend/routes/auth.js new file mode 100644 index 0000000..e26c7a9 --- /dev/null +++ b/backend/routes/auth.js @@ -0,0 +1,42 @@ +const express = require("express"); +const passport = require("passport"); +const User = require("../models/User"); +const router = express.Router(); + +// Signup route +router.post("/signup", async (req, res) => { + + const { username, email, password } = req.body; + + try { + const existingUser = await User.findOne( {email} ); + + if (existingUser) + return res.status(400).json( {message: 'User already exists'} ); + + const newUser = new User( {username, email, password} ); + await newUser.save(); + res.status(201).json( {message: 'User created successfully'} ); + } catch (err) { + res.status(500).json({ message: 'Error creating user', error: err.message }); + } +}); + +// Login route +router.post("/login", passport.authenticate('local'), (req, res) => { + res.status(200).json( { message: 'Login successful', user: req.user } ); +}); + +// Logout route +router.get("/logout", (req, res) => { + + req.logout((err) => { + + if (err) + return res.status(500).json({ message: 'Logout failed', error: err.message }); + else + res.status(200).json({ message: 'Logged out successfully' }); + }); +}); + +module.exports = router; diff --git a/backend/routes/details.js b/backend/routes/details.js new file mode 100644 index 0000000..af93962 --- /dev/null +++ b/backend/routes/details.js @@ -0,0 +1,272 @@ +//Aashish Choudhari's Code | GSSoC Contributor +const express = require('express'); +const router = express.Router(); +const authenticateGitHub = require('../middlewares/authenticateGitHub'); + +router.post('/get-data', authenticateGitHub, async (req,res)=>{ + + try{ + const { octokit, githubUsername } = req; + + // Fetch user's issues and PRs specifically for Home page + const [issuesResponse, prsResponse] = await Promise.all([ + // Get issues created by user + octokit.rest.search.issuesAndPullRequests({ + q: `author:${githubUsername} type:issue`, + sort: 'created', + order: 'desc', + per_page: 100 + }), + // Get pull requests created by user + octokit.rest.search.issuesAndPullRequests({ + q: `author:${githubUsername} type:pr`, + sort: 'created', + order: 'desc', + per_page: 100 + }) + ]); + + // Process issues data + const issues = issuesResponse.data.items.map(issue => ({ + id: issue.id, + title: issue.title, + state: issue.state, + created_at: issue.created_at, + repository_url: issue.repository_url, + html_url: issue.html_url, + number: issue.number, + labels: issue.labels, + assignees: issue.assignees, + user: issue.user + })); + + // Process pull requests data + const prs = prsResponse.data.items.map(pr => ({ + id: pr.id, + title: pr.title, + state: pr.state, + created_at: pr.created_at, + repository_url: pr.repository_url, + html_url: pr.html_url, + number: pr.number, + pull_request: { + merged_at: pr.pull_request?.merged_at || null + }, + labels: pr.labels, + assignees: pr.assignees, + user: pr.user + })); + + const responseData = { + issues, + prs, + totalIssues: issuesResponse.data.total_count, + totalPrs: prsResponse.data.total_count + }; + + res.json(responseData); + } catch (error) { + console.error('Error fetching user data:', error); + res.status(500).json({ message: 'Error fetching user data', error: error.message }); + } +}); + +// New route for comprehensive user analytics +router.post('/user-profile', authenticateGitHub, async (req, res) => { + try { + const { octokit, githubUsername } = req; + + // Fetch user profile + const userResponse = await octokit.rest.users.getByUsername({ + username: githubUsername + }); + + // Fetch user repositories + const reposResponse = await octokit.rest.repos.listForUser({ + username: githubUsername, + type: 'all', + sort: 'updated', + per_page: 100 + }); + + // Calculate language statistics + const languageStats = {}; + const repositories = []; + + for (const repo of reposResponse.data) { + try { + const languagesResponse = await octokit.rest.repos.listLanguages({ + owner: githubUsername, + repo: repo.name + }); + + Object.entries(languagesResponse.data).forEach(([lang, bytes]) => { + languageStats[lang] = (languageStats[lang] || 0) + bytes; + }); + + repositories.push({ + name: repo.name, + description: repo.description, + stars: repo.stargazers_count, + forks: repo.forks_count, + watchers: repo.watchers_count, + language: repo.language, + html_url: repo.html_url, + updated_at: repo.updated_at + }); + } catch (err) { + console.log(`Error fetching languages for ${repo.name}:`, err.message); + } + } + + // Sort repositories by stars + const repositoryRanking = repositories.sort((a, b) => b.stars - a.stars); + + // Calculate social stats + const socialStats = { + totalStars: repositories.reduce((sum, repo) => sum + repo.stars, 0), + totalForks: repositories.reduce((sum, repo) => sum + repo.forks, 0), + totalWatchers: repositories.reduce((sum, repo) => sum + repo.watchers, 0) + }; + + // Calculate real contribution stats + const currentDate = new Date(); + const oneYearAgo = new Date(currentDate.getFullYear() - 1, currentDate.getMonth(), currentDate.getDate()); + + // Get user events for contribution analysis + let userEvents = []; + try { + const eventsResponse = await octokit.rest.activity.listPublicEventsForUser({ + username: githubUsername, + per_page: 100 + }); + userEvents = eventsResponse.data; + } catch (err) { + console.log('Could not fetch user events:', err.message); + } + + // Calculate contributions from repositories and events + const totalContributions = userResponse.data.public_repos + + userResponse.data.public_gists + + (userEvents.length || 0); + + // Calculate streaks and activity patterns + const contributionsByDate = {}; + userEvents.forEach(event => { + const date = event.created_at.split('T')[0]; + contributionsByDate[date] = (contributionsByDate[date] || 0) + 1; + }); + + // Calculate longest and current streak + const dates = Object.keys(contributionsByDate).sort(); + let longestStreak = 0; + let currentStreak = 0; + let tempStreak = 0; + + for (let i = 0; i < dates.length; i++) { + if (i === 0 || isConsecutiveDay(dates[i-1], dates[i])) { + tempStreak++; + } else { + longestStreak = Math.max(longestStreak, tempStreak); + tempStreak = 1; + } + } + longestStreak = Math.max(longestStreak, tempStreak); + + // Current streak calculation + const today = new Date().toISOString().split('T')[0]; + if (dates.length > 0) { + const lastDate = dates[dates.length - 1]; + if (isRecentDate(lastDate, today)) { + currentStreak = calculateCurrentStreak(dates); + } + } + + // Most active day calculation + const dayActivityCount = { Sun: 0, Mon: 0, Tue: 0, Wed: 0, Thu: 0, Fri: 0, Sat: 0 }; + userEvents.forEach(event => { + const dayOfWeek = new Date(event.created_at).getDay(); + const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + dayActivityCount[dayNames[dayOfWeek]]++; + }); + + const mostActiveDay = Object.keys(dayActivityCount).reduce((a, b) => + dayActivityCount[a] > dayActivityCount[b] ? a : b + ); + + // Average contributions per day (last 365 days) + const averagePerDay = userEvents.length > 0 ? (userEvents.length / 365).toFixed(1) : 0; + + const contributionStats = { + totalContributions, + longestStreak, + currentStreak, + mostActiveDay: getDayFullName(mostActiveDay), + averagePerDay: parseFloat(averagePerDay) + }; + + // Helper functions + function isConsecutiveDay(date1, date2) { + const d1 = new Date(date1); + const d2 = new Date(date2); + const diffTime = Math.abs(d2 - d1); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + return diffDays === 1; + } + + function isRecentDate(date, today) { + const diffTime = Math.abs(new Date(today) - new Date(date)); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + return diffDays <= 1; + } + + function calculateCurrentStreak(dates) { + let streak = 0; + for (let i = dates.length - 1; i > 0; i--) { + if (isConsecutiveDay(dates[i-1], dates[i])) { + streak++; + } else { + break; + } + } + return streak + 1; + } + + function getDayFullName(shortDay) { + const dayMap = { + 'Sun': 'Sunday', + 'Mon': 'Monday', + 'Tue': 'Tuesday', + 'Wed': 'Wednesday', + 'Thu': 'Thursday', + 'Fri': 'Friday', + 'Sat': 'Saturday' + }; + return dayMap[shortDay] || 'Monday'; + } + + const responseData = { + profile: userResponse.data, + repositories, + languageStats, + contributionStats, + rankings: { + repositoryRanking + }, + highlights: { + topRepo: repositoryRanking[0] || null, + totalStars: socialStats.totalStars + }, + stars: repositories.filter(repo => repo.stars > 0), + commitHistory: [], // Would need more complex API calls + socialStats + }; + + res.json(responseData); + } catch (error) { + console.error('Error fetching user profile:', error); + res.status(500).json({ message: 'Error fetching user profile', error: error.message }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..3f943f4 --- /dev/null +++ b/backend/server.js @@ -0,0 +1,43 @@ +const express = require('express'); +const mongoose = require('mongoose'); +const session = require('express-session'); +const passport = require('passport'); +const bodyParser = require('body-parser'); +require('dotenv').config(); +const cors = require('cors'); + +// Passport configuration +require('./config/passportConfig'); + +const app = express(); + +// CORS configuration +app.use(cors('*')); + +// Middleware +app.use(bodyParser.json()); +app.use(session({ + secret: process.env.SESSION_SECRET, + resave: false, + saveUninitialized: false, +})); +app.use(passport.initialize()); +app.use(passport.session()); + +// Routes +const authRoutes = require('./routes/auth'); +const githubRoutes = require('./routes/details'); +app.use('/api/auth', authRoutes); +app.use('/api/github', githubRoutes); + +// Connect to MongoDB +mongoose.connect(process.env.MONGO_URI, {}).then(() => { + console.log('Connected to MongoDB'); + app.listen(process.env.PORT, () => { + console.log(`Server running on port ${process.env.PORT}`); + }); +}).catch((err) => { + console.log('MongoDB connection error:', err); +}); + + diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..092408a --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,28 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +) diff --git a/index.html b/index.html new file mode 100644 index 0000000..b6d940d --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + Github Tracker + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..7655b1c --- /dev/null +++ b/package.json @@ -0,0 +1,52 @@ +{ + "name": "my-react-tailwind-app", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@emotion/react": "^11.11.3", + "@emotion/styled": "^11.11.0", + "@mui/icons-material": "^5.15.6", + "@mui/material": "^5.15.6", + "@vitejs/plugin-react": "^4.3.3", + "axios": "^1.7.7", + "lucide-react": "^0.525.0", + "octokit": "^4.0.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-hot-toast": "^2.4.1", + "react-icons": "^5.3.0", + "react-router-dom": "^6.28.0", + "recharts": "^3.1.0" + }, + "devDependencies": { + "@eslint/js": "^9.13.0", + "@types/jasmine": "^5.1.8", + "@types/node": "^22.10.1", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@types/react-redux": "^7.1.34", + "@vitejs/plugin-react-swc": "^3.5.0", + "autoprefixer": "^10.4.20", + "bcryptjs": "^3.0.2", + "eslint": "^9.13.0", + "eslint-plugin-react": "^7.37.2", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.14", + "express-session": "^1.18.2", + "globals": "^15.11.0", + "jasmine": "^5.9.0", + "passport": "^0.7.0", + "passport-local": "^1.0.0", + "postcss": "^8.4.47", + "supertest": "^7.1.4", + "tailwindcss": "^3.4.14", + "vite": "^5.4.10" + } +} diff --git a/postcss.config.cjs b/postcss.config.cjs new file mode 100644 index 0000000..f0c363c --- /dev/null +++ b/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, + }; diff --git a/public/crl-icon.png b/public/crl-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..2f61178e7f963b67cd5f3a32402c31fa76b3c765 GIT binary patch literal 38255 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 literal 0 HcmV?d00001 diff --git a/public/crl.png b/public/crl.png new file mode 100644 index 0000000000000000000000000000000000000000..5e8ce2d7b381b42df865a54fe5cd95c27fd57b3a GIT binary patch literal 6501 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 LNkvXXu0mjfB2tSZ literal 0 HcmV?d00001 diff --git a/public/vite.svg b/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/spec/auth.routes.spec.cjs b/spec/auth.routes.spec.cjs new file mode 100644 index 0000000..c5ac003 --- /dev/null +++ b/spec/auth.routes.spec.cjs @@ -0,0 +1,96 @@ +const mongoose = require('mongoose'); +const express = require('express'); +const request = require('supertest'); +const session = require('express-session'); +const passport = require('passport'); +const User = require('../backend/models/User'); +const authRoutes = require('../backend/routes/auth'); + +// Setup Express app for testing +function createTestApp() { + const app = express(); + app.use(express.json()); + app.use(session({ secret: 'test', resave: false, saveUninitialized: false })); + app.use(passport.initialize()); + app.use(passport.session()); + require('../backend/config/passportConfig'); + app.use('/auth', authRoutes); + return app; +} + +describe('Auth Routes', () => { + let app; + + beforeAll(async () => { + await mongoose.connect('mongodb://127.0.0.1:27017/github_tracker_test', { + useNewUrlParser: true, + useUnifiedTopology: true, + }); + app = createTestApp(); + }); + + afterAll(async () => { + await mongoose.connection.db.dropDatabase(); + await mongoose.disconnect(); + }); + + afterEach(async () => { + await User.deleteMany({}); + }); + + it('should sign up a new user', async () => { + const res = await request(app) + .post('/auth/signup') + .send({ username: 'testuser', email: 'test@example.com', password: 'password123' }); + expect(res.status).toBe(201); + expect(res.body.message).toBe('User created successfully'); + const user = await User.findOne({ email: 'test@example.com' }); + expect(user).toBeTruthy(); + }); + + it('should not sign up a user with existing email', async () => { + await new User({ username: 'testuser', email: 'test@example.com', password: 'password123' }).save(); + const res = await request(app) + .post('/auth/signup') + .send({ username: 'testuser2', email: 'test@example.com', password: 'password456' }); + expect(res.status).toBe(400); + expect(res.body.message).toBe('User already exists'); + }); + + it('should login a user with correct credentials', async () => { + await request(app) + .post('/auth/signup') + .send({ username: 'testuser', email: 'test@example.com', password: 'password123' }); + const agent = request.agent(app); + const res = await agent + .post('/auth/login') + .send({ email: 'test@example.com', password: 'password123' }); + expect(res.status).toBe(200); + expect(res.body.message).toBe('Login successful'); + expect(res.body.user.email).toBe('test@example.com'); + }); + + it('should not login a user with wrong password', async () => { + await request(app) + .post('/auth/signup') + .send({ username: 'testuser', email: 'test@example.com', password: 'password123' }); + const agent = request.agent(app); + const res = await agent + .post('/auth/login') + .send({ email: 'test@example.com', password: 'wrongpassword' }); + expect(res.status).toBe(401); + }); + + it('should logout a logged-in user', async () => { + await request(app) + .post('/auth/signup') + .send({ username: 'testuser', email: 'test@example.com', password: 'password123' }); + const agent = request.agent(app); + await agent + .post('/auth/login') + .send({ email: 'test@example.com', password: 'password123' }); + const res = await agent.get('/auth/logout'); + expect(res.status).toBe(200); + expect(res.body.message).toBe('Logged out successfully'); + }); +}); \ No newline at end of file diff --git a/spec/support/jasmine.mjs b/spec/support/jasmine.mjs new file mode 100644 index 0000000..9973490 --- /dev/null +++ b/spec/support/jasmine.mjs @@ -0,0 +1,15 @@ +export default { + spec_dir: "spec", + spec_files: [ + "**/*[sS]pec.?(m)js", + "**/*[sS]pec.cjs" + ], + helpers: [ + "helpers/**/*.?(m)js" + ], + env: { + stopSpecOnExpectationFailure: false, + random: true, + forbidDuplicateNames: true + } +} diff --git a/spec/user.model.spec.cjs b/spec/user.model.spec.cjs new file mode 100644 index 0000000..236d9bd --- /dev/null +++ b/spec/user.model.spec.cjs @@ -0,0 +1,50 @@ +const mongoose = require('mongoose'); +const bcrypt = require('bcryptjs'); +const User = require('../backend/models/User'); + +describe('User Model', () => { + beforeAll(async () => { + await mongoose.connect('mongodb://127.0.0.1:27017/github_tracker_test', { + useNewUrlParser: true, + useUnifiedTopology: true, + }); + }); + + afterAll(async () => { + await mongoose.connection.db.dropDatabase(); + await mongoose.disconnect(); + }); + + afterEach(async () => { + await User.deleteMany({}); + }); + + it('should create a user with hashed password', async () => { + const userData = { username: 'testuser', email: 'test@example.com', password: 'password123' }; + const user = new User(userData); + await user.save(); + expect(user.password).not.toBe(userData.password); + const isMatch = await bcrypt.compare('password123', user.password); + expect(isMatch).toBeTrue(); + }); + + it('should not hash password again if not modified', async () => { + const userData = { username: 'testuser2', email: 'test2@example.com', password: 'password123' }; + const user = new User(userData); + await user.save(); + const originalHash = user.password; + user.username = 'updateduser'; + await user.save(); + expect(user.password).toBe(originalHash); + }); + + it('should compare passwords correctly', async () => { + const userData = { username: 'testuser3', email: 'test3@example.com', password: 'password123' }; + const user = new User(userData); + await user.save(); + const isMatch = await user.comparePassword('password123'); + expect(isMatch).toBeTrue(); + const isNotMatch = await user.comparePassword('wrongpassword'); + expect(isNotMatch).toBeFalse(); + }); +}); \ No newline at end of file diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..b9d355d --- /dev/null +++ b/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + 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); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..e9adacf --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,50 @@ +import Navbar from "./components/Navbar"; +import Footer from "./components/Footer"; +import ScrollProgressBar from './components/ScrollProgressBar'; +import { Toaster } from "react-hot-toast"; + +import Router from "./Routes/Router"; + +function App() { + return ( + +
+ + + {/* Navbar */} + + + {/* Main content */} +
+ +
+ + {/* Footer */} +
+ + +
+ + ); +} + +export default App; diff --git a/src/Routes/Router.tsx b/src/Routes/Router.tsx new file mode 100644 index 0000000..f40f578 --- /dev/null +++ b/src/Routes/Router.tsx @@ -0,0 +1,29 @@ +import { Navigate, Route, Routes } from "react-router-dom"; + +import Home from "../pages/Home/Home"; // Import the Home component +import About from "../pages/About/About"; // Import the About component +import Contact from "../pages/Contact/Contact"; // Import the Contact component +import Contributors from "../pages/Contributors/Contributors"; +import Signup from "../pages/Signup/Signup.tsx"; +import Login from "../pages/Login/Login.tsx"; +import UserProfile from "../pages/UserProfile/UserProfile.tsx"; +import UserAnalytics from "../pages/UserAnalytics/UserAnalytics.tsx"; + +const Router = () => { + return ( + + {/* Redirect from root (/) to the home page */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + ); +}; + +export default Router; diff --git a/src/assets/react.svg b/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000..50fe108 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,39 @@ +import { FaGithub } from 'react-icons/fa'; + +function Footer() { + return ( +
+ ); +} + +export default Footer; diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx new file mode 100644 index 0000000..9e9c2b2 --- /dev/null +++ b/src/components/Navbar.tsx @@ -0,0 +1,115 @@ +import { Link } from 'react-router-dom'; +import { useState } from 'react'; + +const Navbar: React.FC = () => { + const [isOpen, setIsOpen] = useState(false); + + return ( + + ); +}; + +export default Navbar; diff --git a/src/components/ScrollProgressBar.tsx b/src/components/ScrollProgressBar.tsx new file mode 100644 index 0000000..bbe286f --- /dev/null +++ b/src/components/ScrollProgressBar.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from "react"; + +const ScrollProgressBar = () => { + const [scrollWidth, setScrollWidth] = useState(0); + const [isAnimating, setIsAnimating] = useState(true); // Tracks if the page load animation is active + + useEffect(() => { + // Simulate the page load animation + const animationTimeout = setTimeout(() => { + setIsAnimating(false); // End the animation after 2 seconds + }, 2000); + + // Clean up timeout + return () => clearTimeout(animationTimeout); + }, []); + + const handleScroll = () => { + const scrollTop = document.documentElement.scrollTop; + const scrollHeight = + document.documentElement.scrollHeight - + document.documentElement.clientHeight; + const width = (scrollTop / scrollHeight) * 100; + setScrollWidth(width); + }; + + useEffect(() => { + if (!isAnimating) { + window.addEventListener("scroll", handleScroll); + } + return () => window.removeEventListener("scroll", handleScroll); + }, [isAnimating]); + + return ( + <> + {/* Left-to-right animation during page load */} + {isAnimating && ( +
+ )} + + {/* Scroll progress bar after animation ends */} + {!isAnimating && ( +
+ )} + + {/* Animation Keyframes */} + + + ); +}; + +export default ScrollProgressBar; diff --git a/src/components/UserAnalyticsComp/ContributionStats.tsx b/src/components/UserAnalyticsComp/ContributionStats.tsx new file mode 100644 index 0000000..8249a36 --- /dev/null +++ b/src/components/UserAnalyticsComp/ContributionStats.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { + Grid, + Card, + CardContent, + Typography, + Box +} from '@mui/material'; + +interface ContributionStatsProps { + contributionStats: { + pushEvents: number; + pullRequestEvents: number; + issueEvents: number; + createEvents: number; + }; +} + +const ContributionStats: React.FC = ({ contributionStats }) => { + const statsItems = [ + { label: 'Push Events', value: contributionStats.pushEvents }, + { label: 'Pull Requests', value: contributionStats.pullRequestEvents }, + { label: 'Issues', value: contributionStats.issueEvents }, + { label: 'Repositories Created', value: contributionStats.createEvents } + ]; + + return ( + + + + + + Contribution Breakdown + + + {statsItems.map((item, index) => ( + + {item.label}: + {item.value} + + ))} + + + + + + ); +}; + +export default ContributionStats; \ No newline at end of file diff --git a/src/components/UserAnalyticsComp/LanguageStats.tsx b/src/components/UserAnalyticsComp/LanguageStats.tsx new file mode 100644 index 0000000..1b20343 --- /dev/null +++ b/src/components/UserAnalyticsComp/LanguageStats.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { + Grid, + Card, + CardContent, + Typography, + Box, + LinearProgress +} from '@mui/material'; + +interface LanguageStatsProps { + languageStats: Record; +} + +const LanguageStats: React.FC = ({ languageStats }) => { + const getTopLanguages = (languageStats: Record) => { + const total = Object.values(languageStats).reduce((sum, bytes) => sum + bytes, 0); + return Object.entries(languageStats) + .sort(([, a], [, b]) => b - a) + .slice(0, 5) + .map(([language, bytes]) => ({ + language, + percentage: ((bytes / total) * 100).toFixed(1) + })); + }; + + return ( + + + + + + Top Programming Languages + + {getTopLanguages(languageStats).map(({ language, percentage }) => ( + + + {language} + {percentage}% + + + + ))} + + + + + ); +}; + +export default LanguageStats; \ No newline at end of file diff --git a/src/components/UserAnalyticsComp/RepositoryTable.tsx b/src/components/UserAnalyticsComp/RepositoryTable.tsx new file mode 100644 index 0000000..768a2ea --- /dev/null +++ b/src/components/UserAnalyticsComp/RepositoryTable.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { + Grid, + Card, + CardContent, + Typography, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Box, + Chip +} from '@mui/material'; + +interface RepositoryTableProps { + repositories: any[]; +} + +const RepositoryTable: React.FC = ({ repositories }) => { + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString(); + }; + + return ( + + + + + + Top Repositories by Stars + + + + + + Repository + Stars + Forks + Language + Updated + + + + {repositories.map((repo: any) => ( + + + + + + {repo.name} + + + {repo.description && ( + + {repo.description.substring(0, 100)}... + + )} + + + {repo.stargazers_count} + {repo.forks_count} + + {repo.language && ( + + )} + + {formatDate(repo.updated_at)} + + ))} + +
+
+
+
+
+
+ ); +}; + +export default RepositoryTable; \ No newline at end of file diff --git a/src/components/UserAnalyticsComp/UserFrom.tsx b/src/components/UserAnalyticsComp/UserFrom.tsx new file mode 100644 index 0000000..9846993 --- /dev/null +++ b/src/components/UserAnalyticsComp/UserFrom.tsx @@ -0,0 +1,64 @@ +import React, { useState } from 'react'; +import { + Card, + CardContent, + Typography, + Box, + TextField, + Button, + CircularProgress +} from '@mui/material'; + +interface UserFormProps { + onSubmit: (username: string, token: string) => void; + loading: boolean; +} + +const UserForm: React.FC = ({ onSubmit, loading }) => { + const [username, setUsername] = useState(''); + const [token, setToken] = useState(''); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit(username, token); + }; + + return ( + + + + GitHub User Analytics + +
+ + setUsername(e.target.value)} + required + sx={{ flex: 1 }} + /> + setToken(e.target.value)} + type="password" + required + sx={{ flex: 1 }} + /> + + +
+
+
+ ); +}; + +export default UserForm; \ No newline at end of file diff --git a/src/components/UserAnalyticsComp/UserProfile.tsx b/src/components/UserAnalyticsComp/UserProfile.tsx new file mode 100644 index 0000000..6d6add0 --- /dev/null +++ b/src/components/UserAnalyticsComp/UserProfile.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { + Grid, + Card, + CardContent, + Typography, + Avatar, + Box, + Chip +} from '@mui/material'; + +interface UserProfileProps { + userData: { + profile: any; + socialStats: any; + }; +} + +const UserProfile: React.FC = ({ userData }) => { + return ( + + + + + + + {userData.profile.name || userData.profile.login} + + + @{userData.profile.login} + + {userData.profile.bio && ( + + {userData.profile.bio} + + )} + + + + + + + + + ); +}; + +export default UserProfile; \ No newline at end of file diff --git a/src/components/UserAnalyticsComp/UserStats.tsx b/src/components/UserAnalyticsComp/UserStats.tsx new file mode 100644 index 0000000..10357f3 --- /dev/null +++ b/src/components/UserAnalyticsComp/UserStats.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { + Grid, + Card, + CardContent, + Typography +} from '@mui/material'; +import { Star, GitFork, Code, Activity } from 'lucide-react'; + +interface UserStatsProps { + userData: { + rankings: any; + contributionStats: any; + }; +} + +const UserStats: React.FC = ({ userData }) => { + const statsData = [ + { + icon: , + value: userData.rankings.totalStars, + label: 'Total Stars' + }, + { + icon: , + value: userData.rankings.totalForks, + label: 'Total Forks' + }, + { + icon: , + value: userData.rankings.publicRepos, + label: 'Public Repos' + }, + { + icon: , + value: userData.contributionStats.totalEvents, + label: 'Activities' + } + ]; + + return ( + + {statsData.map((stat, index) => ( + + + + {stat.icon} + {stat.value} + {stat.label} + + + + ))} + + ); +}; + +export default UserStats; \ No newline at end of file diff --git a/src/hooks/useGitHubAuth.ts b/src/hooks/useGitHubAuth.ts new file mode 100644 index 0000000..4ac6139 --- /dev/null +++ b/src/hooks/useGitHubAuth.ts @@ -0,0 +1,23 @@ +import { useState } from 'react'; +import { Octokit } from '@octokit/core'; + +export const useGitHubAuth = () => { + const [username, setUsername] = useState(''); + const [token, setToken] = useState(''); + const [error, setError] = useState(''); + + const getOctokit = () => { + if (!username || !token) return null; + return new Octokit({ auth: token }); + }; + + return { + username, + setUsername, + token, + setToken, + error, + setError, + getOctokit, + }; +}; \ No newline at end of file diff --git a/src/hooks/useGitHubData.ts b/src/hooks/useGitHubData.ts new file mode 100644 index 0000000..967da66 --- /dev/null +++ b/src/hooks/useGitHubData.ts @@ -0,0 +1,74 @@ +import { useState, useCallback } from 'react'; + +interface GitHubIssue { + id: number; + title: string; + state: string; + created_at: string; + html_url: string; + user: { + login: string; + avatar_url: string; + }; +} + +export const useGitHubData = (octokit: any) => { + const [issues, setIssues] = useState([]); + const [prs, setPrs] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const fetchAll = async (url: string, params: any): Promise => { + let page = 1; + let results: GitHubIssue[] = []; + let hasMore = true; + + while (hasMore) { + const response = await octokit.request(url, { ...params, page }); + results = results.concat(response.data.items); + hasMore = response.data.items.length === 100; + page++; + } + + return results; + }; + + const fetchData = useCallback(async (username: string) => { + if (!octokit || !username) return; + + setLoading(true); + setError(''); + + try { + const [issuesResponse, prsResponse] = await Promise.all([ + fetchAll('GET /search/issues', { + q: `author:${username} is:issue`, + sort: 'created', + order: 'desc', + per_page: 100, + }), + fetchAll('GET /search/issues', { + q: `author:${username} is:pr`, + sort: 'created', + order: 'desc', + per_page: 100, + }), + ]); + + setIssues(issuesResponse); + setPrs(prsResponse); + } catch (err: any) { + setError(err?.message || 'An error occurred'); + } finally { + setLoading(false); + } + }, [octokit]); + + return { + issues, + prs, + loading, + error, + fetchData, + }; +}; \ No newline at end of file diff --git a/src/hooks/usePagination.ts b/src/hooks/usePagination.ts new file mode 100644 index 0000000..4c9bd2d --- /dev/null +++ b/src/hooks/usePagination.ts @@ -0,0 +1,21 @@ +import { useState } from 'react'; + +export const usePagination = (rowsPerPage = 10) => { + const [page, setPage] = useState(0); + const [itemsPerPage] = useState(rowsPerPage); + + const handleChangePage = (event, newPage) => { + setPage(newPage); + }; + + const paginateData = (data) => { + return data.slice(page * itemsPerPage, page * itemsPerPage + itemsPerPage); + }; + + return { + page, + itemsPerPage, + handleChangePage, + paginateData, + }; +}; \ No newline at end of file diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..404589f --- /dev/null +++ b/src/index.css @@ -0,0 +1,21 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + + +html, body { + margin: 0; + padding: 0; + width: 100%; + height: 100%; + overflow-x: hidden; +} + +body { + background-color: #000000; +} + +#root { + width: 100%; + min-height: 100vh; +} \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..a9f043f --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App.tsx"; +import "./index.css"; +import { BrowserRouter } from "react-router-dom"; + +createRoot(document.getElementById("root")!).render( + + + + + +); diff --git a/src/pages/About/About.tsx b/src/pages/About/About.tsx new file mode 100644 index 0000000..4272234 --- /dev/null +++ b/src/pages/About/About.tsx @@ -0,0 +1,55 @@ +const About = () => { + return ( +
+ {/* Hero Section */} +
+

About Us

+

+ Welcome to GitHub Tracker! We simplify issue tracking for developers. +

+
+ + {/* Mission Section */} +
+

Our Mission

+

+ We aim to provide an efficient and user-friendly way to track GitHub issues and pull requests. + Our goal is to make it easy for developers to stay organized and focused on their projects without getting bogged down by the details. +

+
+ + {/* Features Section */} +
+

What We Do

+ +
+
+
πŸ”
+

Simple Issue Tracking

+

+ Track your GitHub issues seamlessly with intuitive filters and search options. +

+
+ +
+
πŸ‘₯
+

Team Collaboration

+

+ Collaborate with your team in real-time, manage issues and pull requests effectively. +

+
+ +
+
βš™οΈ
+

Customizable Settings

+

+ Customize your issue tracking workflow to match your team's needs. +

+
+
+
+
+ ); +}; + +export default About; diff --git a/src/pages/Contact/Contact.tsx b/src/pages/Contact/Contact.tsx new file mode 100644 index 0000000..a75368e --- /dev/null +++ b/src/pages/Contact/Contact.tsx @@ -0,0 +1,211 @@ +import { useState } from 'react'; +import { Github, Mail, Phone, Send, X, CheckCircle } from 'lucide-react'; + +function Contact() { + const [showPopup, setShowPopup] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async () => { + setIsSubmitting(true); + + // Simulate API call + await new Promise(resolve => setTimeout(resolve, 1500)); + + setIsSubmitting(false); + setShowPopup(true); + + // Auto-close popup after 5 seconds + setTimeout(() => { + setShowPopup(false); + }, 5000); + }; + + const handleClosePopup = () => { + setShowPopup(false); + }; + + return ( +
+ {/* Animated background elements */} +
+
+
+
+
+ +
+ {/* Header Section */} +
+
+
+ Logo +
+

+ GitHub Tracker +

+
+

+ Get in touch with us to discuss your project tracking needs or report any issues +

+
+ +
+ {/* Contact Info Cards */} +
+
+

Let's Connect

+

+ We're here to help you track and manage your GitHub repositories more effectively +

+
+ +
+
+
+
+ +
+
+

Phone Support

+

(123) 456-7890

+

Mon-Fri, 9AM-6PM EST

+
+
+
+ +
+
+
+ +
+
+

Email Us

+

support@githubtracker.com

+

We'll respond within 24 hours

+
+
+
+ +
+
+
+ +
+
+

GitHub Issues

+

github.com/yourorg/githubtracker

+

Report bugs & feature requests

+
+
+
+
+
+ + {/* Contact Form */} +
+

Send us a Message

+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+
+
+
+ + {/* Success Popup Modal */} + {showPopup && ( +
+
+
+
+ +
+ +

Message Sent Successfully!

+

+ Thank you for reaching out to GitHub Tracker. We've received your message and will get back to you within 24 hours. +

+ + +
+
+
+ )} +
+ ); +} + +export default Contact; \ No newline at end of file diff --git a/src/pages/Contributors/Contributors.tsx b/src/pages/Contributors/Contributors.tsx new file mode 100644 index 0000000..ab9de23 --- /dev/null +++ b/src/pages/Contributors/Contributors.tsx @@ -0,0 +1,173 @@ +import { useEffect, useState } from "react"; +import { + Container, + Grid, + Card, + CardContent, + Avatar, + Typography, + Button, + Box, + CircularProgress, + Alert, +} from "@mui/material"; +import { FaGithub } from "react-icons/fa"; +import axios from "axios"; +import { Link } from "react-router-dom"; // βœ… Added + +interface Contributor { + id: number; + login: string; + avatar_url: string; + contributions: number; + html_url: string; +} + +const ContributorsPage = () => { + const [contributors, setContributors] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchContributors = async () => { + try { + const response = await axios.get( + "https://api.github.com/repos/GitMetricsLab/github_tracker/contributors", + { withCredentials: false } + ); + setContributors(response.data); + } catch (err) { + setError("Failed to fetch contributors. Please try again later. " + err); + } finally { + setLoading(false); + } + }; + + fetchContributors(); + }, []); + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + return ( + + + 🀝 GitHub Contributors + + + + {contributors.map((contributor) => ( + + + + + + + {contributor.login} + + + {contributor.contributions} Contributions + + + Thank you for your valuable contributions! + + + + + + + + + ))} + + + ); +}; + +export default ContributorsPage; diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx new file mode 100644 index 0000000..8a462f8 --- /dev/null +++ b/src/pages/Home/Home.tsx @@ -0,0 +1,451 @@ +import React, { useState } from "react"; +import { + Container, + Box, + TextField, + Button, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TablePagination, + Link, + CircularProgress, + Alert, + Tabs, + Tab, + Select, + MenuItem, + FormControl, + InputLabel, +} from "@mui/material"; +import { BarChart3 } from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import { usePagination } from "../../hooks/usePagination"; +// import { useGitHubAuth } from "../../hooks/useGitHubAuth"; +// import { useGitHubData } from "../../hooks/useGitHubData"; +//moving data fetching to the backend for security purpose - ashish-choudhari-git +import axios from 'axios'; + +const ROWS_PER_PAGE = 10; +const backendUrl = import.meta.env.VITE_BACKEND_URL || 'http://localhost:5000'; + +// Define the shape of the data received from GitHub +interface GitHubItem { + id: number; + title: string; + state: string; + created_at: string; + pull_request?: { merged_at: string | null }; + repository_url: string; + html_url: string; +} + +const Home: React.FC = () => { + const navigate = useNavigate(); + + //moved fetching to the backend routes - details.js + + // Hooks for managing user authentication + // const { + // username, + // setUsername, + // token, + // setToken, + // error: authError, + // getOctokit, + // validateCredentials, + // } = useGitHubAuth(); + + + // const octokit = getOctokit(); + // const { + // issues, + // prs, + // loading, + // error: dataError, + // fetchData, + // } = useGitHubData(octokit); + + //state management + const [username, setUsername] = useState(''); + const [token, setToken] = useState(''); + const [issues, setIssues] = useState([]); + const [prs, setPrs] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + + const { page, itemsPerPage, handleChangePage, paginateData } = + usePagination(ROWS_PER_PAGE); + + // State for various filters and tabs + const [tab, setTab] = useState(0); + const [issueFilter, setIssueFilter] = useState("all"); + const [prFilter, setPrFilter] = useState("all"); + const [searchTitle, setSearchTitle] = useState(""); + const [selectedRepo, setSelectedRepo] = useState(""); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + + //validation of username and token + const validateCredentials= async()=>{ + if(!username.trim()){ + setError('Username is required.') + return false; + } + if(!token.trim()){ + setError('Personal acess token is required.') + return false; + } + + setError(''); + return true; + + } + + // Navigate to analytics page with username and token + const handleViewAnalytics = () => { + if (!username.trim() || !token.trim()) { + setError('Please enter username and token first'); + return; + } + // Pass username and token as state to analytics page + navigate('/analytics', { + state: { + username: username.trim(), + token: token.trim() + } + }); + }; + + + + //fetching data from backend + const fetchData= async()=>{ + + setLoading(true); + setError(''); + + try{ + + console.log('Request payload:', { username, token: token ? 'PROVIDED' : 'MISSING' }); + + const response = await axios.post(`${backendUrl}/api/github/get-data`,{ + username, token + }); //we get data from backend by providng username and token + + setIssues(response.data.issues); + setPrs(response.data.prs); + + }catch(err:any){ + + console.error('Error response:', err.response?.data); + setError(err.response?.data?.message || `Error fetching GitHub data: ${err.message}`); + setIssues([]); + setPrs([]); + } + finally{ + setLoading(false); + } + } + + + + // Handle data submission to fetch GitHub data + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + // ashish-choudhari-git Code for security + const isValid = await validateCredentials(); + if(isValid){ + fetchData(); + } + + }; + + // Format date strings into a readable format + const formatDate = (dateString: string): string => { + return new Date(dateString).toLocaleDateString(); + }; + + // Filter data based on selected criteria + const filterData = ( + data: GitHubItem[], + filterType: string + ): GitHubItem[] => { + let filteredData = [...data]; + + if (filterType === "open" || filterType === "closed" || filterType === "merged") { + filteredData = filteredData.filter((item) => + filterType === "merged" + ? item.pull_request?.merged_at + : item.state === filterType + ); + } + + if (searchTitle) { + filteredData = filteredData.filter((item) => + item.title.toLowerCase().includes(searchTitle.toLowerCase()) + ); + } + + if (selectedRepo) { + filteredData = filteredData.filter((item) => + item.repository_url.includes(selectedRepo) + ); + } + + if (startDate) { + filteredData = filteredData.filter( + (item) => new Date(item.created_at) >= new Date(startDate) + ); + } + if (endDate) { + filteredData = filteredData.filter( + (item) => new Date(item.created_at) <= new Date(endDate) + ); + } + + return filteredData; + }; + + // Determine the current tab's data + const currentData = + tab === 0 ? filterData(issues, issueFilter) : filterData(prs, prFilter); + + // Paginate the filtered data + const displayData = paginateData(currentData); + + // Main UI rendering + return ( + + {/* Authentication Form */} + +
+ + setUsername(e.target.value)} + required + sx={{ flex: 1 }} + /> + setToken(e.target.value)} + type="password" + required + sx={{ flex: 1 }} + /> + + + + + +
+
+ + {/* Filters Section */} + + {/* Search Title */} + setSearchTitle(e.target.value)} + sx={{ + flexBasis: { xs: "100%", sm: "100%", md: "48%", lg: "23%" }, + flexGrow: 1, + }} + /> + + {/* Repository */} + setSelectedRepo(e.target.value)} + sx={{ + flexBasis: { xs: "100%", sm: "100%", md: "48%", lg: "23%" }, + flexGrow: 1, + }} + /> + + {/* Start Date */} + setStartDate(e.target.value)} + InputLabelProps={{ shrink: true }} + sx={{ + flexBasis: { xs: "100%", sm: "100%", md: "48%", lg: "23%" }, + flexGrow: 1, + }} + /> + + {/* End Date */} + setEndDate(e.target.value)} + InputLabelProps={{ shrink: true }} + sx={{ + flexBasis: { xs: "100%", sm: "100%", md: "48%", lg: "23%" }, + flexGrow: 1, + }} + /> + + + +{/* Tabs and State Dropdown */} + + setTab(newValue)} + variant="scrollable" + scrollButtons="auto" + sx={{ flexGrow: 1, minWidth: "200px" }} + > + + + + + + State + + + + + +{/* Error Alert */} +{(error) && ( + + {error} + +)} + +{/* Table Section */} +{loading ? ( + + + +) : ( + + + + + + + Title + Repository + State + Created + + + + {displayData.map((item: GitHubItem) => ( + + + + {item.title} + + + + {item.repository_url.split("/").slice(-1)[0]} + + + {item.pull_request?.merged_at ? "merged" : item.state} + + {formatDate(item.created_at)} + + ))} + +
+ +
+
+ +
+ )} +
+ ); +}; + +export default Home; diff --git a/src/pages/Login/Login.tsx b/src/pages/Login/Login.tsx new file mode 100644 index 0000000..9cd94dd --- /dev/null +++ b/src/pages/Login/Login.tsx @@ -0,0 +1,161 @@ +import React, { useState, ChangeEvent, FormEvent } from "react"; +import axios from "axios"; +import { useNavigate } from "react-router-dom"; + +const backendUrl = import.meta.env.VITE_BACKEND_URL; + +interface LoginFormData { + email: string; + password: string; +} + +const Login: React.FC = () => { + const [formData, setFormData] = useState({ email: "", password: "" }); + const [message, setMessage] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const navigate = useNavigate(); + + const handleChange = (e: ChangeEvent) => { + const { name, value } = e.target; + setFormData({ ...formData, [name]: value }); + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setIsLoading(true); + + try { + const response = await axios.post(`${backendUrl}/api/auth/login`, formData); + setMessage(response.data.message); + + if (response.data.message === 'Login successful') { + navigate("/home"); + } + } catch (error: any) { + setMessage(error.response?.data?.message || "Something went wrong"); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ {/* Enhanced animated background elements */} +
+
+
+
+
+
+ +
+ {/* Enhanced GitHub Logo/Icon */} +
+
+ Logo +
+ +

+ GitHubTracker +

+

Track your GitHub journey

+
+ + {/* Login Form */} +
+

Welcome Back

+ +
+
+
+ + + +
+ +
+ +
+
+ + + +
+ +
+ + +
+ + {/* Message Display */} + {message && ( +
+ {message} +
+ )} + + {/* Additional Links */} + +
+ + {/* Footer */} +
+

+ Don't have an account? + + Sign up here + +

+
+
+ + {/* Additional background coverage */} +
+
+ ); +}; + +export default Login; diff --git a/src/pages/Signup/Signup.tsx b/src/pages/Signup/Signup.tsx new file mode 100644 index 0000000..6ea1fc6 --- /dev/null +++ b/src/pages/Signup/Signup.tsx @@ -0,0 +1,163 @@ +import React, { useState, ChangeEvent, FormEvent } from "react"; +import axios from "axios"; +import { useNavigate } from "react-router-dom"; +import { User, Mail, Lock } from "lucide-react"; +const backendUrl = import.meta.env.VITE_BACKEND_URL; +interface SignUpFormData { + username: string; + email: string; + password: string; +} + +const SignUp: React.FC = () => { + const [formData, setFormData] = useState({ + username: "", + email: "", + password: "" + }); + const [message, setMessage] = useState(""); +const navigate = useNavigate(); + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData({ ...formData, [name]: value }); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + const response = await axios.post(`${backendUrl}/api/auth/signup`, + formData // Include cookies for session + ); + setMessage(response.data.message); // Show success message from backend + + // Navigate to login page after successful signup + if (response.data.message === 'User created successfully') { + navigate("/login");} + + + // // Simulate API call (replace with your actual backend integration) + // try { + // // Mock successful signup + // setMessage("Account created successfully! Redirecting to login..."); + + // // In your actual implementation, integrate with your backend here: + // // const response = await fetch(`${backendUrl}/api/auth/signup`, { + // // method: 'POST', + // // headers: { 'Content-Type': 'application/json' }, + // // body: JSON.stringify(formData) + // // }); + + // setTimeout(() => { + // // Navigate to login page in your actual implementation + // console.log("Redirecting to login page..."); + // }, 2000); + + } catch (error) { + setMessage("Something went wrong. Please try again."); + } + }; + + return ( +
+ {/* Background decorative elements */} +
+
+
+
+ +
+ {/* Logo and Title */} +
+
+ Logo +
+

GitHubTracker

+

Join your GitHub journey

+
+ + {/* Sign Up Form */} +
+

Create Account

+ +
+
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ + +
+ + {message && ( +
+ {message} +
+ )} + +
+

+ Already have an account?{' '} + +

+
+
+
+
+ ); +}; + +export default SignUp; \ No newline at end of file diff --git a/src/pages/UserAnalytics/UserAnalytics.tsx b/src/pages/UserAnalytics/UserAnalytics.tsx new file mode 100644 index 0000000..4f661d0 --- /dev/null +++ b/src/pages/UserAnalytics/UserAnalytics.tsx @@ -0,0 +1,131 @@ +import React, { useState, useEffect } from 'react'; +import { Container, Alert, Box,Grid } from '@mui/material'; +import { useLocation } from 'react-router-dom'; +import axios from 'axios'; +import UserForm from './components/UserAnalyticsComp/UserForm'; +import UserProfile from './components/UserAnalyticsComp/UserProfile'; +import UserStats from './components/UserAnalyticsComp/UserStats'; +import LanguageStats from './components/UserAnalyticsComp/LanguageStats'; +import ContributionStats from './components/UserAnalyticsComp/ContributionStats'; +import RepositoryTable from './components/UserAnalyticsComp/RepositoryTable'; + +const backendUrl = import.meta.env.VITE_BACKEND_URL; + +interface UserData { + profile: any; + repositories: any[]; + languageStats: Record; + contributionStats: any; + rankings: any; + highlights: any; + stars: any[]; + commitHistory: any[]; + socialStats: any; +} + +const UserAnalytics: React.FC = () => { + const location = useLocation(); + const [userData, setUserData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + // Get username and token from navigation state + const navigationState = location.state as { username?: string; token?: string } || {}; + const [initialUsername] = useState(navigationState.username || ''); + const [initialToken] = useState(navigationState.token || ''); + + // Auto-fetch data if username and token are provided from Home page + useEffect(() => { + if (initialUsername && initialToken) { + handleSubmit(initialUsername, initialToken); + } + }, [initialUsername, initialToken]); + + const handleSubmit = async (username: string, token: string) => { + setLoading(true); + setError(''); + + try { + const response = await axios.post(`${backendUrl}/api/github/user-profile`, { + username, + token + }); + setUserData(response.data); + } catch (err: any) { + setError(err.response?.data?.message || 'Error fetching user data'); + } finally { + setLoading(false); + } + }; + + return ( + + + + + {error && ( + + {error} + + )} + + {userData && ( + + + + + + + + + + + + + + )} + + + ); +}; + +export default UserAnalytics; \ No newline at end of file diff --git a/src/pages/UserAnalytics/components/UserAnalyticsComp/ContributionStats.tsx b/src/pages/UserAnalytics/components/UserAnalyticsComp/ContributionStats.tsx new file mode 100644 index 0000000..da952db --- /dev/null +++ b/src/pages/UserAnalytics/components/UserAnalyticsComp/ContributionStats.tsx @@ -0,0 +1,210 @@ +import React from 'react'; +import { Paper, Typography, Grid, Box, Chip } from '@mui/material'; +import { Calendar, GitCommit, TrendingUp } from 'lucide-react'; + +interface ContributionStatsProps { + contributionStats: { + totalContributions?: number; + longestStreak?: number; + currentStreak?: number; + mostActiveDay?: string; + averagePerDay?: number; + }; +} + +const ContributionStats: React.FC = ({ contributionStats }) => { + if (!contributionStats) { + return ( + + + Contribution Statistics + + + No contribution data available + + + ); + } + + const { + totalContributions = 0, + longestStreak = 0, + currentStreak = 0, + mostActiveDay = 'N/A', + averagePerDay = 0 + } = contributionStats; + + return ( + + + Contribution Statistics + + + + + + + + + {totalContributions.toLocaleString()} + + + Total Contributions + + + + + + + + + + + {longestStreak} + + + Longest Streak (days) + + + + + + + + + + + {currentStreak} + + + Current Streak (days) + + + + + + + + + + + ); +}; + +export default ContributionStats; diff --git a/src/pages/UserAnalytics/components/UserAnalyticsComp/LanguageStats.tsx b/src/pages/UserAnalytics/components/UserAnalyticsComp/LanguageStats.tsx new file mode 100644 index 0000000..fc897da --- /dev/null +++ b/src/pages/UserAnalytics/components/UserAnalyticsComp/LanguageStats.tsx @@ -0,0 +1,209 @@ +import React, { useState, useEffect } from 'react'; +import { Paper, Typography, Box, useTheme, useMediaQuery } from '@mui/material'; +import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts'; + +interface LanguageStatsProps { + languageStats: Record; +} + +const LanguageStats: React.FC = ({ languageStats }) => { + const [animationKey, setAnimationKey] = useState(0); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('md')); + + useEffect(() => { + setAnimationKey(prev => prev + 1); + }, [languageStats]); + + if (!languageStats || Object.keys(languageStats).length === 0) { + return ( + + + Programming Languages + + + No language data available + + + ); + } + + const totalBytes = Object.values(languageStats).reduce((sum, bytes) => sum + bytes, 0); + const languageEntries = Object.entries(languageStats) + .sort(([, a], [, b]) => b - a) + .slice(0, 8); + + const modernColors = [ + '#3b82f6', '#8b5cf6', '#06b6d4', '#10b981', + '#f59e0b', '#ef4444', '#ec4899', '#6366f1' + ]; + + const pieData = languageEntries.map(([language, bytes], index) => ({ + name: language, + value: bytes, + percentage: ((bytes / totalBytes) * 100).toFixed(1), + color: modernColors[index % modernColors.length] + })); + + const CustomTooltip = ({ active, payload }: any) => { + if (active && payload && payload.length) { + const data = payload[0].payload; + return ( + + + {data.name} + + + {data.percentage}% + + + ); + } + return null; + }; + + return ( + + + Programming Languages + + + + + + + {pieData.map((entry, index) => ( + + ))} + + } + position={{ x: 0, y: 0 }} + allowEscapeViewBox={{ x: false, y: false }} + wrapperStyle={{ + pointerEvents: 'none', + zIndex: 1000 + }} + /> + { + const dataItem = pieData.find(item => item.name === value); + return ( + + {value} ({dataItem?.percentage || '0'}%) + + ); + }} + /> + + + + + ); +}; + +export default LanguageStats; diff --git a/src/pages/UserAnalytics/components/UserAnalyticsComp/RepositoryTable.tsx b/src/pages/UserAnalytics/components/UserAnalyticsComp/RepositoryTable.tsx new file mode 100644 index 0000000..d28697e --- /dev/null +++ b/src/pages/UserAnalytics/components/UserAnalyticsComp/RepositoryTable.tsx @@ -0,0 +1,321 @@ +import React from 'react'; +import { + Paper, + Typography, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Chip, + Link, + Box, + Card, + CardContent, + Grid, + useTheme, + useMediaQuery, +} from '@mui/material'; +import { Star, GitFork, Eye } from 'lucide-react'; + +interface Repository { + name: string; + description?: string; + stars: number; + forks: number; + watchers: number; + language?: string; + html_url: string; + updated_at: string; +} + +interface RepositoryTableProps { + repositories: Repository[]; +} + +const RepositoryTable: React.FC = ({ repositories }) => { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('md')); + + if (!repositories || repositories.length === 0) { + return ( + + + Top Repositories + + + No repository data available + + + ); + } + + const formatDate = (dateString: string): string => { + return new Date(dateString).toLocaleDateString(); + }; + + const getLanguageColor = (language: string): string => { + const colors: { [key: string]: string } = { + 'JavaScript': '#1976d2', + 'TypeScript': '#42A5F5', + 'Python': '#64B5F6', + 'Java': '#90CAF9', + 'C++': '#BBDEFB', + 'C#': '#1565c0', + 'PHP': '#2196F3', + 'Ruby': '#1e88e5', + 'Go': '#1976d2', + 'Rust': '#0d47a1', + }; + return colors[language] || '#1976d2'; + }; + + return ( + + + Top Repositories + + + {isMobile ? ( + // Mobile Card View + + {repositories.slice(0, 10).map((repo, index) => ( + + + + + + {repo.name} + + {repo.description && ( + + {repo.description.length > 120 + ? `${repo.description.substring(0, 120)}...` + : repo.description} + + )} + + + + {repo.language && ( + + )} + + Updated: {formatDate(repo.updated_at)} + + + + + + + + {repo.stars} + + + + + + {repo.forks} + + + + + + {repo.watchers} + + + + + + + ))} + + ) : ( + // Desktop Table View + + + + + Repository + Language + Stars + Forks + Watchers + Updated + + + + {repositories.slice(0, 10).map((repo, index) => ( + + + + + {repo.name} + + {repo.description && ( + + {repo.description.length > 100 + ? `${repo.description.substring(0, 100)}...` + : repo.description} + + )} + + + + {repo.language && ( + + )} + + + + + + {repo.stars} + + + + + + + + {repo.forks} + + + + + + + + {repo.watchers} + + + + + + {formatDate(repo.updated_at)} + + + + ))} + +
+
+ )} +
+ ); +}; + +export default RepositoryTable; diff --git a/src/pages/UserAnalytics/components/UserAnalyticsComp/UserForm.tsx b/src/pages/UserAnalytics/components/UserAnalyticsComp/UserForm.tsx new file mode 100644 index 0000000..20c7799 --- /dev/null +++ b/src/pages/UserAnalytics/components/UserAnalyticsComp/UserForm.tsx @@ -0,0 +1,181 @@ +import React, { useState, useEffect } from 'react'; +import { Box, TextField, Button, Paper, CircularProgress } from '@mui/material'; + +interface UserFormProps { + onSubmit: (username: string, token: string) => void; + loading: boolean; + initialUsername?: string; + initialToken?: string; +} + +const UserForm: React.FC = ({ + onSubmit, + loading, + initialUsername = '', + initialToken = '' +}) => { + const [username, setUsername] = useState(initialUsername); + const [token, setToken] = useState(initialToken); + + // Update form when initial values change + useEffect(() => { + setUsername(initialUsername); + setToken(initialToken); + }, [initialUsername, initialToken]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit(username, token); + }; + + return ( + +
+ + setUsername(e.target.value)} + required + sx={{ + flex: { md: 1 }, + width: { xs: '100%', md: 'auto' }, + minWidth: { md: '280px' }, + '& .MuiOutlinedInput-root': { + borderRadius: 3, + backgroundColor: '#374151', + fontFamily: '"Inter", "SF Pro Display", "Roboto", sans-serif', + fontSize: '1rem', + '& fieldset': { + borderColor: '#6b7280', + borderWidth: '2px' + }, + '&:hover fieldset': { + borderColor: '#3b82f6', + }, + '&.Mui-focused fieldset': { + borderColor: '#3b82f6', + borderWidth: '2px' + }, + '& input': { + color: '#f9fafb', + fontWeight: 500 + }, + }, + '& .MuiInputLabel-root': { + color: '#d1d5db', + fontWeight: 500, + fontFamily: '"Inter", "SF Pro Display", "Roboto", sans-serif', + '&.Mui-focused': { + color: '#3b82f6', + fontWeight: 600 + } + } + }} + /> + setToken(e.target.value)} + type="password" + required + sx={{ + flex: { md: 1 }, + width: { xs: '100%', md: 'auto' }, + minWidth: { md: '280px' }, + '& .MuiOutlinedInput-root': { + borderRadius: 3, + backgroundColor: '#374151', + fontFamily: '"Inter", "SF Pro Display", "Roboto", sans-serif', + fontSize: '1rem', + '& fieldset': { + borderColor: '#6b7280', + borderWidth: '2px' + }, + '&:hover fieldset': { + borderColor: '#3b82f6', + }, + '&.Mui-focused fieldset': { + borderColor: '#3b82f6', + borderWidth: '2px' + }, + '& input': { + color: '#f9fafb', + fontWeight: 500 + }, + }, + '& .MuiInputLabel-root': { + color: '#d1d5db', + fontWeight: 500, + fontFamily: '"Inter", "SF Pro Display", "Roboto", sans-serif', + '&.Mui-focused': { + color: '#3b82f6', + fontWeight: 600 + } + } + }} + /> + + +
+
+ ); +}; + +export default UserForm; diff --git a/src/pages/UserAnalytics/components/UserAnalyticsComp/UserProfile.tsx b/src/pages/UserAnalytics/components/UserAnalyticsComp/UserProfile.tsx new file mode 100644 index 0000000..c41a1fd --- /dev/null +++ b/src/pages/UserAnalytics/components/UserAnalyticsComp/UserProfile.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import { Box, Paper, Avatar, Typography, Chip, Grid } from '@mui/material'; + +interface UserProfileProps { + userData: { + profile: any; + }; +} + +const UserProfile: React.FC = ({ userData }) => { + const { profile } = userData; + + return ( + + + + + + + + + + + {profile.name || profile.login} + + + @{profile.login} + + {profile.bio && ( + + {profile.bio} + + )} + + + + + + + + + + ); +}; + +export default UserProfile; diff --git a/src/pages/UserAnalytics/components/UserAnalyticsComp/UserStats.tsx b/src/pages/UserAnalytics/components/UserAnalyticsComp/UserStats.tsx new file mode 100644 index 0000000..cadb0f8 --- /dev/null +++ b/src/pages/UserAnalytics/components/UserAnalyticsComp/UserStats.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import { Grid, Paper, Typography, Box } from '@mui/material'; +import { Star, GitFork, Eye, Users } from 'lucide-react'; + +interface UserStatsProps { + userData: { + rankings: any; + highlights: any; + socialStats: any; + stars: any[]; + }; +} + +const UserStats: React.FC = ({ userData }) => { + const { rankings, socialStats, stars } = userData; + + const statCards = [ + { + title: 'Total Stars', + value: stars?.length || 0, + icon: , + color: '#3b82f6', + bgColor: '#1f2937', + gradient: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)' + }, + { + title: 'Repositories', + value: rankings?.repositoryRanking?.length || 0, + icon: , + color: '#8b5cf6', + bgColor: '#1f2937', + gradient: 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)' + }, + { + title: 'Watchers', + value: socialStats?.totalWatchers || 0, + icon: , + color: '#06b6d4', + bgColor: '#1f2937', + gradient: 'linear-gradient(135deg, #06b6d4 0%, #0891b2 100%)' + }, + { + title: 'Forks', + value: socialStats?.totalForks || 0, + icon: , + color: '#10b981', + bgColor: '#1f2937', + gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)' + } + ]; + + return ( + + {statCards.map((stat, index) => ( + + + + {stat.icon} + + + {typeof stat.value === 'number' ? stat.value.toLocaleString() : stat.value} + + + {stat.title} + + + + ))} + + ); +}; + +export default UserStats; diff --git a/src/pages/UserProfile/UserProfile.tsx b/src/pages/UserProfile/UserProfile.tsx new file mode 100644 index 0000000..abf9791 --- /dev/null +++ b/src/pages/UserProfile/UserProfile.tsx @@ -0,0 +1,57 @@ +import { useParams } from "react-router-dom"; +import { useEffect, useState } from "react"; + +type PR = { + title: string; + html_url: string; + repository_url: string; +}; + +export default function UserProfile() { + const { username } = useParams(); + const [profile, setProfile] = useState(null); + const [prs, setPRs] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function fetchData() { + if (!username) return; + + const userRes = await fetch(`https://api.github.com/users/${username}`); + const userData = await userRes.json(); + setProfile(userData); + + const prsRes = await fetch(`https://api.github.com/search/issues?q=author:${username}+type:pr`); + const prsData = await prsRes.json(); + setPRs(prsData.items); + setLoading(false); + } + + fetchData(); + }, [username]); + + if (loading) return
Loading...
; + + return ( +
+ {profile && ( +
+ +

{profile.login}

+

{profile.bio}

+
+ )} + +

Pull Requests

+ +
+ ); +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..eb98ff8 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,11 @@ +/// + +interface ImportMetaEnv { + readonly VITE_BACKEND_URL: string; + // Add other variables if needed + } + + interface ImportMeta { + readonly env: ImportMetaEnv; + } + \ No newline at end of file diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..9b2ed50 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + "./index.html", // For any HTML files in the root + "./src/**/*.{js,jsx,ts,tsx}", // For all JS/JSX/TS/TSX files inside src folder + ], + theme: { + extend: {}, + }, + plugins: [], + } diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..f867de0 --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..abcd7f0 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..8b0f57b --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], +}) From 016b42b5c84e773e08f988c8e19c46cca1c657ee Mon Sep 17 00:00:00 2001 From: ashish-choudhari-git Date: Sun, 27 Jul 2025 17:21:01 +0530 Subject: [PATCH 2/2] Updated readme.md --- README.md | 20 ++++++++++++++++++++ backend/test-api.html | 0 2 files changed, 20 insertions(+) create mode 100644 backend/test-api.html diff --git a/README.md b/README.md index d5b55a0..a65fb82 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,26 @@ $ npm start --- + +## API Endpoints + +### Backend Data Fetching + +The application uses two main API endpoints: + +#### 1. `/api/github/get-data` (Home Page) +- **Purpose**: Fetches basic GitHub data for home dashboard +- **Returns**: User issues and pull requests +- **Used in**: Home page for displaying recent activity + + +#### 2. `/api/github/user-profile` (Analytics Page) +- **Purpose**: Fetches comprehensive user analytics data +- **Returns**: Complete user profile, repositories, contribution stats, language statistics, rankings +- **Used in**: Analytics dashboard for detailed insights + +--- + ### 🌟 Coming Soon - Add options to track stars, followers, following - Add options to track engagements (e.g. comments, closing, opening and merging PRs) diff --git a/backend/test-api.html b/backend/test-api.html new file mode 100644 index 0000000..e69de29