From e3f43045256238f3ac6b2ba93840369741fbe48b Mon Sep 17 00:00:00 2001 From: Jared Schoeny Date: Wed, 15 Oct 2025 13:57:26 -1000 Subject: [PATCH] Refactor and implement submit form functionality --- .gitignore | 3 + .vscode/launch.json | 4 + .vscode/tasks.json | 43 + package-lock.json | 514 +++++++++- package.json | 1 + src/app/api/patches/[id]/download/route.ts | 29 + src/app/globals.css | 55 + src/app/hack/[slug]/page.tsx | 100 +- src/app/submit/actions.ts | 183 ++++ src/app/submit/page.tsx | 2 +- src/components/Discover/DiscoverBrowser.tsx | 108 +- src/components/HackCard.tsx | 31 +- src/components/Submit/SubmitForm.tsx | 1015 ++++++++++++------- src/contexts/AuthContext.tsx | 15 +- src/utils/minio/server.ts | 19 + 15 files changed, 1660 insertions(+), 462 deletions(-) create mode 100644 .vscode/tasks.json create mode 100644 src/app/api/patches/[id]/download/route.ts create mode 100644 src/app/submit/actions.ts create mode 100644 src/utils/minio/server.ts diff --git a/.gitignore b/.gitignore index 5ef6a52..51af8a3 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# local minio storage +.minio diff --git a/.vscode/launch.json b/.vscode/launch.json index ee3bdd7..7672004 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -3,6 +3,8 @@ "configurations": [ { "name": "Next.js: debug server-side", + "preLaunchTask": "startMinio", + "postDebugTask": "stopMinio", "type": "node-terminal", "request": "launch", "command": "npm run dev" @@ -18,6 +20,8 @@ "type": "node-terminal", "request": "launch", "command": "npm run dev", + "preLaunchTask": "startMinio", + "postDebugTask": "stopMinio", "serverReadyAction": { "pattern": "- Local:.+(https?://.+)", "uriFormat": "%s", diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..e56b500 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,43 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "startMinio", + "type": "shell", + "command": "minio", + "args": [ + "server", + ".minio" + ], + "isBackground": true, + "problemMatcher": [ + { + "pattern": [ + { + "regexp": ".", + "file": 1, + "location": 2, + "message": 3 + } + ], + "background": { + "activeOnStart": true, + "beginsPattern": ".", + "endsPattern": "." + } + } + ] + }, + { + "label": "stopMinio", + "type": "shell", + "command": "pkill", + "args": [ + "-f", + "minio" + ], + "isBackground": true, + "problemMatcher": [] + } + ] +} diff --git a/package-lock.json b/package-lock.json index 9160875..d7a119d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@supabase/ssr": "^0.7.0", "@supabase/supabase-js": "^2.74.0", "embla-carousel-react": "8.6.0", + "minio": "^8.0.6", "next": "15.5.4", "react": "19.1.0", "react-dom": "19.1.0", @@ -1229,6 +1230,13 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/@zxing/text-encoding": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz", + "integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==", + "license": "(Unlicense OR Apache-2.0)", + "optional": true + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1244,6 +1252,27 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -1254,6 +1283,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/block-stream2": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/block-stream2/-/block-stream2-2.1.0.tgz", + "integrity": "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==", + "license": "MIT", + "dependencies": { + "readable-stream": "^3.4.0" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -1267,11 +1305,25 @@ "node": ">=8" } }, + "node_modules/browser-or-node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/browser-or-node/-/browser-or-node-2.1.1.tgz", + "integrity": "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==", + "license": "MIT" + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.0", @@ -1290,7 +1342,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -1304,7 +1355,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -1545,11 +1595,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/decode-uri-component": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -1599,7 +1657,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -1656,7 +1713,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -1666,7 +1722,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -1676,7 +1731,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -1707,12 +1761,36 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fast-xml-parser": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz", + "integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^1.1.1" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -1726,6 +1804,15 @@ "node": ">=8" } }, + "node_modules/filter-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", + "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/find-yarn-workspace-root": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", @@ -1736,6 +1823,21 @@ "micromatch": "^4.0.2" } }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -1755,17 +1857,24 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -1790,7 +1899,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -1804,7 +1912,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -1833,7 +1940,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -1846,7 +1952,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -1855,11 +1960,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -1918,12 +2037,27 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/inline-style-parser": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==", "license": "MIT" }, + "node_modules/ipaddr.js": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", + "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -1948,6 +2082,34 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-decimal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", @@ -1974,6 +2136,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-hexadecimal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", @@ -2006,6 +2187,39 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-wsl": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", @@ -2335,6 +2549,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -2369,7 +2589,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3222,6 +3441,27 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -3232,6 +3472,31 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minio": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/minio/-/minio-8.0.6.tgz", + "integrity": "sha512-sOeh2/b/XprRmEtYsnNRFtOqNRTPDvYtMWh+spWlfsuCV/+IdxNeKVUMKLqI7b5Dr07ZqCPuaRGU/rB9pZYVdQ==", + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.4", + "block-stream2": "^2.1.0", + "browser-or-node": "^2.1.1", + "buffer-crc32": "^1.0.0", + "eventemitter3": "^5.0.1", + "fast-xml-parser": "^4.4.1", + "ipaddr.js": "^2.0.1", + "lodash": "^4.17.21", + "mime-types": "^2.1.35", + "query-string": "^7.1.3", + "stream-json": "^1.8.0", + "through2": "^4.0.2", + "web-encoding": "^1.1.5", + "xml2js": "^0.5.0 || ^0.6.2" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, "node_modules/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", @@ -3470,6 +3735,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -3523,6 +3797,24 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/query-string": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", + "integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==", + "license": "MIT", + "dependencies": { + "decode-uri-component": "^0.2.2", + "filter-obj": "^1.1.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/react": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", @@ -3579,6 +3871,20 @@ "react": ">=18" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/remark-gfm": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.0.tgz", @@ -3658,6 +3964,49 @@ "RomPatcher": "index.js" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sax": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", + "license": "ISC" + }, "node_modules/scheduler": { "version": "0.26.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", @@ -3681,7 +4030,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -3790,6 +4138,48 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/split-on-first": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/stream-chain": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz", + "integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==", + "license": "BSD-3-Clause" + }, + "node_modules/stream-json": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz", + "integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==", + "license": "BSD-3-Clause", + "dependencies": { + "stream-chain": "^2.2.5" + } + }, + "node_modules/strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -3804,6 +4194,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/strnum": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", + "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/style-to-js": { "version": "1.1.17", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.17.tgz", @@ -3895,6 +4297,15 @@ "node": ">=18" } }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "license": "MIT", + "dependencies": { + "readable-stream": "3" + } + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -4067,11 +4478,23 @@ "node": ">= 10.0.0" } }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/vfile": { @@ -4102,6 +4525,18 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/web-encoding": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/web-encoding/-/web-encoding-1.1.5.tgz", + "integrity": "sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA==", + "license": "MIT", + "dependencies": { + "util": "^0.12.3" + }, + "optionalDependencies": { + "@zxing/text-encoding": "0.9.0" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -4134,6 +4569,27 @@ "node": ">= 8" } }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ws": { "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", @@ -4155,6 +4611,28 @@ } } }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", diff --git a/package.json b/package.json index 0ad8651..b3e23f5 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@supabase/ssr": "^0.7.0", "@supabase/supabase-js": "^2.74.0", "embla-carousel-react": "8.6.0", + "minio": "^8.0.6", "next": "15.5.4", "react": "19.1.0", "react-dom": "19.1.0", diff --git a/src/app/api/patches/[id]/download/route.ts b/src/app/api/patches/[id]/download/route.ts new file mode 100644 index 0000000..0c46504 --- /dev/null +++ b/src/app/api/patches/[id]/download/route.ts @@ -0,0 +1,29 @@ +import { NextRequest } from "next/server"; +import { getMinioClient, PATCHES_BUCKET } from "@/utils/minio/server"; +import { createClient } from "@/utils/supabase/server"; + +export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const supabase = await createClient(); + + const { data: patch, error } = await supabase + .from("patches") + .select("id, bucket, filename") + .eq("id", Number(id)) + .maybeSingle(); + if (error || !patch) { + return new Response("Not found", { status: 404 }); + } + + // Log download attempt (fire-and-forget) + try { + await supabase.from("patch_downloads").insert({ patch: patch.id }); + } catch {} + + const client = getMinioClient(); + const bucket = patch.bucket || PATCHES_BUCKET; + const url = await client.presignedGetObject(bucket, patch.filename, 60 * 5); + return Response.redirect(url, 302); +} + + diff --git a/src/app/globals.css b/src/app/globals.css index 4021c1e..00ffae2 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -186,6 +186,61 @@ body { --tw-prose-captions: color-mix(in oklab, var(--foreground) 70%, transparent); } +.prose * { + @apply m-0; +} + +.prose h1 { + @apply text-2xl font-bold; +} +.prose h1:not(:first-child) { + @apply mt-4; +} + +.prose h2 { + @apply text-xl font-semibold; +} +.prose h2:not(:first-child) { + @apply mt-4; +} + +.prose h3 { + @apply text-lg font-semibold; +} +.prose h3:not(:first-child) { + @apply mt-4; +} + +.prose h4, .prose h5, .prose h6 { + @apply text-base font-semibold; +} +.prose h4:not(:first-child), .prose h5:not(:first-child), .prose h6:not(:first-child) { + @apply mt-4; +} + +.prose p { + @apply text-base text-foreground/80; +} + +.prose p:not(h1 + p) { + @apply mt-1; +} +.prose p:not(h2 + p) { + @apply mt-1; +} +.prose p:not(h3 + p) { + @apply mt-1; +} +.prose p:not(h4 + p) { + @apply mt-1; +} +.prose p:not(h5 + p) { + @apply mt-1; +} +.prose p:not(h6 + p) { + @apply mt-1; +} + .scrollbar-thin { scrollbar-width: thin; } diff --git a/src/app/hack/[slug]/page.tsx b/src/app/hack/[slug]/page.tsx index 1758652..3591d44 100644 --- a/src/app/hack/[slug]/page.tsx +++ b/src/app/hack/[slug]/page.tsx @@ -1,4 +1,3 @@ -import { hacks } from "@/data/hacks"; import { baseRoms } from "@/data/baseRoms"; import { notFound } from "next/navigation"; import Gallery from "@/components/Hack/Gallery"; @@ -9,6 +8,8 @@ import remarkGfm from "remark-gfm"; import Image from "next/image"; import { FaDiscord, FaTwitter } from "react-icons/fa6"; import PokeCommunityIcon from "@/components/Icons/PokeCommunityIcon"; +import { createClient } from "@/utils/supabase/server"; +import { getMinioClient, PATCHES_BUCKET } from "@/utils/minio/server"; interface HackDetailProps { params: Promise<{ slug: string }>; @@ -16,18 +17,67 @@ interface HackDetailProps { export default async function HackDetail({ params }: HackDetailProps) { const { slug } = await params; - const hack = hacks.find((h) => h.slug === slug) ?? notFound(); - const baseRom = baseRoms.find((r) => r.name === hack.baseRom); + const supabase = await createClient(); + const { data: hack, error } = await supabase + .from("hacks") + .select("slug,title,summary,description,base_rom,version,created_at,updated_at,downloads,current_patch,box_art,social_links,created_by") + .eq("slug", slug) + .maybeSingle(); + if (error || !hack) return notFound(); + const baseRom = baseRoms.find((r) => r.id === hack.base_rom); + + let images: string[] = []; + const { data: covers } = await supabase + .from("hack_covers") + .select("url, position") + .eq("hack_slug", slug) + .order("position", { ascending: true }); + if (covers && covers.length > 0) { + const { data: imagesData } = await supabase.storage + .from('hack-covers') + .createSignedUrls(covers.map(c => c.url), 60 * 5); + if (imagesData) { + images = imagesData.map(d => d.signedUrl); + } + } + + const { data: tagRows } = await supabase + .from("hack_tags") + .select("tags(name)") + .eq("hack_slug", slug); + const tags = (tagRows || []).map((r: any) => r.tags?.name).filter(Boolean) as string[]; + + const { data: profile } = await supabase + .from("profiles") + .select("username") + .eq("id", hack.created_by as string) + .maybeSingle(); + const author = profile?.username || "Unknown"; + + // Resolve a short-lived signed patch URL (if current_patch exists) + let signedPatchUrl = ""; + if (hack.current_patch != null) { + const { data: patch } = await supabase + .from("patches") + .select("id,bucket,filename") + .eq("id", hack.current_patch as number) + .maybeSingle(); + if (patch) { + const client = getMinioClient(); + const bucket = patch.bucket || PATCHES_BUCKET; + signedPatchUrl = await client.presignedGetObject(bucket, patch.filename, 60 * 5); + } + } return (
@@ -39,10 +89,10 @@ export default async function HackDetail({ params }: HackDetailProps) { {hack.version}
-

By {hack.author}

+

By {author}

{hack.summary}

- {hack.tags.map((t) => ( + {tags.map((t) => ( {t} @@ -60,9 +110,9 @@ export default async function HackDetail({ params }: HackDetailProps) {
-
-
- +
+
+

About this hack

@@ -76,26 +126,26 @@ export default async function HackDetail({ params }: HackDetailProps) {

Details

- {hack.boxArt && ( + {hack.box_art && (
Box art
- {`${hack.title} + {`${hack.title}
)} diff --git a/src/app/submit/actions.ts b/src/app/submit/actions.ts new file mode 100644 index 0000000..3fa48e5 --- /dev/null +++ b/src/app/submit/actions.ts @@ -0,0 +1,183 @@ +"use server"; + +import { createClient } from "@/utils/supabase/server"; +import type { TablesInsert } from "@/types/db"; +import { getMinioClient, PATCHES_BUCKET } from "@/utils/minio/server"; + +type HackInsert = TablesInsert<"hacks">; + +function slugify(text: string) { + return text + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +async function ensureUniqueSlug(base: string, supabase: Awaited>) { + let candidate = base; + let suffix = 2; + // Loop until slug is unique + while (true) { + const { data, error } = await supabase + .from("hacks") + .select("slug") + .eq("slug", candidate) + .maybeSingle(); + if (error && error.code !== "PGRST116") throw error; + if (!data) return candidate; + candidate = `${base}-${suffix++}`; + } +} + +export async function prepareSubmission(formData: FormData) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return { ok: false, error: "Unauthorized" } as const; + } + + const title = (formData.get("title") as string)?.trim(); + const summary = (formData.get("summary") as string)?.trim(); + const description = (formData.get("description") as string)?.trim(); + const base_rom = (formData.get("base_rom") as string)?.trim(); + const language = (formData.get("language") as string)?.trim(); + const version = (formData.get("version") as string)?.trim(); + const box_art = (formData.get("box_art") as string)?.trim() || null; + const discord = (formData.get("discord") as string)?.trim(); + const twitter = (formData.get("twitter") as string)?.trim(); + const pokecommunity = (formData.get("pokecommunity") as string)?.trim(); + const tags = (formData.get("tags") as string)?.split(",").map((t) => t.trim()).filter(Boolean) || []; + + if (!title || !summary || !description || !base_rom || !language || !version) { + return { ok: false, error: "Missing required fields" } as const; + } + + const baseSlug = slugify(title); + const slug = await ensureUniqueSlug(baseSlug, supabase); + + const social_links: HackInsert["social_links"] = + discord || twitter || pokecommunity + ? { + discord: discord || undefined, + twitter: twitter || undefined, + pokecommunity: pokecommunity || undefined, + } + : null; + + const insertPayload: HackInsert = { + slug, + title, + summary, + description, + base_rom, + language, + version, + created_by: user.id, + downloads: 0, + box_art, + social_links, + approved: false, + patch_url: "", + } as HackInsert; + + const { error: insertErr } = await supabase.from("hacks").insert(insertPayload); + if (insertErr) { + return { ok: false, error: insertErr.message } as const; + } + + // Tags: restrict to existing only + if (tags.length > 0) { + const { data: existingTags, error: tagErr } = await supabase + .from("tags") + .select("id, name") + .in("name", tags); + if (tagErr) return { ok: false, error: tagErr.message } as const; + if (existingTags && existingTags.length > 0) { + const hackTags = existingTags.map((t) => ({ hack_slug: slug, tag_id: t.id })); + const { error: htErr } = await supabase.from("hack_tags").insert(hackTags); + if (htErr) return { ok: false, error: htErr.message } as const; + } + } + + return { ok: true, slug } as const; +} + +export async function presignPatchAndSaveCovers(args: { + slug: string; + version: string; + coverUrls: string[]; + // desired object key; if omitted we build from slug+version + objectKey?: string; +}) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return { ok: false, error: "Unauthorized" } as const; + + // Ensure hack exists and belongs to user (created_by) to prevent spoof + const { data: hack, error: hErr } = await supabase + .from("hacks") + .select("slug, created_by") + .eq("slug", args.slug) + .maybeSingle(); + if (hErr) return { ok: false, error: hErr.message } as const; + if (!hack) return { ok: false, error: "Hack not found" } as const; + if (hack.created_by !== user.id) return { ok: false, error: "Forbidden" } as const; + + // Insert covers (overwrite positions) + if (args.coverUrls && args.coverUrls.length > 0) { + // Clear any existing rows first (idempotency on retry) + await supabase.from("hack_covers").delete().eq("hack_slug", args.slug); + const rows = args.coverUrls.map((url, idx) => ({ hack_slug: args.slug, url, position: idx + 1 })); + const { error: cErr } = await supabase.from("hack_covers").insert(rows); + if (cErr) return { ok: false, error: cErr.message } as const; + } + + const safeVersion = args.version.replace(/[^a-zA-Z0-9._-]+/g, "-"); + const objectKey = args.objectKey || `${args.slug}-${safeVersion}.bps`; + + const client = getMinioClient(); + // 10 minutes to upload + const url = await client.presignedPutObject(PATCHES_BUCKET, objectKey, 60 * 10); + + return { ok: true, presignedUrl: url, objectKey } as const; +} + +export async function confirmPatchUpload(args: { slug: string; objectKey: string; version: string }) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return { ok: false, error: "Unauthorized" } as const; + + const { data: hack, error: hErr } = await supabase + .from("hacks") + .select("slug, created_by") + .eq("slug", args.slug) + .maybeSingle(); + if (hErr) return { ok: false, error: hErr.message } as const; + if (!hack) return { ok: false, error: "Hack not found" } as const; + if (hack.created_by !== user.id) return { ok: false, error: "Forbidden" } as const; + + // Create patch row + const { data: patch, error: pErr } = await supabase + .from("patches") + .insert({ bucket: PATCHES_BUCKET, filename: args.objectKey, version: args.version, parent_hack: args.slug }) + .select("id") + .single(); + if (pErr) return { ok: false, error: pErr.message } as const; + + const { error: uErr } = await supabase + .from("hacks") + .update({ current_patch: patch.id }) + .eq("slug", args.slug); + if (uErr) return { ok: false, error: uErr.message } as const; + + return { ok: true, patchId: patch.id, redirectTo: `/hack/${args.slug}` } as const; +} + + diff --git a/src/app/submit/page.tsx b/src/app/submit/page.tsx index f8b414c..ee82c31 100644 --- a/src/app/submit/page.tsx +++ b/src/app/submit/page.tsx @@ -16,7 +16,7 @@ export default async function SubmitPage() { } return ( -
+

Submit your ROM hack

Share your hack so others can discover and play it.

diff --git a/src/components/Discover/DiscoverBrowser.tsx b/src/components/Discover/DiscoverBrowser.tsx index 212fa31..5b06daa 100644 --- a/src/components/Discover/DiscoverBrowser.tsx +++ b/src/components/Discover/DiscoverBrowser.tsx @@ -1,35 +1,103 @@ "use client"; import React from "react"; -import { hacks as allHacks } from "@/data/hacks"; import HackCard from "@/components/HackCard"; +import { createClient } from "@/utils/supabase/client"; export default function DiscoverBrowser() { const [query, setQuery] = React.useState(""); const [tag, setTag] = React.useState(null); const [sort, setSort] = React.useState("popular"); + const [hacks, setHacks] = React.useState([]); + const [tags, setTags] = React.useState([]); - const tags = React.useMemo(() => { - const set = new Set(); - allHacks.forEach((h) => h.tags.forEach((t) => set.add(t))); - return Array.from(set).sort(); - }, []); + React.useEffect(() => { + const run = async () => { + const supabase = createClient(); + const { data: rows } = await supabase + .from("hacks") + .select("slug,title,summary,description,base_rom,version,downloads,created_by,patch_url") + .order(sort === "popular" ? "downloads" : "created_at", { ascending: false }); + 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 { data: imagesData } = await supabase.storage + .from('hack-covers') + .createSignedUrls(coverRows.map(c => c.url), 60 * 5); + if (imagesData) { + // Map: storage object url -> signedUrl + const urlToSignedUrl = new Map(); + imagesData.forEach((d, idx) => { + // If creation fails, d.signedUrl might be undefined; filter those out + if (d.signedUrl) urlToSignedUrl.set(coverRows[idx].url, d.signedUrl); + }); - const hacks = React.useMemo(() => { - let filtered = allHacks.filter((h) => { - const q = query.toLowerCase(); - const matchesQ = - !q || + 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,tags(name)") + .in("hack_slug", slugs); + const tagsBySlug = new Map(); + (tagRows || []).forEach((r: any) => { + if (!r.tags?.name) return; + const arr = tagsBySlug.get(r.hack_slug) || []; + arr.push(r.tags.name); + tagsBySlug.set(r.hack_slug, arr); + }); + 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: usernameById.get(r.created_by as string) || "Unknown", + covers: coversBySlug.get(r.slug) || [], + tags: tagsBySlug.get(r.slug) || [], + downloads: r.downloads, + baseRomId: r.base_rom, + version: r.version, + summary: r.summary, + description: r.description, + })); + + setHacks(mapped); + const tagSet = new Set(); + mapped.forEach((h) => h.tags.forEach((t) => tagSet.add(t))); + setTags(Array.from(tagSet).sort()); + }; + run(); + }, [sort]); + + const filtered = React.useMemo(() => { + let out = hacks; + const q = query.toLowerCase(); + if (q) { + out = out.filter((h) => h.title.toLowerCase().includes(q) || h.author.toLowerCase().includes(q) || - h.description.toLowerCase().includes(q); - const matchesTag = !tag || h.tags.includes(tag); - return matchesQ && matchesTag; - }); - if (sort === "popular") filtered = filtered.sort((a, b) => b.downloads - a.downloads); - if (sort === "new") filtered = filtered; // mock - return filtered; - }, [query, tag, sort]); + (h.description || "").toLowerCase().includes(q) + ); + } + if (tag) out = out.filter((h) => h.tags.includes(tag)); + return out; + }, [hacks, query, tag]); return (
@@ -79,7 +147,7 @@ export default function DiscoverBrowser() {
- {hacks.map((hack) => ( + {filtered.map((hack) => ( ))}
diff --git a/src/components/HackCard.tsx b/src/components/HackCard.tsx index 875e3fe..f2fe552 100644 --- a/src/components/HackCard.tsx +++ b/src/components/HackCard.tsx @@ -1,18 +1,33 @@ import Image from "next/image"; +import PixelImage from "./PixelImage"; import Link from "next/link"; import { formatCompactNumber } from "@/utils/format"; import { useBaseRoms } from "@/contexts/BaseRomContext"; -import type { Hack } from "@/data/hacks"; +import { baseRoms } from "@/data/baseRoms"; import { useEffect, useState } from "react"; import useEmblaCarousel from "embla-carousel-react"; import { usePathname } from "next/navigation"; +import { FaRegImages } from "react-icons/fa6"; -// Using shared Hack type from data +type CardHack = { + slug: string; + title: string; + author: string; + covers: string[]; + tags: string[]; + downloads: number; + baseRomId?: string; + version: string; + summary?: string; + description?: string; +}; -export default function HackCard({ hack, clickable = true, className = "" }: { hack: Hack; clickable?: boolean; className?: string }) { +export default function HackCard({ hack, clickable = true, className = "" }: { hack: CardHack; clickable?: boolean; className?: string }) { const { isLinked, hasPermission, hasCached } = useBaseRoms(); - const linked = isLinked(hack.baseRom); - const ready = hasPermission(hack.baseRom) || hasCached(hack.baseRom); + const match = baseRoms.find((r) => r.id === hack.baseRomId); + const baseName = match?.name ?? undefined; + const linked = baseName ? isLinked(baseName) : false; + const ready = baseName ? hasPermission(baseName) || hasCached(baseName) : false; const images = (hack.covers && hack.covers.length > 0 ? hack.covers : []).filter(Boolean); const isCarousel = images.length > 1; const pathname = usePathname(); @@ -30,7 +45,7 @@ export default function HackCard({ hack, clickable = true, className = "" }: { h emblaApi.off("select", onSelect); }; }, [emblaApi]); - const cardClass = `rounded-[12px] overflow-hidden ${ + const cardClass = `rounded-[12px] overflow-hidden min-w-[360px] ${ clickable ? "transition-transform duration-300 hover:-translate-y-0.5 hover:shadow-xl 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'}`; @@ -75,7 +90,7 @@ export default function HackCard({ hack, clickable = true, className = "" }: { h {hack.tags.slice(0, 2).map((t) => ( {t} @@ -141,7 +156,7 @@ export default function HackCard({ hack, clickable = true, className = "" }: { h return text.length > 120 ? text.slice(0, 120).trimEnd() + "…" : text; })()}

-
Base: {hack.baseRom}
+
Base: {baseName ?? "Unknown"}
); diff --git a/src/components/Submit/SubmitForm.tsx b/src/components/Submit/SubmitForm.tsx index 9d18f8d..0370fcf 100644 --- a/src/components/Submit/SubmitForm.tsx +++ b/src/components/Submit/SubmitForm.tsx @@ -4,15 +4,18 @@ import React from "react"; import Image from "next/image"; import { baseRoms } from "@/data/baseRoms"; import HackCard from "@/components/HackCard"; -import type { Hack } from "@/data/hacks"; -import { hacks as allHacks } from "@/data/hacks"; +import { createClient } from "@/utils/supabase/client"; +import { prepareSubmission, presignPatchAndSaveCovers, confirmPatchUpload } from "@/app/submit/actions"; import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from "@dnd-kit/core"; import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import { RxDragHandleDots2, RxPlus } from "react-icons/rx"; +import { useAuthContext } from "@/contexts/AuthContext"; +import { Database } from "@/types/db"; -function SortableCoverItem({ id, index, url, onRemove }: { id: string; index: number; url: string; onRemove: () => void }) { +function SortableCoverItem({ id, index, url, filename, onRemove }: { id: string; index: number; url: string; filename: string; onRemove: () => void }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), @@ -22,12 +25,14 @@ function SortableCoverItem({ id, index, url, onRemove }: { id: string; index: nu
-
⋮⋮
+
+ +
{`Cover
-
{url}
+
{filename}
{index === 0 &&
Primary
}
@@ -49,16 +54,24 @@ interface SubmitFormProps { dummy?: boolean; } +type TagSortable = Database["public"]["Tables"]["tags"]["Row"] & { + popularity: number; +}; + export default function SubmitForm({ dummy = false }: SubmitFormProps) { const MAX_COVERS = 10; + const { profile } = useAuthContext(); const [title, setTitle] = React.useState(""); - const [author, setAuthor] = React.useState(""); + // Author derived from profile later const [summary, setSummary] = React.useState(""); const [description, setDescription] = React.useState(""); - const [coverUrls, setCoverUrls] = React.useState([]); - const [newCoversInput, setNewCoversInput] = React.useState(""); - const [baseRom, setBaseRom] = React.useState("Pokemon Emerald"); - const [version, setVersion] = React.useState("v0.1.0"); + // Deprecated: coverUrls removed; using files array for screenshots + const [newCoverFiles, setNewCoverFiles] = React.useState([]); + const [coverErrors, setCoverErrors] = React.useState([]); + const [baseRom, setBaseRom] = React.useState(""); + const [platform, setPlatform] = React.useState<"GB" | "GBC" | "GBA" | "NDS" | "">(""); + const [version, setVersion] = React.useState(""); + const [language, setLanguage] = React.useState(""); const [boxArt, setBoxArt] = React.useState(""); const [discord, setDiscord] = React.useState(""); const [twitter, setTwitter] = React.useState(""); @@ -66,25 +79,70 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) { const [tags, setTags] = React.useState([]); const [tagsInput, setTagsInput] = React.useState(""); const [showMdPreview, setShowMdPreview] = React.useState(false); + const [patchFile, setPatchFile] = React.useState(null); + const [submitting, setSubmitting] = React.useState(false); + const [step, setStep] = React.useState(1); + const supabase = createClient(); + const isDummy = !!dummy; - const parseUrls = (text: string) => - text - .split(/[\n,]+/) - .map((s) => s.trim()) - .filter(Boolean); + const titleInputRef = React.useRef(null); + const versionInputRef = React.useRef(null); + const screenshotsInputRef = React.useRef(null); + const patchInputRef = React.useRef(null); - const addFromInput = () => { - const urls = parseUrls(newCoversInput); - if (urls.length === 0) return; - setCoverUrls((prev) => [...prev, ...urls]); - setNewCoversInput(""); + // Build object URLs for local screenshot previews and clean them up when files change + const coverPreviews = React.useMemo(() => { + return newCoverFiles.map((f) => URL.createObjectURL(f)); + }, [newCoverFiles]); + + React.useEffect(() => { + return () => { + coverPreviews.forEach((u) => URL.revokeObjectURL(u)); + }; + }, [coverPreviews]); + + const uploadCovers = async (slug: string) => { + if (!newCoverFiles || newCoverFiles.length === 0) return [] as string[]; + const urls: string[] = []; + for (let i = 0; i < newCoverFiles.length; i++) { + const file = newCoverFiles[i]; + const fileExt = file.name.split('.').pop(); + const path = `${slug}/${Date.now()}-${i}.${fileExt}`; + const { error } = await supabase.storage.from('hack-covers').upload(path, file); + if (error) throw error; + urls.push(path); + } + return urls; }; - const overLimit = coverUrls.length > MAX_COVERS; - const overBy = Math.max(0, coverUrls.length - MAX_COVERS); + + function getAllowedSizesForPlatform(platform: "GB" | "GBC" | "GBA" | "NDS") { + if (platform === "GB" || platform === "GBC") return [{ w: 160, h: 144 }]; + if (platform === "GBA") return [{ w: 240, h: 160 }]; + // NDS + return [{ w: 256, h: 192 }, { w: 256, h: 384 }]; + } + + async function validateImageDimensions(file: File, allowed: { w: number; h: number }[]) { + return new Promise((resolve) => { + const img = document.createElement("img"); + const url = URL.createObjectURL(file); + img.onload = () => { + const ok = allowed.some((s) => img.naturalWidth === s.w && img.naturalHeight === s.h); + URL.revokeObjectURL(url); + resolve(ok); + }; + img.onerror = () => { + URL.revokeObjectURL(url); + resolve(false); + }; + img.src = url; + }); + } + const overLimit = newCoverFiles.length > MAX_COVERS; const removeAt = (index: number) => { - setCoverUrls((prev) => prev.filter((_, i) => i !== index)); + setNewCoverFiles((prev) => prev.filter((_, i) => i !== index)); }; const sensors = useSensors( @@ -94,28 +152,88 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) { const onDragEnd = (event: any) => { const { active, over } = event; if (!over || active.id === over.id) return; - const ids = coverUrls.map((url, i) => `${url}-${i}`); + const ids = newCoverFiles.map((f, i) => `${f.name}-${i}`); const oldIndex = ids.indexOf(active.id as string); const newIndex = ids.indexOf(over.id as string); if (oldIndex === -1 || newIndex === -1) return; - setCoverUrls((prev) => arrayMove(prev, oldIndex, newIndex)); + setNewCoverFiles((prev) => arrayMove(prev, oldIndex, newIndex)); }; - const existingTags = React.useMemo(() => { - const set = new Set(); - allHacks.forEach((h) => h.tags.forEach((t) => set.add(t))); - return Array.from(set).sort(); + // Existing tags (from DB) + const [allTags, setAllTags] = React.useState([]); + const [isTagDropdownOpen, setIsTagDropdownOpen] = React.useState(false); + const tagAreaRef = React.useRef(null); + const tagsInputRef = React.useRef(null); + const [activeTagIndex, setActiveTagIndex] = React.useState(0); + + React.useEffect(() => { + // Fetch all tags once on mount + const fetchTags = async () => { + try { + const { data, error } = await supabase.from('tags').select('id, name, usage: hack_tags (count)'); + if (error) return; + const fetchedTags: TagSortable[] = (data || []).map((t: any) => ({ id: t.id, name: t.name, popularity: t.usage[0].count || 0 })); + setAllTags( + fetchedTags.sort((a, b) => { + if (b.popularity !== a.popularity) { + return b.popularity - a.popularity; + } + // If popularity is equal, sort by id + if (a.id < b.id) return -1; + if (a.id > b.id) return 1; + return 0; + }) + ); + } catch {} + }; + fetchTags(); + }, [supabase]); + + React.useEffect(() => { + const onDocClick = (e: MouseEvent) => { + if (!tagAreaRef.current) return; + if (!tagAreaRef.current.contains(e.target as Node)) { + setIsTagDropdownOpen(false); + } + }; + document.addEventListener('mousedown', onDocClick); + return () => document.removeEventListener('mousedown', onDocClick); }, []); - const suggestedTags = React.useMemo(() => { + const filteredTags = React.useMemo(() => { const q = tagsInput.trim().toLowerCase(); - if (!q) return [] as string[]; - return existingTags.filter((t) => t.toLowerCase().startsWith(q) && !tags.includes(t)).slice(0, 6); - }, [existingTags, tags, tagsInput]); + const pool = allTags.filter((t) => !tags.includes(t.name)); + if (!q) return pool.slice(0, 15); + return pool.filter((t) => t.name.toLowerCase().includes(q)).slice(0, 8); + }, [allTags, tags, tagsInput]); + + // Focus the first input on each step when step changes + React.useEffect(() => { + if (isDummy) return; + if (step === 1) { + titleInputRef.current?.focus(); + } else if (step === 2) { + versionInputRef.current?.focus(); + } else if (step === 3) { + screenshotsInputRef.current?.focus(); + } else if (step === 4) { + patchInputRef.current?.focus(); + } + }, [step, isDummy]); + + React.useEffect(() => { + if (!isTagDropdownOpen) return; + if (filteredTags.length === 0) { + setActiveTagIndex(0); + } else { + setActiveTagIndex((i) => Math.min(Math.max(0, i), filteredTags.length - 1)); + } + }, [filteredTags, isTagDropdownOpen]); const addTag = (value: string) => { const tag = value.trim(); if (!tag) return; + if (!allTags.some((t) => t.name === tag)) return; // only allow existing tags if (tags.includes(tag)) return; setTags((prev) => [...prev, tag]); setTagsInput(""); @@ -126,11 +244,27 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) { }; const onTagsKeyDown: React.KeyboardEventHandler = (e) => { - if (e.key === "Enter" || e.key === ",") { + if (e.key === 'ArrowDown') { e.preventDefault(); - addTag(tagsInput); - } else if (e.key === "Backspace" && !tagsInput && tags.length > 0) { - // Quick backspace to remove last + if (!isTagDropdownOpen) setIsTagDropdownOpen(true); + if (filteredTags.length > 0) setActiveTagIndex((i) => (i + 1) % filteredTags.length); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + if (!isTagDropdownOpen) setIsTagDropdownOpen(true); + if (filteredTags.length > 0) setActiveTagIndex((i) => (i - 1 + filteredTags.length) % filteredTags.length); + } else if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + const choice = filteredTags[activeTagIndex] || filteredTags[0]; + if (choice) { + addTag(choice.name); + setIsTagDropdownOpen(true); + } + } else if (e.key === 'Tab') { + // Close dropdown and allow tabbing to next focusable element + setIsTagDropdownOpen(false); + } else if (e.key === 'Escape') { + setIsTagDropdownOpen(false); + } else if (e.key === 'Backspace' && !tagsInput && tags.length > 0) { e.preventDefault(); setTags((prev) => prev.slice(0, prev.length - 1)); } @@ -148,32 +282,71 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) { const summaryLimit = 120; const summaryTooLong = summary.length > summaryLimit; + const allowedSizes = platform ? getAllowedSizesForPlatform(platform) : []; + const urlLike = (s: string) => !s || /^https?:\/\//i.test(s); - const isValid = - !!title.trim() && - !!author.trim() && - !!baseRom.trim() && - !!version.trim() && - coverUrls.length > 0 && - !!summary.trim() && - !summaryTooLong && - urlLike(boxArt) && - urlLike(discord) && - urlLike(twitter) && - urlLike(pokecommunity) && - !overLimit; + const hasOneSocial = [discord, twitter, pokecommunity].some((s) => !!s.trim()); + const allSocialValid = [discord, twitter, pokecommunity].every((s) => !s || urlLike(s)); - const preview: Hack = { + + const step1Valid = !!title.trim() && !!platform && !!baseRom.trim() && !!language.trim(); + const step2Valid = !!version.trim() && !!summary.trim() && !summaryTooLong && !!description.trim() && tags.length > 0; + const step3Valid = (newCoverFiles.length > 0) && !overLimit && coverErrors.length === 0 && (!boxArt.trim() || urlLike(boxArt)) && allSocialValid; + const isValid = step1Valid && step2Valid && step3Valid && !!patchFile; + + const onSubmit = async () => { + if (!isValid || submitting) return; + setSubmitting(true); + try { + // Step 1: create hack & tags + const fd = new FormData(); + fd.set('title', title); + fd.set('summary', summary); + fd.set('description', description); + fd.set('base_rom', baseRom); + fd.set('language', language); + fd.set('version', version); + if (boxArt) fd.set('box_art', boxArt); + if (discord) fd.set('discord', discord); + if (twitter) fd.set('twitter', twitter); + if (pokecommunity) fd.set('pokecommunity', pokecommunity); + if (tags.length) fd.set('tags', tags.join(',')); + + const prepared = await prepareSubmission(fd); + if (!prepared.ok) throw new Error(prepared.error || 'Failed to prepare'); + + // Step 2: upload covers to storage and save rows + const uploadedCoverUrls = await uploadCovers(prepared.slug); + const presigned = await presignPatchAndSaveCovers({ slug: prepared.slug, version, coverUrls: uploadedCoverUrls }); + if (!presigned.ok) throw new Error(presigned.error || 'Failed to presign'); + + // Step 3: upload patch via signed URL + if (patchFile) { + await fetch(presigned.presignedUrl, { method: 'PUT', body: patchFile, headers: { 'Content-Type': 'application/octet-stream' } }); + const finalized = await confirmPatchUpload({ slug: prepared.slug, objectKey: presigned.objectKey!, version }); + if (!finalized.ok) throw new Error(finalized.error || 'Failed to finalize'); + window.location.href = finalized.redirectTo!; + } else { + window.location.href = `/hack/${prepared.slug}`; + } + } catch (e: any) { + alert(e.message || 'Submission failed'); + } finally { + setSubmitting(false); + } + }; + + const preview = { slug: slug || "preview", title: title || "Your hack title", - author: author || "Your name", + author: profile?.username ? `@${profile.username}` : "You", summary: (summary || "Short description, max 100 characters.") as string, description: (description || "Write a longer markdown description here.") as string, - covers: coverUrls, - baseRom: baseRom || "Pokemon Emerald", + covers: coverPreviews, + baseRomId: baseRom, downloads: 0, - version: version || "v0.1.0", + version: version || "v0.0.0", tags, ...(boxArt ? { boxArt } : {}), socialLinks: @@ -184,343 +357,436 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) { patchUrl: "", }; - const isDummy = !!dummy; + const hasBaseRom = !!baseRom.trim(); return ( -
-
+
+
- {/* Title */} -
- - {!isDummy ? ( - setTitle(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)]" - /> - ) : ( -
- Your hack title -
- )} -
URL preview: /hack/{slug || "your-title"}
-
+ {/* Required note */} +
* Required
- {/* Author */} -
- - {!isDummy ? ( - setAuthor(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)]" - /> - ) : ( -
Your name
- )} -
- - {/* Summary */} -
-
- - {summary.length}/{summaryLimit} -
- {!isDummy ? ( - setSummary(e.target.value)} - placeholder="<= 100 characters" - className={`h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 focus:ring-[var(--ring)] ${summaryTooLong ? "ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" : "bg-[var(--surface-2)] ring-[var(--border)]"}`} - /> - ) : ( -
- Short description, max 100 characters. -
- )} -
- - {/* Long description */} -
-
- - {!isDummy && ( -
- - -
- )} -
- {isDummy ? ( -
- {description || "Write a longer markdown description here."} -
- ) : !showMdPreview ? ( -