diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 97584d39..00000000 --- a/.eslintrc.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": ["next/core-web-vitals", "plugin:jsx-a11y/recommended"], - "plugins": ["jsx-a11y"] -} diff --git a/bun.lock b/bun.lock index 94a26f13..5f56449e 100644 --- a/bun.lock +++ b/bun.lock @@ -14,9 +14,9 @@ "jszip": "^3.10.1", "lottie-web": "^5.12.2", "lucide-react": "^0.469.0", - "next": "^15.1.8", - "react": "^19.0.0", - "react-dom": "^19.0.0", + "next": "16.3.4", + "react": "19.2.8", + "react-dom": "19.2.8", "reframe": ".", "tailwind-merge": "^3.6.0", "wasm-feature-detect": "^1.8.0", @@ -31,11 +31,11 @@ "@testing-library/user-event": "^14.4.3", "@types/bun": "^1.3.14", "@types/node": "^25", - "@types/react": "^19", - "@types/react-dom": "^19", + "@types/react": "19.2.18", + "@types/react-dom": "19.2.5", "chromatic": "^18.5.0", - "eslint": "^9", - "eslint-config-next": "^15.1.8", + "eslint": "9.39.5", + "eslint-config-next": "16.3.4", "eslint-plugin-jsx-a11y": "^6.10.2", "jsdom": "^29.1.1", "postcss": "^8", @@ -47,6 +47,10 @@ }, }, }, + "overrides": { + "@types/react": "19.2.18", + "@types/react-dom": "19.2.5", + }, "packages": { "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], @@ -454,25 +458,25 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="], - "@next/env": ["@next/env@15.5.25", "", {}, "sha512-42h1lLr07vl4gawALP1hsgRZjHB1xYa58JfUfHwr0f7jG/zhPakh5GHkADHXOC9ZxUvlQFOPIrp7s6qX4DezPQ=="], + "@next/env": ["@next/env@16.3.4", "", {}, "sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q=="], - "@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.5.25", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-dAzOqZQCAOgIq5yQpsuMBZcwxK0AtzGqHMQpxNWYLQlvf79rsP3SDwdGPNQC4ySaMSihOQXMO/VXubQ3JWW+3A=="], + "@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.3.4", "", { "dependencies": { "@eslint-community/eslint-utils": "4.9.1", "fast-glob": "3.3.1" } }, "sha512-szW9y2Aumu4z88YXfTzcFsgUAg2k64uzbtcO5L9f1AKS4w/GUKJcbFllRflROVyNPgJtGOnvNxiyp3v6b+prIA=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.25", "", { "os": "darwin", "cpu": "arm64" }, "sha512-w+RR0v/QuApnWEjRGm1z6gcObKwGMb5YPA7V3bzBEVSBpMFUXprer0tS27UxjUcEnqbhL7Zuzohej79B6rYmBg=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.25", "", { "os": "darwin", "cpu": "x64" }, "sha512-QiGGBUSakt8S1H4Lt9Ehsh6Ja87axiBnQQgysOObvCbI7iUfJnRGntF1P64S4/ijuHFnSB8KLsEddkY3nN26uw=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.25", "", { "os": "linux", "cpu": "arm64" }, "sha512-ehLos/66zo0d/mJCU5u96a/VDcr01aaUrX0o/i16UdInxz8qPTCDSxGtjk/Lps1sIr1RJFdiX3hxc0fxJo+cPA=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.25", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZVMrqLiJ7DiChgmbkQwFtdhAnUkSH/4p7tB29QY+giATb0Q/XGHNRSKAb/B8XGDHRUaA67NepOW5W8u3ZRJBAA=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.25", "", { "os": "linux", "cpu": "x64" }, "sha512-UOewtDGkTMJTiODrEdeLZ50yGb59xCZSriNpXkfPMxRRgwDkGc7i8mLWqV5076wEdb+Ca/XN7MhJyM3CupyNyQ=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.25", "", { "os": "linux", "cpu": "x64" }, "sha512-UBHwA8AhkCZgtRfU1aJpunuAJe/6gZv6jDESQe4p5MjTb5V0YEeJBWCdNqx15Vj3x+5jmauRfeMJSjfQj9HGFQ=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.25", "", { "os": "win32", "cpu": "arm64" }, "sha512-QcFcPRr16djk5IqK5+e8O80eZfgWzIvVBXfitIq0tQ/uc+eyfdoZ0NmKc0cnbIJyfVwREapKuG97YcxWA9gcpA=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.25", "", { "os": "win32", "cpu": "x64" }, "sha512-zREeykps3ndWr9egJgvJKqVkkDuaw6Zrrg23cYBos0ygydFkAWYU4+PaPVwXzP1eAYQJe53ShSK45iDM529BOg=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -598,8 +602,6 @@ "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], - "@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.16.1", "", {}, "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag=="], - "@sentry-internal/tracing": ["@sentry-internal/tracing@7.120.4", "", { "dependencies": { "@sentry/core": "7.120.4", "@sentry/types": "7.120.4", "@sentry/utils": "7.120.4" } }, "sha512-Fz5+4XCg3akeoFK+K7g+d7HqGMjmnLoY2eJlpONJmaeT9pXY7yfUyXKZMmMajdE2LxxKJgQ2YKvSCaGVamTjHw=="], "@sentry/core": ["@sentry/core@7.120.4", "", { "dependencies": { "@sentry/types": "7.120.4", "@sentry/utils": "7.120.4" } }, "sha512-TXu3Q5kKiq8db9OXGkWyXUbIxMMuttB5vJ031yolOl5T/B69JRyAoKuojLBjRv1XX583gS1rSSoX8YXX7ATFGA=="], @@ -640,7 +642,7 @@ "@storybook/react-dom-shim": ["@storybook/react-dom-shim@10.5.10", "", { "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.5.10" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-rbu62ILo/VE3iXKmu+kWXFpD1H1Lwi0f19q/x7JnDsD2dxKS9w5znLEqPIq2qxpzi/wjjIb2iUP1cRG1d/9W5A=="], - "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], + "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], @@ -1128,7 +1130,7 @@ "eslint": ["eslint@9.39.5", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.6", "@eslint/js": "9.39.5", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw=="], - "eslint-config-next": ["eslint-config-next@15.5.25", "", { "dependencies": { "@next/eslint-plugin-next": "15.5.25", "@rushstack/eslint-patch": "^1.10.3", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^5.0.0" }, "peerDependencies": { "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", "typescript": ">=3.3.1" }, "optionalPeers": ["typescript"] }, "sha512-hB1oClZzRO3vMAeMmBMch0FHNJ4irH74LvQWDEMHkJtG/Us9+SAsg1f5Pvcw9jz4tc8wSGnvbmF050BRAAIbBg=="], + "eslint-config-next": ["eslint-config-next@16.3.4", "", { "dependencies": { "@next/eslint-plugin-next": "16.3.4", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^7.0.0", "globals": "16.4.0", "typescript-eslint": "^8.46.0" }, "peerDependencies": { "eslint": ">=9.0.0", "typescript": ">=3.3.1" }, "optionalPeers": ["typescript"] }, "sha512-35/8RM10huEL9vlr8hUZMERMENHBrnyHN3ZZkF9efSgzGaqK34jIqry44A956//zriUhUAUW0XSkcolhrryqAA=="], "eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.10", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.16.1", "resolve": "^2.0.0-next.6" } }, "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ=="], @@ -1142,7 +1144,7 @@ "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="], - "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="], + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="], "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], @@ -1238,7 +1240,7 @@ "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], - "globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + "globals": ["globals@16.4.0", "", {}, "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw=="], "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], @@ -1266,6 +1268,10 @@ "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], + "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], + + "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + "hmac-drbg": ["hmac-drbg@1.0.1", "", { "dependencies": { "hash.js": "^1.0.3", "minimalistic-assert": "^1.0.0", "minimalistic-crypto-utils": "^1.0.1" } }, "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg=="], "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], @@ -1520,7 +1526,7 @@ "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], - "next": ["next@15.5.25", "", { "dependencies": { "@next/env": "15.5.25", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.25", "@next/swc-darwin-x64": "15.5.25", "@next/swc-linux-arm64-gnu": "15.5.25", "@next/swc-linux-arm64-musl": "15.5.25", "@next/swc-linux-x64-gnu": "15.5.25", "@next/swc-linux-x64-musl": "15.5.25", "@next/swc-win32-arm64-msvc": "15.5.25", "@next/swc-win32-x64-msvc": "15.5.25", "sharp": "^0.34.3 || ^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-OMWNulIIqKM2ykvC2qMjIt0IoavB4UB2SCs4iXJ6z6847FvyH8jBmBWcvrF5iuhTu8Przh20Fo/aoszIdqx4PA=="], + "next": ["next@16.3.4", "", { "dependencies": { "@next/env": "16.3.4", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.4", "@next/swc-darwin-x64": "16.3.4", "@next/swc-linux-arm64-gnu": "16.3.4", "@next/swc-linux-arm64-musl": "16.3.4", "@next/swc-linux-x64-gnu": "16.3.4", "@next/swc-linux-x64-musl": "16.3.4", "@next/swc-win32-arm64-msvc": "16.3.4", "@next/swc-win32-x64-msvc": "16.3.4", "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA=="], "no-case": ["no-case@3.0.4", "", { "dependencies": { "lower-case": "^2.0.2", "tslib": "^2.0.3" } }, "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg=="], @@ -1912,6 +1918,8 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "typescript-eslint": ["typescript-eslint@8.69.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.69.0", "@typescript-eslint/parser": "8.69.0", "@typescript-eslint/typescript-estree": "8.69.0", "@typescript-eslint/utils": "8.69.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA=="], + "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], "undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], @@ -2004,6 +2012,10 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + + "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], @@ -2022,8 +2034,12 @@ "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + "@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + "@next/eslint-plugin-next/@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + "@next/eslint-plugin-next/fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="], "@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q=="], @@ -2150,7 +2166,7 @@ "mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + "next/postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], "next/styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "@babel/core": "*", "babel-plugin-macros": "*", "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" }, "optionalPeers": ["@babel/core", "babel-plugin-macros"] }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], @@ -2280,6 +2296,8 @@ "which-builtin-type/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + "@next/eslint-plugin-next/@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "@next/eslint-plugin-next/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "@oxc-resolver/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 00000000..3de1bf10 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,27 @@ +import { defineConfig } from "eslint/config"; +import nextCoreWebVitals from "eslint-config-next/core-web-vitals"; +import jsxA11Y from "eslint-plugin-jsx-a11y"; + +// jsx-a11y ships a native flat-config export, so no FlatCompat/.eslintrc +// bridge is needed here. That bridge (compat.extends("plugin:jsx-a11y/recommended")) +// pulls in @eslint/eslintrc's legacy config loader, which is incompatible +// with ESLint 10's minimatch resolution and crashes on startup. +// +// nextCoreWebVitals already registers the jsx-a11y plugin itself (with a +// 6-rule subset), so extending flatConfigs.recommended wholesale re-registers +// the same plugin and errors ("Cannot redefine plugin"). Merging just its +// `rules` gets the full 34-rule recommended set without the collision. +export default defineConfig([ + // `next lint` used to exclude build output automatically; plain `eslint` + // doesn't, so this has to be explicit or it lints into bundled/minified + // output (storybook-static, out, .next) as if it were source. + { + ignores: [".next/**", "out/**", "storybook-static/**", "coverage/**"], + }, + { + extends: [...nextCoreWebVitals], + rules: { + ...jsxA11Y.flatConfigs.recommended.rules, + }, + }, +]); diff --git a/next.config.ts b/next.config.ts index 81ede2f9..7f1f28a1 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,8 +5,11 @@ const nextConfig: NextConfig = { experimental: { scrollRestoration: true, }, - // Required for ffmpeg.wasm to load WASM files correctly - // Without this, Next.js might try to process .wasm files and break them + // Required for ffmpeg.wasm to load WASM files correctly. + // Without this, Next.js might try to process .wasm files and break them. + // Next 16 defaults to Turbopack, which ignores this block entirely (and + // errors loudly if it's present without an equivalent `turbopack` config) + // — that's why dev/build scripts in package.json pass `--webpack` explicitly. webpack: (config) => { config.resolve.fallback = { fs: false }; return config; diff --git a/package.json b/package.json index 8a2a53e3..6a38e767 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,10 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", - "build": "next build", + "dev": "next dev --webpack", + "build": "next build --webpack", "start": "next start", - "lint": "next lint", + "lint": "eslint .", "test": "vitest", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", @@ -23,9 +23,9 @@ "jszip": "^3.10.1", "lottie-web": "^5.12.2", "lucide-react": "^0.469.0", - "next": "^15.1.8", - "react": "^19.0.0", - "react-dom": "^19.0.0", + "next": "16.3.4", + "react": "19.2.8", + "react-dom": "19.2.8", "reframe": ".", "tailwind-merge": "^3.6.0", "wasm-feature-detect": "^1.8.0" @@ -40,11 +40,11 @@ "@testing-library/user-event": "^14.4.3", "@types/bun": "^1.3.14", "@types/node": "^25", - "@types/react": "^19", - "@types/react-dom": "^19", + "@types/react": "19.2.18", + "@types/react-dom": "19.2.5", "chromatic": "^18.5.0", - "eslint": "^9", - "eslint-config-next": "^15.1.8", + "eslint": "9.39.5", + "eslint-config-next": "16.3.4", "eslint-plugin-jsx-a11y": "^6.10.2", "jsdom": "^29.1.1", "postcss": "^8", @@ -53,5 +53,9 @@ "tailwindcss": "^3.4.17", "typescript": "^5", "vitest": "^4.1.6" + }, + "overrides": { + "@types/react": "19.2.18", + "@types/react-dom": "19.2.5" } } diff --git a/src/components/AudioSpeedControl.tsx b/src/components/AudioSpeedControl.tsx index 012f9319..127950d6 100644 --- a/src/components/AudioSpeedControl.tsx +++ b/src/components/AudioSpeedControl.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import { EditRecipe } from "@/lib/types"; import { SPEED_STEPS } from "@/lib/constants"; import { Volume2, VolumeX, Gauge, AlertTriangle } from "lucide-react"; @@ -16,10 +16,14 @@ export default function AudioSpeedControl({ recipe, onChange }: Props) { const parentSpeedIndex = SPEED_STEPS.indexOf(recipe.speed as (typeof SPEED_STEPS)[number]); const safeParentIndex = parentSpeedIndex === -1 ? 3 : parentSpeedIndex; const [localSpeedIndex, setLocalSpeedIndex] = useState(safeParentIndex); - - useEffect(() => { + // Mirror the parent's speed into local state without an effect: adjust + // state during render when the incoming prop changes (see + // https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes). + const [prevSafeParentIndex, setPrevSafeParentIndex] = useState(safeParentIndex); + if (safeParentIndex !== prevSafeParentIndex) { + setPrevSafeParentIndex(safeParentIndex); setLocalSpeedIndex(safeParentIndex); - }, [safeParentIndex]); + } // ---------------------------------------- const getSpeedDescription = (speed: number) => { diff --git a/src/components/ComparisonPreview.tsx b/src/components/ComparisonPreview.tsx index b509902f..061cb691 100644 --- a/src/components/ComparisonPreview.tsx +++ b/src/components/ComparisonPreview.tsx @@ -1,4 +1,3 @@ -/* eslint-disable jsx-a11y/no-static-element-interactions */ "use client"; import { useEffect, useRef, useState, useCallback, CSSProperties, RefObject } from "react"; @@ -209,7 +208,6 @@ export default function ComparisonPreview({ file, recipe, videoRef }: Props) { > {/* Left side: Original, unmodified video — clipped to left of slider */}
- {/* eslint-disable-next-line jsx-a11y/media-has-caption */}
diff --git a/src/components/ExportOverlay.tsx b/src/components/ExportOverlay.tsx index a55d4d34..bcc93210 100644 --- a/src/components/ExportOverlay.tsx +++ b/src/components/ExportOverlay.tsx @@ -61,6 +61,9 @@ export default function ExportOverlay({ status, progress, exportStartedAt, onCan useEffect(() => { if (status !== "exporting" || !exportStartedAt) { + // Part of the same timer-subscription lifecycle below, not a + // derivable value — elapsedMs tracks wall-clock time via setInterval. + // eslint-disable-next-line react-hooks/set-state-in-effect setElapsedMs(0); return; } diff --git a/src/components/FileUpload.tsx b/src/components/FileUpload.tsx index f94c17ef..4b18332d 100644 --- a/src/components/FileUpload.tsx +++ b/src/components/FileUpload.tsx @@ -14,114 +14,19 @@ interface Props { duration: number; } -export default function FileUpload({ - onFileSelect, - currentFile, - fileError, - duration, -}: Props) { - const inputRef = useRef(null); - - const [dragging, setDragging] = useState(false); - const [pageDragging, setPageDragging] = useState(false); - const [error, setError] = useState(""); - const [warning, setWarning] = useState(""); - const dragCounterRef = useRef(0); - - // ── Keyboard shortcut Ctrl+O ────────────────────────── - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "o") { - e.preventDefault(); - inputRef.current?.click(); - } - }; - document.addEventListener("keydown", handler); - return () => document.removeEventListener("keydown", handler); - }, []); - - // ── Page-level drag overlay ─────────────────────────── - // Uses a counter so nested dragenter/dragleave don't flicker - useEffect(() => { - const onDragEnter = (e: DragEvent) => { - e.preventDefault(); - dragCounterRef.current += 1; - if (dragCounterRef.current === 1) setPageDragging(true); - }; - - const onDragLeave = (e: DragEvent) => { - e.preventDefault(); - dragCounterRef.current -= 1; - if (dragCounterRef.current === 0) setPageDragging(false); - }; - - const onDragOver = (e: DragEvent) => { - e.preventDefault(); // required to allow drop - }; - - const onDrop = (e: DragEvent) => { - e.preventDefault(); - dragCounterRef.current = 0; - setPageDragging(false); - - const file = e.dataTransfer?.files?.[0]; - if (file) handleFile(file); - }; - - document.addEventListener("dragenter", onDragEnter); - document.addEventListener("dragleave", onDragLeave); - document.addEventListener("dragover", onDragOver); - document.addEventListener("drop", onDrop); - - return () => { - document.removeEventListener("dragenter", onDragEnter); - document.removeEventListener("dragleave", onDragLeave); - document.removeEventListener("dragover", onDragOver); - document.removeEventListener("drop", onDrop); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // ── File validation ─────────────────────────────────── - const handleFile = useCallback((file: File) => { - setError(""); - setWarning(""); - - if (!file.type.startsWith("video/")) { - setError("Please drop a valid video file (MP4, MOV, AVI, WebM, etc.)"); - return; - } - - if (file.size > MAX_FILE_SIZE) { - setError( - `File too large (${formatBytes(file.size)}). Maximum allowed size is 2GB.` - ); - return; - } - - if (file.size > WARNING_FILE_SIZE) { - const estimatedMinutes = Math.max( - 1, - Math.round(file.size / (100 * 1024 * 1024)) - ); - setWarning( - `Large file detected (${formatBytes(file.size)}). Processing may take ~${estimatedMinutes} minutes.` - ); - } - - onFileSelect(file); - }, [onFileSelect]); - - // ── Drop zone (inner) handler ───────────────────────── - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - setDragging(false); - const file = e.dataTransfer.files?.[0]; - if (file) handleFile(file); - }; +// ── File info (shown after upload) ───────────────────── +// Hoisted out of FileUpload: defining components inside a parent's body +// recreates them on every render, forcing React to remount their subtree +// (react-hooks/static-components) instead of reconciling it. +interface FileInfoProps { + currentFile: File | null; + duration: number; + fileError: string; + onChangeClick: () => void; +} - // ── File info (shown after upload) ─────────────────── - const FileInfo = () => ( +function FileInfo({ currentFile, duration, fileError, onChangeClick }: FileInfoProps) { + return (
@@ -155,7 +60,7 @@ export default function FileUpload({
); +} + +// ── Drop zone (shown before upload) ───────────────────── +interface DropZoneProps { + dragging: boolean; + error: string; + fileError: string; + onDragOver: (e: React.DragEvent) => void; + onDragLeave: () => void; + onDrop: (e: React.DragEvent) => void; + onClick: () => void; + onKeyDown: (e: React.KeyboardEvent) => void; +} - // ── Drop zone (inner) ───────────────────────────────── - const DropZone = () => ( +function DropZone({ + dragging, + error, + fileError, + onDragOver, + onDragLeave, + onDrop, + onClick, + onKeyDown, +}: DropZoneProps) { + return (
{ - e.preventDefault(); - setDragging(true); - }} - onDragLeave={() => setDragging(false)} - onDrop={handleDrop} - onClick={() => inputRef.current?.click()} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - inputRef.current?.click(); - } - }} + onDragOver={onDragOver} + onDragLeave={onDragLeave} + onDrop={onDrop} + onClick={onClick} + onKeyDown={onKeyDown} className={cn( "group flex flex-col items-center justify-center gap-4 py-12 px-6", "border-2 border-dashed rounded-xl cursor-pointer transition-all duration-300 relative overflow-hidden", @@ -237,7 +146,7 @@ export default function FileUpload({

Max file size: 2 GB

- + {error && (
@@ -255,6 +164,117 @@ export default function FileUpload({ )}
); +} + +export default function FileUpload({ + onFileSelect, + currentFile, + fileError, + duration, +}: Props) { + const inputRef = useRef(null); + + const [dragging, setDragging] = useState(false); + const [pageDragging, setPageDragging] = useState(false); + const [error, setError] = useState(""); + const [warning, setWarning] = useState(""); + const dragCounterRef = useRef(0); + + // ── Keyboard shortcut Ctrl+O ────────────────────────── + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "o") { + e.preventDefault(); + inputRef.current?.click(); + } + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, []); + + // ── File validation ─────────────────────────────────── + // Declared before the drag-and-drop effect below, which depends on it — + // referencing it earlier (as it was previously) meant the effect's `[]` + // dependency array (with exhaustive-deps silenced) captured whatever + // handleFile was on mount and never picked up a later one if onFileSelect + // ever changed identity. + const handleFile = useCallback((file: File) => { + setError(""); + setWarning(""); + + if (!file.type.startsWith("video/")) { + setError("Please drop a valid video file (MP4, MOV, AVI, WebM, etc.)"); + return; + } + + if (file.size > MAX_FILE_SIZE) { + setError( + `File too large (${formatBytes(file.size)}). Maximum allowed size is 2GB.` + ); + return; + } + + if (file.size > WARNING_FILE_SIZE) { + const estimatedMinutes = Math.max( + 1, + Math.round(file.size / (100 * 1024 * 1024)) + ); + setWarning( + `Large file detected (${formatBytes(file.size)}). Processing may take ~${estimatedMinutes} minutes.` + ); + } + + onFileSelect(file); + }, [onFileSelect]); + + // ── Page-level drag overlay ─────────────────────────── + // Uses a counter so nested dragenter/dragleave don't flicker + useEffect(() => { + const onDragEnter = (e: DragEvent) => { + e.preventDefault(); + dragCounterRef.current += 1; + if (dragCounterRef.current === 1) setPageDragging(true); + }; + + const onDragLeave = (e: DragEvent) => { + e.preventDefault(); + dragCounterRef.current -= 1; + if (dragCounterRef.current === 0) setPageDragging(false); + }; + + const onDragOver = (e: DragEvent) => { + e.preventDefault(); // required to allow drop + }; + + const onDrop = (e: DragEvent) => { + e.preventDefault(); + dragCounterRef.current = 0; + setPageDragging(false); + + const file = e.dataTransfer?.files?.[0]; + if (file) handleFile(file); + }; + + document.addEventListener("dragenter", onDragEnter); + document.addEventListener("dragleave", onDragLeave); + document.addEventListener("dragover", onDragOver); + document.addEventListener("drop", onDrop); + + return () => { + document.removeEventListener("dragenter", onDragEnter); + document.removeEventListener("dragleave", onDragLeave); + document.removeEventListener("dragover", onDragOver); + document.removeEventListener("drop", onDrop); + }; + }, [handleFile]); + + // ── Drop zone (inner) handler ───────────────────────── + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setDragging(false); + const file = e.dataTransfer.files?.[0]; + if (file) handleFile(file); + }; return ( <> @@ -301,7 +321,32 @@ export default function FileUpload({ {warning}

)} - {currentFile ? : } + {currentFile ? ( + inputRef.current?.click()} + /> + ) : ( + { + e.preventDefault(); + setDragging(true); + }} + onDragLeave={() => setDragging(false)} + onDrop={handleDrop} + onClick={() => inputRef.current?.click()} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + inputRef.current?.click(); + } + }} + /> + )} { if (!overlayFile) { + // Part of the same object-URL lifecycle below, not a derivable value. + // eslint-disable-next-line react-hooks/set-state-in-effect setThumbUrl(""); return; } diff --git a/src/components/LottiePlayer.tsx b/src/components/LottiePlayer.tsx index a6ad3045..0f9b820b 100644 --- a/src/components/LottiePlayer.tsx +++ b/src/components/LottiePlayer.tsx @@ -57,7 +57,6 @@ export default function LottiePlayer({ cancelled = true; anim?.destroy(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, [animationData, loop, shouldAutoplay]); return ( diff --git a/src/components/NativeShareButton.tsx b/src/components/NativeShareButton.tsx index 39eeb723..51905a12 100644 --- a/src/components/NativeShareButton.tsx +++ b/src/components/NativeShareButton.tsx @@ -36,7 +36,10 @@ export function NativeShareButton({ const dummyTestFile = new File([new Uint8Array([0])], 'test.mp4', { type: 'video/mp4' }); // 3. Optimize Mount Lifecycle: Verify EXACTLY ONCE on component mount. + // Genuinely needs an effect: navigator.canShare is a browser-only API, + // unavailable during SSR/render, so it can't be computed as derived state. if (navigator.canShare && navigator.canShare({ files: [dummyTestFile] })) { + // eslint-disable-next-line react-hooks/set-state-in-effect setIsSupported(true); } }, []); // Empty dependency array ensures this runs only once diff --git a/src/components/OnboardingTour.tsx b/src/components/OnboardingTour.tsx index 816fd301..861ab7de 100644 --- a/src/components/OnboardingTour.tsx +++ b/src/components/OnboardingTour.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState, useCallback } from "react"; +import { useEffect, useLayoutEffect, useRef, useState, useCallback } from "react"; import { createPortal } from "react-dom"; const TOUR_KEY = "reframe_onboarding_complete"; @@ -54,15 +54,19 @@ interface Rect { height: number; } +const DEFAULT_TOOLTIP_WIDTH = 320; +const DEFAULT_TOOLTIP_HEIGHT = 140; + +// Takes plain dimensions rather than reading a ref itself, so it stays a +// pure function callable during render (initial/fallback size) and from +// useLayoutEffect (actual measured size) alike — reading ref.current +// directly during render isn't safe (react-hooks/refs). function getTooltipStyle( rect: Rect, position: TourStep["position"], - tooltipRef: React.RefObject, + tw: number, + th: number, ): React.CSSProperties { - const tooltip = tooltipRef.current; - const tw = tooltip?.offsetWidth ?? 320; - const th = tooltip?.offsetHeight ?? 140; - const sr = { top: rect.top - PADDING, left: rect.left - PADDING, @@ -166,7 +170,25 @@ function Tooltip({ onSkip, tooltipRef, }: TooltipProps) { - const style = getTooltipStyle(rect, step.position, tooltipRef); + const [style, setStyle] = useState(() => + getTooltipStyle(rect, step.position, DEFAULT_TOOLTIP_WIDTH, DEFAULT_TOOLTIP_HEIGHT), + ); + + // Re-measure the tooltip's actual rendered size after paint (ref reads + // belong in an effect, not render) so positioning uses real dimensions + // instead of the DEFAULT_TOOLTIP_* fallback used for the first render. + useLayoutEffect(() => { + const tooltip = tooltipRef.current; + setStyle( + getTooltipStyle( + rect, + step.position, + tooltip?.offsetWidth ?? DEFAULT_TOOLTIP_WIDTH, + tooltip?.offsetHeight ?? DEFAULT_TOOLTIP_HEIGHT, + ), + ); + }, [rect, step.position, tooltipRef]); + const isLast = stepIndex === totalSteps - 1; return ( @@ -288,6 +310,9 @@ export default function OnboardingTour() { return; } if (!currentStep) { + // dismiss() also persists to localStorage, a real side effect beyond + // just state, so this branch belongs inside the effect. + // eslint-disable-next-line react-hooks/set-state-in-effect dismiss(); return; } diff --git a/src/components/PrivacyBanner.tsx b/src/components/PrivacyBanner.tsx index 051156fb..5b600a63 100644 --- a/src/components/PrivacyBanner.tsx +++ b/src/components/PrivacyBanner.tsx @@ -19,10 +19,13 @@ export default function PrivacyBanner() { const [visible, setVisible] = useState(false); useEffect(() => { + // Genuinely needs an effect: localStorage is unavailable during SSR, so + // this can't be computed as derived state without a hydration mismatch. try { const raw = localStorage.getItem(DISMISS_KEY); const dismissedUntil = raw ? Number(raw) : 0; if (!Number.isFinite(dismissedUntil) || Date.now() >= dismissedUntil) { + // eslint-disable-next-line react-hooks/set-state-in-effect setVisible(true); } } catch { diff --git a/src/components/ThemeProvider.tsx b/src/components/ThemeProvider.tsx index faebf6ae..bf5498d5 100644 --- a/src/components/ThemeProvider.tsx +++ b/src/components/ThemeProvider.tsx @@ -54,8 +54,10 @@ export function ThemeProvider({ children }: { children: ReactNode }) { ); useEffect(() => { - setThemeState(getCurrentTheme()); + // Genuinely needs an effect: getCurrentTheme() reads localStorage and + // matchMedia, browser-only APIs unavailable during SSR/render. const currentTheme = getCurrentTheme(); + // eslint-disable-next-line react-hooks/set-state-in-effect applyTheme(currentTheme, false); const mq = window.matchMedia("(prefers-color-scheme: dark)"); diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx index 26464263..eb6f25ef 100644 --- a/src/components/ThemeToggle.tsx +++ b/src/components/ThemeToggle.tsx @@ -7,6 +7,9 @@ export function ThemeToggle() { const { theme, toggleTheme } = useTheme(); const [mounted, setMounted] = useState(false); useEffect(() => { + // Genuinely needs an effect: "has this mounted on the client" can't be + // known during render without a server/client hydration mismatch. + // eslint-disable-next-line react-hooks/set-state-in-effect setMounted(true); }, []); diff --git a/src/components/ThumbnailStrip.tsx b/src/components/ThumbnailStrip.tsx index aed6e4a3..21b1bfc0 100644 --- a/src/components/ThumbnailStrip.tsx +++ b/src/components/ThumbnailStrip.tsx @@ -157,6 +157,9 @@ export default function ThumbnailStrip({ useEffect(() => { if (videoSrc && duration > 0) { + // Genuinely needs an effect: triggers async canvas/video capture work + // (seeking, drawing frames, Blob creation), not a derivable value. + // eslint-disable-next-line react-hooks/set-state-in-effect generateThumbnails(); } return () => { diff --git a/src/components/TrimControl.tsx b/src/components/TrimControl.tsx index fa970e00..8a3f947c 100644 --- a/src/components/TrimControl.tsx +++ b/src/components/TrimControl.tsx @@ -24,14 +24,18 @@ export default function TrimControl({ recipe, onChange, duration, file }: Props) const [startInput, setStartInput] = useState( recipe.trimStart.toString() ); + // Mirror recipe.trimStart into local editable text without an effect: + // adjust state during render when the incoming prop changes (see + // https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes). + const [prevTrimStart, setPrevTrimStart] = useState(recipe.trimStart); + if (recipe.trimStart !== prevTrimStart) { + setPrevTrimStart(recipe.trimStart); + setStartInput(recipe.trimStart.toString()); + } const { waveform, isLoading: waveformLoading } = useAudioWaveform(file); const hasAudio = waveform.length > 0; - useEffect(() => { - setStartInput(recipe.trimStart.toString()); - }, [recipe.trimStart]); - const clipLength = (recipe.trimEnd ?? duration) - recipe.trimStart; diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index e83216e0..ee2bb0d8 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -314,6 +314,9 @@ export default function VideoEditor() { const [isMac, setIsMac] = useState(false); useEffect(() => { + // Genuinely needs an effect: navigator is a browser-only API, unavailable + // during SSR/render, so it can't be computed as derived state. + // eslint-disable-next-line react-hooks/set-state-in-effect setIsMac(typeof navigator !== "undefined" && /Mac/i.test(navigator.platform)); }, []); diff --git a/src/components/VideoPreview.tsx b/src/components/VideoPreview.tsx index f43d000f..af7d02b0 100644 --- a/src/components/VideoPreview.tsx +++ b/src/components/VideoPreview.tsx @@ -1,4 +1,4 @@ -/* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/no-noninteractive-tabindex, jsx-a11y/no-noninteractive-element-interactions */ +/* eslint-disable jsx-a11y/no-noninteractive-tabindex, jsx-a11y/no-noninteractive-element-interactions */ "use client"; import { useEffect, useRef, useState, useCallback, RefObject } from "react"; @@ -80,6 +80,9 @@ export default function VideoPreview({ if (!file) return; if (urlRef.current) URL.revokeObjectURL(urlRef.current); + // Part of the same object-URL/video-element imperative lifecycle below + // (video.src, video.load()), not a derivable value. + // eslint-disable-next-line react-hooks/set-state-in-effect setIsLoading(true); const id = ++lastId.current; const url = URL.createObjectURL(file); @@ -271,7 +274,6 @@ export default function VideoPreview({ aria-label="Loading video preview" /> )} - {/* eslint-disable-next-line jsx-a11y/media-has-caption */}