feat: categories and popups #171
265
package-lock.json
generated
265
package-lock.json
generated
@ -18,7 +18,7 @@
|
||||
"@tiptap/react": "2.9.1",
|
||||
"@tiptap/starter-kit": "2.9.1",
|
||||
"@types/react-helmet": "^6.1.11",
|
||||
"axios": "1.7.3",
|
||||
"axios": "^1.7.9",
|
||||
"bech32": "2.0.0",
|
||||
"buffer": "6.0.3",
|
||||
"date-fns": "3.6.0",
|
||||
@ -1206,208 +1206,266 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.18.1.tgz",
|
||||
"integrity": "sha512-lncuC4aHicncmbORnx+dUaAgzee9cm/PbIqgWz1PpXuwc+sa1Ct83tnqUDy/GFKleLiN7ZIeytM6KJ4cAn1SxA==",
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.28.1.tgz",
|
||||
"integrity": "sha512-2aZp8AES04KI2dy3Ss6/MDjXbwBzj+i0GqKtWXgw2/Ma6E4jJvujryO6gJAghIRVz7Vwr9Gtl/8na3nDUKpraQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.18.1.tgz",
|
||||
"integrity": "sha512-F/tkdw0WSs4ojqz5Ovrw5r9odqzFjb5LIgHdHZG65dFI1lWTWRVy32KDJLKRISHgJvqUeUhdIvy43fX41znyDg==",
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.28.1.tgz",
|
||||
"integrity": "sha512-EbkK285O+1YMrg57xVA+Dp0tDBRB93/BZKph9XhMjezf6F4TpYjaUSuPt5J0fZXlSag0LmZAsTmdGGqPp4pQFA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.18.1.tgz",
|
||||
"integrity": "sha512-vk+ma8iC1ebje/ahpxpnrfVQJibTMyHdWpOGZ3JpQ7Mgn/3QNHmPq7YwjZbIE7km73dH5M1e6MRRsnEBW7v5CQ==",
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.28.1.tgz",
|
||||
"integrity": "sha512-prduvrMKU6NzMq6nxzQw445zXgaDBbMQvmKSJaxpaZ5R1QDM8w+eGxo6Y/jhT/cLoCvnZI42oEqf9KQNYz1fqQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.18.1.tgz",
|
||||
"integrity": "sha512-IgpzXKauRe1Tafcej9STjSSuG0Ghu/xGYH+qG6JwsAUxXrnkvNHcq/NL6nz1+jzvWAnQkuAJ4uIwGB48K9OCGA==",
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.28.1.tgz",
|
||||
"integrity": "sha512-WsvbOunsUk0wccO/TV4o7IKgloJ942hVFK1CLatwv6TJspcCZb9umQkPdvB7FihmdxgaKR5JyxDjWpCOp4uZlQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.28.1.tgz",
|
||||
"integrity": "sha512-HTDPdY1caUcU4qK23FeeGxCdJF64cKkqajU0iBnTVxS8F7H/7BewvYoG+va1KPSL63kQ1PGNyiwKOfReavzvNA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.28.1.tgz",
|
||||
"integrity": "sha512-m/uYasxkUevcFTeRSM9TeLyPe2QDuqtjkeoTpP9SW0XxUWfcYrGDMkO/m2tTw+4NMAF9P2fU3Mw4ahNvo7QmsQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.18.1.tgz",
|
||||
"integrity": "sha512-P9bSiAUnSSM7EmyRK+e5wgpqai86QOSv8BwvkGjLwYuOpaeomiZWifEos517CwbG+aZl1T4clSE1YqqH2JRs+g==",
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.28.1.tgz",
|
||||
"integrity": "sha512-QAg11ZIt6mcmzpNE6JZBpKfJaKkqTm1A9+y9O+frdZJEuhQxiugM05gnCWiANHj4RmbgeVJpTdmKRmH/a+0QbA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.18.1.tgz",
|
||||
"integrity": "sha512-5RnjpACoxtS+aWOI1dURKno11d7krfpGDEn19jI8BuWmSBbUC4ytIADfROM1FZrFhQPSoP+KEa3NlEScznBTyQ==",
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.28.1.tgz",
|
||||
"integrity": "sha512-dRP9PEBfolq1dmMcFqbEPSd9VlRuVWEGSmbxVEfiq2cs2jlZAl0YNxFzAQS2OrQmsLBLAATDMb3Z6MFv5vOcXg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.18.1.tgz",
|
||||
"integrity": "sha512-8mwmGD668m8WaGbthrEYZ9CBmPug2QPGWxhJxh/vCgBjro5o96gL04WLlg5BA233OCWLqERy4YUzX3bJGXaJgQ==",
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.28.1.tgz",
|
||||
"integrity": "sha512-uGr8khxO+CKT4XU8ZUH1TTEUtlktK6Kgtv0+6bIFSeiSlnGJHG1tSFSjm41uQ9sAO/5ULx9mWOz70jYLyv1QkA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.18.1.tgz",
|
||||
"integrity": "sha512-dJX9u4r4bqInMGOAQoGYdwDP8lQiisWb9et+T84l2WXk41yEej8v2iGKodmdKimT8cTAYt0jFb+UEBxnPkbXEQ==",
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.28.1.tgz",
|
||||
"integrity": "sha512-QF54q8MYGAqMLrX2t7tNpi01nvq5RI59UBNx+3+37zoKX5KViPo/gk2QLhsuqok05sSCRluj0D00LzCwBikb0A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loongarch64-gnu": {
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.28.1.tgz",
|
||||
"integrity": "sha512-vPul4uodvWvLhRco2w0GcyZcdyBfpfDRgNKU+p35AWEbJ/HPs1tOUrkSueVbBS0RQHAf/A+nNtDpvw95PeVKOA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.18.1.tgz",
|
||||
"integrity": "sha512-V72cXdTl4EI0x6FNmho4D502sy7ed+LuVW6Ym8aI6DRQ9hQZdp5sj0a2usYOlqvFBNKQnLQGwmYnujo2HvjCxQ==",
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.28.1.tgz",
|
||||
"integrity": "sha512-pTnTdBuC2+pt1Rmm2SV7JWRqzhYpEILML4PKODqLz+C7Ou2apEV52h19CR7es+u04KlqplggmN9sqZlekg3R1A==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.18.1.tgz",
|
||||
"integrity": "sha512-f+pJih7sxoKmbjghrM2RkWo2WHUW8UbfxIQiWo5yeCaCM0TveMEuAzKJte4QskBp1TIinpnRcxkquY+4WuY/tg==",
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.28.1.tgz",
|
||||
"integrity": "sha512-vWXy1Nfg7TPBSuAncfInmAI/WZDd5vOklyLJDdIRKABcZWojNDY0NJwruY2AcnCLnRJKSaBgf/GiJfauu8cQZA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.18.1.tgz",
|
||||
"integrity": "sha512-qb1hMMT3Fr/Qz1OKovCuUM11MUNLUuHeBC2DPPAWUYYUAOFWaxInaTwTQmc7Fl5La7DShTEpmYwgdt2hG+4TEg==",
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.28.1.tgz",
|
||||
"integrity": "sha512-/yqC2Y53oZjb0yz8PVuGOQQNOTwxcizudunl/tFs1aLvObTclTwZ0JhXF2XcPT/zuaymemCDSuuUPXJJyqeDOg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.18.1.tgz",
|
||||
"integrity": "sha512-7O5u/p6oKUFYjRbZkL2FLbwsyoJAjyeXHCU3O4ndvzg2OFO2GinFPSJFGbiwFDaCFc+k7gs9CF243PwdPQFh5g==",
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.28.1.tgz",
|
||||
"integrity": "sha512-fzgeABz7rrAlKYB0y2kSEiURrI0691CSL0+KXwKwhxvj92VULEDQLpBYLHpF49MSiPG4sq5CK3qHMnb9tlCjBw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.18.1.tgz",
|
||||
"integrity": "sha512-pDLkYITdYrH/9Cv/Vlj8HppDuLMDUBmgsM0+N+xLtFd18aXgM9Nyqupb/Uw+HeidhfYg2lD6CXvz6CjoVOaKjQ==",
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.28.1.tgz",
|
||||
"integrity": "sha512-xQTDVzSGiMlSshpJCtudbWyRfLaNiVPXt1WgdWTwWz9n0U12cI2ZVtWe/Jgwyv/6wjL7b66uu61Vg0POWVfz4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.18.1.tgz",
|
||||
"integrity": "sha512-W2ZNI323O/8pJdBGil1oCauuCzmVd9lDmWBBqxYZcOqWD6aWqJtVBQ1dFrF4dYpZPks6F+xCZHfzG5hYlSHZ6g==",
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.28.1.tgz",
|
||||
"integrity": "sha512-wSXmDRVupJstFP7elGMgv+2HqXelQhuNf+IS4V+nUpNVi/GUiBgDmfwD0UGN3pcAnWsgKG3I52wMOBnk1VHr/A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.18.1.tgz",
|
||||
"integrity": "sha512-ELfEX1/+eGZYMaCIbK4jqLxO1gyTSOIlZr6pbC4SRYFaSIDVKOnZNMdoZ+ON0mrFDp4+H5MhwNC1H/AhE3zQLg==",
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.28.1.tgz",
|
||||
"integrity": "sha512-ZkyTJ/9vkgrE/Rk9vhMXhf8l9D+eAhbAVbsGsXKy2ohmJaWg0LPQLnIxRdRp/bKyr8tXuPlXhIoGlEB5XpJnGA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.18.1.tgz",
|
||||
"integrity": "sha512-yjk2MAkQmoaPYCSu35RLJ62+dz358nE83VfTePJRp8CG7aMg25mEJYpXFiD+NcevhX8LxD5OP5tktPXnXN7GDw==",
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.28.1.tgz",
|
||||
"integrity": "sha512-ZvK2jBafvttJjoIdKm/Q/Bh7IJ1Ose9IBOwpOXcOvW3ikGTQGmKDgxTC6oCAzW6PynbkKP8+um1du81XJHZ0JA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@ -1993,10 +2051,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
|
||||
"integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
|
||||
"dev": true
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
||||
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/file-saver": {
|
||||
"version": "2.0.7",
|
||||
@ -2434,9 +2493,10 @@
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.3.tgz",
|
||||
"integrity": "sha512-Ar7ND9pU99eJ9GpoGQKhKf58GpUOgnzuaB7ueNQ5BMi0p+LZ5oaEnfF999fAArcTIBwXTCHAmGcHOZJaWPq9Nw==",
|
||||
"version": "1.7.9",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz",
|
||||
"integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
@ -2719,10 +2779,11 @@
|
||||
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
@ -4359,10 +4420,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
|
||||
"integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==",
|
||||
"dev": true
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
@ -4377,9 +4439,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.4.39",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz",
|
||||
"integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==",
|
||||
"version": "8.4.49",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
|
||||
"integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@ -4395,10 +4457,11 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.7",
|
||||
"picocolors": "^1.0.1",
|
||||
"source-map-js": "^1.2.0"
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
@ -4912,12 +4975,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.18.1.tgz",
|
||||
"integrity": "sha512-Elx2UT8lzxxOXMpy5HWQGZqkrQOtrVDDa/bm9l10+U4rQnVzbL/LgZ4NOM1MPIDyHk69W4InuYDF5dzRh4Kw1A==",
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.28.1.tgz",
|
||||
"integrity": "sha512-61fXYl/qNVinKmGSTHAZ6Yy8I3YIJC/r2m9feHo6SwVAVcLT5MPwOUFe7EuURA/4m0NR8lXG4BBXuo/IZEsjMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.5"
|
||||
"@types/estree": "1.0.6"
|
||||
},
|
||||
"bin": {
|
||||
"rollup": "dist/bin/rollup"
|
||||
@ -4927,22 +4991,25 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.18.1",
|
||||
"@rollup/rollup-android-arm64": "4.18.1",
|
||||
"@rollup/rollup-darwin-arm64": "4.18.1",
|
||||
"@rollup/rollup-darwin-x64": "4.18.1",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.18.1",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.18.1",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.18.1",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.18.1",
|
||||
"@rollup/rollup-linux-powerpc64le-gnu": "4.18.1",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.18.1",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.18.1",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.18.1",
|
||||
"@rollup/rollup-linux-x64-musl": "4.18.1",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.18.1",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.18.1",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.18.1",
|
||||
"@rollup/rollup-android-arm-eabi": "4.28.1",
|
||||
"@rollup/rollup-android-arm64": "4.28.1",
|
||||
"@rollup/rollup-darwin-arm64": "4.28.1",
|
||||
"@rollup/rollup-darwin-x64": "4.28.1",
|
||||
"@rollup/rollup-freebsd-arm64": "4.28.1",
|
||||
"@rollup/rollup-freebsd-x64": "4.28.1",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.28.1",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.28.1",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.28.1",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.28.1",
|
||||
"@rollup/rollup-linux-loongarch64-gnu": "4.28.1",
|
||||
"@rollup/rollup-linux-powerpc64le-gnu": "4.28.1",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.28.1",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.28.1",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.28.1",
|
||||
"@rollup/rollup-linux-x64-musl": "4.28.1",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.28.1",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.28.1",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.28.1",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
@ -5042,10 +5109,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
|
||||
"integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@ -5397,14 +5465,15 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.3.3.tgz",
|
||||
"integrity": "sha512-NPQdeCU0Dv2z5fu+ULotpuq5yfCS1BzKUIPhNbP3YBfAMGJXbt2nS+sbTFu+qchaqWTD+H3JK++nRwr6XIcp6A==",
|
||||
"version": "5.4.11",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz",
|
||||
"integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.39",
|
||||
"rollup": "^4.13.0"
|
||||
"postcss": "^8.4.43",
|
||||
"rollup": "^4.20.0"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
@ -5423,6 +5492,7 @@
|
||||
"less": "*",
|
||||
"lightningcss": "^1.21.0",
|
||||
"sass": "*",
|
||||
"sass-embedded": "*",
|
||||
"stylus": "*",
|
||||
"sugarss": "*",
|
||||
"terser": "^5.4.0"
|
||||
@ -5440,6 +5510,9 @@
|
||||
"sass": {
|
||||
"optional": true
|
||||
},
|
||||
"sass-embedded": {
|
||||
"optional": true
|
||||
},
|
||||
"stylus": {
|
||||
"optional": true
|
||||
},
|
||||
|
@ -20,7 +20,7 @@
|
||||
"@tiptap/react": "2.9.1",
|
||||
"@tiptap/starter-kit": "2.9.1",
|
||||
"@types/react-helmet": "^6.1.11",
|
||||
"axios": "1.7.3",
|
||||
"axios": "^1.7.9",
|
||||
"bech32": "2.0.0",
|
||||
"buffer": "6.0.3",
|
||||
"date-fns": "3.6.0",
|
||||
|
27
src/assets/categories/categories.json
Normal file
27
src/assets/categories/categories.json
Normal file
@ -0,0 +1,27 @@
|
||||
[
|
||||
{
|
||||
"name": "audio",
|
||||
"sub": [
|
||||
{ "name": "music", "sub": ["background", "ambient"] },
|
||||
{
|
||||
"name": "sound effects",
|
||||
"sub": ["footsteps", "weapons"]
|
||||
},
|
||||
"voice"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "graphical",
|
||||
"sub": [
|
||||
{
|
||||
"name": "textures",
|
||||
"sub": ["highres textures", "lowres textures"]
|
||||
},
|
||||
"models",
|
||||
"shaders"
|
||||
]
|
||||
},
|
||||
{ "name": "user interface", "sub": ["hud", "menus", "icons"] },
|
||||
{ "name": "gameplay", "sub": ["mechanics", "balance", "ai"] },
|
||||
"bugfixes"
|
||||
]
|
72
src/components/AlertPopup.tsx
Normal file
72
src/components/AlertPopup.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { AlertPopupProps } from 'types'
|
||||
|
||||
export const AlertPopup = ({
|
||||
header,
|
||||
label,
|
||||
handleConfirm,
|
||||
handleClose
|
||||
}: AlertPopupProps) => {
|
||||
return createPortal(
|
||||
<div className='popUpMain'>
|
||||
<div className='ContainerMain'>
|
||||
<div className='popUpMainCardWrapper'>
|
||||
<div className='popUpMainCard popUpMainCardQR'>
|
||||
<div className='popUpMainCardTop'>
|
||||
<div className='popUpMainCardTopInfo'>
|
||||
<h3>{header}</h3>
|
||||
</div>
|
||||
<div className='popUpMainCardTopClose' onClick={handleClose}>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-96 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className='pUMCB_Zaps'>
|
||||
<div className='pUMCB_ZapsInside'>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label
|
||||
className='form-label labelMain'
|
||||
style={{ fontWeight: 'bold' }}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
gap: '10px'
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className='btn btnMain btnMainPopup'
|
||||
type='button'
|
||||
onPointerDown={() => handleConfirm(true)}
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
className='btn btnMain btnMainPopup'
|
||||
type='button'
|
||||
onPointerDown={() => handleConfirm(false)}
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
333
src/components/CategoryAutocomplete.tsx
Normal file
333
src/components/CategoryAutocomplete.tsx
Normal file
@ -0,0 +1,333 @@
|
||||
import { useLocalStorage } from 'hooks'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { getGamePageRoute } from 'routes'
|
||||
import { ModFormState, Categories, Category } from 'types'
|
||||
import {
|
||||
getCategories,
|
||||
flattenCategories,
|
||||
addToUserCategories,
|
||||
capitalizeEachWord
|
||||
} from 'utils'
|
||||
|
||||
interface CategoryAutocompleteProps {
|
||||
game: string
|
||||
LTags: string[]
|
||||
setFormState: (value: React.SetStateAction<ModFormState>) => void
|
||||
}
|
||||
|
||||
export const CategoryAutocomplete = ({
|
||||
game,
|
||||
LTags,
|
||||
setFormState
|
||||
}: CategoryAutocompleteProps) => {
|
||||
// Fetch the hardcoded categories from assets
|
||||
const flattenedCategories = useMemo(() => getCategories(), [])
|
||||
|
||||
// Fetch the user categories from local storage
|
||||
const [userHierarchies, setUserHierarchies] = useLocalStorage<
|
||||
(string | Category)[]
|
||||
>('user-hierarchies', [])
|
||||
const flattenedUserCategories = useMemo(
|
||||
() => flattenCategories(userHierarchies, []),
|
||||
[userHierarchies]
|
||||
)
|
||||
|
||||
// Create options and select categories from the mod LTags (hierarchies)
|
||||
const { selectedCategories, combinedOptions } = useMemo(() => {
|
||||
const combinedCategories = [
|
||||
...flattenedCategories,
|
||||
...flattenedUserCategories
|
||||
]
|
||||
const hierarchies = LTags.map((hierarchy) => {
|
||||
const existingCategory = combinedCategories.find(
|
||||
(cat) => cat.hierarchy === hierarchy.replace(/:/g, ' > ')
|
||||
)
|
||||
if (existingCategory) {
|
||||
return existingCategory
|
||||
} else {
|
||||
const segments = hierarchy.split(':')
|
||||
const lastSegment = segments[segments.length - 1]
|
||||
return { name: lastSegment, hierarchy: hierarchy, l: [lastSegment] }
|
||||
}
|
||||
})
|
||||
|
||||
// Selected categorires (based on the LTags)
|
||||
const selectedCategories = Array.from(new Set([...hierarchies]))
|
||||
|
||||
// Combine user, predefined category hierarchies and selected values (LTags in case some are missing)
|
||||
const combinedOptions = Array.from(
|
||||
new Set([...combinedCategories, ...selectedCategories])
|
||||
)
|
||||
|
||||
return { selectedCategories, combinedOptions }
|
||||
}, [LTags, flattenedCategories, flattenedUserCategories])
|
||||
|
||||
const [inputValue, setInputValue] = useState<string>('')
|
||||
const filteredOptions = useMemo(
|
||||
() =>
|
||||
combinedOptions.filter((option) =>
|
||||
option.hierarchy.toLowerCase().includes(inputValue.toLowerCase())
|
||||
),
|
||||
[combinedOptions, inputValue]
|
||||
)
|
||||
|
||||
const getSelectedCategories = (cats: Categories[]) => {
|
||||
const uniqueValues = new Set(
|
||||
cats.reduce<string[]>((prev, cat) => [...prev, ...cat.l], [])
|
||||
)
|
||||
const concatenatedValue = Array.from(uniqueValues)
|
||||
return concatenatedValue
|
||||
}
|
||||
const getSelectedHierarchy = (cats: Categories[]) => {
|
||||
const hierarchies = cats.reduce<string[]>(
|
||||
(prev, cat) => [...prev, cat.hierarchy.replace(/ > /g, ':')],
|
||||
[]
|
||||
)
|
||||
const concatenatedValue = Array.from(hierarchies)
|
||||
return concatenatedValue
|
||||
}
|
||||
const handleReset = () => {
|
||||
setFormState((prevState) => ({
|
||||
...prevState,
|
||||
['lTags']: [],
|
||||
['LTags']: []
|
||||
}))
|
||||
setInputValue('')
|
||||
}
|
||||
const handleRemove = (option: Categories) => {
|
||||
const updatedCategories = selectedCategories.filter(
|
||||
(cat) => cat.hierarchy !== option.hierarchy
|
||||
)
|
||||
setFormState((prevState) => ({
|
||||
...prevState,
|
||||
['lTags']: getSelectedCategories(updatedCategories),
|
||||
['LTags']: getSelectedHierarchy(updatedCategories)
|
||||
}))
|
||||
}
|
||||
const handleSelect = (option: Categories) => {
|
||||
if (!selectedCategories.some((cat) => cat.hierarchy === option.hierarchy)) {
|
||||
const updatedCategories = [...selectedCategories, option]
|
||||
setFormState((prevState) => ({
|
||||
...prevState,
|
||||
['lTags']: getSelectedCategories(updatedCategories),
|
||||
['LTags']: getSelectedHierarchy(updatedCategories)
|
||||
}))
|
||||
}
|
||||
setInputValue('')
|
||||
}
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value)
|
||||
}
|
||||
const handleAddNew = () => {
|
||||
if (inputValue) {
|
||||
const value = inputValue.trim().toLowerCase()
|
||||
const values = value.split('>').map((s) => s.trim())
|
||||
const newOption: Categories = {
|
||||
name: value,
|
||||
hierarchy: value,
|
||||
l: values
|
||||
}
|
||||
setUserHierarchies((prev) => {
|
||||
addToUserCategories(prev, value)
|
||||
return [...prev]
|
||||
})
|
||||
const updatedCategories = [...selectedCategories, newOption]
|
||||
setFormState((prevState) => ({
|
||||
...prevState,
|
||||
['lTags']: getSelectedCategories(updatedCategories),
|
||||
['LTags']: getSelectedHierarchy(updatedCategories)
|
||||
}))
|
||||
setInputValue('')
|
||||
}
|
||||
}
|
||||
const handleAddNewCustom = (option: Categories) => {
|
||||
setUserHierarchies((prev) => {
|
||||
addToUserCategories(prev, option.hierarchy)
|
||||
return [...prev]
|
||||
})
|
||||
}
|
||||
|
||||
const Row = ({ index }: { index: number }) => {
|
||||
return (
|
||||
<div
|
||||
className='dropdown-item dropdownMainMenuItem dropdownMainMenuItemCategory'
|
||||
onClick={() => handleSelect(filteredOptions[index])}
|
||||
>
|
||||
{capitalizeEachWord(filteredOptions[index].hierarchy)}
|
||||
|
||||
{/* Show "Remove" button when the category is selected */}
|
||||
{selectedCategories.some(
|
||||
(cat) => cat.hierarchy === filteredOptions[index].hierarchy
|
||||
) && (
|
||||
<button
|
||||
type='button'
|
||||
className='btn btnMain btnMainInsideField btnMainRemove'
|
||||
onClick={() => handleRemove(filteredOptions[index])}
|
||||
title='Remove'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-32 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M323.3 32.01H188.7C172.3 32.01 160 44.31 160 60.73V96.01H32C14.33 96.01 0 110.3 0 128S14.33 160 32 160H480c17.67 0 32-14.33 32-32.01S497.7 96.01 480 96.01H352v-35.28C352 44.31 339.7 32.01 323.3 32.01zM64.9 477.5C66.5 492.3 79.31 504 94.72 504H417.3c15.41 0 28.22-11.72 29.81-26.5L480 192.2H32L64.9 477.5z'></path>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Show "Add" button when the category is not included in the predefined or userdefined lists */}
|
||||
{!flattenedCategories.some(
|
||||
(cat) => cat.hierarchy === filteredOptions[index].hierarchy
|
||||
) &&
|
||||
!flattenedUserCategories.some(
|
||||
(cat) => cat.hierarchy === filteredOptions[index].hierarchy
|
||||
) && (
|
||||
<button
|
||||
type='button'
|
||||
className='btn btnMain btnMainInsideField btnMainAdd'
|
||||
onClick={() => handleAddNewCustom(filteredOptions[index])}
|
||||
title='Add'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-32 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M432 256c0 17.69-14.33 32.01-32 32.01H256v144c0 17.69-14.33 31.99-32 31.99s-32-14.3-32-31.99v-144H48c-17.67 0-32-14.32-32-32.01s14.33-31.99 32-31.99H192v-144c0-17.69 14.33-32.01 32-32.01s32 14.32 32 32.01v144h144C417.7 224 432 238.3 432 256z'></path>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain'>Categories</label>
|
||||
<p className='labelDescriptionMain'>You can select multiple categories</p>
|
||||
<div className='dropdown dropdownMain'>
|
||||
<div className='inputWrapperMain inputWrapperMainAlt'>
|
||||
<input
|
||||
type='text'
|
||||
className='inputMain inputMainWithBtn dropdown-toggle'
|
||||
placeholder='Select some categories...'
|
||||
data-bs-toggle='dropdown'
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<button
|
||||
className='btn btnMain btnMainInsideField btnMainRemove'
|
||||
title='Remove'
|
||||
type='button'
|
||||
onClick={handleReset}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M480 416C497.7 416 512 430.3 512 448C512 465.7 497.7 480 480 480H150.6C133.7 480 117.4 473.3 105.4 461.3L25.37 381.3C.3786 356.3 .3786 315.7 25.37 290.7L258.7 57.37C283.7 32.38 324.3 32.38 349.3 57.37L486.6 194.7C511.6 219.7 511.6 260.3 486.6 285.3L355.9 416H480zM265.4 416L332.7 348.7L195.3 211.3L70.63 336L150.6 416L265.4 416z'></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className='dropdown-menu dropdownMainMenu dropdownMainMenuAlt category'
|
||||
style={{
|
||||
maxHeight: '500px'
|
||||
}}
|
||||
>
|
||||
{filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((c, i) => <Row key={c.hierarchy} index={i} />)
|
||||
) : (
|
||||
<div
|
||||
className='dropdown-item dropdownMainMenuItem dropdownMainMenuItemCategory'
|
||||
onClick={handleAddNew}
|
||||
>
|
||||
{inputValue &&
|
||||
!filteredOptions?.find(
|
||||
(option) =>
|
||||
option.hierarchy.toLowerCase() === inputValue.toLowerCase()
|
||||
) ? (
|
||||
<>
|
||||
Add "{inputValue}"
|
||||
<button
|
||||
type='button'
|
||||
className='btn btnMain btnMainInsideField btnMainAdd'
|
||||
title='Add'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-32 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M432 256c0 17.69-14.33 32.01-32 32.01H256v144c0 17.69-14.33 31.99-32 31.99s-32-14.3-32-31.99v-144H48c-17.67 0-32-14.32-32-32.01s14.33-31.99 32-31.99H192v-144c0-17.69 14.33-32.01 32-32.01s32 14.32 32 32.01v144h144C417.7 224 432 238.3 432 256z'></path>
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>No matches</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{LTags.length > 0 && (
|
||||
<div className='IBMSMSMBSSCategories'>
|
||||
{LTags.map((hierarchy) => {
|
||||
const hierarchicalCategories = hierarchy.split(`:`)
|
||||
const categories = hierarchicalCategories
|
||||
.map<React.ReactNode>((c, i) => {
|
||||
const partialHierarchy = hierarchicalCategories
|
||||
.slice(0, i + 1)
|
||||
.join(':')
|
||||
|
||||
return game ? (
|
||||
<Link
|
||||
key={`category-${i}`}
|
||||
target='_blank'
|
||||
to={{
|
||||
pathname: getGamePageRoute(game),
|
||||
search: `h=${partialHierarchy}`
|
||||
}}
|
||||
className='IBMSMSMBSSCategoriesBoxItem'
|
||||
>
|
||||
<p>{capitalizeEachWord(c)}</p>
|
||||
</Link>
|
||||
) : (
|
||||
<p className='IBMSMSMBSSCategoriesBoxItem'>
|
||||
{capitalizeEachWord(c)}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
.reduce((prev, curr, i) => [
|
||||
prev,
|
||||
<div
|
||||
key={`separator-${i}`}
|
||||
className='IBMSMSMBSSCategoriesBoxSeparator'
|
||||
>
|
||||
<p>></p>
|
||||
</div>,
|
||||
curr
|
||||
])
|
||||
|
||||
return (
|
||||
<div key={hierarchy} className='IBMSMSMBSSCategoriesBox'>
|
||||
{categories}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,16 +1,11 @@
|
||||
import { useAppSelector, useLocalStorage } from 'hooks'
|
||||
import React from 'react'
|
||||
import {
|
||||
FilterOptions,
|
||||
ModeratedFilter,
|
||||
NSFWFilter,
|
||||
SortBy,
|
||||
WOTFilterOptions
|
||||
} from 'types'
|
||||
import { FilterOptions, ModeratedFilter, SortBy, WOTFilterOptions } from 'types'
|
||||
import { DEFAULT_FILTER_OPTIONS } from 'utils'
|
||||
import { Dropdown } from './Dropdown'
|
||||
import { Option } from './Option'
|
||||
import { Filter } from '.'
|
||||
import { NsfwFilterOptions } from './NsfwFilterOptions'
|
||||
|
||||
type Props = {
|
||||
author?: string | undefined
|
||||
@ -115,19 +110,7 @@ export const BlogsFilter = React.memo(
|
||||
|
||||
{/* nsfw filter options */}
|
||||
<Dropdown label={filterOptions.nsfw}>
|
||||
{Object.values(NSFWFilter).map((item, index) => (
|
||||
<Option
|
||||
key={`nsfwFilterItem-${index}`}
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
nsfw: item
|
||||
}))
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</Option>
|
||||
))}
|
||||
<NsfwFilterOptions filterKey={filterKey} />
|
||||
</Dropdown>
|
||||
|
||||
{/* source filter options */}
|
||||
|
3
src/components/Filters/CategoryFilterPopup.module.scss
Normal file
3
src/components/Filters/CategoryFilterPopup.module.scss
Normal file
@ -0,0 +1,3 @@
|
||||
.noResult:not(:only-child) {
|
||||
display: none;
|
||||
}
|
506
src/components/Filters/CategoryFilterPopup.tsx
Normal file
506
src/components/Filters/CategoryFilterPopup.tsx
Normal file
@ -0,0 +1,506 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Category } from 'types'
|
||||
import {
|
||||
addToUserCategories,
|
||||
capitalizeEachWord,
|
||||
deleteFromUserCategories,
|
||||
flattenCategories
|
||||
} from 'utils'
|
||||
import { useLocalStorage } from 'hooks'
|
||||
import styles from './CategoryFilterPopup.module.scss'
|
||||
import categoriesData from './../../assets/categories/categories.json'
|
||||
|
||||
interface CategoryFilterPopupProps {
|
||||
categories: string[]
|
||||
setCategories: React.Dispatch<React.SetStateAction<string[]>>
|
||||
hierarchies: string[]
|
||||
setHierarchies: React.Dispatch<React.SetStateAction<string[]>>
|
||||
handleClose: () => void
|
||||
}
|
||||
|
||||
export const CategoryFilterPopup = ({
|
||||
categories,
|
||||
setCategories,
|
||||
hierarchies,
|
||||
setHierarchies,
|
||||
handleClose
|
||||
}: CategoryFilterPopupProps) => {
|
||||
const [userHierarchies, setUserHierarchies] = useLocalStorage<
|
||||
(string | Category)[]
|
||||
>('user-hierarchies', [])
|
||||
const [filterCategories, setFilterCategories] = useState(categories)
|
||||
const [filterHierarchies, setFilterHierarchies] = useState(hierarchies)
|
||||
const handleApply = () => {
|
||||
setCategories(filterCategories)
|
||||
setHierarchies(filterHierarchies)
|
||||
}
|
||||
const [inputValue, setInputValue] = useState<string>('')
|
||||
const userHierarchiesMatching = useMemo(
|
||||
() =>
|
||||
flattenCategories(userHierarchies, []).some((h) =>
|
||||
h.hierarchy.includes(inputValue.toLowerCase())
|
||||
),
|
||||
[inputValue, userHierarchies]
|
||||
)
|
||||
// const hierarchiesMatching = useMemo(
|
||||
// () =>
|
||||
// flattenCategories(categoriesData, []).some((h) =>
|
||||
// h.hierarchy.includes(inputValue.toLowerCase())
|
||||
// ),
|
||||
// [inputValue]
|
||||
// )
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value)
|
||||
}
|
||||
const handleSingleSelection = (category: string, isSelected: boolean) => {
|
||||
let updatedCategories = [...filterCategories]
|
||||
if (isSelected) {
|
||||
updatedCategories.push(category)
|
||||
} else {
|
||||
updatedCategories = updatedCategories.filter((item) => item !== category)
|
||||
}
|
||||
setFilterCategories(updatedCategories)
|
||||
}
|
||||
const handleCombinationSelection = (path: string[], isSelected: boolean) => {
|
||||
const pathString = path.join(':')
|
||||
let updatedHierarchies = [...filterHierarchies]
|
||||
if (isSelected) {
|
||||
updatedHierarchies.push(pathString)
|
||||
} else {
|
||||
updatedHierarchies = updatedHierarchies.filter(
|
||||
(item) => item !== pathString
|
||||
)
|
||||
}
|
||||
setFilterHierarchies(updatedHierarchies)
|
||||
}
|
||||
const handleAddNew = () => {
|
||||
if (inputValue) {
|
||||
const value = inputValue.toLowerCase()
|
||||
const values = value
|
||||
.trim()
|
||||
.split('>')
|
||||
.map((s) => s.trim())
|
||||
|
||||
setUserHierarchies((prev) => {
|
||||
addToUserCategories(prev, value)
|
||||
return [...prev]
|
||||
})
|
||||
|
||||
const path = values.join(':')
|
||||
|
||||
// Add new hierarchy to current selection and active selection
|
||||
// Convert through set to remove duplicates
|
||||
setFilterHierarchies((prev) => {
|
||||
prev.push(path)
|
||||
return Array.from(new Set([...prev]))
|
||||
})
|
||||
setHierarchies((prev) => {
|
||||
prev.push(path)
|
||||
return Array.from(new Set([...prev]))
|
||||
})
|
||||
setInputValue('')
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className='popUpMain'>
|
||||
<div className='ContainerMain'>
|
||||
<div className='popUpMainCardWrapper'>
|
||||
<div className='popUpMainCard'>
|
||||
<div className='popUpMainCardTop'>
|
||||
<div className='popUpMainCardTopInfo'>
|
||||
<h3>Categories filter</h3>
|
||||
</div>
|
||||
<div className='popUpMainCardTopClose' onClick={handleClose}>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-96 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className='pUMCB_Zaps'>
|
||||
<div className='pUMCB_ZapsInside'>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label
|
||||
className='form-label labelMain'
|
||||
style={{ fontWeight: 'bold' }}
|
||||
>
|
||||
Choose categories...
|
||||
</label>
|
||||
<p className='labelDescriptionMain'>
|
||||
This is description for an input and how to use search here
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type='text'
|
||||
className='inputMain inputMainWithBtn'
|
||||
placeholder='Select some categories...'
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
{userHierarchies.length > 0 && (
|
||||
<>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label
|
||||
className='form-label labelMain'
|
||||
style={{ fontWeight: 'bold' }}
|
||||
>
|
||||
Custom categories
|
||||
</label>
|
||||
<p className='labelDescriptionMain'>Maybe</p>
|
||||
</div>
|
||||
<div
|
||||
className='inputMain'
|
||||
style={{
|
||||
minHeight: '40px',
|
||||
maxHeight: '500px',
|
||||
height: '100%',
|
||||
overflow: 'auto'
|
||||
}}
|
||||
>
|
||||
{!userHierarchiesMatching && <div>No results.</div>}
|
||||
{userHierarchies
|
||||
.filter((c) => typeof c !== 'string')
|
||||
.map((c, i) => (
|
||||
<CategoryCheckbox
|
||||
key={`${c}_${i}`}
|
||||
inputValue={inputValue}
|
||||
category={c}
|
||||
path={[c.name]}
|
||||
handleSingleSelection={handleSingleSelection}
|
||||
handleCombinationSelection={
|
||||
handleCombinationSelection
|
||||
}
|
||||
selectedSingles={filterCategories}
|
||||
selectedCombinations={filterHierarchies}
|
||||
handleRemove={(path) => {
|
||||
setUserHierarchies((prev) => {
|
||||
deleteFromUserCategories(prev, path.join('>'))
|
||||
return [...prev]
|
||||
})
|
||||
|
||||
// Remove the deleted hierarchies from current filter selection and active selection
|
||||
setFilterHierarchies((prev) =>
|
||||
prev.filter(
|
||||
(h) => !h.startsWith(path.join(':'))
|
||||
)
|
||||
)
|
||||
setHierarchies((prev) =>
|
||||
prev.filter(
|
||||
(h) => !h.startsWith(path.join(':'))
|
||||
)
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label
|
||||
className='form-label labelMain'
|
||||
style={{ fontWeight: 'bold' }}
|
||||
>
|
||||
Categories
|
||||
</label>
|
||||
<p className='labelDescriptionMain'>Maybe</p>
|
||||
</div>
|
||||
<div
|
||||
className='inputMain'
|
||||
style={{
|
||||
minHeight: '40px',
|
||||
maxHeight: '500px',
|
||||
height: '100%',
|
||||
overflow: 'auto'
|
||||
}}
|
||||
>
|
||||
<div className={`${styles.noResult}`}>
|
||||
<div>No results.</div>
|
||||
<br />
|
||||
{userHierarchiesMatching ? (
|
||||
<div>Already defined in your categories</div>
|
||||
) : (
|
||||
<div
|
||||
className='dropdown-item dropdownMainMenuItem dropdownMainMenuItemCategory'
|
||||
onClick={handleAddNew}
|
||||
>
|
||||
Add and search for "{inputValue}" category
|
||||
<button
|
||||
type='button'
|
||||
className='btn btnMain btnMainInsideField btnMainAdd'
|
||||
title='Add'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-32 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M432 256c0 17.69-14.33 32.01-32 32.01H256v144c0 17.69-14.33 31.99-32 31.99s-32-14.3-32-31.99v-144H48c-17.67 0-32-14.32-32-32.01s14.33-31.99 32-31.99H192v-144c0-17.69 14.33-32.01 32-32.01s32 14.32 32 32.01v144h144C417.7 224 432 238.3 432 256z'></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(categoriesData as Category[]).map((category) => {
|
||||
const name =
|
||||
typeof category === 'string' ? category : category.name
|
||||
return (
|
||||
<CategoryCheckbox
|
||||
key={name}
|
||||
inputValue={inputValue}
|
||||
category={category}
|
||||
path={[name]}
|
||||
handleSingleSelection={handleSingleSelection}
|
||||
handleCombinationSelection={
|
||||
handleCombinationSelection
|
||||
}
|
||||
selectedSingles={filterCategories}
|
||||
selectedCombinations={filterHierarchies}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
gap: '10px'
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className='btn btnMain btnMainPopup'
|
||||
type='button'
|
||||
onPointerDown={handleClose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className='btn btnMain btnMainPopup'
|
||||
type='button'
|
||||
onPointerDown={() => {
|
||||
setFilterCategories([])
|
||||
setFilterHierarchies([])
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
className='btn btnMain btnMainPopup'
|
||||
type='button'
|
||||
onPointerDown={() => {
|
||||
handleApply()
|
||||
handleClose()
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
interface CategoryCheckboxProps {
|
||||
inputValue: string
|
||||
category: Category | string
|
||||
path: string[]
|
||||
handleSingleSelection: (category: string, isSelected: boolean) => void
|
||||
handleCombinationSelection: (path: string[], isSelected: boolean) => void
|
||||
selectedSingles: string[]
|
||||
selectedCombinations: string[]
|
||||
indentLevel?: number
|
||||
handleRemove?: (path: string[]) => void
|
||||
}
|
||||
|
||||
const CategoryCheckbox: React.FC<CategoryCheckboxProps> = ({
|
||||
inputValue,
|
||||
category,
|
||||
path,
|
||||
handleSingleSelection,
|
||||
handleCombinationSelection,
|
||||
selectedSingles,
|
||||
selectedCombinations,
|
||||
indentLevel = 0,
|
||||
handleRemove
|
||||
}) => {
|
||||
const name = typeof category === 'string' ? category : category.name
|
||||
const isMatching = path
|
||||
.join(' > ')
|
||||
.toLowerCase()
|
||||
.includes(inputValue.toLowerCase())
|
||||
const [isSingleChecked, setIsSingleChecked] = useState<boolean>(false)
|
||||
const [isCombinationChecked, setIsCombinationChecked] =
|
||||
useState<boolean>(false)
|
||||
const [isIndeterminate, setIsIndeterminate] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
const pathString = path.join(':')
|
||||
setIsSingleChecked(selectedSingles.includes(name))
|
||||
setIsCombinationChecked(selectedCombinations.includes(pathString))
|
||||
// Recursive function to gather all descendant paths
|
||||
const collectChildPaths = (
|
||||
category: string | Category,
|
||||
basePath: string[]
|
||||
) => {
|
||||
if (!category.sub || !Array.isArray(category.sub)) {
|
||||
return []
|
||||
}
|
||||
let paths: string[] = []
|
||||
for (const sub of category.sub) {
|
||||
const subPath =
|
||||
typeof sub === 'string'
|
||||
? [...basePath, sub].join(':')
|
||||
: [...basePath, sub.name].join(':')
|
||||
paths.push(subPath)
|
||||
if (typeof sub === 'object') {
|
||||
paths = paths.concat(collectChildPaths(sub, [...basePath, sub.name]))
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
const childPaths = collectChildPaths(category, path)
|
||||
const anyChildCombinationSelected = childPaths.some((childPath) =>
|
||||
selectedCombinations.includes(childPath)
|
||||
)
|
||||
setIsIndeterminate(
|
||||
anyChildCombinationSelected && !selectedCombinations.includes(pathString)
|
||||
)
|
||||
}, [category, name, path, selectedCombinations, selectedSingles])
|
||||
|
||||
const handleSingleChange = () => {
|
||||
setIsSingleChecked(!isSingleChecked)
|
||||
handleSingleSelection(name, !isSingleChecked)
|
||||
}
|
||||
|
||||
const handleCombinationChange = () => {
|
||||
setIsCombinationChecked(!isCombinationChecked)
|
||||
handleCombinationSelection(path, !isCombinationChecked)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMatching && (
|
||||
<div
|
||||
className='dropdown-item dropdownMainMenuItem dropdownMainMenuItemCategory dropdownMainMenuItemCategoryAlt'
|
||||
style={{
|
||||
marginLeft: `${indentLevel * 20}px`,
|
||||
width: `calc(100% - ${indentLevel * 20}px)`
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`inputLabelWrapperMain inputLabelWrapperMainAlt stylized`}
|
||||
style={{
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<input
|
||||
id={name}
|
||||
type='checkbox'
|
||||
ref={(input) => {
|
||||
if (input) {
|
||||
input.indeterminate = isIndeterminate
|
||||
}
|
||||
}}
|
||||
className={`CheckboxMain ${
|
||||
isIndeterminate ? 'CheckboxIndeterminate' : ''
|
||||
}`}
|
||||
checked={isCombinationChecked}
|
||||
onChange={handleCombinationChange}
|
||||
/>
|
||||
<label
|
||||
htmlFor={name}
|
||||
className='form-label labelMain labelMainCategory'
|
||||
>
|
||||
{capitalizeEachWord(name)}
|
||||
</label>
|
||||
<input
|
||||
style={{
|
||||
display: 'none'
|
||||
}}
|
||||
id={name}
|
||||
type='checkbox'
|
||||
className='CheckboxMain'
|
||||
name={name}
|
||||
checked={isSingleChecked}
|
||||
onChange={handleSingleChange}
|
||||
/>
|
||||
{typeof handleRemove === 'function' && (
|
||||
<button
|
||||
className='btn btnMain btnMainInsideField btnMainRemove'
|
||||
title='Remove'
|
||||
type='button'
|
||||
onClick={() => handleRemove(path)}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-32 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M323.3 32.01H188.7C172.3 32.01 160 44.31 160 60.73V96.01H32C14.33 96.01 0 110.3 0 128S14.33 160 32 160H480c17.67 0 32-14.33 32-32.01S497.7 96.01 480 96.01H352v-35.28C352 44.31 339.7 32.01 323.3 32.01zM64.9 477.5C66.5 492.3 79.31 504 94.72 504H417.3c15.41 0 28.22-11.72 29.81-26.5L480 192.2H32L64.9 477.5z'></path>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{typeof category !== 'string' &&
|
||||
category.sub &&
|
||||
Array.isArray(category.sub) && (
|
||||
<>
|
||||
{category.sub.map((subCategory) => {
|
||||
if (typeof subCategory === 'string') {
|
||||
return (
|
||||
<CategoryCheckbox
|
||||
inputValue={inputValue}
|
||||
key={`${category.name}-${subCategory}`}
|
||||
category={{ name: subCategory }}
|
||||
path={[...path, subCategory]}
|
||||
handleSingleSelection={handleSingleSelection}
|
||||
handleCombinationSelection={handleCombinationSelection}
|
||||
selectedSingles={selectedSingles}
|
||||
selectedCombinations={selectedCombinations}
|
||||
indentLevel={indentLevel + 1}
|
||||
handleRemove={handleRemove}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<CategoryCheckbox
|
||||
inputValue={inputValue}
|
||||
key={subCategory.name}
|
||||
category={subCategory}
|
||||
path={[...path, subCategory.name]}
|
||||
handleSingleSelection={handleSingleSelection}
|
||||
handleCombinationSelection={handleCombinationSelection}
|
||||
selectedSingles={selectedSingles}
|
||||
selectedCombinations={selectedCombinations}
|
||||
indentLevel={indentLevel + 1}
|
||||
handleRemove={handleRemove}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,17 +1,17 @@
|
||||
import { useAppSelector, useLocalStorage } from 'hooks'
|
||||
import React from 'react'
|
||||
import React, { PropsWithChildren } from 'react'
|
||||
import {
|
||||
FilterOptions,
|
||||
SortBy,
|
||||
ModeratedFilter,
|
||||
WOTFilterOptions,
|
||||
NSFWFilter,
|
||||
RepostFilter
|
||||
} from 'types'
|
||||
import { DEFAULT_FILTER_OPTIONS } from 'utils'
|
||||
import { Filter } from '.'
|
||||
import { Dropdown } from './Dropdown'
|
||||
import { Option } from './Option'
|
||||
import { NsfwFilterOptions } from './NsfwFilterOptions'
|
||||
|
||||
type Props = {
|
||||
author?: string | undefined
|
||||
@ -19,7 +19,7 @@ type Props = {
|
||||
}
|
||||
|
||||
export const ModFilter = React.memo(
|
||||
({ author, filterKey = 'filter' }: Props) => {
|
||||
({ author, filterKey = 'filter', children }: PropsWithChildren<Props>) => {
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
const [filterOptions, setFilterOptions] = useLocalStorage<FilterOptions>(
|
||||
filterKey,
|
||||
@ -115,19 +115,7 @@ export const ModFilter = React.memo(
|
||||
|
||||
{/* nsfw filter options */}
|
||||
<Dropdown label={filterOptions.nsfw}>
|
||||
{Object.values(NSFWFilter).map((item, index) => (
|
||||
<Option
|
||||
key={`nsfwFilterItem-${index}`}
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
nsfw: item
|
||||
}))
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</Option>
|
||||
))}
|
||||
<NsfwFilterOptions filterKey={filterKey} />
|
||||
</Dropdown>
|
||||
|
||||
{/* repost filter options */}
|
||||
@ -176,6 +164,8 @@ export const ModFilter = React.memo(
|
||||
Show All
|
||||
</Option>
|
||||
</Dropdown>
|
||||
|
||||
{children}
|
||||
</Filter>
|
||||
)
|
||||
}
|
||||
|
64
src/components/Filters/NsfwFilterOptions.tsx
Normal file
64
src/components/Filters/NsfwFilterOptions.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import { FilterOptions, NSFWFilter } from 'types'
|
||||
import { Option } from './Option'
|
||||
import { NsfwAlertPopup } from 'components/NsfwAlertPopup'
|
||||
import { useState } from 'react'
|
||||
import { useLocalStorage } from 'hooks'
|
||||
import { DEFAULT_FILTER_OPTIONS } from 'utils'
|
||||
|
||||
interface NsfwFilterOptionsProps {
|
||||
filterKey: string
|
||||
}
|
||||
|
||||
export const NsfwFilterOptions = ({ filterKey }: NsfwFilterOptionsProps) => {
|
||||
const [, setFilterOptions] = useLocalStorage<FilterOptions>(
|
||||
filterKey,
|
||||
DEFAULT_FILTER_OPTIONS
|
||||
)
|
||||
const [showNsfwPopup, setShowNsfwPopup] = useState<boolean>(false)
|
||||
const [selectedNsfwOption, setSelectedNsfwOption] = useState<
|
||||
NSFWFilter | undefined
|
||||
>()
|
||||
const [confirmNsfw] = useLocalStorage<boolean>('confirm-nsfw', false)
|
||||
const handleConfirm = (confirm: boolean) => {
|
||||
if (confirm && selectedNsfwOption) {
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
nsfw: selectedNsfwOption
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{Object.values(NSFWFilter).map((item, index) => (
|
||||
<Option
|
||||
key={`nsfwFilterItem-${index}`}
|
||||
onClick={() => {
|
||||
// Trigger NSFW popup
|
||||
if (
|
||||
(item === NSFWFilter.Only_NSFW ||
|
||||
item === NSFWFilter.Show_NSFW) &&
|
||||
!confirmNsfw
|
||||
) {
|
||||
setSelectedNsfwOption(item)
|
||||
setShowNsfwPopup(true)
|
||||
} else {
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
nsfw: item
|
||||
}))
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</Option>
|
||||
))}
|
||||
{showNsfwPopup && (
|
||||
<NsfwAlertPopup
|
||||
handleConfirm={handleConfirm}
|
||||
handleClose={() => setShowNsfwPopup(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
@ -10,7 +10,7 @@ import React, {
|
||||
} from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import { FixedSizeList as List } from 'react-window'
|
||||
import { FixedSizeList } from 'react-window'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { T_TAG_VALUE } from '../constants'
|
||||
import { useAppSelector, useGames, useNDKContext } from '../hooks'
|
||||
@ -30,6 +30,8 @@ import { CheckboxField, InputError, InputField } from './Inputs'
|
||||
import { LoadingSpinner } from './LoadingSpinner'
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk'
|
||||
import { OriginalAuthor } from './OriginalAuthor'
|
||||
import { CategoryAutocomplete } from './CategoryAutocomplete'
|
||||
import { AlertPopup } from './AlertPopup'
|
||||
|
||||
interface FormErrors {
|
||||
game?: string
|
||||
@ -70,9 +72,10 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (location.pathname === appRoutes.submitMod) {
|
||||
// Only trigger when the pathname changes to submit-mod
|
||||
setFormState(initializeFormState())
|
||||
}
|
||||
}, [location.pathname]) // Only trigger when the pathname changes to submit-mod
|
||||
}, [location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
if (existingModData) {
|
||||
@ -174,6 +177,27 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
||||
[]
|
||||
)
|
||||
|
||||
const [showConfirmPopup, setShowConfirmPopup] = useState<boolean>(false)
|
||||
const handleReset = () => {
|
||||
setShowConfirmPopup(true)
|
||||
}
|
||||
const handleResetConfirm = (confirm: boolean) => {
|
||||
setShowConfirmPopup(false)
|
||||
|
||||
// Cancel if not confirmed
|
||||
if (!confirm) return
|
||||
|
||||
// Editing
|
||||
if (existingModData) {
|
||||
// Reset fields to the original existing data
|
||||
setFormState(initializeFormState(existingModData))
|
||||
return
|
||||
}
|
||||
|
||||
// New - set form state to the initial (clear form state)
|
||||
setFormState(initializeFormState())
|
||||
}
|
||||
|
||||
const handlePublish = async () => {
|
||||
setIsPublishing(true)
|
||||
|
||||
@ -231,6 +255,21 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
||||
tags.push(['originalAuthor', formState.originalAuthor])
|
||||
}
|
||||
|
||||
// Prepend com.degmods to avoid leaking categories to 3rd party client's search
|
||||
// Add hierarchical namespaces labels
|
||||
if (formState.LTags.length > 0) {
|
||||
for (let i = 0; i < formState.LTags.length; i++) {
|
||||
tags.push(['L', `com.degmods:${formState.LTags[i]}`])
|
||||
}
|
||||
}
|
||||
|
||||
// Add category labels
|
||||
if (formState.lTags.length > 0) {
|
||||
for (let i = 0; i < formState.lTags.length; i++) {
|
||||
tags.push(['l', `com.degmods:${formState.lTags[i]}`])
|
||||
}
|
||||
}
|
||||
|
||||
const unsignedEvent: UnsignedEvent = {
|
||||
kind: kinds.ClassifiedListing,
|
||||
created_at: currentTimeStamp,
|
||||
@ -445,6 +484,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
||||
className='btn btnMain btnMainAdd'
|
||||
type='button'
|
||||
onClick={addScreenshotUrl}
|
||||
title='Add'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
@ -489,6 +529,11 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
||||
error={formErrors.tags}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<CategoryAutocomplete
|
||||
game={formState.game}
|
||||
LTags={formState.LTags}
|
||||
setFormState={setFormState}
|
||||
/>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<div className='labelWrapperMain'>
|
||||
<label className='form-label labelMain'>Download URLs</label>
|
||||
@ -496,6 +541,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
||||
className='btn btnMain btnMainAdd'
|
||||
type='button'
|
||||
onClick={addDownloadUrl}
|
||||
title='Add'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
@ -532,7 +578,6 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
{formState.downloadUrls.length === 0 &&
|
||||
formErrors.downloadUrls &&
|
||||
formErrors.downloadUrls[0] && (
|
||||
@ -540,6 +585,14 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
||||
)}
|
||||
</div>
|
||||
<div className='IBMSMSMBS_WriteAction'>
|
||||
<button
|
||||
className='btn btnMain'
|
||||
type='button'
|
||||
onClick={handleReset}
|
||||
disabled={isPublishing}
|
||||
>
|
||||
{existingModData ? 'Reset' : 'Clear fields'}
|
||||
</button>
|
||||
<button
|
||||
className='btn btnMain'
|
||||
type='button'
|
||||
@ -549,6 +602,18 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
||||
Publish
|
||||
</button>
|
||||
</div>
|
||||
{showConfirmPopup && (
|
||||
<AlertPopup
|
||||
handleConfirm={handleResetConfirm}
|
||||
handleClose={() => setShowConfirmPopup(false)}
|
||||
header={'Are you sure?'}
|
||||
label={
|
||||
existingModData
|
||||
? `Are you sure you want to clear all changes?`
|
||||
: `Are you sure you want to clear all field data?`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -597,6 +662,7 @@ const DownloadUrlFields = React.memo(
|
||||
className='btn btnMain btnMainRemove'
|
||||
type='button'
|
||||
onClick={() => onRemove(index)}
|
||||
title='Remove'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
@ -751,6 +817,7 @@ const ScreenshotUrlFields = React.memo(
|
||||
className='btn btnMain btnMainRemove'
|
||||
type='button'
|
||||
onClick={() => onRemove(index)}
|
||||
title='Remove'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
@ -831,6 +898,7 @@ const GameDropdown = ({
|
||||
className='btn btnMain btnMainInsideField btnMainRemove'
|
||||
type='button'
|
||||
onClick={() => onChange('game', '')}
|
||||
title='Remove'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
@ -843,7 +911,7 @@ const GameDropdown = ({
|
||||
</svg>
|
||||
</button>
|
||||
<div className='dropdown-menu dropdownMainMenu dropdownMainMenuAlt'>
|
||||
<List
|
||||
<FixedSizeList
|
||||
height={500}
|
||||
width={'100%'}
|
||||
itemCount={filteredOptions.length}
|
||||
@ -865,7 +933,7 @@ const GameDropdown = ({
|
||||
{filteredOptions[index].label}
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
</FixedSizeList>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
36
src/components/NsfwAlertPopup.tsx
Normal file
36
src/components/NsfwAlertPopup.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { AlertPopupProps } from 'types'
|
||||
import { AlertPopup } from './AlertPopup'
|
||||
import { useLocalStorage } from 'hooks'
|
||||
|
||||
type NsfwAlertPopup = Omit<AlertPopupProps, 'header' | 'label'>
|
||||
|
||||
/**
|
||||
* Triggers when the user wants to switch the filter to see any of the NSFW options
|
||||
* (including preferences)
|
||||
*
|
||||
* Option will be remembered for the session only and will not show the popup again
|
||||
*/
|
||||
export const NsfwAlertPopup = ({
|
||||
handleConfirm,
|
||||
handleClose
|
||||
}: NsfwAlertPopup) => {
|
||||
const [confirmNsfw, setConfirmNsfw] = useLocalStorage<boolean>(
|
||||
'confirm-nsfw',
|
||||
false
|
||||
)
|
||||
|
||||
return (
|
||||
!confirmNsfw && (
|
||||
<AlertPopup
|
||||
header='Confirm'
|
||||
label='Are you above 18 years of age?'
|
||||
handleClose={handleClose}
|
||||
handleConfirm={(confirm: boolean) => {
|
||||
setConfirmNsfw(confirm)
|
||||
handleConfirm(confirm)
|
||||
handleClose()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)
|
||||
}
|
@ -3,12 +3,12 @@ import { CheckboxFieldUncontrolled } from 'components/Inputs'
|
||||
import { useEffect } from 'react'
|
||||
import { ReportReason } from 'types/report'
|
||||
import { LoadingSpinner } from './LoadingSpinner'
|
||||
import { PopupProps } from 'types'
|
||||
|
||||
type ReportPopupProps = {
|
||||
openedAt: number
|
||||
reasons: ReportReason[]
|
||||
handleClose: () => void
|
||||
}
|
||||
} & PopupProps
|
||||
|
||||
export const ReportPopup = ({
|
||||
openedAt,
|
||||
|
@ -8,3 +8,4 @@ export * from './useReactions'
|
||||
export * from './useNDKContext'
|
||||
export * from './useScrollDisable'
|
||||
export * from './useLocalStorage'
|
||||
export * from './useSessionStorage'
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
import {
|
||||
getLocalStorageItem,
|
||||
removeLocalStorageItem,
|
||||
@ -11,7 +11,11 @@ const useLocalStorageSubscribe = (callback: () => void) => {
|
||||
}
|
||||
|
||||
function mergeWithInitialValue<T>(storedValue: T, initialValue: T): T {
|
||||
if (typeof storedValue === 'object' && storedValue !== null) {
|
||||
if (
|
||||
!Array.isArray(storedValue) &&
|
||||
typeof storedValue === 'object' &&
|
||||
storedValue !== null
|
||||
) {
|
||||
return { ...initialValue, ...storedValue }
|
||||
}
|
||||
return storedValue
|
||||
@ -64,5 +68,7 @@ export function useLocalStorage<T>(
|
||||
}
|
||||
}, [key, initialValue])
|
||||
|
||||
return [JSON.parse(data) as T, setState]
|
||||
const memoized = useMemo(() => JSON.parse(data) as T, [data])
|
||||
|
||||
return [memoized, setState]
|
||||
}
|
||||
|
77
src/hooks/useSessionStorage.tsx
Normal file
77
src/hooks/useSessionStorage.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import {
|
||||
getSessionStorageItem,
|
||||
removeSessionStorageItem,
|
||||
setSessionStorageItem
|
||||
} from 'utils'
|
||||
|
||||
const useSessionStorageSubscribe = (callback: () => void) => {
|
||||
window.addEventListener('sessionStorage', callback)
|
||||
return () => window.removeEventListener('sessionStorage', callback)
|
||||
}
|
||||
|
||||
function mergeWithInitialValue<T>(storedValue: T, initialValue: T): T {
|
||||
if (
|
||||
!Array.isArray(storedValue) &&
|
||||
typeof storedValue === 'object' &&
|
||||
storedValue !== null
|
||||
) {
|
||||
return { ...initialValue, ...storedValue }
|
||||
}
|
||||
return storedValue
|
||||
}
|
||||
|
||||
export function useSessionStorage<T>(
|
||||
key: string,
|
||||
initialValue: T
|
||||
): [T, React.Dispatch<React.SetStateAction<T>>] {
|
||||
const getSnapshot = () => {
|
||||
// Get the stored value
|
||||
const storedValue = getSessionStorageItem(key, initialValue)
|
||||
|
||||
// Parse the value
|
||||
const parsedStoredValue = JSON.parse(storedValue)
|
||||
|
||||
// Merge the default and the stored in case some of the required fields are missing
|
||||
return JSON.stringify(
|
||||
mergeWithInitialValue(parsedStoredValue, initialValue)
|
||||
)
|
||||
}
|
||||
|
||||
const data = React.useSyncExternalStore(
|
||||
useSessionStorageSubscribe,
|
||||
getSnapshot
|
||||
)
|
||||
|
||||
const setState: React.Dispatch<React.SetStateAction<T>> = React.useCallback(
|
||||
(v: React.SetStateAction<T>) => {
|
||||
try {
|
||||
const nextState =
|
||||
typeof v === 'function'
|
||||
? (v as (prevState: T) => T)(JSON.parse(data))
|
||||
: v
|
||||
|
||||
if (nextState === undefined || nextState === null) {
|
||||
removeSessionStorageItem(key)
|
||||
} else {
|
||||
setSessionStorageItem(key, JSON.stringify(nextState))
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(e)
|
||||
}
|
||||
},
|
||||
[data, key]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
// Set session storage only when it's empty
|
||||
const data = window.sessionStorage.getItem(key)
|
||||
if (data === null) {
|
||||
setSessionStorageItem(key, JSON.stringify(initialValue))
|
||||
}
|
||||
}, [key, initialValue])
|
||||
|
||||
const memoized = useMemo(() => JSON.parse(data) as T, [data])
|
||||
|
||||
return [memoized, setState]
|
||||
}
|
@ -14,17 +14,16 @@ import { LoadingSpinner } from 'components/LoadingSpinner'
|
||||
import { Filter } from 'components/Filters'
|
||||
import { Dropdown } from 'components/Filters/Dropdown'
|
||||
import { Option } from 'components/Filters/Option'
|
||||
import { NsfwFilterOptions } from 'components/Filters/NsfwFilterOptions'
|
||||
|
||||
export const BlogsPage = () => {
|
||||
const navigation = useNavigation()
|
||||
const blogs = useLoaderData() as Partial<BlogCardDetails>[] | undefined
|
||||
const [filterOptions, setFilterOptions] = useLocalStorage(
|
||||
'filter-blog-curated',
|
||||
{
|
||||
sort: SortBy.Latest,
|
||||
nsfw: NSFWFilter.Hide_NSFW
|
||||
}
|
||||
)
|
||||
const filterKey = 'filter-blog-curated'
|
||||
const [filterOptions, setFilterOptions] = useLocalStorage(filterKey, {
|
||||
sort: SortBy.Latest,
|
||||
nsfw: NSFWFilter.Hide_NSFW
|
||||
})
|
||||
|
||||
// Search
|
||||
const searchTermRef = useRef<HTMLInputElement>(null)
|
||||
@ -147,19 +146,7 @@ export const BlogsPage = () => {
|
||||
</Dropdown>
|
||||
|
||||
<Dropdown label={filterOptions.nsfw}>
|
||||
{Object.values(NSFWFilter).map((item, index) => (
|
||||
<Option
|
||||
key={`nsfwFilterItem-${index}`}
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
nsfw: item
|
||||
}))
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</Option>
|
||||
))}
|
||||
<NsfwFilterOptions filterKey={filterKey} />
|
||||
</Dropdown>
|
||||
</Filter>
|
||||
|
||||
|
@ -14,7 +14,8 @@ import {
|
||||
useLocalStorage,
|
||||
useMuteLists,
|
||||
useNDKContext,
|
||||
useNSFWList
|
||||
useNSFWList,
|
||||
useSessionStorage
|
||||
} from 'hooks'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useParams, useSearchParams } from 'react-router-dom'
|
||||
@ -27,6 +28,7 @@ import {
|
||||
scrollIntoView
|
||||
} from 'utils'
|
||||
import { useCuratedSet } from 'hooks/useCuratedSet'
|
||||
import { CategoryFilterPopup } from 'components/Filters/CategoryFilterPopup'
|
||||
|
||||
export const GamePage = () => {
|
||||
const scrollTargetRef = useRef<HTMLDivElement>(null)
|
||||
@ -52,6 +54,13 @@ export const GamePage = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [searchTerm, setSearchTerm] = useState(searchParams.get('q') || '')
|
||||
|
||||
// Categories filter
|
||||
const [categories, setCategories] = useSessionStorage<string[]>('l', [])
|
||||
const [hierarchies, setHierarchies] = useSessionStorage<string[]>('h', [])
|
||||
const [showCategoryPopup, setShowCategoryPopup] = useState(false)
|
||||
const linkedHierarchy = searchParams.get('h')
|
||||
const isCategoryFilterActive = categories.length + hierarchies.length > 0
|
||||
|
||||
const handleSearch = () => {
|
||||
const value = searchTermRef.current?.value || '' // Access the input value from the ref
|
||||
setSearchTerm(value)
|
||||
@ -82,8 +91,38 @@ export const GamePage = () => {
|
||||
return true
|
||||
}
|
||||
|
||||
// If search term is missing, only filter by sources
|
||||
if (searchTerm === '') return mods.filter(filterSourceFn)
|
||||
const filterCategoryFn = (mod: ModDetails) => {
|
||||
// Linked overrides the category popup selection
|
||||
if (linkedHierarchy && linkedHierarchy !== '') {
|
||||
return mod.LTags.includes(linkedHierarchy)
|
||||
}
|
||||
|
||||
// If no selections are active return true
|
||||
if (!(hierarchies.length || categories.length)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Hierarchy selection active
|
||||
if (hierarchies.length) {
|
||||
const isMatch = mod.LTags.some((item) => hierarchies.includes(item))
|
||||
|
||||
// Matched hierarchy, return true immediately otherwise check categories
|
||||
if (isMatch) return isMatch
|
||||
}
|
||||
|
||||
// Category selection
|
||||
if (categories.length) {
|
||||
// Return result immediately
|
||||
return mod.lTags.some((item) => categories.includes(item))
|
||||
}
|
||||
|
||||
// No matches
|
||||
return false
|
||||
}
|
||||
|
||||
// If search term is missing, only filter by sources and category
|
||||
if (searchTerm === '')
|
||||
return mods.filter(filterSourceFn).filter(filterCategoryFn)
|
||||
|
||||
const lowerCaseSearchTerm = searchTerm.toLowerCase()
|
||||
|
||||
@ -96,8 +135,15 @@ export const GamePage = () => {
|
||||
tag.toLowerCase().includes(lowerCaseSearchTerm)
|
||||
) > -1
|
||||
|
||||
return mods.filter(filterFn).filter(filterSourceFn)
|
||||
}, [filterOptions.source, mods, searchTerm])
|
||||
return mods.filter(filterFn).filter(filterSourceFn).filter(filterCategoryFn)
|
||||
}, [
|
||||
categories,
|
||||
filterOptions.source,
|
||||
hierarchies,
|
||||
linkedHierarchy,
|
||||
mods,
|
||||
searchTerm
|
||||
])
|
||||
|
||||
const filteredModList = useFilteredMods(
|
||||
filteredMods,
|
||||
@ -123,6 +169,8 @@ export const GamePage = () => {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameName) return
|
||||
|
||||
const filter: NDKFilter = {
|
||||
kinds: [NDKKind.Classified],
|
||||
'#t': [T_TAG_VALUE]
|
||||
@ -188,7 +236,76 @@ export const GamePage = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ModFilter />
|
||||
<ModFilter>
|
||||
{linkedHierarchy && linkedHierarchy !== '' ? (
|
||||
<span
|
||||
className='IBMSMSMBSSTagsTag'
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
onClick={() => {
|
||||
searchParams.delete('h')
|
||||
setSearchParams(searchParams)
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 576 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M 3.9,22.9 C 10.5,8.9 24.5,0 40,0 h 432 c 15.5,0 29.5,8.9 36.1,22.9 6.6,14 4.6,30.5 -5.2,42.5 L 396.4,195.6 C 316.2,212.1 256,283 256,368 c 0,27.4 6.3,53.4 17.5,76.5 -1.6,-0.8 -3.2,-1.8 -4.7,-2.9 l -64,-48 C 196.7,387.6 192,378.1 192,368 V 288.9 L 9,65.3 C -0.7,53.4 -2.8,36.8 3.9,22.9 Z M 432,224 c 79.52906,0 143.99994,64.471 143.99994,144 0,79.529 -64.47088,144 -143.99994,144 -79.52906,0 -143.99994,-64.471 -143.99994,-144 0,-79.529 64.47088,-144 143.99994,-144 z' />
|
||||
</svg>
|
||||
{linkedHierarchy.replace(/:/g, ' > ')}
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='0.8em'
|
||||
height='0.8em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM175 175c9.4-9.4 24.6-9.4 33.9 0l47 47 47-47c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-47 47 47 47c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-47-47-47 47c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l47-47-47-47c-9.4-9.4-9.4-24.6 0-33.9z' />
|
||||
</svg>
|
||||
</span>
|
||||
) : (
|
||||
<div className='FiltersMainElement'>
|
||||
<button
|
||||
className='btn btnMain btnMainDropdown'
|
||||
type='button'
|
||||
onClick={() => {
|
||||
setShowCategoryPopup(true)
|
||||
}}
|
||||
>
|
||||
Categories
|
||||
{isCategoryFilterActive ? (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 576 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M 3.9,22.9 C 10.5,8.9 24.5,0 40,0 h 432 c 15.5,0 29.5,8.9 36.1,22.9 6.6,14 4.6,30.5 -5.2,42.5 L 396.4,195.6 C 316.2,212.1 256,283 256,368 c 0,27.4 6.3,53.4 17.5,76.5 -1.6,-0.8 -3.2,-1.8 -4.7,-2.9 l -64,-48 C 196.7,387.6 192,378.1 192,368 V 288.9 L 9,65.3 C -0.7,53.4 -2.8,36.8 3.9,22.9 Z M 432,224 c 79.52906,0 143.99994,64.471 143.99994,144 0,79.529 -64.47088,144 -143.99994,144 -79.52906,0 -143.99994,-64.471 -143.99994,-144 0,-79.529 64.47088,-144 143.99994,-144 z' />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M3.9 54.9C10.5 40.9 24.5 32 40 32l432 0c15.5 0 29.5 8.9 36.1 22.9s4.6 30.5-5.2 42.5L320 320.9 320 448c0 12.1-6.8 23.2-17.7 28.6s-23.8 4.3-33.5-3l-64-48c-8.1-6-12.8-15.5-12.8-25.6l0-79.1L9 97.3C-.7 85.4-2.8 68.8 3.9 54.9z' />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</ModFilter>
|
||||
|
||||
<div className='IBMSecMain IBMSMListWrapper'>
|
||||
<div className='IBMSMList'>
|
||||
{currentMods.map((mod) => (
|
||||
@ -204,6 +321,17 @@ export const GamePage = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showCategoryPopup && (
|
||||
<CategoryFilterPopup
|
||||
categories={categories}
|
||||
setCategories={setCategories}
|
||||
hierarchies={hierarchies}
|
||||
setHierarchies={setHierarchies}
|
||||
handleClose={() => {
|
||||
setShowCategoryPopup(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -28,6 +28,7 @@ import '../../styles/tags.css'
|
||||
import '../../styles/write.css'
|
||||
import { DownloadUrl, ModPageLoaderResult } from '../../types'
|
||||
import {
|
||||
capitalizeEachWord,
|
||||
copyTextToClipboard,
|
||||
downloadFile,
|
||||
getFilenameFromUrl
|
||||
@ -103,8 +104,10 @@ export const ModPage = () => {
|
||||
featuredImageUrl={mod.featuredImageUrl}
|
||||
title={mod.title}
|
||||
body={mod.body}
|
||||
game={mod.game}
|
||||
screenshotsUrls={mod.screenshotsUrls}
|
||||
tags={mod.tags}
|
||||
LTags={mod.LTags}
|
||||
nsfw={mod.nsfw}
|
||||
repost={mod.repost}
|
||||
originalAuthor={mod.originalAuthor}
|
||||
@ -424,8 +427,10 @@ type BodyProps = {
|
||||
featuredImageUrl: string
|
||||
title: string
|
||||
body: string
|
||||
game: string
|
||||
screenshotsUrls: string[]
|
||||
tags: string[]
|
||||
LTags: string[]
|
||||
nsfw: boolean
|
||||
repost: boolean
|
||||
originalAuthor?: string
|
||||
@ -433,10 +438,12 @@ type BodyProps = {
|
||||
|
||||
const Body = ({
|
||||
featuredImageUrl,
|
||||
game,
|
||||
title,
|
||||
body,
|
||||
screenshotsUrls,
|
||||
tags,
|
||||
LTags,
|
||||
nsfw,
|
||||
repost,
|
||||
originalAuthor
|
||||
@ -532,6 +539,46 @@ const Body = ({
|
||||
{tag}
|
||||
</a>
|
||||
))}
|
||||
|
||||
{LTags.length > 0 && (
|
||||
<div className='IBMSMSMBSSCategories'>
|
||||
{LTags.map((hierarchy) => {
|
||||
const hierarchicalCategories = hierarchy.split(`:`)
|
||||
const categories = hierarchicalCategories
|
||||
.map<React.ReactNode>((c, i) => {
|
||||
const partialHierarchy = hierarchicalCategories
|
||||
.slice(0, i + 1)
|
||||
.join(':')
|
||||
|
||||
return (
|
||||
<ReactRouterLink
|
||||
className='IBMSMSMBSSCategoriesBoxItem'
|
||||
target='_blank'
|
||||
to={{
|
||||
pathname: getGamePageRoute(game),
|
||||
search: `h=${partialHierarchy}`
|
||||
}}
|
||||
>
|
||||
<p>{capitalizeEachWord(c)}</p>
|
||||
</ReactRouterLink>
|
||||
)
|
||||
})
|
||||
.reduce((prev, curr) => [
|
||||
prev,
|
||||
<div className='IBMSMSMBSSCategoriesBoxSeparator'>
|
||||
<p>></p>
|
||||
</div>,
|
||||
curr
|
||||
])
|
||||
|
||||
return (
|
||||
<div key={hierarchy} className='IBMSMSMBSSCategoriesBox'>
|
||||
{categories}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,6 +1,12 @@
|
||||
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk'
|
||||
import { LoadingSpinner } from 'components/LoadingSpinner'
|
||||
import { useAppDispatch, useAppSelector, useNDKContext } from 'hooks'
|
||||
import { NsfwAlertPopup } from 'components/NsfwAlertPopup'
|
||||
import {
|
||||
useAppDispatch,
|
||||
useAppSelector,
|
||||
useNDKContext,
|
||||
useLocalStorage
|
||||
} from 'hooks'
|
||||
import { kinds, UnsignedEvent, Event } from 'nostr-tools'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
@ -19,6 +25,13 @@ export const PreferencesSetting = () => {
|
||||
const [wotLevel, setWotLevel] = useState(userWotLevel)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const [nsfw, setNsfw] = useState(false)
|
||||
const [confirmNsfw] = useLocalStorage<boolean>('confirm-nsfw', false)
|
||||
const [showNsfwPopup, setShowNsfwPopup] = useState<boolean>(false)
|
||||
const handleNsfwConfirm = (confirm: boolean) => {
|
||||
setNsfw(confirm)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.pubkey) {
|
||||
const hexPubkey = user.pubkey as string
|
||||
@ -191,6 +204,14 @@ export const PreferencesSetting = () => {
|
||||
type='checkbox'
|
||||
className='CheckboxMain'
|
||||
name='NSFWPreference'
|
||||
checked={nsfw}
|
||||
onChange={(e) => {
|
||||
if (e.currentTarget.checked && !confirmNsfw) {
|
||||
setShowNsfwPopup(true)
|
||||
} else {
|
||||
setNsfw(e.currentTarget.checked)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -238,6 +259,12 @@ export const PreferencesSetting = () => {
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
{showNsfwPopup && (
|
||||
<NsfwAlertPopup
|
||||
handleConfirm={handleNsfwConfirm}
|
||||
handleClose={() => setShowNsfwPopup(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import {
|
||||
Form,
|
||||
useActionData,
|
||||
@ -24,6 +24,7 @@ import { EditorContent, useEditor } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import Image from '@tiptap/extension-image'
|
||||
import { AlertPopup } from 'components/AlertPopup'
|
||||
|
||||
export const WritePage = () => {
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
@ -53,6 +54,20 @@ export const WritePage = () => {
|
||||
}
|
||||
})
|
||||
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
const [showConfirmPopup, setShowConfirmPopup] = useState<boolean>(false)
|
||||
const handleReset = () => {
|
||||
setShowConfirmPopup(true)
|
||||
}
|
||||
const handleResetConfirm = (confirm: boolean) => {
|
||||
setShowConfirmPopup(false)
|
||||
|
||||
// Cancel if not confirmed
|
||||
if (!confirm) return
|
||||
|
||||
formRef.current?.reset()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='InnerBodyMain'>
|
||||
<div className='ContainerMain'>
|
||||
@ -68,7 +83,11 @@ export const WritePage = () => {
|
||||
{navigation.state === 'submitting' && (
|
||||
<LoadingSpinner desc='Publishing blog to relays' />
|
||||
)}
|
||||
<Form className='IBMSMSMBS_Write' method={blog ? 'put' : 'post'}>
|
||||
<Form
|
||||
ref={formRef}
|
||||
className='IBMSMSMBS_Write'
|
||||
method={blog ? 'put' : 'post'}
|
||||
>
|
||||
<InputFieldUncontrolled
|
||||
label='Title'
|
||||
name='title'
|
||||
@ -130,6 +149,17 @@ export const WritePage = () => {
|
||||
/>
|
||||
)}
|
||||
<div className='IBMSMSMBS_WriteAction'>
|
||||
<button
|
||||
className='btn btnMain'
|
||||
type='button'
|
||||
onClick={handleReset}
|
||||
disabled={
|
||||
navigation.state === 'loading' ||
|
||||
navigation.state === 'submitting'
|
||||
}
|
||||
>
|
||||
{blog ? 'Reset' : 'Clear fields'}
|
||||
</button>
|
||||
<button
|
||||
className='btn btnMain'
|
||||
type='submit'
|
||||
@ -143,6 +173,18 @@ export const WritePage = () => {
|
||||
: 'Publish'}
|
||||
</button>
|
||||
</div>
|
||||
{showConfirmPopup && (
|
||||
<AlertPopup
|
||||
handleConfirm={handleResetConfirm}
|
||||
handleClose={() => setShowConfirmPopup(false)}
|
||||
header={'Are you sure?'}
|
||||
label={
|
||||
blog
|
||||
? `Are you sure you want to clear all changes?`
|
||||
: `Are you sure you want to clear all field data?`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
{userState.auth && userState.user?.pubkey && (
|
||||
|
@ -271,16 +271,16 @@ h6 {
|
||||
}
|
||||
|
||||
/* the 4 classes below here are a temp fix for the games dropdown stylings */
|
||||
|
||||
.dropdownMainMenu.dropdownMainMenuAlt {
|
||||
/* add an exception (not category) for normal dropdown - due !important */
|
||||
.dropdownMainMenu.dropdownMainMenuAlt:not(.category) {
|
||||
max-height: unset !important;
|
||||
}
|
||||
|
||||
.dropdownMainMenu.dropdownMainMenuAlt > div {
|
||||
.dropdownMainMenu.dropdownMainMenuAlt:not(.category) > div {
|
||||
height: unset !important;
|
||||
}
|
||||
|
||||
.dropdownMainMenu.dropdownMainMenuAlt > div > div {
|
||||
.dropdownMainMenu.dropdownMainMenuAlt:not(.category) > div > div {
|
||||
height: unset !important;
|
||||
width: 100% !important;
|
||||
display: flex;
|
||||
@ -291,14 +291,14 @@ h6 {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.dropdownMainMenu.dropdownMainMenuAlt > div > div > div {
|
||||
.dropdownMainMenu.dropdownMainMenuAlt:not(.category) > div > div > div {
|
||||
position: relative !important;
|
||||
left: unset !important;
|
||||
top: unset !important;
|
||||
}
|
||||
|
||||
.dropdownMainMenuItem {
|
||||
transition: ease 0.4s;
|
||||
transition: background ease 0.4s, color ease 0.4s;
|
||||
background: linear-gradient(
|
||||
rgba(255, 255, 255, 0.03),
|
||||
rgba(255, 255, 255, 0.03)
|
||||
@ -318,8 +318,16 @@ h6 {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dropdownMainMenuItem.dropdownMainMenuItemCategory {
|
||||
position: relative;
|
||||
}
|
||||
.dropdownMainMenuItemCategory.dropdownMainMenuItemCategoryAlt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dropdownMainMenuItem:hover {
|
||||
transition: ease 0.4s;
|
||||
transition: background ease 0.4s, color ease 0.4s;
|
||||
background: linear-gradient(
|
||||
rgba(255, 255, 255, 0.05),
|
||||
rgba(255, 255, 255, 0.05)
|
||||
@ -385,6 +393,10 @@ h6 {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.labelMain.labelMainCategory {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.inputWrapperMain {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
@ -530,6 +542,15 @@ h6 {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.CheckboxMain.CheckboxIndeterminate::before {
|
||||
content: '\2501';
|
||||
transition: ease 0.2s;
|
||||
transform: scale(1);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 0 8px 0 rgb(0, 0, 0, 0.1);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.CheckboxMain:checked::before {
|
||||
transition: ease 0.2s;
|
||||
transform: scale(1);
|
||||
@ -632,6 +653,9 @@ a:hover {
|
||||
bottom: 0;
|
||||
border-radius: unset;
|
||||
}
|
||||
.btnMainInsideField + .btnMainInsideField {
|
||||
right: 50px;
|
||||
}
|
||||
|
||||
.inputMain.inputMainWithBtn {
|
||||
padding-right: 50px;
|
||||
|
@ -11,5 +11,5 @@
|
||||
flex-direction: row;
|
||||
justify-content: end;
|
||||
align-items: center;
|
||||
gap: 25px;
|
||||
}
|
||||
|
||||
|
12
src/types/category.ts
Normal file
12
src/types/category.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export interface Category {
|
||||
name: string
|
||||
sub?: (Category | string)[]
|
||||
}
|
||||
|
||||
export type CategoriesData = Category[]
|
||||
|
||||
export interface Categories {
|
||||
name: string
|
||||
hierarchy: string
|
||||
l: string[]
|
||||
}
|
@ -4,3 +4,5 @@ export * from './nostr'
|
||||
export * from './user'
|
||||
export * from './zap'
|
||||
export * from './blog'
|
||||
export * from './category'
|
||||
export * from './popup'
|
||||
|
@ -31,6 +31,10 @@ export interface ModFormState {
|
||||
originalAuthor?: string
|
||||
screenshotsUrls: string[]
|
||||
tags: string
|
||||
/** Hierarchical labels */
|
||||
LTags: string[]
|
||||
/** Category labels for category search */
|
||||
lTags: string[]
|
||||
downloadUrls: DownloadUrl[]
|
||||
}
|
||||
|
||||
|
9
src/types/popup.ts
Normal file
9
src/types/popup.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export interface PopupProps {
|
||||
handleClose: () => void
|
||||
}
|
||||
|
||||
export interface AlertPopupProps extends PopupProps {
|
||||
header: string
|
||||
label: string
|
||||
handleConfirm: (confirm: boolean) => void
|
||||
}
|
84
src/utils/category.ts
Normal file
84
src/utils/category.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { Categories, Category } from 'types/category'
|
||||
import categoriesData from './../assets/categories/categories.json'
|
||||
|
||||
export const flattenCategories = (
|
||||
categories: (Category | string)[],
|
||||
parentPath: string[] = []
|
||||
): Categories[] => {
|
||||
return categories.flatMap<Categories, Category | string>((cat) => {
|
||||
if (typeof cat === 'string') {
|
||||
const path = [...parentPath, cat]
|
||||
const hierarchy = path.join(' > ')
|
||||
return [{ name: cat, hierarchy, l: path }]
|
||||
} else {
|
||||
const path = [...parentPath, cat.name]
|
||||
const hierarchy = path.join(' > ')
|
||||
if (cat.sub) {
|
||||
const obj: Categories = { name: cat.name, hierarchy, l: path }
|
||||
return [obj].concat(flattenCategories(cat.sub, path))
|
||||
}
|
||||
return [{ name: cat.name, hierarchy, l: path }]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const getCategories = () => {
|
||||
return flattenCategories(categoriesData)
|
||||
}
|
||||
|
||||
export const addToUserCategories = (
|
||||
categories: (string | Category)[],
|
||||
input: string
|
||||
) => {
|
||||
const segments = input.split('>').map((s) => s.trim())
|
||||
let currentLevel: (string | Category)[] = categories
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i].trim()
|
||||
const existingNode = currentLevel.find(
|
||||
(item) => typeof item !== 'string' && item.name === segment
|
||||
)
|
||||
if (!existingNode) {
|
||||
const newCategory: Category = { name: segment, sub: [] }
|
||||
currentLevel.push(newCategory)
|
||||
if (newCategory.sub) {
|
||||
currentLevel = newCategory.sub
|
||||
}
|
||||
} else if (typeof existingNode !== 'string') {
|
||||
if (!existingNode.sub) {
|
||||
existingNode.sub = []
|
||||
}
|
||||
currentLevel = existingNode.sub
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const deleteFromUserCategories = (
|
||||
categories: (string | Category)[],
|
||||
input: string
|
||||
) => {
|
||||
const segments = input.split('>').map((s) => s.trim())
|
||||
const value = segments.pop()
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
|
||||
let currentLevel: (string | Category)[] = categories
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const key = segments[i]
|
||||
const existingNode = currentLevel.find(
|
||||
(item) => typeof item === 'object' && item.name === key
|
||||
) as Category
|
||||
|
||||
if (existingNode && existingNode.sub) {
|
||||
currentLevel = existingNode.sub
|
||||
}
|
||||
}
|
||||
const valueIndex = currentLevel.findIndex(
|
||||
(item) =>
|
||||
item === value || (typeof item === 'object' && item.name === value)
|
||||
)
|
||||
if (valueIndex !== -1) {
|
||||
currentLevel.splice(valueIndex, 1)
|
||||
}
|
||||
}
|
@ -4,6 +4,8 @@ export * from './url'
|
||||
export * from './utils'
|
||||
export * from './zap'
|
||||
export * from './localStorage'
|
||||
export * from './sessionStorage'
|
||||
export * from './consts'
|
||||
export * from './blog'
|
||||
export * from './curationSets'
|
||||
export * from './category'
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { ModDetails, ModFormState } from '../types'
|
||||
import { getTagValue } from './nostr'
|
||||
import { getTagValue, getTagValues } from './nostr'
|
||||
|
||||
/**
|
||||
* Extracts and normalizes mod data from an event.
|
||||
@ -43,6 +43,12 @@ export const extractModData = (event: Event | NDKEvent): ModDetails => {
|
||||
originalAuthor: getFirstTagValue('originalAuthor'),
|
||||
screenshotsUrls: getTagValue(event, 'screenshotsUrls') || [],
|
||||
tags: getTagValue(event, 'tags') || [],
|
||||
LTags: (getTagValues(event, 'L') || []).map((t) =>
|
||||
t.replace('com.degmods:', '')
|
||||
),
|
||||
lTags: (getTagValues(event, 'l') || []).map((t) =>
|
||||
t.replace('com.degmods:', '')
|
||||
),
|
||||
downloadUrls: (getTagValue(event, 'downloadUrls') || []).map((item) =>
|
||||
JSON.parse(item)
|
||||
)
|
||||
@ -124,6 +130,8 @@ export const initializeFormState = (
|
||||
originalAuthor: existingModData?.originalAuthor || undefined,
|
||||
screenshotsUrls: existingModData?.screenshotsUrls || [''],
|
||||
tags: existingModData?.tags.join(',') || '',
|
||||
lTags: existingModData?.lTags || [],
|
||||
LTags: existingModData?.LTags || [],
|
||||
downloadUrls: existingModData?.downloadUrls || [
|
||||
{
|
||||
url: '',
|
||||
|
32
src/utils/sessionStorage.ts
Normal file
32
src/utils/sessionStorage.ts
Normal file
@ -0,0 +1,32 @@
|
||||
export function getSessionStorageItem<T>(key: string, defaultValue: T): string {
|
||||
try {
|
||||
const data = window.sessionStorage.getItem(key)
|
||||
if (data === null) return JSON.stringify(defaultValue)
|
||||
return data
|
||||
} catch (err) {
|
||||
console.error(`Error while fetching session storage value: `, err)
|
||||
return JSON.stringify(defaultValue)
|
||||
}
|
||||
}
|
||||
|
||||
export function setSessionStorageItem(key: string, value: string) {
|
||||
try {
|
||||
window.sessionStorage.setItem(key, value)
|
||||
dispatchSessionStorageEvent(key, value)
|
||||
} catch (err) {
|
||||
console.error(`Error while saving session storage value: `, err)
|
||||
}
|
||||
}
|
||||
|
||||
export function removeSessionStorageItem(key: string) {
|
||||
try {
|
||||
window.sessionStorage.removeItem(key)
|
||||
dispatchSessionStorageEvent(key, null)
|
||||
} catch (err) {
|
||||
console.error(`Error while deleting session storage value: `, err)
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchSessionStorageEvent(key: string, newValue: string | null) {
|
||||
window.dispatchEvent(new StorageEvent('sessionStorage', { key, newValue }))
|
||||
}
|
@ -156,3 +156,7 @@ export const parseFormData = <T>(formData: FormData) => {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export const capitalizeEachWord = (str: string): string => {
|
||||
return str.replace(/\b\w/g, (char) => char.toUpperCase())
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user