diff --git a/package-lock.json b/package-lock.json index 9fc67df..0b491f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@supabase/ssr": "^0.7.0", "@supabase/supabase-js": "^2.74.0", "@types/serialize-javascript": "^5.0.4", + "@vercel/analytics": "^1.6.1", "@vercel/edge-config": "^1.4.3", "chart.js": "^4.5.1", "embla-carousel-react": "8.6.0", @@ -836,6 +837,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -1421,7 +1423,6 @@ "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -2379,6 +2380,7 @@ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.74.0.tgz", "integrity": "sha512-IEMM/V6gKdP+N/X31KDIczVzghDpiPWFGLNjS8Rus71KvV6y6ueLrrE/JGCHDrU+9pq5copF3iCa0YQh+9Lq9Q==", "license": "MIT", + "peer": true, "dependencies": { "@supabase/auth-js": "2.74.0", "@supabase/functions-js": "2.74.0", @@ -2723,7 +2725,6 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -2735,7 +2736,6 @@ "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint": "*", "@types/estree": "*" @@ -2818,6 +2818,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.0.tgz", "integrity": "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -2859,6 +2860,44 @@ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "license": "ISC" }, + "node_modules/@vercel/analytics": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-1.6.1.tgz", + "integrity": "sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg==", + "license": "MPL-2.0", + "peerDependencies": { + "@remix-run/react": "^2", + "@sveltejs/kit": "^1 || ^2", + "next": ">= 13", + "react": "^18 || ^19 || ^19.0.0-rc", + "svelte": ">= 4", + "vue": "^3", + "vue-router": "^4" + }, + "peerDependenciesMeta": { + "@remix-run/react": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "next": { + "optional": true + }, + "react": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + }, + "vue-router": { + "optional": true + } + } + }, "node_modules/@vercel/edge-config": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/@vercel/edge-config/-/edge-config-1.4.3.tgz", @@ -2895,7 +2934,6 @@ "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" @@ -2906,24 +2944,21 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-buffer": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.13.2", @@ -2931,7 +2966,6 @@ "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", @@ -2943,8 +2977,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.14.1", @@ -2952,7 +2985,6 @@ "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -2966,7 +2998,6 @@ "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@xtuc/ieee754": "^1.2.0" } @@ -2977,7 +3008,6 @@ "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@xtuc/long": "4.2.2" } @@ -2987,8 +3017,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/wasm-edit": { "version": "1.14.1", @@ -2996,7 +3025,6 @@ "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -3014,7 +3042,6 @@ "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", @@ -3029,7 +3056,6 @@ "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -3043,7 +3069,6 @@ "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", @@ -3059,7 +3084,6 @@ "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" @@ -3070,16 +3094,14 @@ "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "dev": true, - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/@yarnpkg/lockfile": { "version": "1.1.0", @@ -3115,7 +3137,6 @@ "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10.13.0" }, @@ -3129,6 +3150,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3146,7 +3168,6 @@ "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -3165,7 +3186,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3182,8 +3202,7 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/ajv-keywords": { "version": "3.5.2", @@ -3247,7 +3266,6 @@ "integrity": "sha512-JU0h5APyQNsHOlAM7HnQnPToSDQoEBZqzu/YBlqDnEeymPnZDREeXJA3KBMQee+dKteAxZ2AtvQEvVYdZf241Q==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "baseline-browser-mapping": "dist/cli.js" } @@ -3346,8 +3364,7 @@ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/call-bind": { "version": "1.0.8", @@ -3487,6 +3504,7 @@ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "license": "MIT", + "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -3500,7 +3518,6 @@ "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.0" } @@ -3733,14 +3750,14 @@ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.244.tgz", "integrity": "sha512-OszpBN7xZX4vWMPJwB9illkN/znA8M36GQqQxi6MNy9axWxhOfJyZZJtSLQCpEFLHP2xK33BiWx9aIuIEXVCcw==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/embla-carousel": { "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -3811,8 +3828,7 @@ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/es-object-atoms": { "version": "1.1.1", @@ -3832,7 +3848,6 @@ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -3855,7 +3870,6 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -3870,7 +3884,6 @@ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "estraverse": "^5.2.0" }, @@ -3884,7 +3897,6 @@ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=4.0" } @@ -3895,7 +3907,6 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=4.0" } @@ -3922,7 +3933,6 @@ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.8.x" } @@ -3962,8 +3972,7 @@ "url": "https://opencollective.com/fastify" } ], - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/fast-xml-parser": { "version": "4.5.3", @@ -4111,8 +4120,7 @@ "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", "dev": true, - "license": "BSD-2-Clause", - "peer": true + "license": "BSD-2-Clause" }, "node_modules/gopd": { "version": "1.2.0", @@ -4485,7 +4493,6 @@ "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -4501,7 +4508,6 @@ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -4527,8 +4533,7 @@ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-schema-traverse": { "version": "0.4.1", @@ -4870,7 +4875,6 @@ "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.11.5" }, @@ -5214,8 +5218,7 @@ "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/micromark": { "version": "4.0.2", @@ -5879,14 +5882,14 @@ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/next": { "version": "15.5.9", "resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz", "integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==", "license": "MIT", + "peer": true, "dependencies": { "@next/env": "15.5.9", "@swc/helpers": "0.5.15", @@ -5978,8 +5981,7 @@ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/nodemailer": { "version": "7.0.11", @@ -6197,7 +6199,6 @@ "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "^5.1.0" } @@ -6228,6 +6229,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.2.tgz", "integrity": "sha512-MdWVitvLbQULD+4DP8GYjZUrepGW7d+GQkNVqJEzNxE+e9WIa4egVFE/RDfVb1u9u/Jw7dNMmPB4IqxzbFYJ0w==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -6247,6 +6249,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.2.tgz", "integrity": "sha512-dEoydsCp50i7kS1xHOmPXq4zQYoGWedUsvqv9H6zdif2r7yLHygyfP9qou71TulRN0d6ng9EbRVsQhSqfUc19g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -6393,7 +6396,6 @@ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6606,7 +6608,6 @@ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6626,7 +6627,6 @@ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -6774,7 +6774,8 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.0", @@ -6796,7 +6797,6 @@ "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -6816,7 +6816,6 @@ "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", @@ -6870,7 +6869,6 @@ "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -6883,8 +6881,7 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/terser-webpack-plugin/node_modules/schema-utils": { "version": "4.3.3", @@ -6892,7 +6889,6 @@ "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -6913,7 +6909,6 @@ "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "randombytes": "^2.1.0" } @@ -6923,8 +6918,7 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/through2": { "version": "4.0.2", @@ -7127,7 +7121,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" @@ -7224,7 +7217,6 @@ "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -7257,7 +7249,6 @@ "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -7307,7 +7298,6 @@ "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10.13.0" } @@ -7336,7 +7326,6 @@ "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -7349,8 +7338,7 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/webpack/node_modules/schema-utils": { "version": "4.3.3", @@ -7358,7 +7346,6 @@ "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", diff --git a/package.json b/package.json index 8e89d42..4b116e0 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@supabase/ssr": "^0.7.0", "@supabase/supabase-js": "^2.74.0", "@types/serialize-javascript": "^5.0.4", + "@vercel/analytics": "^1.6.1", "@vercel/edge-config": "^1.4.3", "chart.js": "^4.5.1", "embla-carousel-react": "8.6.0", diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index a513eda..45fe4f1 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,7 +1,7 @@ import { createClient } from "@/utils/supabase/server"; import Link from "next/link"; import { redirect } from "next/navigation"; -import { FiExternalLink } from "react-icons/fi"; +import { FiAlertTriangle, FiExternalLink } from "react-icons/fi"; import DashboardClient from "@/components/Dashboard/DashboardClient"; import ArchiverManagement from "@/components/Dashboard/ArchiverManagement"; import { getDownloadsSeriesAll } from "./actions"; @@ -14,7 +14,7 @@ export default async function DashboardPage() { if (!user) redirect("/login"); const { data: isAdmin } = await supa.rpc("is_admin"); - let pendingHacks: (HackRow & { created_by: string; creator_username: string | null })[] = []; + let pendingHacks: (HackRow & { created_by: string; creator_username: string | null; creator_full_name: string | null })[] = []; if (isAdmin) { const { data: pendingHacksData } = await supa .from("hacks") @@ -27,15 +27,20 @@ export default async function DashboardPage() { const creatorIds = [...new Set(pendingHacksData.map(h => h.created_by as string))]; const { data: profiles } = await supa .from("profiles") - .select("id,username") + .select("id,username,full_name") .in("id", creatorIds); const usernameById = new Map(); - (profiles || []).forEach((p) => usernameById.set(p.id, p.username)); + const fullNameById = new Map(); + (profiles || []).forEach((p) => { + usernameById.set(p.id, p.username); + fullNameById.set(p.id, p.full_name); + }); pendingHacks = pendingHacksData.map((h) => ({ ...h, creator_username: usernameById.get(h.created_by as string) || null, + creator_full_name: fullNameById.get(h.created_by as string) || null, })); } } @@ -75,7 +80,12 @@ export default async function DashboardPage() { {pendingHacks.length > 0 && (
-

Pending hacks

+
+

Pending hacks

+ + {pendingHacks.length} + +
{/* Header row (desktop only) */}
@@ -85,7 +95,6 @@ export default async function DashboardPage() {
{pendingHacks.map((h) => { - console.log(h.created_at); const createdDate = h.created_at ? new Date(h.created_at).toLocaleTimeString(undefined, { year: "numeric", @@ -96,25 +105,40 @@ export default async function DashboardPage() { }) : "Unknown"; const creator = h.creator_username ? `@${h.creator_username}` : "Unknown"; + const hasMissingPatch = h.current_patch === null; + const bgClasses = hasMissingPatch + ? "bg-amber-50/5 dark:bg-amber-900/5 hover:bg-amber-400/10 dark:hover:bg-amber-600/10" + : "bg-amber-500/5 hover:bg-amber-500/10"; return ( {/* Desktop row */}
-
{h.title}
+
+
{h.title}
+ {hasMissingPatch && ( +
+ + Missing Patch +
+ )} +
/{h.slug}
-
{creator}
+
+ {h.creator_full_name &&
{h.creator_full_name}
} +
{creator}
+
{createdDate}
@@ -126,7 +150,15 @@ export default async function DashboardPage() {
-
{h.title}
+
+
{h.title}
+ {hasMissingPatch && ( +
+ + Missing Patch +
+ )} +
/{h.slug}
{creator} diff --git a/src/app/discover/actions.ts b/src/app/discover/actions.ts new file mode 100644 index 0000000..6d88210 --- /dev/null +++ b/src/app/discover/actions.ts @@ -0,0 +1,336 @@ + "use server"; + +import { unstable_cache as cache } from "next/cache"; +import { createServiceClient } from "@/utils/supabase/server"; +import { sortOrderedTags, OrderedTag, getCoverUrls } from "@/utils/format"; +import { HackCardAttributes } from "@/components/HackCard"; +import type { DiscoverSortOption } from "@/types/discover"; + +const TRENDING_WINDOW_DAYS = 3; +const TIME_TO_LIVE = 600; // 10 minutes + + export interface DiscoverDataResult { + hacks: HackCardAttributes[]; + tagGroups: Record; + ungroupedTags: string[]; + } + + function getDayStamp() { + const now = new Date(); + const startOfTodayUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + return startOfTodayUtc.toISOString().slice(0, 10); // YYYY-MM-DD + } + +export async function getDiscoverData(sort: DiscoverSortOption): Promise { + const dayStamp = getDayStamp(); + + const runner = cache( + async () => { + // Must use service role client because cookies cannot be used when caching + // Viewing permissions are enforced manually (only approved hacks are shown) + // TODO: Add `published` as a requirement when it's implemented + const supabase = await createServiceClient(); + + // Build base query for hacks (public/anon view: only approved hacks) + let query = supabase + .from("hacks") + .select("slug,title,summary,description,base_rom,downloads,created_by,updated_at,current_patch,original_author,approved_at") + .eq("approved", true); + + // Apply sorting based on sort type + if (sort === "popular") { + // When sorting by popularity, always show non-archive hacks first. + // Archives are defined as rows where original_author IS NOT NULL and current_patch IS NULL, + // so ordering by current_patch with NULLS LAST effectively pushes archives to the end. + query = query + .order("downloads", { ascending: false }) + .order("current_patch", { ascending: false, nullsFirst: false }); + } else if (sort === "trending") { + // For trending, we'll fetch all and calculate scores in JS + // Still order by downloads first for efficiency + query = query + .order("downloads", { ascending: false }) + .order("current_patch", { ascending: false, nullsFirst: false }); + } else if (sort === "updated") { + // Will sort by current patch published_at in JS after fetching patches + } else if (sort === "alphabetical") { + query = query.order("title", { ascending: true }); + } else { + // "new" or default + query = query.order("approved_at", { ascending: false }); + } + + const { data: rows, error: hacksError } = await query; + if (hacksError) throw hacksError; + + const slugs = (rows || []).map((r) => r.slug); + + // Fetch covers + const { data: coverRows, error: coversError } = await supabase + .from("hack_covers") + .select("hack_slug,url,position") + .in("hack_slug", slugs) + .order("position", { ascending: true }); + if (coversError) throw coversError; + + const coversBySlug = new Map(); + if (coverRows && coverRows.length > 0) { + const coverKeys = coverRows.map((c) => c.url); + const urls = getCoverUrls(coverKeys); + const urlToSignedUrl = new Map(); + coverKeys.forEach((key, idx) => { + if (urls[idx]) urlToSignedUrl.set(key, urls[idx]); + }); + + coverRows.forEach((c) => { + const arr = coversBySlug.get(c.hack_slug) || []; + const signed = urlToSignedUrl.get(c.url); + if (signed) { + arr.push(signed); + coversBySlug.set(c.hack_slug, arr); + } + }); + } + + // Fetch tags - paginate to avoid 1000 row limit per query + const tagsBySlug = new Map(); + const BATCH_SIZE = 1000; + let offset = 0; + let hasMore = true; + + while (hasMore) { + const { data: tagRows, error: tagsError } = await supabase + .from("hack_tags") + .select("hack_slug,order,tags(name,category)") + .in("hack_slug", slugs) + .range(offset, offset + BATCH_SIZE - 1) + .order("hack_slug", { ascending: true }); + + if (tagsError) throw tagsError; + + if (!tagRows || tagRows.length === 0) { + hasMore = false; + } else { + tagRows.forEach((r: any) => { + if (!r.tags?.name) return; + const arr = tagsBySlug.get(r.hack_slug) || []; + arr.push({ + name: r.tags.name, + order: r.order, + }); + tagsBySlug.set(r.hack_slug, arr); + }); + + // If we got fewer rows than the batch size, we've reached the end + if (tagRows.length < BATCH_SIZE) { + hasMore = false; + } else { + offset += BATCH_SIZE; + } + } + } + + // Fetch patches for version mapping + const patchIds = Array.from( + new Set( + (rows || []) + .map((r: any) => r.current_patch as number | null) + .filter((id): id is number => typeof id === "number") + ) + ); + + const versionsByPatchId = new Map(); + const publishedAtByPatchId = new Map(); + if (patchIds.length > 0) { + const { data: patchRows, error: patchesError } = await supabase + .from("patches") + .select("id,version,published_at") + .in("id", patchIds); + if (patchesError) throw patchesError; + + (patchRows || []).forEach((p: any) => { + if (typeof p.id === "number") { + versionsByPatchId.set(p.id, p.version || "Pre-release"); + publishedAtByPatchId.set(p.id, p.published_at ?? null); + } + }); + } + + // Calculate trending scores if needed + let trendingScores: Map | null = null; + if (sort === "trending") { + // Get all patches for all hacks, grouped by slug + const { data: allPatches, error: allPatchesError } = await supabase + .from("patches") + .select("id,parent_hack") + .in("parent_hack", slugs); + if (allPatchesError) throw allPatchesError; + + // Group patch IDs by parent_hack (slug) + const patchIdsBySlug = new Map(); + (allPatches || []).forEach((p: any) => { + if (typeof p.id === "number" && p.parent_hack) { + const arr = patchIdsBySlug.get(p.parent_hack) || []; + arr.push(p.id); + patchIdsBySlug.set(p.parent_hack, arr); + } + }); + + // Calculate recent downloads over the trending window + const since = new Date(); + since.setDate(since.getDate() - TRENDING_WINDOW_DAYS); + const sinceISO = since.toISOString(); + + const recentDownloadsBySlug = new Map(); + + // Query download counts per slug using head: true with count: 'exact' + // This avoids fetching all download rows and just gets counts + // One query per slug instead of one per patch + const downloadCountPromises = Array.from(patchIdsBySlug.entries()).map(async ([slug, patchIds]) => { + const { count, error } = await supabase + .from("patch_downloads") + .select("*", { count: "exact", head: true }) + .in("patch", patchIds) + .gte("created_at", sinceISO); + + if (error) throw error; + return { slug, count: count || 0 }; + }); + + const downloadCounts = await Promise.all(downloadCountPromises); + downloadCounts.forEach(({ slug, count }) => { + recentDownloadsBySlug.set(slug, count); + }); + + // Calculate trending scores: recent_downloads_window + (8 * log(downloads + 1)) + // Give small boost to longer lived popular hacks + trendingScores = new Map(); + (rows || []).forEach((r: any) => { + const recentDownloads = recentDownloadsBySlug.get(r.slug) || 0; + const lifetimeDownloads = r.downloads || 0; + const score = recentDownloads + (8 * Math.log(lifetimeDownloads + 1)); + trendingScores!.set(r.slug, score); + }); + } + + // Map versions and current patch published_at per hack + const mappedVersions = new Map(); + const publishedAtBySlug = new Map(); + (rows || []).forEach((r: any) => { + if (typeof r.current_patch === "number") { + const version = versionsByPatchId.get(r.current_patch) || "Pre-release"; + mappedVersions.set(r.slug, version); + + const publishedAt = publishedAtByPatchId.get(r.current_patch) ?? null; + publishedAtBySlug.set(r.slug, publishedAt); + } else { + mappedVersions.set(r.slug, r.original_author ? "Archive" : "Pre-release"); + publishedAtBySlug.set(r.slug, null); + } + }); + + // Fetch all tags with category to build UI groups + const { data: allTagRows, error: allTagsError } = await supabase + .from("tags") + .select("name,category"); + if (allTagsError) throw allTagsError; + + // Fetch profiles for author names + const { data: profiles, error: profilesError } = await supabase + .from("profiles") + .select("id,username"); + if (profilesError) throw profilesError; + + const usernameById = new Map(); + (profiles || []).forEach((p) => usernameById.set(p.id, p.username ? `@${p.username}` : "Unknown")); + + // Transform rows to HackCardAttributes + let mapped = (rows || []).map((r) => ({ + slug: r.slug, + title: r.title, + author: r.original_author ? r.original_author : usernameById.get(r.created_by as string) || "Unknown", + covers: coversBySlug.get(r.slug) || [], + tags: sortOrderedTags(tagsBySlug.get(r.slug) || []), + downloads: r.downloads, + baseRomId: r.base_rom, + version: mappedVersions.get(r.slug) || "Pre-release", + summary: r.summary, + description: r.description, + isArchive: r.original_author != null && r.current_patch === null, + })); + + // Sort by current patch published_at for "updated" sort + if (sort === "updated") { + mapped = [...mapped].sort((a, b) => { + const aPub = publishedAtBySlug.get(a.slug); + const bPub = publishedAtBySlug.get(b.slug); + + // Nulls (no published_at) go last + if (!aPub && !bPub) return 0; + if (!aPub) return 1; + if (!bPub) return -1; + + const aTime = new Date(aPub).getTime(); + const bTime = new Date(bPub).getTime(); + + // Secondary sort: when times are equal, push archives to end + if (aTime === bTime) { + if (a.isArchive && !b.isArchive) return 1; + if (!a.isArchive && b.isArchive) return -1; + } + + return bTime - aTime; // Descending order (newest first) + }); + } + + // Sort by trending score if needed + if (sort === "trending" && trendingScores) { + mapped = [...mapped].sort((a, b) => { + const scoreA = trendingScores!.get(a.slug) || 0; + const scoreB = trendingScores!.get(b.slug) || 0; + + // Secondary sort: push archives to end + if (scoreA === scoreB) { + if (a.isArchive && !b.isArchive) return 1; + if (!a.isArchive && b.isArchive) return -1; + } + + return scoreB - scoreA; // Descending order + }); + } + + // Build tag groups + const groups: Record = {}; + const ungrouped: string[] = []; + const unique = new Set(); + if (allTagRows) { + for (const row of allTagRows as any[]) { + const name: string = row.name; + if (unique.has(name)) continue; + unique.add(name); + const category: string | null = row.category ?? null; + if (category) { + if (!groups[category]) groups[category] = []; + groups[category].push(name); + } else { + ungrouped.push(name); + } + } + // Sort for stable UI + Object.keys(groups).forEach((k) => groups[k].sort((a, b) => a.localeCompare(b))); + ungrouped.sort((a, b) => a.localeCompare(b)); + } + + return { + hacks: mapped, + tagGroups: groups, + ungroupedTags: ungrouped, + } satisfies DiscoverDataResult; + }, + [`discover-data:${sort}:${dayStamp}`], // Cache key + { revalidate: TIME_TO_LIVE } // Cache duration + ); + + return runner(); + } + diff --git a/src/app/discover/page.tsx b/src/app/discover/page.tsx index 8a8f9db..d8e9b0e 100644 --- a/src/app/discover/page.tsx +++ b/src/app/discover/page.tsx @@ -1,5 +1,6 @@ import DiscoverBrowser from "@/components/Discover/DiscoverBrowser"; import type { Metadata } from "next"; +import type { DiscoverSortOption } from "@/types/discover"; export const metadata: Metadata = { description: "Find and download Pokémon romhacks for Game Boy, Game Boy Color, Game Boy Advance, and Nintendo DS.", @@ -8,19 +9,31 @@ export const metadata: Metadata = { }, }; -export default function DiscoverPage() { +interface DiscoverPageProps { + searchParams: Promise>; +}; + +export default async function DiscoverPage(props: DiscoverPageProps) { + const searchParams = await props.searchParams; + const sortParam = searchParams.sort; + const validSorts: DiscoverSortOption[] = ["trending", "popular", "new", "updated", "alphabetical"]; + const sort: DiscoverSortOption = + typeof sortParam === "string" && (validSorts as string[]).includes(sortParam) + ? (sortParam as DiscoverSortOption) + : "trending"; // Default to trending if no sort param is provided + return (

Discover ROM hacks

-

- Browse curated patches. See what others are downloading. +

+ Hackdex supports developers by only hosting hacks that have been uploaded by the person or team that created them. By using this site, you are supporting the original creators and their labors of love.

- +
); diff --git a/src/app/globals.css b/src/app/globals.css index e2f1b3d..16a5fc0 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -154,7 +154,7 @@ html { scroll-padding-top: var(--anchor-offset); } 100% { transform: scale(1); } } -.anim-float:hover { animation: floatY 2.4s ease-in-out infinite; } +.anim-float { animation: floatY 2.4s ease-in-out infinite; } .anim-pulse { animation: pulseSoft 2.4s ease-in-out infinite; } .anim-pop { animation: popIn 220ms cubic-bezier(0.16, 1, 0.3, 1); } diff --git a/src/app/hack/[slug]/edit/page.tsx b/src/app/hack/[slug]/edit/page.tsx index b17fba0..b0b85e0 100644 --- a/src/app/hack/[slug]/edit/page.tsx +++ b/src/app/hack/[slug]/edit/page.tsx @@ -3,7 +3,7 @@ import HackForm from "@/components/Hack/HackForm"; import { createClient } from "@/utils/supabase/server"; import { FaChevronLeft, FaChevronRight } from "react-icons/fa6"; import Link from "next/link"; -import { getCoverUrls } from "@/utils/format"; +import { sortOrderedTags, getCoverUrls } from "@/utils/format"; import { checkEditPermission } from "@/utils/hack"; interface EditPageProps { @@ -47,9 +47,16 @@ export default async function EditHackPage({ params }: EditPageProps) { const { data: tagRows } = await supabase .from("hack_tags") - .select("tags(name)") + .select("order,tags(name)") .eq("hack_slug", slug); - const tags = (tagRows || []).map((r: any) => r.tags?.name).filter(Boolean) as string[]; + + const tags = sortOrderedTags( + (tagRows || []) + .map((r) => ({ + name: r.tags.name, + order: r.order, + })) + ).map((t) => t.name); let version = ""; if (hack.current_patch) { diff --git a/src/app/hack/[slug]/page.tsx b/src/app/hack/[slug]/page.tsx index 8db968f..6a70fd7 100644 --- a/src/app/hack/[slug]/page.tsx +++ b/src/app/hack/[slug]/page.tsx @@ -1,5 +1,6 @@ import { baseRoms, PLATFORM_NAMES } from "@/data/baseRoms"; import type { Metadata } from "next"; +import Link from "next/link"; import { notFound } from "next/navigation"; import Gallery from "@/components/Hack/Gallery"; import HackActions from "@/components/Hack/HackActions"; @@ -7,7 +8,7 @@ import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import rehypeSlug from "rehype-slug"; import Image from "next/image"; -import { FaDiscord, FaTwitter, FaTriangleExclamation } from "react-icons/fa6"; +import { FaDiscord, FaTwitter, FaTriangleExclamation, FaArrowUpRightFromSquare } from "react-icons/fa6"; import PokeCommunityIcon from "@/components/Icons/PokeCommunityIcon"; import { createClient, createServiceClient } from "@/utils/supabase/server"; import HackOptionsMenu from "@/components/Hack/HackOptionsMenu"; @@ -58,7 +59,7 @@ export async function generateMetadata({ params }: HackDetailProps): Promise )}
-
- {!isArchive && ( +
+ {!hack.original_author && ( )} -

By {isArchive ? (hack.original_author || "Unknown") : author}

+

By {author}

{hack.summary}

@@ -419,6 +430,7 @@ export default async function HackDetail({ params }: HackDetailProps) {

Details

    +
  • Language: {hack.language || "Unknown"}
  • Base ROM: {baseRom?.name || "Unknown"}
  • Created: {new Date(hack.created_at).toLocaleDateString()}
  • {lastUpdated &&
  • Last updated: {lastUpdated}
  • } @@ -462,6 +474,34 @@ export default async function HackDetail({ params }: HackDetailProps) {
)} + {otherHacks && otherHacks.length > 0 && ( +
+

More from {author}

+
    + {otherHacks.map((otherHack) => ( +
  • + +
    + + {otherHack.title} + + +
    + {otherHack.summary && ( +

    + {otherHack.summary} +

    + )} + +
  • + ))} +
+
+ )} {isInformationalArchive ? (

diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2b05c0d..1316556 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; +import { Analytics } from "@vercel/analytics/react"; import "./globals.css"; import Header from "@/components/Header"; import Footer from "@/components/Footer"; @@ -49,6 +50,7 @@ export default function RootLayout({ + ); diff --git a/src/components/Discover/DiscoverBrowser.tsx b/src/components/Discover/DiscoverBrowser.tsx index adc9c92..b4093f7 100644 --- a/src/components/Discover/DiscoverBrowser.tsx +++ b/src/components/Discover/DiscoverBrowser.tsx @@ -2,26 +2,52 @@ import React, { Fragment } from "react"; import HackCard from "@/components/HackCard"; -import { createClient } from "@/utils/supabase/client"; import { baseRoms } from "@/data/baseRoms"; import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from "@headlessui/react"; import { useFloating, offset, flip, shift, size, autoUpdate } from "@floating-ui/react"; import { IconType } from "react-icons"; -import { MdTune } from "react-icons/md"; +import { + MdTune, + MdWhatshot, + MdTrendingUp, + MdNewReleases, + MdUpdate, + MdSortByAlpha, + MdChevronLeft, + MdChevronRight, +} from "react-icons/md"; +import { IoEllipsisHorizontal } from "react-icons/io5"; import { BsSdCardFill } from "react-icons/bs"; import { CATEGORY_ICONS } from "@/components/Icons/tagCategories"; import { useBaseRoms } from "@/contexts/BaseRomContext"; -import { sortOrderedTags, OrderedTag, getCoverUrls } from "@/utils/format"; import { HackCardAttributes } from "@/components/HackCard"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { getDiscoverData } from "@/app/discover/actions"; +import type { DiscoverSortOption } from "@/types/discover"; + +const SORT_ICON_MAP: Record = { + trending: MdWhatshot, + popular: MdTrendingUp, + new: MdNewReleases, + updated: MdUpdate, + alphabetical: MdSortByAlpha, +}; const HACKS_PER_PAGE = 9; -export default function DiscoverBrowser() { - const supabase = createClient(); +interface DiscoverBrowserProps { + initialSort?: DiscoverSortOption; +} + +export default function DiscoverBrowser({ initialSort = "trending" }: DiscoverBrowserProps) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const [query, setQuery] = React.useState(""); const [selectedTags, setSelectedTags] = React.useState([]); const [selectedBaseRoms, setSelectedBaseRoms] = React.useState([]); - const [sort, setSort] = React.useState("popular"); + const [sort, setSort] = React.useState(initialSort ?? "trending"); const [hacks, setHacks] = React.useState([]); const [tagGroups, setTagGroups] = React.useState>({}); const [ungroupedTags, setUngroupedTags] = React.useState([]); @@ -54,144 +80,20 @@ export default function DiscoverBrowser() { const run = async () => { setLoadingHacks(true); setLoadingTags(true); - let query = supabase - .from("hacks") - .select("slug,title,summary,description,base_rom,downloads,created_by,updated_at,current_patch,original_author"); - - if (sort === "popular") { - // When sorting by popularity, always show non-archive hacks first. - // Archives are defined as rows where original_author IS NOT NULL and current_patch IS NULL, - // so ordering by current_patch with NULLS LAST effectively pushes archives to the end. - query = query - .order("downloads", { ascending: false }) - .order("current_patch", { ascending: false, nullsFirst: false }); - } else if (sort === "updated") { - query = query.order("updated_at", { ascending: false }); - } else { - query = query.order("created_at", { ascending: false }); + try { + const result = await getDiscoverData(sort); + setHacks(result.hacks); + setTagGroups(result.tagGroups); + setUngroupedTags(result.ungroupedTags); + } catch (error) { + console.error("Failed to fetch hacks:", error); + setHacks([]); + setTagGroups({}); + setUngroupedTags([]); + } finally { + setLoadingHacks(false); + setLoadingTags(false); } - - const { data: rows } = await query; - const slugs = (rows || []).map((r) => r.slug); - const { data: coverRows } = await supabase - .from("hack_covers") - .select("hack_slug,url,position") - .in("hack_slug", slugs) - .order("position", { ascending: true }); - const coversBySlug = new Map(); - if (coverRows && coverRows.length > 0) { - const coverKeys = coverRows.map(c => c.url); - const urls = getCoverUrls(coverKeys); - // Map: storage object url -> signedUrl - const urlToSignedUrl = new Map(); - coverKeys.forEach((key, idx) => { - if (urls[idx]) urlToSignedUrl.set(key, urls[idx]); - }); - - coverRows.forEach((c) => { - const arr = coversBySlug.get(c.hack_slug) || []; - const signed = urlToSignedUrl.get(c.url); - if (signed) { - arr.push(signed); - coversBySlug.set(c.hack_slug, arr); - } - }); - } - const { data: tagRows } = await supabase - .from("hack_tags") - .select("hack_slug,order,tags(name,category)") - .in("hack_slug", slugs); - const tagsBySlug = new Map(); - (tagRows || []).forEach((r) => { - const arr = tagsBySlug.get(r.hack_slug) || []; - arr.push({ - name: r.tags.name, - order: r.order, - }); - tagsBySlug.set(r.hack_slug, arr); - }); - - const patchIds = Array.from( - new Set( - (rows || []) - .map((r: any) => r.current_patch as number | null) - .filter((id): id is number => typeof id === "number") - ) - ); - - const versionsByPatchId = new Map(); - if (patchIds.length > 0) { - const { data: patchRows } = await supabase - .from("patches") - .select("id,version") - .in("id", patchIds); - (patchRows || []).forEach((p: any) => { - if (typeof p.id === "number") { - versionsByPatchId.set(p.id, p.version || "Pre-release"); - } - }); - } - - const mappedVersions = new Map(); - (rows || []).forEach((r: any) => { - if (typeof r.current_patch === "number") { - const version = versionsByPatchId.get(r.current_patch) || "Pre-release"; - mappedVersions.set(r.slug, version); - } else { - mappedVersions.set(r.slug, r.original_author ? "Archive" : "Pre-release"); - } - }); - // Fetch all tags with category to build UI groups - const { data: allTagRows } = await supabase - .from("tags") - .select("name,category"); - const { data: profiles } = await supabase - .from("profiles") - .select("id,username"); - const usernameById = new Map(); - (profiles || []).forEach((p) => usernameById.set(p.id, p.username ? `@${p.username}` : "Unknown")); - - const mapped = (rows || []).map((r) => ({ - slug: r.slug, - title: r.title, - author: r.original_author ? r.original_author : usernameById.get(r.created_by as string) || "Unknown", - covers: coversBySlug.get(r.slug) || [], - tags: sortOrderedTags(tagsBySlug.get(r.slug) || []), - downloads: r.downloads, - baseRomId: r.base_rom, - version: mappedVersions.get(r.slug) || "Pre-release", - summary: r.summary, - description: r.description, - isArchive: r.original_author != null && r.current_patch === null, - })); - - setHacks(mapped); - setLoadingHacks(false); - if (allTagRows) { - const groups: Record = {}; - const ungrouped: string[] = []; - const unique = new Set(); - // Build groups from authoritative tags table, so we include tags not present in current results too - for (const row of allTagRows as any[]) { - const name: string = row.name; - if (unique.has(name)) continue; - unique.add(name); - const category: string | null = row.category ?? null; - if (category) { - if (!groups[category]) groups[category] = []; - groups[category].push(name); - } else { - ungrouped.push(name); - } - } - // Sort for stable UI - Object.keys(groups).forEach((k) => groups[k].sort((a, b) => a.localeCompare(b))); - ungrouped.sort((a, b) => a.localeCompare(b)); - setTagGroups(groups); - setUngroupedTags(ungrouped); - } - // Ensure loadingTags is cleared even if no rows were returned - setLoadingTags(false); }; run(); }, [sort]); @@ -270,6 +172,43 @@ export default function DiscoverBrowser() { [scrollToListTopOnMobile, totalPages] ); + const getPageNumbers = React.useCallback((current: number, total: number): (number | string)[] => { + // If 7 or fewer pages, show all + if (total <= 6) { + return Array.from({ length: total }, (_, i) => i + 1); + } + + const pages: (number | string)[] = []; + + // Always show first page + pages.push(1); + + if (current <= 3) { + // Near the start: show 1, 2, 3, 4, ..., last + for (let i = 2; i <= 4; i++) { + pages.push(i); + } + pages.push("ellipsis"); + pages.push(total); + } else if (current >= total - 2) { + // Near the end: show 1, ..., total-3, total-2, total-1, total + pages.push("ellipsis"); + for (let i = total - 3; i <= total; i++) { + pages.push(i); + } + } else { + // In the middle: show 1, ..., current-1, current, current+1, ..., last + pages.push("ellipsis"); + pages.push(current - 1); + pages.push(current); + pages.push(current + 1); + pages.push("ellipsis"); + pages.push(total); + } + + return pages; + }, []); + function toggleTag(name: string) { setSelectedTags((prev) => (prev.includes(name) ? prev.filter((t) => t !== name) : [...prev, name])); } @@ -286,6 +225,11 @@ export default function DiscoverBrowser() { setSelectedBaseRoms([]); } + const sortIcon = React.useMemo(() => { + const SortIcon = SORT_ICON_MAP[sort]; + return SortIcon ?

@@ -297,15 +241,31 @@ export default function DiscoverBrowser() { className="h-11 w-full rounded-md bg-[var(--surface-2)] px-3 text-sm text-foreground placeholder:text-foreground/60 ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]" />
- + {sortIcon} + +
{/* Unified filter section: Base ROM dropdown first, category dropdowns next, ungrouped tags last */} @@ -463,32 +423,65 @@ export default function DiscoverBrowser() {
Showing {paginationRange.startIndex + 1}-{paginationRange.endIndex} of {filtered.length} hacks
-
- -
- {Array.from({ length: totalPages }).map((_, i) => { - const page = i + 1; +
+ {/* Mobile: Previous/Next buttons row, Desktop: unwraps with contents */} +
+ + +
+ {/* Page numbers - order-2 on mobile (below buttons), order-2 on desktop (between Previous and Next) */} +
+ {getPageNumbers(currentPage, totalPages).map((item, i) => { + if (item === "ellipsis") { + return ( + + ); + } + const page = item as number; const isActive = page === currentPage; return (
-
)} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index a7742c3..870f4ea 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -55,6 +55,9 @@ export default function Footer() {
  • GitHub
  • +
  • + Roadmap +
  • diff --git a/src/components/Hack/StickyActionBar.tsx b/src/components/Hack/StickyActionBar.tsx index f3ce6fe..bc5b74b 100644 --- a/src/components/Hack/StickyActionBar.tsx +++ b/src/components/Hack/StickyActionBar.tsx @@ -90,9 +90,15 @@ export default function StickyActionBar({
    {!termsAgreed || status === "downloading" ? ( -

    - By downloading this patch, you agree to the Terms of Service. -

    + !romReady ? ( +

    + To download the patch file, you must select a clean ROM for the patcher to use. +

    + ) : ( +

    + By downloading this patch, you agree to the Terms of Service. +

    + ) ) : ( uploadInputRef.current?.click()} - className="w-5/6 mx-auto md:w-auto md:mx-0 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2 text-foreground text-sm md:text-xs cursor-pointer hover:bg-[var(--surface-3)] hover:text-foreground/80 disabled:cursor-not-allowed disabled:opacity-60" + className="shine-wrap btn-premium h-11 md:h-9 w-5/6 mx-auto md:w-auto md:mx-0 md:min-w-34 text-base md:text-sm font-semibold cursor-pointer disabled:cursor-not-allowed disabled:opacity-70" > {baseRomName ? ( Select {baseRomName} ROM @@ -135,15 +141,15 @@ export default function StickyActionBar({
    diff --git a/src/components/HackCard.tsx b/src/components/HackCard.tsx index 3e04677..a114f40 100644 --- a/src/components/HackCard.tsx +++ b/src/components/HackCard.tsx @@ -44,6 +44,7 @@ export default function HackCard({ hack, clickable = true, className = "" }: { h const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true }); const [selectedIndex, setSelectedIndex] = useState(0); + const [isClicked, setIsClicked] = useState(false); const dragStartRef = useRef<{ x: number; y: number } | null>(null); const didDragRef = useRef(false); @@ -56,8 +57,14 @@ export default function HackCard({ hack, clickable = true, className = "" }: { h emblaApi.off("select", onSelect); }; }, [emblaApi]); + + useEffect(() => { + // Reset isClicked state when component mounts + setIsClicked(false); + }, []); + const cardClass = `rounded-[12px] overflow-hidden h-full ${ - clickable ? "transition-transform duration-300 hover:-translate-y-0.5 hover:shadow-xl anim-float" : "" + clickable ? `transition-transform duration-300 hover:-translate-y-0.5 hover:shadow-xl ${isClicked ? "anim-float" : ""}` : "" } ring-1 ${ready ? "ring-emerald-400/50 bg-emerald-500/10" : "card ring-[var(--border)]"}`; const gradientBgClass = `bg-gradient-to-b ${ready ? 'from-emerald-300/5 to-emerald-400/30 dark:from-emerald-950/10 dark:to-emerald-600/40' : 'from-black/30 to-black/10 dark:from-black/80 dark:to-black/40'}`; const shadowClass = `shadow-xl ${ready ? "shadow-emerald-700/40 dark:shadow-emerald-200/40" : "shadow-slate-500/40 dark:shadow-slate-300/40"}`; @@ -219,6 +226,7 @@ export default function HackCard({ hack, clickable = true, className = "" }: { h e.stopPropagation(); didDragRef.current = false; } + setIsClicked(true); }} > {content} diff --git a/src/types/discover.ts b/src/types/discover.ts new file mode 100644 index 0000000..ec793e5 --- /dev/null +++ b/src/types/discover.ts @@ -0,0 +1,3 @@ +export type DiscoverSortOption = "trending" | "popular" | "new" | "updated" | "alphabetical"; + + diff --git a/supabase/templates/confirmation.html b/supabase/templates/confirmation.html new file mode 100644 index 0000000..0e57ffa --- /dev/null +++ b/supabase/templates/confirmation.html @@ -0,0 +1,224 @@ + + + + + + + + + Confirm your email + + + + +
    + + + + +
    + + + + + + + + + + + +
    +
    + + +