diff --git a/eslint.config.js b/eslint.config.js index f4616740..52304070 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -19,5 +19,12 @@ export default tseslint.config([ ecmaVersion: 2020, globals: globals.browser, }, + rules: { + "@typescript-eslint/no-explicit-any": "off", + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + }, }, ]); diff --git a/nginx/studio.conf.template b/nginx/studio.conf.template index 71d3f265..66945cee 100644 --- a/nginx/studio.conf.template +++ b/nginx/studio.conf.template @@ -2,6 +2,7 @@ server { listen 4173; server_name localhost; proxy_http_version 1.1; + client_max_body_size 50m; root /usr/share/nginx/html; @@ -15,5 +16,7 @@ server { location /api { proxy_pass ${VITE_BACKEND_BASE_URL}; proxy_ssl_server_name on; + proxy_read_timeout 300s; + proxy_send_timeout 300s; } } diff --git a/package-lock.json b/package-lock.json index 482168bc..00f1ac0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pechastudio", - "version": "2025.11.05.0657", + "version": "2026.05.28.1200", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pechastudio", - "version": "2025.11.05.0657", + "version": "2026.05.28.1200", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", @@ -22,6 +22,7 @@ "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", + "@tailwindcss/typography": "^0.5.20", "@tailwindcss/vite": "^4.1.12", "@tanstack/react-query": "^5.85.5", "@tolgee/react": "^6.2.7", @@ -30,6 +31,7 @@ "axios": "^1.11.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "crypto-js": "^4.2.0", "date-fns": "^4.1.0", "radix-ui": "^1.4.3", @@ -41,7 +43,10 @@ "react-hook-form": "^7.62.0", "react-icons": "^5.5.0", "react-intersection-observer": "^10.0.3", + "react-markdown": "^10.1.0", "react-router-dom": "^7.8.1", + "react-split-pane": "^3.2.0", + "recharts": "^3.10.0", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.12", @@ -2923,6 +2928,32 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.32", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.32.tgz", @@ -3190,6 +3221,12 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@standard-schema/utils": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", @@ -3453,6 +3490,18 @@ "node": ">= 10" } }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.20", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.20.tgz", + "integrity": "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >=4.0.0 || insiders" + } + }, "node_modules/@tailwindcss/vite": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.12.tgz", @@ -3708,6 +3757,78 @@ "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", "license": "MIT" }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -3721,6 +3842,24 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/history": { "version": "4.7.11", "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", @@ -3735,6 +3874,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.3.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", @@ -3749,7 +3903,6 @@ "version": "19.2.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", - "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -3788,6 +3941,18 @@ "@types/react-router": "*" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.40.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.40.0.tgz", @@ -4059,6 +4224,12 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "license": "ISC" + }, "node_modules/@userback/widget": { "version": "0.3.11", "resolved": "https://registry.npmjs.org/@userback/widget/-/widget-0.3.11.tgz", @@ -4420,6 +4591,16 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -4538,6 +4719,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -4572,6 +4763,46 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/check-error": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", @@ -4612,6 +4843,22 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -4644,6 +4891,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -4695,6 +4952,18 @@ "dev": true, "license": "MIT" }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/cssstyle": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", @@ -4713,9 +4982,129 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", @@ -4750,7 +5139,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4771,6 +5159,25 @@ "dev": true, "license": "MIT" }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -4801,7 +5208,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4822,6 +5228,19 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -4943,6 +5362,16 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", + "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", @@ -5175,6 +5604,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -5195,6 +5634,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/expect-type": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", @@ -5205,6 +5650,12 @@ "node": ">=12.0.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5621,8 +6072,48 @@ "node": ">= 0.4" } }, - "node_modules/html-encoding-sniffer": { - "version": "4.0.0", + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", "dev": true, @@ -5641,6 +6132,16 @@ "dev": true, "license": "MIT" }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -5692,6 +6193,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.15", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.15.tgz", + "integrity": "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -5729,6 +6240,55 @@ "node": ">=8" } }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -5762,6 +6322,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -5772,6 +6342,18 @@ "node": ">=0.12.0" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -6276,6 +6858,16 @@ "dev": true, "license": "MIT" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -6366,25 +6958,620 @@ "node": ">=10" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", - "engines": { - "node": ">= 8" + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -6483,7 +7670,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -6610,6 +7796,31 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -6731,6 +7942,19 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6804,6 +8028,16 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -7036,10 +8270,59 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, "license": "MIT", "peer": true }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -7135,6 +8418,19 @@ "react-dom": ">=18" } }, + "node_modules/react-split-pane": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-split-pane/-/react-split-pane-3.2.0.tgz", + "integrity": "sha512-tH2SaMqBZ7Ypcmj6I/2fIR0gdeE0Q5ve66T51UyXcSqIgPGJh40bOMTVCAFjB0Q3OB5+Aw/4ECVWU+YU9SL88Q==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", @@ -7157,6 +8453,36 @@ } } }, + "node_modules/recharts": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.0.tgz", + "integrity": "sha512-wulMvfncpIlmu2uFtRU/mE5/+NiVtASXkw2KdwJTdHs3WsASX0WxZlX+rpKgyn5BDbIhkPtCpUKkB9XNK5KE0w==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -7171,6 +8497,60 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -7366,6 +8746,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -7434,6 +8824,20 @@ "node": ">=8" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", @@ -7523,6 +8927,24 @@ "dev": true, "license": "MIT" }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -7635,6 +9057,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -7783,6 +9211,26 @@ "node": ">=18" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -7870,6 +9318,93 @@ "devOptional": true, "license": "MIT" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -7975,6 +9510,62 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz", @@ -8442,6 +10033,16 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index 56f770ac..e5c13e85 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "pechastudio", "private": true, - "version": "2025.11.05.0657", + "version": "2026.05.28.1200", "type": "module", "scripts": { "dev": "vite", @@ -34,6 +34,7 @@ "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", + "@tailwindcss/typography": "^0.5.20", "@tailwindcss/vite": "^4.1.12", "@tanstack/react-query": "^5.85.5", "@tolgee/react": "^6.2.7", @@ -42,6 +43,7 @@ "axios": "^1.11.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "crypto-js": "^4.2.0", "date-fns": "^4.1.0", "radix-ui": "^1.4.3", @@ -53,7 +55,10 @@ "react-hook-form": "^7.62.0", "react-icons": "^5.5.0", "react-intersection-observer": "^10.0.3", + "react-markdown": "^10.1.0", "react-router-dom": "^7.8.1", + "react-split-pane": "^3.2.0", + "recharts": "^3.10.0", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.12", diff --git a/src/components/api/searchApi.ts b/src/components/api/searchApi.ts index 0bc4d4a3..d1dd0fe5 100644 --- a/src/components/api/searchApi.ts +++ b/src/components/api/searchApi.ts @@ -18,6 +18,8 @@ type SearchTextDetails = { segmentId?: string; direction?: "next" | "previous"; size?: number; + start?: number; + end?: number; }; export const searchSources = async ({ query, @@ -56,14 +58,58 @@ export const fetchTextDetails = async ({ segmentId, direction = "next", size = 20, + start, + end, }: SearchTextDetails) => { const { data } = await axiosInstance.post(`/api/v1/texts/${textId}/details`, { ...(contentId && { content_id: contentId }), ...(segmentId && { segment_id: segmentId }), + ...(start != null && { start }), + ...(end != null && { end }), direction, size, }); return data; }; +type SearchSegments = { + content: string; +}; + +export type SegmentSearchResult = { + id: string; + pecha_segment_id?: string; + text_id: string; + content: string; + type: string; +}; + +export type SegmentSearchResponse = { + segments: SegmentSearchResult[]; +}; + +export const searchSegments = async ({ + content, +}: SearchSegments): Promise => { + const { data } = await axiosInstance.post(`/api/v1/segments/search`, { + content, + }); + return data; +}; + +export const fetchTextLanguages = async (textId: string) => { + const { data } = await axiosInstance.get(`/api/v1/texts/${textId}/languages`); + return data; +}; + +export const fetchLanguageVersions = async ( + textId: string, + language: string, +) => { + const { data } = await axiosInstance.get( + `/api/v1/texts/${textId}/languages/${language}/versions`, + ); + return data; +}; + export type { SearchCommon }; diff --git a/src/components/auth/PlanRouteGuard.test.tsx b/src/components/auth/PlanRouteGuard.test.tsx new file mode 100644 index 00000000..23b915e0 --- /dev/null +++ b/src/components/auth/PlanRouteGuard.test.tsx @@ -0,0 +1,63 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { describe, expect, it, vi } from "vitest"; +import PlanRouteGuard from "./PlanRouteGuard"; +import { ROUTES } from "@/routes/paths"; + +vi.mock("@/hooks/useUserInfo", () => ({ + useUserInfo: vi.fn(), +})); + +import { useUserInfo } from "@/hooks/useUserInfo"; + +describe("PlanRouteGuard", () => { + it("redirects reviewers to dashboard", () => { + vi.mocked(useUserInfo).mockReturnValue({ + data: { id: "1", platform_role: "REVIEWER" }, + isLoading: false, + } as ReturnType); + + render( + + + +
Plan page
+ + } + /> + Dashboard} /> +
+
, + ); + + expect(screen.getByText("Dashboard")).toBeInTheDocument(); + expect(screen.queryByText("Plan page")).not.toBeInTheDocument(); + }); + + it("renders children for creators", () => { + vi.mocked(useUserInfo).mockReturnValue({ + data: { id: "1", platform_role: "CREATOR" }, + isLoading: false, + } as ReturnType); + + render( + + + +
Plan page
+ + } + /> +
+
, + ); + + expect(screen.getByText("Plan page")).toBeInTheDocument(); + }); +}); diff --git a/src/components/auth/PlanRouteGuard.tsx b/src/components/auth/PlanRouteGuard.tsx new file mode 100644 index 00000000..4458e878 --- /dev/null +++ b/src/components/auth/PlanRouteGuard.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from "react"; +import { Navigate } from "react-router-dom"; +import { useUserInfo } from "@/hooks/useUserInfo"; +import { canAccessPlanRoutes } from "@/lib/platformAccess"; +import { ROUTES } from "@/routes/paths"; + +type PlanRouteGuardProps = { + children: ReactNode; +}; + +/** Blocks `/plan/*` CMS routes for platform REVIEWER (read-only staff). */ +const PlanRouteGuard = ({ children }: PlanRouteGuardProps) => { + const { data: userInfo, isLoading } = useUserInfo(); + + if (isLoading) { + return ( +
+ Loading… +
+ ); + } + + if (userInfo && !canAccessPlanRoutes(userInfo.platform_role)) { + return ; + } + + return <>{children}; +}; + +export default PlanRouteGuard; diff --git a/src/components/auth/ProtectedRoute.test.tsx b/src/components/auth/ProtectedRoute.test.tsx index 3af7cd01..a7aab46b 100644 --- a/src/components/auth/ProtectedRoute.test.tsx +++ b/src/components/auth/ProtectedRoute.test.tsx @@ -4,11 +4,16 @@ import { vi } from "vitest"; import ProtectedRoute from "./ProtectedRoute"; const mockUseAuth = vi.fn(); +const mockUseUserInfo = vi.fn(); vi.mock("@/config/auth-context", () => ({ useAuth: () => mockUseAuth(), })); +vi.mock("@/hooks/useUserInfo", () => ({ + useUserInfo: () => mockUseUserInfo(), +})); + const mockNavigate = vi.fn(); vi.mock("react-router-dom", async () => { const actual = await vi.importActual("react-router-dom"); @@ -28,6 +33,15 @@ const renderWithRouter = (component: React.ReactElement) => { describe("ProtectedRoute Component", () => { beforeEach(() => { vi.clearAllMocks(); + mockUseUserInfo.mockReturnValue({ + data: { + is_verified: true, + is_active: true, + has_group: true, + platform_role: "CREATOR", + }, + isLoading: false, + }); }); it("displays loading state when authentication is loading", () => { @@ -58,20 +72,15 @@ describe("ProtectedRoute Component", () => { , ); - expect(screen.getByTestId("navigate")).toBeDefined(); expect(screen.getByTestId("navigate")).toHaveAttribute("data-to", "/login"); - expect(screen.getByTestId("navigate")).toHaveAttribute( - "data-replace", - "true", - ); - expect(screen.queryByText("Protected Content")).toBeNull(); expect(mockNavigate).toHaveBeenCalledWith("/login", true); }); - it("renders children when user is authenticated and not loading", () => { + it("renders children when user is authenticated and onboarding complete", () => { mockUseAuth.mockReturnValue({ isLoggedIn: true, isAuthLoading: false, + logout: vi.fn(), }); renderWithRouter( @@ -81,40 +90,21 @@ describe("ProtectedRoute Component", () => { ); expect(screen.getByText("Protected Content")).toBeDefined(); - expect(screen.queryByText("common.loading")).toBeNull(); - expect(screen.queryByTestId("navigate")).toBeNull(); - expect(mockNavigate).not.toHaveBeenCalled(); }); - it("renders complex children components when authenticated", () => { + it("redirects inactive users to login", () => { mockUseAuth.mockReturnValue({ isLoggedIn: true, isAuthLoading: false, + logout: vi.fn(), }); - - const ComplexChild = () => ( -
-

Dashboard

- -

Some content here

-
- ); - - renderWithRouter( - - - , - ); - - expect(screen.getByText("Dashboard")).toBeDefined(); - expect(screen.getByText("Action Button")).toBeDefined(); - expect(screen.getByText("Some content here")).toBeDefined(); - }); - - it("does not render children during loading state even if user appears logged in", () => { - mockUseAuth.mockReturnValue({ - isLoggedIn: true, - isAuthLoading: true, + mockUseUserInfo.mockReturnValue({ + data: { + is_verified: true, + is_active: false, + has_group: true, + }, + isLoading: false, }); renderWithRouter( @@ -123,14 +113,22 @@ describe("ProtectedRoute Component", () => { , ); - expect(screen.getByText("common.loading")).toBeDefined(); - expect(screen.queryByText("Protected Content")).toBeNull(); + expect(screen.getByTestId("navigate")).toHaveAttribute("data-to", "/login"); }); - it("uses the correct CSS classes for loading state", () => { + it("redirects to groups when user has no group", () => { mockUseAuth.mockReturnValue({ - isLoggedIn: false, - isAuthLoading: true, + isLoggedIn: true, + isAuthLoading: false, + logout: vi.fn(), + }); + mockUseUserInfo.mockReturnValue({ + data: { + is_verified: true, + is_active: true, + has_group: false, + }, + isLoading: false, }); renderWithRouter( @@ -139,13 +137,9 @@ describe("ProtectedRoute Component", () => { , ); - const loadingContainer = screen.getByText("common.loading").parentElement; - expect(loadingContainer).toHaveClass( - "flex", - "items-center", - "justify-center", - "h-full", + expect(screen.getByTestId("navigate")).toHaveAttribute( + "data-to", + "/groups", ); - expect(screen.getByText("common.loading")).toHaveClass("text-lg"); }); }); diff --git a/src/components/auth/ProtectedRoute.tsx b/src/components/auth/ProtectedRoute.tsx index e6506b7b..0da969b3 100644 --- a/src/components/auth/ProtectedRoute.tsx +++ b/src/components/auth/ProtectedRoute.tsx @@ -1,14 +1,35 @@ +import { useEffect } from "react"; import { useAuth } from "@/config/auth-context"; -import { Navigate } from "react-router-dom"; +import { Navigate, useLocation } from "react-router-dom"; import { useTranslate } from "@tolgee/react"; +import { useUserInfo } from "@/hooks/useUserInfo"; +import { + needsGroupOnboardingRedirect, + needsInactiveLogout, + needsVerifyEmailRedirect, +} from "@/lib/platformAccess"; +import { ROUTES } from "@/routes/paths"; + interface ProtectedRouteProps { children: React.ReactNode; } const ProtectedRoute = ({ children }: ProtectedRouteProps) => { - const { isLoggedIn, isAuthLoading } = useAuth(); + const { isLoggedIn, isAuthLoading, logout } = useAuth(); const { t } = useTranslate(); - if (isAuthLoading) { + const location = useLocation(); + const { data: userInfo, isLoading: isUserInfoLoading } = useUserInfo({ + enabled: isLoggedIn, + }); + + useEffect(() => { + if (!isLoggedIn || isUserInfoLoading || !userInfo) return; + if (needsInactiveLogout(userInfo)) { + logout(); + } + }, [isLoggedIn, isUserInfoLoading, userInfo, logout]); + + if (isAuthLoading || (isLoggedIn && isUserInfoLoading)) { return (
{t("common.loading")}
@@ -17,7 +38,33 @@ const ProtectedRoute = ({ children }: ProtectedRouteProps) => { } if (!isLoggedIn) { - return ; + const inactive = + (location.state as { inactive?: boolean } | null)?.inactive === true; + return ( + + ); + } + + if (userInfo && needsInactiveLogout(userInfo)) { + return ( + + ); + } + + if (userInfo && needsVerifyEmailRedirect(userInfo)) { + return ; + } + + if (userInfo && needsGroupOnboardingRedirect(location.pathname, userInfo)) { + return ; } return <>{children}; diff --git a/src/components/auth/login/Login.tsx b/src/components/auth/login/Login.tsx index 6f1d2c19..0717305d 100644 --- a/src/components/auth/login/Login.tsx +++ b/src/components/auth/login/Login.tsx @@ -2,26 +2,47 @@ import { Button } from "@/components/ui/atoms/button"; import { Input } from "@/components/ui/atoms/input"; import { Label } from "@/components/ui/atoms/label"; import ContainerLayout from "@/components/ui/atoms/studio-card"; -import { Link, useNavigate } from "react-router-dom"; +import { Link, useLocation, useNavigate } from "react-router-dom"; import { useMutation } from "@tanstack/react-query"; import axiosInstance from "@/config/axios-config"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useAuth } from "@/config/auth-context"; import { useTranslate } from "@tolgee/react"; import { createPasswordHash } from "@/lib/utils"; +import { + AUTHOR_NOT_ACTIVE_DETAIL, + isAuthorNotActiveDetail, +} from "@/lib/platformAccess"; +import { getApiErrorDetail } from "@/lib/apiErrors"; +import { ROUTES } from "@/routes/paths"; + interface LoginData { email: string; password: string; } + const Login = () => { const { t } = useTranslate(); const navigate = useNavigate(); + const location = useLocation(); const [email, setEmail] = useState(""); const [errors, setErrors] = useState(""); const [successMessage, setSuccessMessage] = useState(""); const [showEmailReverify, setShowEmailReverify] = useState(false); + const [inactiveOnly, setInactiveOnly] = useState(false); const { login } = useAuth(); + useEffect(() => { + const state = location.state as { + inactive?: boolean; + message?: string; + } | null; + if (state?.inactive) { + setInactiveOnly(true); + setErrors(state.message ?? AUTHOR_NOT_ACTIVE_DETAIL); + } + }, [location.state]); + const loginMutation = useMutation({ mutationFn: async (loginData: LoginData) => { const response = await axiosInstance.post( @@ -34,17 +55,24 @@ const Login = () => { const accessToken = data.auth.access_token; const refreshToken = data.auth.refresh_token; login(accessToken, refreshToken); - navigate("/dashboard"); + navigate(ROUTES.dashboard); }, onError: (error: any) => { - const errorMsg = error?.response?.data?.detail || "Login failed"; - - const emailVerificationErrorMessage = errorMsg + const detail = getApiErrorDetail(error) ?? "Login failed"; + const emailVerificationErrorMessage = detail .toLowerCase() .includes("author not verified"); + if (isAuthorNotActiveDetail(detail)) { + setInactiveOnly(true); + setShowEmailReverify(false); + setErrors(AUTHOR_NOT_ACTIVE_DETAIL); + return; + } + + setInactiveOnly(false); setShowEmailReverify(emailVerificationErrorMessage); - setErrors(errorMsg || ""); + setErrors(detail); }, }); @@ -62,8 +90,8 @@ const Login = () => { }, onError: (error: any) => { const errorMsg = - error?.response?.data?.detail || "Email re-verification failed"; - setErrors(errorMsg || ""); + getApiErrorDetail(error) || "Email re-verification failed"; + setErrors(errorMsg); setSuccessMessage(""); }, }); @@ -72,6 +100,7 @@ const Login = () => { e.preventDefault(); setShowEmailReverify(false); setSuccessMessage(""); + setInactiveOnly(false); const formData = new FormData(e.currentTarget); const password = formData.get("password") as string; const clientPassword = createPasswordHash(email, password); @@ -87,6 +116,22 @@ const Login = () => { emailReverifyMutation.mutate({ email }); }; + if (inactiveOnly) { + return ( + +
+

+ {errors || AUTHOR_NOT_ACTIVE_DETAIL} +

+

+ Your account must be activated by a platform administrator before + you can sign in. +

+
+
+ ); + } + return (
diff --git a/src/components/routes/admin-authors/AdminAuthorsPage.tsx b/src/components/routes/admin-authors/AdminAuthorsPage.tsx new file mode 100644 index 00000000..b2857dd9 --- /dev/null +++ b/src/components/routes/admin-authors/AdminAuthorsPage.tsx @@ -0,0 +1,245 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Pecha } from "@/components/ui/shadimport"; +import { Button } from "@/components/ui/atoms/button"; +import { getApiErrorMessage } from "@/lib/apiErrors"; +import { useUserInfo, USER_INFO_QUERY_KEY } from "@/hooks/useUserInfo"; +import { + canAccessAdminAuthors, + isSuperAdmin, + type PlatformRole, +} from "@/lib/platformAccess"; +import { Navigate } from "react-router-dom"; +import { ROUTES } from "@/routes/paths"; +import { + activateAuthor, + fetchAdminAuthors, + patchAuthorPlatformRole, + suspendAuthor, + type AdminAuthorDTO, +} from "./api/adminAuthorsApi"; + +const PAGE_SIZE = 20; + +const AdminAuthorsPage = () => { + const { data: userInfo } = useUserInfo(); + const queryClient = useQueryClient(); + const [page, setPage] = useState(0); + const [activationQueue, setActivationQueue] = useState(true); + + const canAccess = Boolean( + userInfo && canAccessAdminAuthors(userInfo.platform_role), + ); + const writeEnabled = isSuperAdmin(userInfo?.platform_role); + const showActionsColumn = writeEnabled; + const tableColumnCount = showActionsColumn ? 6 : 5; + + const { data, isLoading, error } = useQuery({ + queryKey: ["admin-authors", page, activationQueue], + queryFn: () => + fetchAdminAuthors({ + skip: page * PAGE_SIZE, + limit: PAGE_SIZE, + ...(activationQueue ? { is_verified: true, is_active: false } : {}), + }), + enabled: canAccess, + }); + + const invalidate = () => { + queryClient.invalidateQueries({ queryKey: ["admin-authors"] }); + queryClient.invalidateQueries({ queryKey: USER_INFO_QUERY_KEY }); + }; + + const activateMutation = useMutation({ + mutationFn: activateAuthor, + onSuccess: () => { + toast.success("Author activated"); + invalidate(); + }, + onError: (err) => toast.error(getApiErrorMessage(err)), + }); + + const suspendMutation = useMutation({ + mutationFn: suspendAuthor, + onSuccess: () => { + toast.success("Author suspended"); + invalidate(); + }, + onError: (err) => toast.error(getApiErrorMessage(err)), + }); + + const roleMutation = useMutation({ + mutationFn: ({ id, role }: { id: string; role: PlatformRole }) => + patchAuthorPlatformRole(id, role), + onSuccess: () => { + toast.success("Platform role updated"); + invalidate(); + }, + onError: (err) => toast.error(getApiErrorMessage(err)), + }); + + const authors = data?.authors ?? []; + const total = data?.total ?? 0; + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + + const displayName = (a: AdminAuthorDTO) => + [a.firstname, a.lastname].filter(Boolean).join(" ").trim() || a.email; + + if (userInfo && !canAccess) { + return ; + } + + return ( +
+
+

Authors

+
+ + +
+
+ + {error ? ( +

+ {getApiErrorMessage(error)} +

+ ) : null} + +
+ + + + Name + Email + Role + Verified + Active + {showActionsColumn ? ( + Actions + ) : null} + + + + {isLoading ? ( + + + Loading… + + + ) : authors.length === 0 ? ( + + + No authors found. + + + ) : ( + authors.map((author) => ( + + {displayName(author)} + {author.email} + + {writeEnabled ? ( + + ) : ( + author.platform_role + )} + + + {author.is_verified ? "Yes" : "No"} + + + {author.is_active ? "Yes" : "No"} + + {showActionsColumn ? ( + +
+ {!author.is_active ? ( + + ) : ( + + )} +
+
+ ) : null} +
+ )) + )} +
+
+ + {totalPages > 1 ? ( +
+ + + Page {page + 1} of {totalPages} + + +
+ ) : null} +
+
+ ); +}; + +export default AdminAuthorsPage; diff --git a/src/components/routes/admin-authors/api/adminAuthorsApi.ts b/src/components/routes/admin-authors/api/adminAuthorsApi.ts new file mode 100644 index 00000000..10f8f9e7 --- /dev/null +++ b/src/components/routes/admin-authors/api/adminAuthorsApi.ts @@ -0,0 +1,72 @@ +import axiosInstance from "@/config/axios-config"; +import type { PlatformRole } from "@/lib/platformAccess"; + +export type AdminAuthorDTO = { + id: string; + email: string; + firstname?: string | null; + lastname?: string | null; + platform_role: PlatformRole; + is_verified: boolean; + is_active: boolean; + has_group: boolean; + created_at?: string; +}; + +export type AdminAuthorsListResponse = { + authors: AdminAuthorDTO[]; + skip: number; + limit: number; + total: number; +}; + +export type FetchAdminAuthorsParams = { + is_verified?: boolean; + is_active?: boolean; + search?: string; + skip?: number; + limit?: number; +}; + +export const fetchAdminAuthors = async ( + params: FetchAdminAuthorsParams = {}, +): Promise => { + const { data } = await axiosInstance.get( + `/api/v1/cms/admin/authors`, + { + params: { + skip: params.skip ?? 0, + limit: params.limit ?? 20, + ...(params.is_verified != null && { is_verified: params.is_verified }), + ...(params.is_active != null && { is_active: params.is_active }), + ...(params.search?.trim() && { search: params.search.trim() }), + }, + }, + ); + return data; +}; + +export const activateAuthor = async (authorId: string) => { + const { data } = await axiosInstance.post( + `/api/v1/cms/admin/authors/${authorId}/activate`, + ); + return data; +}; + +export const suspendAuthor = async (authorId: string) => { + const { data } = await axiosInstance.post( + `/api/v1/cms/admin/authors/${authorId}/suspend`, + ); + return data; +}; + +export const patchAuthorPlatformRole = async ( + authorId: string, + platform_role: PlatformRole, +) => { + const { data } = await axiosInstance.patch( + `/api/v1/cms/admin/authors/${authorId}/platform-role`, + { platform_role }, + ); + return data; +}; diff --git a/src/components/routes/analytics/Analytics.tsx b/src/components/routes/analytics/Analytics.tsx index 6ae21849..28f04bdb 100644 --- a/src/components/routes/analytics/Analytics.tsx +++ b/src/components/routes/analytics/Analytics.tsx @@ -1,5 +1,476 @@ +import { useMemo, useState, type ReactNode } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + format, + subDays, + startOfMonth, + endOfMonth, + parseISO, +} from "date-fns"; +import type { DateRange } from "react-day-picker"; +import { + Area, + AreaChart, + Bar, + BarChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { IoCalendarClearOutline, IoPeopleOutline } from "react-icons/io5"; +import { HiOutlineUserPlus } from "react-icons/hi2"; +import { MdOutlineTrendingUp } from "react-icons/md"; +import { Pecha } from "@/components/ui/shadimport"; +import { Button } from "@/components/ui/atoms/button"; +import { useUserInfo } from "@/hooks/useUserInfo"; +import { useDashboardGroupFilterOptions } from "@/hooks/useDashboardGroupFilterOptions"; +import { getApiErrorMessage } from "@/lib/apiErrors"; +import { cn } from "@/lib/utils"; +import { + fetchAnalyticsOverview, + type AnalyticsTopPlan, +} from "@/components/routes/analytics/analyticsApi"; + +type DatePreset = "7d" | "30d" | "90d" | "month" | "custom"; + +const PRESETS: { id: Exclude; label: string }[] = [ + { id: "7d", label: "7 days" }, + { id: "30d", label: "30 days" }, + { id: "90d", label: "90 days" }, + { id: "month", label: "This month" }, +]; + +const CHART_TOOLTIP_STYLE = { + borderRadius: 12, + border: "1px solid #e5e5e5", + background: "#ffffff", +}; + +function rangeForPreset( + preset: Exclude, + today = new Date(), +): DateRange { + if (preset === "month") { + return { from: startOfMonth(today), to: endOfMonth(today) }; + } + const days = preset === "7d" ? 6 : preset === "90d" ? 89 : 29; + return { from: subDays(today, days), to: today }; +} + +function toApiDate(value: Date): string { + return format(value, "yyyy-MM-dd"); +} + +function formatAxisDate(value: string): string { + try { + return format(parseISO(value), "MMM d"); + } catch { + return value; + } +} + +function formatRangeLabel(range: DateRange): string { + if (range.from && range.to) { + return `${format(range.from, "MMM d, yyyy")} – ${format(range.to, "MMM d, yyyy")}`; + } + if (range.from) { + return format(range.from, "MMM d, yyyy"); + } + return "Select dates"; +} + +function resolveApiDates(range: DateRange): { + startDate: string | null; + endDate: string | null; +} { + if (!range.from) { + return { startDate: null, endDate: null }; + } + const startDate = toApiDate(range.from); + const endDate = toApiDate(range.to ?? range.from); + return { startDate, endDate }; +} + +function StatCard({ + label, + value, + hint, + icon, +}: Readonly<{ + label: string; + value: number | string; + hint?: string; + icon: ReactNode; +}>) { + return ( +
+
+
+

{label}

+

+ {typeof value === "number" ? value.toLocaleString() : value} +

+ {hint ? ( +

{hint}

+ ) : null} +
+
+ {icon} +
+
+
+ ); +} + +function ChartPanel({ + title, + description, + children, +}: Readonly<{ + title: string; + description: string; + children: ReactNode; +}>) { + return ( +
+
+

{title}

+

{description}

+
+
{children}
+
+ ); +} + +function TopPlansTable({ plans }: Readonly<{ plans: AnalyticsTopPlan[] }>) { + if (plans.length === 0) { + return ( +
+

No plan joins yet

+

+ Joins in this date range will show up here. +

+
+ ); + } + + return ( +
+
+

+ Top 10 plans +

+

+ Ranked by joins in the selected date range +

+
+ + + + # + Plan + Series + Joins + Completed + + + + {plans.map((plan, index) => ( + + + {index + 1} + + {plan.title} + + {plan.series_name ?? "—"} + + + {plan.join_count.toLocaleString()} + + + {plan.completion_count.toLocaleString()} + + + ))} + + +
+ ); +} + const Analytics = () => { - return
Analytics
; + const { data: userInfo } = useUserInfo(); + const { options: groupOptions, isLoading: isGroupFilterLoading } = + useDashboardGroupFilterOptions(userInfo); + + const [preset, setPreset] = useState("30d"); + const [range, setRange] = useState(() => rangeForPreset("30d")); + const [calendarOpen, setCalendarOpen] = useState(false); + const [groupId, setGroupId] = useState("all"); + + const { startDate, endDate } = resolveApiDates(range); + const dateLabel = formatRangeLabel(range); + + const { data, isLoading, isFetching, error } = useQuery({ + queryKey: ["analytics-overview", startDate, endDate, groupId], + queryFn: () => + fetchAnalyticsOverview({ + start_date: startDate!, + end_date: endDate!, + ...(groupId !== "all" ? { group_id: groupId } : {}), + top_limit: 10, + }), + enabled: Boolean(startDate && endDate), + staleTime: 60_000, + }); + + const chartData = useMemo( + () => + (data?.timeline ?? []).map((point) => ({ + ...point, + label: formatAxisDate(point.date), + })), + [data?.timeline], + ); + + const applyPreset = (next: Exclude) => { + setPreset(next); + setRange(rangeForPreset(next)); + }; + + const totalUsers = data?.users.total_users ?? (isLoading ? "—" : 0); + const newUsersThisMonth = + data?.users.new_users_this_month ?? (isLoading ? "—" : 0); + const newUsersInRange = + data?.users.new_users_in_range ?? (isLoading ? "—" : 0); + + return ( +
+
+
+
+

+ Analytics +

+

+ Users, joins, and plan completion across your content +

+
+ +
+
+ {PRESETS.map((item) => ( + + ))} +
+ + + + + + + { + setPreset("custom"); + setRange(next ?? { from: undefined, to: undefined }); + }} + disabled={{ after: new Date() }} + /> + + + + {groupOptions.length > 0 ? ( + + + + + + All groups + {groupOptions.map((group) => ( + + {group.label} + + ))} + + + ) : null} +
+
+ + {error ? ( +
+ {getApiErrorMessage(error)} +
+ ) : null} + +
+ } + /> + } + /> + } + /> +
+ +
+ + {isLoading ? ( + + ) : ( + + + + + + + + + + + + + + + + )} + + + + {isLoading ? ( + + ) : ( + + + + + + + + + + + + )} + +
+ + {isLoading ? ( + + ) : ( + + )} +
+
+ ); }; export default Analytics; diff --git a/src/components/routes/analytics/analyticsApi.ts b/src/components/routes/analytics/analyticsApi.ts new file mode 100644 index 00000000..7ebd47f4 --- /dev/null +++ b/src/components/routes/analytics/analyticsApi.ts @@ -0,0 +1,60 @@ +import axiosInstance from "@/config/axios-config"; + +export type AnalyticsDateRange = { + start_date: string; + end_date: string; +}; + +export type AnalyticsUserStats = { + total_users: number; + new_users_this_month: number; + new_users_in_range: number; +}; + +export type AnalyticsTimePoint = { + date: string; + new_users: number; + joins: number; + completions: number; +}; + +export type AnalyticsTopPlan = { + id: string; + title: string; + series_id: string | null; + series_name: string | null; + join_count: number; + completion_count: number; +}; + +export type AnalyticsOverview = { + date_range: AnalyticsDateRange; + users: AnalyticsUserStats; + top_plans: AnalyticsTopPlan[]; + timeline: AnalyticsTimePoint[]; + generated_at: string; +}; + +export type FetchAnalyticsParams = { + start_date: string; + end_date: string; + group_id?: string; + top_limit?: number; +}; + +export async function fetchAnalyticsOverview( + params: FetchAnalyticsParams, +): Promise { + const { data } = await axiosInstance.get( + "/api/v1/cms/analytics/overview", + { + params: { + start_date: params.start_date, + end_date: params.end_date, + ...(params.group_id ? { group_id: params.group_id } : {}), + top_limit: params.top_limit ?? 10, + }, + }, + ); + return data; +} diff --git a/src/components/routes/china-restrictions/ChinaRestrictionsPage.tsx b/src/components/routes/china-restrictions/ChinaRestrictionsPage.tsx new file mode 100644 index 00000000..91c5ef53 --- /dev/null +++ b/src/components/routes/china-restrictions/ChinaRestrictionsPage.tsx @@ -0,0 +1,465 @@ +import { useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { IoMdAdd } from "react-icons/io"; +import { toast } from "sonner"; +import { format } from "date-fns"; +import { Navigate } from "react-router-dom"; +import { Pecha } from "@/components/ui/shadimport"; +import { Button } from "@/components/ui/atoms/button"; +import { DialogDescription, DialogFooter } from "@/components/ui/atoms/dialog"; +import { Pagination } from "@/components/ui/molecules/pagination/Pagination"; +import { getApiErrorMessage } from "@/lib/apiErrors"; +import { useUserInfo } from "@/hooks/useUserInfo"; +import { canAccessAdminAuthors, isSuperAdmin } from "@/lib/platformAccess"; +import { ROUTES } from "@/routes/paths"; +import { + createChinaRestrictedItem, + deleteChinaRestrictedItem, + fetchChinaRestrictedItems, + RESTRICTED_ITEM_TYPES, + searchChinaRestrictionCandidates, + type ChinaRestrictionCandidateDTO, + type RestrictedItemType, +} from "./api/chinaRestrictionsApi"; + +const PAGE_SIZE = 20; +const SEARCH_DEBOUNCE_MS = 400; + +const formatItemType = (type: RestrictedItemType) => + type.replaceAll("_", " ").replace(/\b\w/g, (c) => c.toUpperCase()); + +type RestrictionRow = { + id: string; + item_type: RestrictedItemType; + item_id: string; + title?: string | null; + subtitle?: string | null; + created_at: string; +}; + +const renderTableBody = ({ + isLoading, + items, + tableColumnCount, + writeEnabled, + isDeleting, + onRemove, +}: { + isLoading: boolean; + items: RestrictionRow[]; + tableColumnCount: number; + writeEnabled: boolean; + isDeleting: boolean; + onRemove: (id: string) => void; +}) => { + if (isLoading) { + return ( + + Loading… + + ); + } + + if (items.length === 0) { + return ( + + + No restricted items found. + + + ); + } + + return items.map((item) => ( + + {formatItemType(item.item_type)} + +
+

{item.title?.trim() || "Untitled item"}

+ {item.subtitle?.trim() ? ( +

+ {item.subtitle} +

+ ) : null} +
+
+ + {item.created_at + ? format(new Date(item.created_at), "MMM dd, yyyy HH:mm") + : "—"} + + {writeEnabled ? ( + + + + ) : null} +
+ )); +}; + +const renderCandidateOptions = ({ + isLoading, + candidates, + onSelect, +}: { + isLoading: boolean; + candidates: ChinaRestrictionCandidateDTO[]; + onSelect: (candidate: ChinaRestrictionCandidateDTO) => void; +}) => { + if (isLoading) { + return ( + + Searching… + + ); + } + + if (candidates.length === 0) { + return ( + + No matches found. + + ); + } + + return candidates.map((candidate) => ( + onSelect(candidate)} + > +
+

{candidate.title}

+ {candidate.subtitle?.trim() ? ( +

+ {candidate.subtitle} +

+ ) : null} +
+
+ )); +}; + +const ChinaRestrictionsPage = () => { + const { data: userInfo } = useUserInfo(); + const queryClient = useQueryClient(); + const [page, setPage] = useState(0); + const [typeFilter, setTypeFilter] = useState( + "ALL", + ); + const [addOpen, setAddOpen] = useState(false); + const [newItemType, setNewItemType] = useState("PLAN"); + const [selectedItem, setSelectedItem] = + useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [searchOpen, setSearchOpen] = useState(false); + const [deleteTargetId, setDeleteTargetId] = useState(null); + + const canAccess = Boolean( + userInfo && canAccessAdminAuthors(userInfo.platform_role), + ); + const writeEnabled = isSuperAdmin(userInfo?.platform_role); + + useEffect(() => { + const timer = setTimeout( + () => setDebouncedSearch(searchQuery.trim()), + SEARCH_DEBOUNCE_MS, + ); + return () => clearTimeout(timer); + }, [searchQuery]); + + useEffect(() => { + setSelectedItem(null); + setSearchQuery(""); + setDebouncedSearch(""); + }, [newItemType]); + + const { data, isLoading, error } = useQuery({ + queryKey: ["china-restrictions", page, typeFilter], + queryFn: () => + fetchChinaRestrictedItems({ + skip: page * PAGE_SIZE, + limit: PAGE_SIZE, + ...(typeFilter !== "ALL" && { item_type: typeFilter }), + }), + enabled: canAccess, + }); + + const { data: candidatesData, isFetching: candidatesLoading } = useQuery({ + queryKey: ["china-restriction-candidates", newItemType, debouncedSearch], + queryFn: () => + searchChinaRestrictionCandidates({ + item_type: newItemType, + search: debouncedSearch || undefined, + skip: 0, + limit: 20, + }), + enabled: canAccess && addOpen && searchOpen, + }); + + const invalidate = () => { + queryClient.invalidateQueries({ queryKey: ["china-restrictions"] }); + }; + + const createMutation = useMutation({ + mutationFn: createChinaRestrictedItem, + onSuccess: () => { + toast.success("Item added to China restrictions"); + setAddOpen(false); + setSelectedItem(null); + setSearchQuery(""); + invalidate(); + }, + onError: (err) => toast.error(getApiErrorMessage(err)), + }); + + const deleteMutation = useMutation({ + mutationFn: deleteChinaRestrictedItem, + onSuccess: () => { + toast.success("Restriction removed"); + setDeleteTargetId(null); + invalidate(); + }, + onError: (err) => toast.error(getApiErrorMessage(err)), + }); + + const items = data?.items ?? []; + const total = data?.total ?? 0; + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const tableColumnCount = writeEnabled ? 4 : 3; + const candidates = candidatesData?.items ?? []; + + const handleAdd = () => { + if (!selectedItem) { + toast.error("Search and select an item to restrict"); + return; + } + createMutation.mutate({ + item_type: newItemType, + item_id: selectedItem.id, + }); + }; + + if (userInfo && !canAccess) { + return ; + } + + return ( +
+
+
+

China restrictions

+

+ Items listed here are hidden from users in Chinese timezones. +

+
+ {writeEnabled ? ( + + ) : null} +
+ +
+ +
+ + {error ? ( +

+ {getApiErrorMessage(error)} +

+ ) : null} + +
+ + + + Type + Item + Added + {writeEnabled ? Actions : null} + + + + {renderTableBody({ + isLoading, + items, + tableColumnCount, + writeEnabled, + isDeleting: deleteMutation.isPending, + onRemove: setDeleteTargetId, + })} + + + + {totalPages > 1 ? ( +
+ setPage(p - 1)} + /> +
+ ) : null} +
+ + { + setAddOpen(open); + if (!open) { + setSelectedItem(null); + setSearchQuery(""); + setSearchOpen(false); + } + }} + > + + + Add China restriction + + Choose a content type, search by name, and select the item to hide + for users in Chinese timezones. + + +
+
+ + +
+
+ + + + + + + + + + + {renderCandidateOptions({ + isLoading: candidatesLoading, + candidates, + onSelect: (candidate) => { + setSelectedItem(candidate); + setSearchOpen(false); + }, + })} + + + + + +
+
+ + + + +
+
+ + !open && setDeleteTargetId(null)} + > + + + Remove restriction? + + This item will become visible again for users in Chinese + timezones. + + + + Cancel + { + if (deleteTargetId) { + deleteMutation.mutate(deleteTargetId); + } + }} + > + Remove + + + + +
+ ); +}; + +export default ChinaRestrictionsPage; diff --git a/src/components/routes/china-restrictions/api/chinaRestrictionsApi.ts b/src/components/routes/china-restrictions/api/chinaRestrictionsApi.ts new file mode 100644 index 00000000..61b6747a --- /dev/null +++ b/src/components/routes/china-restrictions/api/chinaRestrictionsApi.ts @@ -0,0 +1,110 @@ +import axiosInstance from "@/config/axios-config"; + +export const RESTRICTED_ITEM_TYPES = [ + "MANTRA", + "ACCUMULATOR", + "GROUP_ACCUMULATOR", + "PLAN", + "SERIES", + "GROUP", + "RECITATION", + "RECITATION_COLLECTION", +] as const; + +export type RestrictedItemType = (typeof RESTRICTED_ITEM_TYPES)[number]; + +export type ChinaRestrictedItemDTO = { + id: string; + item_type: RestrictedItemType; + item_id: string; + title?: string | null; + subtitle?: string | null; + created_at: string; + updated_at?: string | null; +}; + +export type ChinaRestrictedItemListResponse = { + items: ChinaRestrictedItemDTO[]; + skip: number; + limit: number; + total: number; +}; + +export type ChinaRestrictionCandidateDTO = { + id: string; + title: string; + subtitle?: string | null; +}; + +export type ChinaRestrictionCandidateListResponse = { + items: ChinaRestrictionCandidateDTO[]; + skip: number; + limit: number; + total: number; +}; + +export type FetchChinaRestrictedItemsParams = { + skip?: number; + limit?: number; + item_type?: RestrictedItemType; +}; + +export type SearchChinaRestrictionCandidatesParams = { + item_type: RestrictedItemType; + search?: string; + skip?: number; + limit?: number; +}; + +export type CreateChinaRestrictedItemPayload = { + item_type: RestrictedItemType; + item_id: string; +}; + +export const fetchChinaRestrictedItems = async ( + params: FetchChinaRestrictedItemsParams = {}, +): Promise => { + const { data } = await axiosInstance.get( + `/api/v1/cms/admin/china-restrictions`, + { + params: { + skip: params.skip ?? 0, + limit: params.limit ?? 20, + ...(params.item_type && { item_type: params.item_type }), + }, + }, + ); + return data; +}; + +export const searchChinaRestrictionCandidates = async ( + params: SearchChinaRestrictionCandidatesParams, +): Promise => { + const { data } = + await axiosInstance.get( + `/api/v1/cms/admin/china-restrictions/candidates`, + { + params: { + item_type: params.item_type, + skip: params.skip ?? 0, + limit: params.limit ?? 20, + ...(params.search?.trim() && { search: params.search.trim() }), + }, + }, + ); + return data; +}; + +export const createChinaRestrictedItem = async ( + payload: CreateChinaRestrictedItemPayload, +) => { + const { data } = await axiosInstance.post( + `/api/v1/cms/admin/china-restrictions`, + payload, + ); + return data; +}; + +export const deleteChinaRestrictedItem = async (rowId: string) => { + await axiosInstance.delete(`/api/v1/cms/admin/china-restrictions/${rowId}`); +}; diff --git a/src/components/routes/content-transfer/api/transferApi.test.ts b/src/components/routes/content-transfer/api/transferApi.test.ts new file mode 100644 index 00000000..095b47ed --- /dev/null +++ b/src/components/routes/content-transfer/api/transferApi.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { + filterIncomingForGroup, + idsEqual, + normalizeTransferListResponse, + normalizeTransferRequest, +} from "./transferApi"; + +describe("transferApi normalization", () => { + it("parses CMS incoming transfers payload", () => { + const payload = { + transfers: [ + { + id: "971fff46-27c2-4edb-8c7b-5e2d2c0820dc", + entity_type: "plan", + entity_id: "4ed427a3-a9cc-4db3-a612-77b4b906c106", + from_group_id: "c9e9f7f6-1dfd-4649-8b9e-8d9d22fe7951", + to_group_id: "583ccaf1-ffe4-47af-8907-132ea7a5f4b3", + status: "PENDING", + entity_title: "sc", + from_group_title: "G2", + to_group_title: "G3", + expires_at: "2026-06-04T11:27:57.654533Z", + created_at: "2026-06-04T10:47:35.106483Z", + }, + ], + total: 1, + }; + const { requests } = normalizeTransferListResponse(payload); + expect(requests).toHaveLength(1); + expect(requests[0]?.content_type).toBe("plan"); + expect(requests[0]?.target_group_id).toBe( + "583ccaf1-ffe4-47af-8907-132ea7a5f4b3", + ); + expect( + filterIncomingForGroup(requests, "583ccaf1-ffe4-47af-8907-132ea7a5f4b3"), + ).toHaveLength(1); + }); + + it("extracts nested request arrays", () => { + const payload = { + data: { + requests: [ + { + id: "req-1", + content_type: "plan", + content_id: "plan-1", + status: "PENDING", + source_group_id: "g-source", + target_group_id: "g-target", + }, + ], + }, + }; + expect(normalizeTransferListResponse(payload).requests).toHaveLength(1); + }); + + it("normalizes alternate id and group field names", () => { + const row = normalizeTransferRequest({ + request_id: "req-2", + content_type: "series", + series_id: "series-1", + status: "pending", + source_group: { id: "aaa-bbb" }, + target_group: { id: "ccc-ddd" }, + expires_at: "2099-01-01T00:00:00Z", + }); + expect(row?.id).toBe("req-2"); + expect(row?.content_type).toBe("series"); + expect(row?.source_group_id).toBe("aaa-bbb"); + expect(row?.target_group_id).toBe("ccc-ddd"); + expect(row?.status).toBe("PENDING"); + }); + + it("filters incoming by target group case-insensitively", () => { + const requests = [ + { + id: "1", + status: "PENDING" as const, + content_type: "plan" as const, + content_id: "p1", + source_group_id: "a", + target_group_id: "ABC-DEF", + created_at: "", + expires_at: "2099-01-01T00:00:00Z", + }, + ]; + expect( + filterIncomingForGroup(requests, "abc-def").map((r) => r.id), + ).toEqual(["1"]); + }); + + it("compares ids case-insensitively", () => { + expect(idsEqual("ABC", "abc")).toBe(true); + }); +}); diff --git a/src/components/routes/content-transfer/api/transferApi.ts b/src/components/routes/content-transfer/api/transferApi.ts new file mode 100644 index 00000000..54894aa6 --- /dev/null +++ b/src/components/routes/content-transfer/api/transferApi.ts @@ -0,0 +1,284 @@ +import axiosInstance from "@/config/axios-config"; + +export type TransferRequestStatus = + | "PENDING" + | "ACCEPTED" + | "REJECTED" + | "REVOKED" + | "EXPIRED"; + +export type ContentTransferRequestDTO = { + id: string; + status: TransferRequestStatus; + content_type: "plan" | "series"; + content_id: string; + content_title?: string | null; + source_group_id: string; + target_group_id: string; + source_group_title?: string | null; + target_group_title?: string | null; + created_at: string; + expires_at: string; +}; + +export type TransferRequestListResponse = { + requests: ContentTransferRequestDTO[]; +}; + +function pickGroupId(value: unknown): string | undefined { + if (typeof value === "string" && value.trim()) return value.trim(); + if (value && typeof value === "object" && "id" in value) { + const id = (value as { id?: unknown }).id; + if (typeof id === "string" && id.trim()) return id.trim(); + } + return undefined; +} + +function pickString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +export function idsEqual(a?: string | null, b?: string | null): boolean { + if (!a?.trim() || !b?.trim()) return false; + return a.trim().toLowerCase() === b.trim().toLowerCase(); +} + +/** Walk common API envelope shapes to find the request array. */ +export function extractTransferRequestArray( + data: unknown, + depth = 0, +): unknown[] { + if (data == null || depth > 5) return []; + if (Array.isArray(data)) return data; + + if (typeof data !== "object") return []; + + const root = data as Record; + const arrayKeys = [ + "transfers", + "requests", + "items", + "transfer_requests", + "incoming", + "outgoing", + "results", + "data", + ]; + + for (const key of arrayKeys) { + const nested = extractTransferRequestArray(root[key], depth + 1); + if (nested.length > 0) return nested; + } + + return []; +} + +/** Normalizes list payloads when the API uses alternate keys or nested group objects. */ +export function normalizeTransferRequest( + raw: unknown, +): ContentTransferRequestDTO | null { + if (!raw || typeof raw !== "object") return null; + const r = raw as Record; + + const id = + pickString(r.id) ?? + pickString(r.request_id) ?? + pickString(r.transfer_request_id); + if (!id) return null; + + const typeRaw = + pickString(r.entity_type) ?? + pickString(r.content_type) ?? + pickString(r.type); + const contentType = typeRaw?.toLowerCase() === "series" ? "series" : "plan"; + + const contentId = + pickString(r.entity_id) ?? + pickString(r.content_id) ?? + (contentType === "series" + ? pickString(r.series_id) + : pickString(r.plan_id)) ?? + ""; + + const targetGroup = + r.to_group_id ?? + r.target_group_id ?? + r.target_group_uuid ?? + r.targetGroupId ?? + r.destination_group_id ?? + r.target_group; + + const sourceGroup = + r.from_group_id ?? + r.source_group_id ?? + r.source_group_uuid ?? + r.sourceGroupId ?? + r.source_group; + + return { + id, + status: + (pickString(r.status)?.toUpperCase() as TransferRequestStatus) ?? + "PENDING", + content_type: contentType, + content_id: contentId, + content_title: + pickString(r.entity_title) ?? + pickString(r.content_title) ?? + pickString(r.title) ?? + null, + source_group_id: pickGroupId(sourceGroup) ?? "", + target_group_id: pickGroupId(targetGroup) ?? "", + source_group_title: + pickString(r.from_group_title) ?? + pickString(r.source_group_title) ?? + pickString((sourceGroup as { title?: string } | undefined)?.title) ?? + null, + target_group_title: + pickString(r.to_group_title) ?? + pickString(r.target_group_title) ?? + pickString((targetGroup as { title?: string } | undefined)?.title) ?? + null, + created_at: pickString(r.created_at) ?? new Date().toISOString(), + expires_at: + pickString(r.expires_at) ?? + pickString(r.expiresAt) ?? + new Date().toISOString(), + }; +} + +export function normalizeTransferListResponse( + data: unknown, +): TransferRequestListResponse { + const list = extractTransferRequestArray(data); + const requests = list + .map(normalizeTransferRequest) + .filter((r): r is ContentTransferRequestDTO => r != null); + + return { requests }; +} + +export function filterIncomingForGroup( + requests: ContentTransferRequestDTO[], + groupId: string, +): ContentTransferRequestDTO[] { + const matched = requests.filter( + (r) => !r.target_group_id || idsEqual(r.target_group_id, groupId), + ); + return matched; +} + +export function filterOutgoingForGroup( + requests: ContentTransferRequestDTO[], + groupId: string, +): ContentTransferRequestDTO[] { + return requests.filter( + (r) => !r.source_group_id || idsEqual(r.source_group_id, groupId), + ); +} + +/** Prefer global list (what notifications use); optional group query params. */ +async function fetchIncomingList( + groupId?: string, +): Promise { + const { data } = await axiosInstance.get( + "/api/v1/cms/transfer-requests/incoming", + { + params: groupId + ? { target_group_id: groupId, group_id: groupId } + : undefined, + }, + ); + const normalized = normalizeTransferListResponse(data); + + if (!groupId) return normalized; + + const matched = filterIncomingForGroup(normalized.requests, groupId); + if (matched.length > 0) return { requests: matched }; + + // Server returned rows but group ids were missing/mismatched in JSON — show all. + if (normalized.requests.length > 0) return normalized; + + return { requests: [] }; +} + +async function fetchOutgoingList( + groupId?: string, +): Promise { + const { data } = await axiosInstance.get( + "/api/v1/cms/transfer-requests/outgoing", + { + params: groupId + ? { source_group_id: groupId, group_id: groupId } + : undefined, + }, + ); + const normalized = normalizeTransferListResponse(data); + + if (!groupId) return normalized; + + const matched = filterOutgoingForGroup(normalized.requests, groupId); + if (matched.length > 0) return { requests: matched }; + + if (normalized.requests.length > 0) return normalized; + + return { requests: [] }; +} + +export const fetchIncomingTransferRequests = async ( + groupId?: string, +): Promise => fetchIncomingList(groupId); + +export const fetchOutgoingTransferRequests = async ( + groupId?: string, +): Promise => fetchOutgoingList(groupId); + +export const createPlanTransferRequest = async ( + planId: string, + targetGroupId: string, +): Promise => { + const { data } = await axiosInstance.post( + `/api/v1/cms/plans/${planId}/transfer-requests`, + { target_group_id: targetGroupId }, + ); + return normalizeTransferRequest(data) ?? (data as ContentTransferRequestDTO); +}; + +export const createSeriesTransferRequest = async ( + seriesId: string, + targetGroupId: string, +): Promise => { + const { data } = await axiosInstance.post( + `/api/v1/cms/series/${seriesId}/transfer-requests`, + { target_group_id: targetGroupId }, + ); + return normalizeTransferRequest(data) ?? (data as ContentTransferRequestDTO); +}; + +export const acceptTransferRequest = async (requestId: string) => { + const { data } = await axiosInstance.post( + `/api/v1/cms/transfer-requests/${requestId}/accept`, + ); + return data; +}; + +export const rejectTransferRequest = async (requestId: string) => { + const { data } = await axiosInstance.post( + `/api/v1/cms/transfer-requests/${requestId}/reject`, + ); + return data; +}; + +export const revokeTransferRequest = async (requestId: string) => { + const { data } = await axiosInstance.post( + `/api/v1/cms/transfer-requests/${requestId}/revoke`, + ); + return data; +}; + +export function isTransferRequestExpired( + req: ContentTransferRequestDTO, +): boolean { + if (req.status === "EXPIRED") return true; + return new Date(req.expires_at).getTime() <= Date.now(); +} diff --git a/src/components/routes/content-transfer/components/ContentTransferDialog.tsx b/src/components/routes/content-transfer/components/ContentTransferDialog.tsx new file mode 100644 index 00000000..c9541bcc --- /dev/null +++ b/src/components/routes/content-transfer/components/ContentTransferDialog.tsx @@ -0,0 +1,168 @@ +import { useEffect, useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { useDebounce } from "use-debounce"; +import { toast } from "sonner"; +import { Pecha } from "@/components/ui/shadimport"; +import { Button } from "@/components/ui/atoms/button"; +import { getApiErrorMessage } from "@/lib/apiErrors"; +import { + createPlanTransferRequest, + createSeriesTransferRequest, +} from "../api/transferApi"; +import { + fetchGroupsForTransfer, + pickGroupTitle, + type AuthorGroupListItem, +} from "@/components/routes/groups/api/groupsApi"; + +type ContentTransferDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + contentType: "plan" | "series"; + contentId: string; + sourceGroupId: string; + contentTitle?: string; + onSuccess?: () => void; +}; + +const ContentTransferDialog = ({ + open, + onOpenChange, + contentType, + contentId, + sourceGroupId, + contentTitle, + onSuccess, +}: ContentTransferDialogProps) => { + const [search, setSearch] = useState(""); + const [debouncedSearch] = useDebounce(search, 400); + const [groups, setGroups] = useState([]); + const [isLoadingGroups, setIsLoadingGroups] = useState(false); + const [selectedGroupId, setSelectedGroupId] = useState(null); + + useEffect(() => { + if (!open) { + setSearch(""); + setSelectedGroupId(null); + setGroups([]); + return; + } + + let cancelled = false; + setIsLoadingGroups(true); + void fetchGroupsForTransfer({ + search: debouncedSearch || undefined, + excludeGroupId: sourceGroupId, + }) + .then((list) => { + if (cancelled) return; + setGroups(list); + }) + .catch(() => { + if (!cancelled) setGroups([]); + }) + .finally(() => { + if (!cancelled) setIsLoadingGroups(false); + }); + + return () => { + cancelled = true; + }; + }, [open, debouncedSearch, sourceGroupId]); + + const transferMutation = useMutation({ + mutationFn: (targetGroupId: string) => + contentType === "series" + ? createSeriesTransferRequest(contentId, targetGroupId) + : createPlanTransferRequest(contentId, targetGroupId), + onSuccess: () => { + toast.success("Transfer request sent", { + description: "The target group can accept or reject the request.", + }); + onOpenChange(false); + onSuccess?.(); + }, + onError: (err) => toast.error(getApiErrorMessage(err)), + }); + + const entityLabel = contentType === "series" ? "series" : "plan"; + + return ( + + + + Transfer {entityLabel} + +

+ {contentTitle + ? `Send "${contentTitle}" to another group for approval.` + : `Choose a target group for this ${entityLabel}.`} +

+ +
+ setSearch(e.target.value)} + /> + +
+ {isLoadingGroups ? ( +

+ Loading groups… +

+ ) : groups.length === 0 ? ( +

+ No other groups found. +

+ ) : ( +
    + {groups.map((group) => { + const title = pickGroupTitle(group.metadata); + const selected = selectedGroupId === group.id; + return ( +
  • + +
  • + ); + })} +
+ )} +
+
+ +
+ + +
+
+
+ ); +}; + +export default ContentTransferDialog; diff --git a/src/components/routes/create-plan/CreatePlan.test.tsx b/src/components/routes/create-plan/CreatePlan.test.tsx index e546aa4a..83cf990c 100644 --- a/src/components/routes/create-plan/CreatePlan.test.tsx +++ b/src/components/routes/create-plan/CreatePlan.test.tsx @@ -4,8 +4,14 @@ import { screen, waitFor, act, + within, } from "@testing-library/react"; -import { MemoryRouter, useBlocker, useParams } from "react-router-dom"; +import { + MemoryRouter, + useBlocker, + useLocation, + useParams, +} from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import CreatePlan from "./CreatePlan"; import { vi } from "vitest"; @@ -20,11 +26,21 @@ vi.mock("react-router-dom", async () => { proceed: vi.fn(), reset: vi.fn(), })), - useParams: vi.fn(() => ({})), + useParams: vi.fn(() => ({ groupId: "test-group-id" })), + useLocation: vi.fn(() => ({ + pathname: "/groups/test-group-id/plan/new", + search: "", + hash: "", + state: null, + key: "default", + })), }; }); -const renderWithProviders = (component: React.ReactElement) => { +const renderWithProviders = ( + component: React.ReactElement, + initialEntry: string | { pathname: string; state?: unknown } = "/plan/new", +) => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, @@ -33,7 +49,7 @@ const renderWithProviders = (component: React.ReactElement) => { }); return render( - + {component} @@ -65,7 +81,24 @@ vi.mock( describe("CreatePlan Component", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(useParams).mockReturnValue({}); + // `clearAllMocks` resets call history but keeps implementations set via + // `mockReturnValue`. The navigation tests force `useBlocker` into a + // "blocked" state, which opens the navigation dialog; reset it here so + // that leaked dialog doesn't `aria-hidden` the form in later tests. + vi.mocked(useBlocker).mockReturnValue({ + state: "unblocked", + proceed: undefined, + reset: undefined, + location: undefined, + }); + vi.mocked(useParams).mockReturnValue({ groupId: "test-group-id" }); + vi.mocked(useLocation).mockReturnValue({ + pathname: "/groups/test-group-id/plan/new", + search: "", + hash: "", + state: null, + key: "default", + }); vi.spyOn(axiosInstance, "post").mockImplementation((url: string) => { if (url.includes("/media/upload")) { return Promise.resolve({ @@ -151,7 +184,7 @@ describe("CreatePlan Component", () => { renderWithProviders(); expect( - screen.getByText("studio.plan.form_field.details"), + screen.getByRole("heading", { name: "Plan Details" }), ).toBeInTheDocument(); }); @@ -350,7 +383,7 @@ describe("CreatePlan Component", () => { }); it("shows validation errors for required fields", async () => { - vi.mocked(useParams).mockReturnValue({}); + vi.mocked(useParams).mockReturnValue({ groupId: "test-group-id" }); renderWithProviders(); const submitButton = screen.getByText("studio.plan.next_button"); fireEvent.click(submitButton); @@ -405,13 +438,35 @@ describe("CreatePlan Component", () => { screen.getByRole("button", { name: 'Create "Brand New Tag"' }), ); + // Clicking the create option opens a multi-language dialog with the + // typed name pre-filled for the default (EN) language. + const createTagButton = await screen.findByRole("button", { + name: "Create Tag", + }); + fireEvent.click(createTagButton); + await waitFor(() => { expect(axiosInstance.post).toHaveBeenCalledWith( "/api/v1/cms/tags", - expect.objectContaining({ name: "Brand New Tag" }), + expect.objectContaining({ + metadata: expect.arrayContaining([ + expect.objectContaining({ + language: "EN", + name: "Brand New Tag", + }), + ]), + }), expect.any(Object), ); }); + + // Successful creation closes the dialog; wait for it so the Radix + // scroll-lock is torn down and does not leak into later tests. + await waitFor(() => { + expect( + screen.queryByRole("button", { name: "Create Tag" }), + ).not.toBeInTheDocument(); + }); }); it("shows no image preview initially", () => { @@ -555,8 +610,7 @@ describe("CreatePlan Component", () => { await waitFor(() => { expect(axiosInstance.put).toHaveBeenCalledWith( "/api/v1/cms/plans/plan-123", - expect.any(Object), - expect.any(Object), + expect.objectContaining({ title: "Updated Plan" }), ); }); }); @@ -588,8 +642,104 @@ describe("CreatePlan Component", () => { expect(putSpy).toHaveBeenCalledWith( "/api/v1/cms/plans/plan-123", expect.objectContaining({ start_date: null }), - expect.any(Object), ); }); }); + + describe("create plan from series details", () => { + beforeEach(() => { + vi.spyOn(axiosInstance, "get").mockImplementation((url: string) => { + if (url.includes("/api/v1/cms/series")) { + return Promise.resolve({ + data: { + series: [ + { + id: "series-1", + metadata: [{ title: "Abhidhamma in a year", language: "EN" }], + }, + ], + }, + }); + } + if (url.includes("/cms/tags")) { + return Promise.resolve({ + data: { + tags: [], + skip: 0, + limit: 500, + total: 0, + }, + }); + } + return Promise.resolve({ data: {} }); + }); + }); + + it("pre-fills and locks series and language when location state is valid", async () => { + vi.mocked(useLocation).mockReturnValue({ + pathname: "/plan/new", + search: "", + hash: "", + state: { seriesId: "series-1", language: "EN" }, + key: "default", + }); + + renderWithProviders(); + + await waitFor(() => { + expect( + screen.getByRole("heading", { + name: "Add New Plan for Abhidhamma in a year (English)", + }), + ).toBeInTheDocument(); + expect(screen.getByText("Abhidhamma in a year")).toBeInTheDocument(); + }); + + const seriesField = screen + .getByText("Series") + .closest('[data-slot="form-item"]'); + expect(seriesField).not.toBeNull(); + expect( + within(seriesField as HTMLElement).getByRole("combobox", { + hidden: true, + }), + ).toBeDisabled(); + + const languageField = screen + .getByText("studio.plan.form_field.language") + .closest('[data-slot="form-item"]'); + expect(languageField).not.toBeNull(); + const languageSelect = within(languageField as HTMLElement).getByRole( + "combobox", + { hidden: true }, + ); + expect(languageSelect).toBeDisabled(); + expect(languageSelect).toHaveTextContent("English"); + }); + + it("falls back to normal form when series id is not in the list", async () => { + vi.mocked(useLocation).mockReturnValue({ + pathname: "/plan/new", + search: "", + hash: "", + state: { seriesId: "missing-series", language: "EN" }, + key: "default", + }); + + renderWithProviders(); + + await waitFor(() => { + const seriesField = screen + .getByText("Series") + .closest('[data-slot="form-item"]'); + expect(seriesField).not.toBeNull(); + const seriesCombobox = within(seriesField as HTMLElement).getByRole( + "combobox", + { hidden: true }, + ); + expect(seriesCombobox).toHaveTextContent("None"); + expect(seriesCombobox).not.toBeDisabled(); + }); + }); + }); }); diff --git a/src/components/routes/create-plan/CreatePlan.tsx b/src/components/routes/create-plan/CreatePlan.tsx index 14db40b9..9f57b516 100644 --- a/src/components/routes/create-plan/CreatePlan.tsx +++ b/src/components/routes/create-plan/CreatePlan.tsx @@ -3,24 +3,36 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { IoMdAdd, IoMdClose } from "react-icons/io"; import { IoCalendarClearOutline, + IoChevronDown, IoInformationCircleOutline, } from "react-icons/io5"; - -import { useState, useRef, useEffect } from "react"; +import { useState, useRef, useEffect, useMemo } from "react"; import { format } from "date-fns"; -import { Textarea } from "@/components/ui/atoms/textarea"; -import { useBlocker, useNavigate, useParams } from "react-router-dom"; +import { MarkdownEditor } from "@/components/ui/atoms/markdown-editor"; +import { + useBlocker, + useLocation, + useNavigate, + useParams, +} from "react-router-dom"; +import { parsePlanNewFromSeriesState } from "./planNewFromSeriesState"; import { ROUTES } from "@/routes/paths"; import { planSchema } from "@/schema/PlanSchema"; import { z } from "zod"; import { useTranslate } from "@tolgee/react"; import PlanTagSearchInput from "./PlanTagSearchInput"; +import PlanVideosSection from "./PlanVideosSection"; +import type { PlanVideoSummary } from "@/components/routes/task/api/planApi"; import { planTagsToIds, type PlanTagSummary, } from "@/components/routes/tags/api/tagsApi"; +import { + fetchSeriesList, + type SeriesOption, +} from "@/components/routes/create-series/api/seriesApi"; import { DIFFICULTY, PLAN_LANGUAGE } from "@/lib/constant"; -import { toBackendISO, fromBackendISO, isPastDate } from "@/lib/utils"; +import { cn, toBackendISO, fromBackendISO, isPastDate } from "@/lib/utils"; import axiosInstance from "@/config/axios-config"; import { useMutation, useQuery } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -33,14 +45,10 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/atoms/tooltip"; +import { resolveDashboardItemImageUrl } from "@/components/routes/dashboard/dashboardTable"; export const getPlan = async (plan_id: string) => { - const accessToken = sessionStorage.getItem("accessToken"); - const { data } = await axiosInstance.get(`/api/v1/cms/plans/${plan_id}`, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); + const { data } = await axiosInstance.get(`/api/v1/cms/plans/${plan_id}`); return data; }; @@ -51,29 +59,20 @@ export const updatePlan = async ({ plan_id: string; formdata: z.infer; }) => { - const accessToken = sessionStorage.getItem("accessToken"); const { data } = await axiosInstance.put( `/api/v1/cms/plans/${plan_id}`, formdata, - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }, ); return data; }; export const postPlan = async (formdata: z.infer) => { - const accessToken = sessionStorage.getItem("accessToken"); - const { data } = await axiosInstance.post(`/api/v1/cms/plans`, formdata, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); + const { data } = await axiosInstance.post(`/api/v1/cms/plans`, formdata); return data; }; +const EMPTY_SERIES_OPTIONS: SeriesOption[] = []; + const Createplan = () => { const [selectedImage, setSelectedImage] = useState(null); const [imagePreview, setImagePreview] = useState(null); @@ -86,11 +85,26 @@ const Createplan = () => { ); const [isDateOpen, setIsDateOpen] = useState(false); - const { planId } = useParams<{ planId?: string }>(); + const { planId, groupId } = useParams<{ + planId?: string; + groupId?: string; + }>(); + const location = useLocation(); const isCreateMode = !planId; const { t } = useTranslate(); + const planNewFromSeries = useMemo( + () => (isCreateMode ? parsePlanNewFromSeriesState(location.state) : null), + [isCreateMode, location.state], + ); type PlanFormData = z.infer; const navigate = useNavigate(); + + useEffect(() => { + if (isCreateMode && !groupId) { + navigate(ROUTES.groups, { replace: true }); + } + }, [isCreateMode, groupId, navigate]); + const form = useForm({ resolver: zodResolver(planSchema), defaultValues: { @@ -102,6 +116,7 @@ const Createplan = () => { tags: [], language: "", start_date: null, + series_id: null, }, }); @@ -111,6 +126,117 @@ const Createplan = () => { enabled: !!planId, refetchOnWindowFocus: false, }); + + const { + data: seriesOptionsData, + isLoading: isSeriesLoading, + isError: isSeriesError, + } = useQuery({ + queryKey: ["cms-series-list"], + queryFn: fetchSeriesList, + refetchOnWindowFocus: false, + }); + const seriesOptions = seriesOptionsData ?? EMPTY_SERIES_OPTIONS; + + useEffect(() => { + if (isSeriesError) { + toast.error("Couldn't load series", { + description: + "The series list is unavailable. The plan's current series won't be changed.", + }); + } + }, [isSeriesError]); + + const [isSeriesOpen, setIsSeriesOpen] = useState(false); + const [seriesQuery, setSeriesQuery] = useState(""); + + const filteredSeriesOptions = useMemo(() => { + const q = seriesQuery.trim().toLowerCase(); + if (!q) return seriesOptions; + return seriesOptions.filter((s) => s.title.toLowerCase().includes(q)); + }, [seriesOptions, seriesQuery]); + + const lockSeriesAndLanguageFields = useMemo(() => { + if (!planNewFromSeries) return false; + if (isSeriesLoading) return true; + if (isSeriesError) return false; + return seriesOptions.some((s) => s.id === planNewFromSeries.seriesId); + }, [planNewFromSeries, isSeriesLoading, isSeriesError, seriesOptions]); + + const lockStartDateFromSeries = useMemo( + () => + isCreateMode && + planNewFromSeries != null && + planNewFromSeries.start_date !== undefined, + [isCreateMode, planNewFromSeries], + ); + + const pageHeading = useMemo(() => { + if (!isCreateMode) return "Plan Edit"; + if (!planNewFromSeries) return "Plan Details"; + if (lockSeriesAndLanguageFields) { + const seriesTitle = seriesOptions.find( + (s) => s.id === planNewFromSeries.seriesId, + )?.title; + const languageLabel = PLAN_LANGUAGE.find( + (l) => l.value === planNewFromSeries.language, + )?.label; + if (seriesTitle && languageLabel) { + return `Add New Plan for ${seriesTitle} (${languageLabel})`; + } + if (seriesTitle) return `Add New Plan for ${seriesTitle}`; + } + if (isSeriesLoading) return "Add New Plan"; + return "Plan Details"; + }, [ + isCreateMode, + planNewFromSeries, + lockSeriesAndLanguageFields, + seriesOptions, + isSeriesLoading, + ]); + + useEffect(() => { + if (!isCreateMode || !planNewFromSeries) return; + + if (isSeriesLoading) { + form.setValue("series_id", planNewFromSeries.seriesId); + form.setValue("language", planNewFromSeries.language); + return; + } + + const seriesExists = seriesOptions.some( + (s) => s.id === planNewFromSeries.seriesId, + ); + + if (!isSeriesError && seriesExists) { + form.setValue("series_id", planNewFromSeries.seriesId); + form.setValue("language", planNewFromSeries.language); + return; + } + + form.setValue("series_id", null); + form.setValue("language", ""); + }, [ + isCreateMode, + planNewFromSeries, + isSeriesLoading, + isSeriesError, + seriesOptions, + form, + ]); + + useEffect(() => { + if (!isCreateMode || !planNewFromSeries) return; + if (planNewFromSeries.start_date === undefined) return; + + const mode = planNewFromSeries.start_date ? "specific" : "enroll"; + setStartDateMode(mode); + form.setValue("start_date", planNewFromSeries.start_date, { + shouldDirty: false, + }); + }, [isCreateMode, planNewFromSeries, form]); + useEffect(() => { if (planId && planData) { form.reset({ @@ -122,12 +248,24 @@ const Createplan = () => { tags: planTagsToIds(planData.tags), language: planData.language || "", start_date: planData.start_date || null, + series_id: planData.series_id ?? null, }); setStartDateMode(planData.start_date ? "specific" : "enroll"); - setImagePreview(planData.image_url ? `${planData.image_url}` : null); + const resolvedImageUrl = resolveDashboardItemImageUrl({ + image_url: planData.image_url, + plan_image_url: planData.plan_image_url, + image_key: planData.image_key, + image: planData.image, + }); + setImagePreview(resolvedImageUrl || null); } }, [planId, planData]); + const planVideos = useMemo( + () => (Array.isArray(planData?.videos) ? planData.videos : []), + [planData], + ); + const canUpdate = form.formState.isDirty; const hasUnsavedChanges = canUpdate && !form.formState.isSubmitSuccessful; @@ -145,7 +283,11 @@ const Createplan = () => { form.reset(); setSelectedImage(null); setImagePreview(null); - navigate(ROUTES.plan(data.id)); + if (groupId) { + navigate(ROUTES.group(groupId)); + } else { + navigate(ROUTES.plan(data.id)); + } }, onError: (error) => { toast.error("Failed to create plan", { @@ -155,7 +297,7 @@ const Createplan = () => { }); const updatePlanMutation = useMutation({ mutationFn: updatePlan, - onSuccess: (_) => { + onSuccess: () => { toast.success("Plan updated successfully!", { description: "Your plan has been updated and is now available.", }); @@ -210,13 +352,15 @@ const Createplan = () => { }); setIsImageDialogOpen(false); toast.success("Image uploaded successfully!"); - } catch (error: any) { - if (error?.response?.status === 413) { + } catch (error: unknown) { + if ( + (error as { response?: { status?: number } }).response?.status === 413 + ) { toast.error("Failed to update Image", { description: "file exceeds the maximum size of 1MB", }); } else { - console.error("Image upload failed:", error); + console.error("Image upload failed:", error as Error); toast.error("Failed to upload image"); } } finally { @@ -230,17 +374,26 @@ const Createplan = () => { start_date: startDateMode === "specific" ? data.start_date : null, }; if (planId) { - updatePlanMutation.mutate({ plan_id: planId, formdata: payload }); + if (isSeriesError) { + const { series_id: _seriesId, ...rest } = payload; + void _seriesId; + updatePlanMutation.mutate({ plan_id: planId, formdata: rest }); + } else { + updatePlanMutation.mutate({ plan_id: planId, formdata: payload }); + } } else { - createPlanMutation.mutate(payload); + const { series_id, ...rest } = payload; + const body = { + ...(series_id ? { ...rest, series_id } : rest), + group_id: groupId!, + }; + createPlanMutation.mutate(body); } }; return (
-

- {t("studio.plan.form_field.details")} -

+

{pageHeading}

@@ -273,12 +426,14 @@ const Createplan = () => { {t("studio.plan.form_field.description")} -