diff --git a/.gitignore b/.gitignore index 81f818c..b3fe837 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ dist-ssr .superpowers/ docs/superpowers/ +# Raw local data dumps (may contain sensitive info, never commit) +data/ + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/README.md b/README.md index 90322fa..d294918 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ Other scripts: - `npm run build` — production build - `npm run preview` — preview the production build locally - `npm run lint` — run ESLint +- `npm run screenshots` — regenerate the faint website-screenshot backgrounds shown on project cards. Fetches the current published project list, captures a screenshot of each project's site with headless Chrome, and writes the results to `public/screenshots/` + `src/data/screenshotManifest.json`. Run this manually whenever you want to pick up newly-added projects or refresh existing screenshots — it is **not** run automatically on build or deploy. Takes roughly 1–2 minutes for the current project count; sites that fail to load are skipped and logged, not treated as errors. ## Troubleshooting diff --git a/package-lock.json b/package-lock.json index baf445b..2d717cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", "globals": "^17.6.0", + "puppeteer": "^25.4.0", "vite": "^8.1.0" } }, @@ -571,6 +572,35 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@puppeteer/browsers": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.0.6.tgz", + "integrity": "sha512-B/gKoqlFkzhvzsI6jo9K1cZz9o5ypviVv/xu8CwA4grZzyVwN+XfkT+tu8T1zrauuEXv6VhS2oGX+6NL95WcKA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "modern-tar": "^0.7.6", + "yargs": "^18.0.0" + }, + "bin": { + "browsers": "lib/main-cli.js" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "proxy-agent": ">=8.0.1", + "yauzl": "^2.10.0 || ^3.4.0" + }, + "peerDependenciesMeta": { + "proxy-agent": { + "optional": true + }, + "yauzl": { + "optional": true + } + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.12.0", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", @@ -1060,6 +1090,32 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1151,6 +1207,66 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chromium-bidi": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz", + "integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/chromium-bidi/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -1351,6 +1467,13 @@ "node": ">=8" } }, + "node_modules/devtools-protocol": { + "version": "0.0.1653615", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1653615.tgz", + "integrity": "sha512-pGVkY3T/qXxAp2nFPodwYqOevk6ncNMSmvL8QfRCx5ZWGd6Vor7AFNmyaA8Zs6uJyP1QAfjuLandCgvSix1BNA==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/electron-to-chromium": { "version": "1.5.381", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.381.tgz", @@ -1358,6 +1481,13 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, "node_modules/es-toolkit": { "version": "1.50.0", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz", @@ -1698,6 +1828,29 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2149,6 +2302,19 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2191,6 +2357,23 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/modern-tar": { + "version": "0.7.7", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.7.tgz", + "integrity": "sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2373,6 +2556,46 @@ "node": ">=6" } }, + "node_modules/puppeteer": { + "version": "25.4.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.4.0.tgz", + "integrity": "sha512-xfQp8dFBcGaLc1hEMaVr7s+oW4ZkAurr8Y9H81ilKhu6QoLfSTkZjU7IavnyJ/VWpB9ni3KNJUQHUatslLWyGw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.0.6", + "chromium-bidi": "17.0.2", + "devtools-protocol": "0.0.1653615", + "lilconfig": "^3.1.3", + "puppeteer-core": "25.4.0", + "typed-query-selector": "^2.12.2" + }, + "bin": { + "puppeteer": "lib/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/puppeteer-core": { + "version": "25.4.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.4.0.tgz", + "integrity": "sha512-K1plkLOdeoUnGeT1OvdqF3qxl33v+Ra/uH5VyPEhXdMcpvGiEskHzxxEU3fgpccJpJLIipB/rPUsvkZRWeKqOA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.0.6", + "chromium-bidi": "17.0.2", + "devtools-protocol": "0.0.1653615", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.2", + "ws": "^8.21.1" + }, + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", @@ -2558,6 +2781,39 @@ "node": ">=0.10.0" } }, + "node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -2602,6 +2858,13 @@ "node": ">= 0.8.0" } }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "dev": true, + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -2752,6 +3015,13 @@ } } }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz", + "integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2778,6 +3048,74 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -2785,6 +3123,34 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 05d204f..137fe10 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "build": "vite build", "lint": "eslint .", "preview": "vite preview", - "test": "node --test" + "test": "node --test", + "screenshots": "node scripts/generate-screenshots.mjs" }, "dependencies": { "react": "^19.2.7", @@ -24,6 +25,7 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", "globals": "^17.6.0", + "puppeteer": "^25.4.0", "vite": "^8.1.0" } } diff --git a/public/screenshots/1.jpg b/public/screenshots/1.jpg new file mode 100644 index 0000000..524fa2e Binary files /dev/null and b/public/screenshots/1.jpg differ diff --git a/public/screenshots/10.jpg b/public/screenshots/10.jpg new file mode 100644 index 0000000..e83a33e Binary files /dev/null and b/public/screenshots/10.jpg differ diff --git a/public/screenshots/11.jpg b/public/screenshots/11.jpg new file mode 100644 index 0000000..11fb8af Binary files /dev/null and b/public/screenshots/11.jpg differ diff --git a/public/screenshots/12.jpg b/public/screenshots/12.jpg new file mode 100644 index 0000000..d6516cd Binary files /dev/null and b/public/screenshots/12.jpg differ diff --git a/public/screenshots/13.jpg b/public/screenshots/13.jpg new file mode 100644 index 0000000..f71b783 Binary files /dev/null and b/public/screenshots/13.jpg differ diff --git a/public/screenshots/15.jpg b/public/screenshots/15.jpg new file mode 100644 index 0000000..2c69d9a Binary files /dev/null and b/public/screenshots/15.jpg differ diff --git a/public/screenshots/16.jpg b/public/screenshots/16.jpg new file mode 100644 index 0000000..298b5d4 Binary files /dev/null and b/public/screenshots/16.jpg differ diff --git a/public/screenshots/17.jpg b/public/screenshots/17.jpg new file mode 100644 index 0000000..abb907a Binary files /dev/null and b/public/screenshots/17.jpg differ diff --git a/public/screenshots/18.jpg b/public/screenshots/18.jpg new file mode 100644 index 0000000..787f238 Binary files /dev/null and b/public/screenshots/18.jpg differ diff --git a/public/screenshots/19.jpg b/public/screenshots/19.jpg new file mode 100644 index 0000000..4fbcf5c Binary files /dev/null and b/public/screenshots/19.jpg differ diff --git a/public/screenshots/2.jpg b/public/screenshots/2.jpg new file mode 100644 index 0000000..3a57ad6 Binary files /dev/null and b/public/screenshots/2.jpg differ diff --git a/public/screenshots/20.jpg b/public/screenshots/20.jpg new file mode 100644 index 0000000..6350728 Binary files /dev/null and b/public/screenshots/20.jpg differ diff --git a/public/screenshots/21.jpg b/public/screenshots/21.jpg new file mode 100644 index 0000000..e7097db Binary files /dev/null and b/public/screenshots/21.jpg differ diff --git a/public/screenshots/22.jpg b/public/screenshots/22.jpg new file mode 100644 index 0000000..4bcbc98 Binary files /dev/null and b/public/screenshots/22.jpg differ diff --git a/public/screenshots/24.jpg b/public/screenshots/24.jpg new file mode 100644 index 0000000..54d28dc Binary files /dev/null and b/public/screenshots/24.jpg differ diff --git a/public/screenshots/25.jpg b/public/screenshots/25.jpg new file mode 100644 index 0000000..3a01eb3 Binary files /dev/null and b/public/screenshots/25.jpg differ diff --git a/public/screenshots/26.jpg b/public/screenshots/26.jpg new file mode 100644 index 0000000..03e0162 Binary files /dev/null and b/public/screenshots/26.jpg differ diff --git a/public/screenshots/27.jpg b/public/screenshots/27.jpg new file mode 100644 index 0000000..c204155 Binary files /dev/null and b/public/screenshots/27.jpg differ diff --git a/public/screenshots/28.jpg b/public/screenshots/28.jpg new file mode 100644 index 0000000..d57c744 Binary files /dev/null and b/public/screenshots/28.jpg differ diff --git a/public/screenshots/3.jpg b/public/screenshots/3.jpg new file mode 100644 index 0000000..f0028fc Binary files /dev/null and b/public/screenshots/3.jpg differ diff --git a/public/screenshots/30.jpg b/public/screenshots/30.jpg new file mode 100644 index 0000000..fa91f08 Binary files /dev/null and b/public/screenshots/30.jpg differ diff --git a/public/screenshots/31.jpg b/public/screenshots/31.jpg new file mode 100644 index 0000000..4f3ab63 Binary files /dev/null and b/public/screenshots/31.jpg differ diff --git a/public/screenshots/33.jpg b/public/screenshots/33.jpg new file mode 100644 index 0000000..716bc31 Binary files /dev/null and b/public/screenshots/33.jpg differ diff --git a/public/screenshots/34.jpg b/public/screenshots/34.jpg new file mode 100644 index 0000000..e1c6d02 Binary files /dev/null and b/public/screenshots/34.jpg differ diff --git a/public/screenshots/35.jpg b/public/screenshots/35.jpg new file mode 100644 index 0000000..6b0210c Binary files /dev/null and b/public/screenshots/35.jpg differ diff --git a/public/screenshots/36.jpg b/public/screenshots/36.jpg new file mode 100644 index 0000000..49dba1f Binary files /dev/null and b/public/screenshots/36.jpg differ diff --git a/public/screenshots/37.jpg b/public/screenshots/37.jpg new file mode 100644 index 0000000..ad32768 Binary files /dev/null and b/public/screenshots/37.jpg differ diff --git a/public/screenshots/4.jpg b/public/screenshots/4.jpg new file mode 100644 index 0000000..16615bb Binary files /dev/null and b/public/screenshots/4.jpg differ diff --git a/public/screenshots/5.jpg b/public/screenshots/5.jpg new file mode 100644 index 0000000..30d8eab Binary files /dev/null and b/public/screenshots/5.jpg differ diff --git a/public/screenshots/6.jpg b/public/screenshots/6.jpg new file mode 100644 index 0000000..735631f Binary files /dev/null and b/public/screenshots/6.jpg differ diff --git a/public/screenshots/8.jpg b/public/screenshots/8.jpg new file mode 100644 index 0000000..4fc91c1 Binary files /dev/null and b/public/screenshots/8.jpg differ diff --git a/public/screenshots/9.jpg b/public/screenshots/9.jpg new file mode 100644 index 0000000..b43139b Binary files /dev/null and b/public/screenshots/9.jpg differ diff --git a/scripts/generate-screenshots.mjs b/scripts/generate-screenshots.mjs new file mode 100644 index 0000000..ab391e4 --- /dev/null +++ b/scripts/generate-screenshots.mjs @@ -0,0 +1,109 @@ +import { writeFile, mkdir } from 'node:fs/promises' +import path from 'node:path' +import puppeteer from 'puppeteer' + +const API_URL = 'https://www.sefaria.org/api/powered-by' +const SCREENSHOTS_DIR = path.resolve('public/screenshots') +const MANIFEST_PATH = path.resolve('src/data/screenshotManifest.json') +const CONCURRENCY = 3 +const NAV_TIMEOUT_MS = 15000 +const VIEWPORT = { width: 1280, height: 800 } + +async function fetchPublishedProjects() { + const response = await fetch(API_URL) + if (!response.ok) { + throw new Error(`Failed to fetch projects: ${response.status}`) + } + const data = await response.json() + return data.projects.filter( + (project) => project.is_published && project.consent_to_display && project.project_link, + ) +} + +function isSafeUrl(rawUrl) { + let url + try { + url = new URL(rawUrl) + } catch { + return false + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') return false + if (/^(localhost|127\.|0\.|10\.|192\.168\.|169\.254\.|\[::1\])/.test(url.hostname)) return false + return true +} + +async function screenshotProject(browser, project) { + if (!isSafeUrl(project.project_link)) { + console.warn( + `Skipping ${project.project_name} (${project.project_link}): unsafe or invalid URL`, + ) + return { id: project.id, ok: false } + } + + const page = await browser.newPage() + try { + await page.setViewport(VIEWPORT) + await page.goto(project.project_link, { + waitUntil: 'networkidle2', + timeout: NAV_TIMEOUT_MS, + }) + const filePath = path.join(SCREENSHOTS_DIR, `${project.id}.jpg`) + await page.screenshot({ path: filePath, type: 'jpeg', quality: 60 }) + return { id: project.id, ok: true } + } catch (error) { + console.warn( + `Skipping ${project.project_name} (${project.project_link}): ${error.message}`, + ) + return { id: project.id, ok: false } + } finally { + await page.close() + } +} + +async function runPool(items, worker, concurrency) { + const results = [] + let index = 0 + + async function next() { + while (index < items.length) { + const current = items[index] + index += 1 + results.push(await worker(current)) + } + } + + await Promise.all(Array.from({ length: concurrency }, next)) + return results +} + +async function main() { + await mkdir(SCREENSHOTS_DIR, { recursive: true }) + + const projects = await fetchPublishedProjects() + const browser = await puppeteer.launch({ headless: true }) + + let results + try { + results = await runPool( + projects, + (project) => screenshotProject(browser, project), + CONCURRENCY, + ) + } finally { + await browser.close() + } + + const manifest = {} + for (const result of results) { + if (result.ok) manifest[result.id] = true + } + await writeFile(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n') + + const succeeded = results.filter((r) => r.ok).length + console.log(`Generated ${succeeded}/${results.length} screenshots`) +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/src/components/ChartsAndAnalytics.jsx b/src/components/ChartsAndAnalytics.jsx index 53b8f67..350febc 100644 --- a/src/components/ChartsAndAnalytics.jsx +++ b/src/components/ChartsAndAnalytics.jsx @@ -3,17 +3,27 @@ import { Bar, BarChart, CartesianGrid, + Cell, Legend, Line, LineChart, + Pie, + PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis, } from 'recharts' import { fetchProjects } from '../data/fetchProjects.js' -import { getSubmissionsMonthlyTrend, getSubmissionsTrendByExperience } from '../utils/submissionsTrend.js' +import { + getSubmissionsMonthlyTrend, + getSubmissionsTrendByExperience, + getSubmissionsTrendByVibeCoded, + VIBE_CODED_SERIES, +} from '../utils/submissionsTrend.js' import { getKeywordCounts } from '../utils/keywords.js' +import { getToolUsageCounts } from '../utils/sefariaTools.js' +import { getTechCounts } from '../utils/techUsed.js' import { EXPERIENCE_LEVELS } from '../utils/experience.js' const EXPERIENCE_COLORS = { @@ -23,10 +33,27 @@ const EXPERIENCE_COLORS = { Advanced: '#4a3aa7', } +// Fixed-order categorical hues; gray is reserved for the "Other" bucket and +// is never one of the 6 identity colors. +const TOOL_SLICE_COLORS = ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300'] +const OTHER_SLICE_COLOR = '#9a9a94' + +function colorForToolSlice(endpoint, index) { + return endpoint === 'Other' ? OTHER_SLICE_COLOR : TOOL_SLICE_COLORS[index] +} + +const VIBE_CODED_COLORS = { + 'Not vibe-coded': '#2a78d6', + 'Vibe-coded': '#eb6834', +} + function ChartsAndAnalytics() { const [trend, setTrend] = useState(null) const [keywordCounts, setKeywordCounts] = useState(null) const [experienceTrend, setExperienceTrend] = useState(null) + const [toolUsage, setToolUsage] = useState(null) + const [vibeCodedTrend, setVibeCodedTrend] = useState(null) + const [techCounts, setTechCounts] = useState(null) const [error, setError] = useState(null) useEffect(() => { @@ -35,16 +62,17 @@ function ChartsAndAnalytics() { setTrend(getSubmissionsMonthlyTrend(projects)) setKeywordCounts(getKeywordCounts(projects)) setExperienceTrend(getSubmissionsTrendByExperience(projects)) + setToolUsage(getToolUsageCounts(projects)) + setVibeCodedTrend(getSubmissionsTrendByVibeCoded(projects)) + setTechCounts(getTechCounts(projects)) }) .catch((err) => setError(err.message)) }, []) if (error) return
Couldn't load chart data right now.
- if (!trend || !keywordCounts || !experienceTrend) returnLoading chart…
- if (experienceTrend.length === 0) { - returnNo experience-level data available yet.
+ if (!trend || !keywordCounts || !experienceTrend || !toolUsage || !vibeCodedTrend || !techCounts) { + returnLoading chart…
} - return (No experience-level data available yet.
+ ) : ( +No endpoint data available yet.
+ ) : ( ++ "Vibe-coded" is a newly-tracked field, so earlier months may be undercounted or unreported rather than confirmed non-vibe-coded. +
No technology data available yet.
+ ) : ( +