From 30b17ece0246f3fead6a96c6c5271c2e3ca2ec44 Mon Sep 17 00:00:00 2001 From: Kunal Bunkar Date: Mon, 23 Mar 2026 23:47:20 +0530 Subject: [PATCH 1/3] Implement GitHub API caching with TTL --- package-lock.json | 494 +++++++++++++++++++++++++++++++++++++----- package.json | 5 +- src/App.css | 151 ++++++++++++- src/App.tsx | 133 +++++++++++- src/api/github.ts | 96 ++++++++ src/cache/orgCache.ts | 142 ++++++++++++ vite.config.ts | 6 +- 7 files changed, 962 insertions(+), 65 deletions(-) create mode 100644 src/api/github.ts create mode 100644 src/cache/orgCache.ts diff --git a/package-lock.json b/package-lock.json index ab0e168..a55b189 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,20 @@ { - "name": "orgexplorer", + "name": "OrgExplorer", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "orgexplorer", + "name": "OrgExplorer", "version": "0.0.0", "dependencies": { "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "react-router-dom": "^7.13.2" }, "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", @@ -21,6 +23,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "tailwindcss": "^4.2.2", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", "vite": "npm:rolldown-vite@7.2.5" @@ -883,6 +886,278 @@ "dev": true, "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/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", @@ -1480,6 +1755,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", @@ -1544,6 +1832,20 @@ "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/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1892,6 +2194,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", @@ -1986,6 +2295,16 @@ "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", + "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", @@ -2078,9 +2397,9 @@ } }, "node_modules/lightningcss": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", - "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "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": { @@ -2094,23 +2413,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.31.1", - "lightningcss-darwin-arm64": "1.31.1", - "lightningcss-darwin-x64": "1.31.1", - "lightningcss-freebsd-x64": "1.31.1", - "lightningcss-linux-arm-gnueabihf": "1.31.1", - "lightningcss-linux-arm64-gnu": "1.31.1", - "lightningcss-linux-arm64-musl": "1.31.1", - "lightningcss-linux-x64-gnu": "1.31.1", - "lightningcss-linux-x64-musl": "1.31.1", - "lightningcss-win32-arm64-msvc": "1.31.1", - "lightningcss-win32-x64-msvc": "1.31.1" + "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/lightningcss-android-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", - "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "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" ], @@ -2129,9 +2448,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", - "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "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" ], @@ -2150,9 +2469,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", - "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "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" ], @@ -2171,9 +2490,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", - "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "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" ], @@ -2192,9 +2511,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", - "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "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" ], @@ -2213,9 +2532,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", - "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "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" ], @@ -2234,9 +2553,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", - "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "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" ], @@ -2255,9 +2574,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", - "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "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" ], @@ -2276,9 +2595,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", - "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "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" ], @@ -2297,9 +2616,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", - "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "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" ], @@ -2318,9 +2637,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", - "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "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" ], @@ -2371,6 +2690,16 @@ "yallist": "^3.0.2" } }, + "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", @@ -2607,6 +2936,44 @@ "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/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2673,6 +3040,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 +3105,27 @@ "node": ">=8" } }, + "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.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.1.tgz", + "integrity": "sha512-b+u3CEM6FjDHru+nhUSjDofpWSBp2rINziJWgApm72wwGasQ/wKXftZe4tI2Y5HPv6OpzXSZHOFq87H4vfsgsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "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 d75669c..cf2a6ff 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,12 @@ }, "dependencies": { "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "react-router-dom": "^7.13.2" }, "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", @@ -23,6 +25,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "tailwindcss": "^4.2.2", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", "vite": "npm:rolldown-vite@7.2.5" diff --git a/src/App.css b/src/App.css index 027945e..53831a4 100644 --- a/src/App.css +++ b/src/App.css @@ -1,6 +1,147 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; +.app { + min-height: 100vh; + display: grid; + place-items: center; + padding: 1.5rem; +} + +.panel { + width: 100%; + max-width: 760px; + border: 1px solid #2a2a2a; + border-radius: 16px; + background: #171717; + box-shadow: 0 12px 38px rgba(0, 0, 0, 0.32); + padding: 1.25rem; +} + +.title { + margin: 0; + font-size: 1.8rem; +} + +.subtitle { + color: #a0a0a0; + margin-top: 0.4rem; +} + +.searchForm { + margin-top: 1rem; + display: flex; + gap: 0.6rem; + flex-wrap: wrap; +} + +.searchInput { + flex: 1; + min-width: 220px; + padding: 0.7rem 0.8rem; + border-radius: 10px; + border: 1px solid #3b3b3b; + background: #0f0f0f; + color: #f3f3f3; +} + +.searchInput:focus { + outline: 2px solid #60a5fa; + outline-offset: 1px; +} + +.button { + border: 0; + border-radius: 10px; + padding: 0.7rem 0.9rem; + font-weight: 600; + cursor: pointer; + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.button:disabled { + opacity: 0.65; + cursor: not-allowed; +} + +.button.primary { + background: #2563eb; + color: #fff; +} + +.button.ghost { + background: #0f0f0f; + color: #e8e8e8; + border: 1px solid #3b3b3b; +} + +.error { + margin-top: 0.8rem; + color: #fca5a5; +} + +.card { + margin-top: 1rem; + border: 1px solid #2f2f2f; + border-radius: 14px; + padding: 1rem; + background: #111111; +} + +.cardHeader { + display: flex; + gap: 0.85rem; + align-items: center; +} + +.avatar { + width: 64px; + height: 64px; + border-radius: 12px; +} + +.handle { + margin: 0.15rem 0 0; + color: #a0a0a0; +} + +.description { + margin: 0.9rem 0; + color: #d6d6d6; +} + +.stats { + margin: 0; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 0.7rem; +} + +.stats div { + padding: 0.55rem; + border: 1px solid #2d2d2d; + border-radius: 10px; +} + +.stats dt { + color: #9f9f9f; + font-size: 0.82rem; +} + +.stats dd { + margin: 0.2rem 0 0; + font-weight: 600; +} + +.meta { + margin-top: 0.8rem; + color: #9f9f9f; + font-size: 0.88rem; +} + +.actions { + margin-top: 0.9rem; + display: flex; + gap: 0.6rem; + flex-wrap: wrap; } \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 0a3deb1..73aaff0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,133 @@ -import './App.css' +import { useState, type FormEvent } from "react"; +import "./App.css"; +import { getOrganization, refreshOrganization } from "./cache/orgCache"; +import type { OrgResult } from "./api/github"; + +function formatDate(iso: string): string { + try { + return new Intl.DateTimeFormat(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }).format(new Date(iso)); + } catch { + return iso; + } +} function App() { + const [query, setQuery] = useState(""); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + + async function handleSearch(event: FormEvent) { + event.preventDefault(); + setError(null); + setLoading(true); + + try { + const org = await getOrganization(query); + setResult(org); + } catch (err) { + setResult(null); + setError(err instanceof Error ? err.message : "Failed to fetch organization."); + } finally { + setLoading(false); + } + } + + async function handleRefresh() { + if (!result) return; + setRefreshing(true); + setError(null); + try { + const refreshed = await refreshOrganization(result.org.login); + setResult(refreshed); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to refresh organization."); + } finally { + setRefreshing(false); + } + } return ( - <> -

Hello, OrgExplorer!

- - ) +
+
+

Org Explorer

+

+ Search a GitHub organization and cache results with TTL + IndexedDB. +

+ +
+ setQuery(e.target.value)} + disabled={loading || refreshing} + /> + +
+ + {error &&

{error}

} + + {result && ( +
+
+ +
+

{result.org.name || result.org.login}

+

@{result.org.login}

+
+
+ + {result.org.description &&

{result.org.description}

} + +
+
+
Public repos
+
{result.org.public_repos.toLocaleString()}
+
+
+
Followers
+
{result.org.followers.toLocaleString()}
+
+
+
Created
+
{formatDate(result.org.created_at)}
+
+
+
Source
+
{result.source === "cache" ? "Cache (IndexedDB)" : "GitHub API"}
+
+
+ +

+ Last fetched: {new Date(result.cachedAt).toLocaleTimeString()} +

+ +
+ + Open on GitHub + + +
+
+ )} +
+
+ ); } -export default App +export default App; diff --git a/src/api/github.ts b/src/api/github.ts new file mode 100644 index 0000000..14420ba --- /dev/null +++ b/src/api/github.ts @@ -0,0 +1,96 @@ +const API_BASE = "https://api.github.com"; +const RATE_LIMIT_KEY = "org-explorer:rate-limit"; + +export interface GitHubOrg { + login: string; + id: number; + avatar_url: string; + html_url: string; + name: string | null; + location: string | null; + blog: string | null; + description: string | null; + public_repos: number; + followers: number; + created_at: string; +} + +export interface OrgResult { + org: GitHubOrg; + source: "cache" | "network"; + cachedAt: number; +} + +interface RateLimitWindow { + blockedUntil: number; + remaining: number | null; +} + +function getRateLimitWindow(): RateLimitWindow | null { + try { + const raw = localStorage.getItem(RATE_LIMIT_KEY); + if (!raw) return null; + return JSON.parse(raw) as RateLimitWindow; + } catch { + return null; + } +} + +function setRateLimitWindow(headers: Headers) { + const remaining = headers.get("x-ratelimit-remaining"); + const reset = headers.get("x-ratelimit-reset"); + if (!remaining || !reset) return; + + const remainingNum = Number(remaining); + const resetEpochMs = Number(reset) * 1000; + if (Number.isNaN(remainingNum) || Number.isNaN(resetEpochMs)) return; + + const value: RateLimitWindow = { + remaining: remainingNum, + blockedUntil: remainingNum <= 0 ? resetEpochMs : 0, + }; + localStorage.setItem(RATE_LIMIT_KEY, JSON.stringify(value)); +} + +async function parseError(response: Response): Promise { + try { + const body = await response.json(); + if (body && typeof body.message === "string") return body.message; + } catch { + // ignore parse issues and fall back to status text + } + return response.statusText || `Request failed (${response.status})`; +} + +function getRateLimitGuardError(): string | null { + const rateLimit = getRateLimitWindow(); + if (!rateLimit || !rateLimit.blockedUntil) return null; + if (Date.now() >= rateLimit.blockedUntil) return null; + + const waitMinutes = Math.max( + 1, + Math.ceil((rateLimit.blockedUntil - Date.now()) / 60000), + ); + return `GitHub rate limit reached. Try again in about ${waitMinutes} minute(s), or use refresh later.`; +} + +export async function getOrganizationFromGitHub( + login: string, +): Promise { + const rateLimitError = getRateLimitGuardError(); + if (rateLimitError) throw new Error(rateLimitError); + + const response = await fetch(`${API_BASE}/orgs/${encodeURIComponent(login)}`, { + headers: { + Accept: "application/vnd.github+json", + }, + }); + + setRateLimitWindow(response.headers); + + if (!response.ok) { + throw new Error(await parseError(response)); + } + + return (await response.json()) as GitHubOrg; +} diff --git a/src/cache/orgCache.ts b/src/cache/orgCache.ts new file mode 100644 index 0000000..e0cebb6 --- /dev/null +++ b/src/cache/orgCache.ts @@ -0,0 +1,142 @@ +import { getOrganizationFromGitHub, type OrgResult } from "../api/github"; + +const DB_NAME = "org-explorer-cache"; +const STORE_NAME = "organizations"; +const DB_VERSION = 1; +const DEFAULT_TTL_MS = 10 * 60 * 1000; +const META_PREFIX = "org-explorer:meta:"; + +interface CachedOrgRecord { + login: string; + data: OrgResult["org"]; + cachedAt: number; + expiresAt: number; +} + +interface CachedMeta { + cachedAt: number; + expiresAt: number; +} + +function metaKey(login: string): string { + return `${META_PREFIX}${login.toLowerCase()}`; +} + +function getMeta(login: string): CachedMeta | null { + try { + const raw = localStorage.getItem(metaKey(login)); + if (!raw) return null; + return JSON.parse(raw) as CachedMeta; + } catch { + return null; + } +} + +function setMeta(login: string, meta: CachedMeta): void { + localStorage.setItem(metaKey(login), JSON.stringify(meta)); +} + +function clearMeta(login: string): void { + localStorage.removeItem(metaKey(login)); +} + +function openDatabase(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: "login" }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => + reject(request.error ?? new Error("Failed to open IndexedDB")); + }); +} + +async function readCached(login: string): Promise { + const db = await openDatabase(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readonly"); + const store = tx.objectStore(STORE_NAME); + const request = store.get(login.toLowerCase()); + request.onsuccess = () => { + resolve((request.result as CachedOrgRecord | undefined) ?? null); + }; + request.onerror = () => + reject(request.error ?? new Error("Failed to read cached organization")); + }); +} + +async function writeCached(record: CachedOrgRecord): Promise { + const db = await openDatabase(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readwrite"); + tx.oncomplete = () => resolve(); + tx.onerror = () => + reject(tx.error ?? new Error("Failed to write cached organization")); + tx.objectStore(STORE_NAME).put(record); + }); +} + +async function removeCached(login: string): Promise { + const db = await openDatabase(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readwrite"); + tx.oncomplete = () => resolve(); + tx.onerror = () => + reject(tx.error ?? new Error("Failed to clear cached organization")); + tx.objectStore(STORE_NAME).delete(login.toLowerCase()); + }); +} + +export async function getOrganization( + rawLogin: string, + options?: { forceRefresh?: boolean; ttlMs?: number }, +): Promise { + const login = rawLogin.trim().toLowerCase(); + if (!login) throw new Error("Please enter an organization login."); + + const forceRefresh = options?.forceRefresh ?? false; + const ttlMs = options?.ttlMs ?? DEFAULT_TTL_MS; + + if (!forceRefresh) { + const meta = getMeta(login); + if (meta && Date.now() <= meta.expiresAt) { + const cached = await readCached(login); + if (cached) { + return { + org: cached.data, + source: "cache", + cachedAt: cached.cachedAt, + }; + } + } + } + + const org = await getOrganizationFromGitHub(login); + const cachedAt = Date.now(); + const expiresAt = cachedAt + ttlMs; + const record: CachedOrgRecord = { + login, + data: org, + cachedAt, + expiresAt, + }; + await writeCached(record); + setMeta(login, { cachedAt, expiresAt }); + + return { org, source: "network", cachedAt }; +} + +export async function refreshOrganization(login: string): Promise { + return getOrganization(login, { forceRefresh: true }); +} + +export async function clearOrganizationCache(login: string): Promise { + const cleaned = login.trim().toLowerCase(); + if (!cleaned) return; + clearMeta(cleaned); + await removeCached(cleaned); +} diff --git a/vite.config.ts b/vite.config.ts index 8b0f57b..0e43ae8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,7 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; // https://vite.dev/config/ export default defineConfig({ plugins: [react()], -}) +}); From 3a74df0057f28dc6fe10cf52f3ff286a9457ba78 Mon Sep 17 00:00:00 2001 From: Kunal Bunkar Date: Tue, 24 Mar 2026 00:17:48 +0530 Subject: [PATCH 2/3] resolve conflicts --- src/App.css | 12 ++++++++++++ src/App.tsx | 35 +++++++++++++++++++---------------- src/api/github.ts | 6 +++++- src/cache/orgCache.ts | 42 +++++++++++++++++++++++++++++++----------- src/i18n/strings.ts | 20 ++++++++++++++++++++ vite.config.ts | 3 ++- 6 files changed, 89 insertions(+), 29 deletions(-) create mode 100644 src/i18n/strings.ts diff --git a/src/App.css b/src/App.css index 53831a4..2d7e3a8 100644 --- a/src/App.css +++ b/src/App.css @@ -32,6 +32,18 @@ flex-wrap: wrap; } +.srOnly { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .searchInput { flex: 1; min-width: 220px; diff --git a/src/App.tsx b/src/App.tsx index 73aaff0..f3bc637 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { useState, type FormEvent } from "react"; import "./App.css"; import { getOrganization, refreshOrganization } from "./cache/orgCache"; import type { OrgResult } from "./api/github"; +import { uiText } from "./i18n/strings"; function formatDate(iso: string): string { try { @@ -32,7 +33,7 @@ function App() { setResult(org); } catch (err) { setResult(null); - setError(err instanceof Error ? err.message : "Failed to fetch organization."); + setError(err instanceof Error ? err.message : uiText.fetchErrorFallback); } finally { setLoading(false); } @@ -46,7 +47,7 @@ function App() { const refreshed = await refreshOrganization(result.org.login); setResult(refreshed); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to refresh organization."); + setError(err instanceof Error ? err.message : uiText.refreshErrorFallback); } finally { setRefreshing(false); } @@ -55,21 +56,23 @@ function App() { return (
-

Org Explorer

-

- Search a GitHub organization and cache results with TTL + IndexedDB. -

+

{uiText.appTitle}

+

{uiText.appSubtitle}

+ setQuery(e.target.value)} disabled={loading || refreshing} />
@@ -89,30 +92,30 @@ function App() {
-
Public repos
+
{uiText.publicRepos}
{result.org.public_repos.toLocaleString()}
-
Followers
+
{uiText.followers}
{result.org.followers.toLocaleString()}
-
Created
+
{uiText.created}
{formatDate(result.org.created_at)}
-
Source
-
{result.source === "cache" ? "Cache (IndexedDB)" : "GitHub API"}
+
{uiText.source}
+
{result.source === "cache" ? uiText.sourceCache : uiText.sourceApi}

- Last fetched: {new Date(result.cachedAt).toLocaleTimeString()} + {uiText.lastFetched}: {new Date(result.cachedAt).toLocaleTimeString()}

- Open on GitHub + {uiText.openGitHub}
diff --git a/src/api/github.ts b/src/api/github.ts index 14420ba..f6530b2 100644 --- a/src/api/github.ts +++ b/src/api/github.ts @@ -49,7 +49,11 @@ function setRateLimitWindow(headers: Headers) { remaining: remainingNum, blockedUntil: remainingNum <= 0 ? resetEpochMs : 0, }; - localStorage.setItem(RATE_LIMIT_KEY, JSON.stringify(value)); + try { + localStorage.setItem(RATE_LIMIT_KEY, JSON.stringify(value)); + } catch { + // Best-effort only: cache persistence must never break API flow. + } } async function parseError(response: Response): Promise { diff --git a/src/cache/orgCache.ts b/src/cache/orgCache.ts index e0cebb6..da9f02d 100644 --- a/src/cache/orgCache.ts +++ b/src/cache/orgCache.ts @@ -33,11 +33,19 @@ function getMeta(login: string): CachedMeta | null { } function setMeta(login: string, meta: CachedMeta): void { - localStorage.setItem(metaKey(login), JSON.stringify(meta)); + try { + localStorage.setItem(metaKey(login), JSON.stringify(meta)); + } catch { + // Best-effort only. + } } function clearMeta(login: string): void { - localStorage.removeItem(metaKey(login)); + try { + localStorage.removeItem(metaKey(login)); + } catch { + // Best-effort only. + } } function openDatabase(): Promise { @@ -104,13 +112,17 @@ export async function getOrganization( if (!forceRefresh) { const meta = getMeta(login); if (meta && Date.now() <= meta.expiresAt) { - const cached = await readCached(login); - if (cached) { - return { - org: cached.data, - source: "cache", - cachedAt: cached.cachedAt, - }; + try { + const cached = await readCached(login); + if (cached) { + return { + org: cached.data, + source: "cache", + cachedAt: cached.cachedAt, + }; + } + } catch { + // Cache read failure should never block network fallback. } } } @@ -124,7 +136,11 @@ export async function getOrganization( cachedAt, expiresAt, }; - await writeCached(record); + try { + await writeCached(record); + } catch { + // Ignore cache write errors; return fresh data anyway. + } setMeta(login, { cachedAt, expiresAt }); return { org, source: "network", cachedAt }; @@ -138,5 +154,9 @@ export async function clearOrganizationCache(login: string): Promise { const cleaned = login.trim().toLowerCase(); if (!cleaned) return; clearMeta(cleaned); - await removeCached(cleaned); + try { + await removeCached(cleaned); + } catch { + // Best-effort only. + } } diff --git a/src/i18n/strings.ts b/src/i18n/strings.ts new file mode 100644 index 0000000..b9f07ee --- /dev/null +++ b/src/i18n/strings.ts @@ -0,0 +1,20 @@ +export const uiText = { + appTitle: "Org Explorer", + appSubtitle: "Search a GitHub organization and cache results with TTL + IndexedDB.", + searchLabel: "GitHub organization login", + searchPlaceholder: "Enter organization login, e.g. github", + searchLoading: "Searching...", + searchIdle: "Search", + fetchErrorFallback: "Failed to fetch organization.", + refreshErrorFallback: "Failed to refresh organization.", + publicRepos: "Public repos", + followers: "Followers", + created: "Created", + source: "Source", + sourceCache: "Cache (IndexedDB)", + sourceApi: "GitHub API", + lastFetched: "Last fetched", + openGitHub: "Open on GitHub", + refreshLoading: "Refreshing...", + refreshIdle: "Refresh data", +}; diff --git a/vite.config.ts b/vite.config.ts index 0e43ae8..4ff4f8f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,8 @@ 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: [react(), tailwindcss()], }); From aa20c9e88d2f41a8cb5b739201029272826aa038 Mon Sep 17 00:00:00 2001 From: Kunal Bunkar Date: Tue, 24 Mar 2026 00:30:53 +0530 Subject: [PATCH 3/3] API resilience with TTL cache, IndexedDB persistence, manual refresh, rate-limit safeguards, request timeout, and accessibility/i18n updates --- src/App.css | 2 +- src/api/github.ts | 24 +++++++++++++++++++----- src/cache/orgCache.ts | 29 ++++++++++++++++++++++++----- 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/src/App.css b/src/App.css index 2d7e3a8..e62f472 100644 --- a/src/App.css +++ b/src/App.css @@ -39,7 +39,7 @@ padding: 0; margin: -1px; overflow: hidden; - clip: rect(0, 0, 0, 0); + clip-path: inset(50%); white-space: nowrap; border: 0; } diff --git a/src/api/github.ts b/src/api/github.ts index f6530b2..79f6feb 100644 --- a/src/api/github.ts +++ b/src/api/github.ts @@ -1,5 +1,6 @@ const API_BASE = "https://api.github.com"; const RATE_LIMIT_KEY = "org-explorer:rate-limit"; +const REQUEST_TIMEOUT_MS = 15_000; export interface GitHubOrg { login: string; @@ -84,11 +85,24 @@ export async function getOrganizationFromGitHub( const rateLimitError = getRateLimitGuardError(); if (rateLimitError) throw new Error(rateLimitError); - const response = await fetch(`${API_BASE}/orgs/${encodeURIComponent(login)}`, { - headers: { - Accept: "application/vnd.github+json", - }, - }); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + let response: Response; + try { + response = await fetch(`${API_BASE}/orgs/${encodeURIComponent(login)}`, { + headers: { + Accept: "application/vnd.github+json", + }, + signal: controller.signal, + }); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + throw new Error("Request timed out. Please try again."); + } + throw error; + } finally { + clearTimeout(timeoutId); + } setRateLimitWindow(response.headers); diff --git a/src/cache/orgCache.ts b/src/cache/orgCache.ts index da9f02d..5c2d39b 100644 --- a/src/cache/orgCache.ts +++ b/src/cache/orgCache.ts @@ -69,11 +69,20 @@ async function readCached(login: string): Promise { const tx = db.transaction(STORE_NAME, "readonly"); const store = tx.objectStore(STORE_NAME); const request = store.get(login.toLowerCase()); + let result: CachedOrgRecord | null = null; request.onsuccess = () => { - resolve((request.result as CachedOrgRecord | undefined) ?? null); + result = (request.result as CachedOrgRecord | undefined) ?? null; }; request.onerror = () => reject(request.error ?? new Error("Failed to read cached organization")); + tx.oncomplete = () => { + db.close(); + resolve(result); + }; + tx.onerror = () => { + db.close(); + reject(tx.error ?? new Error("Failed to read cached organization")); + }; }); } @@ -81,9 +90,14 @@ async function writeCached(record: CachedOrgRecord): Promise { const db = await openDatabase(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, "readwrite"); - tx.oncomplete = () => resolve(); - tx.onerror = () => + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onerror = () => { + db.close(); reject(tx.error ?? new Error("Failed to write cached organization")); + }; tx.objectStore(STORE_NAME).put(record); }); } @@ -92,9 +106,14 @@ async function removeCached(login: string): Promise { const db = await openDatabase(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, "readwrite"); - tx.oncomplete = () => resolve(); - tx.onerror = () => + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onerror = () => { + db.close(); reject(tx.error ?? new Error("Failed to clear cached organization")); + }; tx.objectStore(STORE_NAME).delete(login.toLowerCase()); }); }