From 2932b35a79ba490c1da2878043c7f1bd81d205d9 Mon Sep 17 00:00:00 2001 From: seif-a096 Date: Thu, 26 Mar 2026 03:12:11 +0200 Subject: [PATCH 1/3] my work --- index.html | 3 +- package-lock.json | 1242 +++++++++++++++++- package.json | 14 +- public/aossie-icon.svg | 9 + src/App.tsx | 46 +- src/components/common/TokenModal.tsx | 85 ++ src/components/dashboard/ContributorsTab.tsx | 111 ++ src/components/dashboard/F1Race.tsx | 107 ++ src/components/dashboard/LanguageChart.tsx | 57 + src/components/dashboard/OverviewTab.tsx | 178 +++ src/components/dashboard/RepoDetailPanel.tsx | 191 +++ src/components/dashboard/ReposTab.tsx | 217 +++ src/components/dashboard/StatCard.tsx | 32 + src/components/layout/Sidebar.tsx | 113 ++ src/hooks/useRateLimit.ts | 37 + src/index.css | 63 +- src/lib/utils.ts | 6 + src/main.tsx | 14 +- src/services/cache.ts | 79 ++ src/services/githubApi.ts | 109 ++ tailwind.config.js | 22 + vite.config.ts | 6 +- 22 files changed, 2701 insertions(+), 40 deletions(-) create mode 100644 public/aossie-icon.svg create mode 100644 src/components/common/TokenModal.tsx create mode 100644 src/components/dashboard/ContributorsTab.tsx create mode 100644 src/components/dashboard/F1Race.tsx create mode 100644 src/components/dashboard/LanguageChart.tsx create mode 100644 src/components/dashboard/OverviewTab.tsx create mode 100644 src/components/dashboard/RepoDetailPanel.tsx create mode 100644 src/components/dashboard/ReposTab.tsx create mode 100644 src/components/dashboard/StatCard.tsx create mode 100644 src/components/layout/Sidebar.tsx create mode 100644 src/hooks/useRateLimit.ts create mode 100644 src/lib/utils.ts create mode 100644 src/services/cache.ts create mode 100644 src/services/githubApi.ts create mode 100644 tailwind.config.js diff --git a/index.html b/index.html index d7a09f0..8a30da0 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,8 @@ - OrgExplorer + + AOSSIE | WebOrg
diff --git a/package-lock.json b/package-lock.json index ab0e168..119e266 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,26 +1,38 @@ { - "name": "orgexplorer", + "name": "OrgExplorer", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "orgexplorer", + "name": "OrgExplorer", "version": "0.0.0", "dependencies": { + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "framer-motion": "^12.38.0", + "idb": "^8.0.3", + "lucide-react": "^1.7.0", "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "react-router-dom": "^7.13.2", + "recharts": "^3.8.1", + "tailwind-merge": "^3.5.0" }, "devDependencies": { "@eslint/js": "^9.39.1", + "@tailwindcss/vite": "^4.2.2", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", + "autoprefixer": "^10.4.23", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "postcss": "^8.5.8", + "tailwindcss": "^4.1.18", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", "vite": "npm:rolldown-vite@7.2.5" @@ -57,6 +69,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -638,6 +651,42 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-beta.50", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.50.tgz", @@ -883,6 +932,551 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -939,6 +1533,69 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -959,6 +1616,7 @@ "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -967,8 +1625,9 @@ "version": "19.2.10", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", - "dev": true, + "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -983,6 +1642,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.54.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", @@ -1028,6 +1693,7 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -1279,6 +1945,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1336,6 +2003,43 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/autoprefixer": { + "version": "10.4.23", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", + "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001760", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1384,6 +2088,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1446,6 +2151,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1480,6 +2194,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1499,9 +2226,140 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1520,6 +2378,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1544,6 +2408,30 @@ "dev": true, "license": "ISC" }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1573,6 +2461,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -1751,6 +2640,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1841,6 +2736,47 @@ "dev": true, "license": "ISC" }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/framer-motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", + "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.38.0", + "motion-utils": "^12.36.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1892,6 +2828,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -1919,6 +2862,12 @@ "hermes-estree": "0.25.1" } }, + "node_modules/idb": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", + "license": "ISC" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -1929,6 +2878,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -1956,6 +2915,15 @@ "node": ">=0.8.19" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -1986,6 +2954,17 @@ "dev": true, "license": "ISC" }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2371,6 +3350,25 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz", + "integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2384,6 +3382,21 @@ "node": "*" } }, + "node_modules/motion-dom": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", + "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.36.0" + } + }, + "node_modules/motion-utils": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", + "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2520,6 +3533,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2528,9 +3542,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "dev": true, "funding": [ { @@ -2547,6 +3561,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2556,6 +3571,13 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2581,6 +3603,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -2590,6 +3613,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -2597,6 +3621,37 @@ "react": "^19.2.4" } }, + "node_modules/react-is": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -2607,6 +3662,96 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.13.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.2.tgz", + "integrity": "sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.2.tgz", + "integrity": "sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT", + "peer": true + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2673,6 +3818,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2732,6 +3883,43 @@ "node": ">=8" } }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -2766,9 +3954,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", @@ -2789,6 +3975,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2869,6 +4056,37 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "name": "rolldown-vite", "version": "7.2.5", @@ -2876,6 +4094,7 @@ "integrity": "sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@oxc-project/runtime": "0.97.0", "fdir": "^6.5.0", @@ -2998,6 +4217,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index d75669c..419f28f 100644 --- a/package.json +++ b/package.json @@ -10,19 +10,31 @@ "preview": "vite preview" }, "dependencies": { + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "framer-motion": "^12.38.0", + "idb": "^8.0.3", + "lucide-react": "^1.7.0", "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "react-router-dom": "^7.13.2", + "recharts": "^3.8.1", + "tailwind-merge": "^3.5.0" }, "devDependencies": { "@eslint/js": "^9.39.1", + "@tailwindcss/vite": "^4.2.2", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", + "autoprefixer": "^10.4.23", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "postcss": "^8.5.8", + "tailwindcss": "^4.1.18", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", "vite": "npm:rolldown-vite@7.2.5" diff --git a/public/aossie-icon.svg b/public/aossie-icon.svg new file mode 100644 index 0000000..102d7d7 --- /dev/null +++ b/public/aossie-icon.svg @@ -0,0 +1,9 @@ + + + A + {} + SIE + diff --git a/src/App.tsx b/src/App.tsx index 0a3deb1..7216a5b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,46 @@ -import './App.css' +import { Suspense, lazy, useEffect, useState } from 'react'; +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { Sidebar } from './components/layout/Sidebar'; +import { TokenModal } from './components/common/TokenModal'; + +const OverviewTab = lazy(() => import('./components/dashboard/OverviewTab').then(m => ({ default: m.OverviewTab }))); +const ReposTab = lazy(() => import('./components/dashboard/ReposTab').then(m => ({ default: m.ReposTab }))); +const ContributorsTab = lazy(() => import('./components/dashboard/ContributorsTab').then(m => ({ default: m.ContributorsTab }))); function App() { + const [showTokenModal, setShowTokenModal] = useState(false); + + useEffect(() => { + const handleLimitHit = () => { + setShowTokenModal(true); + }; + window.addEventListener('github-api-limit', handleLimitHit); + return () => window.removeEventListener('github-api-limit', handleLimitHit); + }, []); return ( - <> -

Hello, OrgExplorer!

- - ) + +
+ setShowTokenModal(true)} /> + +
+ LOADING_MODULE...
}> + + } /> + } /> + } /> + } /> + + + + + setShowTokenModal(false)} + /> + +
+ ); } -export default App +export default App; diff --git a/src/components/common/TokenModal.tsx b/src/components/common/TokenModal.tsx new file mode 100644 index 0000000..391c36e --- /dev/null +++ b/src/components/common/TokenModal.tsx @@ -0,0 +1,85 @@ +import { useState } from 'react'; +import { X, Key, ShieldCheck } from 'lucide-react'; +import { cacheService } from '../../services/cache'; + +interface TokenModalProps { + isOpen: boolean; + onClose: () => void; +} + +export function TokenModal({ isOpen, onClose }: TokenModalProps) { + const [token, setToken] = useState(''); + + if (!isOpen) return null; + + const handleSave = () => { + if (token.trim()) { + cacheService.setToken(token.trim()); + window.location.reload(); // Reload to re-fetch with token + } + }; + + return ( +
+
+
+
+ +

Connect GitHub Token

+
+ +
+ +
+

+ Required to continue browsing or to access private repositories. Your token never leaves your browser. +

+ +
+ + setToken(e.target.value)} + placeholder="ghp_..." + className="w-full bg-github-canvas sharp-border px-3 py-2 text-white placeholder:text-github-muted focus:outline-none focus:border-[var(--color-aossie-green)] transition-all font-mono" + /> +
+ +
+ +

+ We only request read:org and public_repo scopes. Store token in + + GitHub Settings + . +

+
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/components/dashboard/ContributorsTab.tsx b/src/components/dashboard/ContributorsTab.tsx new file mode 100644 index 0000000..34c4789 --- /dev/null +++ b/src/components/dashboard/ContributorsTab.tsx @@ -0,0 +1,111 @@ +import { useState, useEffect } from 'react'; +import { githubApi } from '../../services/githubApi'; +import { cacheService } from '../../services/cache'; +import { GitCommit, ExternalLink } from 'lucide-react'; + +export function ContributorsTab() { + const [contributors, setContributors] = useState([]); + const [loading, setLoading] = useState(true); + + const orgName = cacheService.getLastOrg(); + + useEffect(() => { + const fetchContributors = async () => { + setLoading(true); + try { + const repos = await githubApi.getOrgRepos(orgName); + + // Fetch contributors for the top 10 most active repos to avoid massive limits but get a good sample + const activeRepos = [...repos].sort((a, b) => new Date(b.pushed_at).getTime() - new Date(a.pushed_at).getTime()).slice(0, 10); + + const contributorPromises = activeRepos.map(r => githubApi.getRepoContributors(orgName, r.name).catch(() => [])); + const contributorsArrays = await Promise.all(contributorPromises); + + const combinedContributors: Record = {}; + contributorsArrays.flat().forEach(c => { + if (!c || !c.login) return; + if (combinedContributors[c.login]) { + combinedContributors[c.login].contributions += c.contributions; + } else { + combinedContributors[c.login] = { ...c }; + } + }); + + const sortedContributors = Object.values(combinedContributors) + .sort((a: any, b: any) => b.contributions - a.contributions); + + setContributors(sortedContributors); + } catch (error) { + console.error("Failed to fetch contributors", error); + } finally { + setLoading(false); + } + }; + fetchContributors(); + }, [orgName]); + + if (loading) { + return ( +
+
+
+ {[...Array(15)].map((_, i) => ( +
+ ))} +
+
+ ); + } + + return ( +
+
+

Top Contributors

+

+ Recent Activity +

+
+ +
+ {contributors.map((user, idx) => ( +
+
+ {user.login} +
+ #{idx + 1} +
+
+ + + {user.login} + + + +
+ + {user.contributions} COMMITS +
+
+ ))} + + {contributors.length === 0 && ( +
+ No contributors found. +
+ )} +
+
+ ); +} diff --git a/src/components/dashboard/F1Race.tsx b/src/components/dashboard/F1Race.tsx new file mode 100644 index 0000000..81af3f4 --- /dev/null +++ b/src/components/dashboard/F1Race.tsx @@ -0,0 +1,107 @@ +import { useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Flag, Trophy } from 'lucide-react'; +import { cn } from '../../lib/utils'; + +interface Contributor { + login: string; + avatar_url: string; + contributions: number; +} + +interface F1RaceProps { + contributors: Contributor[]; + className?: string; +} + +const CAR_COLORS = [ + 'bg-red-500', // Ferrari red + 'bg-[#00D2BE]', // Mercedes 'petronas' green + 'bg-blue-600', // Alpine / Williams + 'bg-[#FF8700]', // McLaren orange + 'bg-[#F596C8]', // Racing point pink +]; + +export function F1Race({ contributors, className }: F1RaceProps) { + const [hoveredIndex, setHoveredIndex] = useState(null); + + // Take top 5 for the race + const racers = contributors.slice(0, 5); + const maxContributions = Math.max(...racers.map(r => r.contributions), 1); + + if (racers.length === 0) { + return ( +
+ +

No racers (contributors) found.

+
+ ); + } + + return ( +
+
+

+ + RACE: TOP ACTIVITY +

+ +
+ +
+ {/* Track lines representation */} +
+ + {racers.map((racer, index) => { + // Calculate width percentage relative to the max contributor (winner gets 100%) + const progress = (racer.contributions / maxContributions) * 100; + const carColor = CAR_COLORS[index % CAR_COLORS.length]; + + return ( +
setHoveredIndex(index)} + onMouseLeave={() => setHoveredIndex(null)} + > + {/* Lane */} +
+ + {/* Box Car & Avatar */} + + {/* Avatar (Driver) */} +
+ {racer.login} +
+
+ + {/* Popup Details */} + + {hoveredIndex === index && ( + + {racer.login} + {racer.contributions} CMTS + + )} + +
+ ); + })} +
+ + {/* Starting line visual */} +
+
+ ); +} diff --git a/src/components/dashboard/LanguageChart.tsx b/src/components/dashboard/LanguageChart.tsx new file mode 100644 index 0000000..94ad4aa --- /dev/null +++ b/src/components/dashboard/LanguageChart.tsx @@ -0,0 +1,57 @@ +import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts'; + +interface LanguageChartProps { + data: { name: string; value: number; color: string }[]; +} + +export function LanguageChart({ data }: LanguageChartProps) { + if (data.length === 0) { + return ( +
+

