mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-08-27 11:04:27 -05:00
Merge branch 'main' into feat/version-management
This commit is contained in:
155
package-lock.json
generated
155
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, string | null>();
|
||||
(profiles || []).forEach((p) => usernameById.set(p.id, p.username));
|
||||
const fullNameById = new Map<string, string | null>();
|
||||
(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 && (
|
||||
<div className="mt-12">
|
||||
<h2 className="text-xl font-semibold mb-4">Pending hacks</h2>
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<h2 className="text-xl font-semibold">Pending hacks</h2>
|
||||
<span className="inline-flex items-center rounded-full bg-amber-500/10 px-2.5 py-0.5 text-sm font-medium text-amber-900/90 dark:bg-amber-500/20 dark:text-amber-100 border border-amber-600/30">
|
||||
{pendingHacks.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-lg border border-amber-600/30 bg-amber-500/5">
|
||||
{/* Header row (desktop only) */}
|
||||
<div className="hidden lg:grid grid-cols-12 bg-amber-500/5 px-4 py-2 text-xs text-amber-900/80 dark:text-amber-200/80">
|
||||
@@ -85,7 +95,6 @@ export default async function DashboardPage() {
|
||||
</div>
|
||||
<div className="divide-y divide-amber-600/20">
|
||||
{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 (
|
||||
<Link
|
||||
key={h.slug}
|
||||
href={`/hack/${h.slug}`}
|
||||
target="_blank"
|
||||
className="group block px-4 py-3 text-sm bg-amber-500/5 hover:bg-amber-500/10 transition-colors"
|
||||
className={`group block px-4 py-3 text-sm ${bgClasses} transition-colors`}
|
||||
>
|
||||
{/* Desktop row */}
|
||||
<div className="hidden lg:grid grid-cols-12 items-center">
|
||||
<div className="col-span-5 min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-col items-start min-w-0">
|
||||
<div className="truncate font-medium text-amber-900/90 dark:text-amber-200/90 group-hover:underline">{h.title}</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="truncate font-medium text-amber-900/90 dark:text-amber-200/90 group-hover:underline">{h.title}</div>
|
||||
{hasMissingPatch && (
|
||||
<div className="inline-flex items-center gap-1 rounded-full bg-amber-600/20 dark:bg-amber-600/40 px-2 py-0.5 text-xs font-medium text-amber-900/90 dark:text-amber-200/90">
|
||||
<FiAlertTriangle className="h-3 w-3 text-amber-900/90 dark:text-amber-200/90" />
|
||||
<span>Missing Patch</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-amber-900/60 dark:text-amber-200/60 group-hover:underline">/{h.slug}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-3 text-amber-900/90 dark:text-amber-200/90">{creator}</div>
|
||||
<div className="col-span-3 flex flex-col">
|
||||
{h.creator_full_name && <div className="text-xs text-amber-900/70 dark:text-amber-200/70">{h.creator_full_name}</div>}
|
||||
<div className="text-amber-900/90 dark:text-amber-200/90">{creator}</div>
|
||||
</div>
|
||||
<div className="col-span-4 min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-amber-900/90 dark:text-amber-200/90 flex-1">{createdDate}</div>
|
||||
@@ -126,7 +150,15 @@ export default async function DashboardPage() {
|
||||
<div className="lg:hidden flex flex-col gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-col items-start min-w-0 flex-1">
|
||||
<div className="font-medium break-words">{h.title}</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="font-medium wrap-break-word">{h.title}</div>
|
||||
{hasMissingPatch && (
|
||||
<div className="inline-flex items-center gap-1 rounded-full bg-amber-600/20 dark:bg-amber-600/40 px-2 py-0.5 text-xs font-medium text-amber-900/90 dark:text-amber-200/90">
|
||||
<FiAlertTriangle className="h-3 w-3 text-amber-900/90 dark:text-amber-200/90" />
|
||||
<span>Missing Patch</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-foreground/60 break-all">/{h.slug}</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-amber-900/90 dark:text-amber-200/90 mt-2">
|
||||
<span>{creator}</span>
|
||||
|
||||
336
src/app/discover/actions.ts
Normal file
336
src/app/discover/actions.ts
Normal file
@@ -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<string, string[]>;
|
||||
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<DiscoverDataResult> {
|
||||
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<string, string[]>();
|
||||
if (coverRows && coverRows.length > 0) {
|
||||
const coverKeys = coverRows.map((c) => c.url);
|
||||
const urls = getCoverUrls(coverKeys);
|
||||
const urlToSignedUrl = new Map<string, string>();
|
||||
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<string, OrderedTag[]>();
|
||||
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<number, string>();
|
||||
const publishedAtByPatchId = new Map<number, string | null>();
|
||||
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<string, number> | 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<string, number[]>();
|
||||
(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<string, number>();
|
||||
|
||||
// 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<string, number>();
|
||||
(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<string, string>();
|
||||
const publishedAtBySlug = new Map<string, string | null>();
|
||||
(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<string, string>();
|
||||
(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<string, string[]> = {};
|
||||
const ungrouped: string[] = [];
|
||||
const unique = new Set<string>();
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="mx-auto max-w-screen-2xl px-6 py-10">
|
||||
<div className="flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Discover ROM hacks</h1>
|
||||
<p className="mt-2 text-[15px] text-foreground/80">
|
||||
Browse curated patches. See what others are downloading.
|
||||
<p className="mt-2 text-[15px] text-foreground/80 max-w-198">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<DiscoverBrowser />
|
||||
<DiscoverBrowser initialSort={sort} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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); }
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<Met
|
||||
.select("username")
|
||||
.eq("id", hack.created_by as string)
|
||||
.maybeSingle();
|
||||
const author = profile?.username ? `@${profile.username}` : undefined;
|
||||
const author = hack.original_author ? hack.original_author : (profile?.username ? `@${profile.username}` : undefined);
|
||||
|
||||
if (!hack.approved) return {
|
||||
title: hack.title,
|
||||
@@ -118,7 +119,7 @@ export default async function HackDetail({ params }: HackDetailProps) {
|
||||
const supabase = await createClient();
|
||||
const { data: hack, error } = await supabase
|
||||
.from("hacks")
|
||||
.select("slug,title,summary,description,base_rom,created_at,updated_at,downloads,current_patch,box_art,social_links,created_by,approved,original_author,permission_from")
|
||||
.select("slug,title,summary,description,base_rom,created_at,updated_at,downloads,current_patch,box_art,social_links,created_by,approved,original_author,permission_from,language")
|
||||
.eq("slug", slug)
|
||||
.maybeSingle();
|
||||
if (error || !hack) return notFound();
|
||||
@@ -152,7 +153,17 @@ export default async function HackDetail({ params }: HackDetailProps) {
|
||||
.select("username,avatar_url")
|
||||
.eq("id", hack.created_by as string)
|
||||
.maybeSingle();
|
||||
const author = profile?.username ? `@${profile.username}` : "Unknown";
|
||||
const author = hack.original_author ? hack.original_author : (profile?.username ? `@${profile.username}` : "Unknown");
|
||||
|
||||
// Get other approved hacks by the same author
|
||||
const { data: otherHacks } = await supabase
|
||||
.from("hacks")
|
||||
.select("slug,title,summary")
|
||||
.eq("created_by", hack.created_by)
|
||||
.eq("approved", true)
|
||||
.neq("slug", hack.slug)
|
||||
.order("downloads", { ascending: false })
|
||||
.limit(10);
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
@@ -209,7 +220,7 @@ export default async function HackDetail({ params }: HackDetailProps) {
|
||||
const baseUrl = siteBase || (proto && host ? `${proto}://${host}` : "");
|
||||
const pageUrl = baseUrl ? `${baseUrl}/hack/${hack.slug}` : `/hack/${hack.slug}`;
|
||||
|
||||
const authorName = profile?.username || "Unknown";
|
||||
const authorName = hack.original_author || profile?.username || "Unknown";
|
||||
|
||||
const sameAs: string[] = [];
|
||||
const social = (hack.social_links as unknown) as { discord?: string; twitter?: string; pokecommunity?: string } | null;
|
||||
@@ -346,15 +357,15 @@ export default async function HackDetail({ params }: HackDetailProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`mt-1 flex items-center gap-2 ${!isArchive ? "h-[28px]" : ""}`}>
|
||||
{!isArchive && (
|
||||
<div className={`mt-1 flex items-center gap-2 ${!hack.original_author ? "h-[28px]" : ""}`}>
|
||||
{!hack.original_author && (
|
||||
<Avatar
|
||||
uid={hack.created_by as string}
|
||||
url={profile?.avatar_url ?? null}
|
||||
size={28}
|
||||
/>
|
||||
)}
|
||||
<p className="text-[16px] md:text-[18px] text-foreground/70">By {isArchive ? (hack.original_author || "Unknown") : author}</p>
|
||||
<p className="text-[16px] md:text-[18px] text-foreground/70">By {author}</p>
|
||||
</div>
|
||||
<p className={`${!isArchive ? "mt-4" : "mt-2"} text-sm text-foreground/75`}>{hack.summary}</p>
|
||||
</div>
|
||||
@@ -419,6 +430,7 @@ export default async function HackDetail({ params }: HackDetailProps) {
|
||||
<div className="card p-5">
|
||||
<h3 className="text-[15px] font-semibold tracking-tight">Details</h3>
|
||||
<ul className="mt-3 grid gap-2 text-sm text-foreground/75">
|
||||
<li>Language: {hack.language || "Unknown"}</li>
|
||||
<li>Base ROM: {baseRom?.name || "Unknown"}</li>
|
||||
<li>Created: {new Date(hack.created_at).toLocaleDateString()}</li>
|
||||
{lastUpdated && <li>Last updated: {lastUpdated}</li>}
|
||||
@@ -462,6 +474,34 @@ export default async function HackDetail({ params }: HackDetailProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{otherHacks && otherHacks.length > 0 && (
|
||||
<div className="card p-5">
|
||||
<h3 className="text-[15px] font-semibold tracking-tight">More from {author}</h3>
|
||||
<ul className="mt-3 space-y-3 text-sm text-foreground/75">
|
||||
{otherHacks.map((otherHack) => (
|
||||
<li key={otherHack.slug}>
|
||||
<Link
|
||||
href={`/hack/${otherHack.slug}`}
|
||||
target="_blank"
|
||||
className="group block hover:text-foreground"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium group-hover:underline underline-offset-2">
|
||||
{otherHack.title}
|
||||
</span>
|
||||
<FaArrowUpRightFromSquare className="inline-block shrink-0" size={12} />
|
||||
</div>
|
||||
{otherHack.summary && (
|
||||
<p className="mt-1 text-xs text-foreground/60 group-hover:text-foreground line-clamp-2">
|
||||
{otherHack.summary}
|
||||
</p>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{isInformationalArchive ? (
|
||||
<div className="card overflow-hidden p-4 mt-4 text-sm text-foreground/60">
|
||||
<p>
|
||||
|
||||
@@ -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({
|
||||
<MobileFooterSpacer />
|
||||
</BaseRomProvider>
|
||||
</AuthProvider>
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -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<DiscoverSortOption, IconType> = {
|
||||
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<string[]>([]);
|
||||
const [selectedBaseRoms, setSelectedBaseRoms] = React.useState<string[]>([]);
|
||||
const [sort, setSort] = React.useState("popular");
|
||||
const [sort, setSort] = React.useState<DiscoverSortOption>(initialSort ?? "trending");
|
||||
const [hacks, setHacks] = React.useState<HackCardAttributes[]>([]);
|
||||
const [tagGroups, setTagGroups] = React.useState<Record<string, string[]>>({});
|
||||
const [ungroupedTags, setUngroupedTags] = React.useState<string[]>([]);
|
||||
@@ -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<string, string[]>();
|
||||
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<string, string>();
|
||||
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<string, OrderedTag[]>();
|
||||
(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<number, string>();
|
||||
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<string, string>();
|
||||
(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<string, string>();
|
||||
(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<string, string[]> = {};
|
||||
const ungrouped: string[] = [];
|
||||
const unique = new Set<string>();
|
||||
// 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 ? <SortIcon className="h-5 w-5 text-foreground/80" aria-hidden="true" /> : null;
|
||||
}, [sort]);
|
||||
|
||||
return (
|
||||
<div className="max-w-[1200px] mx-auto">
|
||||
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center">
|
||||
@@ -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)]"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value)}
|
||||
className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
|
||||
<div
|
||||
className="inline-flex h-11 items-center gap-1.5 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus-within:ring-2 focus-within:ring-[var(--ring)]"
|
||||
>
|
||||
<option value="popular">Most popular</option>
|
||||
<option value="new">Newest</option>
|
||||
<option value="updated">Recently updated</option>
|
||||
</select>
|
||||
{sortIcon}
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => {
|
||||
const nextSort = e.target.value as DiscoverSortOption;
|
||||
setSort(nextSort);
|
||||
// Keep URL query param in sync so refresh/back preserves sort
|
||||
const current = searchParams ? new URLSearchParams(searchParams.toString()) : new URLSearchParams();
|
||||
current.set("sort", nextSort);
|
||||
const queryString = current.toString();
|
||||
const url = queryString ? `${pathname}?${queryString}` : pathname;
|
||||
router.replace(url);
|
||||
}}
|
||||
className="w-full h-full bg-transparent pl-1 pr-0 text-sm text-foreground focus:outline-none focus:ring-0"
|
||||
>
|
||||
<option value="trending">Trending</option>
|
||||
<option value="popular">Most popular</option>
|
||||
<option value="new">Newest</option>
|
||||
<option value="updated">Recently updated</option>
|
||||
<option value="alphabetical">Alphabetical</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Unified filter section: Base ROM dropdown first, category dropdowns next, ungrouped tags last */}
|
||||
@@ -463,32 +423,65 @@ export default function DiscoverBrowser() {
|
||||
<div className="text-xs text-foreground/70 text-center">
|
||||
Showing {paginationRange.startIndex + 1}-{paginationRange.endIndex} of {filtered.length} hacks
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changePage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className={`h-9 rounded-full px-3 text-sm ring-1 ring-inset transition-colors min-w-[90px] ${
|
||||
currentPage === 1
|
||||
? "cursor-not-allowed bg-[var(--surface-2)] text-foreground/40 ring-[var(--border)]"
|
||||
: "bg-[var(--surface-2)] text-foreground/80 ring-[var(--border)] hover:bg-black/5 dark:hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: totalPages }).map((_, i) => {
|
||||
const page = i + 1;
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 sm:gap-2 w-full">
|
||||
{/* Mobile: Previous/Next buttons row, Desktop: unwraps with contents */}
|
||||
<div className="order-1 sm:contents flex items-center justify-center gap-3 sm:gap-2 w-full sm:w-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changePage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className={`h-11 sm:h-9 rounded-full px-3 text-sm ring-1 ring-inset transition-colors flex-1 sm:flex-none max-w-[120px] sm:max-w-none min-w-0 sm:min-w-[90px] flex items-center justify-center ${
|
||||
currentPage === 1
|
||||
? "cursor-not-allowed bg-[var(--surface-2)] text-foreground/40 ring-[var(--border)]"
|
||||
: "bg-[var(--surface-2)] text-foreground/80 ring-[var(--border)] active:bg-black/10 dark:active:bg-white/15 sm:hover:bg-black/5 sm:dark:hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
<span className="sm:hidden">
|
||||
<MdChevronLeft className="h-6 w-6" />
|
||||
</span>
|
||||
<span className="hidden sm:inline">Previous</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changePage(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className={`sm:order-3 h-11 sm:h-9 rounded-full px-3 text-sm ring-1 ring-inset transition-colors flex-1 sm:flex-none max-w-[120px] sm:max-w-none min-w-0 sm:min-w-[90px] flex items-center justify-center ${
|
||||
currentPage === totalPages
|
||||
? "cursor-not-allowed bg-[var(--surface-2)] text-foreground/40 ring-[var(--border)]"
|
||||
: "bg-[var(--surface-2)] text-foreground/80 ring-[var(--border)] active:bg-black/10 dark:active:bg-white/15 sm:hover:bg-black/5 sm:dark:hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
<span className="sm:hidden">
|
||||
<MdChevronRight className="h-6 w-6" />
|
||||
</span>
|
||||
<span className="hidden sm:inline">Next</span>
|
||||
</button>
|
||||
</div>
|
||||
{/* Page numbers - order-2 on mobile (below buttons), order-2 on desktop (between Previous and Next) */}
|
||||
<div className="order-2 flex items-center justify-center gap-1.5 sm:gap-1 flex-wrap">
|
||||
{getPageNumbers(currentPage, totalPages).map((item, i) => {
|
||||
if (item === "ellipsis") {
|
||||
return (
|
||||
<span
|
||||
key={`ellipsis-${i}`}
|
||||
className="h-10 sm:h-8 min-w-6 sm:min-w-5 flex items-center justify-center text-sm sm:text-xs text-foreground/50"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<IoEllipsisHorizontal className="h-5 w-5 sm:h-4 sm:w-4" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const page = item as number;
|
||||
const isActive = page === currentPage;
|
||||
return (
|
||||
<button
|
||||
key={page}
|
||||
type="button"
|
||||
onClick={() => changePage(page)}
|
||||
className={`h-8 min-w-8 rounded-full px-2 text-xs font-medium ring-1 ring-inset transition-colors ${
|
||||
className={`h-10 sm:h-8 min-w-10 sm:min-w-8 rounded-full px-2 text-sm sm:text-xs ring-1 ring-inset transition-colors flex items-center justify-center ${
|
||||
isActive
|
||||
? "bg-[var(--accent)] text-[var(--foreground)] ring-[var(--accent)]/60"
|
||||
: "bg-[var(--surface-2)] text-foreground/70 ring-[var(--border)] hover:bg-black/5 dark:hover:bg-white/10"
|
||||
? "bg-[var(--accent)] text-[var(--foreground)] font-bold ring-[var(--accent)]/60"
|
||||
: "bg-[var(--surface-2)] text-foreground/70 font-medium ring-[var(--border)] active:bg-black/10 dark:active:bg-white/15 sm:hover:bg-black/5 sm:dark:hover:bg-white/10"
|
||||
}`}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
>
|
||||
@@ -497,18 +490,6 @@ export default function DiscoverBrowser() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changePage(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className={`h-9 rounded-full px-3 text-sm ring-1 ring-inset transition-colors min-w-[90px] ${
|
||||
currentPage === totalPages
|
||||
? "cursor-not-allowed bg-[var(--surface-2)] text-foreground/40 ring-[var(--border)]"
|
||||
: "bg-[var(--surface-2)] text-foreground/80 ring-[var(--border)] hover:bg-black/5 dark:hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -55,6 +55,9 @@ export default function Footer() {
|
||||
<li>
|
||||
<Link href="https://github.com/Hackdex-App/hackdex-website" className="block py-1 hover:underline">GitHub</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="https://github.com/orgs/Hackdex-App/projects/4" className="block py-1 hover:underline">Roadmap</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -90,9 +90,15 @@ export default function StickyActionBar({
|
||||
</div>
|
||||
<div className="flex w-full md:w-auto flex-col md:flex-row md:flex-wrap items-stretch md:items-center gap-2 mb-4 md:mb-0">
|
||||
{!termsAgreed || status === "downloading" ? (
|
||||
<p className="rounded-full mx-auto md:mx-0 px-2 py-0.5 text-xs">
|
||||
By downloading this patch, you agree to the <Link href="/terms" target="_blank" className="underline">Terms of Service</Link>.
|
||||
</p>
|
||||
!romReady ? (
|
||||
<p className="rounded-full mx-auto md:mx-0 px-2 py-0.5 text-xs text-center md:text-left">
|
||||
To download the patch file, you must select a <span className="font-bold">clean ROM</span> for the patcher to use.
|
||||
</p>
|
||||
) : (
|
||||
<p className="rounded-full mx-auto md:mx-0 px-2 py-0.5 text-xs text-center md:text-left">
|
||||
By downloading this patch, you agree to the <Link href="/terms" target="_blank" className="underline">Terms of Service</Link>.
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<span className={`rounded-full mx-auto md:mx-0 px-2 py-0.5 text-xs ring-1 transition-opacity duration-300 ${
|
||||
romReady
|
||||
@@ -110,7 +116,7 @@ export default function StickyActionBar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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 ? (
|
||||
<span>Select <span className="font-bold">{baseRomName}</span> ROM</span>
|
||||
@@ -135,15 +141,15 @@ export default function StickyActionBar({
|
||||
<button
|
||||
onClick={onPatch}
|
||||
data-ready={romReady}
|
||||
disabled={!mounted || (status !== "ready" && status !== "done" && status !== "idle") || !patchAgainReady}
|
||||
className={`shine-wrap btn-premium max-md:data-[ready=false]:hidden! h-11 md:h-9 w-full md:min-w-[7.5rem] ${!termsAgreed || status === 'downloading' ? "md:w-32" : "md:w-auto"} text-base md:text-sm font-semibold cursor-pointer disabled:cursor-not-allowed disabled:opacity-70 ${romReady && status !== 'downloading' && status !== 'ready' && termsAgreed ? "mt-6 md:mt-0" : ""}`}
|
||||
disabled={!mounted || !romReady || (status !== "ready" && status !== "done" && status !== "idle") || !patchAgainReady}
|
||||
className={`shine-wrap btn-premium data-[ready=false]:hidden! h-11 md:h-9 w-full md:min-w-46 ${!termsAgreed || status === 'downloading' ? "md:w-32" : "md:w-auto"} text-base md:text-sm font-semibold cursor-pointer disabled:cursor-not-allowed disabled:opacity-70 ${romReady && status !== 'downloading' && status !== 'ready' && termsAgreed ? "mt-6 md:mt-0" : ""}`}
|
||||
>
|
||||
<span>{
|
||||
status === "patching" ? "Patching…" :
|
||||
status === "downloading" ? "Downloading…" :
|
||||
status === "done" ? (
|
||||
patchAgainReady ? "Patch Again" : "Patched"
|
||||
) : termsAgreed ? "Patch Now" : "I Agree"
|
||||
) : termsAgreed ? "Patch Now" : "Agree and Download"
|
||||
}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
|
||||
3
src/types/discover.ts
Normal file
3
src/types/discover.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type DiscoverSortOption = "trending" | "popular" | "new" | "updated" | "alphabetical";
|
||||
|
||||
|
||||
224
supabase/templates/confirmation.html
Normal file
224
supabase/templates/confirmation.html
Normal file
@@ -0,0 +1,224 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<title>Confirm your email</title>
|
||||
<style>
|
||||
/* Basic email reset */
|
||||
body,
|
||||
table,
|
||||
td,
|
||||
a {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
img {
|
||||
-ms-interpolation-mode: bicubic;
|
||||
border: 0;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.wrapper {
|
||||
width: 100%;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 24px;
|
||||
background: #fafafa;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
font-size: 20px;
|
||||
margin: 0;
|
||||
color: #171717;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 24px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
color: #171717;
|
||||
}
|
||||
|
||||
.heading {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 20px;
|
||||
line-height: 1.3;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #4b5563;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
/* Button */
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 12px 18px;
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
background: #f43f5e;
|
||||
}
|
||||
|
||||
.code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
background: #fafafa;
|
||||
border: 1px solid #ededed;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
display: block;
|
||||
color: #0a0a0a;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
padding: 16px 24px 24px 24px;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" bgcolor="#0a0a0a">
|
||||
<tr>
|
||||
<td align="center" style="padding: 0 16px;">
|
||||
<table role="presentation" class="container" width="100%" border="0" cellpadding="0" cellspacing="0" bgcolor="#ffffff">
|
||||
<tr>
|
||||
<td class="header">
|
||||
<div class="logo-container">
|
||||
<svg width="32" height="32" viewBox="0 0 134 134" version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve"
|
||||
xmlns:serif="http://www.serif.com/"
|
||||
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<rect id="Artboard1" x="0" y="0" width="133.333" height="133.333" style="fill:none;" />
|
||||
<clipPath id="_clip1">
|
||||
<rect id="Artboard11" serif:id="Artboard1" x="0" y="0" width="133.333" height="133.333" />
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip1)">
|
||||
<path
|
||||
d="M125,32.833c0,-13.522 -10.978,-24.5 -24.5,-24.5l-67.667,0c-13.522,0 -24.5,10.978 -24.5,24.5l0,67.667c0,13.522 10.978,24.5 24.5,24.5l67.667,0c13.522,0 24.5,-10.978 24.5,-24.5l0,-67.667Z"
|
||||
style="fill:url(#_Linear2);" />
|
||||
<g>
|
||||
<path
|
||||
d="M85.582,55.832c-3.353,-1.18 -7.191,-0.594 -10.062,1.758l-3.351,-3.351c-0.61,-0.61 -1.6,-0.61 -2.209,-0l-2.21,2.21c-0.61,0.609 -0.61,1.599 -0,2.209l3.314,3.315l-4.419,4.419l-3.315,-3.314c-0.609,-0.61 -1.6,-0.61 -2.209,-0l-2.21,2.209c-0.61,0.61 -0.61,1.6 0,2.21l3.315,3.315l-4.42,4.419l-3.314,-3.314c-0.61,-0.61 -1.6,-0.61 -2.21,-0l-2.21,2.209c-0.61,0.61 -0.61,1.6 0,2.21l3.351,3.351c-2.351,2.871 -2.937,6.709 -1.757,10.061l-2.561,2.561c-0.61,0.61 -0.61,1.6 -0,2.21l2.209,2.21c0.61,0.61 1.6,0.61 2.21,-0l1.841,-1.842l3.914,3.914l-1.841,1.842c-0.61,0.61 -0.61,1.6 -0,2.209l2.21,2.21c0.609,0.61 1.599,0.61 2.209,0l1.842,-1.841l3.914,3.914l-1.842,1.841c-0.61,0.61 -0.61,1.6 0,2.21l2.21,2.209c0.61,0.61 1.6,0.61 2.21,0l2.561,-2.561c3.352,1.18 7.19,0.594 10.061,-1.757l3.351,3.351c0.61,0.61 1.6,0.61 2.21,-0l2.209,-2.21c0.61,-0.61 0.61,-1.6 0,-2.21l-3.314,-3.314l4.419,-4.42l3.315,3.315c0.61,0.61 1.6,0.61 2.21,-0l2.209,-2.21c0.61,-0.609 0.61,-1.6 0,-2.209l-3.314,-3.315l4.419,-4.419l3.315,3.314c0.61,0.61 1.6,0.61 2.209,0l2.21,-2.21c0.61,-0.609 0.61,-1.599 0,-2.209l-3.351,-3.351c2.352,-2.871 2.938,-6.709 1.758,-10.062l2.561,-2.561c0.61,-0.61 0.61,-1.6 -0,-2.209l-2.21,-2.21c-0.61,-0.61 -1.6,-0.61 -2.21,-0l-1.841,1.841l-3.914,-3.914l1.841,-1.841c0.61,-0.61 0.61,-1.6 0,-2.21l-2.209,-2.209c-0.61,-0.61 -1.6,-0.61 -2.21,-0l-1.841,1.841l-3.914,-3.914l1.841,-1.841c0.61,-0.61 0.61,-1.6 0,-2.21l-2.21,-2.21c-0.609,-0.61 -1.599,-0.61 -2.209,0l-2.561,2.561Zm0.066,10.413c-1.951,-1.951 -5.119,-1.951 -7.071,-0l-16.499,16.499c-1.951,1.951 -1.951,5.12 0,7.071l10.607,10.607c1.951,1.951 5.12,1.951 7.071,-0l16.499,-16.499c1.951,-1.952 1.951,-5.12 0,-7.071l-10.607,-10.607Z"
|
||||
style="fill:#faf0f2;" />
|
||||
</g>
|
||||
<g>
|
||||
<path
|
||||
d="M18.274,119.294c-0.576,0.391 -0.918,1.045 -0.912,1.741c0.006,0.696 0.36,1.343 0.942,1.725c4.177,2.733 9.169,4.323 14.529,4.323c0,0 10.418,0 10.418,0c0.44,0 0.867,-0.138 1.223,-0.396c5.472,-3.967 9.083,-6.24 15.109,-11.229c0.452,-0.374 0.725,-0.922 0.753,-1.507c0.027,-0.586 -0.194,-1.157 -0.608,-1.571l-12.032,-12.032c-0.753,-0.753 -1.953,-0.817 -2.781,-0.149c-10.179,8.209 -18.009,13.224 -26.641,19.095Zm103.735,-102.078c-0.402,-0.552 -1.048,-0.872 -1.73,-0.857c-0.682,0.014 -1.314,0.362 -1.691,0.931c-6.435,9.694 -12.659,19.52 -22.556,31.791c-0.668,0.829 -0.604,2.028 0.149,2.781l12.032,12.032c0.415,0.415 0.985,0.636 1.571,0.608c0.586,-0.027 1.133,-0.3 1.507,-0.752c6.826,-8.245 10.45,-13.853 15.421,-21.02c0.242,-0.349 0.371,-0.763 0.371,-1.188l0,-8.709c0,-5.834 -1.883,-11.232 -5.074,-15.617Z"
|
||||
style="fill:#faf0f2;" />
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="_Linear2" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(123.81,123.81,-123.81,123.81,4.7619,4.7619)">
|
||||
<stop offset="0" style="stop-color:#f43f5e;stop-opacity:1" />
|
||||
<stop offset="1" style="stop-color:#be123c;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<h1 class="brand">Hackdex</h1>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="content">
|
||||
<h2 class="heading">Confirm your email</h2>
|
||||
<p class="muted">Thanks for signing up! Please confirm your email address by clicking the button below
|
||||
to activate your account.</p>
|
||||
<div class="spacer"></div>
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td>
|
||||
<a class="btn" href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email"
|
||||
target="_blank" rel="noopener">Confirm email</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="spacer"></div>
|
||||
<div class="card">
|
||||
<p class="muted" style="margin:0 0 8px 0;">If the button doesn't work, copy and paste this link into
|
||||
your browser:</p>
|
||||
<span class="code">{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email</span>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<p class="muted" style="margin:0;">This link will expire in 24 hours. If you didn't create an account,
|
||||
you can safely ignore this email.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="footer">
|
||||
© 2025 Hackdex. All rights reserved. Please do not reply to this message.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user