diff --git a/package-lock.json b/package-lock.json index 19b9ff3..5368607 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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 }, diff --git a/package.json b/package.json index b356c8a..a99abbb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/assets/categories/categories.json b/src/assets/categories/categories.json new file mode 100644 index 0000000..eaa4079 --- /dev/null +++ b/src/assets/categories/categories.json @@ -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" +] diff --git a/src/components/AlertPopup.tsx b/src/components/AlertPopup.tsx new file mode 100644 index 0000000..e9334ee --- /dev/null +++ b/src/components/AlertPopup.tsx @@ -0,0 +1,72 @@ +import { createPortal } from 'react-dom' +import { AlertPopupProps } from 'types' + +export const AlertPopup = ({ + header, + label, + handleConfirm, + handleClose +}: AlertPopupProps) => { + return createPortal( +
+
+
+
+
+
+

{header}

+
+
+ + + +
+
+
+
+
+ +
+
+ + +
+
+
+
+
+
+
, + document.body + ) +} diff --git a/src/components/CategoryAutocomplete.tsx b/src/components/CategoryAutocomplete.tsx new file mode 100644 index 0000000..4c2ea50 --- /dev/null +++ b/src/components/CategoryAutocomplete.tsx @@ -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) => 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('') + const filteredOptions = useMemo( + () => + combinedOptions.filter((option) => + option.hierarchy.toLowerCase().includes(inputValue.toLowerCase()) + ), + [combinedOptions, inputValue] + ) + + const getSelectedCategories = (cats: Categories[]) => { + const uniqueValues = new Set( + cats.reduce((prev, cat) => [...prev, ...cat.l], []) + ) + const concatenatedValue = Array.from(uniqueValues) + return concatenatedValue + } + const getSelectedHierarchy = (cats: Categories[]) => { + const hierarchies = cats.reduce( + (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) => { + 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 ( +
handleSelect(filteredOptions[index])} + > + {capitalizeEachWord(filteredOptions[index].hierarchy)} + + {/* Show "Remove" button when the category is selected */} + {selectedCategories.some( + (cat) => cat.hierarchy === filteredOptions[index].hierarchy + ) && ( + + )} + + {/* 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 + ) && ( + + )} +
+ ) + } + + return ( +
+ +

You can select multiple categories

+
+
+ + + +
+ {filteredOptions.length > 0 ? ( + filteredOptions.map((c, i) => ) + ) : ( +
+ {inputValue && + !filteredOptions?.find( + (option) => + option.hierarchy.toLowerCase() === inputValue.toLowerCase() + ) ? ( + <> + Add "{inputValue}" + + + ) : ( + <>No matches + )} +
+ )} +
+
+
+ {LTags.length > 0 && ( +
+ {LTags.map((hierarchy) => { + const hierarchicalCategories = hierarchy.split(`:`) + const categories = hierarchicalCategories + .map((c, i) => { + const partialHierarchy = hierarchicalCategories + .slice(0, i + 1) + .join(':') + + return game ? ( + +

{capitalizeEachWord(c)}

+ + ) : ( +

+ {capitalizeEachWord(c)} +

+ ) + }) + .reduce((prev, curr, i) => [ + prev, +
+

>

+
, + curr + ]) + + return ( +
+ {categories} +
+ ) + })} +
+ )} +
+ ) +} diff --git a/src/components/Filters/BlogsFilter.tsx b/src/components/Filters/BlogsFilter.tsx index 71b4098..5efc7d2 100644 --- a/src/components/Filters/BlogsFilter.tsx +++ b/src/components/Filters/BlogsFilter.tsx @@ -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 */} - {Object.values(NSFWFilter).map((item, index) => ( - - ))} + {/* source filter options */} diff --git a/src/components/Filters/CategoryFilterPopup.module.scss b/src/components/Filters/CategoryFilterPopup.module.scss new file mode 100644 index 0000000..752a21e --- /dev/null +++ b/src/components/Filters/CategoryFilterPopup.module.scss @@ -0,0 +1,3 @@ +.noResult:not(:only-child) { + display: none; +} diff --git a/src/components/Filters/CategoryFilterPopup.tsx b/src/components/Filters/CategoryFilterPopup.tsx new file mode 100644 index 0000000..d4abdfe --- /dev/null +++ b/src/components/Filters/CategoryFilterPopup.tsx @@ -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> + hierarchies: string[] + setHierarchies: React.Dispatch> + 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('') + 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) => { + 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( +
+
+
+
+
+
+

Categories filter

+
+
+ + + +
+
+
+
+
+ +

+ This is description for an input and how to use search here +

+
+ + {userHierarchies.length > 0 && ( + <> +
+ +

Maybe

+
+
+ {!userHierarchiesMatching &&
No results.
} + {userHierarchies + .filter((c) => typeof c !== 'string') + .map((c, i) => ( + { + 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(':')) + ) + ) + }} + /> + ))} +
+ + )} +
+
+ +

