feat: categories and popups #171

Merged
enes merged 22 commits from 116-categories into staging 2024-12-12 16:37:38 +00:00
32 changed files with 1832 additions and 181 deletions

265
package-lock.json generated
View File

@ -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
},

View File

@ -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",

View 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"
]

View 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
)
}

View 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>&gt;</p>
</div>,
curr
])
return (
<div key={hierarchy} className='IBMSMSMBSSCategoriesBox'>
{categories}
</div>
)
})}
</div>
)}
</div>
)
}

View File

@ -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 */}

View File

@ -0,0 +1,3 @@
.noResult:not(:only-child) {
display: none;
}

View 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}
/>
)
}
})}
</>
)}
</>
)
}

View File

@ -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>
)
}

View 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)}
/>
)}
</>
)
}

View File

@ -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>

View 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()
}}
/>
)
)
}

View File

@ -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,

View File

@ -8,3 +8,4 @@ export * from './useReactions'
export * from './useNDKContext'
export * from './useScrollDisable'
export * from './useLocalStorage'
export * from './useSessionStorage'

View File

@ -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]
}

View 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]
}

View File

@ -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>

View File

@ -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)
}}
/>
)}
</>
)
}

View File

@ -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>&gt;</p>
</div>,
curr
])
return (
<div key={hierarchy} className='IBMSMSMBSSCategoriesBox'>
{categories}
</div>
)
})}
</div>
)}
</div>
</div>
</div>

View File

@ -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>

View File

@ -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 && (

View File

@ -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;

View File

@ -11,5 +11,5 @@
flex-direction: row;
justify-content: end;
align-items: center;
gap: 25px;
}

12
src/types/category.ts Normal file
View 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[]
}

View File

@ -4,3 +4,5 @@ export * from './nostr'
export * from './user'
export * from './zap'
export * from './blog'
export * from './category'
export * from './popup'

View File

@ -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
View 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
View 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)
}
}

View File

@ -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'

View File

@ -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: '',

View 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 }))
}

View File

@ -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())
}