No language data available

+
+ ); + } + + return ( +
+

Top Languages

+
+ + + + {data.map((entry, index) => ( + + ))} + + [`${value} repos`, 'Usage']} + /> + {value}} + /> + + +
+
+ ); +} diff --git a/src/components/dashboard/OverviewTab.tsx b/src/components/dashboard/OverviewTab.tsx new file mode 100644 index 0000000..e825f1d --- /dev/null +++ b/src/components/dashboard/OverviewTab.tsx @@ -0,0 +1,178 @@ +import { useEffect, useState } from 'react'; +import { BookMarked, Star, GitFork, AlertCircle } from 'lucide-react'; +import { StatCard } from './StatCard'; +import { LanguageChart } from './LanguageChart'; +import { F1Race } from './F1Race'; +import { githubApi } from '../../services/githubApi'; +import { cacheService } from '../../services/cache'; + +const LANGUAGE_COLORS: Record = { + JavaScript: '#f1e05a', + TypeScript: '#3178c6', + Python: '#3572A5', + Java: '#b07219', + Go: '#00ADD8', + Ruby: '#701516', + HTML: '#e34c26', + CSS: '#563d7c', + C: '#555555', + 'C++': '#f34b7d', + Lua: '#000080', +}; + +export function OverviewTab() { + const [loading, setLoading] = useState(true); + const [stats, setStats] = useState({ repos: 0, stars: 0, forks: 0, issues: 0 }); + const [languages, setLanguages] = useState([]); + const [racers, setRacers] = useState([]); + const [topRepos, setTopRepos] = useState([]); + const [orgData, setOrgData] = useState(null); + + const orgName = cacheService.getLastOrg(); + + useEffect(() => { + const fetchData = async () => { + setLoading(true); + try { + const [org, repos] = await Promise.all([ + githubApi.getOrgDetails(orgName), + githubApi.getOrgRepos(orgName) + ]); + + setOrgData(org); + + let totalStars = 0; + let totalForks = 0; + let totalIssues = 0; + const langCounts: Record = {}; + + repos.forEach((repo: any) => { + totalStars += repo.stargazers_count; + totalForks += repo.forks_count; + totalIssues += repo.open_issues_count; + if (repo.language) { + langCounts[repo.language] = (langCounts[repo.language] || 0) + 1; + } + }); + + setStats({ repos: repos.length, stars: totalStars, forks: totalForks, issues: totalIssues }); + + const formattedLangs = Object.entries(langCounts) + .sort((a, b) => b[1] - a[1]) + .slice(0, 6) + .map(([name, value]) => ({ + name, + value, + color: LANGUAGE_COLORS[name] || '#8b949e' + })); + + setLanguages(formattedLangs); + + // Top repos + const sortedRepos = [...repos].sort((a, b) => b.stargazers_count - a.stargazers_count); + setTopRepos(sortedRepos.slice(0, 5)); + + // Get contributors from top 5 most recently active repos to avoid hitting API limit quickly + const activeRepos = [...repos].sort((a, b) => new Date(b.pushed_at).getTime() - new Date(a.pushed_at).getTime()).slice(0, 5); + + const contributorPromises = activeRepos.map(r => githubApi.getRepoContributors(orgName, r.name).catch(() => [])); + const contributorsArrays = await Promise.all(contributorPromises); + + const combinedContributors: Record = {}; + contributorsArrays.flat().forEach(c => { + if (!c || !c.login) return; + if (combinedContributors[c.login]) { + combinedContributors[c.login].contributions += c.contributions; + } else { + combinedContributors[c.login] = { ...c }; + } + }); + + const topContributors = Object.values(combinedContributors) + .sort((a: any, b: any) => b.contributions - a.contributions) + .slice(0, 5); + + setRacers(topContributors); + + } catch (error) { + console.error("Failed to fetch overview data", error); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [orgName]); + + if (loading) { + return ( +
+
+
+ {[1,2,3,4].map(i =>
)} +
+
+
+
+
+
+ ); + } + + return ( +
+
+ {orgData?.avatar_url && ( + {orgName} + )} +
+

{orgData?.name || orgName}

+ {orgData?.description &&

{orgData.description}

} +
+
+ +
+ + + + +
+ +
+ + +
+ +
+
+ +
+

+ Top 5 Starred Repositories +

+
+ {topRepos.map(repo => ( +
+
+ + {repo.name} + +

{repo.description || 'No description provided.'}

+
+
+ {repo.language && ( + + + {repo.language} + + )} + {repo.stargazers_count} + {repo.forks_count} +
+
+ ))} +
+
+
+ ); +} diff --git a/src/components/dashboard/RepoDetailPanel.tsx b/src/components/dashboard/RepoDetailPanel.tsx new file mode 100644 index 0000000..9bcb4a7 --- /dev/null +++ b/src/components/dashboard/RepoDetailPanel.tsx @@ -0,0 +1,191 @@ +import { useEffect, useState } from 'react'; +import { X, Star, GitFork, AlertCircle, GitCommit, ExternalLink } from 'lucide-react'; +import { BarChart, Bar, ResponsiveContainer, Tooltip } from 'recharts'; +import { githubApi } from '../../services/githubApi'; +import { cacheService } from '../../services/cache'; + +interface RepoDetailPanelProps { + repo: any | null; + onClose: () => void; +} + +export function RepoDetailPanel({ repo, onClose }: RepoDetailPanelProps) { + const [contributors, setContributors] = useState([]); + const [activity, setActivity] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!repo) return; + + const fetchData = async () => { + setLoading(true); + const orgName = cacheService.getLastOrg(); + try { + const [contribsData, activityData] = await Promise.all([ + githubApi.getRepoContributors(orgName, repo.name).catch(() => []), + githubApi.getRepoActivity(orgName, repo.name).catch(() => []) + ]); + + setContributors(contribsData.slice(0, 5)); // Top 5 contributors for this repo + + // Format activity data for 52 weeks (last year) + if (Array.isArray(activityData) && activityData.length > 0) { + const formattedActivity = activityData.map(week => ({ + week: new Date(week.week * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }), + commits: week.total + })).slice(-52); // strict 52 weeks + setActivity(formattedActivity); + } else { + setActivity([]); + } + } catch (error) { + console.error("Failed to fetch repo details", error); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [repo]); + + if (!repo) return null; + + return ( + <> +
+ +
+
+
+

{repo.name}

+

{repo.description || 'NO DESCRIPTION'}

+
+ +
+ +
+ {/* Quick Stats */} +
+
+ + {repo.stargazers_count} + Stars +
+
+ + {repo.forks_count} + Forks +
+ + + + {repo.open_issues_count} + + Issues + +
+ + {/* Commit Activity Graph */} +
+

+ Activity [52 WEEKS] +

+
+ {loading ? ( +
+
+
+ ) : activity.length > 0 ? ( + + + + + + + ) : ( +
+ No activity data available. +
+ )} +
+
+ + {/* Top Contributors */} +
+

Top Contributors

+ {loading ? ( +
+ {[1, 2, 3].map(i => ( +
+
+
+
+
+
+
+ ))} +
+ ) : contributors.length > 0 ? ( + + ) : ( +
+ NO CONTRIBUTORS DATA. +
+ )} +
+
+ + +
+ + ); +} diff --git a/src/components/dashboard/ReposTab.tsx b/src/components/dashboard/ReposTab.tsx new file mode 100644 index 0000000..626e5f7 --- /dev/null +++ b/src/components/dashboard/ReposTab.tsx @@ -0,0 +1,217 @@ +import { useState, useEffect, useMemo } from 'react'; +import { Search, Star, GitFork, AlertCircle, ArrowUpDown, ChevronLeft, ChevronRight } from 'lucide-react'; +import { githubApi } from '../../services/githubApi'; +import { cacheService } from '../../services/cache'; +import { cn } from '../../lib/utils'; +import { RepoDetailPanel } from './RepoDetailPanel'; + +const ITEMS_PER_PAGE = 10; + +export function ReposTab() { + const [repos, setRepos] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + const [selectedRepo, setSelectedRepo] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + + const [sortField, setSortField] = useState<'stargazers_count' | 'forks_count' | 'open_issues_count' | 'updated_at'>('stargazers_count'); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); + + const orgName = cacheService.getLastOrg(); + + useEffect(() => { + const fetchRepos = async () => { + setLoading(true); + try { + const data = await githubApi.getOrgRepos(orgName); + setRepos(data); + } catch (error) { + console.error("Failed to fetch repos", error); + } finally { + setLoading(false); + } + }; + fetchRepos(); + }, [orgName]); + + const toggleSort = (field: typeof sortField) => { + if (sortField === field) { + setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); + } else { + setSortField(field); + setSortOrder('desc'); + } + setCurrentPage(1); // Reset to first page on sort change + }; + + const filteredAndSortedRepos = useMemo(() => { + return repos + .filter(repo => repo.name.toLowerCase().includes(search.toLowerCase()) || + (repo.description && repo.description.toLowerCase().includes(search.toLowerCase())) || + (repo.language && repo.language.toLowerCase().includes(search.toLowerCase()))) + .sort((a, b) => { + let aValue = a[sortField]; + let bValue = b[sortField]; + + if (sortField === 'updated_at') { + aValue = new Date(aValue).getTime(); + bValue = new Date(bValue).getTime(); + } + + if (aValue < bValue) return sortOrder === 'asc' ? -1 : 1; + if (aValue > bValue) return sortOrder === 'asc' ? 1 : -1; + return 0; + }); + }, [repos, search, sortField, sortOrder]); + + const totalPages = Math.ceil(filteredAndSortedRepos.length / ITEMS_PER_PAGE); + const paginatedRepos = filteredAndSortedRepos.slice( + (currentPage - 1) * ITEMS_PER_PAGE, + currentPage * ITEMS_PER_PAGE + ); + + // Reset to first page on search + useEffect(() => { + setCurrentPage(1); + }, [search]); + + if (loading) { + return ( +
+
+
+
+ ); + } + + return ( +
+
+

Repositories [{repos.length}]

+
+ + setSearch(e.target.value)} + className="w-full sharp-interactive bg-github-canvas pl-10 pr-3 py-2 text-sm text-white focus:outline-none focus:border-[var(--color-aossie-yellow)]" + /> +
+
+ +
+
+ + + + + + + + + + + + + {paginatedRepos.map((repo) => ( + setSelectedRepo(repo)} + > + + + + + + + + ))} + {paginatedRepos.length === 0 && ( + + + + )} + +
Repository toggleSort('stargazers_count')} + > +
Stars
+
toggleSort('forks_count')} + > +
Forks
+
toggleSort('open_issues_count')} + > +
Issues
+
Language toggleSort('updated_at')} + > +
Last Updated
+
+ + {repo.name} + + {repo.description && ( +

{repo.description}

+ )} +
+
{repo.stargazers_count}
+
+
{repo.forks_count}
+
+
{repo.open_issues_count}
+
+ {repo.language ? ( + + {repo.language} + + ) : ( + - + )} + + {new Date(repo.updated_at).toLocaleDateString()} +
+ No repositories found. +
+
+ + {/* Pagination Controls */} + {totalPages > 1 && ( +
+ + SHOWING {(currentPage - 1) * ITEMS_PER_PAGE + 1} TO {Math.min(currentPage * ITEMS_PER_PAGE, filteredAndSortedRepos.length)} OF {filteredAndSortedRepos.length} + +
+ + +
+
+ )} +
+ + setSelectedRepo(null)} + /> +
+ ); +} diff --git a/src/components/dashboard/StatCard.tsx b/src/components/dashboard/StatCard.tsx new file mode 100644 index 0000000..689f4ae --- /dev/null +++ b/src/components/dashboard/StatCard.tsx @@ -0,0 +1,32 @@ +import type { LucideIcon } from 'lucide-react'; +import { cn } from '../../lib/utils'; +import { motion } from 'framer-motion'; + +interface StatCardProps { + title: string; + value: string | number; + icon: LucideIcon; + colorClass?: string; + delay?: number; +} + +export function StatCard({ title, value, icon: Icon, colorClass = "text-github-text", delay = 0 }: StatCardProps) { + return ( + +
+
+

{title}

+

{value}

+
+
+ +
+
+
+ ); +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..14a7793 --- /dev/null +++ b/src/components/layout/Sidebar.tsx @@ -0,0 +1,113 @@ +import { NavLink, Link } from 'react-router-dom'; +import { + LayoutDashboard, + FolderGit2, + Users, + Key, + ShieldAlert +} from 'lucide-react'; +import { cn } from '../../lib/utils'; +import { useRateLimit } from '../../hooks/useRateLimit'; +import { cacheService } from '../../services/cache'; + +interface SidebarProps { + className?: string; + onConnectToken: () => void; +} + +const navItems = [ + { path: '/overview', label: 'Overview', icon: LayoutDashboard }, + { path: '/repos', label: 'Repositories', icon: FolderGit2 }, + { path: '/contributors', label: 'Contributors', icon: Users }, +]; + +export function Sidebar({ className, onConnectToken }: SidebarProps) { + const { rateLimit, isLoading } = useRateLimit(); + const hasToken = !!cacheService.getToken(); + + return ( + + ); +} diff --git a/src/hooks/useRateLimit.ts b/src/hooks/useRateLimit.ts new file mode 100644 index 0000000..2f09dba --- /dev/null +++ b/src/hooks/useRateLimit.ts @@ -0,0 +1,37 @@ +import { useState, useEffect } from 'react'; +import { githubApi } from '../services/githubApi'; +import type { RateLimitInfo } from '../services/githubApi'; + +export function useRateLimit() { + const [rateLimit, setRateLimit] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const fetchRateLimit = async () => { + setIsLoading(true); + const limitInfo = await githubApi.getRateLimit(); + setRateLimit(limitInfo); + setIsLoading(false); + }; + + useEffect(() => { + fetchRateLimit(); + + const handleLimitHit = () => { + fetchRateLimit(); + }; + + window.addEventListener('github-api-limit', handleLimitHit); + + // Poll every 5 minutes or on tab focus + const interval = setInterval(fetchRateLimit, 5 * 60 * 1000); + window.addEventListener('focus', fetchRateLimit); + + return () => { + window.removeEventListener('github-api-limit', handleLimitHit); + window.removeEventListener('focus', fetchRateLimit); + clearInterval(interval); + }; + }, []); + + return { rateLimit, isLoading, refetch: fetchRateLimit }; +} diff --git a/src/index.css b/src/index.css index e0dbee4..429e51c 100644 --- a/src/index.css +++ b/src/index.css @@ -1,15 +1,52 @@ -:root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; +@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700;800&family=Space+Mono:ital,wght@0,400;0,700;1,400&display=swap'); + +@import "tailwindcss"; + +@theme { + --color-aossie-green: #8CC63F; + --color-aossie-yellow: #F7D100; + + --color-github-dark: #0d1117; + --color-github-border: #30363d; + --color-github-canvas: #010409; + --color-github-muted: #8b949e; + --color-github-text: #c9d1d9; + + --font-sans: 'Montserrat', sans-serif; + --font-mono: 'Space Mono', monospace; } +@layer base { + body { + background-color: var(--color-github-dark); + color: var(--color-github-text); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; + } + + /* Sharp minimalist scrollbar */ + ::-webkit-scrollbar { + width: 8px; + height: 8px; + } + ::-webkit-scrollbar-track { + background: var(--color-github-canvas); + } + ::-webkit-scrollbar-thumb { + background: var(--color-github-border); + border-radius: 0; + } + ::-webkit-scrollbar-thumb:hover { + background: var(--color-aossie-green); + } +} + +@layer utilities { + .sharp-border { + @apply border border-github-border rounded-none; + } + + .sharp-interactive { + @apply border border-github-border hover:border-aossie-green transition-colors duration-200 rounded-none; + } +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..a5ef193 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/src/main.tsx b/src/main.tsx index bef5202..6169352 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,10 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' -import App from './App.tsx' +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App.tsx'; +import './index.css'; createRoot(document.getElementById('root')!).render( - + - , -) + , +); diff --git a/src/services/cache.ts b/src/services/cache.ts new file mode 100644 index 0000000..a41e68e --- /dev/null +++ b/src/services/cache.ts @@ -0,0 +1,79 @@ +import { openDB } from 'idb'; +import type { DBSchema, IDBPDatabase } from 'idb'; + +interface OrgExplorerDB extends DBSchema { + repos: { + key: string; + value: any; + }; + contributors: { + key: string; + value: any; + }; + activity: { + key: string; + value: any; + }; + metadata: { + key: string; + value: { + timestamp: number; + }; + }; +} + +let dbPromise: Promise> | null = null; + +const getDB = () => { + if (!dbPromise) { + dbPromise = openDB('orgExplorerDB', 1, { + upgrade(db) { + db.createObjectStore('repos'); + db.createObjectStore('contributors'); + db.createObjectStore('activity'); + db.createObjectStore('metadata'); + }, + }); + } + return dbPromise; +}; + +const TTL_MS = 1000 * 60 * 60; // 1 hour + +export const cacheService = { + // Local storage (long-lived state) + getToken: () => localStorage.getItem('gh_token'), + setToken: (token: string) => localStorage.setItem('gh_token', token), + removeToken: () => localStorage.removeItem('gh_token'), + + getLastOrg: () => localStorage.getItem('last_org') || 'AOSSIE-Org', + setLastOrg: (org: string) => localStorage.setItem('last_org', org), + + // IndexedDB (large datastores) + async get( + storeName: StoreName, + key: string + ): Promise { + const db = await getDB(); + const metadata = await db.get('metadata', `${storeName}_${key}`); + + if (!metadata || Date.now() - metadata.timestamp > TTL_MS) { + return null; // Cache default/miss + } + return db.get(storeName, key); + }, + + async set( + storeName: StoreName, + key: string, + value: any + ) { + const db = await getDB(); + const tx = db.transaction([storeName, 'metadata'], 'readwrite'); + await Promise.all([ + tx.objectStore(storeName).put(value, key), + tx.objectStore('metadata').put({ timestamp: Date.now() }, `${storeName}_${key}`), + tx.done, + ]); + }, +}; diff --git a/src/services/githubApi.ts b/src/services/githubApi.ts new file mode 100644 index 0000000..3511f6f --- /dev/null +++ b/src/services/githubApi.ts @@ -0,0 +1,109 @@ +import { cacheService } from './cache'; + +const API_BASE = 'https://api.github.com'; + +export interface RateLimitInfo { + limit: number; + remaining: number; + reset: number; +} + +class GitHubApiError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +export const githubApi = { + getRateLimit: async (): Promise => { + try { + const response = await fetch(`${API_BASE}/rate_limit`, { + headers: getHeaders(), + }); + const data = await response.json(); + return { + limit: data.rate.limit, + remaining: data.rate.remaining, + reset: data.rate.reset, + }; + } catch { + return null; + } + }, + + fetchWithCache: async ( + url: string, + storeName: 'repos' | 'contributors' | 'activity', + cacheKey: string + ): Promise => { + const cached = await cacheService.get(storeName, cacheKey); + if (cached) { + return cached as T; + } + + const response = await fetch(`${API_BASE}${url}`, { + headers: getHeaders(), + }); + + if (!response.ok) { + if (response.status === 403 || response.status === 401) { + // Trigger generic rate limit event + window.dispatchEvent(new CustomEvent('github-api-limit', { detail: response.headers })); + } + throw new GitHubApiError(response.status, `GitHub API Error: ${response.statusText}`); + } + + const data = await response.json(); + await cacheService.set(storeName, cacheKey, data); + return data; + }, + + getOrgDetails: async (org: string) => { + // Standard fetch, small enough to bypass idb cache or cache separately + const response = await fetch(`${API_BASE}/orgs/${org}`, { + headers: getHeaders(), + }); + if (!response.ok) throw new Error('Org not found'); + return response.json(); + }, + + getOrgRepos: async (org: string) => { + // Using fetchWithCache allows us to cache the entire repo list + // Note: A real implementation might need to handle pagination if an org has >100 repos. + // We'll fetch the first 100 for simplicity in this demo. + return githubApi.fetchWithCache( + `/orgs/${org}/repos?per_page=100&sort=pushed&direction=desc`, + 'repos', + org + ); + }, + + getRepoContributors: async (org: string, repo: string) => { + return githubApi.fetchWithCache( + `/repos/${org}/${repo}/contributors?per_page=100`, + 'contributors', + `${org}_${repo}` + ); + }, + + getRepoActivity: async (org: string, repo: string) => { + return githubApi.fetchWithCache( + `/repos/${org}/${repo}/stats/commit_activity`, + 'activity', + `${org}_${repo}` + ); + } +}; + +function getHeaders() { + const token = cacheService.getToken(); + const headers: Record = { + Accept: 'application/vnd.github.v3+json', + }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + return headers; +} diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..0654be8 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,22 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: { + colors: { + github: { + green: '#238636', + dark: '#0d1117', + border: '#30363d', + canvas: '#010409', + muted: '#8b949e', + text: '#c9d1d9' + } + } + }, + }, + plugins: [], +} diff --git a/vite.config.ts b/vite.config.ts index 8b0f57b..05bc19a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,11 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' // https://vite.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [ + tailwindcss(), + react() + ], }) From 2b55919a2c3d43ff0a89d22900cc2d07f8c82575 Mon Sep 17 00:00:00 2001 From: seif-a096 Date: Thu, 26 Mar 2026 03:17:25 +0200 Subject: [PATCH 2/3] fix: Vercel SPA routing 404 --- vercel.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 vercel.json diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..1323cda --- /dev/null +++ b/vercel.json @@ -0,0 +1,8 @@ +{ + "rewrites": [ + { + "source": "/(.*)", + "destination": "/index.html" + } + ] +} From 2ecc1a9f46ab3fa57f19fd3b6f31a561e09fb6ef Mon Sep 17 00:00:00 2001 From: seif-a096 Date: Fri, 27 Mar 2026 11:15:42 +0200 Subject: [PATCH 3/3] (feat):restyle ReposTab filters, add leaderboard & velocity analytics to OverviewTab, sync graph edge weights, and remove unused components --- .gitignore | 6 + package-lock.json | 334 +++++++++- package.json | 2 + .../dashboard/ContributorRepoGraph.tsx | 561 +++++++++++++++++ src/components/dashboard/ContributorsTab.tsx | 289 ++++++--- src/components/dashboard/F1Race.tsx | 107 ---- src/components/dashboard/OverviewTab.tsx | 581 +++++++++++++++--- src/components/dashboard/RepoDetailPanel.tsx | 125 ++-- src/components/dashboard/ReposTab.tsx | 344 +++++++++-- src/components/layout/Sidebar.tsx | 127 +++- src/services/cache.ts | 54 +- src/services/githubApi.ts | 45 +- 12 files changed, 2136 insertions(+), 439 deletions(-) create mode 100644 src/components/dashboard/ContributorRepoGraph.tsx delete mode 100644 src/components/dashboard/F1Race.tsx diff --git a/.gitignore b/.gitignore index 216a31b..854163a 100644 --- a/.gitignore +++ b/.gitignore @@ -406,3 +406,9 @@ Desktop.ini *.key *.crt *.local + + +# =============================== +# Trials +# =============================== +trials/ \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 119e266..404cf0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,14 @@ "version": "0.0.0", "dependencies": { "clsx": "^2.1.1", + "d3-force": "^3.0.0", "date-fns": "^4.1.0", "framer-motion": "^12.38.0", "idb": "^8.0.3", "lucide-react": "^1.7.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-force-graph-2d": "^1.29.1", "react-router-dom": "^7.13.2", "recharts": "^3.8.1", "tailwind-merge": "^3.5.0" @@ -1477,6 +1479,12 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@tweenjs/tween.js": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", + "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -1939,6 +1947,15 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/accessor-fn": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz", + "integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -2057,6 +2074,16 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bezier-js": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/bezier-js/-/bezier-js-6.1.4.tgz", + "integrity": "sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -2134,6 +2161,18 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-color-tracker": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz", + "integrity": "sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==", + "license": "MIT", + "dependencies": { + "tinycolor2": "^1.6.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2241,6 +2280,12 @@ "node": ">=12" } }, + "node_modules/d3-binarytree": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz", + "integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==", + "license": "MIT" + }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", @@ -2250,6 +2295,28 @@ "node": ">=12" } }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -2259,6 +2326,36 @@ "node": ">=12" } }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force-3d": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz", + "integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==", + "license": "MIT", + "dependencies": { + "d3-binarytree": "1", + "d3-dispatch": "1 - 3", + "d3-octree": "1", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-format": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", @@ -2280,6 +2377,12 @@ "node": ">=12" } }, + "node_modules/d3-octree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz", + "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==", + "license": "MIT" + }, "node_modules/d3-path": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", @@ -2289,6 +2392,15 @@ "node": ">=12" } }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-scale": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", @@ -2305,6 +2417,29 @@ "node": ">=12" } }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -2350,6 +2485,41 @@ "node": ">=12" } }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/date-fns": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", @@ -2736,6 +2906,46 @@ "dev": true, "license": "ISC" }, + "node_modules/float-tooltip": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/float-tooltip/-/float-tooltip-1.7.5.tgz", + "integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==", + "license": "MIT", + "dependencies": { + "d3-selection": "2 - 3", + "kapsule": "^1.16", + "preact": "10" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/force-graph": { + "version": "1.51.2", + "resolved": "https://registry.npmjs.org/force-graph/-/force-graph-1.51.2.tgz", + "integrity": "sha512-zZNdMqx8qIQGurgnbgYIUsdXxSfvhfRSIdncsKGv/twUOZpwCsk9hPHmdjdcme1+epATgb41G0rkIGHJ0Wydng==", + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "18 - 25", + "accessor-fn": "1", + "bezier-js": "3 - 6", + "canvas-color-tracker": "^1.3", + "d3-array": "1 - 3", + "d3-drag": "2 - 3", + "d3-force-3d": "2 - 3", + "d3-scale": "1 - 4", + "d3-scale-chromatic": "1 - 3", + "d3-selection": "2 - 3", + "d3-zoom": "2 - 3", + "float-tooltip": "^1.7", + "index-array-by": "1", + "kapsule": "^1.16", + "lodash-es": "4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -2915,6 +3125,15 @@ "node": ">=0.8.19" } }, + "node_modules/index-array-by": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz", + "integrity": "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -2954,13 +3173,21 @@ "dev": true, "license": "ISC" }, + "node_modules/jerrypick": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/jerrypick/-/jerrypick-1.1.2.tgz", + "integrity": "sha512-YKnxXEekXKzhpf7CLYA0A+oDP8V0OhICNCr5lv96FvSsDEmrb0GKM776JgQvHTMjr7DTTPEVv/1Ciaw0uEWzBA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -2969,7 +3196,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -3032,6 +3258,18 @@ "node": ">=6" } }, + "node_modules/kapsule": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz", + "integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==", + "license": "MIT", + "dependencies": { + "lodash-es": "4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3333,6 +3571,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash-es": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -3340,6 +3584,18 @@ "dev": true, "license": "MIT" }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3437,6 +3693,15 @@ "dev": true, "license": "MIT" }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3578,6 +3843,16 @@ "dev": true, "license": "MIT" }, + "node_modules/preact": { + "version": "10.29.0", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.0.tgz", + "integrity": "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -3588,6 +3863,23 @@ "node": ">= 0.8.0" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3621,6 +3913,23 @@ "react": "^19.2.4" } }, + "node_modules/react-force-graph-2d": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/react-force-graph-2d/-/react-force-graph-2d-1.29.1.tgz", + "integrity": "sha512-1Rl/1Z3xy2iTHKj6a0jRXGyiI86xUti81K+jBQZ+Oe46csaMikp47L5AjrzA9hY9fNGD63X8ffrqnvaORukCuQ==", + "license": "MIT", + "dependencies": { + "force-graph": "^1.51", + "prop-types": "15", + "react-kapsule": "^2.5" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", @@ -3628,6 +3937,21 @@ "license": "MIT", "peer": true }, + "node_modules/react-kapsule": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/react-kapsule/-/react-kapsule-2.5.7.tgz", + "integrity": "sha512-kifAF4ZPD77qZKc4CKLmozq6GY1sBzPEJTIJb0wWFK6HsePJatK3jXplZn2eeAt3x67CDozgi7/rO8fNQ/AL7A==", + "license": "MIT", + "dependencies": { + "jerrypick": "^1.1.1" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", @@ -3920,6 +4244,12 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", diff --git a/package.json b/package.json index 419f28f..11d3f25 100644 --- a/package.json +++ b/package.json @@ -11,12 +11,14 @@ }, "dependencies": { "clsx": "^2.1.1", + "d3-force": "^3.0.0", "date-fns": "^4.1.0", "framer-motion": "^12.38.0", "idb": "^8.0.3", "lucide-react": "^1.7.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-force-graph-2d": "^1.29.1", "react-router-dom": "^7.13.2", "recharts": "^3.8.1", "tailwind-merge": "^3.5.0" diff --git a/src/components/dashboard/ContributorRepoGraph.tsx b/src/components/dashboard/ContributorRepoGraph.tsx new file mode 100644 index 0000000..7d07477 --- /dev/null +++ b/src/components/dashboard/ContributorRepoGraph.tsx @@ -0,0 +1,561 @@ +import { useEffect, useMemo, useRef, useState, useCallback } from "react"; +import ForceGraph2D from "react-force-graph-2d"; + +/* ─── types ──────────────────────────────────────────────────────── */ +export type GraphNode = { + id: string; + type: "repo" | "contributor"; + label: string; + value: number; + x?: number; + y?: number; + fx?: number; + fy?: number; + avatarUrl?: string; + profileUrl?: string; + stars?: number; + forks?: number; + openIssues?: number; + reposTouched?: number; + daysSinceActive?: number; +}; + +export type GraphLink = { + source: string | GraphNode; + target: string | GraphNode; + weight: number; +}; + +interface Props { + nodes: GraphNode[]; + links: GraphLink[]; +} + +/* ─── constants ──────────────────────────────────────────────────── */ +const CANVAS_H = 720; + +/* card dimensions in graph‑space units */ +const CONTRIB_W = 120; +const CONTRIB_H = 150; +const REPO_W = 140; +const REPO_H = 90; + +const ACCENT = "#8CC63F"; // aossie-green +const ACCENT2 = "#F7D100"; // aossie-yellow +const BG_CARD = "#0d1117"; +const BG_CARD_HOVER = "#161b22"; +const BORDER = "#30363d"; +const TEXT_PRIMARY = "#e6edf3"; +const TEXT_MUTED = "#8b949e"; + +/* ─── helpers ────────────────────────────────────────────────────── */ + +/** draw a rounded rect on canvas */ +function roundRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + r: number, +) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.lineTo(x + w - r, y); + ctx.quadraticCurveTo(x + w, y, x + w, y + r); + ctx.lineTo(x + w, y + h - r); + ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); + ctx.lineTo(x + r, y + h); + ctx.quadraticCurveTo(x, y + h, x, y + h - r); + ctx.lineTo(x, y + r); + ctx.quadraticCurveTo(x, y, x + r, y); + ctx.closePath(); +} + +function truncate(s: string, max: number) { + return s.length > max ? s.slice(0, max - 1) + "…" : s; +} + +function formatNum(n: number) { + if (n >= 1000) return (n / 1000).toFixed(1) + "k"; + return String(n); +} + +/* ─── component ──────────────────────────────────────────────────── */ +export function ContributorRepoGraph({ nodes, links }: Props) { + const fgRef = useRef(null); + const containerRef = useRef(null); + const [graphWidth, setGraphWidth] = useState(980); + const [hoverNodeId, setHoverNodeId] = useState(null); + const [selectedNode, setSelectedNode] = useState(null); + const imgCache = useRef>(new Map()); + + /* which node ids are connected to hovered node */ + const hoverNeighbors = useMemo(() => { + if (!hoverNodeId) return null; + const set = new Set(); + set.add(hoverNodeId); + links.forEach((l) => { + const s = typeof l.source === "object" ? l.source.id : l.source; + const t = typeof l.target === "object" ? l.target.id : l.target; + if (s === hoverNodeId) set.add(t); + if (t === hoverNodeId) set.add(s); + }); + return set; + }, [hoverNodeId, links]); + + const graphData = useMemo(() => ({ nodes, links }), [nodes, links]); + + /* responsive width */ + useEffect(() => { + if (!containerRef.current) return; + const obs = new ResizeObserver((entries) => { + const w = entries[0]?.contentRect?.width; + if (w) setGraphWidth(Math.max(520, Math.floor(w))); + }); + obs.observe(containerRef.current); + return () => obs.disconnect(); + }, []); + + /* configure forces */ + useEffect(() => { + const fg = fgRef.current; + if (!fg) return; + + fg.d3Force("charge")?.strength(-450); + + fg.d3Force("link") + ?.distance((link: any) => { + const w = link.weight || 1; + return Math.max(200, 400 - Math.log2(w + 1) * 25); + }) + .strength(0.12); + + fg.d3Force("x") + ?.x((n: GraphNode) => (n.type === "contributor" ? -480 : 480)) + .strength(0.85); + + fg.d3Force("y") + ?.y((n: GraphNode) => { + const days = Math.max(0, Math.min(180, n.daysSinceActive || 0)); + return -300 + (days / 180) * 600; + }) + .strength(0.4); + + fg.d3ReheatSimulation(); + + const t = setTimeout(() => fg.zoomToFit?.(600, 80), 500); + return () => clearTimeout(t); + }, [graphData]); + + /* preload all contributor avatars */ + useEffect(() => { + nodes.forEach((n) => { + if (n.type === "contributor" && n.avatarUrl && !imgCache.current.has(n.avatarUrl)) { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.src = n.avatarUrl; + imgCache.current.set(n.avatarUrl, img); + } + }); + }, [nodes]); + + /* ─── canvas draw: node ──────────────────────────────────────── */ + const drawNode = useCallback( + (node: any, ctx: CanvasRenderingContext2D, globalScale: number) => { + const n = node as GraphNode; + const x = n.x ?? 0; + const y = n.y ?? 0; + const isHovered = n.id === hoverNodeId; + const dimmed = hoverNeighbors && !hoverNeighbors.has(n.id); + + ctx.save(); + if (dimmed) ctx.globalAlpha = 0.15; + + if (n.type === "contributor") { + drawContributorCard(ctx, n, x, y, isHovered, globalScale); + } else { + drawRepoCard(ctx, n, x, y, isHovered, globalScale); + } + + ctx.restore(); + }, + [hoverNodeId, hoverNeighbors], + ); + + /* ─── contributor card ─────────────────────────────────────────── */ + function drawContributorCard( + ctx: CanvasRenderingContext2D, + n: GraphNode, + cx: number, + cy: number, + hover: boolean, + _gs: number, + ) { + const w = CONTRIB_W; + const h = CONTRIB_H; + const left = cx - w / 2; + const top = cy - h / 2; + const r = 8; + + /* card bg */ + roundRect(ctx, left, top, w, h, r); + ctx.fillStyle = hover ? BG_CARD_HOVER : BG_CARD; + ctx.fill(); + + /* border — accent if hovered */ + ctx.lineWidth = hover ? 2 : 1; + ctx.strokeStyle = hover ? ACCENT : BORDER; + ctx.stroke(); + + /* avatar */ + const avatarSize = 48; + const ax = cx - avatarSize / 2; + const ay = top + 14; + + const img = n.avatarUrl ? imgCache.current.get(n.avatarUrl) : undefined; + if (img && img.complete && img.naturalWidth > 0) { + /* clip to rounded square */ + ctx.save(); + roundRect(ctx, ax, ay, avatarSize, avatarSize, 6); + ctx.clip(); + ctx.drawImage(img, ax, ay, avatarSize, avatarSize); + ctx.restore(); + + /* avatar border */ + roundRect(ctx, ax, ay, avatarSize, avatarSize, 6); + ctx.lineWidth = 1.5; + ctx.strokeStyle = ACCENT; + ctx.stroke(); + } else { + /* fallback circle with initial */ + roundRect(ctx, ax, ay, avatarSize, avatarSize, 6); + ctx.fillStyle = "#21262d"; + ctx.fill(); + ctx.strokeStyle = BORDER; + ctx.lineWidth = 1; + ctx.stroke(); + + ctx.fillStyle = ACCENT; + ctx.font = "bold 20px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText( + (n.label || "?")[0].toUpperCase(), + cx, + ay + avatarSize / 2, + ); + } + + /* username */ + ctx.fillStyle = TEXT_PRIMARY; + ctx.font = "bold 11px 'Montserrat', sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + ctx.fillText(truncate(n.label, 14), cx, ay + avatarSize + 8); + + /* commits badge */ + const badgeY = ay + avatarSize + 26; + const badgeText = `${formatNum(n.value)} commits`; + const badgeW = ctx.measureText(badgeText).width + 16; + const badgeH = 18; + const bx = cx - badgeW / 2; + + roundRect(ctx, bx, badgeY, badgeW, badgeH, 4); + ctx.fillStyle = "rgba(140, 198, 63, 0.12)"; + ctx.fill(); + ctx.strokeStyle = ACCENT; + ctx.lineWidth = 0.8; + ctx.stroke(); + + ctx.fillStyle = ACCENT; + ctx.font = "600 9px 'Space Mono', monospace"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(badgeText, cx, badgeY + badgeH / 2); + } + + /* ─── repo card ────────────────────────────────────────────────── */ + function drawRepoCard( + ctx: CanvasRenderingContext2D, + n: GraphNode, + cx: number, + cy: number, + hover: boolean, + _gs: number, + ) { + const w = REPO_W; + const h = REPO_H; + const left = cx - w / 2; + const top = cy - h / 2; + const r = 8; + + /* card bg */ + roundRect(ctx, left, top, w, h, r); + ctx.fillStyle = hover ? BG_CARD_HOVER : BG_CARD; + ctx.fill(); + + ctx.lineWidth = hover ? 2 : 1; + ctx.strokeStyle = hover ? ACCENT2 : BORDER; + ctx.stroke(); + + /* repo icon (small book icon) */ + const iconSize = 22; + const ix = cx - iconSize / 2; + const iy = top + 10; + + /* draw a simple repo/code icon */ + ctx.save(); + ctx.translate(ix, iy); + ctx.fillStyle = ACCENT2; + /* book shape */ + roundRect(ctx, 0, 0, iconSize, iconSize, 4); + ctx.fillStyle = "rgba(247, 209, 0, 0.15)"; + ctx.fill(); + ctx.strokeStyle = ACCENT2; + ctx.lineWidth = 1; + ctx.stroke(); + + /* code brackets < > inside */ + ctx.fillStyle = ACCENT2; + ctx.font = "bold 12px monospace"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("", iconSize / 2, iconSize / 2); + ctx.restore(); + + /* repo name */ + ctx.fillStyle = TEXT_PRIMARY; + ctx.font = "bold 11px 'Montserrat', sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + ctx.fillText(truncate(n.label, 18), cx, iy + iconSize + 6); + + /* stats row: ★ 12 ⑂ 3 ⚠ 5 */ + const statsY = iy + iconSize + 24; + ctx.font = "500 9px 'Space Mono', monospace"; + ctx.textBaseline = "middle"; + + const statsStr = `★${formatNum(n.stars || 0)} ⑂${formatNum(n.forks || 0)} !${formatNum(n.openIssues || 0)}`; + ctx.fillStyle = TEXT_MUTED; + ctx.textAlign = "center"; + ctx.fillText(statsStr, cx, statsY); + } + + /* ─── canvas draw: link ──────────────────────────────────────── */ + const drawLink = useCallback( + (link: any, ctx: CanvasRenderingContext2D, _gs: number) => { + const src = link.source; + const tgt = link.target; + if (!src || !tgt || src.x == null || tgt.x == null) return; + + const w = link.weight || 1; + const dimmed = + hoverNeighbors && + !hoverNeighbors.has( + typeof src === "object" ? src.id : src, + ) && + !hoverNeighbors.has( + typeof tgt === "object" ? tgt.id : tgt, + ); + + ctx.save(); + if (dimmed) ctx.globalAlpha = 0.04; + + /* thickness: log-scaled */ + const thickness = Math.max(0.8, Math.min(6, Math.log2(w + 1) * 1.2)); + /* alpha: scaled by thickness for visual consistency */ + const alpha = Math.max(0.1, Math.min(0.9, thickness / 6)); + + ctx.beginPath(); + + /* bezier curve for a smoother look */ + const midX = (src.x + tgt.x) / 2; + ctx.moveTo(src.x, src.y); + ctx.quadraticCurveTo(midX, (src.y + tgt.y) / 2 + (src.y - tgt.y) * 0.1, tgt.x, tgt.y); + + // Interpolate from a dim green to a bright vibrant green + ctx.strokeStyle = `rgba(140, 198, 63, ${alpha})`; + ctx.lineWidth = thickness; + ctx.stroke(); + + ctx.restore(); + }, + [hoverNeighbors], + ); + + /* ─── node hit area ──────────────────────────────────────────── */ + const nodePointerArea = useCallback((node: any, color: string, ctx: CanvasRenderingContext2D) => { + const n = node as GraphNode; + const w = n.type === "contributor" ? CONTRIB_W : REPO_W; + const h = n.type === "contributor" ? CONTRIB_H : REPO_H; + const left = (n.x ?? 0) - w / 2; + const top = (n.y ?? 0) - h / 2; + + ctx.fillStyle = color; + ctx.fillRect(left, top, w, h); + }, []); + + return ( +
+ setHoverNodeId(node ? (node as GraphNode).id : null)} + onNodeClick={(node: any) => { + const n = node as GraphNode; + setSelectedNode((prev) => (prev?.id === n.id ? null : n)); + }} + onBackgroundClick={() => setSelectedNode(null)} + enableNodeDrag={true} + d3AlphaDecay={0.02} + d3VelocityDecay={0.3} + /> + + {/* ── detail panel ──────────────────────────────────────── */} + {selectedNode && ( +
+ + + {selectedNode.type === "contributor" ? ( +
+
+ {selectedNode.avatarUrl ? ( + {selectedNode.label} + ) : ( +
+ {(selectedNode.label || "?")[0].toUpperCase()} +
+ )} +
+

+ {selectedNode.label} +

+

+ Contributor +

+
+
+ +
+ + + +
+ + {selectedNode.profileUrl && ( + + Open GitHub Profile → + + )} +
+ ) : ( +
+
+
+ {""} +
+
+

+ {selectedNode.label} +

+

+ Repository +

+
+
+ +
+ + + +
+ + +
+ )} +
+ )} + + {/* ── legend ────────────────────────────────────────────── */} +
+ + + Contributor + + + + Repository + + + + Contribution + +
+
+ ); +} + +/* ── tiny stat block ─────────────────────────────────────────────── */ +function Stat({ label, value, accent }: { label: string; value: string; accent?: boolean }) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} diff --git a/src/components/dashboard/ContributorsTab.tsx b/src/components/dashboard/ContributorsTab.tsx index 34c4789..b5157ed 100644 --- a/src/components/dashboard/ContributorsTab.tsx +++ b/src/components/dashboard/ContributorsTab.tsx @@ -1,110 +1,237 @@ -import { useState, useEffect } from 'react'; -import { githubApi } from '../../services/githubApi'; -import { cacheService } from '../../services/cache'; -import { GitCommit, ExternalLink } from 'lucide-react'; +import { useState, useEffect } from "react"; +import { githubApi } from "../../services/githubApi"; +import { cacheService } from "../../services/cache"; +import { + ContributorRepoGraph, + type GraphNode, + type GraphLink, +} from "./ContributorRepoGraph"; + +/* ─────────────────────────────────────────────────────────────────── */ export function ContributorsTab() { - const [contributors, setContributors] = useState([]); + const [nodes, setNodes] = useState([]); + const [links, setLinks] = useState([]); const [loading, setLoading] = useState(true); + const [stats, setStats] = useState({ contributors: 0, repos: 0, edges: 0 }); - const orgName = cacheService.getLastOrg(); + const orgNames = cacheService.getLastOrgs(); useEffect(() => { - const fetchContributors = async () => { + let cancelled = false; + + const load = async () => { setLoading(true); try { - const repos = await githubApi.getOrgRepos(orgName); - - // Fetch contributors for the top 10 most active repos to avoid massive limits but get a good sample - const activeRepos = [...repos].sort((a, b) => new Date(b.pushed_at).getTime() - new Date(a.pushed_at).getTime()).slice(0, 10); - - const contributorPromises = activeRepos.map(r => githubApi.getRepoContributors(orgName, r.name).catch(() => [])); - const contributorsArrays = await Promise.all(contributorPromises); - - const combinedContributors: Record = {}; - contributorsArrays.flat().forEach(c => { - if (!c || !c.login) return; - if (combinedContributors[c.login]) { - combinedContributors[c.login].contributions += c.contributions; - } else { - combinedContributors[c.login] = { ...c }; + /* 1. fetch repos for all tracked orgs */ + const repoArrays = await Promise.all( + orgNames.map((org) => githubApi.getOrgRepos(org).catch(() => [])), + ); + const allRepos = repoArrays.flat(); + + /* 2. pick top‑20 most recently active repos */ + const activeRepos = [...allRepos] + .sort( + (a, b) => + new Date(b.pushed_at).getTime() - new Date(a.pushed_at).getTime(), + ) + .slice(0, 20); + + /* 3. build repo nodes */ + const repoNodes: GraphNode[] = activeRepos.map((repo: any) => ({ + id: `repo:${repo.full_name}`, + type: "repo", + label: repo.name, + value: Math.max( + 1, + repo.stargazers_count + repo.forks_count + repo.open_issues_count, + ), + stars: repo.stargazers_count, + forks: repo.forks_count, + openIssues: repo.open_issues_count, + daysSinceActive: Math.floor( + (Date.now() - new Date(repo.pushed_at).getTime()) / + (1000 * 60 * 60 * 24), + ), + })); + + /* 4. fetch contributors per repo */ + const contribsByRepo = await Promise.all( + activeRepos.map((repo: any) => + githubApi + .getRepoContributors( + repo.owner?.login || repo.owner_name, + repo.name, + ) + .catch(() => []), + ), + ); + + /* 5. aggregate contributor data + build links */ + const contribMap: Record< + string, + { + id: string; + label: string; + totalCommits: number; + avatarUrl: string; + profileUrl: string; + repos: Set; + recencyDays: number[]; } + > = {}; + + const rawLinks: GraphLink[] = []; + + activeRepos.forEach((repo: any, i: number) => { + const repoId = `repo:${repo.full_name}`; + const repoDays = Math.floor( + (Date.now() - new Date(repo.pushed_at).getTime()) / + (1000 * 60 * 60 * 24), + ); + + (contribsByRepo[i] || []).forEach((c: any) => { + if (!c?.login) return; + const cid = `contrib:${c.login}`; + + if (!contribMap[cid]) { + contribMap[cid] = { + id: cid, + label: c.login, + totalCommits: 0, + avatarUrl: c.avatar_url, + profileUrl: c.html_url, + repos: new Set(), + recencyDays: [], + }; + } + + contribMap[cid].totalCommits += c.contributions; + contribMap[cid].repos.add(repoId); + contribMap[cid].recencyDays.push(repoDays); + + rawLinks.push({ + source: cid, + target: repoId, + weight: c.contributions, + }); + }); }); - const sortedContributors = Object.values(combinedContributors) - .sort((a: any, b: any) => b.contributions - a.contributions); - - setContributors(sortedContributors); - } catch (error) { - console.error("Failed to fetch contributors", error); + /* 6. pick top 50 contributors by commits */ + const contribNodes: GraphNode[] = Object.values(contribMap) + .sort((a, b) => b.totalCommits - a.totalCommits) + .slice(0, 50) + .map((entry) => { + const avgRecency = + entry.recencyDays.length > 0 + ? Math.floor( + entry.recencyDays.reduce((s, d) => s + d, 0) / + entry.recencyDays.length, + ) + : 90; + return { + id: entry.id, + type: "contributor" as const, + label: entry.label, + value: entry.totalCommits, + avatarUrl: entry.avatarUrl, + profileUrl: entry.profileUrl, + reposTouched: entry.repos.size, + daysSinceActive: avgRecency, + }; + }); + + /* 7. prune links: keep top‑4 per contributor, min weight 1 */ + const contribIds = new Set(contribNodes.map((n) => n.id)); + const repoIds = new Set(repoNodes.map((n) => n.id)); + + const byContrib = new Map(); + rawLinks.forEach((l) => { + const s = typeof l.source === "string" ? l.source : l.source.id; + const t = typeof l.target === "string" ? l.target : l.target.id; + if (!contribIds.has(s) || !repoIds.has(t)) return; + const arr = byContrib.get(s) || []; + arr.push(l); + byContrib.set(s, arr); + }); + + const prunedLinks = Array.from(byContrib.values()) + .flatMap((ls) => + ls + .sort((a, b) => b.weight - a.weight) + .slice(0, 4), + ) + .filter((l) => l.weight >= 1); + + if (cancelled) return; + + setNodes([...repoNodes, ...contribNodes]); + setLinks(prunedLinks); + setStats({ + contributors: contribNodes.length, + repos: repoNodes.length, + edges: prunedLinks.length, + }); + } catch (err) { + console.error("ContributorsTab fetch failed:", err); } finally { - setLoading(false); + if (!cancelled) setLoading(false); } }; - fetchContributors(); - }, [orgName]); + load(); + return () => { + cancelled = true; + }; + }, [JSON.stringify(orgNames)]); + + /* ─── loading skeleton ──────────────────────────────────────── */ if (loading) { return (
-
-
- {[...Array(15)].map((_, i) => ( -
- ))} -
+
+
); } + /* ─── render ────────────────────────────────────────────────── */ return (
-
-

Top Contributors

-

- Recent Activity -

-
- -
- {contributors.map((user, idx) => ( - + + {/* graph container */} +
+

+ Edge thickness = contribution volume · Recently active nodes float higher +

+ +
); diff --git a/src/components/dashboard/F1Race.tsx b/src/components/dashboard/F1Race.tsx deleted file mode 100644 index 81af3f4..0000000 --- a/src/components/dashboard/F1Race.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { useState } from 'react'; -import { motion, AnimatePresence } from 'framer-motion'; -import { Flag, Trophy } from 'lucide-react'; -import { cn } from '../../lib/utils'; - -interface Contributor { - login: string; - avatar_url: string; - contributions: number; -} - -interface F1RaceProps { - contributors: Contributor[]; - className?: string; -} - -const CAR_COLORS = [ - 'bg-red-500', // Ferrari red - 'bg-[#00D2BE]', // Mercedes 'petronas' green - 'bg-blue-600', // Alpine / Williams - 'bg-[#FF8700]', // McLaren orange - 'bg-[#F596C8]', // Racing point pink -]; - -export function F1Race({ contributors, className }: F1RaceProps) { - const [hoveredIndex, setHoveredIndex] = useState(null); - - // Take top 5 for the race - const racers = contributors.slice(0, 5); - const maxContributions = Math.max(...racers.map(r => r.contributions), 1); - - if (racers.length === 0) { - return ( -
- -

No racers (contributors) found.

-
- ); - } - - return ( -
-
-

- - RACE: TOP ACTIVITY -

- -
- -
- {/* Track lines representation */} -
- - {racers.map((racer, index) => { - // Calculate width percentage relative to the max contributor (winner gets 100%) - const progress = (racer.contributions / maxContributions) * 100; - const carColor = CAR_COLORS[index % CAR_COLORS.length]; - - return ( -
setHoveredIndex(index)} - onMouseLeave={() => setHoveredIndex(null)} - > - {/* Lane */} -
- - {/* Box Car & Avatar */} - - {/* Avatar (Driver) */} -
- {racer.login} -
-
- - {/* Popup Details */} - - {hoveredIndex === index && ( - - {racer.login} - {racer.contributions} CMTS - - )} - -
- ); - })} -
- - {/* Starting line visual */} -
-
- ); -} diff --git a/src/components/dashboard/OverviewTab.tsx b/src/components/dashboard/OverviewTab.tsx index e825f1d..c58af71 100644 --- a/src/components/dashboard/OverviewTab.tsx +++ b/src/components/dashboard/OverviewTab.tsx @@ -1,51 +1,98 @@ -import { useEffect, useState } from 'react'; -import { BookMarked, Star, GitFork, AlertCircle } from 'lucide-react'; -import { StatCard } from './StatCard'; -import { LanguageChart } from './LanguageChart'; -import { F1Race } from './F1Race'; -import { githubApi } from '../../services/githubApi'; -import { cacheService } from '../../services/cache'; +import { useEffect, useState } from "react"; +import { + BookMarked, + Star, + GitFork, + AlertCircle, + Activity, + TrendingDown, +} from "lucide-react"; +import { + ResponsiveContainer, + LineChart, + CartesianGrid, + XAxis, + YAxis, + Tooltip, + Line, +} from "recharts"; +import { StatCard } from "./StatCard"; +import { LanguageChart } from "./LanguageChart"; +import { githubApi } from "../../services/githubApi"; +import { cacheService } from "../../services/cache"; const LANGUAGE_COLORS: Record = { - JavaScript: '#f1e05a', - TypeScript: '#3178c6', - Python: '#3572A5', - Java: '#b07219', - Go: '#00ADD8', - Ruby: '#701516', - HTML: '#e34c26', - CSS: '#563d7c', - C: '#555555', - 'C++': '#f34b7d', - Lua: '#000080', + JavaScript: "#f1e05a", + TypeScript: "#3178c6", + Python: "#3572A5", + Java: "#b07219", + Go: "#00ADD8", + Ruby: "#701516", + HTML: "#e34c26", + CSS: "#563d7c", + C: "#555555", + "C++": "#f34b7d", + Lua: "#000080", }; export function OverviewTab() { const [loading, setLoading] = useState(true); - const [stats, setStats] = useState({ repos: 0, stars: 0, forks: 0, issues: 0 }); + const [stats, setStats] = useState({ + repos: 0, + stars: 0, + forks: 0, + issues: 0, + }); const [languages, setLanguages] = useState([]); - const [racers, setRacers] = useState([]); - const [topRepos, setTopRepos] = useState([]); + const [activitySeries, setActivitySeries] = useState([]); + const [stagnantRepos, setStagnantRepos] = useState([]); + const [showStagnantRepos, setShowStagnantRepos] = useState(false); + const [pinStagnantRepos, setPinStagnantRepos] = useState(false); const [orgData, setOrgData] = useState(null); - - const orgName = cacheService.getLastOrg(); + const [openSourceIndicator, setOpenSourceIndicator] = useState({ + percent: 0, + contributorsLast3Months: 0, + totalContributors: 0, + status: "N/A", + }); + const [topContributors, setTopContributors] = useState([]); + const [projectVelocity, setProjectVelocity] = useState({ + avgCommitsPerWeek: 0, + totalCommitsLast3Months: 0, + busFactor: 0, + }); + + const orgNames = cacheService.getLastOrgs(); useEffect(() => { const fetchData = async () => { setLoading(true); try { - const [org, repos] = await Promise.all([ - githubApi.getOrgDetails(orgName), - githubApi.getOrgRepos(orgName) - ]); + const orgsData = await Promise.all( + orgNames.map((org) => githubApi.getOrgDetails(org).catch(() => null)), + ); + const validOrgs = orgsData.filter(Boolean); + const reposArrays = await Promise.all( + orgNames.map((org) => githubApi.getOrgRepos(org).catch(() => [])), + ); + + const combinedOrgData = validOrgs.length > 0 ? validOrgs[0] : null; // simplified display of org details + if (validOrgs.length > 1) { + combinedOrgData.name = validOrgs + .map((o) => o.name || o.login) + .join(" + "); + combinedOrgData.description = + "Combined statistics for multiple organizations"; + } + + setOrgData(combinedOrgData); - setOrgData(org); - let totalStars = 0; let totalForks = 0; let totalIssues = 0; const langCounts: Record = {}; + const repos = reposArrays.flat(); repos.forEach((repo: any) => { totalStars += repo.stargazers_count; totalForks += repo.forks_count; @@ -55,7 +102,12 @@ export function OverviewTab() { } }); - setStats({ repos: repos.length, stars: totalStars, forks: totalForks, issues: totalIssues }); + setStats({ + repos: repos.length, + stars: totalStars, + forks: totalForks, + issues: totalIssues, + }); const formattedLangs = Object.entries(langCounts) .sort((a, b) => b[1] - a[1]) @@ -63,37 +115,180 @@ export function OverviewTab() { .map(([name, value]) => ({ name, value, - color: LANGUAGE_COLORS[name] || '#8b949e' + color: LANGUAGE_COLORS[name] || "#8b949e", })); - + setLanguages(formattedLangs); - // Top repos - const sortedRepos = [...repos].sort((a, b) => b.stargazers_count - a.stargazers_count); - setTopRepos(sortedRepos.slice(0, 5)); + const activeRepos = [...repos] + .sort( + (a, b) => + new Date(b.pushed_at).getTime() - new Date(a.pushed_at).getTime(), + ) + .slice(0, 12); - // Get contributors from top 5 most recently active repos to avoid hitting API limit quickly - const activeRepos = [...repos].sort((a, b) => new Date(b.pushed_at).getTime() - new Date(a.pushed_at).getTime()).slice(0, 5); - - const contributorPromises = activeRepos.map(r => githubApi.getRepoContributors(orgName, r.name).catch(() => [])); - const contributorsArrays = await Promise.all(contributorPromises); - - const combinedContributors: Record = {}; - contributorsArrays.flat().forEach(c => { - if (!c || !c.login) return; - if (combinedContributors[c.login]) { - combinedContributors[c.login].contributions += c.contributions; - } else { - combinedContributors[c.login] = { ...c }; + const today = new Date(); + const twelveWeeksAgo = new Date( + today.getTime() - 84 * 24 * 60 * 60 * 1000, + ); + const weekBuckets = Array.from({ length: 12 }).map((_, idx) => { + const pointDate = new Date( + twelveWeeksAgo.getTime() + idx * 7 * 24 * 60 * 60 * 1000, + ); + return { + weekStart: pointDate, + label: pointDate.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }), + prsOpened: 0, + issuesOpened: 0, + forks: 0, + activeTotal: 0, + }; + }); + + const stagnant = repos + .filter((repo: any) => { + if (repo.archived) return false; + const lastCommitAgeDays = + (Date.now() - new Date(repo.pushed_at).getTime()) / + (1000 * 60 * 60 * 24); + return repo.open_issues_count > 0 && lastCommitAgeDays >= 90; + }) + .sort((a: any, b: any) => b.open_issues_count - a.open_issues_count) + .slice(0, 8); + setStagnantRepos(stagnant); + + const eventsByRepo = await Promise.all( + activeRepos.map((repo: any) => + githubApi + .getRepoEvents(repo.owner?.login || repo.owner_name, repo.name) + .catch(() => []), + ), + ); + + const contributorsLast3Months = new Set(); + + eventsByRepo.flat().forEach((event: any) => { + if (!event?.created_at || !event?.type) { + return; + } + const eventDate = new Date(event.created_at); + if (eventDate < twelveWeeksAgo || eventDate > today) { + return; + } + const weekIndex = Math.min( + 11, + Math.floor( + (eventDate.getTime() - twelveWeeksAgo.getTime()) / + (7 * 24 * 60 * 60 * 1000), + ), + ); + const weekBucket = weekBuckets[weekIndex]; + if (!weekBucket) { + return; + } + + if (event.type === "ForkEvent") { + weekBucket.forks += 1; } + if ( + event.type === "PullRequestEvent" && + event.payload?.action === "opened" + ) { + weekBucket.prsOpened += 1; + } + if ( + event.type === "IssuesEvent" && + event.payload?.action === "opened" + ) { + weekBucket.issuesOpened += 1; + } + + if (event.actor?.login) { + contributorsLast3Months.add(event.actor.login); + } + }); + + weekBuckets.forEach((bucket) => { + bucket.activeTotal = + bucket.forks + bucket.prsOpened + bucket.issuesOpened; }); + setActivitySeries(weekBuckets); - const topContributors = Object.values(combinedContributors) - .sort((a: any, b: any) => b.contributions - a.contributions) - .slice(0, 5); + const contributorsByRepo = await Promise.all( + activeRepos.map((repo: any) => + githubApi + .getRepoContributors( + repo.owner?.login || repo.owner_name, + repo.name, + ) + .catch(() => []), + ), + ); + + const totalContributorsSet = new Set(); + contributorsByRepo.flat().forEach((contributor: any) => { + if (contributor?.login) { + totalContributorsSet.add(contributor.login); + } + }); + + const totalContributors = totalContributorsSet.size; + const contributors3M = contributorsLast3Months.size; + const percent = + totalContributors > 0 + ? Math.round((contributors3M / totalContributors) * 100) + : 0; + const status = + percent >= 60 ? "Healthy" : percent >= 35 ? "Watch" : "At Risk"; + + setOpenSourceIndicator({ + percent, + contributorsLast3Months: contributors3M, + totalContributors, + status, + }); + + // --- NEW: Top Contributors across all repos --- + const contribMap: Record = {}; + contributorsByRepo.flat().forEach((c: any) => { + if (!c?.login) return; + if (!contribMap[c.login]) { + contribMap[c.login] = { contributions: 0, avatar_url: c.avatar_url, html_url: c.html_url }; + } + contribMap[c.login].contributions += c.contributions; + }); + + const sortedTopContribs = Object.entries(contribMap) + .sort((a, b) => b[1].contributions - a[1].contributions) + .slice(0, 5) + .map(([login, data]) => ({ login, ...data })); - setRacers(topContributors); + setTopContributors(sortedTopContribs); + // --- NEW: Project Velocity & Bus Factor --- + const totalCommitsLast3Months = weekBuckets.reduce((sum, b) => sum + b.activeTotal, 0); // Active score as proxy for velocity + const avgCommitsPerWeek = Math.round(totalCommitsLast3Months / 12); + + // Simple Bus Factor: number of contributors who do > 40% of work + let runningSum = 0; + let busFactor = 0; + const allContribsSorted = Object.values(contribMap).sort((a, b) => b.contributions - a.contributions); + const totalAllCommits = allContribsSorted.reduce((sum, c) => sum + c.contributions, 0); + + for (const c of allContribsSorted) { + runningSum += c.contributions; + busFactor++; + if (runningSum >= totalAllCommits * 0.5) break; + } + + setProjectVelocity({ + avgCommitsPerWeek, + totalCommitsLast3Months, + busFactor: busFactor || 0, + }); } catch (error) { console.error("Failed to fetch overview data", error); } finally { @@ -102,14 +297,16 @@ export function OverviewTab() { }; fetchData(); - }, [orgName]); + }, [JSON.stringify(orgNames)]); if (loading) { return (
- {[1,2,3,4].map(i =>
)} + {[1, 2, 3, 4].map((i) => ( +
+ ))}
@@ -123,54 +320,274 @@ export function OverviewTab() {
{orgData?.avatar_url && ( - {orgName} + Org avatar )}
-

{orgData?.name || orgName}

- {orgData?.description &&

{orgData.description}

} +

+ {orgData?.name || orgNames.join(", ")} +

+ {orgData?.description && ( +

+ {orgData.description} +

+ )}
- - - - + + + +
- +
- +

+ Organization Activity (Last 3 Months) +

+

+ Active score = forks + PRs opened + issues opened +

+
+ + + + + + [ + Number(value ?? 0), + String(name), + ]} + labelFormatter={(label) => `Week of ${label}`} + /> + + + + + + +

- Top 5 Starred Repositories + {" "} + Organization Analytics

-
- {topRepos.map(repo => ( -
-
- - {repo.name} - -

{repo.description || 'No description provided.'}

+
+
+ {(showStagnantRepos || pinStagnantRepos) && ( +
+

+ Stagnant Repository List +

+
+ {stagnantRepos.length > 0 ? ( + stagnantRepos.map((repo: any) => ( + +
+ {repo.name} +
+
+ Issues: {repo.open_issues_count} + + Last Commit:{" "} + {new Date(repo.pushed_at).toLocaleDateString()} + +
+
+ )) + ) : ( +
+ No stagnant repositories detected from current data. +
+ )} +
-
- {repo.language && ( - - - {repo.language} - - )} - {repo.stargazers_count} - {repo.forks_count} + )} +
setShowStagnantRepos(true)} + onMouseLeave={() => setShowStagnantRepos(false)} + onClick={() => setPinStagnantRepos((prev) => !prev)} + > +

+ Potentially + Stagnant Repos +

+

+ Repos with open issues but no commits in the last 3 months. +

+
+ {stagnantRepos.length}
+

+ Hover or click to inspect repos +

- ))} +
+
+

+ Open + Source Indicator +

+

+ Ratio of contributors active in the last 3 months. +

+
+ {openSourceIndicator.percent}%{" "} + + {openSourceIndicator.status} + +
+

+ {openSourceIndicator.contributorsLast3Months} active of{" "} + {openSourceIndicator.totalContributors} contributors +

+
+
+

+ Project Velocity +

+

+ Avg activity points per week and core team size. +

+
+ {projectVelocity.avgCommitsPerWeek} + Points / Wk +
+
+ + BUS FACTOR: {projectVelocity.busFactor} + + + Nodes for 50% work + +
+
+
+ + {/* Top Contributors Leaderboard */} +
+

+ Top contributors [All-Time Across Featured Repos] +

+
diff --git a/src/components/dashboard/RepoDetailPanel.tsx b/src/components/dashboard/RepoDetailPanel.tsx index 9bcb4a7..1191fe7 100644 --- a/src/components/dashboard/RepoDetailPanel.tsx +++ b/src/components/dashboard/RepoDetailPanel.tsx @@ -1,8 +1,15 @@ -import { useEffect, useState } from 'react'; -import { X, Star, GitFork, AlertCircle, GitCommit, ExternalLink } from 'lucide-react'; -import { BarChart, Bar, ResponsiveContainer, Tooltip } from 'recharts'; -import { githubApi } from '../../services/githubApi'; -import { cacheService } from '../../services/cache'; +import { useEffect, useState } from "react"; +import { + X, + Star, + GitFork, + AlertCircle, + GitCommit, + ExternalLink, +} from "lucide-react"; +import { BarChart, Bar, ResponsiveContainer, Tooltip } from "recharts"; +import { githubApi } from "../../services/githubApi"; +import { cacheService } from "../../services/cache"; interface RepoDetailPanelProps { repo: any | null; @@ -19,21 +26,26 @@ export function RepoDetailPanel({ repo, onClose }: RepoDetailPanelProps) { const fetchData = async () => { setLoading(true); - const orgName = cacheService.getLastOrg(); + const orgName = repo.owner?.login || cacheService.getLastOrgs()[0]; try { const [contribsData, activityData] = await Promise.all([ githubApi.getRepoContributors(orgName, repo.name).catch(() => []), - githubApi.getRepoActivity(orgName, repo.name).catch(() => []) + githubApi.getRepoActivity(orgName, repo.name).catch(() => []), ]); setContributors(contribsData.slice(0, 5)); // Top 5 contributors for this repo - + // Format activity data for 52 weeks (last year) if (Array.isArray(activityData) && activityData.length > 0) { - const formattedActivity = activityData.map(week => ({ - week: new Date(week.week * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }), - commits: week.total - })).slice(-52); // strict 52 weeks + const formattedActivity = activityData + .map((week) => ({ + week: new Date(week.week * 1000).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }), + commits: week.total, + })) + .slice(-52); // strict 52 weeks setActivity(formattedActivity); } else { setActivity([]); @@ -52,18 +64,22 @@ export function RepoDetailPanel({ repo, onClose }: RepoDetailPanelProps) { return ( <> -
- +
-

{repo.name}

-

{repo.description || 'NO DESCRIPTION'}

+

+ {repo.name} +

+

+ {repo.description || "NO DESCRIPTION"} +

- + )} +
+ {/* ── category filter bar ──────────────────────────────────── */} +
+ + Filter: + + {CATEGORY_OPTIONS.map((option) => { + const isActive = categoryFilter === option.value; + const count = option.value + ? repos.filter((repo) => { + const text = [ + repo.name || "", + repo.description || "", + ...(repo.topics || []), + ] + .join(" ") + .toLowerCase(); + return CATEGORY_KEYWORDS[option.value]?.some((kw) => + text.includes(kw), + ); + }).length + : repos.length; + + return ( + + ); + })} +
+
- - - - {paginatedRepos.map((repo) => ( - setSelectedRepo(repo)} > @@ -145,17 +337,28 @@ export function ReposTab() { {repo.name} {repo.description && ( -

{repo.description}

+

+ {repo.description} +

)} - @@ -181,23 +387,39 @@ export function ReposTab() {
Repository toggleSort('stargazers_count')} + onClick={() => toggleSort("stargazers_count")} > -
Stars
+
+ Stars{" "} + +
toggleSort('forks_count')} + onClick={() => toggleSort("forks_count")} > -
Forks
+
+ Forks{" "} + +
toggleSort('open_issues_count')} + onClick={() => toggleSort("open_issues_count")} > -
Issues
+
+ Issues{" "} + +
Language toggleSort('updated_at')} + onClick={() => toggleSort("updated_at")} > -
Last Updated
+
+ Last Updated{" "} + +
-
{repo.stargazers_count}
+
+ {" "} + {repo.stargazers_count} +
-
{repo.forks_count}
+
+ {" "} + {repo.forks_count} +
-
{repo.open_issues_count}
+
+ {" "} + {repo.open_issues_count} +
{repo.language ? ( @@ -173,7 +376,10 @@ export function ReposTab() { ))} {paginatedRepos.length === 0 && (
+ No repositories found.
- + {/* Pagination Controls */} {totalPages > 1 && (
- SHOWING {(currentPage - 1) * ITEMS_PER_PAGE + 1} TO {Math.min(currentPage * ITEMS_PER_PAGE, filteredAndSortedRepos.length)} OF {filteredAndSortedRepos.length} + SHOWING{" "} + + {(currentPage - 1) * ITEMS_PER_PAGE + 1} + {" "} + TO{" "} + + {Math.min( + currentPage * ITEMS_PER_PAGE, + filteredAndSortedRepos.length, + )} + {" "} + OF{" "} + + {filteredAndSortedRepos.length} +
- setSelectedRepo(null)} + setSelectedRepo(null)} />
); diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 14a7793..aefba63 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -1,14 +1,18 @@ -import { NavLink, Link } from 'react-router-dom'; -import { - LayoutDashboard, - FolderGit2, - Users, +import { NavLink, Link } from "react-router-dom"; +import { useState } from "react"; +import type { FormEvent } from "react"; +import { + LayoutDashboard, + FolderGit2, + Users, Key, - ShieldAlert -} from 'lucide-react'; -import { cn } from '../../lib/utils'; -import { useRateLimit } from '../../hooks/useRateLimit'; -import { cacheService } from '../../services/cache'; + ShieldAlert, + Search, + RefreshCw, +} from "lucide-react"; +import { cn } from "../../lib/utils"; +import { useRateLimit } from "../../hooks/useRateLimit"; +import { cacheService } from "../../services/cache"; interface SidebarProps { className?: string; @@ -16,27 +20,71 @@ interface SidebarProps { } const navItems = [ - { path: '/overview', label: 'Overview', icon: LayoutDashboard }, - { path: '/repos', label: 'Repositories', icon: FolderGit2 }, - { path: '/contributors', label: 'Contributors', icon: Users }, + { path: "/overview", label: "Overview", icon: LayoutDashboard }, + { path: "/repos", label: "Repositories", icon: FolderGit2 }, + { path: "/contributors", label: "Contributors", icon: Users }, ]; export function Sidebar({ className, onConnectToken }: SidebarProps) { const { rateLimit, isLoading } = useRateLimit(); const hasToken = !!cacheService.getToken(); + const [orgsInput, setOrgsInput] = useState( + cacheService.getLastOrgs().join(", "), + ); + + const handleOrgSubmit = (e: FormEvent) => { + e.preventDefault(); + if (!orgsInput.trim()) return; + const orgs = orgsInput + .split(",") + .map((o) => o.trim()) + .filter(Boolean); + if (orgs.length > 0) { + cacheService.setLastOrgs(orgs); + window.location.reload(); + } + }; + + const clearCacheAndRefresh = async () => { + // Basic forceful refresh, could be made more elegant + const dbs = await window.indexedDB.databases(); + for (const db of dbs) { + if (db.name) { + window.indexedDB.deleteDatabase(db.name); + } + } + window.location.reload(); + }; + return (
- +
RATE_LIMIT - - [{isLoading ? '...' : `${rateLimit?.remaining || 0}/${rateLimit?.limit || 0}`}] + + [ + {isLoading + ? "..." + : `${rateLimit?.remaining || 0}/${rateLimit?.limit || 0}`} + ]
- +
-
0.2 ? "bg-[var(--color-aossie-green)]" : "bg-red-500" + rateLimit && rateLimit.remaining / rateLimit.limit > 0.2 + ? "bg-[var(--color-aossie-green)]" + : "bg-red-500", )} - style={{ - width: rateLimit ? `${(rateLimit.remaining / rateLimit.limit) * 100}%` : '0%' + style={{ + width: rateLimit + ? `${(rateLimit.remaining / rateLimit.limit) * 100}%` + : "0%", }} />
{!hasToken && ( - )} + +
diff --git a/src/services/cache.ts b/src/services/cache.ts index a41e68e..187ffd7 100644 --- a/src/services/cache.ts +++ b/src/services/cache.ts @@ -1,5 +1,5 @@ -import { openDB } from 'idb'; -import type { DBSchema, IDBPDatabase } from 'idb'; +import { openDB } from "idb"; +import type { DBSchema, IDBPDatabase } from "idb"; interface OrgExplorerDB extends DBSchema { repos: { @@ -26,12 +26,12 @@ let dbPromise: Promise> | null = null; const getDB = () => { if (!dbPromise) { - dbPromise = openDB('orgExplorerDB', 1, { + dbPromise = openDB("orgExplorerDB", 1, { upgrade(db) { - db.createObjectStore('repos'); - db.createObjectStore('contributors'); - db.createObjectStore('activity'); - db.createObjectStore('metadata'); + db.createObjectStore("repos"); + db.createObjectStore("contributors"); + db.createObjectStore("activity"); + db.createObjectStore("metadata"); }, }); } @@ -42,37 +42,51 @@ const TTL_MS = 1000 * 60 * 60; // 1 hour export const cacheService = { // Local storage (long-lived state) - getToken: () => localStorage.getItem('gh_token'), - setToken: (token: string) => localStorage.setItem('gh_token', token), - removeToken: () => localStorage.removeItem('gh_token'), + getToken: () => localStorage.getItem("gh_token"), + setToken: (token: string) => localStorage.setItem("gh_token", token), + removeToken: () => localStorage.removeItem("gh_token"), - getLastOrg: () => localStorage.getItem('last_org') || 'AOSSIE-Org', - setLastOrg: (org: string) => localStorage.setItem('last_org', org), + getLastOrgs: (): string[] => { + const saved = localStorage.getItem("last_orgs"); + if (saved) { + try { + return JSON.parse(saved); + } catch (e) { + return ["AOSSIE-Org"]; + } + } + const old = localStorage.getItem("last_org"); + return old ? [old] : ["AOSSIE-Org"]; + }, + setLastOrgs: (orgs: string[]) => + localStorage.setItem("last_orgs", JSON.stringify(orgs)), // IndexedDB (large datastores) - async get( + async get( storeName: StoreName, - key: string + key: string, ): Promise { const db = await getDB(); - const metadata = await db.get('metadata', `${storeName}_${key}`); - + const metadata = await db.get("metadata", `${storeName}_${key}`); + if (!metadata || Date.now() - metadata.timestamp > TTL_MS) { return null; // Cache default/miss } return db.get(storeName, key); }, - async set( + async set( storeName: StoreName, key: string, - value: any + value: any, ) { const db = await getDB(); - const tx = db.transaction([storeName, 'metadata'], 'readwrite'); + const tx = db.transaction([storeName, "metadata"], "readwrite"); await Promise.all([ tx.objectStore(storeName).put(value, key), - tx.objectStore('metadata').put({ timestamp: Date.now() }, `${storeName}_${key}`), + tx + .objectStore("metadata") + .put({ timestamp: Date.now() }, `${storeName}_${key}`), tx.done, ]); }, diff --git a/src/services/githubApi.ts b/src/services/githubApi.ts index 3511f6f..80986ae 100644 --- a/src/services/githubApi.ts +++ b/src/services/githubApi.ts @@ -1,6 +1,6 @@ -import { cacheService } from './cache'; +import { cacheService } from "./cache"; -const API_BASE = 'https://api.github.com'; +const API_BASE = "https://api.github.com"; export interface RateLimitInfo { limit: number; @@ -35,8 +35,8 @@ export const githubApi = { fetchWithCache: async ( url: string, - storeName: 'repos' | 'contributors' | 'activity', - cacheKey: string + storeName: "repos" | "contributors" | "activity", + cacheKey: string, ): Promise => { const cached = await cacheService.get(storeName, cacheKey); if (cached) { @@ -50,9 +50,14 @@ export const githubApi = { if (!response.ok) { if (response.status === 403 || response.status === 401) { // Trigger generic rate limit event - window.dispatchEvent(new CustomEvent('github-api-limit', { detail: response.headers })); + window.dispatchEvent( + new CustomEvent("github-api-limit", { detail: response.headers }), + ); } - throw new GitHubApiError(response.status, `GitHub API Error: ${response.statusText}`); + throw new GitHubApiError( + response.status, + `GitHub API Error: ${response.statusText}`, + ); } const data = await response.json(); @@ -65,7 +70,7 @@ export const githubApi = { const response = await fetch(`${API_BASE}/orgs/${org}`, { headers: getHeaders(), }); - if (!response.ok) throw new Error('Org not found'); + if (!response.ok) throw new Error("Org not found"); return response.json(); }, @@ -75,32 +80,40 @@ export const githubApi = { // We'll fetch the first 100 for simplicity in this demo. return githubApi.fetchWithCache( `/orgs/${org}/repos?per_page=100&sort=pushed&direction=desc`, - 'repos', - org + "repos", + org, ); }, getRepoContributors: async (org: string, repo: string) => { return githubApi.fetchWithCache( `/repos/${org}/${repo}/contributors?per_page=100`, - 'contributors', - `${org}_${repo}` + "contributors", + `${org}_${repo}`, ); }, - + getRepoActivity: async (org: string, repo: string) => { return githubApi.fetchWithCache( `/repos/${org}/${repo}/stats/commit_activity`, - 'activity', - `${org}_${repo}` + "activity", + `${org}_${repo}`, ); - } + }, + + getRepoEvents: async (org: string, repo: string) => { + return githubApi.fetchWithCache( + `/repos/${org}/${repo}/events?per_page=100`, + "activity", + `${org}_${repo}_events`, + ); + }, }; function getHeaders() { const token = cacheService.getToken(); const headers: Record = { - Accept: 'application/vnd.github.v3+json', + Accept: "application/vnd.github.v3+json", }; if (token) { headers.Authorization = `Bearer ${token}`;