Maybe

+
+
+
+
No results.
+
+ {userHierarchiesMatching ? ( +
Already defined in your categories
+ ) : ( +
+ Add and search for "{inputValue}" category + +
+ )} +
+ {(categoriesData as Category[]).map((category) => { + const name = + typeof category === 'string' ? category : category.name + return ( + + ) + })} +
+
+
+ + + +
+
+
+
+
+
+
, + 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 = ({ + 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(false) + const [isCombinationChecked, setIsCombinationChecked] = + useState(false) + const [isIndeterminate, setIsIndeterminate] = useState(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 && ( +
+
+ { + if (input) { + input.indeterminate = isIndeterminate + } + }} + className={`CheckboxMain ${ + isIndeterminate ? 'CheckboxIndeterminate' : '' + }`} + checked={isCombinationChecked} + onChange={handleCombinationChange} + /> + + + {typeof handleRemove === 'function' && ( + + )} +
+
+ )} + {typeof category !== 'string' && + category.sub && + Array.isArray(category.sub) && ( + <> + {category.sub.map((subCategory) => { + if (typeof subCategory === 'string') { + return ( + + ) + } else { + return ( + + ) + } + })} + + )} + + ) +} diff --git a/src/components/Filters/ModsFilter.tsx b/src/components/Filters/ModsFilter.tsx index afa43c4..44f182d 100644 --- a/src/components/Filters/ModsFilter.tsx +++ b/src/components/Filters/ModsFilter.tsx @@ -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) => { const userState = useAppSelector((state) => state.user) const [filterOptions, setFilterOptions] = useLocalStorage( filterKey, @@ -115,19 +115,7 @@ export const ModFilter = React.memo( {/* nsfw filter options */} - {Object.values(NSFWFilter).map((item, index) => ( - - ))} + {/* repost filter options */} @@ -176,6 +164,8 @@ export const ModFilter = React.memo( Show All + + {children} ) } diff --git a/src/components/Filters/NsfwFilterOptions.tsx b/src/components/Filters/NsfwFilterOptions.tsx new file mode 100644 index 0000000..05ab906 --- /dev/null +++ b/src/components/Filters/NsfwFilterOptions.tsx @@ -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( + filterKey, + DEFAULT_FILTER_OPTIONS + ) + const [showNsfwPopup, setShowNsfwPopup] = useState(false) + const [selectedNsfwOption, setSelectedNsfwOption] = useState< + NSFWFilter | undefined + >() + const [confirmNsfw] = useLocalStorage('confirm-nsfw', false) + const handleConfirm = (confirm: boolean) => { + if (confirm && selectedNsfwOption) { + setFilterOptions((prev) => ({ + ...prev, + nsfw: selectedNsfwOption + })) + } + } + + return ( + <> + {Object.values(NSFWFilter).map((item, index) => ( + + ))} + {showNsfwPopup && ( + setShowNsfwPopup(false)} + /> + )} + + ) +} diff --git a/src/components/ModForm.tsx b/src/components/ModForm.tsx index ea54912..1db8b96 100644 --- a/src/components/ModForm.tsx +++ b/src/components/ModForm.tsx @@ -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(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' > { error={formErrors.tags} onChange={handleInputChange} /> +
@@ -496,6 +541,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => { className='btn btnMain btnMainAdd' type='button' onClick={addDownloadUrl} + title='Add' > { )} ))} - {formState.downloadUrls.length === 0 && formErrors.downloadUrls && formErrors.downloadUrls[0] && ( @@ -540,6 +585,14 @@ export const ModForm = ({ existingModData }: ModFormProps) => { )}
+
+ {showConfirmPopup && ( + 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' > onRemove(index)} + title='Remove' > onChange('game', '')} + title='Remove' >
- )} - +
diff --git a/src/components/NsfwAlertPopup.tsx b/src/components/NsfwAlertPopup.tsx new file mode 100644 index 0000000..1685a97 --- /dev/null +++ b/src/components/NsfwAlertPopup.tsx @@ -0,0 +1,36 @@ +import { AlertPopupProps } from 'types' +import { AlertPopup } from './AlertPopup' +import { useLocalStorage } from 'hooks' + +type NsfwAlertPopup = Omit + +/** + * 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( + 'confirm-nsfw', + false + ) + + return ( + !confirmNsfw && ( + { + setConfirmNsfw(confirm) + handleConfirm(confirm) + handleClose() + }} + /> + ) + ) +} diff --git a/src/components/ReportPopup.tsx b/src/components/ReportPopup.tsx index d31e4b3..ace813d 100644 --- a/src/components/ReportPopup.tsx +++ b/src/components/ReportPopup.tsx @@ -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, diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 3daf9f4..c01237e 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -8,3 +8,4 @@ export * from './useReactions' export * from './useNDKContext' export * from './useScrollDisable' export * from './useLocalStorage' +export * from './useSessionStorage' diff --git a/src/hooks/useLocalStorage.tsx b/src/hooks/useLocalStorage.tsx index 4d1eac2..10579cd 100644 --- a/src/hooks/useLocalStorage.tsx +++ b/src/hooks/useLocalStorage.tsx @@ -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(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( } }, [key, initialValue]) - return [JSON.parse(data) as T, setState] + const memoized = useMemo(() => JSON.parse(data) as T, [data]) + + return [memoized, setState] } diff --git a/src/hooks/useSessionStorage.tsx b/src/hooks/useSessionStorage.tsx new file mode 100644 index 0000000..cc0756f --- /dev/null +++ b/src/hooks/useSessionStorage.tsx @@ -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(storedValue: T, initialValue: T): T { + if ( + !Array.isArray(storedValue) && + typeof storedValue === 'object' && + storedValue !== null + ) { + return { ...initialValue, ...storedValue } + } + return storedValue +} + +export function useSessionStorage( + key: string, + initialValue: T +): [T, React.Dispatch>] { + 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.useCallback( + (v: React.SetStateAction) => { + 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] +} diff --git a/src/pages/blogs/index.tsx b/src/pages/blogs/index.tsx index 2155b39..d4b4fab 100644 --- a/src/pages/blogs/index.tsx +++ b/src/pages/blogs/index.tsx @@ -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[] | 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(null) @@ -147,19 +146,7 @@ export const BlogsPage = () => { - {Object.values(NSFWFilter).map((item, index) => ( - - ))} + diff --git a/src/pages/game.tsx b/src/pages/game.tsx index da49f87..c9557c9 100644 --- a/src/pages/game.tsx +++ b/src/pages/game.tsx @@ -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(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('l', []) + const [hierarchies, setHierarchies] = useSessionStorage('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 = () => { /> - + + {linkedHierarchy && linkedHierarchy !== '' ? ( + { + searchParams.delete('h') + setSearchParams(searchParams) + }} + > + + + + {linkedHierarchy.replace(/:/g, ' > ')} + + + + + ) : ( +
+ +
+ )} +
+
{currentMods.map((mod) => ( @@ -204,6 +321,17 @@ export const GamePage = () => {
+ {showCategoryPopup && ( + { + setShowCategoryPopup(false) + }} + /> + )} ) } diff --git a/src/pages/mod/index.tsx b/src/pages/mod/index.tsx index cc77765..dc94295 100644 --- a/src/pages/mod/index.tsx +++ b/src/pages/mod/index.tsx @@ -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} ))} + + {LTags.length > 0 && ( +
+ {LTags.map((hierarchy) => { + const hierarchicalCategories = hierarchy.split(`:`) + const categories = hierarchicalCategories + .map((c, i) => { + const partialHierarchy = hierarchicalCategories + .slice(0, i + 1) + .join(':') + + return ( + +

{capitalizeEachWord(c)}

+
+ ) + }) + .reduce((prev, curr) => [ + prev, +
+

>

+
, + curr + ]) + + return ( +
+ {categories} +
+ ) + })} +
+ )} diff --git a/src/pages/settings/preference.tsx b/src/pages/settings/preference.tsx index 985a509..7f6c8e0 100644 --- a/src/pages/settings/preference.tsx +++ b/src/pages/settings/preference.tsx @@ -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('confirm-nsfw', false) + const [showNsfwPopup, setShowNsfwPopup] = useState(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) + } + }} /> @@ -238,6 +259,12 @@ export const PreferencesSetting = () => { Save + {showNsfwPopup && ( + setShowNsfwPopup(false)} + /> + )} diff --git a/src/pages/write/index.tsx b/src/pages/write/index.tsx index 870bb2b..26544e0 100644 --- a/src/pages/write/index.tsx +++ b/src/pages/write/index.tsx @@ -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(null) + const [showConfirmPopup, setShowConfirmPopup] = useState(false) + const handleReset = () => { + setShowConfirmPopup(true) + } + const handleResetConfirm = (confirm: boolean) => { + setShowConfirmPopup(false) + + // Cancel if not confirmed + if (!confirm) return + + formRef.current?.reset() + } + return (
@@ -68,7 +83,11 @@ export const WritePage = () => { {navigation.state === 'submitting' && ( )} -
+ { /> )}
+
+ {showConfirmPopup && ( + 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?` + } + /> + )}
{userState.auth && userState.user?.pubkey && ( diff --git a/src/styles/styles.css b/src/styles/styles.css index 8458aae..95669e4 100644 --- a/src/styles/styles.css +++ b/src/styles/styles.css @@ -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; diff --git a/src/styles/write.css b/src/styles/write.css index 7ac363d..412bcec 100644 --- a/src/styles/write.css +++ b/src/styles/write.css @@ -11,5 +11,5 @@ flex-direction: row; justify-content: end; align-items: center; + gap: 25px; } - diff --git a/src/types/category.ts b/src/types/category.ts new file mode 100644 index 0000000..4d871f8 --- /dev/null +++ b/src/types/category.ts @@ -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[] +} diff --git a/src/types/index.ts b/src/types/index.ts index 8fe37df..7b2f35c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,3 +4,5 @@ export * from './nostr' export * from './user' export * from './zap' export * from './blog' +export * from './category' +export * from './popup' diff --git a/src/types/mod.ts b/src/types/mod.ts index bc27e18..c520ac3 100644 --- a/src/types/mod.ts +++ b/src/types/mod.ts @@ -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[] } diff --git a/src/types/popup.ts b/src/types/popup.ts new file mode 100644 index 0000000..b1551cf --- /dev/null +++ b/src/types/popup.ts @@ -0,0 +1,9 @@ +export interface PopupProps { + handleClose: () => void +} + +export interface AlertPopupProps extends PopupProps { + header: string + label: string + handleConfirm: (confirm: boolean) => void +} diff --git a/src/utils/category.ts b/src/utils/category.ts new file mode 100644 index 0000000..85c7d97 --- /dev/null +++ b/src/utils/category.ts @@ -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((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) + } +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 91fe37b..d8c7a4d 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -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' diff --git a/src/utils/mod.ts b/src/utils/mod.ts index 24b59bd..07f34a0 100644 --- a/src/utils/mod.ts +++ b/src/utils/mod.ts @@ -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: '', diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts new file mode 100644 index 0000000..e40b0e9 --- /dev/null +++ b/src/utils/sessionStorage.ts @@ -0,0 +1,32 @@ +export function getSessionStorageItem(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 })) +} diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 4658496..bb740e5 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -156,3 +156,7 @@ export const parseFormData = (formData: FormData) => { return result } + +export const capitalizeEachWord = (str: string): string => { + return str.replace(/\b\w/g, (char) => char.toUpperCase()) +}