Compare commits
49 Commits
upgrade
...
6a7d119d3e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a7d119d3e | ||
|
|
4b534c86cd | ||
|
|
f96f92c7c9 | ||
|
|
97427718e8 | ||
|
|
15bbca8b90 | ||
|
|
49bbd1611c | ||
|
|
45db283bc5 | ||
|
|
b673f9e072 | ||
|
|
34d896947d | ||
|
|
42bd2542f3 | ||
|
|
b2df869111 | ||
|
|
375e0bbfc8 | ||
|
|
147dc4e744 | ||
|
|
b458872ac1 | ||
|
|
afd0b692fa | ||
|
|
af0ec85811 | ||
|
|
d18d148d32 | ||
|
|
95a50ee7a7 | ||
|
|
1ef786ea79 | ||
|
|
47962a8681 | ||
|
|
2e6d3366e4 | ||
|
|
fafe5ca3ec | ||
|
|
b52c79772f | ||
|
|
8579ec36ca | ||
|
|
2dfb7c63d3 | ||
|
|
03b2b54c1f | ||
|
|
0910f11228 | ||
|
|
a376f98fe7 | ||
|
|
1f4f66f8c5 | ||
|
|
5729dab35f | ||
|
|
c05fd5dc6d | ||
|
|
b9a42b4743 | ||
|
|
74e28cc2ac | ||
|
|
adf304b1e0 | ||
|
|
d18cdce15f | ||
|
|
aa87a9da5b | ||
|
|
6224404aa9 | ||
|
|
654041b6b6 | ||
|
|
ce29c41bf3 | ||
|
|
63ea515a31 | ||
|
|
8b1bbe213d | ||
|
|
ed538b7373 | ||
|
|
03e3bbcbc0 | ||
|
|
baf75eddba | ||
|
|
5094933302 | ||
|
|
9564b46d45 | ||
|
|
ece5094f13 | ||
|
|
cfe1ee7171 | ||
|
|
3a41609a91 |
24
bun.lock
@@ -14,6 +14,7 @@
|
|||||||
"@mantine/tiptap": "^8.2.4",
|
"@mantine/tiptap": "^8.2.4",
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@svgmoji/noto": "^3.2.0",
|
"@svgmoji/noto": "^3.2.0",
|
||||||
|
"@tanstack/react-devtools": "^0.7.6",
|
||||||
"@tanstack/react-query": "^5.66.0",
|
"@tanstack/react-query": "^5.66.0",
|
||||||
"@tanstack/react-query-devtools": "^5.66.0",
|
"@tanstack/react-query-devtools": "^5.66.0",
|
||||||
"@tanstack/react-router": "^1.130.12",
|
"@tanstack/react-router": "^1.130.12",
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
"@tiptap/starter-kit": "^3.4.3",
|
"@tiptap/starter-kit": "^3.4.3",
|
||||||
"@types/bun": "^1.2.22",
|
"@types/bun": "^1.2.22",
|
||||||
"@types/ioredis": "^4.28.10",
|
"@types/ioredis": "^4.28.10",
|
||||||
|
"browser-image-compression": "^2.0.2",
|
||||||
"dotenv": "^17.2.2",
|
"dotenv": "^17.2.2",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"framer-motion": "^12.23.12",
|
"framer-motion": "^12.23.12",
|
||||||
@@ -336,6 +338,14 @@
|
|||||||
|
|
||||||
"@simplewebauthn/browser": ["@simplewebauthn/browser@13.2.0", "", {}, "sha512-N3fuA1AAnTo5gCStYoIoiasPccC+xPLx2YU88Dv0GeAmPQTWHETlZQq5xZ0DgUq1H9loXMWQH5qqUjcI7BHJ1A=="],
|
"@simplewebauthn/browser": ["@simplewebauthn/browser@13.2.0", "", {}, "sha512-N3fuA1AAnTo5gCStYoIoiasPccC+xPLx2YU88Dv0GeAmPQTWHETlZQq5xZ0DgUq1H9loXMWQH5qqUjcI7BHJ1A=="],
|
||||||
|
|
||||||
|
"@solid-primitives/event-listener": ["@solid-primitives/event-listener@2.4.3", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-h4VqkYFv6Gf+L7SQj+Y6puigL/5DIi7x5q07VZET7AWcS+9/G3WfIE9WheniHWJs51OEkRB43w6lDys5YeFceg=="],
|
||||||
|
|
||||||
|
"@solid-primitives/keyboard": ["@solid-primitives/keyboard@1.3.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-9dQHTTgLBqyAI7aavtO+HnpTVJgWQA1ghBSrmLtMu1SMxLPDuLfuNr+Tk5udb4AL4Ojg7h9JrKOGEEDqsJXWJA=="],
|
||||||
|
|
||||||
|
"@solid-primitives/rootless": ["@solid-primitives/rootless@1.5.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-9HULb0QAzL2r47CCad0M+NKFtQ+LrGGNHZfteX/ThdGvKIg2o2GYhBooZubTCd/RTu2l2+Nw4s+dEfiDGvdrrQ=="],
|
||||||
|
|
||||||
|
"@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.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
|
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
|
||||||
|
|
||||||
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||||
@@ -344,6 +354,12 @@
|
|||||||
|
|
||||||
"@svgmoji/noto": ["@svgmoji/noto@3.2.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@svgmoji/core": "^3.2.0" } }, "sha512-JgtNciB06hMDI1Pb1N2IgLh44XRMZUUNwBANzjY5jXTPqOCu1A1VA35ENvUsRhEUZOm8I+hbdAEHkwMVqxLeIQ=="],
|
"@svgmoji/noto": ["@svgmoji/noto@3.2.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@svgmoji/core": "^3.2.0" } }, "sha512-JgtNciB06hMDI1Pb1N2IgLh44XRMZUUNwBANzjY5jXTPqOCu1A1VA35ENvUsRhEUZOm8I+hbdAEHkwMVqxLeIQ=="],
|
||||||
|
|
||||||
|
"@tanstack/devtools": ["@tanstack/devtools@0.6.20", "", { "dependencies": { "@solid-primitives/keyboard": "^1.3.3", "@tanstack/devtools-event-bus": "0.3.2", "@tanstack/devtools-ui": "0.4.2", "clsx": "^2.1.1", "goober": "^2.1.16", "solid-js": "^1.9.9" } }, "sha512-7Sw6bWvwKsHDNLg+8v7xOXhE5tzwx6/KgLWSSP55pJ86wpSXYdIm89vvXm4ED1lgKfEU5l3f4Y6QVagU4rgRiQ=="],
|
||||||
|
|
||||||
|
"@tanstack/devtools-event-bus": ["@tanstack/devtools-event-bus@0.3.2", "", { "dependencies": { "ws": "^8.18.3" } }, "sha512-yJT2As/drc+Epu0nsqCsJaKaLcaNGufiNxSlp/+/oeTD0jsBxF9/PJBfh66XVpYXkKr97b8689mSu7QMef0Rrw=="],
|
||||||
|
|
||||||
|
"@tanstack/devtools-ui": ["@tanstack/devtools-ui@0.4.2", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "solid-js": "^1.9.9" } }, "sha512-xvALRLeD+TYjaLx9f9OrRBBZITAYPIk7RH8LRiESUQHw7lZO/sBU1ggrcSePh7TwKWXl9zLmtUi+7xVIS+j/dQ=="],
|
||||||
|
|
||||||
"@tanstack/directive-functions-plugin": ["@tanstack/directive-functions-plugin@1.132.0", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/router-utils": "1.132.0", "babel-dead-code-elimination": "^1.0.10", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "vite": ">=6.0.0 || >=7.0.0" } }, "sha512-5+K3msIpSYkiDE0PTIAT2HzZRps/M2uQsDEA5HApXxOhIAWykQ/yyO1umgkMwYpgJqnT96AVHb0E559Dfvhj0A=="],
|
"@tanstack/directive-functions-plugin": ["@tanstack/directive-functions-plugin@1.132.0", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/router-utils": "1.132.0", "babel-dead-code-elimination": "^1.0.10", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "vite": ">=6.0.0 || >=7.0.0" } }, "sha512-5+K3msIpSYkiDE0PTIAT2HzZRps/M2uQsDEA5HApXxOhIAWykQ/yyO1umgkMwYpgJqnT96AVHb0E559Dfvhj0A=="],
|
||||||
|
|
||||||
"@tanstack/history": ["@tanstack/history@1.132.0", "", {}, "sha512-GG2R9I6QSlbNR9fEuX2sQCigY6K28w51h2634TWmkaHXlzQw+rWuIWr4nAGM9doA+kWRi1LFSFMvAiG3cOqjXQ=="],
|
"@tanstack/history": ["@tanstack/history@1.132.0", "", {}, "sha512-GG2R9I6QSlbNR9fEuX2sQCigY6K28w51h2634TWmkaHXlzQw+rWuIWr4nAGM9doA+kWRi1LFSFMvAiG3cOqjXQ=="],
|
||||||
@@ -352,6 +368,8 @@
|
|||||||
|
|
||||||
"@tanstack/query-devtools": ["@tanstack/query-devtools@5.90.1", "", {}, "sha512-GtINOPjPUH0OegJExZ70UahT9ykmAhmtNVcmtdnOZbxLwT7R5OmRztR5Ahe3/Cu7LArEmR6/588tAycuaWb1xQ=="],
|
"@tanstack/query-devtools": ["@tanstack/query-devtools@5.90.1", "", {}, "sha512-GtINOPjPUH0OegJExZ70UahT9ykmAhmtNVcmtdnOZbxLwT7R5OmRztR5Ahe3/Cu7LArEmR6/588tAycuaWb1xQ=="],
|
||||||
|
|
||||||
|
"@tanstack/react-devtools": ["@tanstack/react-devtools@0.7.6", "", { "dependencies": { "@tanstack/devtools": "0.6.20" }, "peerDependencies": { "@types/react": ">=16.8", "@types/react-dom": ">=16.8", "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-fP0jY7yed0HVIEhs+rjn8wZqABD/6TUiq6SV8jlyYP8NBK2Jfq3ce+IRw5w+N7KBzEokveLQFktxoLNpt3ZOkA=="],
|
||||||
|
|
||||||
"@tanstack/react-query": ["@tanstack/react-query@5.90.2", "", { "dependencies": { "@tanstack/query-core": "5.90.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-CLABiR+h5PYfOWr/z+vWFt5VsOA2ekQeRQBFSKlcoW6Ndx/f8rfyVmq4LbgOM4GG2qtxAxjLYLOpCNTYm4uKzw=="],
|
"@tanstack/react-query": ["@tanstack/react-query@5.90.2", "", { "dependencies": { "@tanstack/query-core": "5.90.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-CLABiR+h5PYfOWr/z+vWFt5VsOA2ekQeRQBFSKlcoW6Ndx/f8rfyVmq4LbgOM4GG2qtxAxjLYLOpCNTYm4uKzw=="],
|
||||||
|
|
||||||
"@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.90.2", "", { "dependencies": { "@tanstack/query-devtools": "5.90.1" }, "peerDependencies": { "@tanstack/react-query": "^5.90.2", "react": "^18 || ^19" } }, "sha512-vAXJzZuBXtCQtrY3F/yUNJCV4obT/A/n81kb3+YqLbro5Z2+phdAbceO+deU3ywPw8B42oyJlp4FhO0SoivDFQ=="],
|
"@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.90.2", "", { "dependencies": { "@tanstack/query-devtools": "5.90.1" }, "peerDependencies": { "@tanstack/react-query": "^5.90.2", "react": "^18 || ^19" } }, "sha512-vAXJzZuBXtCQtrY3F/yUNJCV4obT/A/n81kb3+YqLbro5Z2+phdAbceO+deU3ywPw8B42oyJlp4FhO0SoivDFQ=="],
|
||||||
@@ -544,6 +562,8 @@
|
|||||||
|
|
||||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||||
|
|
||||||
|
"browser-image-compression": ["browser-image-compression@2.0.2", "", { "dependencies": { "uzip": "0.20201231.0" } }, "sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw=="],
|
||||||
|
|
||||||
"browser-tabs-lock": ["browser-tabs-lock@1.3.0", "", { "dependencies": { "lodash": ">=4.17.21" } }, "sha512-g6nHaobTiT0eMZ7jh16YpD2kcjAp+PInbiVq3M1x6KKaEIVhT4v9oURNIpZLOZ3LQbQ3XYfNhMAb/9hzNLIWrw=="],
|
"browser-tabs-lock": ["browser-tabs-lock@1.3.0", "", { "dependencies": { "lodash": ">=4.17.21" } }, "sha512-g6nHaobTiT0eMZ7jh16YpD2kcjAp+PInbiVq3M1x6KKaEIVhT4v9oURNIpZLOZ3LQbQ3XYfNhMAb/9hzNLIWrw=="],
|
||||||
|
|
||||||
"browserslist": ["browserslist@4.26.2", "", { "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", "electron-to-chromium": "^1.5.218", "node-releases": "^2.0.21", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A=="],
|
"browserslist": ["browserslist@4.26.2", "", { "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", "electron-to-chromium": "^1.5.218", "node-releases": "^2.0.21", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A=="],
|
||||||
@@ -1166,6 +1186,8 @@
|
|||||||
|
|
||||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
|
|
||||||
|
"uzip": ["uzip@0.20201231.0", "", {}, "sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng=="],
|
||||||
|
|
||||||
"vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
|
"vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
|
||||||
|
|
||||||
"victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
|
"victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
|
||||||
@@ -1190,6 +1212,8 @@
|
|||||||
|
|
||||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
|
|
||||||
|
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||||
|
|
||||||
"xmlbuilder": ["xmlbuilder@13.0.2", "", {}, "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ=="],
|
"xmlbuilder": ["xmlbuilder@13.0.2", "", {}, "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ=="],
|
||||||
|
|
||||||
"xmlbuilder2": ["xmlbuilder2@3.1.1", "", { "dependencies": { "@oozcitak/dom": "1.15.10", "@oozcitak/infra": "1.0.8", "@oozcitak/util": "8.3.8", "js-yaml": "3.14.1" } }, "sha512-WCSfbfZnQDdLQLiMdGUQpMxxckeQ4oZNMNhLVkcekTu7xhD4tuUDyAPoY8CwXvBYE6LwBHd6QW2WZXlOWr1vCw=="],
|
"xmlbuilder2": ["xmlbuilder2@3.1.1", "", { "dependencies": { "@oozcitak/dom": "1.15.10", "@oozcitak/infra": "1.0.8", "@oozcitak/util": "8.3.8", "js-yaml": "3.14.1" } }, "sha512-WCSfbfZnQDdLQLiMdGUQpMxxckeQ4oZNMNhLVkcekTu7xhD4tuUDyAPoY8CwXvBYE6LwBHd6QW2WZXlOWr1vCw=="],
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"@mantine/tiptap": "^8.2.4",
|
"@mantine/tiptap": "^8.2.4",
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@svgmoji/noto": "^3.2.0",
|
"@svgmoji/noto": "^3.2.0",
|
||||||
|
"@tanstack/react-devtools": "^0.7.6",
|
||||||
"@tanstack/react-query": "^5.66.0",
|
"@tanstack/react-query": "^5.66.0",
|
||||||
"@tanstack/react-query-devtools": "^5.66.0",
|
"@tanstack/react-query-devtools": "^5.66.0",
|
||||||
"@tanstack/react-router": "^1.130.12",
|
"@tanstack/react-router": "^1.130.12",
|
||||||
|
|||||||
108
pb_migrations/1759244692_created_activities.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = new Collection({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1579384326",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json4225120046",
|
||||||
|
"maxSize": 0,
|
||||||
|
"name": "arguments",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_3072146508",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation2551806565",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "player",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3293145029",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "user_agent",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate3332085495",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "pbc_1262591861",
|
||||||
|
"indexes": [],
|
||||||
|
"listRule": null,
|
||||||
|
"name": "activities",
|
||||||
|
"system": false,
|
||||||
|
"type": "base",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1262591861");
|
||||||
|
|
||||||
|
return app.delete(collection);
|
||||||
|
})
|
||||||
27
pb_migrations/1759245857_updated_activities.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1262591861")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(5, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number2254405824",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "duration",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1262591861")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("number2254405824")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
43
pb_migrations/1759246171_updated_activities.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1262591861")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(6, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "bool1862328242",
|
||||||
|
"name": "success",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "bool"
|
||||||
|
}))
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(7, new Field({
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1574812785",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "error",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1262591861")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("bool1862328242")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("text1574812785")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
27
pb_migrations/1759340868_updated_badges.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1340419796")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(7, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number4113142680",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "order",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1340419796")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("number4113142680")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
46
pb_migrations/1759344923_updated_players.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_3072146508")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("relation2029409178")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("relation2813965191")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_3072146508")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(5, new Field({
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_1340419796",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation2029409178",
|
||||||
|
"maxSelect": 999,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "badges",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
}))
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(6, new Field({
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_1340419796",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation2813965191",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "featured_badge",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
187
pb_migrations/1759344931_deleted_player_badges_view.js
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_5062686152");
|
||||||
|
|
||||||
|
return app.delete(collection);
|
||||||
|
}, (app) => {
|
||||||
|
const collection = new Collection({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_3072146508",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation2582050271",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "player_id",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_1340419796",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation4154639100",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "badge_id",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_GhrR",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "badge_name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_DEaW",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "badge_description",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_MHmw",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"name": "badge_type",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "select",
|
||||||
|
"values": [
|
||||||
|
"tournament_participation",
|
||||||
|
"tournament_placement",
|
||||||
|
"performance",
|
||||||
|
"overtime",
|
||||||
|
"match_milestone"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_11YE",
|
||||||
|
"max": 50,
|
||||||
|
"min": 0,
|
||||||
|
"name": "badge_icon",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_qAJu",
|
||||||
|
"max": 50,
|
||||||
|
"min": 0,
|
||||||
|
"name": "badge_color",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_giOf",
|
||||||
|
"name": "is_progressive",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json3212413036",
|
||||||
|
"maxSize": 1,
|
||||||
|
"name": "current_progress",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json4171899439",
|
||||||
|
"maxSize": 1,
|
||||||
|
"name": "target_progress",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json3435813110",
|
||||||
|
"maxSize": 1,
|
||||||
|
"name": "is_earned",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "_clone_Q7lC",
|
||||||
|
"max": "",
|
||||||
|
"min": "",
|
||||||
|
"name": "earned_at",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "date"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "pbc_5062686152",
|
||||||
|
"indexes": [],
|
||||||
|
"listRule": null,
|
||||||
|
"name": "player_badges_view",
|
||||||
|
"system": false,
|
||||||
|
"type": "view",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewQuery": "\n SELECT\n (p.id || '_' || b.id) as id,\n p.id as player_id,\n b.id as badge_id,\n b.name as badge_name,\n b.description as badge_description,\n b.type as badge_type,\n b.icon as badge_icon,\n b.color as badge_color,\n b.is_progressive,\n COALESCE(pbp.current_progress, 0) as current_progress,\n COALESCE(pbp.target_progress, b.progress_target, 1) as target_progress,\n COALESCE(pbp.is_earned, false) as is_earned,\n pbp.earned_at\n FROM players p\n CROSS JOIN badges b\n LEFT JOIN player_badge_progress pbp ON pbp.player_id = p.id AND pbp.badge_id = b.id\n ",
|
||||||
|
"viewRule": null
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
})
|
||||||
129
pb_migrations/1759344938_deleted_player_badge_progress.js
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_4251874343");
|
||||||
|
|
||||||
|
return app.delete(collection);
|
||||||
|
}, (app) => {
|
||||||
|
const collection = new Collection({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": true,
|
||||||
|
"collectionId": "pbc_3072146508",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation2847519201",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 1,
|
||||||
|
"name": "player_id",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": true,
|
||||||
|
"collectionId": "pbc_1340419796",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation3948571039",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 1,
|
||||||
|
"name": "badge_id",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number1847293057",
|
||||||
|
"max": null,
|
||||||
|
"min": 0,
|
||||||
|
"name": "current_progress",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number2948571040",
|
||||||
|
"max": null,
|
||||||
|
"min": 1,
|
||||||
|
"name": "target_progress",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "bool3049672141",
|
||||||
|
"name": "is_earned",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "date1150773242",
|
||||||
|
"max": "",
|
||||||
|
"min": "",
|
||||||
|
"name": "earned_at",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "date"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate3332085495",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "pbc_4251874343",
|
||||||
|
"indexes": [
|
||||||
|
"CREATE UNIQUE INDEX `idx_unique_player_badge` ON `player_badge_progress` (`player_id`, `badge_id`)"
|
||||||
|
],
|
||||||
|
"listRule": null,
|
||||||
|
"name": "player_badge_progress",
|
||||||
|
"system": false,
|
||||||
|
"type": "base",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
})
|
||||||
173
pb_migrations/1759344944_deleted_badges.js
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1340419796");
|
||||||
|
|
||||||
|
return app.delete(collection);
|
||||||
|
}, (app) => {
|
||||||
|
const collection = new Collection({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1579384326",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1843675174",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "description",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "select4029814376",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"name": "type",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "select",
|
||||||
|
"values": [
|
||||||
|
"tournament_participation",
|
||||||
|
"tournament_placement",
|
||||||
|
"performance",
|
||||||
|
"overtime",
|
||||||
|
"match_milestone"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json1578432567",
|
||||||
|
"maxSize": 2000000,
|
||||||
|
"name": "criteria",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3928475610",
|
||||||
|
"max": 50,
|
||||||
|
"min": 0,
|
||||||
|
"name": "icon",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1847293056",
|
||||||
|
"max": 50,
|
||||||
|
"min": 0,
|
||||||
|
"name": "color",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number4113142680",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "order",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "bool2847519203",
|
||||||
|
"name": "is_progressive",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number2948571038",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "progress_target",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate3332085495",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "pbc_1340419796",
|
||||||
|
"indexes": [],
|
||||||
|
"listRule": null,
|
||||||
|
"name": "badges",
|
||||||
|
"system": false,
|
||||||
|
"type": "base",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
})
|
||||||
145
pb_migrations/1759345060_created_badges.js
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = new Collection({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1579384326",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text2324736937",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "key",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1843675174",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "description",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json3055524737",
|
||||||
|
"maxSize": 0,
|
||||||
|
"name": "criteria",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "select2363381545",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"name": "type",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "select",
|
||||||
|
"values": [
|
||||||
|
"manual",
|
||||||
|
"match",
|
||||||
|
"tournament"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "bool3646955747",
|
||||||
|
"name": "progressive",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number4113142680",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "order",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate3332085495",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "pbc_1340419796",
|
||||||
|
"indexes": [],
|
||||||
|
"listRule": null,
|
||||||
|
"name": "badges",
|
||||||
|
"system": false,
|
||||||
|
"type": "base",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1340419796");
|
||||||
|
|
||||||
|
return app.delete(collection);
|
||||||
|
})
|
||||||
104
pb_migrations/1759345122_created_player_badge_progress.js
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = new Collection({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_1340419796",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation4277159965",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "badge",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_3072146508",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation2551806565",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "player",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number570552902",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "progress",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "bool2625885481",
|
||||||
|
"name": "earned",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate3332085495",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "pbc_1063824264",
|
||||||
|
"indexes": [],
|
||||||
|
"listRule": null,
|
||||||
|
"name": "player_badge_progress",
|
||||||
|
"system": false,
|
||||||
|
"type": "base",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1063824264");
|
||||||
|
|
||||||
|
return app.delete(collection);
|
||||||
|
})
|
||||||
20
pb_migrations/1759345318_updated_player_badge_progress.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1063824264")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"name": "badge_progress"
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1063824264")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"name": "player_badge_progress"
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
29
pb_migrations/1759594431_updated_tournaments.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(10, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "file538556518",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"maxSize": 0,
|
||||||
|
"mimeTypes": [],
|
||||||
|
"name": "glitch_logo",
|
||||||
|
"presentable": false,
|
||||||
|
"protected": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"thumbs": [],
|
||||||
|
"type": "file"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("file538556518")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
42
pb_migrations/1759594880_updated_tournaments.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||||
|
|
||||||
|
// update field
|
||||||
|
collection.fields.addAt(10, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "file538556518",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"maxSize": 6000000,
|
||||||
|
"mimeTypes": [],
|
||||||
|
"name": "glitch_logo",
|
||||||
|
"presentable": false,
|
||||||
|
"protected": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"thumbs": [],
|
||||||
|
"type": "file"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||||
|
|
||||||
|
// update field
|
||||||
|
collection.fields.addAt(10, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "file538556518",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"maxSize": 0,
|
||||||
|
"mimeTypes": [],
|
||||||
|
"name": "glitch_logo",
|
||||||
|
"presentable": false,
|
||||||
|
"protected": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"thumbs": [],
|
||||||
|
"type": "file"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
26
pb_migrations/1760127117_updated_players.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_3072146508")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(5, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "date3558165700",
|
||||||
|
"max": "",
|
||||||
|
"min": "",
|
||||||
|
"name": "last_activity",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "date"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_3072146508")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("date3558165700")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 832 B |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 64 KiB |
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "",
|
"name": "FLXN IX",
|
||||||
"short_name": "",
|
"short_name": "FLXN",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/android-chrome-192x192.png",
|
"src": "/favicon.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/android-chrome-512x512.png",
|
"src": "/favicon.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/static/img/b2b_badge.png
Normal file
|
After Width: | Height: | Size: 436 KiB |
BIN
public/static/img/big_diff_badge.png
Normal file
|
After Width: | Height: | Size: 273 KiB |
BIN
public/static/img/bronze_medal_badge.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
public/static/img/developer_badge.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
public/static/img/dunce_cap_badge.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
public/static/img/experienced_player_badge.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
public/static/img/getting_started_badge.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
public/static/img/helper_badge.png
Normal file
|
After Width: | Height: | Size: 189 KiB |
BIN
public/static/img/hoster_badge.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 747 KiB |
BIN
public/static/img/new_player_badge.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
public/static/img/out_of_towner_badge.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
public/static/img/pilot_program_badge.png
Normal file
|
After Width: | Height: | Size: 147 KiB |
BIN
public/static/img/regional_winner_badge.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
public/static/img/regular_player_badge.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
public/static/img/reigning_champion_badge.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
public/static/img/runners_club_badge.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
public/static/img/silver_medal_badge.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
public/static/img/supreme_badge.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/static/img/time_and_half_badge.png
Normal file
|
After Width: | Height: | Size: 284 KiB |
BIN
public/static/img/veteran_1_badge.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
public/static/img/veteran_2_badge.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
public/static/img/veteran_3_badge.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
public/static/img/veteran_4_badge.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
public/static/img/veteran_5_badge.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
public/static/img/veteran_6_badge.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
public/static/img/veteran_7_badge.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
public/static/img/veteran_8_badge.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
public/static/img/winner_badge.png
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
public/static/img/working_overtime_badge.png
Normal file
|
After Width: | Height: | Size: 226 KiB |
@@ -330,6 +330,8 @@ async function startServer() {
|
|||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
port: PORT,
|
port: PORT,
|
||||||
|
|
||||||
|
idleTimeout: 255,
|
||||||
|
|
||||||
routes: {
|
routes: {
|
||||||
// Serve static assets (preloaded or on-demand)
|
// Serve static assets (preloaded or on-demand)
|
||||||
...routes,
|
...routes,
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_aut
|
|||||||
import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$teamId'
|
import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$teamId'
|
||||||
import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/profile.$playerId'
|
import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/profile.$playerId'
|
||||||
import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview'
|
import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview'
|
||||||
|
import { Route as AuthedAdminBadgesRouteImport } from './routes/_authed/admin/badges'
|
||||||
|
import { Route as AuthedAdminActivitiesRouteImport } from './routes/_authed/admin/activities'
|
||||||
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
|
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
|
||||||
import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
|
import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
|
||||||
import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index'
|
import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index'
|
||||||
@@ -161,6 +163,16 @@ const AuthedAdminPreviewRoute = AuthedAdminPreviewRouteImport.update({
|
|||||||
path: '/preview',
|
path: '/preview',
|
||||||
getParentRoute: () => AuthedAdminRoute,
|
getParentRoute: () => AuthedAdminRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthedAdminBadgesRoute = AuthedAdminBadgesRouteImport.update({
|
||||||
|
id: '/badges',
|
||||||
|
path: '/badges',
|
||||||
|
getParentRoute: () => AuthedAdminRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthedAdminActivitiesRoute = AuthedAdminActivitiesRouteImport.update({
|
||||||
|
id: '/activities',
|
||||||
|
path: '/activities',
|
||||||
|
getParentRoute: () => AuthedAdminRoute,
|
||||||
|
} as any)
|
||||||
const AuthedAdminTournamentsIndexRoute =
|
const AuthedAdminTournamentsIndexRoute =
|
||||||
AuthedAdminTournamentsIndexRouteImport.update({
|
AuthedAdminTournamentsIndexRouteImport.update({
|
||||||
id: '/tournaments/',
|
id: '/tournaments/',
|
||||||
@@ -206,6 +218,8 @@ export interface FileRoutesByFullPath {
|
|||||||
'/settings': typeof AuthedSettingsRoute
|
'/settings': typeof AuthedSettingsRoute
|
||||||
'/stats': typeof AuthedStatsRoute
|
'/stats': typeof AuthedStatsRoute
|
||||||
'/': typeof AuthedIndexRoute
|
'/': typeof AuthedIndexRoute
|
||||||
|
'/admin/activities': typeof AuthedAdminActivitiesRoute
|
||||||
|
'/admin/badges': typeof AuthedAdminBadgesRoute
|
||||||
'/admin/preview': typeof AuthedAdminPreviewRoute
|
'/admin/preview': typeof AuthedAdminPreviewRoute
|
||||||
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
||||||
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
||||||
@@ -236,6 +250,8 @@ export interface FileRoutesByTo {
|
|||||||
'/settings': typeof AuthedSettingsRoute
|
'/settings': typeof AuthedSettingsRoute
|
||||||
'/stats': typeof AuthedStatsRoute
|
'/stats': typeof AuthedStatsRoute
|
||||||
'/': typeof AuthedIndexRoute
|
'/': typeof AuthedIndexRoute
|
||||||
|
'/admin/activities': typeof AuthedAdminActivitiesRoute
|
||||||
|
'/admin/badges': typeof AuthedAdminBadgesRoute
|
||||||
'/admin/preview': typeof AuthedAdminPreviewRoute
|
'/admin/preview': typeof AuthedAdminPreviewRoute
|
||||||
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
||||||
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
||||||
@@ -269,6 +285,8 @@ export interface FileRoutesById {
|
|||||||
'/_authed/settings': typeof AuthedSettingsRoute
|
'/_authed/settings': typeof AuthedSettingsRoute
|
||||||
'/_authed/stats': typeof AuthedStatsRoute
|
'/_authed/stats': typeof AuthedStatsRoute
|
||||||
'/_authed/': typeof AuthedIndexRoute
|
'/_authed/': typeof AuthedIndexRoute
|
||||||
|
'/_authed/admin/activities': typeof AuthedAdminActivitiesRoute
|
||||||
|
'/_authed/admin/badges': typeof AuthedAdminBadgesRoute
|
||||||
'/_authed/admin/preview': typeof AuthedAdminPreviewRoute
|
'/_authed/admin/preview': typeof AuthedAdminPreviewRoute
|
||||||
'/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
'/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
||||||
'/_authed/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
'/_authed/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
||||||
@@ -302,6 +320,8 @@ export interface FileRouteTypes {
|
|||||||
| '/settings'
|
| '/settings'
|
||||||
| '/stats'
|
| '/stats'
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/admin/activities'
|
||||||
|
| '/admin/badges'
|
||||||
| '/admin/preview'
|
| '/admin/preview'
|
||||||
| '/profile/$playerId'
|
| '/profile/$playerId'
|
||||||
| '/teams/$teamId'
|
| '/teams/$teamId'
|
||||||
@@ -332,6 +352,8 @@ export interface FileRouteTypes {
|
|||||||
| '/settings'
|
| '/settings'
|
||||||
| '/stats'
|
| '/stats'
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/admin/activities'
|
||||||
|
| '/admin/badges'
|
||||||
| '/admin/preview'
|
| '/admin/preview'
|
||||||
| '/profile/$playerId'
|
| '/profile/$playerId'
|
||||||
| '/teams/$teamId'
|
| '/teams/$teamId'
|
||||||
@@ -364,6 +386,8 @@ export interface FileRouteTypes {
|
|||||||
| '/_authed/settings'
|
| '/_authed/settings'
|
||||||
| '/_authed/stats'
|
| '/_authed/stats'
|
||||||
| '/_authed/'
|
| '/_authed/'
|
||||||
|
| '/_authed/admin/activities'
|
||||||
|
| '/_authed/admin/badges'
|
||||||
| '/_authed/admin/preview'
|
| '/_authed/admin/preview'
|
||||||
| '/_authed/profile/$playerId'
|
| '/_authed/profile/$playerId'
|
||||||
| '/_authed/teams/$teamId'
|
| '/_authed/teams/$teamId'
|
||||||
@@ -576,6 +600,20 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthedAdminPreviewRouteImport
|
preLoaderRoute: typeof AuthedAdminPreviewRouteImport
|
||||||
parentRoute: typeof AuthedAdminRoute
|
parentRoute: typeof AuthedAdminRoute
|
||||||
}
|
}
|
||||||
|
'/_authed/admin/badges': {
|
||||||
|
id: '/_authed/admin/badges'
|
||||||
|
path: '/badges'
|
||||||
|
fullPath: '/admin/badges'
|
||||||
|
preLoaderRoute: typeof AuthedAdminBadgesRouteImport
|
||||||
|
parentRoute: typeof AuthedAdminRoute
|
||||||
|
}
|
||||||
|
'/_authed/admin/activities': {
|
||||||
|
id: '/_authed/admin/activities'
|
||||||
|
path: '/activities'
|
||||||
|
fullPath: '/admin/activities'
|
||||||
|
preLoaderRoute: typeof AuthedAdminActivitiesRouteImport
|
||||||
|
parentRoute: typeof AuthedAdminRoute
|
||||||
|
}
|
||||||
'/_authed/admin/tournaments/': {
|
'/_authed/admin/tournaments/': {
|
||||||
id: '/_authed/admin/tournaments/'
|
id: '/_authed/admin/tournaments/'
|
||||||
path: '/tournaments'
|
path: '/tournaments'
|
||||||
@@ -622,6 +660,8 @@ declare module '@tanstack/react-router' {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface AuthedAdminRouteChildren {
|
interface AuthedAdminRouteChildren {
|
||||||
|
AuthedAdminActivitiesRoute: typeof AuthedAdminActivitiesRoute
|
||||||
|
AuthedAdminBadgesRoute: typeof AuthedAdminBadgesRoute
|
||||||
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
|
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
|
||||||
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
|
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
|
||||||
AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute
|
AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute
|
||||||
@@ -631,6 +671,8 @@ interface AuthedAdminRouteChildren {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
|
const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
|
||||||
|
AuthedAdminActivitiesRoute: AuthedAdminActivitiesRoute,
|
||||||
|
AuthedAdminBadgesRoute: AuthedAdminBadgesRoute,
|
||||||
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
|
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
|
||||||
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
|
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
|
||||||
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,
|
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function getRouter() {
|
|||||||
gcTime: 5 * 60 * 1000, // 5 minutes
|
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: "always",
|
refetchOnReconnect: "always",
|
||||||
retry: 3,
|
retry: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const Route = createRootRouteWithContext<{
|
|||||||
fullWidth: boolean;
|
fullWidth: boolean;
|
||||||
}>()({
|
}>()({
|
||||||
head: () => ({
|
head: () => ({
|
||||||
|
title: "FLXN IX",
|
||||||
meta: [
|
meta: [
|
||||||
{
|
{
|
||||||
charSet: "utf-8",
|
charSet: "utf-8",
|
||||||
@@ -37,33 +38,49 @@ export const Route = createRootRouteWithContext<{
|
|||||||
{
|
{
|
||||||
name: "viewport",
|
name: "viewport",
|
||||||
content:
|
content:
|
||||||
"width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, interactive-widget=overlays-content",
|
"width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, interactive-widget=resizes-content",
|
||||||
},
|
},
|
||||||
|
{ property: 'og:title', content: 'FLXN IX' },
|
||||||
|
{ property: 'og:description', content: 'Register for FLXN IX and view FLXN stats' },
|
||||||
|
{ property: 'og:url', content: 'https://flexxon.app' },
|
||||||
|
{ property: 'og:type', content: 'website' },
|
||||||
|
{ property: 'og:site_name', content: 'FLXN IX' },
|
||||||
|
{ property: 'og:image', content: 'https://flexxon.app/favicon.png' },
|
||||||
],
|
],
|
||||||
links: [
|
links: [
|
||||||
{
|
{
|
||||||
rel: "apple-touch-icon",
|
rel: "apple-touch-icon",
|
||||||
sizes: "180x180",
|
sizes: "180x180",
|
||||||
href: "/apple-touch-icon.png",
|
href: "/favicon.png",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
rel: "icon",
|
rel: "icon",
|
||||||
type: "image/png",
|
type: "image/png",
|
||||||
sizes: "32x32",
|
sizes: "32x32",
|
||||||
href: "/favicon-32x32.png",
|
href: "/favicon.png",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
rel: "icon",
|
rel: "icon",
|
||||||
type: "image/png",
|
type: "image/png",
|
||||||
sizes: "16x16",
|
sizes: "16x16",
|
||||||
href: "/favicon-16x16.png",
|
href: "/favicon.png",
|
||||||
},
|
},
|
||||||
{ rel: "manifest", href: "/site.webmanifest" },
|
{ rel: "manifest", href: "/site.webmanifest" },
|
||||||
{ rel: "icon", href: "/favicon.ico" },
|
{ rel: "icon", href: "/favicon.ico" },
|
||||||
{ rel: 'stylesheet', href: mantineCssUrl },
|
{ rel: 'stylesheet', href: mantineCssUrl },
|
||||||
{ rel: 'stylesheet', href: mantineCarouselCssUrl },
|
{ rel: 'stylesheet', href: mantineCarouselCssUrl },
|
||||||
{ rel: 'stylesheet', href: mantineDatesCssUrl },
|
{ rel: 'stylesheet', href: mantineDatesCssUrl },
|
||||||
{ rel: 'stylesheet', href: mantineTiptapCssUrl }
|
{ rel: 'stylesheet', href: mantineTiptapCssUrl },
|
||||||
|
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
||||||
|
{
|
||||||
|
rel: "preconnect",
|
||||||
|
href: "https://fonts.gstatic.com",
|
||||||
|
crossOrigin: "anonymous",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rel: "stylesheet",
|
||||||
|
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=League+Spartan:wght@100..900&display=swap",
|
||||||
|
}
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
errorComponent: (props) => {
|
errorComponent: (props) => {
|
||||||
@@ -122,8 +139,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
|||||||
{...mantineHtmlProps}
|
{...mantineHtmlProps}
|
||||||
style={{
|
style={{
|
||||||
overflowX: "hidden",
|
overflowX: "hidden",
|
||||||
overflowY: "hidden",
|
height: "100%",
|
||||||
position: "fixed",
|
|
||||||
width: "100%",
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -135,9 +151,10 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
|||||||
<body
|
<body
|
||||||
style={{
|
style={{
|
||||||
overflowX: "hidden",
|
overflowX: "hidden",
|
||||||
overflowY: "hidden",
|
height: "100%",
|
||||||
position: "fixed",
|
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="app">{children}</div>
|
<div className="app">{children}</div>
|
||||||
|
|||||||
45
src/app/routes/_authed/admin/activities.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
||||||
|
import { ActivitiesTable, activityQueries } from "@/features/activities";
|
||||||
|
import { PlayersActivityTable, playerQueries } from "@/features/players";
|
||||||
|
import { Tabs } from "@mantine/core";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authed/admin/activities")({
|
||||||
|
component: Stats,
|
||||||
|
beforeLoad: ({ context }) => {
|
||||||
|
const queryClient = context.queryClient;
|
||||||
|
prefetchServerQuery(queryClient, activityQueries.search());
|
||||||
|
prefetchServerQuery(queryClient, playerQueries.activity());
|
||||||
|
},
|
||||||
|
loader: () => ({
|
||||||
|
withPadding: false,
|
||||||
|
fullWidth: true,
|
||||||
|
header: {
|
||||||
|
title: "Activities",
|
||||||
|
withBackButton: true,
|
||||||
|
},
|
||||||
|
refresh: [activityQueries.search().queryKey, playerQueries.activity().queryKey],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
function Stats() {
|
||||||
|
const [activeTab, setActiveTab] = useState<string | null>("server-functions");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs value={activeTab} onChange={setActiveTab}>
|
||||||
|
<Tabs.List mb='md'>
|
||||||
|
<Tabs.Tab value="server-functions">Server Functions</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="player-activity">Player Activity</Tabs.Tab>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<Tabs.Panel value="server-functions">
|
||||||
|
<ActivitiesTable />
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="player-activity">
|
||||||
|
<PlayersActivityTable />
|
||||||
|
</Tabs.Panel>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
src/app/routes/_authed/admin/badges.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import AwardBadges from "@/features/admin/components/award-badges";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authed/admin/badges")({
|
||||||
|
component: RouteComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
return <AwardBadges />;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { BracketData } from "@/features/bracket/types";
|
|||||||
import { Match } from "@/features/matches/types";
|
import { Match } from "@/features/matches/types";
|
||||||
import BracketView from "@/features/bracket/components/bracket-view";
|
import BracketView from "@/features/bracket/components/bracket-view";
|
||||||
import { SpotifyControlsBar } from "@/features/spotify/components";
|
import { SpotifyControlsBar } from "@/features/spotify/components";
|
||||||
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/admin/tournaments/run/$id")({
|
export const Route = createFileRoute("/_authed/admin/tournaments/run/$id")({
|
||||||
beforeLoad: async ({ context, params }) => {
|
beforeLoad: async ({ context, params }) => {
|
||||||
@@ -39,6 +40,8 @@ export const Route = createFileRoute("/_authed/admin/tournaments/run/$id")({
|
|||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { id } = Route.useParams();
|
const { id } = Route.useParams();
|
||||||
const { data: tournament } = useTournament(id);
|
const { data: tournament } = useTournament(id);
|
||||||
|
const { roles } = useAuth();
|
||||||
|
const isAdmin = roles?.includes('Admin') || false;
|
||||||
|
|
||||||
const bracket: BracketData = useMemo(() => {
|
const bracket: BracketData = useMemo(() => {
|
||||||
if (!tournament.matches || tournament.matches.length === 0) {
|
if (!tournament.matches || tournament.matches.length === 0) {
|
||||||
@@ -76,7 +79,7 @@ function RouteComponent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="md" px={0}>
|
<Container size="md" px={0}>
|
||||||
<SpotifyControlsBar />
|
{ isAdmin && <SpotifyControlsBar />}
|
||||||
{tournament.matches?.length ? (
|
{tournament.matches?.length ? (
|
||||||
<BracketView bracket={bracket} showControls />
|
<BracketView bracket={bracket} showControls />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { badgeKeys, badgeQueries } from "@/features/badges/queries";
|
||||||
import Profile from "@/features/players/components/profile";
|
import Profile from "@/features/players/components/profile";
|
||||||
import HeaderSkeleton from "@/features/players/components/profile/header-skeleton";
|
import HeaderSkeleton from "@/features/players/components/profile/header-skeleton";
|
||||||
import ProfileSkeleton from "@/features/players/components/profile/skeleton";
|
import ProfileSkeleton from "@/features/players/components/profile/skeleton";
|
||||||
@@ -24,6 +25,14 @@ export const Route = createFileRoute("/_authed/profile/$playerId")({
|
|||||||
queryClient,
|
queryClient,
|
||||||
playerQueries.matches(params.playerId)
|
playerQueries.matches(params.playerId)
|
||||||
),
|
),
|
||||||
|
prefetchServerQuery(
|
||||||
|
queryClient,
|
||||||
|
playerQueries.stats(params.playerId)
|
||||||
|
),
|
||||||
|
prefetchServerQuery(
|
||||||
|
queryClient,
|
||||||
|
badgeQueries.playerBadges(params.playerId)
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
loader: ({ params, context }) => ({
|
loader: ({ params, context }) => ({
|
||||||
@@ -34,7 +43,7 @@ export const Route = createFileRoute("/_authed/profile/$playerId")({
|
|||||||
context?.auth.user.id === params.playerId ? "/settings" : undefined,
|
context?.auth.user.id === params.playerId ? "/settings" : undefined,
|
||||||
},
|
},
|
||||||
withPadding: false,
|
withPadding: false,
|
||||||
refresh: [playerKeys.details(params.playerId), playerKeys.matches(params.playerId), playerKeys.stats(params.playerId)],
|
refresh: [playerKeys.details(params.playerId), playerKeys.matches(params.playerId), playerKeys.stats(params.playerId), badgeKeys.playerBadges(params.playerId)],
|
||||||
}),
|
}),
|
||||||
component: () => {
|
component: () => {
|
||||||
const { playerId } = Route.useParams();
|
const { playerId } = Route.useParams();
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { useMemo } from "react";
|
|||||||
import { BracketData } from "@/features/bracket/types";
|
import { BracketData } from "@/features/bracket/types";
|
||||||
import { Match } from "@/features/matches/types";
|
import { Match } from "@/features/matches/types";
|
||||||
import BracketView from "@/features/bracket/components/bracket-view";
|
import BracketView from "@/features/bracket/components/bracket-view";
|
||||||
import { SpotifyControlsBar } from "@/features/spotify/components";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({
|
export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({
|
||||||
beforeLoad: async ({ context, params }) => {
|
beforeLoad: async ({ context, params }) => {
|
||||||
|
|||||||
@@ -3,11 +3,16 @@ import { serverEvents, type ServerEvent } from "@/lib/events/emitter";
|
|||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||||
|
|
||||||
|
let activeConnections = 0;
|
||||||
|
|
||||||
export const Route = createFileRoute("/api/events/$")({
|
export const Route = createFileRoute("/api/events/$")({
|
||||||
server: {
|
server: {
|
||||||
middleware: [superTokensRequestMiddleware],
|
middleware: [superTokensRequestMiddleware],
|
||||||
handlers: {
|
handlers: {
|
||||||
GET: ({ request, context }) => {
|
GET: ({ request }) => {
|
||||||
|
activeConnections++;
|
||||||
|
const connectionId = `conn_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
||||||
|
logger.info(`ServerEvents | New connection ${connectionId}. Active: ${activeConnections}`);
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
const connectMessage = `data: ${JSON.stringify({ type: "connected" })}\n\n`;
|
const connectMessage = `data: ${JSON.stringify({ type: "connected" })}\n\n`;
|
||||||
@@ -17,6 +22,10 @@ export const Route = createFileRoute("/api/events/$")({
|
|||||||
logger.info("ServerEvents | Event received", event);
|
logger.info("ServerEvents | Event received", event);
|
||||||
const message = `data: ${JSON.stringify(event)}\n\n`;
|
const message = `data: ${JSON.stringify(event)}\n\n`;
|
||||||
try {
|
try {
|
||||||
|
if (!controller.desiredSize || controller.desiredSize <= 0) {
|
||||||
|
logger.warn("ServerEvents | Stream closed, skipping event");
|
||||||
|
return;
|
||||||
|
}
|
||||||
controller.enqueue(new TextEncoder().encode(message));
|
controller.enqueue(new TextEncoder().encode(message));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("ServerEvents | Error sending SSE message", error);
|
logger.error("ServerEvents | Error sending SSE message", error);
|
||||||
@@ -29,16 +38,34 @@ export const Route = createFileRoute("/api/events/$")({
|
|||||||
|
|
||||||
const pingInterval = setInterval(() => {
|
const pingInterval = setInterval(() => {
|
||||||
try {
|
try {
|
||||||
const pingMessage = `data: ${JSON.stringify({ type: "ping" })}\n\n`;
|
if (!controller.desiredSize || controller.desiredSize <= 0) {
|
||||||
|
clearInterval(pingInterval);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pingMessage = `data: ${JSON.stringify({ type: "ping", timestamp: Date.now() })}\n\n`;
|
||||||
controller.enqueue(new TextEncoder().encode(pingMessage));
|
controller.enqueue(new TextEncoder().encode(pingMessage));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
logger.error("ServerEvents | Ping interval error", e);
|
||||||
clearInterval(pingInterval);
|
clearInterval(pingInterval);
|
||||||
}
|
}
|
||||||
}, 30000);
|
}, 15000);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const heartbeatMessage = `data: ${JSON.stringify({ type: "heartbeat", timestamp: Date.now() })}\n\n`;
|
||||||
|
controller.enqueue(new TextEncoder().encode(heartbeatMessage));
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("ServerEvents | Heartbeat error", e);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
|
activeConnections--;
|
||||||
serverEvents.off("test", handleEvent);
|
serverEvents.off("test", handleEvent);
|
||||||
|
serverEvents.off("match", handleEvent);
|
||||||
|
serverEvents.off("reaction", handleEvent);
|
||||||
clearInterval(pingInterval);
|
clearInterval(pingInterval);
|
||||||
|
logger.info(`ServerEvents | Connection ${connectionId} cleanup completed. Active: ${activeConnections}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
request.signal?.addEventListener("abort", cleanup);
|
request.signal?.addEventListener("abort", cleanup);
|
||||||
@@ -49,10 +76,14 @@ export const Route = createFileRoute("/api/events/$")({
|
|||||||
return new Response(stream, {
|
return new Response(stream, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "text/event-stream",
|
"Content-Type": "text/event-stream",
|
||||||
"Cache-Control": "no-cache",
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||||
Connection: "keep-alive",
|
"Connection": "keep-alive",
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
"Access-Control-Allow-Headers": "Cache-Control",
|
"Access-Control-Allow-Headers": "Cache-Control",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
"X-Proxy-Buffering": "no",
|
||||||
|
"Proxy-Buffering": "off",
|
||||||
|
"Transfer-Encoding": "chunked",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,13 +13,6 @@ export const Route = createFileRoute(
|
|||||||
process.env.POCKETBASE_URL || "http://127.0.0.1:8090";
|
process.env.POCKETBASE_URL || "http://127.0.0.1:8090";
|
||||||
const fileUrl = `${pocketbaseUrl}/api/files/${collection}/${recordId}/${file}`;
|
const fileUrl = `${pocketbaseUrl}/api/files/${collection}/${recordId}/${file}`;
|
||||||
|
|
||||||
logger.info("File proxy", {
|
|
||||||
collection,
|
|
||||||
recordId,
|
|
||||||
file,
|
|
||||||
targetUrl: fileUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(fileUrl, {
|
const response = await fetch(fileUrl, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -81,12 +74,6 @@ export const Route = createFileRoute(
|
|||||||
"Range, If-None-Match, If-Modified-Since"
|
"Range, If-None-Match, If-Modified-Since"
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info("File proxy response", {
|
|
||||||
status: response.status,
|
|
||||||
contentType: response.headers.get("content-type"),
|
|
||||||
contentLength: response.headers.get("content-length"),
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(body, {
|
return new Response(body, {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
statusText: response.statusText,
|
statusText: response.statusText,
|
||||||
|
|||||||
@@ -107,10 +107,9 @@ export const Route = createFileRoute("/api/teams/upload-logo")({
|
|||||||
const pbFormData = new FormData();
|
const pbFormData = new FormData();
|
||||||
pbFormData.append("logo", logoFile);
|
pbFormData.append("logo", logoFile);
|
||||||
|
|
||||||
const updatedTeam = await pbAdmin.updateTeam(
|
await pbAdmin.updateTeam(teamId, pbFormData as any);
|
||||||
teamId,
|
const updatedTeam = await pbAdmin.getTeam(teamId);
|
||||||
pbFormData as any
|
if (!updatedTeam) throw new Error("Failed to fetch updated team");
|
||||||
);
|
|
||||||
|
|
||||||
logger.info("Team logo uploaded successfully", {
|
logger.info("Team logo uploaded successfully", {
|
||||||
teamId,
|
teamId,
|
||||||
|
|||||||
@@ -10,5 +10,4 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
|
|||||||
return <MantineButton fullWidth ref={ref} {...props} />;
|
return <MantineButton fullWidth ref={ref} {...props} />;
|
||||||
});
|
});
|
||||||
|
|
||||||
Button.displayName = "Button";
|
|
||||||
export default Button;
|
export default Button;
|
||||||
|
|||||||
179
src/components/glitch-avatar.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { Paper, Box } from "@mantine/core";
|
||||||
|
import {
|
||||||
|
Avatar as MantineAvatar,
|
||||||
|
AvatarProps as MantineAvatarProps,
|
||||||
|
} from "@mantine/core";
|
||||||
|
|
||||||
|
interface GlitchAvatarProps
|
||||||
|
extends Omit<MantineAvatarProps, "radius" | "color" | "size"> {
|
||||||
|
name: string;
|
||||||
|
src?: string;
|
||||||
|
glitchSrc?: string;
|
||||||
|
size?: number;
|
||||||
|
radius?: string | number;
|
||||||
|
withBorder?: boolean;
|
||||||
|
contain?: boolean;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
px?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GlitchAvatar = ({
|
||||||
|
name,
|
||||||
|
src,
|
||||||
|
glitchSrc,
|
||||||
|
size = 35,
|
||||||
|
radius = "100%",
|
||||||
|
withBorder = true,
|
||||||
|
contain = false,
|
||||||
|
children,
|
||||||
|
px,
|
||||||
|
...props
|
||||||
|
}: GlitchAvatarProps) => {
|
||||||
|
const [showGlitch, setShowGlitch] = useState(false);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!glitchSrc) return;
|
||||||
|
|
||||||
|
const scheduleNextGlitch = () => {
|
||||||
|
const delay = Math.random() * 10000 + 5000;
|
||||||
|
return setTimeout(() => {
|
||||||
|
setShowGlitch(true);
|
||||||
|
setIsPlaying(true);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowGlitch(false);
|
||||||
|
setIsPlaying(false);
|
||||||
|
scheduleNextGlitch();
|
||||||
|
}, 4000);
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeoutId = scheduleNextGlitch();
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [glitchSrc]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
const handleEnded = () => {
|
||||||
|
setShowGlitch(false);
|
||||||
|
setIsPlaying(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
video.addEventListener("ended", handleEnded);
|
||||||
|
return () => video.removeEventListener("ended", handleEnded);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
video.load();
|
||||||
|
}, [glitchSrc]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video || !showGlitch || !isPlaying) return;
|
||||||
|
|
||||||
|
video.currentTime = 0;
|
||||||
|
video.play().catch((err) => {
|
||||||
|
console.error("Failed to play glitch", err);
|
||||||
|
});
|
||||||
|
}, [showGlitch, isPlaying]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
padding: "8px",
|
||||||
|
borderRadius:
|
||||||
|
typeof radius === "number"
|
||||||
|
? `${radius + 8}px`
|
||||||
|
: "calc(var(--mantine-radius-md) + 8px)",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
opacity: showGlitch ? 0 : 1,
|
||||||
|
transition: "opacity 0.05s ease-in-out",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
py={size / 12.5}
|
||||||
|
px={size / 20}
|
||||||
|
bg="var(--mantine-color-default-border)"
|
||||||
|
radius={radius}
|
||||||
|
withBorder={false}
|
||||||
|
style={{
|
||||||
|
cursor: "default",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MantineAvatar
|
||||||
|
alt={name}
|
||||||
|
key={name}
|
||||||
|
name={name}
|
||||||
|
color="initials"
|
||||||
|
size={size}
|
||||||
|
radius={radius}
|
||||||
|
w={size}
|
||||||
|
styles={{
|
||||||
|
image: {
|
||||||
|
objectFit: contain ? "contain" : "cover",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
src={src}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</MantineAvatar>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{glitchSrc && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "8px",
|
||||||
|
left: "8px",
|
||||||
|
opacity: showGlitch ? 1 : 0,
|
||||||
|
visibility: showGlitch ? "visible" : "hidden",
|
||||||
|
transition: "opacity 0.05s ease-in-out",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
py={size / 12.5}
|
||||||
|
px={size / 20}
|
||||||
|
bg="var(--mantine-color-default-border)"
|
||||||
|
radius={radius}
|
||||||
|
withBorder={false}
|
||||||
|
style={{
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={glitchSrc}
|
||||||
|
style={{
|
||||||
|
width: `${size}px`,
|
||||||
|
height: `${size}px`,
|
||||||
|
objectFit: contain ? "contain" : "cover",
|
||||||
|
borderRadius: typeof radius === "number" ? `${radius}px` : radius,
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
preload="auto"
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GlitchAvatar;
|
||||||
@@ -1,22 +1,33 @@
|
|||||||
import { Divider, Group, Text, UnstyledButton } from "@mantine/core";
|
import { Divider, Group, Loader, Text, UnstyledButton } from "@mantine/core";
|
||||||
import { CaretRightIcon, Icon } from "@phosphor-icons/react";
|
import { CaretRightIcon, Icon } from "@phosphor-icons/react";
|
||||||
|
|
||||||
interface ListButtonProps {
|
interface ListButtonProps {
|
||||||
label: string;
|
label: string;
|
||||||
Icon: Icon;
|
Icon: Icon;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ListButton = ({ label, onClick, Icon }: ListButtonProps) => {
|
const ListButton = ({ label, onClick, Icon, loading }: ListButtonProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<UnstyledButton w="100%" p="md" component={"button"} onClick={onClick}>
|
<UnstyledButton
|
||||||
|
w="100%"
|
||||||
|
p="md"
|
||||||
|
component={"button"}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
<Group>
|
<Group>
|
||||||
<Icon weight="bold" size={20} />
|
<Icon weight="bold" size={20} />
|
||||||
<Text fw={500} size="md">
|
<Text fw={500} size="md">
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
<CaretRightIcon style={{ marginLeft: "auto" }} size={20} />
|
{loading ? (
|
||||||
|
<Loader size="sm" style={{ marginLeft: "auto" }} />
|
||||||
|
) : (
|
||||||
|
<CaretRightIcon style={{ marginLeft: "auto" }} size={20} />
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Box, Container, Flex, Loader, useComputedColorScheme } from "@mantine/core";
|
import { Box, Container, Flex, Loader, Title, useComputedColorScheme } from "@mantine/core";
|
||||||
import { PropsWithChildren, Suspense, useEffect, useRef } from "react";
|
import { PropsWithChildren, Suspense, useEffect, useRef } from "react";
|
||||||
import { Drawer as VaulDrawer } from "vaul";
|
import { Drawer as VaulDrawer } from "vaul";
|
||||||
import styles from "./styles.module.css";
|
import styles from "./styles.module.css";
|
||||||
@@ -120,7 +120,7 @@ const Drawer: React.FC<DrawerProps> = ({
|
|||||||
style={{ borderRadius: "9999px" }}
|
style={{ borderRadius: "9999px" }}
|
||||||
/>
|
/>
|
||||||
<Container mx="auto" maw="28rem" px={0}>
|
<Container mx="auto" maw="28rem" px={0}>
|
||||||
<VaulDrawer.Title>{title}</VaulDrawer.Title>
|
<VaulDrawer.Title><Title order={2}>{title}</Title></VaulDrawer.Title>
|
||||||
<Suspense fallback={
|
<Suspense fallback={
|
||||||
<Flex justify='center' align='center' w='100%' h={400}>
|
<Flex justify='center' align='center' w='100%' h={400}>
|
||||||
<Loader size='lg' />
|
<Loader size='lg' />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ActionIcon,
|
ActionIcon,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
Divider,
|
Divider,
|
||||||
|
Stack,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { ArrowLeftIcon, CheckIcon } from "@phosphor-icons/react";
|
import { ArrowLeftIcon, CheckIcon } from "@phosphor-icons/react";
|
||||||
import { useState, ReactNode } from "react";
|
import { useState, ReactNode } from "react";
|
||||||
@@ -69,6 +70,7 @@ const SlidePanel = ({
|
|||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
@@ -167,11 +169,17 @@ const SlidePanel = ({
|
|||||||
bg="var(--mantine-color-dimmed)"
|
bg="var(--mantine-color-dimmed)"
|
||||||
my="xs"
|
my="xs"
|
||||||
/>
|
/>
|
||||||
<panelConfig.Component
|
<ScrollArea.Autosize w="100%" p={0} offsetScrollbars>
|
||||||
value={tempValue}
|
<panelConfig.Component
|
||||||
onChange={setTempValue}
|
value={tempValue}
|
||||||
{...(panelConfig.componentProps || {})}
|
onChange={setTempValue}
|
||||||
/>
|
{...(panelConfig.componentProps || {})}
|
||||||
|
/>
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
<Stack mt="auto" w="100%" gap={2}>
|
||||||
|
<Button mt="md" onClick={handleConfirm}>Confirm</Button>
|
||||||
|
<Button variant="subtle" onClick={closePanel} mt="sm" color="red">Cancel</Button>
|
||||||
|
</Stack>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -101,10 +101,10 @@ const StatsOverview = ({ statsData, isLoading = false }: StatsOverviewProps) =>
|
|||||||
{ label: "Losses", value: overallStats.losses, Icon: XIcon },
|
{ label: "Losses", value: overallStats.losses, Icon: XIcon },
|
||||||
{ label: "Cups Made", value: overallStats.total_cups_made, Icon: FireIcon },
|
{ label: "Cups Made", value: overallStats.total_cups_made, Icon: FireIcon },
|
||||||
{ label: "Cups Against", value: overallStats.total_cups_against, Icon: ShieldIcon },
|
{ label: "Cups Against", value: overallStats.total_cups_against, Icon: ShieldIcon },
|
||||||
{ label: "Avg Cups Per Game", value: avgCupsPerMatch > 0 ? avgCupsPerMatch : null, Icon: ChartLineUpIcon },
|
{ label: "Avg Cups Per Game", value: avgCupsPerMatch >= 0 ? avgCupsPerMatch : null, Icon: ChartLineUpIcon },
|
||||||
{ label: "Avg Cups Against", value: avgCupsAgainstPerMatch > 0 ? avgCupsAgainstPerMatch : null, Icon: ShieldCheckIcon },
|
{ label: "Avg Cups Against", value: avgCupsAgainstPerMatch >= 0 ? avgCupsAgainstPerMatch : null, Icon: ShieldCheckIcon },
|
||||||
{ label: "Avg Win Margin", value: avgMarginOfVictory > 0 ? avgMarginOfVictory : null, Icon: ArrowUpIcon },
|
{ label: "Avg Win Margin", value: avgMarginOfVictory >= 0 ? avgMarginOfVictory : null, Icon: ArrowUpIcon },
|
||||||
{ label: "Avg Loss Margin", value: avgMarginOfLoss > 0 ? avgMarginOfLoss : null, Icon: ArrowDownIcon },
|
{ label: "Avg Loss Margin", value: avgMarginOfLoss >= 0 ? avgMarginOfLoss : null, Icon: ArrowDownIcon },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface TypeaheadProps<T> {
|
|||||||
debounceMs?: number;
|
debounceMs?: number;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
initialValue?: string;
|
initialValue?: string;
|
||||||
|
maxHeight?: number | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Typeahead = <T,>({
|
const Typeahead = <T,>({
|
||||||
@@ -26,7 +27,8 @@ const Typeahead = <T,>({
|
|||||||
placeholder = "Search...",
|
placeholder = "Search...",
|
||||||
debounceMs = 300,
|
debounceMs = 300,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
initialValue = ""
|
initialValue = "",
|
||||||
|
maxHeight = 200,
|
||||||
}: TypeaheadProps<T>) => {
|
}: TypeaheadProps<T>) => {
|
||||||
const [searchQuery, setSearchQuery] = useState(initialValue);
|
const [searchQuery, setSearchQuery] = useState(initialValue);
|
||||||
const [searchResults, setSearchResults] = useState<TypeaheadOption<T>[]>([]);
|
const [searchResults, setSearchResults] = useState<TypeaheadOption<T>[]>([]);
|
||||||
@@ -36,13 +38,7 @@ const Typeahead = <T,>({
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const debouncedSearch = useDebouncedCallback(async (query: string) => {
|
const performSearch = async (query: string) => {
|
||||||
if (!query.trim()) {
|
|
||||||
setSearchResults([]);
|
|
||||||
setIsOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const results = await searchFn(query);
|
const results = await searchFn(query);
|
||||||
@@ -56,7 +52,9 @@ const Typeahead = <T,>({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, debounceMs);
|
};
|
||||||
|
|
||||||
|
const debouncedSearch = useDebouncedCallback(performSearch, debounceMs);
|
||||||
|
|
||||||
const handleSearchChange = (value: string) => {
|
const handleSearchChange = (value: string) => {
|
||||||
setSearchQuery(value);
|
setSearchQuery(value);
|
||||||
@@ -114,8 +112,12 @@ const Typeahead = <T,>({
|
|||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(event) => handleSearchChange(event.currentTarget.value)}
|
onChange={(event) => handleSearchChange(event.currentTarget.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onFocus={() => {
|
onFocus={async () => {
|
||||||
if (searchResults.length > 0) setIsOpen(true);
|
if (searchResults.length > 0) {
|
||||||
|
setIsOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await performSearch(searchQuery);
|
||||||
}}
|
}}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
rightSection={isLoading ? <Loader size="xs" /> : null}
|
rightSection={isLoading ? <Loader size="xs" /> : null}
|
||||||
@@ -133,7 +135,7 @@ const Typeahead = <T,>({
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
maxHeight: '160px',
|
maxHeight,
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
WebkitOverflowScrolling: 'touch',
|
WebkitOverflowScrolling: 'touch',
|
||||||
touchAction: 'pan-y',
|
touchAction: 'pan-y',
|
||||||
|
|||||||
382
src/features/activities/components/activities-table.tsx
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
import { useState, useMemo, memo } from "react";
|
||||||
|
import {
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Stack,
|
||||||
|
Group,
|
||||||
|
Box,
|
||||||
|
Container,
|
||||||
|
Divider,
|
||||||
|
UnstyledButton,
|
||||||
|
Select,
|
||||||
|
Pagination,
|
||||||
|
Code,
|
||||||
|
Alert,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
MagnifyingGlassIcon,
|
||||||
|
CaretUpIcon,
|
||||||
|
CaretDownIcon,
|
||||||
|
CheckIcon,
|
||||||
|
XIcon,
|
||||||
|
ChecksIcon,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
|
import { Activity, ActivitySearchParams } from "../types";
|
||||||
|
import { useActivities } from "../queries";
|
||||||
|
import Sheet from "@/components/sheet/sheet";
|
||||||
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
|
|
||||||
|
interface ActivityListItemProps {
|
||||||
|
activity: Activity;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ActivityListItem = memo(({ activity, onClick }: ActivityListItemProps) => {
|
||||||
|
const playerName = typeof activity.player === "object" && activity.player
|
||||||
|
? `${activity.player.first_name} ${activity.player.last_name}`
|
||||||
|
: "System";
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UnstyledButton
|
||||||
|
w="100%"
|
||||||
|
p="md"
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
borderRadius: 0,
|
||||||
|
transition: "background-color 0.15s ease",
|
||||||
|
}}
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'var(--mantine-color-gray-0)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="flex-start" w="100%">
|
||||||
|
<Stack gap={4} flex={1}>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{activity.name}
|
||||||
|
</Text>
|
||||||
|
{activity.success ? (
|
||||||
|
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
|
||||||
|
) : (
|
||||||
|
<XIcon size={16} color="var(--mantine-color-red-6)" />
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Group gap="md">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{playerName}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{activity.duration}ms
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{formatDate(activity.created)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
{activity.error && (
|
||||||
|
<Text size="xs" c="red" lineClamp={1}>
|
||||||
|
{activity.error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ActivityDetailsSheetProps {
|
||||||
|
activity: Activity | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ActivityDetailsSheet = memo(({ activity, isOpen, onClose }: ActivityDetailsSheetProps) => {
|
||||||
|
if (!activity) return null;
|
||||||
|
|
||||||
|
const playerName = typeof activity.player === "object" && activity.player
|
||||||
|
? `${activity.player.first_name} ${activity.player.last_name}`
|
||||||
|
: "System";
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet title="Activity Details" opened={isOpen} onChange={onClose}>
|
||||||
|
<Stack gap="md" p="md">
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs" fw={700} c="dimmed">
|
||||||
|
Function Name
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">{activity.name}</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs" fw={700} c="dimmed">
|
||||||
|
Status
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
{activity.success ? (
|
||||||
|
<>
|
||||||
|
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
|
||||||
|
<Text size="sm" c="green">
|
||||||
|
Success
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<XIcon size={16} color="var(--mantine-color-red-6)" />
|
||||||
|
<Text size="sm" c="red">
|
||||||
|
Failed
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs" fw={700} c="dimmed">
|
||||||
|
Player
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">{playerName}</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs" fw={700} c="dimmed">
|
||||||
|
Duration
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">{activity.duration}ms</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs" fw={700} c="dimmed">
|
||||||
|
Created
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">{formatDate(activity.created)}</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{activity.user_agent && (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs" fw={700} c="dimmed">
|
||||||
|
User Agent
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" style={{ wordBreak: "break-word" }}>
|
||||||
|
{activity.user_agent}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activity.error && (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs" fw={700} c="dimmed">
|
||||||
|
Error Message
|
||||||
|
</Text>
|
||||||
|
<Alert color="red" variant="light">
|
||||||
|
<Text size="sm" style={{ wordBreak: "break-word" }}>
|
||||||
|
{activity.error}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activity.arguments && (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs" fw={700} c="dimmed">
|
||||||
|
Arguments
|
||||||
|
</Text>
|
||||||
|
<Code block style={{ fontSize: "11px" }}>
|
||||||
|
{JSON.stringify(activity.arguments, null, 2)}
|
||||||
|
</Code>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ActivitiesResults = ({ searchParams, page, setPage, onActivityClick }: any) => {
|
||||||
|
const { data: result } = useActivities(searchParams);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack gap={0}>
|
||||||
|
{result.items.map((activity: Activity, index: number) => (
|
||||||
|
<Box key={activity.id}>
|
||||||
|
<ActivityListItem
|
||||||
|
activity={activity}
|
||||||
|
onClick={() => onActivityClick(activity)}
|
||||||
|
/>
|
||||||
|
{index < result.items.length - 1 && <Divider />}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{result.items.length === 0 && (
|
||||||
|
<Text ta="center" c="dimmed" py="xl">
|
||||||
|
No activities found
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result.totalPages > 1 && (
|
||||||
|
<Group justify="center" py="md">
|
||||||
|
<Pagination
|
||||||
|
total={result.totalPages}
|
||||||
|
value={page}
|
||||||
|
onChange={setPage}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ActivitiesTable = () => {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [successFilter, setSuccessFilter] = useState<string | null>(null);
|
||||||
|
const [sortBy, setSortBy] = useState("-created");
|
||||||
|
const [selectedActivity, setSelectedActivity] = useState<Activity | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isOpen: detailsOpened,
|
||||||
|
open: openDetails,
|
||||||
|
close: closeDetails,
|
||||||
|
} = useSheet();
|
||||||
|
|
||||||
|
const searchParams: ActivitySearchParams = useMemo(
|
||||||
|
() => ({
|
||||||
|
page,
|
||||||
|
perPage: 100,
|
||||||
|
name: search || undefined,
|
||||||
|
success: successFilter === "success" ? true : successFilter === "failure" ? false : undefined,
|
||||||
|
sortBy,
|
||||||
|
}),
|
||||||
|
[page, search, successFilter, sortBy]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: result } = useActivities(searchParams);
|
||||||
|
|
||||||
|
const handleSort = (field: string) => {
|
||||||
|
setSortBy((prev) => {
|
||||||
|
if (prev === field) return `-${field}`;
|
||||||
|
if (prev === `-${field}`) return field;
|
||||||
|
return `-${field}`;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSortIcon = (field: string) => {
|
||||||
|
if (sortBy === field) return <CaretUpIcon size={14} />;
|
||||||
|
if (sortBy === `-${field}`) return <CaretDownIcon size={14} />;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleActivityClick = (activity: Activity) => {
|
||||||
|
setSelectedActivity(activity);
|
||||||
|
openDetails();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseDetails = () => {
|
||||||
|
setSelectedActivity(null);
|
||||||
|
closeDetails();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="100%" px={0}>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Stack gap="xs" px="md">
|
||||||
|
<TextInput
|
||||||
|
placeholder="serverFn name"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.currentTarget.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
leftSection={<MagnifyingGlassIcon size={16} />}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group>
|
||||||
|
<Select
|
||||||
|
placeholder="Status"
|
||||||
|
value={successFilter}
|
||||||
|
onChange={(value) => {
|
||||||
|
setSuccessFilter(value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
data={[
|
||||||
|
{ value: "all", label: "All" },
|
||||||
|
{ value: "success", label: "Success" },
|
||||||
|
{ value: "failure", label: "Failure" },
|
||||||
|
]}
|
||||||
|
clearable
|
||||||
|
size="sm"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Group px="md" justify="space-between" align="center">
|
||||||
|
<Text size="10px" lh={0} c="dimmed">
|
||||||
|
{result.totalItems} total activities
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Sort:
|
||||||
|
</Text>
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={() => handleSort("created")}
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
fw={sortBy.includes("created") ? 600 : 400}
|
||||||
|
c={sortBy.includes("created") ? "dark" : "dimmed"}
|
||||||
|
>
|
||||||
|
Date
|
||||||
|
</Text>
|
||||||
|
{getSortIcon("created")}
|
||||||
|
</UnstyledButton>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
•
|
||||||
|
</Text>
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={() => handleSort("duration")}
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
fw={sortBy.includes("duration") ? 600 : 400}
|
||||||
|
c={sortBy.includes("duration") ? "dark" : "dimmed"}
|
||||||
|
>
|
||||||
|
Duration
|
||||||
|
</Text>
|
||||||
|
{getSortIcon("duration")}
|
||||||
|
</UnstyledButton>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<ActivitiesResults
|
||||||
|
searchParams={searchParams}
|
||||||
|
page={page}
|
||||||
|
setPage={setPage}
|
||||||
|
onActivityClick={handleActivityClick}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<ActivityDetailsSheet
|
||||||
|
activity={selectedActivity}
|
||||||
|
isOpen={detailsOpened}
|
||||||
|
onClose={handleCloseDetails}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
3
src/features/activities/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./types";
|
||||||
|
export * from "./queries";
|
||||||
|
export { ActivitiesTable } from "./components/activities-table";
|
||||||
17
src/features/activities/queries.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
||||||
|
import { searchActivities } from "./server";
|
||||||
|
import { ActivitySearchParams } from "./types";
|
||||||
|
|
||||||
|
export const activityKeys = {
|
||||||
|
search: (params: ActivitySearchParams) => ['activities', 'search', params] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const activityQueries = {
|
||||||
|
search: (params: ActivitySearchParams = {}) => ({
|
||||||
|
queryKey: activityKeys.search(params),
|
||||||
|
queryFn: () => searchActivities({ data: params }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useActivities = (params: ActivitySearchParams = {}) =>
|
||||||
|
useServerSuspenseQuery(activityQueries.search(params));
|
||||||
29
src/features/activities/server.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { superTokensAdminFunctionMiddleware } from "@/utils/supertokens";
|
||||||
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||||
|
import { transformActivity } from "@/lib/pocketbase/util/transform-types";
|
||||||
|
import { Activity, ActivityListResult, ActivitySearchParams } from "./types";
|
||||||
|
|
||||||
|
const activitySearchParamsSchema = z.object({
|
||||||
|
page: z.number().optional(),
|
||||||
|
perPage: z.number().optional(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
player: z.string().optional(),
|
||||||
|
success: z.boolean().optional(),
|
||||||
|
sortBy: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const searchActivities = createServerFn()
|
||||||
|
.inputValidator(activitySearchParamsSchema)
|
||||||
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
|
.handler(async ({ data }) =>
|
||||||
|
toServerResult<ActivityListResult>(async () => {
|
||||||
|
const result = await pbAdmin.searchActivities(data);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
items: result.items.map(transformActivity),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
1
src/features/activities/types.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export type { Activity, ActivityListResult, ActivitySearchParams } from "@/lib/pocketbase/services/activities";
|
||||||
@@ -4,10 +4,25 @@ import {
|
|||||||
DatabaseIcon,
|
DatabaseIcon,
|
||||||
TreeStructureIcon,
|
TreeStructureIcon,
|
||||||
TrophyIcon,
|
TrophyIcon,
|
||||||
|
MedalIcon,
|
||||||
|
CrownIcon,
|
||||||
|
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 { useState } from "react";
|
||||||
|
|
||||||
const AdminPage = () => {
|
const AdminPage = () => {
|
||||||
|
const [isMigrating, setIsMigrating] = useState(false);
|
||||||
|
|
||||||
|
const handleMigrateBadges = async () => {
|
||||||
|
if (isMigrating) return;
|
||||||
|
|
||||||
|
setIsMigrating(true);
|
||||||
|
await migrateBadgeProgress();
|
||||||
|
setIsMigrating(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List p="0">
|
<List p="0">
|
||||||
<ListLink
|
<ListLink
|
||||||
@@ -15,6 +30,22 @@ const AdminPage = () => {
|
|||||||
Icon={TrophyIcon}
|
Icon={TrophyIcon}
|
||||||
to="/admin/tournaments"
|
to="/admin/tournaments"
|
||||||
/>
|
/>
|
||||||
|
<ListLink
|
||||||
|
label="Award Badges"
|
||||||
|
Icon={CrownIcon}
|
||||||
|
to="/admin/badges"
|
||||||
|
/>
|
||||||
|
<ListButton
|
||||||
|
label="Migrate Badge Progress"
|
||||||
|
Icon={MedalIcon}
|
||||||
|
onClick={handleMigrateBadges}
|
||||||
|
loading={isMigrating}
|
||||||
|
/>
|
||||||
|
<ListLink
|
||||||
|
label="Activities"
|
||||||
|
Icon={ListIcon}
|
||||||
|
to="/admin/activities"
|
||||||
|
/>
|
||||||
<ListButton
|
<ListButton
|
||||||
label="Open Pocketbase"
|
label="Open Pocketbase"
|
||||||
Icon={DatabaseIcon}
|
Icon={DatabaseIcon}
|
||||||
|
|||||||
114
src/features/admin/components/award-badges.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Box, Card, Text, Select, Button, Group, Stack, Badge, Divider } from "@mantine/core";
|
||||||
|
import { awardManualBadge } from "@/features/badges/server";
|
||||||
|
import { useAllBadges } from "@/features/badges/queries";
|
||||||
|
import toast from "@/lib/sonner";
|
||||||
|
import { usePlayers } from "@/features/players/queries";
|
||||||
|
|
||||||
|
const AwardBadges = () => {
|
||||||
|
const { data: players } = usePlayers();
|
||||||
|
const { data: allBadges } = useAllBadges();
|
||||||
|
|
||||||
|
const [selectedPlayerId, setSelectedPlayerId] = useState<string | null>(null);
|
||||||
|
const [selectedBadgeId, setSelectedBadgeId] = useState<string | null>(null);
|
||||||
|
const [isAwarding, setIsAwarding] = useState(false);
|
||||||
|
|
||||||
|
const manualBadges = allBadges.filter((badge) => badge.type === "manual");
|
||||||
|
|
||||||
|
const handleAwardBadge = async () => {
|
||||||
|
if (!selectedPlayerId || !selectedBadgeId) return;
|
||||||
|
|
||||||
|
setIsAwarding(true);
|
||||||
|
try {
|
||||||
|
await awardManualBadge({
|
||||||
|
data: {
|
||||||
|
playerId: selectedPlayerId,
|
||||||
|
badgeId: selectedBadgeId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedPlayer = players.find((p) => p.id === selectedPlayerId);
|
||||||
|
const playerName = selectedPlayer
|
||||||
|
? `${selectedPlayer.first_name} ${selectedPlayer.last_name}`
|
||||||
|
: "Player";
|
||||||
|
|
||||||
|
toast.success(`Badge awarded to ${playerName}`);
|
||||||
|
|
||||||
|
setSelectedPlayerId(null);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to award badge");
|
||||||
|
} finally {
|
||||||
|
setIsAwarding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const playerOptions = players.map((player) => ({
|
||||||
|
value: player.id,
|
||||||
|
label: `${player.first_name} ${player.last_name}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const badgeOptions = manualBadges.map((badge) => ({
|
||||||
|
value: badge.id,
|
||||||
|
label: badge.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const selectedBadge = manualBadges.find((b) => b.id === selectedBadgeId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box p="md">
|
||||||
|
<Card withBorder radius="md" p="md">
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Box>
|
||||||
|
<Text size="lg" fw={600} mb="xs">
|
||||||
|
Award Manual Badge
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Badge Type"
|
||||||
|
placeholder="Select a badge"
|
||||||
|
data={badgeOptions}
|
||||||
|
value={selectedBadgeId}
|
||||||
|
onChange={setSelectedBadgeId}
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selectedBadgeId && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Stack gap="md">
|
||||||
|
<Select
|
||||||
|
label="Select Player"
|
||||||
|
placeholder="Choose a player"
|
||||||
|
data={playerOptions}
|
||||||
|
value={selectedPlayerId}
|
||||||
|
onChange={setSelectedPlayerId}
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
size="md"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button
|
||||||
|
onClick={handleAwardBadge}
|
||||||
|
disabled={!selectedPlayerId}
|
||||||
|
loading={isAwarding}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
Award Badge
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AwardBadges;
|
||||||
47
src/features/badges/components/badge-showcase-skeleton.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Box, Skeleton, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
const BadgeShowcaseSkeleton = () => {
|
||||||
|
return (
|
||||||
|
<Box mb="lg">
|
||||||
|
<Box
|
||||||
|
px="md"
|
||||||
|
style={{
|
||||||
|
maxHeight: '220px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(85px, 1fr))',
|
||||||
|
gap: 'var(--mantine-spacing-md)',
|
||||||
|
paddingBottom: 'var(--mantine-spacing-sm)',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||||
|
<Box
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
aspectRatio: '1',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Skeleton
|
||||||
|
height="100%"
|
||||||
|
radius="12px"
|
||||||
|
style={{
|
||||||
|
aspectRatio: '1',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BadgeShowcaseSkeleton;
|
||||||
312
src/features/badges/components/badge-showcase.tsx
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
import { Box, Text, Progress, Image, Popover, Title, Stack } from "@mantine/core";
|
||||||
|
import { usePlayerBadges, useAllBadges } from "../queries";
|
||||||
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
import { Badge, BadgeProgress } from "../types";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { MedalIcon, LockKeyIcon } from "@phosphor-icons/react";
|
||||||
|
|
||||||
|
interface BadgeShowcaseProps {
|
||||||
|
playerId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BadgeDisplay {
|
||||||
|
badge: Badge;
|
||||||
|
progress?: BadgeProgress;
|
||||||
|
earned: boolean;
|
||||||
|
progressText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BadgeIconProps {
|
||||||
|
badge: Badge;
|
||||||
|
earned: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BadgeIcon = ({ badge, earned, size = 48 }: BadgeIconProps & { size?: number }) => {
|
||||||
|
const [imageError, setImageError] = useState(false);
|
||||||
|
const imagePath = `/static/img/${badge.key}.png`;
|
||||||
|
|
||||||
|
if (imageError) {
|
||||||
|
return earned ? (
|
||||||
|
<MedalIcon
|
||||||
|
size={size}
|
||||||
|
weight="fill"
|
||||||
|
color="var(--mantine-primary-color-6)"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<LockKeyIcon
|
||||||
|
size={size - 4}
|
||||||
|
weight="regular"
|
||||||
|
color="var(--mantine-color-dimmed)"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
src={imagePath}
|
||||||
|
alt={badge.name}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
onError={() => setImageError(true)}
|
||||||
|
style={{
|
||||||
|
objectFit: 'contain',
|
||||||
|
opacity: earned ? 1 : 0.4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { data: badgeProgress } = usePlayerBadges(playerId);
|
||||||
|
const { data: allBadges } = useAllBadges();
|
||||||
|
|
||||||
|
const isCurrentUser = user?.id === playerId;
|
||||||
|
|
||||||
|
const badgesToDisplay = useMemo(() => {
|
||||||
|
const displays: BadgeDisplay[] = [];
|
||||||
|
|
||||||
|
if (isCurrentUser) {
|
||||||
|
for (const badge of allBadges) {
|
||||||
|
const progress = badgeProgress.find(bp => bp.badge.id === badge.id);
|
||||||
|
const earned = progress?.earned || false;
|
||||||
|
|
||||||
|
if (badge.type === 'manual' && !earned) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isVeteranBadge = /^veteran_\d+_badge$/.test(badge.key);
|
||||||
|
if (isVeteranBadge && !earned) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (badge.key === 'new_player_badge') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let progressText = "";
|
||||||
|
if (progress) {
|
||||||
|
const target = getTargetProgress(badge);
|
||||||
|
progressText = `${progress.progress} / ${target}`;
|
||||||
|
} else {
|
||||||
|
const target = getTargetProgress(badge);
|
||||||
|
progressText = `0 / ${target}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
displays.push({
|
||||||
|
badge,
|
||||||
|
progress,
|
||||||
|
earned,
|
||||||
|
progressText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
displays.sort((a, b) => {
|
||||||
|
if (a.earned && !b.earned) return -1;
|
||||||
|
if (!a.earned && b.earned) return 1;
|
||||||
|
return a.badge.order - b.badge.order;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const earnedProgress = badgeProgress.filter(bp => bp.earned);
|
||||||
|
for (const progress of earnedProgress) {
|
||||||
|
const badge: Badge = {
|
||||||
|
...progress.badge,
|
||||||
|
criteria: {},
|
||||||
|
created: progress.created,
|
||||||
|
updated: progress.updated,
|
||||||
|
};
|
||||||
|
|
||||||
|
const target = getTargetProgress(badge);
|
||||||
|
displays.push({
|
||||||
|
badge,
|
||||||
|
progress,
|
||||||
|
earned: true,
|
||||||
|
progressText: `${progress.progress} / ${target}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
displays.sort((a, b) => a.badge.order - b.badge.order);
|
||||||
|
}
|
||||||
|
|
||||||
|
return displays;
|
||||||
|
}, [allBadges, badgeProgress, isCurrentUser]);
|
||||||
|
|
||||||
|
if (badgesToDisplay.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box mb="lg">
|
||||||
|
<Box
|
||||||
|
px="md"
|
||||||
|
style={{
|
||||||
|
maxHeight: '240px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(85px, 1fr))',
|
||||||
|
gap: 'var(--mantine-spacing-md)',
|
||||||
|
paddingBottom: 'var(--mantine-spacing-sm)',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{badgesToDisplay.map((display) => {
|
||||||
|
const isStackableBadge = ['winner_badge', 'silver_medal_badge', 'bronze_medal_badge'].includes(display.badge.key);
|
||||||
|
const stackCount = display.earned && isStackableBadge
|
||||||
|
? (display.progress?.progress || 0)
|
||||||
|
: 1;
|
||||||
|
const showStack = stackCount > 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover key={display.badge.id} width={280} position="top" withArrow shadow="md" withinPortal>
|
||||||
|
<Popover.Target>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showStack && (
|
||||||
|
<>
|
||||||
|
{[...Array(Math.min(stackCount - 1, 2))].map((_, i) => (
|
||||||
|
<Box
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
width: '100px',
|
||||||
|
height: '100px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
background: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-7))',
|
||||||
|
border: '2px solid var(--mantine-primary-color-5)',
|
||||||
|
position: 'absolute',
|
||||||
|
top: `${(i + 1) * 4}px`,
|
||||||
|
left: `${(i + 1) * 4}px`,
|
||||||
|
opacity: 0.3 - (i * 0.1),
|
||||||
|
zIndex: -(i + 1),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: '100px',
|
||||||
|
height: '100px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
background: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-7))',
|
||||||
|
border: display.earned
|
||||||
|
? '2px solid var(--mantine-primary-color-6)'
|
||||||
|
: '2px dashed var(--mantine-primary-color-4)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '4px',
|
||||||
|
padding: 'var(--mantine-spacing-sm)',
|
||||||
|
position: 'relative',
|
||||||
|
boxShadow: display.earned
|
||||||
|
? '0 0 0 1px color-mix(in srgb, var(--mantine-primary-color-6) 20%, transparent)'
|
||||||
|
: 'none',
|
||||||
|
opacity: display.earned ? 1 : 0.4,
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BadgeIcon badge={display.badge} earned={display.earned} />
|
||||||
|
|
||||||
|
{showStack && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '4px',
|
||||||
|
right: '4px',
|
||||||
|
color: 'var(--mantine-primary-color-6)',
|
||||||
|
width: '20px',
|
||||||
|
height: '20px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
x{stackCount}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
px={4}
|
||||||
|
fw={display.earned ? 600 : 500}
|
||||||
|
ta="center"
|
||||||
|
c={display.earned ? undefined : 'dimmed'}
|
||||||
|
style={{ lineHeight: 1.1 }}
|
||||||
|
>
|
||||||
|
{display.badge.name}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown>
|
||||||
|
<Stack gap={4} align="center">
|
||||||
|
<Box style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<BadgeIcon badge={display.badge} earned={display.earned} size={80} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Title order={5} ta="center">
|
||||||
|
{display.badge.name}
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
|
{display.badge.description}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{isCurrentUser && (
|
||||||
|
<Box>
|
||||||
|
<Box mb="xs" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Text size="sm" fw={500} c="dimmed">
|
||||||
|
Progress
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={600} c="dimmed">
|
||||||
|
{display.progressText}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Progress
|
||||||
|
value={(display.progress?.progress || 0) / getTargetProgress(display.badge) * 100}
|
||||||
|
size="sm"
|
||||||
|
radius="sm"
|
||||||
|
color={display.earned ? "green" : undefined}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function getTargetProgress(badge: Badge): number {
|
||||||
|
const criteria = badge.criteria;
|
||||||
|
return (
|
||||||
|
criteria.matches_played ||
|
||||||
|
criteria.tournament_wins ||
|
||||||
|
criteria.tournaments_attended ||
|
||||||
|
criteria.overtime_matches ||
|
||||||
|
criteria.overtime_wins ||
|
||||||
|
criteria.consecutive_wins ||
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BadgeShowcase;
|
||||||
24
src/features/badges/queries.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
||||||
|
import { getPlayerBadges, getAllBadges } from "./server";
|
||||||
|
|
||||||
|
export const badgeKeys = {
|
||||||
|
playerBadges: (playerId: string) => ['badges', 'player', playerId],
|
||||||
|
allBadges: () => ['badges', 'all'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const badgeQueries = {
|
||||||
|
playerBadges: (playerId: string) => ({
|
||||||
|
queryKey: badgeKeys.playerBadges(playerId),
|
||||||
|
queryFn: async () => await getPlayerBadges({ data: playerId })
|
||||||
|
}),
|
||||||
|
allBadges: () => ({
|
||||||
|
queryKey: badgeKeys.allBadges(),
|
||||||
|
queryFn: async () => await getAllBadges()
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePlayerBadges = (playerId: string) =>
|
||||||
|
useServerSuspenseQuery(badgeQueries.playerBadges(playerId));
|
||||||
|
|
||||||
|
export const useAllBadges = () =>
|
||||||
|
useServerSuspenseQuery(badgeQueries.allBadges());
|
||||||
@@ -1,4 +1,34 @@
|
|||||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||||
import { superTokensAdminFunctionMiddleware } from "@/utils/supertokens";
|
import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const getPlayerBadges = createServerFn()
|
||||||
|
.inputValidator(z.string())
|
||||||
|
.middleware([superTokensFunctionMiddleware])
|
||||||
|
.handler(async ({ data: playerId }) =>
|
||||||
|
toServerResult(() => pbAdmin.getPlayerBadgeProgress(playerId))
|
||||||
|
);
|
||||||
|
|
||||||
|
export const migrateBadgeProgress = createServerFn()
|
||||||
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
|
.handler(async () =>
|
||||||
|
toServerResult(() => pbAdmin.migrateBadgeProgress())
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getAllBadges = createServerFn()
|
||||||
|
.middleware([superTokensFunctionMiddleware])
|
||||||
|
.handler(async () =>
|
||||||
|
toServerResult(() => pbAdmin.listBadges())
|
||||||
|
);
|
||||||
|
|
||||||
|
export const awardManualBadge = createServerFn()
|
||||||
|
.inputValidator(z.object({
|
||||||
|
playerId: z.string(),
|
||||||
|
badgeId: z.string(),
|
||||||
|
}))
|
||||||
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
|
.handler(async ({ data }) =>
|
||||||
|
toServerResult(() => pbAdmin.awardManualBadge(data.playerId, data.badgeId))
|
||||||
|
);
|
||||||
|
|||||||
25
src/features/badges/types.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export interface BadgeInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
key: string;
|
||||||
|
description: string;
|
||||||
|
type: "manual" | "match" | "tournament";
|
||||||
|
progressive: boolean;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Badge extends BadgeInfo {
|
||||||
|
criteria: Record<string, any>;
|
||||||
|
created: string;
|
||||||
|
updated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BadgeProgress {
|
||||||
|
id: string;
|
||||||
|
badge: BadgeInfo;
|
||||||
|
player: string;
|
||||||
|
progress: number;
|
||||||
|
earned: boolean;
|
||||||
|
created: string;
|
||||||
|
updated: string;
|
||||||
|
}
|
||||||
@@ -8,11 +8,12 @@ const Header = ({ collapsed, title, withBackButton }: HeaderProps) => {
|
|||||||
return (
|
return (
|
||||||
<AppShell.Header
|
<AppShell.Header
|
||||||
id='app-header'
|
id='app-header'
|
||||||
display={collapsed ? 'none' : 'block'}
|
display={collapsed ? 'none' : 'flex'}
|
||||||
|
style={{ alignItems: 'center', justifyContent: 'center' }}
|
||||||
>
|
>
|
||||||
{ withBackButton && <BackButton /> }
|
{ withBackButton && <BackButton /> }
|
||||||
<Flex justify='center' align='center' h='100%' px='md'>
|
<Flex justify='center' px='md' mt={8}>
|
||||||
<Title order={2}>{title}</Title>
|
<Title order={1}>{title?.toLocaleUpperCase()}</Title>
|
||||||
</Flex>
|
</Flex>
|
||||||
</AppShell.Header>
|
</AppShell.Header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,14 +31,18 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
pos='relative'
|
pos='relative'
|
||||||
h='100dvh'
|
h='100dvh'
|
||||||
mah='100dvh'
|
mah='100dvh'
|
||||||
// style={{ top: viewport.top }} //, transition: 'top 0.1s ease-in-out' }}
|
style={{
|
||||||
|
height: `${viewport.height}px`,
|
||||||
|
minHeight: '100dvh',
|
||||||
|
// top: viewport.top
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Header {...header} />
|
<Header {...header} />
|
||||||
<AppShell.Main
|
<AppShell.Main
|
||||||
pos='relative'
|
pos='relative'
|
||||||
h='100%'
|
h='100%'
|
||||||
mah='100%'
|
mah='100%'
|
||||||
pb={{ base: 65, md: 0 }}
|
pb={{ base: 65, sm: 0 }}
|
||||||
px={{ base: 0.01, sm: 100, md: 200, lg: 300 }}
|
px={{ base: 0.01, sm: 100, md: 200, lg: 300 }}
|
||||||
maw='100dvw'
|
maw='100dvw'
|
||||||
style={{ transition: 'none', overflow: 'hidden' }}
|
style={{ transition: 'none', overflow: 'hidden' }}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const Navbar = () => {
|
|||||||
// boxShadow: `5px 5px ${boxShadowColor}`, borderColor
|
// boxShadow: `5px 5px ${boxShadowColor}`, borderColor
|
||||||
|
|
||||||
if (isMobile) return (
|
if (isMobile) return (
|
||||||
<Paper component='nav' role='navigation' withBorder shadow="sm" radius='lg' h='4rem' w='calc(100% - 1.5rem)' pos='fixed' m='0.75rem' bottom='0' style={{ zIndex: 10 }}>
|
<Paper component='nav' role='navigation' withBorder shadow="sm" radius='lg' h='4rem' w='calc(100% - 1rem)' pos='fixed' m='0.5rem' bottom='0' style={{ zIndex: 10 }}>
|
||||||
<Group gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
|
<Group gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
|
||||||
{links.map((link) => (
|
{links.map((link) => (
|
||||||
<NavLink key={link.href} {...link} />
|
<NavLink key={link.href} {...link} />
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { AuthProvider } from "@/contexts/auth-context"
|
import { AuthProvider } from "@/contexts/auth-context"
|
||||||
import { SpotifyProvider } from "@/contexts/spotify-context"
|
import { SpotifyProvider } from "@/contexts/spotify-context"
|
||||||
import MantineProvider from "@/lib/mantine/mantine-provider"
|
import MantineProvider from "@/lib/mantine/mantine-provider"
|
||||||
|
import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools'
|
||||||
|
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
|
||||||
|
import { TanStackDevtools } from '@tanstack/react-devtools'
|
||||||
import { Toaster } from "sonner"
|
import { Toaster } from "sonner"
|
||||||
|
|
||||||
const Providers = ({ children }: { children: React.ReactNode }) => {
|
const Providers = ({ children }: { children: React.ReactNode }) => {
|
||||||
@@ -8,6 +11,22 @@ const Providers = ({ children }: { children: React.ReactNode }) => {
|
|||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<SpotifyProvider>
|
<SpotifyProvider>
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
|
<TanStackDevtools
|
||||||
|
eventBusConfig={{
|
||||||
|
debug: false,
|
||||||
|
connectToServerBus: true,
|
||||||
|
}}
|
||||||
|
plugins={[
|
||||||
|
{
|
||||||
|
name: 'TanStack Query',
|
||||||
|
render: <ReactQueryDevtoolsPanel />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'TanStack Router',
|
||||||
|
render: <TanStackRouterDevtoolsPanel />,
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
<Toaster position='top-center' />
|
<Toaster position='top-center' />
|
||||||
{children}
|
{children}
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
|
|||||||
if (refresh.length > 0) {
|
if (refresh.length > 0) {
|
||||||
// TODO: Remove this after testing - or does the delay help ux?
|
// TODO: Remove this after testing - or does the delay help ux?
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
await queryClient.refetchQueries({ queryKey: refresh, exact: true});
|
refresh.forEach(async (queryKey) => {
|
||||||
|
const keyArray = Array.isArray(queryKey) ? queryKey : [queryKey];
|
||||||
|
await queryClient.refetchQueries({ queryKey: keyArray, exact: true });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
setIsRefreshing(false);
|
setIsRefreshing(false);
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|||||||
@@ -19,11 +19,17 @@ const useVisualViewportSize = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!windowExists) return;
|
if (!windowExists) return;
|
||||||
|
|
||||||
|
setSize();
|
||||||
|
|
||||||
window.visualViewport?.addEventListener('resize', setSize, eventListerOptions);
|
window.visualViewport?.addEventListener('resize', setSize, eventListerOptions);
|
||||||
|
window.visualViewport?.addEventListener('scroll', setSize, eventListerOptions);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.visualViewport?.removeEventListener('resize', setSize);
|
window.visualViewport?.removeEventListener('resize', setSize);
|
||||||
|
window.visualViewport?.removeEventListener('scroll', setSize);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [setSize]);
|
||||||
|
|
||||||
return windowSize;
|
return windowSize;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import GlitchAvatar from '@/components/glitch-avatar';
|
||||||
import useVisualViewportSize from '@/features/core/hooks/use-visual-viewport-size';
|
import useVisualViewportSize from '@/features/core/hooks/use-visual-viewport-size';
|
||||||
|
import { useCurrentTournament } from '@/features/tournaments/queries';
|
||||||
import { AppShell, Flex, Paper, em, Title, Stack } from '@mantine/core';
|
import { AppShell, Flex, Paper, em, Title, Stack } from '@mantine/core';
|
||||||
import { useMediaQuery, useViewportSize } from '@mantine/hooks';
|
import { useMediaQuery, useViewportSize } from '@mantine/hooks';
|
||||||
import { TrophyIcon } from '@phosphor-icons/react';
|
import { TrophyIcon } from '@phosphor-icons/react';
|
||||||
@@ -8,6 +10,7 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
const isMobile = useMediaQuery(`(max-width: ${em(450)})`);
|
const isMobile = useMediaQuery(`(max-width: ${em(450)})`);
|
||||||
const visualViewport = useVisualViewportSize();
|
const visualViewport = useVisualViewportSize();
|
||||||
const viewport = useViewportSize();
|
const viewport = useViewportSize();
|
||||||
|
const { data: tournament } = useCurrentTournament();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
@@ -31,8 +34,27 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
radius='md'
|
radius='md'
|
||||||
>
|
>
|
||||||
<Stack align='center' gap='xs' mb='md'>
|
<Stack align='center' gap='xs' mb='md'>
|
||||||
<TrophyIcon size={75} />
|
<GlitchAvatar
|
||||||
<Title order={4} ta='center'>Welcome to FLXN</Title>
|
name={tournament.name}
|
||||||
|
contain
|
||||||
|
src={
|
||||||
|
tournament.logo
|
||||||
|
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
glitchSrc={
|
||||||
|
tournament.glitch_logo
|
||||||
|
? `/api/files/tournaments/${tournament.id}/${tournament.glitch_logo}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
radius="md"
|
||||||
|
size={250}
|
||||||
|
px="xs"
|
||||||
|
withBorder={false}
|
||||||
|
>
|
||||||
|
<TrophyIcon size={32} />
|
||||||
|
</GlitchAvatar>
|
||||||
|
<Title order={1} ta='center'>Welcome to FLXN</Title>
|
||||||
</Stack>
|
</Stack>
|
||||||
{children}
|
{children}
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface MatchListProps {
|
|||||||
const MatchList = ({ matches }: MatchListProps) => {
|
const MatchList = ({ matches }: MatchListProps) => {
|
||||||
const filteredMatches = matches?.filter(match =>
|
const filteredMatches = matches?.filter(match =>
|
||||||
match.home && match.away && !match.bye && match.status != "tbd"
|
match.home && match.away && !match.bye && match.status != "tbd"
|
||||||
) || [];
|
).sort((a, b) => a.start_time < b.start_time ? 1 : -1) || [];
|
||||||
|
|
||||||
if (!filteredMatches.length) {
|
if (!filteredMatches.length) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { MatchInput } from "@/features/matches/types";
|
|||||||
import { serverEvents } from "@/lib/events/emitter";
|
import { serverEvents } from "@/lib/events/emitter";
|
||||||
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||||
import { PlayerInfo } from "../players/types";
|
import { PlayerInfo } from "../players/types";
|
||||||
|
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||||
|
|
||||||
const orderedTeamsSchema = z.object({
|
const orderedTeamsSchema = z.object({
|
||||||
tournamentId: z.string(),
|
tournamentId: z.string(),
|
||||||
@@ -17,7 +18,7 @@ const orderedTeamsSchema = z.object({
|
|||||||
|
|
||||||
export const generateTournamentBracket = createServerFn()
|
export const generateTournamentBracket = createServerFn()
|
||||||
.inputValidator(orderedTeamsSchema)
|
.inputValidator(orderedTeamsSchema)
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
.handler(async ({ data: { tournamentId, orderedTeamIds } }) =>
|
.handler(async ({ data: { tournamentId, orderedTeamIds } }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
logger.info("Generating tournament bracket", {
|
logger.info("Generating tournament bracket", {
|
||||||
@@ -138,7 +139,7 @@ export const generateTournamentBracket = createServerFn()
|
|||||||
|
|
||||||
export const startMatch = createServerFn()
|
export const startMatch = createServerFn()
|
||||||
.inputValidator(z.string())
|
.inputValidator(z.string())
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
logger.info("Starting match", data);
|
logger.info("Starting match", data);
|
||||||
@@ -171,7 +172,7 @@ const endMatchSchema = z.object({
|
|||||||
});
|
});
|
||||||
export const endMatch = createServerFn()
|
export const endMatch = createServerFn()
|
||||||
.inputValidator(endMatchSchema)
|
.inputValidator(endMatchSchema)
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count } }) =>
|
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count } }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
logger.info("Ending match", matchId);
|
logger.info("Ending match", matchId);
|
||||||
|
|||||||
@@ -5,52 +5,66 @@ import {
|
|||||||
Container,
|
Container,
|
||||||
Divider,
|
Divider,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
|
ScrollArea,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
|
||||||
const PlayerListItemSkeleton = () => {
|
const PlayerListItemSkeleton = () => {
|
||||||
return (
|
return (
|
||||||
<Box p="md">
|
<Box p="md">
|
||||||
<Group justify="space-between" align="center" w="100%">
|
<Group gap="sm" align="center" w="100%" wrap="nowrap" style={{ overflow: 'hidden' }}>
|
||||||
<Group gap="sm" align="center">
|
<Skeleton height={40} width={40} circle style={{ flexShrink: 0 }} />
|
||||||
<Skeleton height={45} circle />
|
<Stack gap={2} style={{ flexGrow: 1, overflow: 'hidden', minWidth: 0 }}>
|
||||||
<Stack gap={2}>
|
<Group gap='xs'>
|
||||||
<Group gap='xs'>
|
<Skeleton height={16} width={120} />
|
||||||
<Skeleton height={16} width={120} />
|
<Skeleton height={12} width={30} />
|
||||||
<Skeleton height={12} width={60} />
|
<Skeleton height={12} width={30} />
|
||||||
<Skeleton height={12} width={80} />
|
</Group>
|
||||||
</Group>
|
|
||||||
<Group gap="md" ta="center">
|
<ScrollArea type="never">
|
||||||
<Stack gap={0}>
|
<Group gap='xs' wrap="nowrap">
|
||||||
|
<Stack gap={0} style={{ flexShrink: 0 }}>
|
||||||
<Skeleton height={10} width={25} />
|
<Skeleton height={10} width={25} />
|
||||||
<Skeleton height={10} width={30} />
|
<Skeleton height={10} width={30} />
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack gap={0}>
|
<Stack gap={0} style={{ flexShrink: 0 }}>
|
||||||
<Skeleton height={10} width={10} />
|
<Skeleton height={10} width={10} />
|
||||||
<Skeleton height={10} width={15} />
|
<Skeleton height={10} width={15} />
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack gap={0}>
|
<Stack gap={0} style={{ flexShrink: 0 }}>
|
||||||
<Skeleton height={10} width={10} />
|
<Skeleton height={10} width={10} />
|
||||||
<Skeleton height={10} width={15} />
|
<Skeleton height={10} width={15} />
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack gap={0}>
|
<Stack gap={0} style={{ flexShrink: 0 }}>
|
||||||
<Skeleton height={10} width={20} />
|
<Skeleton height={10} width={20} />
|
||||||
<Skeleton height={10} width={25} />
|
<Skeleton height={10} width={25} />
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack gap={0}>
|
<Stack gap={0} style={{ flexShrink: 0 }}>
|
||||||
<Skeleton height={10} width={25} />
|
<Skeleton height={10} width={25} />
|
||||||
<Skeleton height={10} width={20} />
|
<Skeleton height={10} width={20} />
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack gap={0}>
|
<Stack gap={0} style={{ flexShrink: 0 }}>
|
||||||
|
<Skeleton height={10} width={25} />
|
||||||
|
<Skeleton height={10} width={20} />
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={0} style={{ flexShrink: 0 }}>
|
||||||
|
<Skeleton height={10} width={30} />
|
||||||
|
<Skeleton height={10} width={25} />
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={0} style={{ flexShrink: 0 }}>
|
||||||
|
<Skeleton height={10} width={30} />
|
||||||
|
<Skeleton height={10} width={25} />
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={0} style={{ flexShrink: 0 }}>
|
||||||
<Skeleton height={10} width={15} />
|
<Skeleton height={10} width={15} />
|
||||||
<Skeleton height={10} width={25} />
|
<Skeleton height={10} width={25} />
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack gap={0}>
|
<Stack gap={0} style={{ flexShrink: 0 }}>
|
||||||
<Skeleton height={10} width={15} />
|
<Skeleton height={10} width={15} />
|
||||||
<Skeleton height={10} width={25} />
|
<Skeleton height={10} width={25} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</ScrollArea>
|
||||||
</Group>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -60,13 +74,13 @@ const PlayerStatsTableSkeleton = () => {
|
|||||||
return (
|
return (
|
||||||
<Container size="100%" px={0}>
|
<Container size="100%" px={0}>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Box px="md" pb="xs">
|
<Skeleton mx="md" height={12} width={100} />
|
||||||
|
<Box px="md" pb={4}>
|
||||||
<Skeleton height={40} />
|
<Skeleton height={40} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Group px="md" justify="space-between" align="center">
|
<Group justify="space-between" align="center" w='100%'>
|
||||||
<Skeleton height={12} width={100} />
|
<Group ml="auto" gap="xs">
|
||||||
<Group gap="xs">
|
|
||||||
<Skeleton height={12} width={200} />
|
<Skeleton height={12} width={200} />
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo, useCallback, memo } from "react";
|
import { useState, useMemo, useCallback, memo, useRef, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
Popover,
|
Popover,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
|
ScrollArea,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
MagnifyingGlassIcon,
|
MagnifyingGlassIcon,
|
||||||
@@ -37,9 +38,41 @@ interface PlayerListItemProps {
|
|||||||
stat: PlayerStats;
|
stat: PlayerStats;
|
||||||
onPlayerClick: (playerId: string) => void;
|
onPlayerClick: (playerId: string) => void;
|
||||||
mmr: number;
|
mmr: number;
|
||||||
|
onRegisterViewport: (viewport: HTMLDivElement) => void;
|
||||||
|
onUnregisterViewport: (viewport: HTMLDivElement) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlayerListItem = memo(({ stat, onPlayerClick, mmr }: PlayerListItemProps) => {
|
interface StatCellProps {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatCell = memo(({ label, value }: StatCellProps) => (
|
||||||
|
<Stack justify="center" gap={0} style={{ textAlign: 'center', flexShrink: 0 }}>
|
||||||
|
<Text size="xs" c="dimmed" fw={700}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
));
|
||||||
|
|
||||||
|
const PlayerListItem = memo(({ stat, onPlayerClick, mmr, onRegisterViewport, onUnregisterViewport }: PlayerListItemProps) => {
|
||||||
|
const viewportRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const avg_cups_against = useMemo(() => stat.total_cups_against / stat.matches || 0, [stat.total_cups_against, stat.matches]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewportRef.current) {
|
||||||
|
onRegisterViewport(viewportRef.current);
|
||||||
|
return () => {
|
||||||
|
if (viewportRef.current) {
|
||||||
|
onUnregisterViewport(viewportRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [onRegisterViewport, onUnregisterViewport]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -59,90 +92,47 @@ const PlayerListItem = memo(({ stat, onPlayerClick, mmr }: PlayerListItemProps)
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group justify="space-between" align="center" w="100%">
|
<Group p={0} gap="sm" align="center" w="100%" wrap="nowrap" style={{ overflow: 'hidden' }}>
|
||||||
<Group gap="sm" align="center">
|
<Avatar name={stat.player_name} size={40} style={{ flexShrink: 0 }} />
|
||||||
<Avatar name={stat.player_name} size={40} />
|
<Stack gap={2} style={{ flexGrow: 1, overflow: 'hidden', minWidth: 0 }}>
|
||||||
<Stack gap={2}>
|
<Group gap='xs'>
|
||||||
<Group gap='xs'>
|
<Text size="sm" fw={600}>
|
||||||
<Text size="sm" fw={600}>
|
{stat.player_name}
|
||||||
{stat.player_name}
|
</Text>
|
||||||
</Text>
|
<Text size="xs" c="dimmed" ta="right">
|
||||||
<Text size="xs" c="dimmed" ta="right">
|
{stat.matches}
|
||||||
{stat.matches} matches
|
<Text span fw={800}>M</Text>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed" ta="right">
|
<Text size="xs" c="dimmed" ta="right">
|
||||||
{stat.tournaments} tournaments
|
{stat.tournaments}
|
||||||
</Text>
|
<Text span fw={800}>T</Text>
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<ScrollArea
|
||||||
|
viewportRef={viewportRef}
|
||||||
|
type="never"
|
||||||
|
>
|
||||||
|
<Group gap='xs' wrap="nowrap">
|
||||||
|
<StatCell label="MMR" value={mmr.toFixed(1)} />
|
||||||
|
<StatCell label="W" value={stat.wins} />
|
||||||
|
<StatCell label="L" value={stat.losses} />
|
||||||
|
<StatCell label="W%" value={`${stat.win_percentage.toFixed(1)}%`} />
|
||||||
|
<StatCell label="AWM" value={stat.margin_of_victory?.toFixed(1) || 0} />
|
||||||
|
<StatCell label="ALM" value={stat.margin_of_loss?.toFixed(1) || 0} />
|
||||||
|
<StatCell label="ACPM" value={stat.avg_cups_per_match.toFixed(1)} />
|
||||||
|
<StatCell label="ACA" value={avg_cups_against?.toFixed(1) || 0} />
|
||||||
|
<StatCell label="CF" value={stat.total_cups_made} />
|
||||||
|
<StatCell label="CA" value={stat.total_cups_against} />
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="md" ta="center">
|
</ScrollArea>
|
||||||
<Stack gap={0}>
|
</Stack>
|
||||||
<Text size="xs" c="dimmed" fw={700}>
|
|
||||||
MMR
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{mmr.toFixed(1)}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
<Stack gap={0}>
|
|
||||||
<Text size="xs" c="dimmed" fw={700}>
|
|
||||||
W
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{stat.wins}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
<Stack gap={0}>
|
|
||||||
<Text size="xs" c="dimmed" fw={700}>
|
|
||||||
L
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{stat.losses}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
<Stack gap={0}>
|
|
||||||
<Text size="xs" c="dimmed" fw={700}>
|
|
||||||
W%
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{stat.win_percentage.toFixed(1)}%
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
<Stack gap={0}>
|
|
||||||
<Text size="xs" c="dimmed" fw={700}>
|
|
||||||
AVG
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{stat.avg_cups_per_match.toFixed(1)}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
<Stack gap={0}>
|
|
||||||
<Text size="xs" c="dimmed" fw={700}>
|
|
||||||
CF
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{stat.total_cups_made}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
<Stack gap={0}>
|
|
||||||
<Text size="xs" c="dimmed" fw={700}>
|
|
||||||
CA
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{stat.total_cups_against}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
</Group>
|
</Group>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
PlayerListItem.displayName = 'PlayerListItem';
|
|
||||||
|
|
||||||
const PlayerStatsTable = () => {
|
const PlayerStatsTable = () => {
|
||||||
const { data: playerStats } = useAllPlayerStats();
|
const { data: playerStats } = useAllPlayerStats();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -152,6 +142,37 @@ const PlayerStatsTable = () => {
|
|||||||
direction: "desc",
|
direction: "desc",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const viewportsRef = useRef<Set<HTMLDivElement>>(new Set());
|
||||||
|
const isScrollingRef = useRef(false);
|
||||||
|
|
||||||
|
const handleRegisterViewport = useCallback((viewport: HTMLDivElement) => {
|
||||||
|
viewportsRef.current.add(viewport);
|
||||||
|
|
||||||
|
const handleScroll = (e: Event) => {
|
||||||
|
if (isScrollingRef.current) return;
|
||||||
|
|
||||||
|
isScrollingRef.current = true;
|
||||||
|
const scrollLeft = (e.target as HTMLDivElement).scrollLeft;
|
||||||
|
|
||||||
|
viewportsRef.current.forEach((vp) => {
|
||||||
|
if (vp !== e.target) {
|
||||||
|
vp.scrollLeft = scrollLeft;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
isScrollingRef.current = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
viewport.addEventListener('scroll', handleScroll);
|
||||||
|
viewport.dataset.scrollHandler = 'attached';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUnregisterViewport = useCallback((viewport: HTMLDivElement) => {
|
||||||
|
viewportsRef.current.delete(viewport);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const calculateMMR = (stat: PlayerStats): number => {
|
const calculateMMR = (stat: PlayerStats): number => {
|
||||||
if (stat.matches === 0) return 0;
|
if (stat.matches === 0) return 0;
|
||||||
|
|
||||||
@@ -251,6 +272,9 @@ const PlayerStatsTable = () => {
|
|||||||
return (
|
return (
|
||||||
<Container size="100%" px={0}>
|
<Container size="100%" px={0}>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
|
<Text px="md" size="10px" lh={0} c="dimmed">
|
||||||
|
Showing {filteredAndSortedStats.length} of {playerStats.length} players
|
||||||
|
</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="Search players"
|
placeholder="Search players"
|
||||||
value={search}
|
value={search}
|
||||||
@@ -261,11 +285,9 @@ const PlayerStatsTable = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Group px="md" justify="space-between" align="center">
|
<Group px="md" justify="space-between" align="center">
|
||||||
<Text size="10px" lh={0} c="dimmed">
|
<Group gap="xs" w="100%">
|
||||||
{filteredAndSortedStats.length} of {playerStats.length} players
|
<div></div>
|
||||||
</Text>
|
<Text ml='auto' size="xs" c="dimmed">Sort:</Text>
|
||||||
<Group gap="xs">
|
|
||||||
<Text size="xs" c="dimmed">Sort:</Text>
|
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
onClick={() => handleSort("mmr")}
|
onClick={() => handleSort("mmr")}
|
||||||
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||||
@@ -303,6 +325,48 @@ const PlayerStatsTable = () => {
|
|||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
<Popover.Dropdown>
|
<Popover.Dropdown>
|
||||||
<Box maw={280}>
|
<Box maw={280}>
|
||||||
|
<Text size="sm" fw={500} mb="xs">
|
||||||
|
Stat Abbreviations:
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" mb={2}>
|
||||||
|
• <strong>M:</strong> Matches
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" mb={2}>
|
||||||
|
• <strong>T:</strong> Tournaments
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" mb={2}>
|
||||||
|
• <strong>MMR:</strong> Matchmaking Rating
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" mb={2}>
|
||||||
|
• <strong>W:</strong> Wins
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" mb={2}>
|
||||||
|
• <strong>L:</strong> Losses
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" mb={2}>
|
||||||
|
• <strong>W%:</strong> Win Percentage
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" mb={2}>
|
||||||
|
• <strong>AWM:</strong> Average Win Margin
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" mb={2}>
|
||||||
|
• <strong>ALM:</strong> Average Loss Margin
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" mb={2}>
|
||||||
|
• <strong>AC:</strong> Average Cups Per Match
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" mb={2}>
|
||||||
|
• <strong>ACA:</strong> Average Cups Against
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" mb={2}>
|
||||||
|
• <strong>CF:</strong> Cups For
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" mb={2}>
|
||||||
|
• <strong>CA:</strong> Cups Against
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Divider my="sm" />
|
||||||
|
|
||||||
<Text size="sm" fw={500} mb="xs">
|
<Text size="sm" fw={500} mb="xs">
|
||||||
MMR Calculation:
|
MMR Calculation:
|
||||||
</Text>
|
</Text>
|
||||||
@@ -337,6 +401,8 @@ const PlayerStatsTable = () => {
|
|||||||
stat={stat}
|
stat={stat}
|
||||||
onPlayerClick={handlePlayerClick}
|
onPlayerClick={handlePlayerClick}
|
||||||
mmr={stat.mmr}
|
mmr={stat.mmr}
|
||||||
|
onRegisterViewport={handleRegisterViewport}
|
||||||
|
onUnregisterViewport={handleUnregisterViewport}
|
||||||
/>
|
/>
|
||||||
{index < filteredAndSortedStats.length - 1 && <Divider />}
|
{index < filteredAndSortedStats.length - 1 && <Divider />}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
118
src/features/players/components/players-activity-table.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { memo } from "react";
|
||||||
|
import {
|
||||||
|
Text,
|
||||||
|
Stack,
|
||||||
|
Group,
|
||||||
|
Box,
|
||||||
|
Container,
|
||||||
|
Divider,
|
||||||
|
UnstyledButton,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { Player } from "../types";
|
||||||
|
import { usePlayersActivity } from "../queries";
|
||||||
|
|
||||||
|
interface PlayerActivityItemProps {
|
||||||
|
player: Player;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlayerActivityItem = memo(({ player }: PlayerActivityItemProps) => {
|
||||||
|
const playerName = player.first_name && player.last_name
|
||||||
|
? `${player.first_name} ${player.last_name}`
|
||||||
|
: player.first_name || player.last_name || "Unknown Player";
|
||||||
|
|
||||||
|
const formatDate = (dateStr?: string) => {
|
||||||
|
if (!dateStr) return "Never";
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTimeSince = (dateStr?: string) => {
|
||||||
|
if (!dateStr) return "Never active";
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMins / 60);
|
||||||
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
|
||||||
|
if (diffMins < 1) return "Just now";
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
|
if (diffDays < 30) return `${diffDays}d ago`;
|
||||||
|
return formatDate(dateStr);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isActive = player.last_activity &&
|
||||||
|
(new Date().getTime() - new Date(player.last_activity).getTime()) < 5 * 60 * 1000;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
w="100%"
|
||||||
|
p="md"
|
||||||
|
style={{
|
||||||
|
borderRadius: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="flex-start" w="100%">
|
||||||
|
<Stack gap={4} flex={1}>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{playerName}
|
||||||
|
</Text>
|
||||||
|
{isActive && (
|
||||||
|
<Box
|
||||||
|
w={8}
|
||||||
|
h={8}
|
||||||
|
style={{
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: "var(--mantine-color-green-6)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Group gap="md">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{getTimeSince(player.last_activity)}
|
||||||
|
</Text>
|
||||||
|
{player.last_activity && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{formatDate(player.last_activity)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PlayersActivityTable = () => {
|
||||||
|
const { data: players } = usePlayersActivity();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="100%" px={0}>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Group px="md" justify="space-between" align="center">
|
||||||
|
<Text size="10px" lh={0} c="dimmed">
|
||||||
|
{players.length} players
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Stack gap={0}>
|
||||||
|
{players.map((player: Player, index: number) => (
|
||||||
|
<Box key={player.id}>
|
||||||
|
<PlayerActivityItem player={player} />
|
||||||
|
{index < players.length - 1 && <Divider />}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{players.length === 0 && (
|
||||||
|
<Text ta="center" c="dimmed" py="xl">
|
||||||
|
No player activity found
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -20,8 +20,8 @@ const Header = ({ player }: HeaderProps) => {
|
|||||||
const name = useMemo(() => `${player.first_name} ${player.last_name}`, [player.first_name, player.last_name]);
|
const name = useMemo(() => `${player.first_name} ${player.last_name}`, [player.first_name, player.last_name]);
|
||||||
|
|
||||||
const fontSize = useMemo(() => {
|
const fontSize = useMemo(() => {
|
||||||
const baseSize = 24;
|
const baseSize = 28;
|
||||||
const maxLength = 20;
|
const maxLength = 24;
|
||||||
|
|
||||||
if (name.length <= maxLength) {
|
if (name.length <= maxLength) {
|
||||||
return `${baseSize}px`;
|
return `${baseSize}px`;
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { Box } from "@mantine/core";
|
import { Box, Stack, Text, Divider } from "@mantine/core";
|
||||||
|
import { Suspense } from "react";
|
||||||
import Header from "./header";
|
import Header from "./header";
|
||||||
import SwipeableTabs from "@/components/swipeable-tabs";
|
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||||
import { usePlayer, usePlayerMatches, usePlayerStats } from "../../queries";
|
import { usePlayer, usePlayerMatches, usePlayerStats } from "../../queries";
|
||||||
import TeamList from "@/features/teams/components/team-list";
|
import TeamList from "@/features/teams/components/team-list";
|
||||||
import StatsOverview from "@/components/stats-overview";
|
import StatsOverview from "@/components/stats-overview";
|
||||||
import MatchList from "@/features/matches/components/match-list";
|
import MatchList from "@/features/matches/components/match-list";
|
||||||
|
import BadgeShowcase from "@/features/badges/components/badge-showcase";
|
||||||
|
import BadgeShowcaseSkeleton from "@/features/badges/components/badge-showcase-skeleton";
|
||||||
|
|
||||||
interface ProfileProps {
|
interface ProfileProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,7 +21,19 @@ const Profile = ({ id }: ProfileProps) => {
|
|||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
label: "Overview",
|
label: "Overview",
|
||||||
content: <StatsOverview statsData={stats} isLoading={statsLoading} />,
|
content: <>
|
||||||
|
<Stack px="md">
|
||||||
|
<Text size="md" fw={700}>Badges</Text>
|
||||||
|
<Suspense fallback={<BadgeShowcaseSkeleton />}>
|
||||||
|
<BadgeShowcase playerId={id} />
|
||||||
|
</Suspense>
|
||||||
|
</Stack>
|
||||||
|
<Divider my="md" />
|
||||||
|
<Stack>
|
||||||
|
<Text px="md" size="md" fw={700}>Statistics</Text>
|
||||||
|
<StatsOverview statsData={stats} isLoading={statsLoading} />
|
||||||
|
</Stack>
|
||||||
|
</>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Matches",
|
label: "Matches",
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
import { Logger } from "@/lib/logger";
|
import { Logger } from "@/lib/logger";
|
||||||
|
|
||||||
export const logger = new Logger('Players');
|
export const logger = new Logger('Players');
|
||||||
|
export * from "./queries";
|
||||||
|
export { PlayersActivityTable } from "./components/players-activity-table";
|
||||||