Compare commits
13
Commits
8a894e8365
...
5a4fdbbb89
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a4fdbbb89 | ||
|
|
3761f8be6f | ||
|
|
569f29dbe2 | ||
|
|
ed5eaa057c | ||
|
|
a12d078323 | ||
|
|
d9ccd5acf1 | ||
|
|
f126767436 | ||
|
|
f2e2af729a | ||
|
|
33738e9d71 | ||
|
|
b98e4fda73 | ||
|
|
b0e0cb9e61 | ||
|
|
ac99b508f9 | ||
|
|
52d7a56d23 |
@@ -62,6 +62,7 @@
|
|||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^7.1.7",
|
"vite": "^7.1.7",
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
|
"vitest": "^4.1.11",
|
||||||
"workbox-build": "^7.4.1",
|
"workbox-build": "^7.4.1",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -499,6 +500,8 @@
|
|||||||
|
|
||||||
"@solid-primitives/utils": ["@solid-primitives/utils@6.3.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ=="],
|
"@solid-primitives/utils": ["@solid-primitives/utils@6.3.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ=="],
|
||||||
|
|
||||||
|
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
"@tanstack/devtools": ["@tanstack/devtools@0.7.0", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/keyboard": "^1.3.3", "@solid-primitives/resize-observer": "^2.1.3", "@tanstack/devtools-client": "0.0.3", "@tanstack/devtools-event-bus": "0.3.3", "@tanstack/devtools-ui": "0.4.4", "clsx": "^2.1.1", "goober": "^2.1.16", "solid-js": "^1.9.9" } }, "sha512-AlAoCqJhWLg9GBEaoV1g/j+X/WA1aJSWOsekxeuZpYeS2hdVuKAjj04KQLUMJhtLfNl2s2E+TCj7ZRtWyY3U4w=="],
|
"@tanstack/devtools": ["@tanstack/devtools@0.7.0", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/keyboard": "^1.3.3", "@solid-primitives/resize-observer": "^2.1.3", "@tanstack/devtools-client": "0.0.3", "@tanstack/devtools-event-bus": "0.3.3", "@tanstack/devtools-ui": "0.4.4", "clsx": "^2.1.1", "goober": "^2.1.16", "solid-js": "^1.9.9" } }, "sha512-AlAoCqJhWLg9GBEaoV1g/j+X/WA1aJSWOsekxeuZpYeS2hdVuKAjj04KQLUMJhtLfNl2s2E+TCj7ZRtWyY3U4w=="],
|
||||||
|
|
||||||
"@tanstack/devtools-client": ["@tanstack/devtools-client@0.0.3", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.3.3" } }, "sha512-kl0r6N5iIL3t9gGDRAv55VRM3UIyMKVH83esRGq7xBjYsRLe/BeCIN2HqrlJkObUXQMKhy7i8ejuGOn+bDqDBw=="],
|
"@tanstack/devtools-client": ["@tanstack/devtools-client@0.0.3", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.3.3" } }, "sha512-kl0r6N5iIL3t9gGDRAv55VRM3UIyMKVH83esRGq7xBjYsRLe/BeCIN2HqrlJkObUXQMKhy7i8ejuGOn+bDqDBw=="],
|
||||||
@@ -631,6 +634,10 @@
|
|||||||
|
|
||||||
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
||||||
|
|
||||||
|
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||||
|
|
||||||
|
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||||
|
|
||||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||||
|
|
||||||
"@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="],
|
"@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="],
|
||||||
@@ -665,6 +672,20 @@
|
|||||||
|
|
||||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.3", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.2", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg=="],
|
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.3", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.2", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg=="],
|
||||||
|
|
||||||
|
"@vitest/expect": ["@vitest/expect@4.1.11", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw=="],
|
||||||
|
|
||||||
|
"@vitest/mocker": ["@vitest/mocker@4.1.11", "", { "dependencies": { "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ=="],
|
||||||
|
|
||||||
|
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.11", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw=="],
|
||||||
|
|
||||||
|
"@vitest/runner": ["@vitest/runner@4.1.11", "", { "dependencies": { "@vitest/utils": "4.1.11", "pathe": "^2.0.3" } }, "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw=="],
|
||||||
|
|
||||||
|
"@vitest/snapshot": ["@vitest/snapshot@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog=="],
|
||||||
|
|
||||||
|
"@vitest/spy": ["@vitest/spy@4.1.11", "", {}, "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA=="],
|
||||||
|
|
||||||
|
"@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="],
|
||||||
|
|
||||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||||
|
|
||||||
"agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
|
"agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
|
||||||
@@ -687,6 +708,8 @@
|
|||||||
|
|
||||||
"asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="],
|
"asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="],
|
||||||
|
|
||||||
|
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||||
|
|
||||||
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
||||||
|
|
||||||
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
|
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
|
||||||
@@ -749,6 +772,8 @@
|
|||||||
|
|
||||||
"caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="],
|
"caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="],
|
||||||
|
|
||||||
|
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||||
|
|
||||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||||
|
|
||||||
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||||
@@ -855,6 +880,8 @@
|
|||||||
|
|
||||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||||
|
|
||||||
|
"es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
|
||||||
|
|
||||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||||
|
|
||||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||||
@@ -867,12 +894,14 @@
|
|||||||
|
|
||||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||||
|
|
||||||
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||||
|
|
||||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||||
|
|
||||||
"eta": ["eta@4.6.0", "", {}, "sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA=="],
|
"eta": ["eta@4.6.0", "", {}, "sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA=="],
|
||||||
|
|
||||||
|
"expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="],
|
||||||
|
|
||||||
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
|
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
|
||||||
|
|
||||||
"facehash": ["facehash@0.0.7", "", { "peerDependencies": { "@types/react": "", "next": ">=15", "react": ">=18 <20", "react-dom": ">=18 <20" }, "optionalPeers": ["@types/react", "next"] }, "sha512-P4fw6z5DIGMbjtqEaOw7fYvYpQetSOSJOfqy3xuET7cDUI6f9CKlSX0UZIYNrtsPpCoz3LoPP5E8bNbpZBP30A=="],
|
"facehash": ["facehash@0.0.7", "", { "peerDependencies": { "@types/react": "", "next": ">=15", "react": ">=18 <20", "react-dom": ">=18 <20" }, "optionalPeers": ["@types/react", "next"] }, "sha512-P4fw6z5DIGMbjtqEaOw7fYvYpQetSOSJOfqy3xuET7cDUI6f9CKlSX0UZIYNrtsPpCoz3LoPP5E8bNbpZBP30A=="],
|
||||||
@@ -1189,6 +1218,8 @@
|
|||||||
|
|
||||||
"object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="],
|
"object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="],
|
||||||
|
|
||||||
|
"obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="],
|
||||||
|
|
||||||
"onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
|
"onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
|
||||||
|
|
||||||
"ora": ["ora@9.4.1", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.2", "string-width": "^8.1.0" } }, "sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw=="],
|
"ora": ["ora@9.4.1", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.2", "string-width": "^8.1.0" } }, "sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw=="],
|
||||||
@@ -1397,6 +1428,8 @@
|
|||||||
|
|
||||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||||
|
|
||||||
|
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||||
|
|
||||||
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||||
|
|
||||||
"smob": ["smob@1.6.2", "", {}, "sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw=="],
|
"smob": ["smob@1.6.2", "", {}, "sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw=="],
|
||||||
@@ -1413,6 +1446,10 @@
|
|||||||
|
|
||||||
"srvx": ["srvx@0.11.22", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-LqZxxBDMKuMAZzFzJnDCkFOrs9MZQZr0LvHiO/SuSZVdQaXD7xQ5UWTUxheJrQPve1qk9MG2B/yttUvJxw8egQ=="],
|
"srvx": ["srvx@0.11.22", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-LqZxxBDMKuMAZzFzJnDCkFOrs9MZQZr0LvHiO/SuSZVdQaXD7xQ5UWTUxheJrQPve1qk9MG2B/yttUvJxw8egQ=="],
|
||||||
|
|
||||||
|
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||||
|
|
||||||
|
"std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
|
||||||
|
|
||||||
"stdin-discarder": ["stdin-discarder@0.3.2", "", {}, "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A=="],
|
"stdin-discarder": ["stdin-discarder@0.3.2", "", {}, "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A=="],
|
||||||
|
|
||||||
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
|
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
|
||||||
@@ -1457,10 +1494,16 @@
|
|||||||
|
|
||||||
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||||
|
|
||||||
|
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||||
|
|
||||||
|
"tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
|
||||||
|
|
||||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||||
|
|
||||||
"tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="],
|
"tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="],
|
||||||
|
|
||||||
|
"tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="],
|
||||||
|
|
||||||
"tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="],
|
"tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="],
|
||||||
|
|
||||||
"tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="],
|
"tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="],
|
||||||
@@ -1541,6 +1584,8 @@
|
|||||||
|
|
||||||
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
|
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
|
||||||
|
|
||||||
|
"vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="],
|
||||||
|
|
||||||
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
|
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
|
||||||
|
|
||||||
"web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="],
|
"web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="],
|
||||||
@@ -1561,6 +1606,8 @@
|
|||||||
|
|
||||||
"which-typed-array": ["which-typed-array@1.1.22", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw=="],
|
"which-typed-array": ["which-typed-array@1.1.22", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw=="],
|
||||||
|
|
||||||
|
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||||
|
|
||||||
"workbox-background-sync": ["workbox-background-sync@7.4.1", "", { "dependencies": { "idb": "^7.0.1", "workbox-core": "7.4.1" } }, "sha512-HhT7KE8tOWDm02wRNshXUnUPofMlhenF2DBdUnDPOubhizzPeItkYTmAB6td1Z2cjYPa98vzEiPLEuzn5hN66g=="],
|
"workbox-background-sync": ["workbox-background-sync@7.4.1", "", { "dependencies": { "idb": "^7.0.1", "workbox-core": "7.4.1" } }, "sha512-HhT7KE8tOWDm02wRNshXUnUPofMlhenF2DBdUnDPOubhizzPeItkYTmAB6td1Z2cjYPa98vzEiPLEuzn5hN66g=="],
|
||||||
|
|
||||||
"workbox-broadcast-update": ["workbox-broadcast-update@7.4.1", "", { "dependencies": { "workbox-core": "7.4.1" } }, "sha512-uAlgslKLvbQY+suirIdnBCSYrcgBhjp81Nj4l1lj/Jmj0MJO2CJERnCJjT0GFVwmReV0N+zs78K6gqd5gr9/+A=="],
|
"workbox-broadcast-update": ["workbox-broadcast-update@7.4.1", "", { "dependencies": { "workbox-core": "7.4.1" } }, "sha512-uAlgslKLvbQY+suirIdnBCSYrcgBhjp81Nj4l1lj/Jmj0MJO2CJERnCJjT0GFVwmReV0N+zs78K6gqd5gr9/+A=="],
|
||||||
@@ -1817,6 +1864,8 @@
|
|||||||
|
|
||||||
"@jest/types/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
"@jest/types/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||||
|
|
||||||
|
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||||
|
|
||||||
"@rollup/pluginutils/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
|
"@rollup/pluginutils/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
|
||||||
|
|
||||||
"@tanstack/router-generator/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
"@tanstack/router-generator/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||||
@@ -1907,6 +1956,8 @@
|
|||||||
|
|
||||||
"unplugin/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
|
"unplugin/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
|
||||||
|
|
||||||
|
"vitest/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
|
||||||
|
|
||||||
"web-push/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
"web-push/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||||
|
|
||||||
"workbox-build/source-map": ["source-map@0.8.0-beta.0", "", { "dependencies": { "whatwg-url": "^7.0.0" } }, "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA=="],
|
"workbox-build/source-map": ["source-map@0.8.0-beta.0", "", { "dependencies": { "whatwg-url": "^7.0.0" } }, "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA=="],
|
||||||
|
|||||||
+3
-1
@@ -12,7 +12,8 @@
|
|||||||
"extract": "lingui extract --clean",
|
"extract": "lingui extract --clean",
|
||||||
"i18n:check": "lingui extract --clean && lingui compile --strict",
|
"i18n:check": "lingui extract --clean && lingui compile --strict",
|
||||||
"build": "lingui extract --clean && vite build && tsc --noEmit && bun scripts/generate-sw.mjs",
|
"build": "lingui extract --clean && vite build && tsc --noEmit && bun scripts/generate-sw.mjs",
|
||||||
"start": "bun run server.ts"
|
"start": "bun run server.ts",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
@@ -73,6 +74,7 @@
|
|||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^7.1.7",
|
"vite": "^7.1.7",
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
|
"vitest": "^4.1.11",
|
||||||
"workbox-build": "^7.4.1"
|
"workbox-build": "^7.4.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
// Dedupe before adding the unique index: keep the best row per
|
||||||
|
// (player, badge) — earned first, then highest progress, then newest.
|
||||||
|
app.db().newQuery(`
|
||||||
|
DELETE FROM badge_progress
|
||||||
|
WHERE id NOT IN (
|
||||||
|
SELECT id FROM (
|
||||||
|
SELECT id, ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY player, badge
|
||||||
|
ORDER BY earned DESC, progress DESC, updated DESC
|
||||||
|
) AS rn
|
||||||
|
FROM badge_progress
|
||||||
|
)
|
||||||
|
WHERE rn = 1
|
||||||
|
)
|
||||||
|
`).execute()
|
||||||
|
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1063824264")
|
||||||
|
|
||||||
|
unmarshal({
|
||||||
|
"indexes": [
|
||||||
|
"CREATE UNIQUE INDEX `idx_badge_progress_player_badge` ON `badge_progress` (`player`, `badge`)"
|
||||||
|
]
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1063824264")
|
||||||
|
|
||||||
|
unmarshal({
|
||||||
|
"indexes": []
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
@@ -18,7 +18,13 @@ import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core";
|
|||||||
import { HeaderConfig } from "@/features/core/types/header-config";
|
import { HeaderConfig } from "@/features/core/types/header-config";
|
||||||
import { playerQueries } from "@/features/players/queries";
|
import { playerQueries } from "@/features/players/queries";
|
||||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||||
import { DEFAULT_LOCALE, ensureMessages, resolveLocale } from "@/lib/i18n";
|
import {
|
||||||
|
DEFAULT_LOCALE,
|
||||||
|
ensureMessages,
|
||||||
|
getCachedMessages,
|
||||||
|
i18nMessagesQueryKey,
|
||||||
|
resolveLocale,
|
||||||
|
} from "@/lib/i18n";
|
||||||
import { getRootMeta } from "@/lib/i18n/meta";
|
import { getRootMeta } from "@/lib/i18n/meta";
|
||||||
import FullScreenLoader from "@/components/full-screen-loader";
|
import FullScreenLoader from "@/components/full-screen-loader";
|
||||||
import { CHROME_COLORS } from "@/lib/mantine/theme-colors";
|
import { CHROME_COLORS } from "@/lib/mantine/theme-colors";
|
||||||
@@ -132,7 +138,14 @@ export const Route = createRootRouteWithContext<{
|
|||||||
context.queryClient,
|
context.queryClient,
|
||||||
playerQueries.auth()
|
playerQueries.auth()
|
||||||
);
|
);
|
||||||
await ensureMessages(resolveLocale(auth?.metadata?.locale));
|
const locale = resolveLocale(auth?.metadata?.locale);
|
||||||
|
await ensureMessages(locale);
|
||||||
|
if (typeof window === "undefined" && locale !== DEFAULT_LOCALE) {
|
||||||
|
context.queryClient.setQueryData(
|
||||||
|
i18nMessagesQueryKey(locale),
|
||||||
|
getCachedMessages(locale)
|
||||||
|
);
|
||||||
|
}
|
||||||
return { auth };
|
return { auth };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (isRedirect(error) || error instanceof Response) throw error;
|
if (isRedirect(error) || error instanceof Response) throw error;
|
||||||
|
|||||||
@@ -2,9 +2,15 @@ import { useEffect, useState } from 'react'
|
|||||||
import { Box, Paper, Group, Text, ActionIcon } from '@mantine/core'
|
import { Box, Paper, Group, Text, ActionIcon } from '@mantine/core'
|
||||||
import { DownloadIcon, XIcon } from '@phosphor-icons/react'
|
import { DownloadIcon, XIcon } from '@phosphor-icons/react'
|
||||||
import { Trans, useLingui } from '@lingui/react/macro'
|
import { Trans, useLingui } from '@lingui/react/macro'
|
||||||
|
import { useAuth } from '@/contexts/auth-context'
|
||||||
|
import { useIsMobile } from '@/hooks/use-is-mobile'
|
||||||
|
|
||||||
|
const ABOVE_MOBILE_NAV_OFFSET = 'calc(4.5rem + env(safe-area-inset-bottom, 0px))'
|
||||||
|
|
||||||
export function IOSInstallPrompt() {
|
export function IOSInstallPrompt() {
|
||||||
const { t } = useLingui()
|
const { t } = useLingui()
|
||||||
|
const { user } = useAuth()
|
||||||
|
const isMobile = useIsMobile()
|
||||||
const [show, setShow] = useState(false)
|
const [show, setShow] = useState(false)
|
||||||
const [platform, setPlatform] = useState<'ios' | 'android' | null>(null)
|
const [platform, setPlatform] = useState<'ios' | 'android' | null>(null)
|
||||||
|
|
||||||
@@ -36,22 +42,27 @@ export function IOSInstallPrompt() {
|
|||||||
? t`Tap Share → Add to Home Screen`
|
? t`Tap Share → Add to Home Screen`
|
||||||
: t`Tap Menu (⋮) → Add to Home screen`
|
: t`Tap Menu (⋮) → Add to Home screen`
|
||||||
|
|
||||||
|
const aboveBottomNav = Boolean(user) && isMobile
|
||||||
|
const bottomStyle = aboveBottomNav
|
||||||
|
? { bottom: ABOVE_MOBILE_NAV_OFFSET, paddingBottom: '8px' }
|
||||||
|
: { bottom: 0, paddingBottom: 'calc(8px + env(safe-area-inset-bottom, 0px))' }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box style={{ position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 1000, padding: '8px' }}>
|
<Box style={{ position: 'fixed', left: 0, right: 0, zIndex: 1000, padding: '8px', ...bottomStyle }}>
|
||||||
<Paper shadow="lg" p="sm" style={{ background: 'var(--mantine-color-blue-9)', color: 'white' }}>
|
<Paper shadow="lg" p="sm" style={{ background: 'var(--mantine-color-blue-9)', color: 'white' }}>
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Group gap="xs" wrap="nowrap" style={{ flex: 1 }}>
|
<Group gap="xs" wrap="nowrap" style={{ flex: 1 }}>
|
||||||
<DownloadIcon size={20} style={{ flexShrink: 0 }} />
|
<DownloadIcon size={20} style={{ flexShrink: 0 }} />
|
||||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Text size="sm" fw={500} style={{ lineHeight: 1.3 }}>
|
<Text size="sm" fw={500} style={{ lineHeight: 1.3 }}>
|
||||||
<Trans>Please install FLXN • This will save me Twilio credits as you won't be signed out!</Trans>
|
<Trans>Add FLXN to your home screen for a smoother experience</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" opacity={0.9} style={{ lineHeight: 1.2 }}>
|
<Text size="xs" opacity={0.9} style={{ lineHeight: 1.2 }}>
|
||||||
{instructions}
|
{instructions}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
<ActionIcon variant="subtle" color="white" onClick={handleDismiss}>
|
<ActionIcon variant="subtle" color="white" onClick={handleDismiss} aria-label={t`Dismiss`}>
|
||||||
<XIcon size={18} />
|
<XIcon size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { List } from "@mantine/core";
|
import { List, Text } from "@mantine/core";
|
||||||
import { useLingui } from "@lingui/react/macro";
|
import { Plural, Trans, useLingui } from "@lingui/react/macro";
|
||||||
import ListLink from "@/components/list-link";
|
import ListLink from "@/components/list-link";
|
||||||
import {
|
import {
|
||||||
DatabaseIcon,
|
DatabaseIcon,
|
||||||
@@ -10,19 +10,38 @@ import {
|
|||||||
ListIcon,
|
ListIcon,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import ListButton from "@/components/list-button";
|
import ListButton from "@/components/list-button";
|
||||||
import { migrateBadgeProgress } from "@/features/badges/server";
|
import { getBadgeMigrationStatus, migrateBadgeProgress } from "@/features/badges/server";
|
||||||
import { useState } from "react";
|
import { useServerQuery } from "@/lib/tanstack-query/hooks";
|
||||||
|
import toast from "@/lib/sonner";
|
||||||
|
|
||||||
const AdminPage = () => {
|
const AdminPage = () => {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const [isMigrating, setIsMigrating] = useState(false);
|
|
||||||
|
const { data: migrationStatus, refetch: refetchMigrationStatus } = useServerQuery({
|
||||||
|
queryKey: ["admin", "badge-migration-status"],
|
||||||
|
queryFn: () => getBadgeMigrationStatus(),
|
||||||
|
options: {
|
||||||
|
refetchInterval: (query) =>
|
||||||
|
query.state.data?.state === "running" ? 2000 : false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isMigrating = migrationStatus?.state === "running";
|
||||||
|
|
||||||
const handleMigrateBadges = async () => {
|
const handleMigrateBadges = async () => {
|
||||||
if (isMigrating) return;
|
if (isMigrating) return;
|
||||||
|
|
||||||
setIsMigrating(true);
|
const result = await migrateBadgeProgress();
|
||||||
await migrateBadgeProgress();
|
if (result.success) {
|
||||||
setIsMigrating(false);
|
toast.success(
|
||||||
|
result.data.started
|
||||||
|
? t`Badge recalculation started in the background`
|
||||||
|
: t`Badge recalculation is already running`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error(t`Failed to start badge recalculation`);
|
||||||
|
}
|
||||||
|
await refetchMigrationStatus();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -43,6 +62,30 @@ const AdminPage = () => {
|
|||||||
onClick={handleMigrateBadges}
|
onClick={handleMigrateBadges}
|
||||||
loading={isMigrating}
|
loading={isMigrating}
|
||||||
/>
|
/>
|
||||||
|
{migrationStatus?.state === "running" && (
|
||||||
|
<Text size="sm" c="dimmed" px="md" py="xs">
|
||||||
|
<Trans>
|
||||||
|
Recalculating badges in the background ({migrationStatus.playersProcessed}/{migrationStatus.totalPlayers})
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{migrationStatus?.state === "completed" && (
|
||||||
|
<Text size="sm" c="dimmed" px="md" py="xs">
|
||||||
|
<Trans>
|
||||||
|
Last recalculation:{" "}
|
||||||
|
<Plural
|
||||||
|
value={migrationStatus.result.totalBadgesEarned}
|
||||||
|
one="# badge earned"
|
||||||
|
other="# badges earned"
|
||||||
|
/>
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{migrationStatus?.state === "failed" && (
|
||||||
|
<Text size="sm" c="red" px="md" py="xs">
|
||||||
|
<Trans>Badge recalculation failed. Check the server logs.</Trans>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<ListLink
|
<ListLink
|
||||||
label={t`Activities`}
|
label={t`Activities`}
|
||||||
Icon={ListIcon}
|
Icon={ListIcon}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
|
import { emitServerEvent } from "@/lib/events/emitter";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
|
export type BadgeMigrationResult = {
|
||||||
|
playersProcessed: number;
|
||||||
|
totalBadgesEarned: number;
|
||||||
|
created: number;
|
||||||
|
updated: number;
|
||||||
|
deleted: number;
|
||||||
|
averageBadgesPerPlayer: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BadgeMigrationStatus =
|
||||||
|
| { state: "idle" }
|
||||||
|
| { state: "running"; startedAt: string; playersProcessed: number; totalPlayers: number }
|
||||||
|
| { state: "completed"; startedAt: string; finishedAt: string; result: BadgeMigrationResult }
|
||||||
|
| { state: "failed"; startedAt: string; finishedAt: string; error: string };
|
||||||
|
|
||||||
|
// In-process job state — valid only because the app runs a single replica.
|
||||||
|
let status: BadgeMigrationStatus = { state: "idle" };
|
||||||
|
|
||||||
|
export const getBadgeMigrationJobStatus = (): BadgeMigrationStatus => status;
|
||||||
|
|
||||||
|
export const startBadgeMigration = (): { started: boolean; status: BadgeMigrationStatus } => {
|
||||||
|
if (status.state === "running") {
|
||||||
|
return { started: false, status };
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
status = { state: "running", startedAt, playersProcessed: 0, totalPlayers: 0 };
|
||||||
|
|
||||||
|
void pbAdmin
|
||||||
|
.migrateBadgeProgress((playersProcessed, totalPlayers) => {
|
||||||
|
if (status.state === "running") {
|
||||||
|
status = { ...status, playersProcessed, totalPlayers };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
status = { state: "completed", startedAt, finishedAt: new Date().toISOString(), result };
|
||||||
|
logger.info("Badge migration completed", result);
|
||||||
|
emitServerEvent({ type: "badge" });
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
status = {
|
||||||
|
state: "failed",
|
||||||
|
startedAt,
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
logger.error("Badge migration failed", error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { started: true, status };
|
||||||
|
};
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||||
import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens";
|
import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||||
|
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { emitServerEvent } from "@/lib/events/emitter";
|
import { emitServerEvent } from "@/lib/events/emitter";
|
||||||
|
import { getBadgeMigrationJobStatus, startBadgeMigration } from "./migration.server";
|
||||||
|
|
||||||
export const getPlayerBadges = createServerFn()
|
export const getPlayerBadges = createServerFn()
|
||||||
.validator(z.string())
|
.validator(z.string())
|
||||||
@@ -13,13 +15,15 @@ export const getPlayerBadges = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const migrateBadgeProgress = createServerFn()
|
export const migrateBadgeProgress = createServerFn()
|
||||||
|
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
|
.handler(async () =>
|
||||||
|
toServerResult(async () => startBadgeMigration())
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getBadgeMigrationStatus = createServerFn()
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
.handler(async () =>
|
.handler(async () =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => getBadgeMigrationJobStatus())
|
||||||
const result = await pbAdmin.migrateBadgeProgress();
|
|
||||||
emitServerEvent({ type: "badge" });
|
|
||||||
return result;
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getAllBadges = createServerFn()
|
export const getAllBadges = createServerFn()
|
||||||
@@ -37,7 +41,7 @@ export const awardManualBadge = createServerFn()
|
|||||||
playerId: z.string(),
|
playerId: z.string(),
|
||||||
badgeId: z.string(),
|
badgeId: z.string(),
|
||||||
}))
|
}))
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
const result = await pbAdmin.awardManualBadge(data.playerId, data.badgeId);
|
const result = await pbAdmin.awardManualBadge(data.playerId, data.badgeId);
|
||||||
|
|||||||
@@ -1,15 +1,57 @@
|
|||||||
|
import { useLayoutEffect, useRef } from "react";
|
||||||
import { Title, AppShell, Flex } from "@mantine/core";
|
import { Title, AppShell, Flex } from "@mantine/core";
|
||||||
import { useLingui } from "@lingui/react";
|
import { useLingui } from "@lingui/react";
|
||||||
import { HeaderConfig } from "../types/header-config";
|
import { HeaderConfig } from "../types/header-config";
|
||||||
import BackButton from "./back-button";
|
import BackButton from "./back-button";
|
||||||
|
|
||||||
|
const MIN_TITLE_SCALE = 0.55;
|
||||||
|
|
||||||
const Header = ({ collapsed, title, titleValues, withBackButton }: HeaderConfig) => {
|
const Header = ({ collapsed, title, titleValues, withBackButton }: HeaderConfig) => {
|
||||||
const { i18n } = useLingui();
|
const { i18n } = useLingui();
|
||||||
|
const titleRef = useRef<HTMLHeadingElement>(null);
|
||||||
const resolvedTitle =
|
const resolvedTitle =
|
||||||
typeof title === "string" || title === undefined
|
typeof title === "string" || title === undefined
|
||||||
? title
|
? title
|
||||||
: i18n._({ ...title, values: titleValues });
|
: i18n._({ ...title, values: titleValues });
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = titleRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const textWidth = () => {
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(el);
|
||||||
|
return range.getBoundingClientRect().width;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fit = () => {
|
||||||
|
el.style.fontSize = "";
|
||||||
|
const available = el.clientWidth - 1;
|
||||||
|
if (available <= 0) return;
|
||||||
|
let needed = textWidth();
|
||||||
|
if (needed <= available) return;
|
||||||
|
const natural = parseFloat(getComputedStyle(el).fontSize);
|
||||||
|
let scale = Math.max(available / needed, MIN_TITLE_SCALE);
|
||||||
|
el.style.fontSize = `${natural * scale}px`;
|
||||||
|
for (let i = 0; i < 3 && scale > MIN_TITLE_SCALE && textWidth() > available; i++) {
|
||||||
|
scale = Math.max(scale * 0.98, MIN_TITLE_SCALE);
|
||||||
|
el.style.fontSize = `${natural * scale}px`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fit();
|
||||||
|
document.fonts?.ready.then(() => {
|
||||||
|
if (!cancelled) fit();
|
||||||
|
});
|
||||||
|
const observer = new ResizeObserver(fit);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [resolvedTitle]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell.Header
|
<AppShell.Header
|
||||||
id='app-header'
|
id='app-header'
|
||||||
@@ -20,8 +62,20 @@ const Header = ({ collapsed, title, titleValues, withBackButton }: HeaderConfig)
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{ withBackButton && <BackButton /> }
|
{ withBackButton && <BackButton /> }
|
||||||
<Flex justify='center' px='md' mt={8}>
|
<Flex justify='center' px={48} mt={8} w='100%'>
|
||||||
<Title order={1} lts='0.08em' style={{ userSelect: 'none' }}>
|
<Title
|
||||||
|
ref={titleRef}
|
||||||
|
order={1}
|
||||||
|
lts='0.08em'
|
||||||
|
style={{
|
||||||
|
userSelect: 'none',
|
||||||
|
width: '100%',
|
||||||
|
textAlign: 'center',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{resolvedTitle?.toLocaleUpperCase()}
|
{resolvedTitle?.toLocaleUpperCase()}
|
||||||
</Title>
|
</Title>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ const eventListerOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const useVisualViewportSize = () => {
|
const useVisualViewportSize = () => {
|
||||||
const windowExists = typeof window !== 'undefined';
|
|
||||||
const [windowSize, setWindowSize] = useState({
|
const [windowSize, setWindowSize] = useState({
|
||||||
width: windowExists ? window.visualViewport?.width || 0 : 0,
|
width: 0,
|
||||||
height: windowExists ? window.visualViewport?.height || 0 : 0,
|
height: 0,
|
||||||
top: windowExists ? window.visualViewport?.offsetTop || 0 : 0,
|
top: 0,
|
||||||
});
|
});
|
||||||
const rafRef = useRef<number | null>(null);
|
const rafRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Stack, Text, TextInput, Box, Paper, Group, Divider, Center, ActionIcon, Badge } from "@mantine/core";
|
import { Stack, Text, TextInput, Box, Paper, Group, Divider, ActionIcon, Button } from "@mantine/core";
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { MagnifyingGlassIcon, XIcon, ArrowRightIcon } from "@phosphor-icons/react";
|
import { MagnifyingGlassIcon, XIcon, ArrowRightIcon, PlusIcon } from "@phosphor-icons/react";
|
||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import { useAllPlayerStats } from "../queries";
|
import { useAllPlayerStats } from "../queries";
|
||||||
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
import { useSheet } from "@/hooks/use-sheet";
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
import Sheet from "@/components/sheet/sheet";
|
import Sheet from "@/components/sheet/sheet";
|
||||||
import PlayerHeadToHeadSheet from "./player-head-to-head-sheet";
|
import PlayerHeadToHeadSheet from "./player-head-to-head-sheet";
|
||||||
@@ -10,16 +11,24 @@ import PlayerAvatar from "@/components/player-avatar";
|
|||||||
|
|
||||||
const LeagueHeadToHead = () => {
|
const LeagueHeadToHead = () => {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
const { user } = useAuth();
|
||||||
const [player1Id, setPlayer1Id] = useState<string | null>(null);
|
const [player1Id, setPlayer1Id] = useState<string | null>(null);
|
||||||
const [player2Id, setPlayer2Id] = useState<string | null>(null);
|
const [player2Id, setPlayer2Id] = useState<string | null>(null);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const { data: allPlayerStats } = useAllPlayerStats();
|
const { data: allPlayerStats } = useAllPlayerStats();
|
||||||
const h2hSheet = useSheet();
|
const h2hSheet = useSheet();
|
||||||
|
|
||||||
|
const myName = useMemo(() => {
|
||||||
|
if (!user) return "";
|
||||||
|
const fromStats = allPlayerStats?.find((p) => p.player_id === user.id)?.player_name;
|
||||||
|
return fromStats || [user.first_name, user.last_name].filter(Boolean).join(" ");
|
||||||
|
}, [user, allPlayerStats]);
|
||||||
|
|
||||||
const player1Name = useMemo(() => {
|
const player1Name = useMemo(() => {
|
||||||
if (!player1Id || !allPlayerStats) return "";
|
if (!player1Id) return "";
|
||||||
return allPlayerStats.find((p) => p.player_id === player1Id)?.player_name || "";
|
if (user && player1Id === user.id) return myName;
|
||||||
}, [player1Id, allPlayerStats]);
|
return allPlayerStats?.find((p) => p.player_id === player1Id)?.player_name || "";
|
||||||
|
}, [player1Id, allPlayerStats, user, myName]);
|
||||||
|
|
||||||
const player2Name = useMemo(() => {
|
const player2Name = useMemo(() => {
|
||||||
if (!player2Id || !allPlayerStats) return "";
|
if (!player2Id || !allPlayerStats) return "";
|
||||||
@@ -30,21 +39,17 @@ const LeagueHeadToHead = () => {
|
|||||||
if (!allPlayerStats) return [];
|
if (!allPlayerStats) return [];
|
||||||
|
|
||||||
return allPlayerStats
|
return allPlayerStats
|
||||||
.filter((stat) => {
|
.filter((stat) => stat.player_id !== player1Id)
|
||||||
if (player1Id && stat.player_id === player1Id) return false;
|
|
||||||
if (player2Id && stat.player_id === player2Id) return false;
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.filter((stat) =>
|
.filter((stat) =>
|
||||||
stat.player_name.toLowerCase().includes(search.toLowerCase())
|
stat.player_name.toLowerCase().includes(search.toLowerCase())
|
||||||
)
|
)
|
||||||
.sort((a, b) => b.matches - a.matches);
|
.sort((a, b) => b.matches - a.matches);
|
||||||
}, [allPlayerStats, player1Id, player2Id, search]);
|
}, [allPlayerStats, player1Id, search]);
|
||||||
|
|
||||||
const handlePlayerClick = (playerId: string) => {
|
const handlePlayerClick = (playerId: string) => {
|
||||||
if (!player1Id) {
|
if (!player1Id) {
|
||||||
setPlayer1Id(playerId);
|
setPlayer1Id(playerId);
|
||||||
} else if (!player2Id) {
|
} else {
|
||||||
setPlayer2Id(playerId);
|
setPlayer2Id(playerId);
|
||||||
h2hSheet.open();
|
h2hSheet.open();
|
||||||
}
|
}
|
||||||
@@ -52,160 +57,73 @@ const LeagueHeadToHead = () => {
|
|||||||
|
|
||||||
const handleClearPlayer1 = () => {
|
const handleClearPlayer1 = () => {
|
||||||
setPlayer1Id(null);
|
setPlayer1Id(null);
|
||||||
if (player2Id) {
|
|
||||||
setPlayer1Id(player2Id);
|
|
||||||
setPlayer2Id(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClearPlayer2 = () => {
|
|
||||||
setPlayer2Id(null);
|
setPlayer2Id(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeStep = !player1Id ? 1 : !player2Id ? 2 : 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Paper px="md" pt="md" pb="sm" withBorder shadow="sm">
|
<Box px="md" pt="md">
|
||||||
<Stack gap="xs">
|
<Paper
|
||||||
<Group gap="xs" wrap="nowrap">
|
p="sm"
|
||||||
<Paper
|
withBorder
|
||||||
p="sm"
|
shadow="sm"
|
||||||
withBorder
|
style={{
|
||||||
style={{
|
minHeight: 64,
|
||||||
flex: 1,
|
display: "flex",
|
||||||
minHeight: 70,
|
alignItems: "center",
|
||||||
display: "flex",
|
borderWidth: 2,
|
||||||
alignItems: "center",
|
borderColor: !player1Id ? "var(--mantine-primary-color-filled)" : undefined,
|
||||||
justifyContent: "center",
|
transition: "border-color 150ms ease",
|
||||||
position: "relative",
|
}}
|
||||||
borderWidth: 2,
|
>
|
||||||
borderColor: activeStep === 1 ? "var(--mantine-primary-color-filled)" : undefined,
|
{player1Id ? (
|
||||||
backgroundColor: player1Id && activeStep !== 1 ? "var(--mantine-color-default-hover)" : undefined,
|
<Group justify="space-between" wrap="nowrap" style={{ flex: 1 }}>
|
||||||
cursor: player1Id && activeStep === 0 ? "pointer" : undefined,
|
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
transition: "all 150ms ease",
|
<PlayerAvatar name={player1Name} size={36} disableFullscreen />
|
||||||
}}
|
<Box style={{ minWidth: 0 }}>
|
||||||
onClick={player1Id && activeStep === 0 ? handleClearPlayer1 : undefined}
|
<Text size="sm" fw={600} truncate>
|
||||||
>
|
{player1Name}
|
||||||
{player1Id ? (
|
|
||||||
<>
|
|
||||||
<Stack gap={4} align="center" style={{ flex: 1 }}>
|
|
||||||
<PlayerAvatar name={player1Name} size={36} disableFullscreen />
|
|
||||||
<Text size="xs" fw={600} ta="center" lineClamp={1}>
|
|
||||||
{player1Name}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
<ActionIcon
|
|
||||||
variant="light"
|
|
||||||
color="gray"
|
|
||||||
size="xs"
|
|
||||||
radius="xl"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleClearPlayer1();
|
|
||||||
}}
|
|
||||||
style={{ position: "absolute", top: 4, right: 4 }}
|
|
||||||
>
|
|
||||||
<XIcon size={10} />
|
|
||||||
</ActionIcon>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Stack gap={4} align="center">
|
|
||||||
<PlayerAvatar size={36} disableFullscreen />
|
|
||||||
<Text size="xs" c="dimmed" fw={500}>
|
|
||||||
<Trans>Player 1</Trans>
|
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
<Text size="xs" c="dimmed">
|
||||||
)}
|
<Trans>Tap an opponent below to compare</Trans>
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Center>
|
|
||||||
<Text size="xl" fw={700} c="dimmed">
|
|
||||||
<Trans>VS</Trans>
|
|
||||||
</Text>
|
|
||||||
</Center>
|
|
||||||
|
|
||||||
<Paper
|
|
||||||
p="sm"
|
|
||||||
withBorder
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
minHeight: 70,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
position: "relative",
|
|
||||||
borderWidth: 2,
|
|
||||||
borderColor: activeStep === 2 ? "var(--mantine-primary-color-filled)" : undefined,
|
|
||||||
backgroundColor: player2Id && activeStep !== 2 ? "var(--mantine-color-default-hover)" : undefined,
|
|
||||||
cursor: player2Id && activeStep === 0 ? "pointer" : undefined,
|
|
||||||
transition: "all 150ms ease",
|
|
||||||
}}
|
|
||||||
onClick={player2Id && activeStep === 0 ? handleClearPlayer2 : undefined}
|
|
||||||
>
|
|
||||||
{player2Id ? (
|
|
||||||
<>
|
|
||||||
<Stack gap={4} align="center" style={{ flex: 1 }}>
|
|
||||||
<PlayerAvatar name={player2Name} size={36} disableFullscreen />
|
|
||||||
<Text size="xs" fw={600} ta="center" lineClamp={1}>
|
|
||||||
{player2Name}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
<ActionIcon
|
|
||||||
variant="light"
|
|
||||||
color="gray"
|
|
||||||
size="xs"
|
|
||||||
radius="xl"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleClearPlayer2();
|
|
||||||
}}
|
|
||||||
style={{ position: "absolute", top: 4, right: 4 }}
|
|
||||||
>
|
|
||||||
<XIcon size={10} />
|
|
||||||
</ActionIcon>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Stack gap={4} align="center">
|
|
||||||
<PlayerAvatar size={36} disableFullscreen />
|
|
||||||
<Text size="xs" c="dimmed" fw={500}>
|
|
||||||
<Trans>Player 2</Trans>
|
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Box>
|
||||||
)}
|
</Group>
|
||||||
</Paper>
|
<ActionIcon
|
||||||
</Group>
|
variant="light"
|
||||||
|
color="gray"
|
||||||
{activeStep > 0 ? (
|
size="sm"
|
||||||
<Badge
|
radius="xl"
|
||||||
variant="light"
|
onClick={handleClearPlayer1}
|
||||||
size="sm"
|
aria-label={t`Clear selected player`}
|
||||||
radius="sm"
|
|
||||||
fullWidth
|
|
||||||
styles={{ label: { textTransform: "none" } }}
|
|
||||||
>
|
|
||||||
{activeStep === 1 && <Trans>Select first player</Trans>}
|
|
||||||
{activeStep === 2 && <Trans>Select second player</Trans>}
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Group justify="center">
|
|
||||||
<Text
|
|
||||||
size="xs"
|
|
||||||
c="dimmed"
|
|
||||||
style={{ cursor: "pointer" }}
|
|
||||||
onClick={() => {
|
|
||||||
setPlayer1Id(null);
|
|
||||||
setPlayer2Id(null);
|
|
||||||
}}
|
|
||||||
td="underline"
|
|
||||||
>
|
>
|
||||||
<Trans>Clear both players</Trans>
|
<XIcon size={12} />
|
||||||
</Text>
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Group justify="space-between" wrap="nowrap" style={{ flex: 1 }}>
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<PlayerAvatar size={36} disableFullscreen />
|
||||||
|
<Text size="sm" c="dimmed" fw={500}>
|
||||||
|
<Trans>Select a player</Trans>
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
{user && (
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
size="xs"
|
||||||
|
radius="xl"
|
||||||
|
leftSection={<PlusIcon size={12} />}
|
||||||
|
onClick={() => setPlayer1Id(user.id)}
|
||||||
|
>
|
||||||
|
<Trans>Me</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Paper>
|
||||||
</Paper>
|
</Box>
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder={t`Search players`}
|
placeholder={t`Search players`}
|
||||||
@@ -230,6 +148,7 @@ const LeagueHeadToHead = () => {
|
|||||||
p="md"
|
p="md"
|
||||||
gap="sm"
|
gap="sm"
|
||||||
wrap="nowrap"
|
wrap="nowrap"
|
||||||
|
className="flxn-press"
|
||||||
style={{
|
style={{
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
transition: "background-color 150ms ease",
|
transition: "background-color 150ms ease",
|
||||||
@@ -250,7 +169,7 @@ const LeagueHeadToHead = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<ActionIcon variant="subtle" color="gray" size="lg" radius="xl">
|
<ActionIcon variant="subtle" color="gray" size="lg" radius="xl">
|
||||||
<ArrowRightIcon size={18} />
|
{player1Id ? <ArrowRightIcon size={18} /> : <PlusIcon size={18} />}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Group>
|
</Group>
|
||||||
{index < filteredPlayers.length - 1 && <Divider />}
|
{index < filteredPlayers.length - 1 && <Divider />}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export const updatePlayer = createServerFn()
|
|||||||
|
|
||||||
export const createPlayer = createServerFn()
|
export const createPlayer = createServerFn()
|
||||||
.validator(playerInputSchema)
|
.validator(playerInputSchema)
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
.handler(async ({ context, data }) =>
|
.handler(async ({ context, data }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
const userAuthId = context.userAuthId;
|
const userAuthId = context.userAuthId;
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export const generateRandomTeams = createServerFn()
|
|||||||
tournamentId: z.string(),
|
tournamentId: z.string(),
|
||||||
seed: z.number().optional()
|
seed: z.number().optional()
|
||||||
}))
|
}))
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
const freeAgents = await pbAdmin.getFreeAgents(data.tournamentId);
|
const freeAgents = await pbAdmin.getFreeAgents(data.tournamentId);
|
||||||
|
|||||||
+18
-3
@@ -32,6 +32,8 @@ export function resolveLocale(candidate: string | null | undefined): AppLocale {
|
|||||||
: DEFAULT_LOCALE;
|
: DEFAULT_LOCALE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inflightLoads: Partial<Record<AppLocale, Promise<Messages>>> = {};
|
||||||
|
|
||||||
export async function ensureMessages(locale: AppLocale): Promise<Messages> {
|
export async function ensureMessages(locale: AppLocale): Promise<Messages> {
|
||||||
const cached = messageCache[locale];
|
const cached = messageCache[locale];
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
@@ -39,11 +41,24 @@ export async function ensureMessages(locale: AppLocale): Promise<Messages> {
|
|||||||
const loader = catalogLoaders[`/src/locales/${locale}/messages.po`];
|
const loader = catalogLoaders[`/src/locales/${locale}/messages.po`];
|
||||||
if (!loader) return messageCache[DEFAULT_LOCALE]!;
|
if (!loader) return messageCache[DEFAULT_LOCALE]!;
|
||||||
|
|
||||||
const { messages } = await loader();
|
const pending = (inflightLoads[locale] ??= loader()
|
||||||
messageCache[locale] = messages;
|
.then(({ messages }) => {
|
||||||
return messages;
|
messageCache[locale] = messages;
|
||||||
|
return messages;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
delete inflightLoads[locale];
|
||||||
|
}));
|
||||||
|
return pending;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function primeMessages(locale: AppLocale, messages: Messages) {
|
||||||
|
messageCache[locale] ??= messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const i18nMessagesQueryKey = (locale: AppLocale) =>
|
||||||
|
["i18n-messages", locale] as const;
|
||||||
|
|
||||||
export function getCachedMessages(locale: AppLocale): Messages {
|
export function getCachedMessages(locale: AppLocale): Messages {
|
||||||
return messageCache[locale] ?? messageCache[DEFAULT_LOCALE]!;
|
return messageCache[locale] ?? messageCache[DEFAULT_LOCALE]!;
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-10
@@ -1,11 +1,14 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { I18nProvider } from "@lingui/react";
|
import { I18nProvider } from "@lingui/react";
|
||||||
|
import type { Messages } from "@lingui/core";
|
||||||
import { playerQueries } from "@/features/players/queries";
|
import { playerQueries } from "@/features/players/queries";
|
||||||
import {
|
import {
|
||||||
createI18n,
|
createI18n,
|
||||||
ensureMessages,
|
ensureMessages,
|
||||||
hasCachedMessages,
|
hasCachedMessages,
|
||||||
|
i18nMessagesQueryKey,
|
||||||
|
primeMessages,
|
||||||
resolveLocale,
|
resolveLocale,
|
||||||
} from "@/lib/i18n";
|
} from "@/lib/i18n";
|
||||||
|
|
||||||
@@ -29,27 +32,42 @@ export const LinguiProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const locale = resolveLocale(data?.metadata?.locale);
|
const locale = resolveLocale(data?.metadata?.locale);
|
||||||
const [catalogVersion, setCatalogVersion] = useState(0);
|
const [catalogVersion, setCatalogVersion] = useState(0);
|
||||||
|
|
||||||
// Fresh instance per locale (and per SSR request tree). __root beforeLoad
|
|
||||||
// preloads the catalog on authed routes; on public routes (login) it may
|
|
||||||
// not be cached yet — createI18n falls back to en until the effect below
|
|
||||||
// loads it and bumps catalogVersion.
|
|
||||||
const i18n = useMemo(
|
const i18n = useMemo(
|
||||||
() => createI18n(locale),
|
() => {
|
||||||
|
if (!hasCachedMessages(locale)) {
|
||||||
|
const seeded = queryClient.getQueryData<Messages>(
|
||||||
|
i18nMessagesQueryKey(locale)
|
||||||
|
);
|
||||||
|
if (seeded) primeMessages(locale, seeded);
|
||||||
|
}
|
||||||
|
return createI18n(locale);
|
||||||
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[locale, catalogVersion]
|
[locale, catalogVersion, queryClient]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasCachedMessages(locale)) return;
|
if (hasCachedMessages(locale)) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
ensureMessages(locale).then(() => {
|
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
if (!cancelled) setCatalogVersion((v) => v + 1);
|
const load = () => {
|
||||||
});
|
ensureMessages(locale).then(
|
||||||
|
() => {
|
||||||
|
if (!cancelled) setCatalogVersion((v) => v + 1);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (!cancelled) retryTimer = setTimeout(load, 3000);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
load();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
if (retryTimer) clearTimeout(retryTimer);
|
||||||
};
|
};
|
||||||
}, [locale]);
|
}, [locale]);
|
||||||
|
|
||||||
|
|||||||
@@ -19,15 +19,30 @@ class PocketBaseAdminClient {
|
|||||||
this.pb = new PocketBase(process.env.POCKETBASE_URL);
|
this.pb = new PocketBase(process.env.POCKETBASE_URL);
|
||||||
|
|
||||||
this.pb.beforeSend = async (url, options) => {
|
this.pb.beforeSend = async (url, options) => {
|
||||||
await this.authPromise;
|
// Auth requests skip the gate: awaiting authPromise here would deadlock them.
|
||||||
|
if (!url.includes("/collections/_superusers/auth-")) {
|
||||||
if (this.pb.authStore.isValid && this.isTokenExpiringSoon()) {
|
|
||||||
try {
|
try {
|
||||||
await this.refreshAuth();
|
await this.authPromise;
|
||||||
} catch (error) {
|
} catch {}
|
||||||
console.error('Failed to refresh admin token, re-authenticating:', error);
|
|
||||||
await this.authenticate();
|
if (!this.pb.authStore.isValid) {
|
||||||
|
this.authPromise = this.authenticate();
|
||||||
|
await this.authPromise;
|
||||||
|
this.startTokenRefresh();
|
||||||
|
} else if (this.isTokenExpiringSoon()) {
|
||||||
|
try {
|
||||||
|
await this.refreshAuth();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to refresh admin token, re-authenticating:', error);
|
||||||
|
this.authPromise = this.authenticate();
|
||||||
|
await this.authPromise;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
options.headers = {
|
||||||
|
...options.headers,
|
||||||
|
Authorization: this.pb.authStore.token,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
options.cache = "no-store";
|
options.cache = "no-store";
|
||||||
@@ -54,9 +69,16 @@ class PocketBaseAdminClient {
|
|||||||
Object.assign(this, createPushService(this.pb));
|
Object.assign(this, createPushService(this.pb));
|
||||||
|
|
||||||
this.authPromise = this.authenticate();
|
this.authPromise = this.authenticate();
|
||||||
this.authPromise.then(() => {
|
this.authPromise
|
||||||
this.startTokenRefresh();
|
.then(() => {
|
||||||
});
|
this.startTokenRefresh();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
console.error(
|
||||||
|
"PocketBase admin authentication failed - is PocketBase running and are " +
|
||||||
|
"POCKETBASE_URL / POCKETBASE_ADMIN_EMAIL / POCKETBASE_ADMIN_PASSWORD set?"
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async authenticate() {
|
private async authenticate() {
|
||||||
@@ -98,6 +120,8 @@ class PocketBaseAdminClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private startTokenRefresh() {
|
private startTokenRefresh() {
|
||||||
|
if (this.refreshInterval) return;
|
||||||
|
|
||||||
this.refreshInterval = setInterval(async () => {
|
this.refreshInterval = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
await this.refreshAuth();
|
await this.refreshAuth();
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import PocketBase from "pocketbase";
|
import PocketBase from "pocketbase";
|
||||||
import { PlayerInfo } from "@/features/players/types";
|
import { PlayerInfo } from "@/features/players/types";
|
||||||
|
import { pbFilter } from "../util/filter";
|
||||||
|
import { likePattern } from "../util/like-pattern";
|
||||||
|
|
||||||
export interface Activity {
|
export interface Activity {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -61,15 +63,15 @@ export function createActivitiesService(pb: PocketBase) {
|
|||||||
const filters: string[] = [];
|
const filters: string[] = [];
|
||||||
|
|
||||||
if (name) {
|
if (name) {
|
||||||
filters.push(`name ~ "${name}"`);
|
filters.push(pbFilter(pb, "name ~ {:name}", { name: likePattern(name) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (player) {
|
if (player) {
|
||||||
filters.push(`player = "${player}"`);
|
filters.push(pbFilter(pb, "player = {:player}", { player }));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (success !== undefined) {
|
if (success !== undefined) {
|
||||||
filters.push(`success = ${success}`);
|
filters.push(pbFilter(pb, "success = {:success}", { success }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterString = filters.length > 0 ? filters.join(" && ") : "";
|
const filterString = filters.length > 0 ? filters.join(" && ") : "";
|
||||||
@@ -98,7 +100,7 @@ export function createActivitiesService(pb: PocketBase) {
|
|||||||
|
|
||||||
async getActivitiesByUser(userId: string, limit: number = 50): Promise<Activity[]> {
|
async getActivitiesByUser(userId: string, limit: number = 50): Promise<Activity[]> {
|
||||||
const result = await pb.collection("activities").getList<Activity>(1, limit, {
|
const result = await pb.collection("activities").getList<Activity>(1, limit, {
|
||||||
filter: `player = "${userId}"`,
|
filter: pbFilter(pb, "player = {:userId}", { userId }),
|
||||||
sort: "-created",
|
sort: "-created",
|
||||||
});
|
});
|
||||||
return result.items;
|
return result.items;
|
||||||
@@ -106,7 +108,7 @@ export function createActivitiesService(pb: PocketBase) {
|
|||||||
|
|
||||||
async getActivitiesByFunction(functionName: string, limit: number = 50): Promise<Activity[]> {
|
async getActivitiesByFunction(functionName: string, limit: number = 50): Promise<Activity[]> {
|
||||||
const result = await pb.collection("activities").getList<Activity>(1, limit, {
|
const result = await pb.collection("activities").getList<Activity>(1, limit, {
|
||||||
filter: `name = "${functionName}"`,
|
filter: pbFilter(pb, "name = {:functionName}", { functionName }),
|
||||||
sort: "-created",
|
sort: "-created",
|
||||||
});
|
});
|
||||||
return result.items;
|
return result.items;
|
||||||
|
|||||||
@@ -70,16 +70,6 @@ export function createBadgesService(pb: PocketBase) {
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
async clearAllBadgeProgress(): Promise<number> {
|
|
||||||
const existingProgress = await pb.collection("badge_progress").getFullList({
|
|
||||||
filter: 'badge.type != "manual"',
|
|
||||||
});
|
|
||||||
for (const progress of existingProgress) {
|
|
||||||
await pb.collection("badge_progress").delete(progress.id);
|
|
||||||
}
|
|
||||||
return existingProgress.length;
|
|
||||||
},
|
|
||||||
|
|
||||||
async calculateBadgeProgress(playerId: string, badge: Badge): Promise<number> {
|
async calculateBadgeProgress(playerId: string, badge: Badge): Promise<number> {
|
||||||
if (badge.type === "manual") {
|
if (badge.type === "manual") {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -651,27 +641,36 @@ export function createBadgesService(pb: PocketBase) {
|
|||||||
return transformBadgeProgress(created);
|
return transformBadgeProgress(created);
|
||||||
},
|
},
|
||||||
|
|
||||||
async migrateBadgeProgress(): Promise<{
|
async migrateBadgeProgress(
|
||||||
success: boolean;
|
onProgress?: (playersProcessed: number, totalPlayers: number) => void
|
||||||
|
): Promise<{
|
||||||
playersProcessed: number;
|
playersProcessed: number;
|
||||||
progressRecordsCreated: number;
|
|
||||||
totalBadgesEarned: number;
|
totalBadgesEarned: number;
|
||||||
|
created: number;
|
||||||
|
updated: number;
|
||||||
|
deleted: number;
|
||||||
averageBadgesPerPlayer: string;
|
averageBadgesPerPlayer: string;
|
||||||
}> {
|
}> {
|
||||||
await this.clearAllBadgeProgress();
|
|
||||||
|
|
||||||
const badges = await this.listBadges();
|
const badges = await this.listBadges();
|
||||||
|
const autoBadges = badges.filter((badge) => badge.type !== "manual");
|
||||||
|
const players = await pb.collection("players").getFullList();
|
||||||
|
|
||||||
const allPlayers = await pb.collection("players").getFullList();
|
// Phase 1: read-only calculation. Existing badge_progress rows stay
|
||||||
const uniquePlayers = new Set(allPlayers.map((p: any) => p.id));
|
// untouched until the full desired state is known.
|
||||||
|
const desired = new Map<string, {
|
||||||
|
badge: string;
|
||||||
|
player: string;
|
||||||
|
progress: number;
|
||||||
|
earned: boolean;
|
||||||
|
}>();
|
||||||
|
const failedKeys = new Set<string>();
|
||||||
|
let playersProcessed = 0;
|
||||||
|
|
||||||
let totalProgressRecords = 0;
|
for (const player of players) {
|
||||||
let totalBadgesEarned = 0;
|
for (const badge of autoBadges) {
|
||||||
|
const key = `${player.id}|${badge.id}`;
|
||||||
for (const playerId of uniquePlayers) {
|
|
||||||
for (const badge of badges) {
|
|
||||||
try {
|
try {
|
||||||
const progress = await this.calculateBadgeProgress(playerId, badge);
|
const progress = await this.calculateBadgeProgress(player.id, badge);
|
||||||
const target = this.getTargetProgress(badge);
|
const target = this.getTargetProgress(badge);
|
||||||
|
|
||||||
const isPlacementBadge = badge.criteria.placement !== undefined;
|
const isPlacementBadge = badge.criteria.placement !== undefined;
|
||||||
@@ -681,31 +680,77 @@ export function createBadgesService(pb: PocketBase) {
|
|||||||
: progress === target;
|
: progress === target;
|
||||||
|
|
||||||
if (progress > 0 || earned) {
|
if (progress > 0 || earned) {
|
||||||
await this.createBadgeProgress({
|
desired.set(key, {
|
||||||
badge: badge.id,
|
badge: badge.id,
|
||||||
player: playerId,
|
player: player.id,
|
||||||
progress: progress,
|
progress,
|
||||||
earned: earned,
|
earned,
|
||||||
});
|
});
|
||||||
|
|
||||||
totalProgressRecords++;
|
|
||||||
|
|
||||||
if (earned) {
|
|
||||||
totalBadgesEarned++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`Error processing badge "${badge.name}" for player ${playerId}:`, error.message);
|
// Keep whatever row already exists when a calculation fails.
|
||||||
|
failedKeys.add(key);
|
||||||
|
console.error(`Error processing badge "${badge.name}" for player ${player.id}:`, error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
playersProcessed++;
|
||||||
|
onProgress?.(playersProcessed, players.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: apply as a diff — update changed rows, create missing ones,
|
||||||
|
// delete stale/duplicate ones. No window where all badges are gone.
|
||||||
|
const existing = await pb.collection("badge_progress").getFullList({
|
||||||
|
filter: 'badge.type != "manual"',
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingByKey = new Map<string, { id: string; progress: number; earned: boolean }>();
|
||||||
|
const toDelete: string[] = [];
|
||||||
|
for (const row of existing) {
|
||||||
|
const key = `${row.player}|${row.badge}`;
|
||||||
|
if (existingByKey.has(key)) {
|
||||||
|
toDelete.push(row.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
existingByKey.set(key, { id: row.id, progress: row.progress, earned: row.earned });
|
||||||
|
if (!desired.has(key) && !failedKeys.has(key)) {
|
||||||
|
toDelete.push(row.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
let updated = 0;
|
||||||
|
let totalBadgesEarned = 0;
|
||||||
|
|
||||||
|
for (const [key, want] of desired) {
|
||||||
|
if (want.earned) {
|
||||||
|
totalBadgesEarned++;
|
||||||
|
}
|
||||||
|
const row = existingByKey.get(key);
|
||||||
|
if (!row) {
|
||||||
|
await this.createBadgeProgress(want);
|
||||||
|
created++;
|
||||||
|
} else if (row.progress !== want.progress || row.earned !== want.earned) {
|
||||||
|
await this.updateBadgeProgress(row.id, {
|
||||||
|
progress: want.progress,
|
||||||
|
earned: want.earned,
|
||||||
|
});
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of toDelete) {
|
||||||
|
await this.deleteBadgeProgress(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
playersProcessed: players.length,
|
||||||
playersProcessed: uniquePlayers.size,
|
totalBadgesEarned,
|
||||||
progressRecordsCreated: totalProgressRecords,
|
created,
|
||||||
totalBadgesEarned: totalBadgesEarned,
|
updated,
|
||||||
averageBadgesPerPlayer: (totalBadgesEarned / uniquePlayers.size).toFixed(2),
|
deleted: toDelete.length,
|
||||||
|
averageBadgesPerPlayer: players.length
|
||||||
|
? (totalBadgesEarned / players.length).toFixed(2)
|
||||||
|
: "0",
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import PocketBase from "pocketbase";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { pbFilter } from "./filter";
|
||||||
|
|
||||||
|
// Real SDK, not a stub: the escaping under test lives in PocketBase's own
|
||||||
|
// replaceAll.
|
||||||
|
const pb = new PocketBase("http://pocketbase.test");
|
||||||
|
|
||||||
|
const quotedLiteral = (expression: string) => {
|
||||||
|
const open = expression.indexOf("'");
|
||||||
|
let literal = "";
|
||||||
|
for (let i = open + 1; i < expression.length; i++) {
|
||||||
|
if (expression[i] === "\\") {
|
||||||
|
literal += expression[i + 1];
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (expression[i] === "'") return literal;
|
||||||
|
literal += expression[i];
|
||||||
|
}
|
||||||
|
throw new Error(`unterminated literal in ${expression}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("pbFilter", () => {
|
||||||
|
it.each([
|
||||||
|
["a match-substitution pattern", "a$&b"],
|
||||||
|
["a preceding-input pattern", "a$`b"],
|
||||||
|
["a following-input pattern", "a$'b"],
|
||||||
|
["an escaped-dollar pattern", "a$$b"],
|
||||||
|
["a bare dollar", "a$b"],
|
||||||
|
["nothing but a dollar", "$"],
|
||||||
|
["a dollar beside a metacharacter", "50$%"],
|
||||||
|
])("substitutes %s as a literal", (_label, term) => {
|
||||||
|
const expression = pbFilter(pb, "first_name ~ {:query}", { query: term });
|
||||||
|
|
||||||
|
expect(quotedLiteral(expression)).toBe(term);
|
||||||
|
expect(expression).not.toContain("{:query}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps both dollars of an escaped-dollar pattern", () => {
|
||||||
|
expect(pbFilter(pb, "f ~ {:q}", { q: "a$$b" })).toBe("f ~ 'a$$b'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splices no part of the surrounding expression into the literal", () => {
|
||||||
|
const expression = pbFilter(pb, "first_name ~ {:query}", {
|
||||||
|
query: "a$`b",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(expression).toBe("first_name ~ 'a$`b'");
|
||||||
|
expect(quotedLiteral(expression)).not.toContain("first_name");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a single quote", "o'brien"],
|
||||||
|
["a trailing backslash", "ada\\"],
|
||||||
|
["a percent sign", "50%"],
|
||||||
|
])("escapes %s exactly as the SDK does", (_label, term) => {
|
||||||
|
expect(pbFilter(pb, "f ~ {:q}", { q: term })).toBe(
|
||||||
|
pb.filter("f ~ {:q}", { q: term })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a number", 42],
|
||||||
|
["a boolean", true],
|
||||||
|
["null", null],
|
||||||
|
["a date", new Date(Date.UTC(2024, 0, 2, 3, 4, 5, 678))],
|
||||||
|
])("renders %s identically to pb.filter", (_label, value) => {
|
||||||
|
expect(pbFilter(pb, "f = {:v}", { v: value })).toBe(
|
||||||
|
pb.filter("f = {:v}", { v: value })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a placeholder with no matching parameter verbatim", () => {
|
||||||
|
expect(pbFilter(pb, "a = {:missing}", {})).toBe("a = {:missing}");
|
||||||
|
expect(pbFilter(pb, "a = {:missing}", {})).toBe(
|
||||||
|
pb.filter("a = {:missing}", {})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a hyphen", "user-id"],
|
||||||
|
["a dot", "meta.user"],
|
||||||
|
["a digit and underscore", "auth_id2"],
|
||||||
|
])("resolves a key containing %s the way the SDK does", (_label, key) => {
|
||||||
|
const raw = `a = {:${key}}`;
|
||||||
|
|
||||||
|
expect(pbFilter(pb, raw, { [key]: "v" })).toBe("a = 'v'");
|
||||||
|
expect(pbFilter(pb, raw, { [key]: "v" })).toBe(pb.filter(raw, { [key]: "v" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not absorb another parameter's value into a literal", () => {
|
||||||
|
expect(
|
||||||
|
pbFilter(pb, "a = {:x} && b = {:y}", { x: "%{:y}%", y: "SECRET" })
|
||||||
|
).toBe("a = '%{:y}%' && b = 'SECRET'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("substitutes every occurrence of a repeated placeholder", () => {
|
||||||
|
expect(
|
||||||
|
pbFilter(pb, "(first_name ~ {:q} || last_name ~ {:q})", { q: "%ada%" })
|
||||||
|
).toBe("(first_name ~ '%ada%' || last_name ~ '%ada%')");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pins the SDK behaviour the doubling in `quote` compensates for. If an upgrade
|
||||||
|
// fixes the expansion upstream, these fail rather than the doubling silently
|
||||||
|
// becoming a double-escape.
|
||||||
|
describe("the pocketbase SDK substitution this works around", () => {
|
||||||
|
it("still mis-expands a dollar pattern in a parameter value", () => {
|
||||||
|
expect(pb.filter("a ~ {:q}", { q: "x$&y" })).toContain("{:q}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still splices a later parameter into an earlier literal", () => {
|
||||||
|
expect(pb.filter("a = {:x} && b = {:y}", { x: "%{:y}%", y: "SECRET" })).toBe(
|
||||||
|
"a = '%'SECRET'%' && b = 'SECRET'"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type PocketBase from "pocketbase";
|
||||||
|
|
||||||
|
export type FilterParam = string | number | boolean | Date | null;
|
||||||
|
|
||||||
|
const PLACEHOLDER = /\{:([^}]+)\}/g;
|
||||||
|
|
||||||
|
// pb.filter $-expands string replacements; doubling `$` keeps the value literal.
|
||||||
|
const quote = (pb: PocketBase, value: FilterParam) =>
|
||||||
|
pb.filter("{:v}", {
|
||||||
|
v: typeof value === "string" ? value.replaceAll("$", () => "$$") : value,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pbFilter = (
|
||||||
|
pb: PocketBase,
|
||||||
|
raw: string,
|
||||||
|
params: Record<string, FilterParam>
|
||||||
|
) =>
|
||||||
|
raw.replace(PLACEHOLDER, (token, key: string) =>
|
||||||
|
Object.hasOwn(params, key) ? quote(pb, params[key]) : token
|
||||||
|
);
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import PocketBase from "pocketbase";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { literalTerminates } from "@/test/pb-filter";
|
||||||
|
import { pbFilter } from "./filter";
|
||||||
|
import { likePattern } from "./like-pattern";
|
||||||
|
|
||||||
|
const compose = (term: string) =>
|
||||||
|
pbFilter(new PocketBase("http://pocketbase.test"), "first_name ~ {:query}", {
|
||||||
|
query: likePattern(term),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("likePattern", () => {
|
||||||
|
it("wraps a plain term so ~ matches a substring", () => {
|
||||||
|
expect(likePattern("ada")).toBe("%ada%");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a percent sign", "50%", "%50\\%%"],
|
||||||
|
["an underscore", "_", "%\\_%"],
|
||||||
|
["a backslash", "ada\\", "%ada\\\\%"],
|
||||||
|
])("escapes %s so it matches literally", (_label, term, expected) => {
|
||||||
|
expect(likePattern(term)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("always ends the operand with an unescaped wildcard", () => {
|
||||||
|
// An odd run of backslashes before the final % would mean the % is itself
|
||||||
|
// escaped, which is the shape that swallows the closing quote.
|
||||||
|
for (const term of ["ada", "ada\\", "\\", "50%", "_", "o'brien\\"]) {
|
||||||
|
const pattern = likePattern(term);
|
||||||
|
const trailingSlashes = /(\\*)%$/.exec(pattern)?.[1] ?? "";
|
||||||
|
|
||||||
|
expect(pattern.endsWith("%")).toBe(true);
|
||||||
|
expect(trailingSlashes.length % 2).toBe(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("likePattern composed through the real SDK", () => {
|
||||||
|
it.each(["ada\\", "\\", "a_b\\", "o'brien\\", "ada\\\\"])(
|
||||||
|
"keeps the filter expression parseable for %j",
|
||||||
|
(term) => {
|
||||||
|
expect(literalTerminates(compose(term))).toBe(true);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it("detects the unterminated literal a raw term produces", () => {
|
||||||
|
const raw = new PocketBase("http://pocketbase.test").filter(
|
||||||
|
"first_name ~ {:query}",
|
||||||
|
{ query: "ada\\" }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(raw).toBe("first_name ~ 'ada\\'");
|
||||||
|
expect(literalTerminates(raw)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a term without metacharacters exactly as before", () => {
|
||||||
|
expect(compose("ada")).toBe("first_name ~ '%ada%'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["a$&b", "a$`b", "50$%", "a$&_b", "$"])(
|
||||||
|
"carries %j through both escaping layers intact",
|
||||||
|
(term) => {
|
||||||
|
const composed = compose(term);
|
||||||
|
|
||||||
|
expect(composed).toContain(likePattern(term));
|
||||||
|
expect(literalTerminates(composed)).toBe(true);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Escapes LIKE metacharacters and wraps in `%` for a literal substring match.
|
||||||
|
export const likePattern = (term: string) =>
|
||||||
|
`%${term.replace(/[\\%_]/g, (char) => `\\${char}`)}%`;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { redactValue, SENSITIVE_KEY } from "./redact";
|
||||||
|
|
||||||
|
// Shared scrubber for logs + audit rows - a regression leaks PII from both.
|
||||||
|
describe("redactValue", () => {
|
||||||
|
it("redacts sensitive keys and preserves benign ones", () => {
|
||||||
|
expect(
|
||||||
|
redactValue({ phone: "+17135550142", first_name: "Ada", token: "abc" })
|
||||||
|
).toEqual({ phone: "[redacted]", first_name: "Ada", token: "[redacted]" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts recursively through nested objects and arrays", () => {
|
||||||
|
expect(
|
||||||
|
redactValue({
|
||||||
|
user: { first_name: "Ada", password: "hunter2" },
|
||||||
|
items: [{ authToken: "x" }, { label: "ok" }],
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
user: { first_name: "Ada", password: "[redacted]" },
|
||||||
|
items: [{ authToken: "[redacted]" }, { label: "ok" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes primitives through untouched (a bare string is not assumed secret)", () => {
|
||||||
|
expect(redactValue("hello")).toBe("hello");
|
||||||
|
expect(redactValue(42)).toBe(42);
|
||||||
|
expect(redactValue(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("covers the documented sensitive keys", () => {
|
||||||
|
for (const key of ["token", "secret", "password", "phone", "otp", "code", "auth", "key"]) {
|
||||||
|
expect(SENSITIVE_KEY.test(key)).toBe(true);
|
||||||
|
}
|
||||||
|
expect(SENSITIVE_KEY.test("first_name")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export const SENSITIVE_KEY = /token|secret|password|phone|otp|code|auth|key/i;
|
||||||
|
|
||||||
|
const REDACTED = "[redacted]";
|
||||||
|
|
||||||
|
export const redactValue = (value: unknown): unknown => {
|
||||||
|
if (Array.isArray(value)) return value.map(redactValue);
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
if (value instanceof Error) {
|
||||||
|
return { name: value.name, message: value.message, stack: value.stack };
|
||||||
|
}
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
out[key] = SENSITIVE_KEY.test(key) ? REDACTED : redactValue(v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { logger } from "../../logger";
|
import { logger } from "../../logger";
|
||||||
import { ErrorType, ServerError, ServerResult } from "../types";
|
import { ErrorType, ServerError, ServerResult } from "../types";
|
||||||
import { getRequest } from "@tanstack/react-start/server";
|
|
||||||
import { isRedirect } from "@tanstack/react-router";
|
import { isRedirect } from "@tanstack/react-router";
|
||||||
|
|
||||||
export const createServerError = (
|
export const createServerError = (
|
||||||
@@ -20,54 +19,15 @@ export const createServerError = (
|
|||||||
export const toServerResult = async <T>(
|
export const toServerResult = async <T>(
|
||||||
serverFn: () => Promise<T>
|
serverFn: () => Promise<T>
|
||||||
): Promise<ServerResult<T>> => {
|
): Promise<ServerResult<T>> => {
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await serverFn();
|
const data = await serverFn();
|
||||||
return { success: true, data };
|
return { success: true, data };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isRedirect(error) || error instanceof Response) throw error;
|
if (isRedirect(error) || error instanceof Response) throw error;
|
||||||
|
|
||||||
const duration = Date.now() - startTime;
|
|
||||||
logger.error('Server Fn Error', error);
|
logger.error('Server Fn Error', error);
|
||||||
|
|
||||||
const mappedError = mapKnownError(error);
|
return { success: false, error: mapKnownError(error) };
|
||||||
|
|
||||||
let fnName = 'unknown';
|
|
||||||
try {
|
|
||||||
const request = getRequest();
|
|
||||||
const url = new URL(request.url);
|
|
||||||
|
|
||||||
const functionId = url.searchParams.get('_serverFnId') || url.pathname;
|
|
||||||
|
|
||||||
if (functionId.includes('--')) {
|
|
||||||
const match = functionId.match(/--([^_]+)_/);
|
|
||||||
fnName = match?.[1] || functionId.split('--')[1]?.split('_')[0] || 'unknown';
|
|
||||||
} else {
|
|
||||||
fnName = serverFn.name || 'unknown';
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
fnName = serverFn.name || 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
import("../../pocketbase/client")
|
|
||||||
.then(async ({ pbAdmin }) => {
|
|
||||||
await pbAdmin.authPromise;
|
|
||||||
await pbAdmin.createActivity({
|
|
||||||
name: fnName,
|
|
||||||
duration,
|
|
||||||
success: false,
|
|
||||||
error: mappedError.message,
|
|
||||||
arguments: {
|
|
||||||
errorType: mappedError.code,
|
|
||||||
statusCode: mappedError.statusCode,
|
|
||||||
userMessage: mappedError.userMessage,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
|
|
||||||
return { success: false, error: mappedError };
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+62
-33
@@ -384,7 +384,7 @@ msgid "Active device: {0}"
|
|||||||
msgstr "Aktives Gerät: {0}"
|
msgstr "Aktives Gerät: {0}"
|
||||||
|
|
||||||
#: src/app/routes/_authed/admin/activities.tsx:21
|
#: src/app/routes/_authed/admin/activities.tsx:21
|
||||||
#: src/features/admin/components/admin-page.tsx:47
|
#: src/features/admin/components/admin-page.tsx:90
|
||||||
msgid "Activities"
|
msgid "Activities"
|
||||||
msgstr "Aktivitäten"
|
msgstr "Aktivitäten"
|
||||||
|
|
||||||
@@ -392,6 +392,10 @@ msgstr "Aktivitäten"
|
|||||||
msgid "Activity Details"
|
msgid "Activity Details"
|
||||||
msgstr "Aktivitätsdetails"
|
msgstr "Aktivitätsdetails"
|
||||||
|
|
||||||
|
#: src/components/ios-install-prompt.tsx:58
|
||||||
|
msgid "Add FLXN to your home screen for a smoother experience"
|
||||||
|
msgstr "Leg FLXN auf deinen Homescreen für ein flüssigeres Erlebnis"
|
||||||
|
|
||||||
#: src/features/tournaments/components/edit-enrolled-players.tsx:117
|
#: src/features/tournaments/components/edit-enrolled-players.tsx:117
|
||||||
msgid "Add Player"
|
msgid "Add Player"
|
||||||
msgstr "Spieler hinzufügen"
|
msgstr "Spieler hinzufügen"
|
||||||
@@ -520,7 +524,7 @@ msgstr "Ø Sieg-Marge"
|
|||||||
msgid "Award Badge"
|
msgid "Award Badge"
|
||||||
msgstr "Abzeichen verleihen"
|
msgstr "Abzeichen verleihen"
|
||||||
|
|
||||||
#: src/features/admin/components/admin-page.tsx:36
|
#: src/features/admin/components/admin-page.tsx:55
|
||||||
msgid "Award Badges"
|
msgid "Award Badges"
|
||||||
msgstr "Abzeichen verleihen"
|
msgstr "Abzeichen verleihen"
|
||||||
|
|
||||||
@@ -554,6 +558,18 @@ msgstr "Zurück"
|
|||||||
msgid "Badge awarded to {playerName}"
|
msgid "Badge awarded to {playerName}"
|
||||||
msgstr "Abzeichen an {playerName} verliehen"
|
msgstr "Abzeichen an {playerName} verliehen"
|
||||||
|
|
||||||
|
#: src/features/admin/components/admin-page.tsx:86
|
||||||
|
msgid "Badge recalculation failed. Check the server logs."
|
||||||
|
msgstr "Abzeichen-Neuberechnung fehlgeschlagen. Prüfe die Server-Logs."
|
||||||
|
|
||||||
|
#: src/features/admin/components/admin-page.tsx:39
|
||||||
|
msgid "Badge recalculation is already running"
|
||||||
|
msgstr "Abzeichen-Neuberechnung läuft bereits"
|
||||||
|
|
||||||
|
#: src/features/admin/components/admin-page.tsx:38
|
||||||
|
msgid "Badge recalculation started in the background"
|
||||||
|
msgstr "Abzeichen-Neuberechnung im Hintergrund gestartet"
|
||||||
|
|
||||||
#: src/features/admin/components/award-badges.tsx:70
|
#: src/features/admin/components/award-badges.tsx:70
|
||||||
msgid "Badge Type"
|
msgid "Badge Type"
|
||||||
msgstr "Abzeichentyp"
|
msgstr "Abzeichentyp"
|
||||||
@@ -581,7 +597,7 @@ msgid "Bracket"
|
|||||||
msgstr "Turnierbaum"
|
msgstr "Turnierbaum"
|
||||||
|
|
||||||
#: src/app/routes/_authed/admin/preview.tsx:13
|
#: src/app/routes/_authed/admin/preview.tsx:13
|
||||||
#: src/features/admin/components/admin-page.tsx:59
|
#: src/features/admin/components/admin-page.tsx:102
|
||||||
msgid "Bracket Preview"
|
msgid "Bracket Preview"
|
||||||
msgstr "Turnierbaum-Vorschau"
|
msgstr "Turnierbaum-Vorschau"
|
||||||
|
|
||||||
@@ -651,9 +667,9 @@ msgstr "Wähle einen Spieler"
|
|||||||
msgid "Claymation"
|
msgid "Claymation"
|
||||||
msgstr "Knete"
|
msgstr "Knete"
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:203
|
#: src/features/players/components/league-head-to-head.tsx:99
|
||||||
msgid "Clear both players"
|
msgid "Clear selected player"
|
||||||
msgstr "Beide Spieler entfernen"
|
msgstr "Ausgewählten Spieler entfernen"
|
||||||
|
|
||||||
#: src/components/sheet/modal.tsx:35
|
#: src/components/sheet/modal.tsx:35
|
||||||
#: src/features/tournaments/components/started-tournament/sections/nav-grid.tsx:169
|
#: src/features/tournaments/components/started-tournament/sections/nav-grid.tsx:169
|
||||||
@@ -841,6 +857,10 @@ msgstr "deviceId ist für die Übertragungsaktion erforderlich"
|
|||||||
msgid "Disconnect Spotify"
|
msgid "Disconnect Spotify"
|
||||||
msgstr "Spotify trennen"
|
msgstr "Spotify trennen"
|
||||||
|
|
||||||
|
#: src/components/ios-install-prompt.tsx:65
|
||||||
|
msgid "Dismiss"
|
||||||
|
msgstr "Schließen"
|
||||||
|
|
||||||
#: src/features/bracket/components/match-report.tsx:159
|
#: src/features/bracket/components/match-report.tsx:159
|
||||||
msgid "Does this look right?"
|
msgid "Does this look right?"
|
||||||
msgstr "Sieht das richtig aus?"
|
msgstr "Sieht das richtig aus?"
|
||||||
@@ -1060,6 +1080,10 @@ msgstr "Aktives Gerät konnte nicht festgelegt werden"
|
|||||||
msgid "Failed to set volume"
|
msgid "Failed to set volume"
|
||||||
msgstr "Lautstärke konnte nicht festgelegt werden"
|
msgstr "Lautstärke konnte nicht festgelegt werden"
|
||||||
|
|
||||||
|
#: src/features/admin/components/admin-page.tsx:42
|
||||||
|
msgid "Failed to start badge recalculation"
|
||||||
|
msgstr "Abzeichen-Neuberechnung konnte nicht gestartet werden"
|
||||||
|
|
||||||
#: src/features/teams/components/team-form/index.tsx:105
|
#: src/features/teams/components/team-form/index.tsx:105
|
||||||
msgid "Failed to update team"
|
msgid "Failed to update team"
|
||||||
msgstr "Team konnte nicht aktualisiert werden"
|
msgstr "Team konnte nicht aktualisiert werden"
|
||||||
@@ -1200,7 +1224,7 @@ msgstr "Hast du schon mal mitgespielt?"
|
|||||||
#: src/features/bracket/components/match-dock.tsx:215
|
#: src/features/bracket/components/match-dock.tsx:215
|
||||||
#: src/features/matches/components/match-card.tsx:84
|
#: src/features/matches/components/match-card.tsx:84
|
||||||
#: src/features/matches/components/match-card.tsx:228
|
#: src/features/matches/components/match-card.tsx:228
|
||||||
#: src/features/players/components/league-head-to-head.tsx:264
|
#: src/features/players/components/league-head-to-head.tsx:183
|
||||||
#: src/features/players/components/profile/header.tsx:90
|
#: src/features/players/components/profile/header.tsx:90
|
||||||
msgid "Head to Head"
|
msgid "Head to Head"
|
||||||
msgstr "Direktvergleich"
|
msgstr "Direktvergleich"
|
||||||
@@ -1322,6 +1346,11 @@ msgstr "Der Nachname muss 2-20 Zeichen lang sein und darf nur Buchstaben enthalt
|
|||||||
msgid "Last name must be 3-20 characters long and contain only letters"
|
msgid "Last name must be 3-20 characters long and contain only letters"
|
||||||
msgstr "Der Nachname muss 3-20 Zeichen lang sein und darf nur Buchstaben enthalten"
|
msgstr "Der Nachname muss 3-20 Zeichen lang sein und darf nur Buchstaben enthalten"
|
||||||
|
|
||||||
|
#. placeholder {0}: migrationStatus.result.totalBadgesEarned
|
||||||
|
#: src/features/admin/components/admin-page.tsx:74
|
||||||
|
msgid "Last recalculation: {0, plural, one {# badge earned} other {# badges earned}}"
|
||||||
|
msgstr "Letzte Neuberechnung: {0, plural, one {# Abzeichen verdient} other {# Abzeichen verdient}}"
|
||||||
|
|
||||||
#: src/features/settings/components/color-scheme-picker.tsx:43
|
#: src/features/settings/components/color-scheme-picker.tsx:43
|
||||||
msgid "Light"
|
msgid "Light"
|
||||||
msgstr "Hell"
|
msgstr "Hell"
|
||||||
@@ -1410,7 +1439,7 @@ msgid "Manage Team Songs/Logos"
|
|||||||
msgstr "Team-Songs/Logos verwalten"
|
msgstr "Team-Songs/Logos verwalten"
|
||||||
|
|
||||||
#: src/app/routes/_authed/admin/tournaments/index.tsx:17
|
#: src/app/routes/_authed/admin/tournaments/index.tsx:17
|
||||||
#: src/features/admin/components/admin-page.tsx:31
|
#: src/features/admin/components/admin-page.tsx:50
|
||||||
msgid "Manage Tournaments"
|
msgid "Manage Tournaments"
|
||||||
msgstr "Turniere verwalten"
|
msgstr "Turniere verwalten"
|
||||||
|
|
||||||
@@ -1485,7 +1514,11 @@ msgstr "Matchup"
|
|||||||
msgid "Matchup details"
|
msgid "Matchup details"
|
||||||
msgstr "Matchup-Details"
|
msgstr "Matchup-Details"
|
||||||
|
|
||||||
#: src/features/admin/components/admin-page.tsx:41
|
#: src/features/players/components/league-head-to-head.tsx:120
|
||||||
|
msgid "Me"
|
||||||
|
msgstr "Ich"
|
||||||
|
|
||||||
|
#: src/features/admin/components/admin-page.tsx:60
|
||||||
msgid "Migrate Badge Progress"
|
msgid "Migrate Badge Progress"
|
||||||
msgstr "Abzeichen-Fortschritt migrieren"
|
msgstr "Abzeichen-Fortschritt migrieren"
|
||||||
|
|
||||||
@@ -1600,7 +1633,7 @@ msgstr "Keine Matches geplant"
|
|||||||
msgid "No player activity found"
|
msgid "No player activity found"
|
||||||
msgstr "Keine Spieleraktivität gefunden"
|
msgstr "Keine Spieleraktivität gefunden"
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:223
|
#: src/features/players/components/league-head-to-head.tsx:141
|
||||||
msgid "No players available"
|
msgid "No players available"
|
||||||
msgstr "Keine Spieler verfügbar"
|
msgstr "Keine Spieler verfügbar"
|
||||||
|
|
||||||
@@ -1609,7 +1642,7 @@ msgstr "Keine Spieler verfügbar"
|
|||||||
msgid "No players enrolled yet"
|
msgid "No players enrolled yet"
|
||||||
msgstr "Noch keine Spieler angemeldet"
|
msgstr "Noch keine Spieler angemeldet"
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:223
|
#: src/features/players/components/league-head-to-head.tsx:141
|
||||||
#: src/features/players/components/player-stats-table.tsx:476
|
#: src/features/players/components/player-stats-table.tsx:476
|
||||||
msgid "No players found matching \"{search}\""
|
msgid "No players found matching \"{search}\""
|
||||||
msgstr "Keine Spieler gefunden, die zu \"{search}\" passen"
|
msgstr "Keine Spieler gefunden, die zu \"{search}\" passen"
|
||||||
@@ -1725,7 +1758,7 @@ msgstr "Sobald du angemeldet bist, kannst du andere Free Agents und ihre Telefon
|
|||||||
msgid "One time code"
|
msgid "One time code"
|
||||||
msgstr "Einmalcode"
|
msgstr "Einmalcode"
|
||||||
|
|
||||||
#: src/features/admin/components/admin-page.tsx:52
|
#: src/features/admin/components/admin-page.tsx:95
|
||||||
msgid "Open Pocketbase"
|
msgid "Open Pocketbase"
|
||||||
msgstr "Pocketbase öffnen"
|
msgstr "Pocketbase öffnen"
|
||||||
|
|
||||||
@@ -1819,12 +1852,10 @@ msgstr "Wiedergabesteuerung fehlgeschlagen"
|
|||||||
msgid "Player"
|
msgid "Player"
|
||||||
msgstr "Spieler"
|
msgstr "Spieler"
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:117
|
|
||||||
#: src/features/teams/components/team-form/players-picker.tsx:45
|
#: src/features/teams/components/team-form/players-picker.tsx:45
|
||||||
msgid "Player 1"
|
msgid "Player 1"
|
||||||
msgstr "Spieler 1"
|
msgstr "Spieler 1"
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:173
|
|
||||||
#: src/features/teams/components/team-form/players-picker.tsx:53
|
#: src/features/teams/components/team-form/players-picker.tsx:53
|
||||||
msgid "Player 2"
|
msgid "Player 2"
|
||||||
msgstr "Spieler 2"
|
msgstr "Spieler 2"
|
||||||
@@ -1862,10 +1893,6 @@ msgstr "Spieler sind erforderlich"
|
|||||||
msgid "Playing on {0}"
|
msgid "Playing on {0}"
|
||||||
msgstr "Wird auf {0} abgespielt"
|
msgstr "Wird auf {0} abgespielt"
|
||||||
|
|
||||||
#: src/components/ios-install-prompt.tsx:47
|
|
||||||
msgid "Please install FLXN • This will save me Twilio credits as you won't be signed out!"
|
|
||||||
msgstr "Installier FLXN • Das spart mir Twilio-Credits, weil du nicht ausgeloggt wirst!"
|
|
||||||
|
|
||||||
#: src/features/tournaments/components/group-stage-view.tsx:314
|
#: src/features/tournaments/components/group-stage-view.tsx:314
|
||||||
msgid "Populate Knockout Bracket"
|
msgid "Populate Knockout Bracket"
|
||||||
msgstr "K.-o.-Runde befüllen"
|
msgstr "K.-o.-Runde befüllen"
|
||||||
@@ -1925,6 +1952,12 @@ msgstr "Neu würfeln"
|
|||||||
msgid "Reactions"
|
msgid "Reactions"
|
||||||
msgstr "Reaktionen"
|
msgstr "Reaktionen"
|
||||||
|
|
||||||
|
#. placeholder {0}: migrationStatus.playersProcessed
|
||||||
|
#. placeholder {1}: migrationStatus.totalPlayers
|
||||||
|
#: src/features/admin/components/admin-page.tsx:67
|
||||||
|
msgid "Recalculating badges in the background ({0}/{1})"
|
||||||
|
msgstr "Abzeichen werden im Hintergrund neu berechnet ({0}/{1})"
|
||||||
|
|
||||||
#: src/features/tournaments/components/started-tournament/sections/recent-results.tsx:34
|
#: src/features/tournaments/components/started-tournament/sections/recent-results.tsx:34
|
||||||
msgid "Recently finished"
|
msgid "Recently finished"
|
||||||
msgstr "Kürzlich beendet"
|
msgstr "Kürzlich beendet"
|
||||||
@@ -2032,7 +2065,7 @@ msgstr "Nach Songs suchen..."
|
|||||||
msgid "Search for teams to enroll..."
|
msgid "Search for teams to enroll..."
|
||||||
msgstr "Teams zum Anmelden suchen..."
|
msgstr "Teams zum Anmelden suchen..."
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:211
|
#: src/features/players/components/league-head-to-head.tsx:129
|
||||||
#: src/features/players/components/player-stats-table.tsx:329
|
#: src/features/players/components/player-stats-table.tsx:329
|
||||||
msgid "Search players"
|
msgid "Search players"
|
||||||
msgstr "Spieler suchen"
|
msgstr "Spieler suchen"
|
||||||
@@ -2070,6 +2103,10 @@ msgstr "Abzeichen auswählen"
|
|||||||
msgid "Select a device..."
|
msgid "Select a device..."
|
||||||
msgstr "Gerät auswählen..."
|
msgstr "Gerät auswählen..."
|
||||||
|
|
||||||
|
#: src/features/players/components/league-head-to-head.tsx:109
|
||||||
|
msgid "Select a player"
|
||||||
|
msgstr "Wähle einen Spieler"
|
||||||
|
|
||||||
#: src/features/teams/components/team-form/song-picker.tsx:131
|
#: src/features/teams/components/team-form/song-picker.tsx:131
|
||||||
msgid "Select a song to see details"
|
msgid "Select a song to see details"
|
||||||
msgstr "Song auswählen, um Details zu sehen"
|
msgstr "Song auswählen, um Details zu sehen"
|
||||||
@@ -2100,10 +2137,6 @@ msgstr "Enddatum auswählen"
|
|||||||
msgid "Select Enrollment Due Date"
|
msgid "Select Enrollment Due Date"
|
||||||
msgstr "Anmeldeschluss auswählen"
|
msgstr "Anmeldeschluss auswählen"
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:188
|
|
||||||
msgid "Select first player"
|
|
||||||
msgstr "Ersten Spieler auswählen"
|
|
||||||
|
|
||||||
#: src/features/tournaments/components/upcoming-tournament/enroll-team/team-selection-view.tsx:64
|
#: src/features/tournaments/components/upcoming-tournament/enroll-team/team-selection-view.tsx:64
|
||||||
msgid "Select one of your existing teams"
|
msgid "Select one of your existing teams"
|
||||||
msgstr "Wähle eines deiner bestehenden Teams"
|
msgstr "Wähle eines deiner bestehenden Teams"
|
||||||
@@ -2112,10 +2145,6 @@ msgstr "Wähle eines deiner bestehenden Teams"
|
|||||||
msgid "Select Player"
|
msgid "Select Player"
|
||||||
msgstr "Spieler auswählen"
|
msgstr "Spieler auswählen"
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:189
|
|
||||||
msgid "Select second player"
|
|
||||||
msgstr "Zweiten Spieler auswählen"
|
|
||||||
|
|
||||||
#: src/features/teams/components/team-form/song-picker.tsx:68
|
#: src/features/teams/components/team-form/song-picker.tsx:68
|
||||||
msgid "Select Song"
|
msgid "Select Song"
|
||||||
msgstr "Song auswählen"
|
msgstr "Song auswählen"
|
||||||
@@ -2318,11 +2347,15 @@ msgstr "System"
|
|||||||
msgid "T"
|
msgid "T"
|
||||||
msgstr "T"
|
msgstr "T"
|
||||||
|
|
||||||
#: src/components/ios-install-prompt.tsx:37
|
#: src/features/players/components/league-head-to-head.tsx:89
|
||||||
|
msgid "Tap an opponent below to compare"
|
||||||
|
msgstr "Tippe unten auf einen Gegner zum Vergleichen"
|
||||||
|
|
||||||
|
#: src/components/ios-install-prompt.tsx:43
|
||||||
msgid "Tap Menu (⋮) → Add to Home screen"
|
msgid "Tap Menu (⋮) → Add to Home screen"
|
||||||
msgstr "Tippe auf Menü (⋮) → Zum Home-Bildschirm hinzufügen"
|
msgstr "Tippe auf Menü (⋮) → Zum Home-Bildschirm hinzufügen"
|
||||||
|
|
||||||
#: src/components/ios-install-prompt.tsx:36
|
#: src/components/ios-install-prompt.tsx:42
|
||||||
msgid "Tap Share → Add to Home Screen"
|
msgid "Tap Share → Add to Home Screen"
|
||||||
msgstr "Tippe auf Teilen → Zum Home-Bildschirm hinzufügen"
|
msgstr "Tippe auf Teilen → Zum Home-Bildschirm hinzufügen"
|
||||||
|
|
||||||
@@ -2624,10 +2657,6 @@ msgstr "volumePercent muss eine Zahl sein"
|
|||||||
msgid "vs"
|
msgid "vs"
|
||||||
msgstr "vs"
|
msgstr "vs"
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:125
|
|
||||||
msgid "VS"
|
|
||||||
msgstr "VS"
|
|
||||||
|
|
||||||
#: src/features/players/components/player-stats-table.tsx:131
|
#: src/features/players/components/player-stats-table.tsx:131
|
||||||
#: src/features/tournaments/components/tournament-stats.tsx:112
|
#: src/features/tournaments/components/tournament-stats.tsx:112
|
||||||
msgid "W"
|
msgid "W"
|
||||||
|
|||||||
+62
-33
@@ -384,7 +384,7 @@ msgid "Active device: {0}"
|
|||||||
msgstr "Active device: {0}"
|
msgstr "Active device: {0}"
|
||||||
|
|
||||||
#: src/app/routes/_authed/admin/activities.tsx:21
|
#: src/app/routes/_authed/admin/activities.tsx:21
|
||||||
#: src/features/admin/components/admin-page.tsx:47
|
#: src/features/admin/components/admin-page.tsx:90
|
||||||
msgid "Activities"
|
msgid "Activities"
|
||||||
msgstr "Activities"
|
msgstr "Activities"
|
||||||
|
|
||||||
@@ -392,6 +392,10 @@ msgstr "Activities"
|
|||||||
msgid "Activity Details"
|
msgid "Activity Details"
|
||||||
msgstr "Activity Details"
|
msgstr "Activity Details"
|
||||||
|
|
||||||
|
#: src/components/ios-install-prompt.tsx:58
|
||||||
|
msgid "Add FLXN to your home screen for a smoother experience"
|
||||||
|
msgstr "Add FLXN to your home screen for a smoother experience"
|
||||||
|
|
||||||
#: src/features/tournaments/components/edit-enrolled-players.tsx:117
|
#: src/features/tournaments/components/edit-enrolled-players.tsx:117
|
||||||
msgid "Add Player"
|
msgid "Add Player"
|
||||||
msgstr "Add Player"
|
msgstr "Add Player"
|
||||||
@@ -520,7 +524,7 @@ msgstr "Avg Win Margin"
|
|||||||
msgid "Award Badge"
|
msgid "Award Badge"
|
||||||
msgstr "Award Badge"
|
msgstr "Award Badge"
|
||||||
|
|
||||||
#: src/features/admin/components/admin-page.tsx:36
|
#: src/features/admin/components/admin-page.tsx:55
|
||||||
msgid "Award Badges"
|
msgid "Award Badges"
|
||||||
msgstr "Award Badges"
|
msgstr "Award Badges"
|
||||||
|
|
||||||
@@ -554,6 +558,18 @@ msgstr "Back"
|
|||||||
msgid "Badge awarded to {playerName}"
|
msgid "Badge awarded to {playerName}"
|
||||||
msgstr "Badge awarded to {playerName}"
|
msgstr "Badge awarded to {playerName}"
|
||||||
|
|
||||||
|
#: src/features/admin/components/admin-page.tsx:86
|
||||||
|
msgid "Badge recalculation failed. Check the server logs."
|
||||||
|
msgstr "Badge recalculation failed. Check the server logs."
|
||||||
|
|
||||||
|
#: src/features/admin/components/admin-page.tsx:39
|
||||||
|
msgid "Badge recalculation is already running"
|
||||||
|
msgstr "Badge recalculation is already running"
|
||||||
|
|
||||||
|
#: src/features/admin/components/admin-page.tsx:38
|
||||||
|
msgid "Badge recalculation started in the background"
|
||||||
|
msgstr "Badge recalculation started in the background"
|
||||||
|
|
||||||
#: src/features/admin/components/award-badges.tsx:70
|
#: src/features/admin/components/award-badges.tsx:70
|
||||||
msgid "Badge Type"
|
msgid "Badge Type"
|
||||||
msgstr "Badge Type"
|
msgstr "Badge Type"
|
||||||
@@ -581,7 +597,7 @@ msgid "Bracket"
|
|||||||
msgstr "Bracket"
|
msgstr "Bracket"
|
||||||
|
|
||||||
#: src/app/routes/_authed/admin/preview.tsx:13
|
#: src/app/routes/_authed/admin/preview.tsx:13
|
||||||
#: src/features/admin/components/admin-page.tsx:59
|
#: src/features/admin/components/admin-page.tsx:102
|
||||||
msgid "Bracket Preview"
|
msgid "Bracket Preview"
|
||||||
msgstr "Bracket Preview"
|
msgstr "Bracket Preview"
|
||||||
|
|
||||||
@@ -651,9 +667,9 @@ msgstr "Choose a player"
|
|||||||
msgid "Claymation"
|
msgid "Claymation"
|
||||||
msgstr "Claymation"
|
msgstr "Claymation"
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:203
|
#: src/features/players/components/league-head-to-head.tsx:99
|
||||||
msgid "Clear both players"
|
msgid "Clear selected player"
|
||||||
msgstr "Clear both players"
|
msgstr "Clear selected player"
|
||||||
|
|
||||||
#: src/components/sheet/modal.tsx:35
|
#: src/components/sheet/modal.tsx:35
|
||||||
#: src/features/tournaments/components/started-tournament/sections/nav-grid.tsx:169
|
#: src/features/tournaments/components/started-tournament/sections/nav-grid.tsx:169
|
||||||
@@ -841,6 +857,10 @@ msgstr "deviceId is required for transfer action"
|
|||||||
msgid "Disconnect Spotify"
|
msgid "Disconnect Spotify"
|
||||||
msgstr "Disconnect Spotify"
|
msgstr "Disconnect Spotify"
|
||||||
|
|
||||||
|
#: src/components/ios-install-prompt.tsx:65
|
||||||
|
msgid "Dismiss"
|
||||||
|
msgstr "Dismiss"
|
||||||
|
|
||||||
#: src/features/bracket/components/match-report.tsx:159
|
#: src/features/bracket/components/match-report.tsx:159
|
||||||
msgid "Does this look right?"
|
msgid "Does this look right?"
|
||||||
msgstr "Does this look right?"
|
msgstr "Does this look right?"
|
||||||
@@ -1060,6 +1080,10 @@ msgstr "Failed to set active device"
|
|||||||
msgid "Failed to set volume"
|
msgid "Failed to set volume"
|
||||||
msgstr "Failed to set volume"
|
msgstr "Failed to set volume"
|
||||||
|
|
||||||
|
#: src/features/admin/components/admin-page.tsx:42
|
||||||
|
msgid "Failed to start badge recalculation"
|
||||||
|
msgstr "Failed to start badge recalculation"
|
||||||
|
|
||||||
#: src/features/teams/components/team-form/index.tsx:105
|
#: src/features/teams/components/team-form/index.tsx:105
|
||||||
msgid "Failed to update team"
|
msgid "Failed to update team"
|
||||||
msgstr "Failed to update team"
|
msgstr "Failed to update team"
|
||||||
@@ -1200,7 +1224,7 @@ msgstr "Have you played before?"
|
|||||||
#: src/features/bracket/components/match-dock.tsx:215
|
#: src/features/bracket/components/match-dock.tsx:215
|
||||||
#: src/features/matches/components/match-card.tsx:84
|
#: src/features/matches/components/match-card.tsx:84
|
||||||
#: src/features/matches/components/match-card.tsx:228
|
#: src/features/matches/components/match-card.tsx:228
|
||||||
#: src/features/players/components/league-head-to-head.tsx:264
|
#: src/features/players/components/league-head-to-head.tsx:183
|
||||||
#: src/features/players/components/profile/header.tsx:90
|
#: src/features/players/components/profile/header.tsx:90
|
||||||
msgid "Head to Head"
|
msgid "Head to Head"
|
||||||
msgstr "Head to Head"
|
msgstr "Head to Head"
|
||||||
@@ -1322,6 +1346,11 @@ msgstr "Last name must be 2-20 characters long and contain only letters"
|
|||||||
msgid "Last name must be 3-20 characters long and contain only letters"
|
msgid "Last name must be 3-20 characters long and contain only letters"
|
||||||
msgstr "Last name must be 3-20 characters long and contain only letters"
|
msgstr "Last name must be 3-20 characters long and contain only letters"
|
||||||
|
|
||||||
|
#. placeholder {0}: migrationStatus.result.totalBadgesEarned
|
||||||
|
#: src/features/admin/components/admin-page.tsx:74
|
||||||
|
msgid "Last recalculation: {0, plural, one {# badge earned} other {# badges earned}}"
|
||||||
|
msgstr "Last recalculation: {0, plural, one {# badge earned} other {# badges earned}}"
|
||||||
|
|
||||||
#: src/features/settings/components/color-scheme-picker.tsx:43
|
#: src/features/settings/components/color-scheme-picker.tsx:43
|
||||||
msgid "Light"
|
msgid "Light"
|
||||||
msgstr "Light"
|
msgstr "Light"
|
||||||
@@ -1410,7 +1439,7 @@ msgid "Manage Team Songs/Logos"
|
|||||||
msgstr "Manage Team Songs/Logos"
|
msgstr "Manage Team Songs/Logos"
|
||||||
|
|
||||||
#: src/app/routes/_authed/admin/tournaments/index.tsx:17
|
#: src/app/routes/_authed/admin/tournaments/index.tsx:17
|
||||||
#: src/features/admin/components/admin-page.tsx:31
|
#: src/features/admin/components/admin-page.tsx:50
|
||||||
msgid "Manage Tournaments"
|
msgid "Manage Tournaments"
|
||||||
msgstr "Manage Tournaments"
|
msgstr "Manage Tournaments"
|
||||||
|
|
||||||
@@ -1485,7 +1514,11 @@ msgstr "Matchup"
|
|||||||
msgid "Matchup details"
|
msgid "Matchup details"
|
||||||
msgstr "Matchup details"
|
msgstr "Matchup details"
|
||||||
|
|
||||||
#: src/features/admin/components/admin-page.tsx:41
|
#: src/features/players/components/league-head-to-head.tsx:120
|
||||||
|
msgid "Me"
|
||||||
|
msgstr "Me"
|
||||||
|
|
||||||
|
#: src/features/admin/components/admin-page.tsx:60
|
||||||
msgid "Migrate Badge Progress"
|
msgid "Migrate Badge Progress"
|
||||||
msgstr "Migrate Badge Progress"
|
msgstr "Migrate Badge Progress"
|
||||||
|
|
||||||
@@ -1600,7 +1633,7 @@ msgstr "No matches scheduled"
|
|||||||
msgid "No player activity found"
|
msgid "No player activity found"
|
||||||
msgstr "No player activity found"
|
msgstr "No player activity found"
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:223
|
#: src/features/players/components/league-head-to-head.tsx:141
|
||||||
msgid "No players available"
|
msgid "No players available"
|
||||||
msgstr "No players available"
|
msgstr "No players available"
|
||||||
|
|
||||||
@@ -1609,7 +1642,7 @@ msgstr "No players available"
|
|||||||
msgid "No players enrolled yet"
|
msgid "No players enrolled yet"
|
||||||
msgstr "No players enrolled yet"
|
msgstr "No players enrolled yet"
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:223
|
#: src/features/players/components/league-head-to-head.tsx:141
|
||||||
#: src/features/players/components/player-stats-table.tsx:476
|
#: src/features/players/components/player-stats-table.tsx:476
|
||||||
msgid "No players found matching \"{search}\""
|
msgid "No players found matching \"{search}\""
|
||||||
msgstr "No players found matching \"{search}\""
|
msgstr "No players found matching \"{search}\""
|
||||||
@@ -1725,7 +1758,7 @@ msgstr "Once enrolled, you can view other free agents and their phone number in
|
|||||||
msgid "One time code"
|
msgid "One time code"
|
||||||
msgstr "One time code"
|
msgstr "One time code"
|
||||||
|
|
||||||
#: src/features/admin/components/admin-page.tsx:52
|
#: src/features/admin/components/admin-page.tsx:95
|
||||||
msgid "Open Pocketbase"
|
msgid "Open Pocketbase"
|
||||||
msgstr "Open Pocketbase"
|
msgstr "Open Pocketbase"
|
||||||
|
|
||||||
@@ -1819,12 +1852,10 @@ msgstr "Playback control failed"
|
|||||||
msgid "Player"
|
msgid "Player"
|
||||||
msgstr "Player"
|
msgstr "Player"
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:117
|
|
||||||
#: src/features/teams/components/team-form/players-picker.tsx:45
|
#: src/features/teams/components/team-form/players-picker.tsx:45
|
||||||
msgid "Player 1"
|
msgid "Player 1"
|
||||||
msgstr "Player 1"
|
msgstr "Player 1"
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:173
|
|
||||||
#: src/features/teams/components/team-form/players-picker.tsx:53
|
#: src/features/teams/components/team-form/players-picker.tsx:53
|
||||||
msgid "Player 2"
|
msgid "Player 2"
|
||||||
msgstr "Player 2"
|
msgstr "Player 2"
|
||||||
@@ -1862,10 +1893,6 @@ msgstr "Players are required"
|
|||||||
msgid "Playing on {0}"
|
msgid "Playing on {0}"
|
||||||
msgstr "Playing on {0}"
|
msgstr "Playing on {0}"
|
||||||
|
|
||||||
#: src/components/ios-install-prompt.tsx:47
|
|
||||||
msgid "Please install FLXN • This will save me Twilio credits as you won't be signed out!"
|
|
||||||
msgstr "Please install FLXN • This will save me Twilio credits as you won't be signed out!"
|
|
||||||
|
|
||||||
#: src/features/tournaments/components/group-stage-view.tsx:314
|
#: src/features/tournaments/components/group-stage-view.tsx:314
|
||||||
msgid "Populate Knockout Bracket"
|
msgid "Populate Knockout Bracket"
|
||||||
msgstr "Populate Knockout Bracket"
|
msgstr "Populate Knockout Bracket"
|
||||||
@@ -1925,6 +1952,12 @@ msgstr "Re-roll"
|
|||||||
msgid "Reactions"
|
msgid "Reactions"
|
||||||
msgstr "Reactions"
|
msgstr "Reactions"
|
||||||
|
|
||||||
|
#. placeholder {0}: migrationStatus.playersProcessed
|
||||||
|
#. placeholder {1}: migrationStatus.totalPlayers
|
||||||
|
#: src/features/admin/components/admin-page.tsx:67
|
||||||
|
msgid "Recalculating badges in the background ({0}/{1})"
|
||||||
|
msgstr "Recalculating badges in the background ({0}/{1})"
|
||||||
|
|
||||||
#: src/features/tournaments/components/started-tournament/sections/recent-results.tsx:34
|
#: src/features/tournaments/components/started-tournament/sections/recent-results.tsx:34
|
||||||
msgid "Recently finished"
|
msgid "Recently finished"
|
||||||
msgstr "Recently finished"
|
msgstr "Recently finished"
|
||||||
@@ -2032,7 +2065,7 @@ msgstr "Search for songs..."
|
|||||||
msgid "Search for teams to enroll..."
|
msgid "Search for teams to enroll..."
|
||||||
msgstr "Search for teams to enroll..."
|
msgstr "Search for teams to enroll..."
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:211
|
#: src/features/players/components/league-head-to-head.tsx:129
|
||||||
#: src/features/players/components/player-stats-table.tsx:329
|
#: src/features/players/components/player-stats-table.tsx:329
|
||||||
msgid "Search players"
|
msgid "Search players"
|
||||||
msgstr "Search players"
|
msgstr "Search players"
|
||||||
@@ -2070,6 +2103,10 @@ msgstr "Select a badge"
|
|||||||
msgid "Select a device..."
|
msgid "Select a device..."
|
||||||
msgstr "Select a device..."
|
msgstr "Select a device..."
|
||||||
|
|
||||||
|
#: src/features/players/components/league-head-to-head.tsx:109
|
||||||
|
msgid "Select a player"
|
||||||
|
msgstr "Select a player"
|
||||||
|
|
||||||
#: src/features/teams/components/team-form/song-picker.tsx:131
|
#: src/features/teams/components/team-form/song-picker.tsx:131
|
||||||
msgid "Select a song to see details"
|
msgid "Select a song to see details"
|
||||||
msgstr "Select a song to see details"
|
msgstr "Select a song to see details"
|
||||||
@@ -2100,10 +2137,6 @@ msgstr "Select End Date"
|
|||||||
msgid "Select Enrollment Due Date"
|
msgid "Select Enrollment Due Date"
|
||||||
msgstr "Select Enrollment Due Date"
|
msgstr "Select Enrollment Due Date"
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:188
|
|
||||||
msgid "Select first player"
|
|
||||||
msgstr "Select first player"
|
|
||||||
|
|
||||||
#: src/features/tournaments/components/upcoming-tournament/enroll-team/team-selection-view.tsx:64
|
#: src/features/tournaments/components/upcoming-tournament/enroll-team/team-selection-view.tsx:64
|
||||||
msgid "Select one of your existing teams"
|
msgid "Select one of your existing teams"
|
||||||
msgstr "Select one of your existing teams"
|
msgstr "Select one of your existing teams"
|
||||||
@@ -2112,10 +2145,6 @@ msgstr "Select one of your existing teams"
|
|||||||
msgid "Select Player"
|
msgid "Select Player"
|
||||||
msgstr "Select Player"
|
msgstr "Select Player"
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:189
|
|
||||||
msgid "Select second player"
|
|
||||||
msgstr "Select second player"
|
|
||||||
|
|
||||||
#: src/features/teams/components/team-form/song-picker.tsx:68
|
#: src/features/teams/components/team-form/song-picker.tsx:68
|
||||||
msgid "Select Song"
|
msgid "Select Song"
|
||||||
msgstr "Select Song"
|
msgstr "Select Song"
|
||||||
@@ -2318,11 +2347,15 @@ msgstr "System"
|
|||||||
msgid "T"
|
msgid "T"
|
||||||
msgstr "T"
|
msgstr "T"
|
||||||
|
|
||||||
#: src/components/ios-install-prompt.tsx:37
|
#: src/features/players/components/league-head-to-head.tsx:89
|
||||||
|
msgid "Tap an opponent below to compare"
|
||||||
|
msgstr "Tap an opponent below to compare"
|
||||||
|
|
||||||
|
#: src/components/ios-install-prompt.tsx:43
|
||||||
msgid "Tap Menu (⋮) → Add to Home screen"
|
msgid "Tap Menu (⋮) → Add to Home screen"
|
||||||
msgstr "Tap Menu (⋮) → Add to Home screen"
|
msgstr "Tap Menu (⋮) → Add to Home screen"
|
||||||
|
|
||||||
#: src/components/ios-install-prompt.tsx:36
|
#: src/components/ios-install-prompt.tsx:42
|
||||||
msgid "Tap Share → Add to Home Screen"
|
msgid "Tap Share → Add to Home Screen"
|
||||||
msgstr "Tap Share → Add to Home Screen"
|
msgstr "Tap Share → Add to Home Screen"
|
||||||
|
|
||||||
@@ -2624,10 +2657,6 @@ msgstr "volumePercent must be a number"
|
|||||||
msgid "vs"
|
msgid "vs"
|
||||||
msgstr "vs"
|
msgstr "vs"
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:125
|
|
||||||
msgid "VS"
|
|
||||||
msgstr "VS"
|
|
||||||
|
|
||||||
#: src/features/players/components/player-stats-table.tsx:131
|
#: src/features/players/components/player-stats-table.tsx:131
|
||||||
#: src/features/tournaments/components/tournament-stats.tsx:112
|
#: src/features/tournaments/components/tournament-stats.tsx:112
|
||||||
msgid "W"
|
msgid "W"
|
||||||
|
|||||||
+62
-33
@@ -384,7 +384,7 @@ msgid "Active device: {0}"
|
|||||||
msgstr "Dispositivo activo: {0}"
|
msgstr "Dispositivo activo: {0}"
|
||||||
|
|
||||||
#: src/app/routes/_authed/admin/activities.tsx:21
|
#: src/app/routes/_authed/admin/activities.tsx:21
|
||||||
#: src/features/admin/components/admin-page.tsx:47
|
#: src/features/admin/components/admin-page.tsx:90
|
||||||
msgid "Activities"
|
msgid "Activities"
|
||||||
msgstr "Actividades"
|
msgstr "Actividades"
|
||||||
|
|
||||||
@@ -392,6 +392,10 @@ msgstr "Actividades"
|
|||||||
msgid "Activity Details"
|
msgid "Activity Details"
|
||||||
msgstr "Detalles de la actividad"
|
msgstr "Detalles de la actividad"
|
||||||
|
|
||||||
|
#: src/components/ios-install-prompt.tsx:58
|
||||||
|
msgid "Add FLXN to your home screen for a smoother experience"
|
||||||
|
msgstr "Agrega FLXN a tu pantalla de inicio para una experiencia más fluida"
|
||||||
|
|
||||||
#: src/features/tournaments/components/edit-enrolled-players.tsx:117
|
#: src/features/tournaments/components/edit-enrolled-players.tsx:117
|
||||||
msgid "Add Player"
|
msgid "Add Player"
|
||||||
msgstr "Agregar Jugador"
|
msgstr "Agregar Jugador"
|
||||||
@@ -520,7 +524,7 @@ msgstr "Prom. Margen de Victoria"
|
|||||||
msgid "Award Badge"
|
msgid "Award Badge"
|
||||||
msgstr "Otorgar insignia"
|
msgstr "Otorgar insignia"
|
||||||
|
|
||||||
#: src/features/admin/components/admin-page.tsx:36
|
#: src/features/admin/components/admin-page.tsx:55
|
||||||
msgid "Award Badges"
|
msgid "Award Badges"
|
||||||
msgstr "Otorgar insignias"
|
msgstr "Otorgar insignias"
|
||||||
|
|
||||||
@@ -554,6 +558,18 @@ msgstr "Atrás"
|
|||||||
msgid "Badge awarded to {playerName}"
|
msgid "Badge awarded to {playerName}"
|
||||||
msgstr "Insignia otorgada a {playerName}"
|
msgstr "Insignia otorgada a {playerName}"
|
||||||
|
|
||||||
|
#: src/features/admin/components/admin-page.tsx:86
|
||||||
|
msgid "Badge recalculation failed. Check the server logs."
|
||||||
|
msgstr "Falló el recálculo de insignias. Revisa los registros del servidor."
|
||||||
|
|
||||||
|
#: src/features/admin/components/admin-page.tsx:39
|
||||||
|
msgid "Badge recalculation is already running"
|
||||||
|
msgstr "El recálculo de insignias ya está en curso"
|
||||||
|
|
||||||
|
#: src/features/admin/components/admin-page.tsx:38
|
||||||
|
msgid "Badge recalculation started in the background"
|
||||||
|
msgstr "Recálculo de insignias iniciado en segundo plano"
|
||||||
|
|
||||||
#: src/features/admin/components/award-badges.tsx:70
|
#: src/features/admin/components/award-badges.tsx:70
|
||||||
msgid "Badge Type"
|
msgid "Badge Type"
|
||||||
msgstr "Tipo de insignia"
|
msgstr "Tipo de insignia"
|
||||||
@@ -581,7 +597,7 @@ msgid "Bracket"
|
|||||||
msgstr "Bracket"
|
msgstr "Bracket"
|
||||||
|
|
||||||
#: src/app/routes/_authed/admin/preview.tsx:13
|
#: src/app/routes/_authed/admin/preview.tsx:13
|
||||||
#: src/features/admin/components/admin-page.tsx:59
|
#: src/features/admin/components/admin-page.tsx:102
|
||||||
msgid "Bracket Preview"
|
msgid "Bracket Preview"
|
||||||
msgstr "Vista previa del bracket"
|
msgstr "Vista previa del bracket"
|
||||||
|
|
||||||
@@ -651,9 +667,9 @@ msgstr "Elige un jugador"
|
|||||||
msgid "Claymation"
|
msgid "Claymation"
|
||||||
msgstr "Plastilina"
|
msgstr "Plastilina"
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:203
|
#: src/features/players/components/league-head-to-head.tsx:99
|
||||||
msgid "Clear both players"
|
msgid "Clear selected player"
|
||||||
msgstr "Quitar ambos jugadores"
|
msgstr "Quitar jugador seleccionado"
|
||||||
|
|
||||||
#: src/components/sheet/modal.tsx:35
|
#: src/components/sheet/modal.tsx:35
|
||||||
#: src/features/tournaments/components/started-tournament/sections/nav-grid.tsx:169
|
#: src/features/tournaments/components/started-tournament/sections/nav-grid.tsx:169
|
||||||
@@ -841,6 +857,10 @@ msgstr "Se requiere deviceId para la acción de transferencia"
|
|||||||
msgid "Disconnect Spotify"
|
msgid "Disconnect Spotify"
|
||||||
msgstr "Desconectar Spotify"
|
msgstr "Desconectar Spotify"
|
||||||
|
|
||||||
|
#: src/components/ios-install-prompt.tsx:65
|
||||||
|
msgid "Dismiss"
|
||||||
|
msgstr "Cerrar"
|
||||||
|
|
||||||
#: src/features/bracket/components/match-report.tsx:159
|
#: src/features/bracket/components/match-report.tsx:159
|
||||||
msgid "Does this look right?"
|
msgid "Does this look right?"
|
||||||
msgstr "¿Se ve bien así?"
|
msgstr "¿Se ve bien así?"
|
||||||
@@ -1060,6 +1080,10 @@ msgstr "No se pudo establecer el dispositivo activo"
|
|||||||
msgid "Failed to set volume"
|
msgid "Failed to set volume"
|
||||||
msgstr "No se pudo ajustar el volumen"
|
msgstr "No se pudo ajustar el volumen"
|
||||||
|
|
||||||
|
#: src/features/admin/components/admin-page.tsx:42
|
||||||
|
msgid "Failed to start badge recalculation"
|
||||||
|
msgstr "No se pudo iniciar el recálculo de insignias"
|
||||||
|
|
||||||
#: src/features/teams/components/team-form/index.tsx:105
|
#: src/features/teams/components/team-form/index.tsx:105
|
||||||
msgid "Failed to update team"
|
msgid "Failed to update team"
|
||||||
msgstr "No se pudo actualizar el equipo"
|
msgstr "No se pudo actualizar el equipo"
|
||||||
@@ -1200,7 +1224,7 @@ msgstr "¿Ya has jugado antes?"
|
|||||||
#: src/features/bracket/components/match-dock.tsx:215
|
#: src/features/bracket/components/match-dock.tsx:215
|
||||||
#: src/features/matches/components/match-card.tsx:84
|
#: src/features/matches/components/match-card.tsx:84
|
||||||
#: src/features/matches/components/match-card.tsx:228
|
#: src/features/matches/components/match-card.tsx:228
|
||||||
#: src/features/players/components/league-head-to-head.tsx:264
|
#: src/features/players/components/league-head-to-head.tsx:183
|
||||||
#: src/features/players/components/profile/header.tsx:90
|
#: src/features/players/components/profile/header.tsx:90
|
||||||
msgid "Head to Head"
|
msgid "Head to Head"
|
||||||
msgstr "Cara a cara"
|
msgstr "Cara a cara"
|
||||||
@@ -1322,6 +1346,11 @@ msgstr "El apellido debe tener entre 2 y 20 caracteres y contener solo letras"
|
|||||||
msgid "Last name must be 3-20 characters long and contain only letters"
|
msgid "Last name must be 3-20 characters long and contain only letters"
|
||||||
msgstr "El apellido debe tener entre 3 y 20 caracteres y contener solo letras"
|
msgstr "El apellido debe tener entre 3 y 20 caracteres y contener solo letras"
|
||||||
|
|
||||||
|
#. placeholder {0}: migrationStatus.result.totalBadgesEarned
|
||||||
|
#: src/features/admin/components/admin-page.tsx:74
|
||||||
|
msgid "Last recalculation: {0, plural, one {# badge earned} other {# badges earned}}"
|
||||||
|
msgstr "Último recálculo: {0, plural, one {# insignia obtenida} other {# insignias obtenidas}}"
|
||||||
|
|
||||||
#: src/features/settings/components/color-scheme-picker.tsx:43
|
#: src/features/settings/components/color-scheme-picker.tsx:43
|
||||||
msgid "Light"
|
msgid "Light"
|
||||||
msgstr "Claro"
|
msgstr "Claro"
|
||||||
@@ -1410,7 +1439,7 @@ msgid "Manage Team Songs/Logos"
|
|||||||
msgstr "Administrar canciones/logos del equipo"
|
msgstr "Administrar canciones/logos del equipo"
|
||||||
|
|
||||||
#: src/app/routes/_authed/admin/tournaments/index.tsx:17
|
#: src/app/routes/_authed/admin/tournaments/index.tsx:17
|
||||||
#: src/features/admin/components/admin-page.tsx:31
|
#: src/features/admin/components/admin-page.tsx:50
|
||||||
msgid "Manage Tournaments"
|
msgid "Manage Tournaments"
|
||||||
msgstr "Administrar torneos"
|
msgstr "Administrar torneos"
|
||||||
|
|
||||||
@@ -1485,7 +1514,11 @@ msgstr "Enfrentamiento"
|
|||||||
msgid "Matchup details"
|
msgid "Matchup details"
|
||||||
msgstr "Detalles del enfrentamiento"
|
msgstr "Detalles del enfrentamiento"
|
||||||
|
|
||||||
#: src/features/admin/components/admin-page.tsx:41
|
#: src/features/players/components/league-head-to-head.tsx:120
|
||||||
|
msgid "Me"
|
||||||
|
msgstr "Yo"
|
||||||
|
|
||||||
|
#: src/features/admin/components/admin-page.tsx:60
|
||||||
msgid "Migrate Badge Progress"
|
msgid "Migrate Badge Progress"
|
||||||
msgstr "Migrar progreso de insignias"
|
msgstr "Migrar progreso de insignias"
|
||||||
|
|
||||||
@@ -1600,7 +1633,7 @@ msgstr "No hay partidos programados"
|
|||||||
msgid "No player activity found"
|
msgid "No player activity found"
|
||||||
msgstr "No se encontró actividad de jugadores"
|
msgstr "No se encontró actividad de jugadores"
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:223
|
#: src/features/players/components/league-head-to-head.tsx:141
|
||||||
msgid "No players available"
|
msgid "No players available"
|
||||||
msgstr "No hay jugadores disponibles"
|
msgstr "No hay jugadores disponibles"
|
||||||
|
|
||||||
@@ -1609,7 +1642,7 @@ msgstr "No hay jugadores disponibles"
|
|||||||
msgid "No players enrolled yet"
|
msgid "No players enrolled yet"
|
||||||
msgstr "Aún no hay jugadores inscritos"
|
msgstr "Aún no hay jugadores inscritos"
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:223
|
#: src/features/players/components/league-head-to-head.tsx:141
|
||||||
#: src/features/players/components/player-stats-table.tsx:476
|
#: src/features/players/components/player-stats-table.tsx:476
|
||||||
msgid "No players found matching \"{search}\""
|
msgid "No players found matching \"{search}\""
|
||||||
msgstr "No se encontraron jugadores que coincidan con \"{search}\""
|
msgstr "No se encontraron jugadores que coincidan con \"{search}\""
|
||||||
@@ -1725,7 +1758,7 @@ msgstr "Una vez inscrito, puedes ver a otros agentes libres y su número de tel
|
|||||||
msgid "One time code"
|
msgid "One time code"
|
||||||
msgstr "Código de un solo uso"
|
msgstr "Código de un solo uso"
|
||||||
|
|
||||||
#: src/features/admin/components/admin-page.tsx:52
|
#: src/features/admin/components/admin-page.tsx:95
|
||||||
msgid "Open Pocketbase"
|
msgid "Open Pocketbase"
|
||||||
msgstr "Abrir Pocketbase"
|
msgstr "Abrir Pocketbase"
|
||||||
|
|
||||||
@@ -1819,12 +1852,10 @@ msgstr "Falló el control de reproducción"
|
|||||||
msgid "Player"
|
msgid "Player"
|
||||||
msgstr "Jugador"
|
msgstr "Jugador"
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:117
|
|
||||||
#: src/features/teams/components/team-form/players-picker.tsx:45
|
#: src/features/teams/components/team-form/players-picker.tsx:45
|
||||||
msgid "Player 1"
|
msgid "Player 1"
|
||||||
msgstr "Jugador 1"
|
msgstr "Jugador 1"
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:173
|
|
||||||
#: src/features/teams/components/team-form/players-picker.tsx:53
|
#: src/features/teams/components/team-form/players-picker.tsx:53
|
||||||
msgid "Player 2"
|
msgid "Player 2"
|
||||||
msgstr "Jugador 2"
|
msgstr "Jugador 2"
|
||||||
@@ -1862,10 +1893,6 @@ msgstr "Los jugadores son obligatorios"
|
|||||||
msgid "Playing on {0}"
|
msgid "Playing on {0}"
|
||||||
msgstr "Reproduciendo en {0}"
|
msgstr "Reproduciendo en {0}"
|
||||||
|
|
||||||
#: src/components/ios-install-prompt.tsx:47
|
|
||||||
msgid "Please install FLXN • This will save me Twilio credits as you won't be signed out!"
|
|
||||||
msgstr "Por favor instala FLXN • Así me ahorras créditos de Twilio porque no se cerrará tu sesión"
|
|
||||||
|
|
||||||
#: src/features/tournaments/components/group-stage-view.tsx:314
|
#: src/features/tournaments/components/group-stage-view.tsx:314
|
||||||
msgid "Populate Knockout Bracket"
|
msgid "Populate Knockout Bracket"
|
||||||
msgstr "Llenar el bracket eliminatorio"
|
msgstr "Llenar el bracket eliminatorio"
|
||||||
@@ -1925,6 +1952,12 @@ msgstr "Volver a sortear"
|
|||||||
msgid "Reactions"
|
msgid "Reactions"
|
||||||
msgstr "Reacciones"
|
msgstr "Reacciones"
|
||||||
|
|
||||||
|
#. placeholder {0}: migrationStatus.playersProcessed
|
||||||
|
#. placeholder {1}: migrationStatus.totalPlayers
|
||||||
|
#: src/features/admin/components/admin-page.tsx:67
|
||||||
|
msgid "Recalculating badges in the background ({0}/{1})"
|
||||||
|
msgstr "Recalculando insignias en segundo plano ({0}/{1})"
|
||||||
|
|
||||||
#: src/features/tournaments/components/started-tournament/sections/recent-results.tsx:34
|
#: src/features/tournaments/components/started-tournament/sections/recent-results.tsx:34
|
||||||
msgid "Recently finished"
|
msgid "Recently finished"
|
||||||
msgstr "Recién terminados"
|
msgstr "Recién terminados"
|
||||||
@@ -2032,7 +2065,7 @@ msgstr "Buscar canciones..."
|
|||||||
msgid "Search for teams to enroll..."
|
msgid "Search for teams to enroll..."
|
||||||
msgstr "Buscar equipos para inscribir..."
|
msgstr "Buscar equipos para inscribir..."
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:211
|
#: src/features/players/components/league-head-to-head.tsx:129
|
||||||
#: src/features/players/components/player-stats-table.tsx:329
|
#: src/features/players/components/player-stats-table.tsx:329
|
||||||
msgid "Search players"
|
msgid "Search players"
|
||||||
msgstr "Buscar jugadores"
|
msgstr "Buscar jugadores"
|
||||||
@@ -2070,6 +2103,10 @@ msgstr "Selecciona una insignia"
|
|||||||
msgid "Select a device..."
|
msgid "Select a device..."
|
||||||
msgstr "Selecciona un dispositivo..."
|
msgstr "Selecciona un dispositivo..."
|
||||||
|
|
||||||
|
#: src/features/players/components/league-head-to-head.tsx:109
|
||||||
|
msgid "Select a player"
|
||||||
|
msgstr "Selecciona un jugador"
|
||||||
|
|
||||||
#: src/features/teams/components/team-form/song-picker.tsx:131
|
#: src/features/teams/components/team-form/song-picker.tsx:131
|
||||||
msgid "Select a song to see details"
|
msgid "Select a song to see details"
|
||||||
msgstr "Selecciona una canción para ver detalles"
|
msgstr "Selecciona una canción para ver detalles"
|
||||||
@@ -2100,10 +2137,6 @@ msgstr "Selecciona fecha de fin"
|
|||||||
msgid "Select Enrollment Due Date"
|
msgid "Select Enrollment Due Date"
|
||||||
msgstr "Selecciona fecha límite de inscripción"
|
msgstr "Selecciona fecha límite de inscripción"
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:188
|
|
||||||
msgid "Select first player"
|
|
||||||
msgstr "Selecciona el primer jugador"
|
|
||||||
|
|
||||||
#: src/features/tournaments/components/upcoming-tournament/enroll-team/team-selection-view.tsx:64
|
#: src/features/tournaments/components/upcoming-tournament/enroll-team/team-selection-view.tsx:64
|
||||||
msgid "Select one of your existing teams"
|
msgid "Select one of your existing teams"
|
||||||
msgstr "Selecciona uno de tus equipos existentes"
|
msgstr "Selecciona uno de tus equipos existentes"
|
||||||
@@ -2112,10 +2145,6 @@ msgstr "Selecciona uno de tus equipos existentes"
|
|||||||
msgid "Select Player"
|
msgid "Select Player"
|
||||||
msgstr "Selecciona jugador"
|
msgstr "Selecciona jugador"
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:189
|
|
||||||
msgid "Select second player"
|
|
||||||
msgstr "Selecciona el segundo jugador"
|
|
||||||
|
|
||||||
#: src/features/teams/components/team-form/song-picker.tsx:68
|
#: src/features/teams/components/team-form/song-picker.tsx:68
|
||||||
msgid "Select Song"
|
msgid "Select Song"
|
||||||
msgstr "Selecciona canción"
|
msgstr "Selecciona canción"
|
||||||
@@ -2318,11 +2347,15 @@ msgstr "Sistema"
|
|||||||
msgid "T"
|
msgid "T"
|
||||||
msgstr "T"
|
msgstr "T"
|
||||||
|
|
||||||
#: src/components/ios-install-prompt.tsx:37
|
#: src/features/players/components/league-head-to-head.tsx:89
|
||||||
|
msgid "Tap an opponent below to compare"
|
||||||
|
msgstr "Toca un oponente abajo para comparar"
|
||||||
|
|
||||||
|
#: src/components/ios-install-prompt.tsx:43
|
||||||
msgid "Tap Menu (⋮) → Add to Home screen"
|
msgid "Tap Menu (⋮) → Add to Home screen"
|
||||||
msgstr "Toca Menú (⋮) → Agregar a pantalla de inicio"
|
msgstr "Toca Menú (⋮) → Agregar a pantalla de inicio"
|
||||||
|
|
||||||
#: src/components/ios-install-prompt.tsx:36
|
#: src/components/ios-install-prompt.tsx:42
|
||||||
msgid "Tap Share → Add to Home Screen"
|
msgid "Tap Share → Add to Home Screen"
|
||||||
msgstr "Toca Compartir → Agregar a pantalla de inicio"
|
msgstr "Toca Compartir → Agregar a pantalla de inicio"
|
||||||
|
|
||||||
@@ -2624,10 +2657,6 @@ msgstr "volumePercent debe ser un número"
|
|||||||
msgid "vs"
|
msgid "vs"
|
||||||
msgstr "vs"
|
msgstr "vs"
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:125
|
|
||||||
msgid "VS"
|
|
||||||
msgstr "VS"
|
|
||||||
|
|
||||||
#: src/features/players/components/player-stats-table.tsx:131
|
#: src/features/players/components/player-stats-table.tsx:131
|
||||||
#: src/features/tournaments/components/tournament-stats.tsx:112
|
#: src/features/tournaments/components/tournament-stats.tsx:112
|
||||||
msgid "W"
|
msgid "W"
|
||||||
|
|||||||
+62
-33
@@ -384,7 +384,7 @@ msgid "Active device: {0}"
|
|||||||
msgstr "アクティブなデバイス: {0}"
|
msgstr "アクティブなデバイス: {0}"
|
||||||
|
|
||||||
#: src/app/routes/_authed/admin/activities.tsx:21
|
#: src/app/routes/_authed/admin/activities.tsx:21
|
||||||
#: src/features/admin/components/admin-page.tsx:47
|
#: src/features/admin/components/admin-page.tsx:90
|
||||||
msgid "Activities"
|
msgid "Activities"
|
||||||
msgstr "アクティビティ"
|
msgstr "アクティビティ"
|
||||||
|
|
||||||
@@ -392,6 +392,10 @@ msgstr "アクティビティ"
|
|||||||
msgid "Activity Details"
|
msgid "Activity Details"
|
||||||
msgstr "アクティビティの詳細"
|
msgstr "アクティビティの詳細"
|
||||||
|
|
||||||
|
#: src/components/ios-install-prompt.tsx:58
|
||||||
|
msgid "Add FLXN to your home screen for a smoother experience"
|
||||||
|
msgstr "FLXNをホーム画面に追加すると、もっと快適に使えます"
|
||||||
|
|
||||||
#: src/features/tournaments/components/edit-enrolled-players.tsx:117
|
#: src/features/tournaments/components/edit-enrolled-players.tsx:117
|
||||||
msgid "Add Player"
|
msgid "Add Player"
|
||||||
msgstr "プレイヤーを追加"
|
msgstr "プレイヤーを追加"
|
||||||
@@ -520,7 +524,7 @@ msgstr "平均勝差"
|
|||||||
msgid "Award Badge"
|
msgid "Award Badge"
|
||||||
msgstr "バッジを付与"
|
msgstr "バッジを付与"
|
||||||
|
|
||||||
#: src/features/admin/components/admin-page.tsx:36
|
#: src/features/admin/components/admin-page.tsx:55
|
||||||
msgid "Award Badges"
|
msgid "Award Badges"
|
||||||
msgstr "バッジを付与"
|
msgstr "バッジを付与"
|
||||||
|
|
||||||
@@ -554,6 +558,18 @@ msgstr "戻る"
|
|||||||
msgid "Badge awarded to {playerName}"
|
msgid "Badge awarded to {playerName}"
|
||||||
msgstr "{playerName}にバッジを付与しました"
|
msgstr "{playerName}にバッジを付与しました"
|
||||||
|
|
||||||
|
#: src/features/admin/components/admin-page.tsx:86
|
||||||
|
msgid "Badge recalculation failed. Check the server logs."
|
||||||
|
msgstr "バッジの再計算に失敗しました。サーバーログを確認してください。"
|
||||||
|
|
||||||
|
#: src/features/admin/components/admin-page.tsx:39
|
||||||
|
msgid "Badge recalculation is already running"
|
||||||
|
msgstr "バッジの再計算はすでに実行中です"
|
||||||
|
|
||||||
|
#: src/features/admin/components/admin-page.tsx:38
|
||||||
|
msgid "Badge recalculation started in the background"
|
||||||
|
msgstr "バックグラウンドでバッジの再計算を開始しました"
|
||||||
|
|
||||||
#: src/features/admin/components/award-badges.tsx:70
|
#: src/features/admin/components/award-badges.tsx:70
|
||||||
msgid "Badge Type"
|
msgid "Badge Type"
|
||||||
msgstr "バッジの種類"
|
msgstr "バッジの種類"
|
||||||
@@ -581,7 +597,7 @@ msgid "Bracket"
|
|||||||
msgstr "ブラケット"
|
msgstr "ブラケット"
|
||||||
|
|
||||||
#: src/app/routes/_authed/admin/preview.tsx:13
|
#: src/app/routes/_authed/admin/preview.tsx:13
|
||||||
#: src/features/admin/components/admin-page.tsx:59
|
#: src/features/admin/components/admin-page.tsx:102
|
||||||
msgid "Bracket Preview"
|
msgid "Bracket Preview"
|
||||||
msgstr "ブラケットプレビュー"
|
msgstr "ブラケットプレビュー"
|
||||||
|
|
||||||
@@ -651,9 +667,9 @@ msgstr "プレイヤーを選択"
|
|||||||
msgid "Claymation"
|
msgid "Claymation"
|
||||||
msgstr "クレイ"
|
msgstr "クレイ"
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:203
|
#: src/features/players/components/league-head-to-head.tsx:99
|
||||||
msgid "Clear both players"
|
msgid "Clear selected player"
|
||||||
msgstr "両方のプレイヤーをクリア"
|
msgstr "選択したプレイヤーを解除"
|
||||||
|
|
||||||
#: src/components/sheet/modal.tsx:35
|
#: src/components/sheet/modal.tsx:35
|
||||||
#: src/features/tournaments/components/started-tournament/sections/nav-grid.tsx:169
|
#: src/features/tournaments/components/started-tournament/sections/nav-grid.tsx:169
|
||||||
@@ -841,6 +857,10 @@ msgstr "転送操作にはdeviceIdが必要です"
|
|||||||
msgid "Disconnect Spotify"
|
msgid "Disconnect Spotify"
|
||||||
msgstr "Spotifyの連携を解除"
|
msgstr "Spotifyの連携を解除"
|
||||||
|
|
||||||
|
#: src/components/ios-install-prompt.tsx:65
|
||||||
|
msgid "Dismiss"
|
||||||
|
msgstr "閉じる"
|
||||||
|
|
||||||
#: src/features/bracket/components/match-report.tsx:159
|
#: src/features/bracket/components/match-report.tsx:159
|
||||||
msgid "Does this look right?"
|
msgid "Does this look right?"
|
||||||
msgstr "これで合っていますか?"
|
msgstr "これで合っていますか?"
|
||||||
@@ -1060,6 +1080,10 @@ msgstr "アクティブなデバイスの設定に失敗しました"
|
|||||||
msgid "Failed to set volume"
|
msgid "Failed to set volume"
|
||||||
msgstr "音量の設定に失敗しました"
|
msgstr "音量の設定に失敗しました"
|
||||||
|
|
||||||
|
#: src/features/admin/components/admin-page.tsx:42
|
||||||
|
msgid "Failed to start badge recalculation"
|
||||||
|
msgstr "バッジの再計算を開始できませんでした"
|
||||||
|
|
||||||
#: src/features/teams/components/team-form/index.tsx:105
|
#: src/features/teams/components/team-form/index.tsx:105
|
||||||
msgid "Failed to update team"
|
msgid "Failed to update team"
|
||||||
msgstr "チームの更新に失敗しました"
|
msgstr "チームの更新に失敗しました"
|
||||||
@@ -1200,7 +1224,7 @@ msgstr "参加したことはありますか?"
|
|||||||
#: src/features/bracket/components/match-dock.tsx:215
|
#: src/features/bracket/components/match-dock.tsx:215
|
||||||
#: src/features/matches/components/match-card.tsx:84
|
#: src/features/matches/components/match-card.tsx:84
|
||||||
#: src/features/matches/components/match-card.tsx:228
|
#: src/features/matches/components/match-card.tsx:228
|
||||||
#: src/features/players/components/league-head-to-head.tsx:264
|
#: src/features/players/components/league-head-to-head.tsx:183
|
||||||
#: src/features/players/components/profile/header.tsx:90
|
#: src/features/players/components/profile/header.tsx:90
|
||||||
msgid "Head to Head"
|
msgid "Head to Head"
|
||||||
msgstr "対戦成績"
|
msgstr "対戦成績"
|
||||||
@@ -1322,6 +1346,11 @@ msgstr "姓は2〜20文字の英字のみで入力してください"
|
|||||||
msgid "Last name must be 3-20 characters long and contain only letters"
|
msgid "Last name must be 3-20 characters long and contain only letters"
|
||||||
msgstr "姓は3〜20文字の英字のみで入力してください"
|
msgstr "姓は3〜20文字の英字のみで入力してください"
|
||||||
|
|
||||||
|
#. placeholder {0}: migrationStatus.result.totalBadgesEarned
|
||||||
|
#: src/features/admin/components/admin-page.tsx:74
|
||||||
|
msgid "Last recalculation: {0, plural, one {# badge earned} other {# badges earned}}"
|
||||||
|
msgstr "前回の再計算:{0, plural, one {# 個のバッジを獲得} other {# 個のバッジを獲得}}"
|
||||||
|
|
||||||
#: src/features/settings/components/color-scheme-picker.tsx:43
|
#: src/features/settings/components/color-scheme-picker.tsx:43
|
||||||
msgid "Light"
|
msgid "Light"
|
||||||
msgstr "ライト"
|
msgstr "ライト"
|
||||||
@@ -1410,7 +1439,7 @@ msgid "Manage Team Songs/Logos"
|
|||||||
msgstr "チームの曲・ロゴを管理"
|
msgstr "チームの曲・ロゴを管理"
|
||||||
|
|
||||||
#: src/app/routes/_authed/admin/tournaments/index.tsx:17
|
#: src/app/routes/_authed/admin/tournaments/index.tsx:17
|
||||||
#: src/features/admin/components/admin-page.tsx:31
|
#: src/features/admin/components/admin-page.tsx:50
|
||||||
msgid "Manage Tournaments"
|
msgid "Manage Tournaments"
|
||||||
msgstr "トーナメント管理"
|
msgstr "トーナメント管理"
|
||||||
|
|
||||||
@@ -1485,7 +1514,11 @@ msgstr "対戦カード"
|
|||||||
msgid "Matchup details"
|
msgid "Matchup details"
|
||||||
msgstr "対戦カードの詳細"
|
msgstr "対戦カードの詳細"
|
||||||
|
|
||||||
#: src/features/admin/components/admin-page.tsx:41
|
#: src/features/players/components/league-head-to-head.tsx:120
|
||||||
|
msgid "Me"
|
||||||
|
msgstr "自分"
|
||||||
|
|
||||||
|
#: src/features/admin/components/admin-page.tsx:60
|
||||||
msgid "Migrate Badge Progress"
|
msgid "Migrate Badge Progress"
|
||||||
msgstr "バッジ進捗を移行"
|
msgstr "バッジ進捗を移行"
|
||||||
|
|
||||||
@@ -1600,7 +1633,7 @@ msgstr "予定されている試合はありません"
|
|||||||
msgid "No player activity found"
|
msgid "No player activity found"
|
||||||
msgstr "プレイヤーのアクティビティが見つかりません"
|
msgstr "プレイヤーのアクティビティが見つかりません"
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:223
|
#: src/features/players/components/league-head-to-head.tsx:141
|
||||||
msgid "No players available"
|
msgid "No players available"
|
||||||
msgstr "利用可能なプレイヤーがいません"
|
msgstr "利用可能なプレイヤーがいません"
|
||||||
|
|
||||||
@@ -1609,7 +1642,7 @@ msgstr "利用可能なプレイヤーがいません"
|
|||||||
msgid "No players enrolled yet"
|
msgid "No players enrolled yet"
|
||||||
msgstr "まだプレイヤーが登録されていません"
|
msgstr "まだプレイヤーが登録されていません"
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:223
|
#: src/features/players/components/league-head-to-head.tsx:141
|
||||||
#: src/features/players/components/player-stats-table.tsx:476
|
#: src/features/players/components/player-stats-table.tsx:476
|
||||||
msgid "No players found matching \"{search}\""
|
msgid "No players found matching \"{search}\""
|
||||||
msgstr "「{search}」に一致するプレイヤーが見つかりません"
|
msgstr "「{search}」に一致するプレイヤーが見つかりません"
|
||||||
@@ -1725,7 +1758,7 @@ msgstr "登録が完了すると、他のフリーエージェントとその電
|
|||||||
msgid "One time code"
|
msgid "One time code"
|
||||||
msgstr "ワンタイムコード"
|
msgstr "ワンタイムコード"
|
||||||
|
|
||||||
#: src/features/admin/components/admin-page.tsx:52
|
#: src/features/admin/components/admin-page.tsx:95
|
||||||
msgid "Open Pocketbase"
|
msgid "Open Pocketbase"
|
||||||
msgstr "Pocketbaseを開く"
|
msgstr "Pocketbaseを開く"
|
||||||
|
|
||||||
@@ -1819,12 +1852,10 @@ msgstr "再生コントロールに失敗しました"
|
|||||||
msgid "Player"
|
msgid "Player"
|
||||||
msgstr "プレイヤー"
|
msgstr "プレイヤー"
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:117
|
|
||||||
#: src/features/teams/components/team-form/players-picker.tsx:45
|
#: src/features/teams/components/team-form/players-picker.tsx:45
|
||||||
msgid "Player 1"
|
msgid "Player 1"
|
||||||
msgstr "プレイヤー1"
|
msgstr "プレイヤー1"
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:173
|
|
||||||
#: src/features/teams/components/team-form/players-picker.tsx:53
|
#: src/features/teams/components/team-form/players-picker.tsx:53
|
||||||
msgid "Player 2"
|
msgid "Player 2"
|
||||||
msgstr "プレイヤー2"
|
msgstr "プレイヤー2"
|
||||||
@@ -1862,10 +1893,6 @@ msgstr "プレイヤーの選択が必要です"
|
|||||||
msgid "Playing on {0}"
|
msgid "Playing on {0}"
|
||||||
msgstr "{0}で再生中"
|
msgstr "{0}で再生中"
|
||||||
|
|
||||||
#: src/components/ios-install-prompt.tsx:47
|
|
||||||
msgid "Please install FLXN • This will save me Twilio credits as you won't be signed out!"
|
|
||||||
msgstr "FLXNをインストールしてください • ログアウトされなくなるので、Twilioのクレジット節約になります!"
|
|
||||||
|
|
||||||
#: src/features/tournaments/components/group-stage-view.tsx:314
|
#: src/features/tournaments/components/group-stage-view.tsx:314
|
||||||
msgid "Populate Knockout Bracket"
|
msgid "Populate Knockout Bracket"
|
||||||
msgstr "ノックアウトブラケットを作成"
|
msgstr "ノックアウトブラケットを作成"
|
||||||
@@ -1925,6 +1952,12 @@ msgstr "再抽選"
|
|||||||
msgid "Reactions"
|
msgid "Reactions"
|
||||||
msgstr "リアクション"
|
msgstr "リアクション"
|
||||||
|
|
||||||
|
#. placeholder {0}: migrationStatus.playersProcessed
|
||||||
|
#. placeholder {1}: migrationStatus.totalPlayers
|
||||||
|
#: src/features/admin/components/admin-page.tsx:67
|
||||||
|
msgid "Recalculating badges in the background ({0}/{1})"
|
||||||
|
msgstr "バックグラウンドでバッジを再計算中({0}/{1})"
|
||||||
|
|
||||||
#: src/features/tournaments/components/started-tournament/sections/recent-results.tsx:34
|
#: src/features/tournaments/components/started-tournament/sections/recent-results.tsx:34
|
||||||
msgid "Recently finished"
|
msgid "Recently finished"
|
||||||
msgstr "最近終了した試合"
|
msgstr "最近終了した試合"
|
||||||
@@ -2032,7 +2065,7 @@ msgstr "曲を検索..."
|
|||||||
msgid "Search for teams to enroll..."
|
msgid "Search for teams to enroll..."
|
||||||
msgstr "登録するチームを検索..."
|
msgstr "登録するチームを検索..."
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:211
|
#: src/features/players/components/league-head-to-head.tsx:129
|
||||||
#: src/features/players/components/player-stats-table.tsx:329
|
#: src/features/players/components/player-stats-table.tsx:329
|
||||||
msgid "Search players"
|
msgid "Search players"
|
||||||
msgstr "プレイヤーを検索"
|
msgstr "プレイヤーを検索"
|
||||||
@@ -2070,6 +2103,10 @@ msgstr "バッジを選択"
|
|||||||
msgid "Select a device..."
|
msgid "Select a device..."
|
||||||
msgstr "デバイスを選択..."
|
msgstr "デバイスを選択..."
|
||||||
|
|
||||||
|
#: src/features/players/components/league-head-to-head.tsx:109
|
||||||
|
msgid "Select a player"
|
||||||
|
msgstr "プレイヤーを選択"
|
||||||
|
|
||||||
#: src/features/teams/components/team-form/song-picker.tsx:131
|
#: src/features/teams/components/team-form/song-picker.tsx:131
|
||||||
msgid "Select a song to see details"
|
msgid "Select a song to see details"
|
||||||
msgstr "詳細を見るには曲を選択してください"
|
msgstr "詳細を見るには曲を選択してください"
|
||||||
@@ -2100,10 +2137,6 @@ msgstr "終了日を選択"
|
|||||||
msgid "Select Enrollment Due Date"
|
msgid "Select Enrollment Due Date"
|
||||||
msgstr "登録締切日を選択"
|
msgstr "登録締切日を選択"
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:188
|
|
||||||
msgid "Select first player"
|
|
||||||
msgstr "1人目のプレイヤーを選択"
|
|
||||||
|
|
||||||
#: src/features/tournaments/components/upcoming-tournament/enroll-team/team-selection-view.tsx:64
|
#: src/features/tournaments/components/upcoming-tournament/enroll-team/team-selection-view.tsx:64
|
||||||
msgid "Select one of your existing teams"
|
msgid "Select one of your existing teams"
|
||||||
msgstr "既存のチームから選択してください"
|
msgstr "既存のチームから選択してください"
|
||||||
@@ -2112,10 +2145,6 @@ msgstr "既存のチームから選択してください"
|
|||||||
msgid "Select Player"
|
msgid "Select Player"
|
||||||
msgstr "プレイヤーを選択"
|
msgstr "プレイヤーを選択"
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:189
|
|
||||||
msgid "Select second player"
|
|
||||||
msgstr "2人目のプレイヤーを選択"
|
|
||||||
|
|
||||||
#: src/features/teams/components/team-form/song-picker.tsx:68
|
#: src/features/teams/components/team-form/song-picker.tsx:68
|
||||||
msgid "Select Song"
|
msgid "Select Song"
|
||||||
msgstr "曲を選択"
|
msgstr "曲を選択"
|
||||||
@@ -2318,11 +2347,15 @@ msgstr "システム"
|
|||||||
msgid "T"
|
msgid "T"
|
||||||
msgstr "T"
|
msgstr "T"
|
||||||
|
|
||||||
#: src/components/ios-install-prompt.tsx:37
|
#: src/features/players/components/league-head-to-head.tsx:89
|
||||||
|
msgid "Tap an opponent below to compare"
|
||||||
|
msgstr "下の対戦相手をタップして比較"
|
||||||
|
|
||||||
|
#: src/components/ios-install-prompt.tsx:43
|
||||||
msgid "Tap Menu (⋮) → Add to Home screen"
|
msgid "Tap Menu (⋮) → Add to Home screen"
|
||||||
msgstr "メニュー (⋮) をタップ →「ホーム画面に追加」"
|
msgstr "メニュー (⋮) をタップ →「ホーム画面に追加」"
|
||||||
|
|
||||||
#: src/components/ios-install-prompt.tsx:36
|
#: src/components/ios-install-prompt.tsx:42
|
||||||
msgid "Tap Share → Add to Home Screen"
|
msgid "Tap Share → Add to Home Screen"
|
||||||
msgstr "共有をタップ →「ホーム画面に追加」"
|
msgstr "共有をタップ →「ホーム画面に追加」"
|
||||||
|
|
||||||
@@ -2624,10 +2657,6 @@ msgstr "volumePercentは数値である必要があります"
|
|||||||
msgid "vs"
|
msgid "vs"
|
||||||
msgstr "vs"
|
msgstr "vs"
|
||||||
|
|
||||||
#: src/features/players/components/league-head-to-head.tsx:125
|
|
||||||
msgid "VS"
|
|
||||||
msgstr "VS"
|
|
||||||
|
|
||||||
#: src/features/players/components/player-stats-table.tsx:131
|
#: src/features/players/components/player-stats-table.tsx:131
|
||||||
#: src/features/tournaments/components/tournament-stats.tsx:112
|
#: src/features/tournaments/components/tournament-stats.tsx:112
|
||||||
msgid "W"
|
msgid "W"
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Mirrors PocketBase's own scan of a quoted literal: a backslash consumes the
|
||||||
|
// character after it, so an unterminated literal is exactly what a trailing
|
||||||
|
// backslash produces.
|
||||||
|
export const literalTerminates = (expression: string) => {
|
||||||
|
const open = expression.indexOf("'");
|
||||||
|
for (let i = open + 1; i < expression.length; i++) {
|
||||||
|
if (expression[i] === "\\") {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (expression[i] === "'") return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const h = vi.hoisted(() => ({
|
||||||
|
request: undefined as { url: string; headers: Headers } | undefined,
|
||||||
|
createActivity: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@tanstack/react-start/server", () => ({
|
||||||
|
getRequest: () => h.request,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/pocketbase/client", () => ({
|
||||||
|
pbAdmin: { authPromise: Promise.resolve(), createActivity: h.createActivity },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/logger", () => ({
|
||||||
|
Logger: class {
|
||||||
|
error() {}
|
||||||
|
info() {}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { recordDeniedServerFn, serverFnLoggingMiddleware } from "./activities";
|
||||||
|
|
||||||
|
type ServerHandler = (opts: {
|
||||||
|
next: () => Promise<unknown>;
|
||||||
|
data: unknown;
|
||||||
|
context: unknown;
|
||||||
|
serverFnMeta?: { id: string; name?: string; filename?: string };
|
||||||
|
}) => Promise<unknown>;
|
||||||
|
|
||||||
|
const runMiddleware = (opts: Parameters<ServerHandler>[0]) =>
|
||||||
|
(serverFnLoggingMiddleware as any).options.server(opts) as ReturnType<ServerHandler>;
|
||||||
|
|
||||||
|
const setRequest = (url: string, userAgent = "vitest") => {
|
||||||
|
h.request = { url, headers: new Headers({ "user-agent": userAgent }) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const successEnvelope = { result: { success: true, data: {} } };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
h.createActivity.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("serverFnLoggingMiddleware", () => {
|
||||||
|
it("records the source name from the compile-time meta, not the hashed url segment", async () => {
|
||||||
|
const hashedId = "a".repeat(64);
|
||||||
|
setRequest(`http://localhost:3000/_serverFn/${hashedId}`);
|
||||||
|
|
||||||
|
await runMiddleware({
|
||||||
|
next: async () => successEnvelope,
|
||||||
|
data: undefined,
|
||||||
|
context: { metadata: { player_id: "p1" } },
|
||||||
|
serverFnMeta: {
|
||||||
|
id: hashedId,
|
||||||
|
name: "updatePlayer",
|
||||||
|
filename: "src/features/players/server.ts",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
|
||||||
|
expect(h.createActivity.mock.calls[0][0]).toMatchObject({
|
||||||
|
name: "updatePlayer",
|
||||||
|
player: "p1",
|
||||||
|
success: true,
|
||||||
|
user_agent: "vitest",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the last path segment when the meta carries no name", async () => {
|
||||||
|
setRequest("http://localhost:3000/_serverFn/deadbeef");
|
||||||
|
|
||||||
|
await runMiddleware({
|
||||||
|
next: async () => successEnvelope,
|
||||||
|
data: undefined,
|
||||||
|
context: {},
|
||||||
|
serverFnMeta: { id: "deadbeef" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
|
||||||
|
expect(h.createActivity.mock.calls[0][0].name).toBe("deadbeef");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the last path segment when there is no meta at all", async () => {
|
||||||
|
setRequest("http://localhost:3000/_serverFn/legacySegment");
|
||||||
|
|
||||||
|
await runMiddleware({
|
||||||
|
next: async () => successEnvelope,
|
||||||
|
data: undefined,
|
||||||
|
context: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
|
||||||
|
expect(h.createActivity.mock.calls[0][0].name).toBe("legacySegment");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records no player when the session has no player_id", async () => {
|
||||||
|
setRequest("http://localhost:3000/_serverFn/doThing");
|
||||||
|
|
||||||
|
await runMiddleware({
|
||||||
|
next: async () => successEnvelope,
|
||||||
|
data: undefined,
|
||||||
|
context: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
|
||||||
|
expect(h.createActivity.mock.calls[0][0].player).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to 'unknown' when the path has no trailing segment", async () => {
|
||||||
|
setRequest("http://localhost:3000/");
|
||||||
|
|
||||||
|
await runMiddleware({
|
||||||
|
next: async () => successEnvelope,
|
||||||
|
data: undefined,
|
||||||
|
context: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
|
||||||
|
expect(h.createActivity.mock.calls[0][0].name).toBe("unknown");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records success:false by reading the returned envelope, not by throwing", async () => {
|
||||||
|
setRequest("http://localhost:3000/_serverFn/doThing");
|
||||||
|
|
||||||
|
await runMiddleware({
|
||||||
|
next: async () => ({
|
||||||
|
result: {
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", userMessage: "nope" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
data: undefined,
|
||||||
|
context: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
|
||||||
|
const row = h.createActivity.mock.calls[0][0];
|
||||||
|
expect(row.success).toBe(false);
|
||||||
|
expect(row.error).toContain("NOT_FOUND");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records success:false and rethrows when the handler throws", async () => {
|
||||||
|
setRequest("http://localhost:3000/_serverFn/doThing");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runMiddleware({
|
||||||
|
next: async () => {
|
||||||
|
throw new Error("boom");
|
||||||
|
},
|
||||||
|
data: undefined,
|
||||||
|
context: {},
|
||||||
|
})
|
||||||
|
).rejects.toThrow("boom");
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
|
||||||
|
expect(h.createActivity.mock.calls[0][0]).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
error: "boom",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts sensitive argument keys before they reach the audit row", async () => {
|
||||||
|
setRequest("http://localhost:3000/_serverFn/login");
|
||||||
|
|
||||||
|
await runMiddleware({
|
||||||
|
next: async () => successEnvelope,
|
||||||
|
data: { phone: "+17135550142", first_name: "Ada" },
|
||||||
|
context: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
|
||||||
|
expect(h.createActivity.mock.calls[0][0].arguments).toEqual({
|
||||||
|
phone: "[redacted]",
|
||||||
|
first_name: "Ada",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates oversized arguments", async () => {
|
||||||
|
setRequest("http://localhost:3000/_serverFn/doThing");
|
||||||
|
|
||||||
|
await runMiddleware({
|
||||||
|
next: async () => successEnvelope,
|
||||||
|
data: { blob: "x".repeat(5000) },
|
||||||
|
context: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
|
||||||
|
const args = h.createActivity.mock.calls[0][0].arguments;
|
||||||
|
expect(args.truncated).toBeDefined();
|
||||||
|
expect(args.truncated.length).toBeLessThanOrEqual(2048);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("recordDeniedServerFn", () => {
|
||||||
|
const deniedRequest = (url: string) =>
|
||||||
|
({ url, headers: new Headers({ "user-agent": "vitest" }) }) as Request;
|
||||||
|
|
||||||
|
it("names the denial row from the meta so it matches the success rows", async () => {
|
||||||
|
recordDeniedServerFn(
|
||||||
|
deniedRequest(`http://localhost:3000/_serverFn/${"b".repeat(64)}`),
|
||||||
|
{ player_id: "p1" },
|
||||||
|
{ name: "createTournament" }
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
|
||||||
|
expect(h.createActivity.mock.calls[0][0]).toMatchObject({
|
||||||
|
name: "createTournament",
|
||||||
|
player: "p1",
|
||||||
|
success: false,
|
||||||
|
error: "FORBIDDEN: Access denied",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still records a name when the meta is absent", async () => {
|
||||||
|
recordDeniedServerFn(
|
||||||
|
deniedRequest("http://localhost:3000/_serverFn/rawSegment")
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
|
||||||
|
expect(h.createActivity.mock.calls[0][0]).toMatchObject({
|
||||||
|
name: "rawSegment",
|
||||||
|
success: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+72
-13
@@ -1,6 +1,7 @@
|
|||||||
import { createMiddleware } from "@tanstack/react-start";
|
import { createMiddleware } from "@tanstack/react-start";
|
||||||
import { getRequest } from "@tanstack/react-start/server";
|
import { getRequest } from "@tanstack/react-start/server";
|
||||||
import { Logger } from "@/lib/logger";
|
import { Logger } from "@/lib/logger";
|
||||||
|
import { redactValue } from "@/lib/redact";
|
||||||
import type { ActivityInput } from "@/lib/pocketbase/services/activities";
|
import type { ActivityInput } from "@/lib/pocketbase/services/activities";
|
||||||
|
|
||||||
const logger = new Logger("Activities");
|
const logger = new Logger("Activities");
|
||||||
@@ -16,16 +17,61 @@ const recordActivity = (activity: ActivityInput) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MAX_ARGUMENTS_CHARS = 2048;
|
||||||
|
|
||||||
|
const redactArguments = (data: unknown): unknown => {
|
||||||
|
if (data === undefined) return undefined;
|
||||||
|
|
||||||
|
const redacted = redactValue(data);
|
||||||
|
const serialized = JSON.stringify(redacted) ?? "";
|
||||||
|
if (serialized.length > MAX_ARGUMENTS_CHARS) {
|
||||||
|
return { truncated: serialized.slice(0, MAX_ARGUMENTS_CHARS) };
|
||||||
|
}
|
||||||
|
return redacted;
|
||||||
|
};
|
||||||
|
|
||||||
|
const auditErrorMessage = (
|
||||||
|
error?: { code?: string; userMessage?: string }
|
||||||
|
): string => {
|
||||||
|
if (!error) return "error";
|
||||||
|
return [error.code, error.userMessage].filter(Boolean).join(": ") || "error";
|
||||||
|
};
|
||||||
|
|
||||||
|
type Actor = { player_id?: string };
|
||||||
|
|
||||||
|
type ServerFnMeta = { name?: string };
|
||||||
|
|
||||||
|
// The URL segment is a hash of the fn id; serverFnMeta carries the readable name.
|
||||||
|
const serverFnName = (request: Request, meta?: ServerFnMeta): string => {
|
||||||
|
if (meta?.name) return meta.name;
|
||||||
|
const pathParts = new URL(request.url).pathname.split("/").filter(Boolean);
|
||||||
|
return pathParts[pathParts.length - 1] || "unknown";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const recordDeniedServerFn = (
|
||||||
|
request: Request,
|
||||||
|
actor?: Actor,
|
||||||
|
meta?: ServerFnMeta
|
||||||
|
) => {
|
||||||
|
recordActivity({
|
||||||
|
name: serverFnName(request, meta),
|
||||||
|
player: actor?.player_id,
|
||||||
|
duration: 0,
|
||||||
|
success: false,
|
||||||
|
error: "FORBIDDEN: Access denied",
|
||||||
|
user_agent: request.headers.get("user-agent") || undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const serverFnLoggingMiddleware = createMiddleware({
|
export const serverFnLoggingMiddleware = createMiddleware({
|
||||||
type: "function",
|
type: "function",
|
||||||
}).server(async ({ next, data, context }) => {
|
}).server(async ({ next, data, context, serverFnMeta }) => {
|
||||||
const request = getRequest();
|
const request = getRequest();
|
||||||
|
|
||||||
const url = new URL(request.url);
|
const name = serverFnName(request, serverFnMeta);
|
||||||
const pathParts = url.pathname.split('/').filter(Boolean);
|
const playerId = (context as any)?.metadata?.player_id as string | undefined;
|
||||||
const serverFnName = pathParts[pathParts.length - 1] || 'unknown';
|
const userAgent = request.headers.get("user-agent") || undefined;
|
||||||
const userId = (context as any)?.metadata?.player_id || 'unknown';
|
const safeArgs = redactArguments(data);
|
||||||
const userAgent = request.headers.get('user-agent') || undefined;
|
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
@@ -33,12 +79,25 @@ export const serverFnLoggingMiddleware = createMiddleware({
|
|||||||
const result = await next();
|
const result = await next();
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
const envelope = (result as { result?: unknown })?.result;
|
||||||
|
const failed =
|
||||||
|
!!envelope &&
|
||||||
|
typeof envelope === "object" &&
|
||||||
|
"success" in envelope &&
|
||||||
|
(envelope as { success: boolean }).success === false;
|
||||||
|
|
||||||
recordActivity({
|
recordActivity({
|
||||||
name: serverFnName,
|
name,
|
||||||
player: userId !== 'unknown' ? userId : undefined,
|
player: playerId,
|
||||||
duration,
|
duration,
|
||||||
success: true,
|
success: !failed,
|
||||||
arguments: data,
|
error: failed
|
||||||
|
? auditErrorMessage(
|
||||||
|
(envelope as { error?: { code?: string; userMessage?: string } })
|
||||||
|
.error
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
arguments: safeArgs,
|
||||||
user_agent: userAgent,
|
user_agent: userAgent,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,12 +107,12 @@ export const serverFnLoggingMiddleware = createMiddleware({
|
|||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
recordActivity({
|
recordActivity({
|
||||||
name: serverFnName,
|
name,
|
||||||
player: userId !== 'unknown' ? userId : undefined,
|
player: playerId,
|
||||||
duration,
|
duration,
|
||||||
success: false,
|
success: false,
|
||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
arguments: data,
|
arguments: safeArgs,
|
||||||
user_agent: userAgent,
|
user_agent: userAgent,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export const superTokensFunctionMiddleware = createMiddleware({
|
|||||||
|
|
||||||
export const superTokensAdminFunctionMiddleware = createMiddleware({
|
export const superTokensAdminFunctionMiddleware = createMiddleware({
|
||||||
type: "function",
|
type: "function",
|
||||||
}).server(async ({ next }) => {
|
}).server(async ({ next, serverFnMeta }) => {
|
||||||
const request = getRequest();
|
const request = getRequest();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -62,7 +62,12 @@ export const superTokensAdminFunctionMiddleware = createMiddleware({
|
|||||||
return next({ context });
|
return next({ context });
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error("Unauthorized user in admin function.", context);
|
logger.error("Unauthorized user in admin function.", {
|
||||||
|
userAuthId: context.userAuthId,
|
||||||
|
roles: context.roles,
|
||||||
|
});
|
||||||
|
const { recordDeniedServerFn } = await import("./activities");
|
||||||
|
recordDeniedServerFn(request, context.metadata, serverFnMeta);
|
||||||
throw new Error("Unauthorized");
|
throw new Error("Unauthorized");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.message === "SESSION_REFRESH_REQUIRED") {
|
if (error.message === "SESSION_REFRESH_REQUIRED") {
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import tsConfigPaths from 'vite-tsconfig-paths'
|
||||||
|
|
||||||
|
// Excludes the TanStack Start plugin: its SSR/router codegen isn't needed for unit tests.
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tsConfigPaths({ projects: ['./tsconfig.json'] })],
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.test.{ts,tsx}'],
|
||||||
|
restoreMocks: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user