diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c69598d9d..dd6405c68 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,9 @@ "@radix-ui/react-dropdown-menu": "^2.1.14", "@radix-ui/react-select": "^2.2.4", "@radix-ui/react-slot": "^1.2.2", + "@tailwindcss/typography": "^0.5.16", + "@tanstack/react-query": "^5.76.1", + "@tanstack/react-query-devtools": "^5.76.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", @@ -21,7 +24,11 @@ "next-themes": "^0.4.6", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-markdown": "^10.1.0", "react-router-dom": "^7.6.0", + "react-textarea-autosize": "^8.5.9", + "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.1", "sonner": "^2.0.3", "tailwind-merge": "^3.2.0", "tailwindcss": "^4.1.6", @@ -279,6 +286,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.4.tgz", + "integrity": "sha512-t3yaEOuGu9NlIZ+hIeGbBjFtZT7j2cb2tg0fuaJKeGotchRjjLfrBA9Kwf8quhpP1EUuxModQg04q/mBwyg8uA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -2505,6 +2521,21 @@ "node": ">= 10" } }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz", + "integrity": "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==", + "license": "MIT", + "dependencies": { + "lodash.castarray": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.merge": "^4.6.2", + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, "node_modules/@tailwindcss/vite": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.6.tgz", @@ -2520,6 +2551,59 @@ "vite": "^5.2.0 || ^6" } }, + "node_modules/@tanstack/query-core": { + "version": "5.76.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.76.0.tgz", + "integrity": "sha512-FN375hb8ctzfNAlex5gHI6+WDXTNpe0nbxp/d2YJtnP+IBM6OUm7zcaoCW6T63BawGOYZBbKC0iPvr41TteNVg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.76.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.76.0.tgz", + "integrity": "sha512-1p92nqOBPYVqVDU0Ua5nzHenC6EGZNrLnB2OZphYw8CNA1exuvI97FVgIKON7Uug3uQqvH/QY8suUKpQo8qHNQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.76.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.76.1.tgz", + "integrity": "sha512-YxdLZVGN4QkT5YT1HKZQWiIlcgauIXEIsMOTSjvyD5wLYK8YVvKZUPAysMqossFJJfDpJW3pFn7WNZuPOqq+fw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.76.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.76.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.76.1.tgz", + "integrity": "sha512-LFVWgk/VtXPkerNLfYIeuGHh0Aim/k9PFGA+JxLdRaUiroQ4j4eoEqBrUpQ1Pd/KXoG4AB9vVE/M6PUQ9vwxBQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.76.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.76.1", + "react": "^18 || ^19" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2565,11 +2649,52 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", - "dev": true, + "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/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": { @@ -2586,7 +2711,6 @@ "version": "19.1.3", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.3.tgz", "integrity": "sha512-dLWQ+Z0CkIvK1J8+wrDPwGxEYFA4RAyHoZPxHVGspYmFVnwGSNT24cGIhFJrtfRnWVuW8X7NO52gCXmhkVUWGQ==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -2596,12 +2720,24 @@ "version": "19.1.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.3.tgz", "integrity": "sha512-rJXC08OG0h3W6wDMFxQrZF00Kq6qQvw0djHRdzl3U5DnIERz0MRce3WVc7IS6JYBwtaP/DwYtRRjVlvivNveKg==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.0.0" } }, + "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/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, "node_modules/@vitejs/plugin-react": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.4.1.tgz", @@ -2641,6 +2777,16 @@ "node": ">=10" } }, + "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/browserslist": { "version": "4.24.5", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz", @@ -2718,6 +2864,56 @@ ], "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/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/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -2749,6 +2945,16 @@ "node": ">=6" } }, + "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/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2792,11 +2998,22 @@ } } }, + "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/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, "license": "MIT" }, "node_modules/date-fns": { @@ -2813,7 +3030,6 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2827,6 +3043,28 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.1.0.tgz", + "integrity": "sha512-Wy+JTSbFThEOXQIR2L6mxJvEs+veIzpmqD7ynWxMXGpnk3smkHQOp6forLdHsKpAMW9iJpaBBIxz285t1n1C3w==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", @@ -2843,6 +3081,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/dot-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", @@ -2949,6 +3200,28 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", @@ -2956,6 +3229,12 @@ "dev": true, "license": "MIT" }, + "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/fdir": { "version": "6.4.4", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", @@ -3029,6 +3308,56 @@ "dev": true, "license": "ISC" }, + "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-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/immer": { "version": "10.1.1", "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", @@ -3056,6 +3385,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/inline-style-parser": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", + "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==", + "license": "MIT" + }, + "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-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -3063,6 +3422,38 @@ "dev": true, "license": "MIT" }, + "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-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-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/jiti": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", @@ -3372,6 +3763,34 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.castarray": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", + "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "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/lower-case": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", @@ -3411,36 +3830,893 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/minizlib": { + "node_modules/mdast-util-find-and-replace": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", - "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", - "dev": true, + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", "license": "MIT", "dependencies": { - "minipass": "^7.1.2" + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" }, - "engines": { - "node": ">= 18" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/mkdirp": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", - "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", - "dev": true, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", "license": "MIT", - "bin": { + "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-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.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-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.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-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@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-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-newline-to-break": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-newline-to-break/-/mdast-util-newline-to-break-2.0.0.tgz", + "integrity": "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-find-and-replace": "^3.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.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", + "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", + "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/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-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "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", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "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", + "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/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", + "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "dev": true, + "license": "MIT", + "bin": { "mkdirp": "dist/cjs/src/bin.js" }, "engines": { @@ -3454,7 +4730,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": { @@ -3517,6 +4792,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/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -3595,6 +4895,29 @@ "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/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/react": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", @@ -3616,6 +4939,33 @@ "react": "^19.1.0" } }, + "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-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -3733,6 +5083,104 @@ } } }, + "node_modules/react-textarea-autosize": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz", + "integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "use-composed-ref": "^1.3.0", + "use-latest": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/remark-breaks": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz", + "integrity": "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-newline-to-break": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "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/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3836,6 +5284,48 @@ "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/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/style-to-js": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.16.tgz", + "integrity": "sha512-/Q6ld50hKYPH3d/r6nr117TZkHR0w0kGGIVfpG9N6D8NymRPM9RqCUv4pRpJ62E5DqOYx2AFpbZMyCPnjQCnOw==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.8" + } + }, + "node_modules/style-to-object": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.8.tgz", + "integrity": "sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.4" + } + }, "node_modules/svg-parser": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", @@ -3914,6 +5404,26 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "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/tsconfck": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.5.tgz", @@ -3972,6 +5482,93 @@ "dev": 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.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", + "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", + "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.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "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.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", + "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", + "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", @@ -4024,6 +5621,51 @@ } } }, + "node_modules/use-composed-ref": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz", + "integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", + "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-latest": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz", + "integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==", + "license": "MIT", + "dependencies": { + "use-isomorphic-layout-effect": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sidecar": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", @@ -4055,6 +5697,40 @@ "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.2", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz", + "integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==", + "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/vite": { "version": "6.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", @@ -4200,6 +5876,16 @@ "optional": true } } + }, + "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/frontend/package.json b/frontend/package.json index 0a207555e..ff1fac9aa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,9 @@ "@radix-ui/react-dropdown-menu": "^2.1.14", "@radix-ui/react-select": "^2.2.4", "@radix-ui/react-slot": "^1.2.2", + "@tailwindcss/typography": "^0.5.16", + "@tanstack/react-query": "^5.76.1", + "@tanstack/react-query-devtools": "^5.76.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", @@ -25,7 +28,11 @@ "next-themes": "^0.4.6", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-markdown": "^10.1.0", "react-router-dom": "^7.6.0", + "react-textarea-autosize": "^8.5.9", + "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.1", "sonner": "^2.0.3", "tailwind-merge": "^3.2.0", "tailwindcss": "^4.1.6", diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 12aa584d6..c51e0a84a 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -1,7 +1,15 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import AppRouter from './providers/Router'; +const queryClient = new QueryClient(); + const App = () => { - return ; + return ( + + + + + ); }; - export default App; diff --git a/frontend/src/app/layout/AppLayout.tsx b/frontend/src/app/layout/AppLayout.tsx index e25cde17e..493ce569a 100644 --- a/frontend/src/app/layout/AppLayout.tsx +++ b/frontend/src/app/layout/AppLayout.tsx @@ -8,7 +8,7 @@ const AppLayout = () => (
-
+
diff --git a/frontend/src/app/providers/Router.tsx b/frontend/src/app/providers/Router.tsx index 04564cfac..3711b114b 100644 --- a/frontend/src/app/providers/Router.tsx +++ b/frontend/src/app/providers/Router.tsx @@ -9,15 +9,16 @@ import { Toaster } from '@/shared/ui/sonner'; import AppLayout from '@/app/layout/AppLayout'; import NoHeaderLayout from '@/app/layout/NoHeaderLayout'; +import IssueDetailPage from '@/pages/IssueDetailPage'; +import IssueListPage from '@/pages/IssueListPage'; import LabelListPage from '@/pages/LabelListPage'; -import LoginPage from '@/pages/LoginPage'; import MilestoneListPage from '@/pages/MilestoneListPage'; -import IssueDetailPage from '@/pages/issues/IssueDetailPage'; -import IssueListPage from '@/pages/issues/IssueListPage'; +import IssueCreatePage from '@/pages/issueCreatePage'; -import { IssueCreateModal } from '@/features/issueList/widget'; - -import AuthGuard from '@/shared/auth/AuthGuard'; +import { GitHubCallbackPage } from '@/pages/github-callback'; +import { LoginPage } from '@/pages/login'; +import { SignUpPage } from '@/pages/signup'; +import { ProtectedRoute } from '@/widgets/auth'; const router = createBrowserRouter([ { @@ -27,21 +28,26 @@ const router = createBrowserRouter([ path: '/login', element: , }, + { + path: '/auth/github/callback', + element: , + }, + { + path: '/signup', + element: , + }, ], }, { element: ( - + - + ), children: [ { path: '/', element: }, - { - path: '/issues', - element: , - children: [{ path: 'new', element: }], - }, + { path: '/issues', element: }, + { path: '/issues/new', element: }, { path: '/issues/:id', element: }, { path: '/labels', element: }, { path: '/milestones', element: }, diff --git a/frontend/src/assets/alertCircle.svg b/frontend/src/assets/alertCircle.svg new file mode 100644 index 000000000..5dd8856d1 --- /dev/null +++ b/frontend/src/assets/alertCircle.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/src/assets/archive.svg b/frontend/src/assets/archive.svg new file mode 100644 index 000000000..138d8e7f2 --- /dev/null +++ b/frontend/src/assets/archive.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/src/assets/calendar.svg b/frontend/src/assets/calendar.svg new file mode 100644 index 000000000..186bd2a26 --- /dev/null +++ b/frontend/src/assets/calendar.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/assets/edit.svg b/frontend/src/assets/edit.svg new file mode 100644 index 000000000..ff6e2cb59 --- /dev/null +++ b/frontend/src/assets/edit.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/src/assets/grip.svg b/frontend/src/assets/grip.svg new file mode 100644 index 000000000..2c08cb7ef --- /dev/null +++ b/frontend/src/assets/grip.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/icon_info.svg b/frontend/src/assets/icon_info.svg index 92ee49f52..5c12733dd 100644 --- a/frontend/src/assets/icon_info.svg +++ b/frontend/src/assets/icon_info.svg @@ -1,8 +1,8 @@ - - - + + + diff --git a/frontend/src/assets/light_logo_large.svg b/frontend/src/assets/light_logo_large.svg deleted file mode 100644 index 3b2a9b728..000000000 --- a/frontend/src/assets/light_logo_large.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/src/assets/light_logo_medium.svg b/frontend/src/assets/light_logo_medium.svg deleted file mode 100644 index 13740df45..000000000 --- a/frontend/src/assets/light_logo_medium.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/src/assets/dark_logo_large.svg b/frontend/src/assets/logo_large.svg similarity index 98% rename from frontend/src/assets/dark_logo_large.svg rename to frontend/src/assets/logo_large.svg index 5a1d82b15..c4f2fe41e 100644 --- a/frontend/src/assets/dark_logo_large.svg +++ b/frontend/src/assets/logo_large.svg @@ -1,3 +1,3 @@ - - + + diff --git a/frontend/src/assets/dark_logo_medium.svg b/frontend/src/assets/logo_medium.svg similarity index 98% rename from frontend/src/assets/dark_logo_medium.svg rename to frontend/src/assets/logo_medium.svg index 177fd1a3f..f0cd39c43 100644 --- a/frontend/src/assets/dark_logo_medium.svg +++ b/frontend/src/assets/logo_medium.svg @@ -1,3 +1,3 @@ - - + + diff --git a/frontend/src/assets/paperclip.svg b/frontend/src/assets/paperclip.svg new file mode 100644 index 000000000..c8404cca0 --- /dev/null +++ b/frontend/src/assets/paperclip.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/src/assets/refreshCcw.svg b/frontend/src/assets/refreshCcw.svg new file mode 100644 index 000000000..516980897 --- /dev/null +++ b/frontend/src/assets/refreshCcw.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/src/assets/search.svg b/frontend/src/assets/search.svg new file mode 100644 index 000000000..e5093945c --- /dev/null +++ b/frontend/src/assets/search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/smile.svg b/frontend/src/assets/smile.svg new file mode 100644 index 000000000..176a851cf --- /dev/null +++ b/frontend/src/assets/smile.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/src/assets/trash.svg b/frontend/src/assets/trash.svg new file mode 100644 index 000000000..00b4a1001 --- /dev/null +++ b/frontend/src/assets/trash.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/assets/xSquare.svg b/frontend/src/assets/xSquare.svg new file mode 100644 index 000000000..be1d8b69b --- /dev/null +++ b/frontend/src/assets/xSquare.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/entities/comment/api/commentAPI.ts b/frontend/src/entities/comment/api/commentAPI.ts new file mode 100644 index 000000000..35859bdc9 --- /dev/null +++ b/frontend/src/entities/comment/api/commentAPI.ts @@ -0,0 +1,43 @@ +import { getAuthHeaders } from '@/shared/lib/getAuthHeaders'; +import type { + CommentCreateRequest, + CommentResponse, + CommentUpdateRequest, +} from '../model/comment.types'; + +export async function createComment( + issueId: number, + payload: CommentCreateRequest, +): Promise { + const url = `/api/issues/${issueId}/comments`; + const res = await fetch(url, { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify(payload), + }); + return res.json(); +} + +export async function updateComment( + commentId: number, + payload: CommentUpdateRequest, +) { + const url = `/api/comments/${commentId}`; + const res = await fetch(url, { + method: 'PATCH', + headers: getAuthHeaders(), + body: JSON.stringify(payload), + }); + if (res.status === 204) return; + throw new Error('코멘트 수정 실패'); +} + +export async function deleteComment(commentId: number) { + const url = `/api/comments/${commentId}`; + const res = await fetch(url, { + method: 'DELETE', + headers: getAuthHeaders(false), + }); + if (res.status === 204) return; + throw new Error('코멘트 삭제 실패'); +} diff --git a/frontend/src/entities/comment/hooks/useCreateComment.ts b/frontend/src/entities/comment/hooks/useCreateComment.ts new file mode 100644 index 000000000..a57d7e40c --- /dev/null +++ b/frontend/src/entities/comment/hooks/useCreateComment.ts @@ -0,0 +1,23 @@ +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { createComment } from '../api/commentAPI'; +import type { + CommentCreateRequest, + CommentResponse, +} from '../model/comment.types'; + +export function useCreateComment(onSuccess?: () => void) { + return useMutation< + CommentResponse, + Error, + { issueId: number; payload: CommentCreateRequest } + >({ + mutationFn: ({ issueId, payload }) => createComment(issueId, payload), + onSuccess: (_data) => { + onSuccess?.(); + }, + onError: (error) => { + toast.error(error.message || '코멘트 생성에 실패했습니다.'); + }, + }); +} diff --git a/frontend/src/entities/comment/hooks/useDeleteComment.ts b/frontend/src/entities/comment/hooks/useDeleteComment.ts new file mode 100644 index 000000000..045313c15 --- /dev/null +++ b/frontend/src/entities/comment/hooks/useDeleteComment.ts @@ -0,0 +1,15 @@ +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { deleteComment } from '../api/commentAPI'; + +export function useDeleteComment(onSuccess?: () => void) { + return useMutation({ + mutationFn: deleteComment, + onSuccess: (_data) => { + onSuccess?.(); + }, + onError: (error) => { + toast.error(error.message || '코멘트 삭제에 실패했습니다.'); + }, + }); +} diff --git a/frontend/src/entities/comment/hooks/useUpdateComment.ts b/frontend/src/entities/comment/hooks/useUpdateComment.ts new file mode 100644 index 000000000..900ff2937 --- /dev/null +++ b/frontend/src/entities/comment/hooks/useUpdateComment.ts @@ -0,0 +1,20 @@ +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { updateComment } from '../api/commentAPI'; +import type { CommentUpdateRequest } from '../model/comment.types'; + +export function useUpdateComment(onSuccess?: () => void) { + return useMutation< + void, + Error, + { commentId: number; payload: CommentUpdateRequest } + >({ + mutationFn: ({ commentId, payload }) => updateComment(commentId, payload), + onSuccess: (_data) => { + onSuccess?.(); + }, + onError: (error) => { + toast.error(error.message || '코멘트 수정에 실패했습니다.'); + }, + }); +} diff --git a/frontend/src/entities/comment/model/comment.types.ts b/frontend/src/entities/comment/model/comment.types.ts new file mode 100644 index 000000000..48b3fc037 --- /dev/null +++ b/frontend/src/entities/comment/model/comment.types.ts @@ -0,0 +1,19 @@ +export interface CommentAttachment { + id: number; + fileName: string; + url: string; +} + +export interface CommentCreateRequest { + content: string; +} + +export interface CommentUpdateRequest { + content?: string; +} + +export interface CommentResponse { + success: boolean; + data: null; + error: string | null; +} diff --git a/frontend/src/entities/issue/api/issueAPI.ts b/frontend/src/entities/issue/api/issueAPI.ts new file mode 100644 index 000000000..cc6a06096 --- /dev/null +++ b/frontend/src/entities/issue/api/issueAPI.ts @@ -0,0 +1,96 @@ +import { getAuthHeaders } from '@/shared/lib/getAuthHeaders'; +import type { ApiResponse } from '../model/api.types'; +import type { + IssueCreateRequest, + IssueCreateResponse, +} from '../model/issue.create.types'; +import type { IssueListData } from '../model/issue.read.types'; +import type { IssueUpdateRequest } from '../model/issue.update.types'; +import type { IssueDetail } from '../model/issueDetail.read.types'; + +export async function fetchIssues( + q = '', + page?: number, + perPage?: number, +): Promise { + let url = q ? `/api/issues?q=${encodeURIComponent(q)}` : '/api/issues'; + if (page !== undefined) url += `&page=${page}`; + if (perPage !== undefined) url += `&perPage=${perPage}`; + + const res = await fetch(url, { + method: 'GET', + headers: getAuthHeaders(), + }); + + const json: ApiResponse = await res.json(); + if (!json.success) + throw new Error(json.error?.message ?? '이슈 목록 조회 실패'); + return json.data; +} + +export async function fetchIssueDetail(id: number): Promise { + const url = `/api/issues/${id}`; + const res = await fetch(url, { + method: 'GET', + headers: getAuthHeaders(), + }); + + const json: ApiResponse = await res.json(); + if (!json.success) + throw new Error(json.error?.message ?? '이슈 상세 조회 실패'); + return json.data; +} + +export async function createIssue( + payload: IssueCreateRequest, +): Promise { + const res = await fetch('/api/issues', { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify(payload), + }); + + const json: IssueCreateResponse = await res.json(); + if (!json.success) throw new Error(json.error?.message ?? '이슈 생성 실패'); + return json.data; +} + +const keyToUrlMap: Record = { + assigneeIds: 'assignees', + labelIds: 'labels', + milestoneId: 'milestone', + isOpen: 'status', + title: 'title', + body: 'body', // 필요하다면 추가 +}; + +// 이슈 수정 +export async function updateIssue( + id: number, + payload: IssueUpdateRequest, +): Promise { + const [key] = Object.keys(payload); + const urlKey = keyToUrlMap[key] ?? key; + const url = `/api/issues/${id}/${urlKey}`; + + const res = await fetch(url, { + method: 'PATCH', + headers: getAuthHeaders(), + body: JSON.stringify(payload), + }); + + if (res.status === 204) return; + throw new Error('이슈 수정 실패'); +} + +// 이슈 삭제 +export async function deleteIssue(id: number): Promise { + const url = `/api/issues/${id}`; + const res = await fetch(url, { + method: 'DELETE', + headers: getAuthHeaders(false), + }); + + if (res.status === 204) return; + throw new Error('이슈 삭제 실패'); +} diff --git a/frontend/src/entities/issue/hooks/useCreateIssue.ts b/frontend/src/entities/issue/hooks/useCreateIssue.ts new file mode 100644 index 000000000..bd1bfa223 --- /dev/null +++ b/frontend/src/entities/issue/hooks/useCreateIssue.ts @@ -0,0 +1,16 @@ +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { createIssue } from '../api/issueAPI'; +import type { IssueCreateRequest } from '../model/issue.create.types'; + +export function useCreateIssue(onSuccess?: (id: number) => void) { + return useMutation({ + mutationFn: createIssue, + onSuccess: (id) => { + onSuccess?.(id); + }, + onError: (error) => { + toast.error(error.message); + }, + }); +} diff --git a/frontend/src/entities/issue/hooks/useDeleteIssue.ts b/frontend/src/entities/issue/hooks/useDeleteIssue.ts new file mode 100644 index 000000000..37b1983cc --- /dev/null +++ b/frontend/src/entities/issue/hooks/useDeleteIssue.ts @@ -0,0 +1,15 @@ +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { deleteIssue } from '../api/issueAPI'; + +export function useDeleteIssue(onSuccess?: () => void) { + return useMutation({ + mutationFn: deleteIssue, + onSuccess: () => { + onSuccess?.(); + }, + onError: (error) => { + toast.error(error.message); + }, + }); +} diff --git a/frontend/src/entities/issue/hooks/useFetchIssueDetail.ts b/frontend/src/entities/issue/hooks/useFetchIssueDetail.ts new file mode 100644 index 000000000..b26fa5593 --- /dev/null +++ b/frontend/src/entities/issue/hooks/useFetchIssueDetail.ts @@ -0,0 +1,11 @@ +import { fetchIssueDetail } from '@/entities/issue/api/issueAPI'; +import { useQuery } from '@tanstack/react-query'; +import type { IssueDetail } from '../model/issueDetail.read.types'; + +export function useFetchIssueDetail(id: number) { + return useQuery({ + queryKey: ['issues', id], + queryFn: () => fetchIssueDetail(id), + staleTime: 1000 * 60, + }); +} diff --git a/frontend/src/entities/issue/hooks/useFetchIssueList.ts b/frontend/src/entities/issue/hooks/useFetchIssueList.ts new file mode 100644 index 000000000..aff15e17c --- /dev/null +++ b/frontend/src/entities/issue/hooks/useFetchIssueList.ts @@ -0,0 +1,10 @@ +import { fetchIssues } from '@/entities/issue/api/issueAPI'; +import type { IssueListData } from '@/entities/issue/model/issue.read.types'; +import { useQuery } from '@tanstack/react-query'; + +export function useFetchIssueList(q: string, page?: number, perPage?: number) { + return useQuery({ + queryKey: ['issues', q, page, perPage], + queryFn: () => fetchIssues(q, page, perPage), + }); +} diff --git a/frontend/src/entities/issue/hooks/useUpdateIssue.ts b/frontend/src/entities/issue/hooks/useUpdateIssue.ts new file mode 100644 index 000000000..521e3a48a --- /dev/null +++ b/frontend/src/entities/issue/hooks/useUpdateIssue.ts @@ -0,0 +1,36 @@ +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { updateIssue } from '../api/issueAPI'; +import type { IssueUpdateRequest } from '../model/issue.update.types'; + +const updateIssueMessageMap: Record< + keyof IssueUpdateRequest, + (payload: IssueUpdateRequest) => string +> = { + title: () => '제목을 수정했습니다.', + assigneeIds: () => '담당자를 수정했습니다.', + labelIds: () => '레이블을 수정했습니다.', + milestoneId: () => '마일스톤을 수정했습니다.', + isOpen: (payload) => + payload.isOpen ? '이슈를 열었습니다.' : '이슈를 닫았습니다.', +}; + +export function useUpdateIssue(onSuccess?: () => void) { + return useMutation({ + mutationFn: ({ id, payload }) => updateIssue(id, payload), + onSuccess: (_data, variables) => { + const { payload } = variables; + const key = Object.keys(payload)[0] as keyof IssueUpdateRequest; + + const getMessage = updateIssueMessageMap[key]; + if (getMessage) { + toast.success(getMessage(payload)); + } + + onSuccess?.(); + }, + onError: (error) => { + toast.error(error.message); + }, + }); +} diff --git a/frontend/src/entities/issue/issue.types.ts b/frontend/src/entities/issue/issue.types.ts deleted file mode 100644 index 209c6d472..000000000 --- a/frontend/src/entities/issue/issue.types.ts +++ /dev/null @@ -1,42 +0,0 @@ -export interface Label { - id: number; - name: string; - color: string; -} - -export interface Author { - id: number; - username: string; - imageUrl: string; -} - -export interface Milestone { - id: number; - title: string; -} - -export interface Issue { - id: number; - number?: number; - title: string; - labels: Label[]; - author: Author; - milestone: Milestone | null; - createdAt: string; - updatedAt: string | null; - commentsCount: number; - open: boolean; -} - -export interface IssueListData { - total: number; - page: number; - perPage: number; - issues: Issue[]; -} - -export interface ApiResponse { - success: boolean; - data: T; - error: E | null; -} diff --git a/frontend/src/entities/issue/issueAPI.ts b/frontend/src/entities/issue/issueAPI.ts deleted file mode 100644 index 60d47e3f1..000000000 --- a/frontend/src/entities/issue/issueAPI.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { getJSON } from '@/shared/api/client'; -import type { ApiResponse, IssueListData } from './issue.types'; - -export async function fetchIssues(): Promise { - const res = await getJSON>('/api/issues'); - return res.data; -} diff --git a/frontend/src/entities/issue/issueFixtures.ts b/frontend/src/entities/issue/issueFixtures.ts deleted file mode 100644 index 386843e87..000000000 --- a/frontend/src/entities/issue/issueFixtures.ts +++ /dev/null @@ -1,51 +0,0 @@ -// src/entities/issue/issueFixtures.ts -import type { ApiResponse, Issue, IssueListData } from './issue.types'; - -const issues: Issue[] = [ - { - id: 42, - number: 1234, - title: '버튼 클릭 시 모달이 열리지 않는 문제', - labels: [ - { id: 1, name: 'bug', color: '#d73a4a' }, - { id: 3, name: 'ui', color: '#1d76db' }, - ], - author: { - id: 7, - username: 'alice', - imageUrl: 'https://example.com/avatar/alice.png', - }, - milestone: { id: 2, title: 'v1.0 릴리즈' }, - createdAt: '2025-05-01T09:15:00Z', - updatedAt: '2025-05-10T10:11:00Z', - commentsCount: 4, - open: true, - }, - { - id: 43, - number: 1235, - title: '로그인 API 응답 속도 개선 요청', - labels: [{ id: 2, name: 'enhancement', color: '#a2eeef' }], - author: { - id: 9, - username: 'bob', - imageUrl: 'https://example.com/avatar/bob.png', - }, - milestone: null, - createdAt: '2025-04-28T14:03:00Z', - updatedAt: null, - commentsCount: 2, - open: false, - }, -]; - -export const mockIssueListResponse: ApiResponse = { - success: true, - data: { - total: issues.length, - page: 1, - perPage: issues.length, - issues, - }, - error: null, -}; diff --git a/frontend/src/entities/issue/model/api.types.ts b/frontend/src/entities/issue/model/api.types.ts new file mode 100644 index 000000000..f1049c1ee --- /dev/null +++ b/frontend/src/entities/issue/model/api.types.ts @@ -0,0 +1,8 @@ +export interface ApiResponse { + success: boolean; + data: T; + error: { + message: string; + code: number; + } | null; +} diff --git a/frontend/src/entities/issue/model/issue.create.types.ts b/frontend/src/entities/issue/model/issue.create.types.ts new file mode 100644 index 000000000..b3e04d7dd --- /dev/null +++ b/frontend/src/entities/issue/model/issue.create.types.ts @@ -0,0 +1,20 @@ +// 이슈 생성 요청 타입 (POST /issues) +export interface IssueCreateRequest { + title: string; + comment: { + content: string; + }; + assigneeIds: number[]; + labelIds: number[]; + milestoneId: number | null; +} + +// 이슈 생성 응답 타입 (POST /issues 응답) +export interface IssueCreateResponse { + success: boolean; + data: number; // 생성된 이슈의 id + error: { + message: string; + code: number; + } | null; +} diff --git a/frontend/src/entities/issue/model/issue.delete.types.ts b/frontend/src/entities/issue/model/issue.delete.types.ts new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/entities/issue/model/issue.read.types.ts b/frontend/src/entities/issue/model/issue.read.types.ts new file mode 100644 index 000000000..6ef87b5c6 --- /dev/null +++ b/frontend/src/entities/issue/model/issue.read.types.ts @@ -0,0 +1,51 @@ +import type { ApiResponse } from './api.types'; + +export interface Label { + id: number; + name: string; + description: string; + textColor: string; + backgroundColor: string; +} + +export interface Author { + id: number; + username: string; +} + +export interface Milestone { + id: number; + name: string; +} + +export interface Assignee { + id: number; + username: string; + imageUrl: string; +} + +export interface Issue { + id: number; + title: string; + isOpen: boolean; + labels: Label[]; + author: Author; + milestone: Milestone | null; + assignees: Assignee[]; + createdAt: string; // ISO date string + updatedAt: string; // ISO date string + commentsCount: number; +} + +// 전체 이슈 목록/페이지네이션 응답 +export interface IssueListData { + total: number; + page: number; + perPage: number; + q: string; + openCount: number; + closedCount: number; + issues: Issue[]; +} + +export type IssueDetailResponse = ApiResponse; diff --git a/frontend/src/entities/issue/model/issue.update.types.ts b/frontend/src/entities/issue/model/issue.update.types.ts new file mode 100644 index 000000000..13238a2a3 --- /dev/null +++ b/frontend/src/entities/issue/model/issue.update.types.ts @@ -0,0 +1,7 @@ +export interface IssueUpdateRequest { + title?: string; + assigneeIds?: number[]; + labelIds?: number[]; + milestoneId?: number | null; + isOpen?: boolean; +} diff --git a/frontend/src/entities/issue/model/issueDetail.read.types.ts b/frontend/src/entities/issue/model/issueDetail.read.types.ts new file mode 100644 index 000000000..68fa8726a --- /dev/null +++ b/frontend/src/entities/issue/model/issueDetail.read.types.ts @@ -0,0 +1,55 @@ +import type { ApiResponse } from './api.types'; + +// Label +export interface Label { + id: number; + name: string; + description: string; + textColor: string; + backgroundColor: string; +} + +// Author / Assignee +export interface User { + id: number; + username: string; + imageUrl: string; +} + +// Milestone (상세 정보 포함) +export interface MilestoneDetail { + id: number; + name: string; + description: string | null; + deadline: string | null; // yyyy-mm-dd + isOpen: boolean; + openIssueCount: number; + closedIssueCount: number; + progress: number; // 0~100 +} + +// Comment (실제 구조에 맞게 필요한 필드 추가) +export interface Comment { + id: number; + author: User; + content: string; + createdAt: string; + updatedAt: string; +} + +// IssueDetail +export interface IssueDetail { + id: number; + title: string; + isOpen: boolean; + labels: Label[]; + author: User; + assignees: User[]; + milestone: MilestoneDetail | null; + createdAt: string; + updatedAt: string; + comments: Comment[]; +} + +// 상세 조회 응답 타입 +export type IssueDetailResponse = ApiResponse; diff --git a/frontend/src/entities/issueLabel/api/labelFilterApi.ts b/frontend/src/entities/issueLabel/api/labelFilterApi.ts new file mode 100644 index 000000000..d6e98e9b6 --- /dev/null +++ b/frontend/src/entities/issueLabel/api/labelFilterApi.ts @@ -0,0 +1,20 @@ +import { getAuthHeaders } from '@/shared/lib/getAuthHeaders'; +import type { IssueLabelOptionsResponseDto } from '../model/labelFilter.types'; + +/** API에서 반환되는 data 필드 타입 */ +export type IssueLabelOptionsData = IssueLabelOptionsResponseDto['data']; + +/** + * 이슈 필터 드롭다운에서 쓸 레이블 옵션 목록을 가져옵니다. + */ +export async function fetchIssueLabelOptions(): Promise { + const url = '/api/issues/labels?limit=2000'; + const res = await fetch(url, { + method: 'GET', + headers: getAuthHeaders(), + }); + + const json: IssueLabelOptionsResponseDto = await res.json(); + if (!json.success) throw new Error(json.error ?? '라벨 목록 조회 실패'); + return json.data; +} diff --git a/frontend/src/entities/issueLabel/hooks/useIssueLabelOptions.ts b/frontend/src/entities/issueLabel/hooks/useIssueLabelOptions.ts new file mode 100644 index 000000000..b1cae09e9 --- /dev/null +++ b/frontend/src/entities/issueLabel/hooks/useIssueLabelOptions.ts @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchIssueLabelOptions } from '../api/labelFilterApi'; +import type { IssueLabelOptionsData } from '../api/labelFilterApi'; + +/** + * 이슈 필터 드롭다운에서 쓸 레이블 옵션 목록을 가져오는 커스텀 훅 + */ +export function useIssueLabelOptions() { + return useQuery({ + queryKey: ['issueLabelOptions'], + queryFn: fetchIssueLabelOptions, + staleTime: 1000 * 60 * 10, // 10분 동안 캐시에서만 제공 + }); +} diff --git a/frontend/src/entities/issueLabel/model/labelFilter.types.ts b/frontend/src/entities/issueLabel/model/labelFilter.types.ts new file mode 100644 index 000000000..9343588e0 --- /dev/null +++ b/frontend/src/entities/issueLabel/model/labelFilter.types.ts @@ -0,0 +1,27 @@ +// API에서 내려주는 레이블 option raw 타입 +export interface IssueLabelOptionApiDto { + id: number; + name: string; + backgroundColor: string; + textColor: string; +} + +// API 전체 응답 타입 +export interface IssueLabelOptionsResponseDto { + success: boolean; + data: { + total: number; + page: number; + perPage: number; + labels: IssueLabelOptionApiDto[]; + }; + error: string | null; +} + +// FE에서 사용하는 option 타입 (단순 alias) +export interface IssueLabelOption { + id: number; + name: string; + backgroundColor: string; + textColor: string; +} diff --git a/frontend/src/entities/issueLabel/model/parseToDropdownOption.ts b/frontend/src/entities/issueLabel/model/parseToDropdownOption.ts new file mode 100644 index 000000000..78e1cd3d0 --- /dev/null +++ b/frontend/src/entities/issueLabel/model/parseToDropdownOption.ts @@ -0,0 +1,14 @@ +import type { DropdownOption } from '@/shared/ui/Dropdown/DropdownOption'; +import type { IssueLabelOptionApiDto } from './labelFilter.types'; + +// raw → FE에서 쓸 DropdownOption으로 변환 +export function parseLabelOptionToDropdownOption( + label: IssueLabelOptionApiDto, +): DropdownOption { + return { + id: label.id, + display: label.name, + color: label.backgroundColor, + // 필요하면 textColor도 추가로 넣을 수 있음 + }; +} diff --git a/frontend/src/entities/issueMilestone/api/milestoneFilterApi.ts b/frontend/src/entities/issueMilestone/api/milestoneFilterApi.ts new file mode 100644 index 000000000..455136633 --- /dev/null +++ b/frontend/src/entities/issueMilestone/api/milestoneFilterApi.ts @@ -0,0 +1,21 @@ +import { getAuthHeaders } from '@/shared/lib/getAuthHeaders'; +import type { IssueMilestoneOptionsResponseDto } from '../model/milestoneFilter.types'; + +/** API에서 반환되는 data 필드 타입 */ +export type IssueMilestoneOptionsData = + IssueMilestoneOptionsResponseDto['data']; + +/** + * 이슈 필터용 마일스톤 옵션 목록을 가져옵니다. + */ +export async function fetchIssueMilestoneOptions(): Promise { + const url = '/api/issues/milestones?limit=2000'; + const res = await fetch(url, { + method: 'GET', + headers: getAuthHeaders(), + }); + + const json: IssueMilestoneOptionsResponseDto = await res.json(); + if (!json.success) throw new Error(json.error ?? '마일스톤 목록 조회 실패'); + return json.data; +} diff --git a/frontend/src/entities/issueMilestone/hooks/useIssueMilestoneOptions.ts b/frontend/src/entities/issueMilestone/hooks/useIssueMilestoneOptions.ts new file mode 100644 index 000000000..a6343d84a --- /dev/null +++ b/frontend/src/entities/issueMilestone/hooks/useIssueMilestoneOptions.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchIssueMilestoneOptions } from '../api/milestoneFilterApi'; +import type { IssueMilestoneOptionsData } from '../api/milestoneFilterApi'; + +/** + * 이슈 필터 드롭다운에서 쓸 마일스톤 옵션 목록을 가져오는 커스텀 훅 + */ +export function useIssueMilestoneOptions() { + return useQuery({ + queryKey: ['issueMilestoneOptions'], + queryFn: fetchIssueMilestoneOptions, + }); +} diff --git a/frontend/src/entities/issueMilestone/model/milestoneFilter.types.ts b/frontend/src/entities/issueMilestone/model/milestoneFilter.types.ts new file mode 100644 index 000000000..64977175c --- /dev/null +++ b/frontend/src/entities/issueMilestone/model/milestoneFilter.types.ts @@ -0,0 +1,20 @@ +export interface IssueMilestoneOptionApiDto { + id: number; + name: string; +} + +export interface IssueMilestoneOptionsResponseDto { + success: boolean; + data: { + total: number; + page: number; + perPage: number; + milestones: IssueMilestoneOptionApiDto[]; + }; + error: string | null; +} + +export interface IssueMilestoneOption { + id: number; + name: string; +} diff --git a/frontend/src/entities/issueMilestone/model/parseToDropdownOption.ts b/frontend/src/entities/issueMilestone/model/parseToDropdownOption.ts new file mode 100644 index 000000000..86cdf8d6f --- /dev/null +++ b/frontend/src/entities/issueMilestone/model/parseToDropdownOption.ts @@ -0,0 +1,11 @@ +import type { DropdownOption } from '@/shared/ui/Dropdown/DropdownOption'; +import type { IssueMilestoneOptionApiDto } from './milestoneFilter.types'; + +export function parseMilestoneOptionToDropdownOption( + milestone: IssueMilestoneOptionApiDto, +): DropdownOption { + return { + id: milestone.id, + display: milestone.name, + }; +} diff --git a/frontend/src/entities/label/api/labelApi.ts b/frontend/src/entities/label/api/labelApi.ts new file mode 100644 index 000000000..72c4acd7d --- /dev/null +++ b/frontend/src/entities/label/api/labelApi.ts @@ -0,0 +1,66 @@ +import { getAuthHeaders } from '@/shared/lib/getAuthHeaders'; +import type { LabelCountResponse } from '../model/label.types'; +import type { LabelUpdatePayload } from '../model/label.types'; +import type { + LabelCreatePayload, + LabelCreateResponseDto, + LabelListApiResponseDto, + LabelListData, +} from '../model/label.types'; + +export async function fetchLabels(): Promise { + const url = '/api/labels'; + const res = await fetch(url, { + method: 'GET', + headers: getAuthHeaders(), + }); + + const json: LabelListApiResponseDto = await res.json(); + // if (!json.success) + // throw new Error(json.error?.message ?? '라벨 조회 실패'); + return json.data; +} + +export async function createLabel( + payload: LabelCreatePayload, +): Promise { + const res = await fetch('/api/labels', { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify(payload), + }); + + return await res.json(); +} + +export async function updateLabel(payload: LabelUpdatePayload): Promise { + const { id, ...body } = payload; + const res = await fetch(`/api/labels/${id}`, { + method: 'PUT', + headers: getAuthHeaders(), + body: JSON.stringify(body), + }); + if (res.status === 204) return; + throw new Error('라벨 수정 실패'); +} + +export async function deleteLabel(id: number): Promise { + const res = await fetch(`/api/labels/${id}`, { + method: 'DELETE', + headers: getAuthHeaders(false), + }); + if (res.status === 204) return; + throw new Error('이슈 수정 실패'); +} + +export async function fetchLabelCount(): Promise { + const res = await fetch('/api/labels/count', { + method: 'GET', + headers: getAuthHeaders(), + }); + if (!res.ok) throw new Error('서버 연결에 실패했습니다.'); + const json: LabelCountResponse = await res.json(); + if (!json.success) + throw new Error(json.error?.message || '카운트 가져오기 실패'); + return json.data; +} diff --git a/frontend/src/entities/label/hooks/useCreateLabel.ts b/frontend/src/entities/label/hooks/useCreateLabel.ts new file mode 100644 index 000000000..9ce2e510c --- /dev/null +++ b/frontend/src/entities/label/hooks/useCreateLabel.ts @@ -0,0 +1,24 @@ +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { createLabel } from '../api/labelApi'; +import type { + LabelCreatePayload, + LabelCreateResponseDto, +} from '../model/label.types'; + +export function useCreateLabel(onSuccess?: () => void) { + return useMutation< + LabelCreateResponseDto, + Error, + { payload: LabelCreatePayload } + >({ + mutationFn: ({ payload }: { payload: LabelCreatePayload }) => + createLabel(payload), + onSuccess: () => { + onSuccess?.(); + }, + onError: () => { + toast.error('라벨 생성에 실패했습니다.'); + }, + }); +} diff --git a/frontend/src/entities/label/hooks/useDeleteLabel.ts b/frontend/src/entities/label/hooks/useDeleteLabel.ts new file mode 100644 index 000000000..126b6b573 --- /dev/null +++ b/frontend/src/entities/label/hooks/useDeleteLabel.ts @@ -0,0 +1,16 @@ +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { deleteLabel } from '../api/labelApi'; + +export function useDeleteLabel(onSuccess?: () => void) { + return useMutation({ + mutationFn: deleteLabel, + onSuccess: () => { + toast.success('레이블이 삭제되었습니다.'); + onSuccess?.(); + }, + onError: (err) => { + toast.error(err.message || '레이블 삭제에 실패했습니다.'); + }, + }); +} diff --git a/frontend/src/entities/label/hooks/useFetchLabelCount.ts b/frontend/src/entities/label/hooks/useFetchLabelCount.ts new file mode 100644 index 000000000..c0969b531 --- /dev/null +++ b/frontend/src/entities/label/hooks/useFetchLabelCount.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchLabelCount } from '../api/labelApi'; + +export function useFetchLabelCount() { + return useQuery({ + queryKey: ['labelCount'], + queryFn: fetchLabelCount, + staleTime: 1000 * 60, + refetchInterval: 1000 * 60, + }); +} diff --git a/frontend/src/entities/label/hooks/useFetchLabelList.ts b/frontend/src/entities/label/hooks/useFetchLabelList.ts new file mode 100644 index 000000000..a46ef4642 --- /dev/null +++ b/frontend/src/entities/label/hooks/useFetchLabelList.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchLabels } from '../api/labelApi'; +import type { LabelListData } from '../model/label.types'; + +export function useFetchLabelList() { + return useQuery({ + queryKey: ['labels'], + queryFn: fetchLabels, + staleTime: 1000 * 60, + refetchInterval: 1000 * 60, + }); +} diff --git a/frontend/src/entities/label/hooks/useUpdateLabel.ts b/frontend/src/entities/label/hooks/useUpdateLabel.ts new file mode 100644 index 000000000..bdc0067e6 --- /dev/null +++ b/frontend/src/entities/label/hooks/useUpdateLabel.ts @@ -0,0 +1,16 @@ +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { updateLabel } from '../api/labelApi'; +import type { LabelUpdatePayload } from '../model/label.types'; + +export function useUpdateLabel(onSuccess?: () => void) { + return useMutation({ + mutationFn: ({ payload }) => updateLabel(payload), + onSuccess: () => { + onSuccess?.(); + }, + onError: (error) => { + toast.error(error.message || '라벨 수정에 실패했습니다.'); + }, + }); +} diff --git a/frontend/src/entities/label/model/label.types.ts b/frontend/src/entities/label/model/label.types.ts new file mode 100644 index 000000000..cd5951301 --- /dev/null +++ b/frontend/src/entities/label/model/label.types.ts @@ -0,0 +1,69 @@ +// API에서 내려오는 단일 Label +export interface LabelApiEntity { + id: number; + name: string; + description: string; + backgroundColor: string; + textColor: string; +} + +// API 응답의 data 부분만 타입으로 분리 +export interface LabelListData { + total: number; + page: number; + perPage: number; + labels: LabelApiEntity[]; +} + +// API 응답 - Label 리스트 (페이징 포함) +export interface LabelListApiResponseDto { + success: boolean; + data: LabelListData; + error: string | null; +} + +// API 응답 - 단일 Label +export interface LabelApiResponseDto { + success: boolean; + data: LabelApiEntity; + error: string | null; +} + +// 생성 입력값 (id 제외) +export interface LabelCreatePayload { + name: string; + description: string; + backgroundColor: string; + textColor: string; +} + +// 생성 응답값 (id) +export interface LabelCreateResponseDto { + success: boolean; + data: number; + error: string | null; +} + +// 수정 입력값 - 메서드는 put으로 전체 대체용 +export interface LabelUpdatePayload { + id: number; + name: string; + description: string; + backgroundColor: string; + textColor: string; +} + +// 프론트엔드 도메인 모델 (설명, description은 optional로 처리) +export interface Label { + id: number; + name: string; + description: string; + backgroundColor: string; + textColor: string; +} + +export interface LabelCountResponse { + success: boolean; + data: number; + error: { message: string; code: number } | null; +} diff --git a/frontend/src/entities/milestone/api/milestoneApi.ts b/frontend/src/entities/milestone/api/milestoneApi.ts new file mode 100644 index 000000000..b05553308 --- /dev/null +++ b/frontend/src/entities/milestone/api/milestoneApi.ts @@ -0,0 +1,93 @@ +import type { ApiResponse } from '@/shared/api/types'; +import { getAuthHeaders } from '@/shared/lib/getAuthHeaders'; +import type { + MilestoneCountResponse, + MilestoneCreatePayload, + MilestoneCreateResponseDto, + MilestoneDetail, + MilestoneListData, + MilestoneUpdatePayload, +} from '../model/milestone.types'; + +// 마일스톤 전체 리스트 조회 +export async function fetchMilestones(): Promise { + const url = '/api/milestones'; + const res = await fetch(url, { + method: 'GET', + headers: getAuthHeaders(), + }); + + const json: ApiResponse = await res.json(); + if (!json.success) throw new Error(json.error ?? '마일스톤 목록 조회 실패'); + return json.data; +} + +// 마일스톤 상세(강제 예시) - fetch + json 패턴 (에러 핸들링 직관적) +export interface MilestoneApiResponse { + success: boolean; + data: T; + error?: { message: string; code: number }; +} + +export async function fetchMilestoneDetail( + id: number, +): Promise { + const res = await fetch(`/api/milestones/${id}`, { + method: 'GET', + headers: getAuthHeaders(), + }); + if (!res.ok) throw new Error('서버 연결에 실패했습니다.'); + const json: MilestoneApiResponse = await res.json(); + if (!json.success) + throw new Error(json.error?.message || '마일스톤 정보 가져오기 실패'); + return json.data; +} + +// 마일스톤 생성 +export async function createMilestone( + payload: MilestoneCreatePayload, +): Promise { + const res = await fetch('/api/milestones', { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify(payload), + }); + return await res.json(); +} + +// 마일스톤 수정 (PUT) +export async function updateMilestone( + payload: MilestoneUpdatePayload, +): Promise { + const { id, ...body } = payload; + await fetch(`/api/milestones/${id}`, { + method: 'PUT', + headers: getAuthHeaders(), + body: JSON.stringify(body), + }); +} + +// 마일스톤 삭제 +export async function deleteMilestone(id: number): Promise { + const res = await fetch(`/api/milestones/${id}`, { + method: 'DELETE', + headers: getAuthHeaders(false), + }); + if (!res.ok) { + const error = await res.json().catch(() => ({})); + throw new Error(error?.error ?? '마일스톤 삭제에 실패했습니다'); + } +} + +// 마일스톤 카운트 +export async function fetchMilestoneCount(): Promise { + const res = await fetch('/api/milestones/count', { + method: 'GET', + headers: getAuthHeaders(), + }); + if (!res.ok) throw new Error('서버 연결에 실패했습니다.'); + const json: MilestoneCountResponse = await res.json(); + if (!json.success) + throw new Error(json.error?.message || '카운트 가져오기 실패'); + return json.data; +} diff --git a/frontend/src/entities/milestone/hooks/useCreateMilestone.ts b/frontend/src/entities/milestone/hooks/useCreateMilestone.ts new file mode 100644 index 000000000..b9b976e45 --- /dev/null +++ b/frontend/src/entities/milestone/hooks/useCreateMilestone.ts @@ -0,0 +1,23 @@ +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { createMilestone } from '../api/milestoneApi'; +import type { + MilestoneCreatePayload, + MilestoneCreateResponseDto, +} from '../model/milestone.types'; + +export function useCreateMilestone(onSuccess?: () => void) { + return useMutation< + MilestoneCreateResponseDto, + Error, + { payload: MilestoneCreatePayload } + >({ + mutationFn: ({ payload }) => createMilestone(payload), + onSuccess: () => { + onSuccess?.(); + }, + onError: () => { + toast.error('마일스톤 생성에 실패했습니다.'); + }, + }); +} diff --git a/frontend/src/entities/milestone/hooks/useDeleteMilestone.ts b/frontend/src/entities/milestone/hooks/useDeleteMilestone.ts new file mode 100644 index 000000000..a9a121923 --- /dev/null +++ b/frontend/src/entities/milestone/hooks/useDeleteMilestone.ts @@ -0,0 +1,16 @@ +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { deleteMilestone } from '../api/milestoneApi'; + +export function useDeleteMilestone(onSuccess?: () => void) { + return useMutation({ + mutationFn: deleteMilestone, + onSuccess: () => { + toast.success('마일스톤이 삭제되었습니다.'); + onSuccess?.(); + }, + onError: (err) => { + toast.error(err.message || '마일스톤 삭제에 실패했습니다.'); + }, + }); +} diff --git a/frontend/src/entities/milestone/hooks/useFetchMilestoneCount.ts b/frontend/src/entities/milestone/hooks/useFetchMilestoneCount.ts new file mode 100644 index 000000000..f3b8ce501 --- /dev/null +++ b/frontend/src/entities/milestone/hooks/useFetchMilestoneCount.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchMilestoneCount } from '../api/milestoneApi'; + +export function useFetchMilestoneCount() { + return useQuery({ + queryKey: ['milestoneCount'], + queryFn: fetchMilestoneCount, + staleTime: 1000 * 60, + refetchInterval: 1000 * 60, + }); +} diff --git a/frontend/src/entities/milestone/hooks/useFetchMilestoneDetail.ts b/frontend/src/entities/milestone/hooks/useFetchMilestoneDetail.ts new file mode 100644 index 000000000..6b7cba28d --- /dev/null +++ b/frontend/src/entities/milestone/hooks/useFetchMilestoneDetail.ts @@ -0,0 +1,10 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchMilestoneDetail } from '../api/milestoneApi'; +import type { MilestoneDetail } from '../model/milestone.types'; + +export function useFetchMilestoneDetail(id: number) { + return useQuery({ + queryKey: ['milestoneDetail', id], + queryFn: () => fetchMilestoneDetail(id), + }); +} diff --git a/frontend/src/entities/milestone/hooks/useFetchMilestoneList.ts b/frontend/src/entities/milestone/hooks/useFetchMilestoneList.ts new file mode 100644 index 000000000..9ec62f4d1 --- /dev/null +++ b/frontend/src/entities/milestone/hooks/useFetchMilestoneList.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchMilestones } from '../api/milestoneApi'; +import type { MilestoneListData } from '../model/milestone.types'; + +export function useFetchMilestoneList() { + return useQuery({ + queryKey: ['milestones'], + queryFn: fetchMilestones, + staleTime: 1000 * 60, + refetchInterval: 1000 * 60, + }); +} diff --git a/frontend/src/entities/milestone/hooks/useUpdateMilestone.ts b/frontend/src/entities/milestone/hooks/useUpdateMilestone.ts new file mode 100644 index 000000000..732a31768 --- /dev/null +++ b/frontend/src/entities/milestone/hooks/useUpdateMilestone.ts @@ -0,0 +1,16 @@ +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { updateMilestone } from '../api/milestoneApi'; +import type { MilestoneUpdatePayload } from '../model/milestone.types'; + +export function useUpdateMilestone(onSuccess?: () => void) { + return useMutation({ + mutationFn: ({ payload }) => updateMilestone(payload), + onSuccess: () => { + onSuccess?.(); + }, + onError: (error) => { + toast.error(error.message || '마일스톤 수정에 실패했습니다.'); + }, + }); +} diff --git a/frontend/src/entities/milestone/model/milestone.types.ts b/frontend/src/entities/milestone/model/milestone.types.ts new file mode 100644 index 000000000..9ec744c40 --- /dev/null +++ b/frontend/src/entities/milestone/model/milestone.types.ts @@ -0,0 +1,79 @@ +// 마일스톤 상세(상세 조회에서 사용) +export interface MilestoneDetail { + id: number; + name: string; + description: string | null; + deadline: string | null; // "YYYY-MM-DD" + isOpen: boolean; + openIssueCount: number; + closedIssueCount: number; + progress: number; +} + +// 리스트/엔티티 공통 (description, deadline은 string | null로 맞추는 것이 일관) +export interface MilestoneApiEntity { + id: number; + name: string; + description: string | null; + deadline: string | null; + isOpen: boolean; + openIssueCount: number; + closedIssueCount: number; + progress: number; +} + +// 리스트 데이터 +export interface MilestoneListData { + total: number; + page: number; + perPage: number; + milestones: MilestoneApiEntity[]; + openCount: number; + closedCount: number; +} + +// API 응답 - 마일스톤 리스트 +export interface MilestoneListApiResponseDto { + success: boolean; + data: MilestoneListData; + error: { message: string; code: number } | null; +} + +// API 응답 - 단일 마일스톤 (detail도 동일하게 받음) +export interface MilestoneApiResponseDto { + success: boolean; + data: MilestoneApiEntity; + error: { message: string; code: number } | null; +} + +// 생성 입력값 (id 제외) +export interface MilestoneCreatePayload { + name: string; + description?: string | null; + deadline?: string | null; +} + +// 생성 응답값 (id) +export interface MilestoneCreateResponseDto { + success: boolean; + data: number; + error: { message: string; code: number } | null; +} + +// 수정 입력값 - 전체 대체(put) +export interface MilestoneUpdatePayload { + id: number; + name: string; + description?: string | null; + deadline?: string | null; +} + +// 도메인 모델 (프론트에서 사용할 때) +export interface Milestone extends MilestoneApiEntity {} + +// 카운트 응답 +export interface MilestoneCountResponse { + success: boolean; + data: number; + error: { message: string; code: number } | null; +} diff --git a/frontend/src/entities/milestone/ui/MilestoneDetailCard.tsx b/frontend/src/entities/milestone/ui/MilestoneDetailCard.tsx new file mode 100644 index 000000000..77b800119 --- /dev/null +++ b/frontend/src/entities/milestone/ui/MilestoneDetailCard.tsx @@ -0,0 +1,79 @@ +import MilestoneIcon from '@/assets/milestone.svg?react'; +import { useFetchMilestoneDetail } from '../hooks/useFetchMilestoneDetail'; + +interface Props { + id: number; +} + +export function MilestoneDetailCard({ id }: Props) { + const { data } = useFetchMilestoneDetail(id); + + if (!data) return; + + const formattedDeadline = data.deadline + ? data.deadline.replace(/-/g, '. ') + : ''; + + return ( +
+
+ + + {data.name} + + {!data.isOpen && ( + + 종료 + + )} +
+ {data.deadline && ( +
+ 마감일 + {formattedDeadline} +
+ )} + {data.description && ( +
+ {data.description} +
+ )} + + {/* 열린/닫힌/진행률 가로로 정렬 */} +
+
+ + {data.openIssueCount} + + + 열린 이슈 + +
+
+ + {data.closedIssueCount} + + + 닫힌 이슈 + +
+
+ + {data.progress}% + + + 진행률 + +
+
+ + {/* 프로그레스바: 전체 너비, 맨 아래 */} +
+
+
+
+ ); +} diff --git a/frontend/src/entities/user/api/authorApi.ts b/frontend/src/entities/user/api/authorApi.ts new file mode 100644 index 000000000..ad6526826 --- /dev/null +++ b/frontend/src/entities/user/api/authorApi.ts @@ -0,0 +1,20 @@ +import { getAuthHeaders } from '@/shared/lib/getAuthHeaders'; +import type { AuthorsResponseDto } from '../model/author.types'; + +/** API에서 반환되는 data 필드 타입 */ +export type AuthorListData = AuthorsResponseDto['data']; + +/** + * 전체 작성자(Authors) 목록을 가져옵니다. + */ +export async function fetchAuthors(): Promise { + const url = '/api/issues/authors?limit=2000'; + const res = await fetch(url, { + method: 'GET', + headers: getAuthHeaders(), + }); + + const json: AuthorsResponseDto = await res.json(); + if (!json.success) throw new Error(json.error ?? '작성자 목록 조회 실패'); + return json.data; +} diff --git a/frontend/src/entities/user/api/userApi.ts b/frontend/src/entities/user/api/userApi.ts new file mode 100644 index 000000000..a8b29bd01 --- /dev/null +++ b/frontend/src/entities/user/api/userApi.ts @@ -0,0 +1,21 @@ +import { getAuthHeaders } from '@/shared/lib/getAuthHeaders'; +import type { UsersResponseDto } from '../model/user.types'; + +/** API에서 반환되는 data 필드 타입 */ +export type UserListData = UsersResponseDto['data']; + +/** + * 전체 유저 목록을 가져옵니다. + * - page, perPage 같은 파라미터가 필요하다면 URL에 쿼리스트링을 추가해 주세요. + */ +export async function fetchUsers(): Promise { + const url = '/api/users?limit=2000'; + const res = await fetch(url, { + method: 'GET', + headers: getAuthHeaders(), + }); + + const json: UsersResponseDto = await res.json(); + if (!json.success) throw new Error(json.error ?? '담당자 목록 조회 실패'); + return json.data; +} diff --git a/frontend/src/entities/user/hooks/useAuthorList.ts b/frontend/src/entities/user/hooks/useAuthorList.ts new file mode 100644 index 000000000..8ca22024c --- /dev/null +++ b/frontend/src/entities/user/hooks/useAuthorList.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchAuthors } from '../api/authorApi'; +import type { AuthorListData } from '../api/authorApi'; + +export function useAuthorList() { + return useQuery({ + queryKey: ['authors'], + queryFn: fetchAuthors, + // 필요 시 staleTime, enabled 등 옵션 추가 가능 + }); +} diff --git a/frontend/src/entities/user/hooks/useUserList.ts b/frontend/src/entities/user/hooks/useUserList.ts new file mode 100644 index 000000000..269e5a36e --- /dev/null +++ b/frontend/src/entities/user/hooks/useUserList.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchUsers } from '../api/userApi'; +import type { UserListData } from '../api/userApi'; + +export function useUserList() { + return useQuery({ + queryKey: ['users'], + queryFn: fetchUsers, + // staleTime, retry, enabled 등 옵션 필요에 따라 추가 + }); +} diff --git a/frontend/src/entities/user/model/author.types.ts b/frontend/src/entities/user/model/author.types.ts new file mode 100644 index 000000000..0b1cf0ccd --- /dev/null +++ b/frontend/src/entities/user/model/author.types.ts @@ -0,0 +1,25 @@ +// API 레이어가 반환하는 raw 타입 +export interface AuthorApiDto { + id: number; + username: string; + imageUrl: string; +} + +// API wrapper 전체 응답 +export interface AuthorsResponseDto { + success: boolean; + data: { + total: number; + page: number; + perPage: number; + users: AuthorApiDto[]; + }; + error: string | null; +} + +// 프론트엔드에서 사용할 Author 타입 +export interface Author { + id: number; + username: string; + imageUrl: string; +} diff --git a/frontend/src/entities/user/model/parseToDropdownOption.ts b/frontend/src/entities/user/model/parseToDropdownOption.ts new file mode 100644 index 000000000..203615b41 --- /dev/null +++ b/frontend/src/entities/user/model/parseToDropdownOption.ts @@ -0,0 +1,19 @@ +import type { DropdownOption } from '@/shared/ui/Dropdown/DropdownOption'; +import type { Author } from './author.types'; +import type { User } from './user.types'; + +export function parseUserToDropdownOption(user: User): DropdownOption { + return { + id: user.id, + display: user.username, + imageUrl: user.imageUrl, + }; +} + +export function parseAuthorToDropdownOption(author: Author): DropdownOption { + return { + id: author.id, + display: author.username, + imageUrl: author.imageUrl, + }; +} diff --git a/frontend/src/entities/user/user.types.ts b/frontend/src/entities/user/model/user.types.ts similarity index 89% rename from frontend/src/entities/user/user.types.ts rename to frontend/src/entities/user/model/user.types.ts index 1856e6862..b80f30917 100644 --- a/frontend/src/entities/user/user.types.ts +++ b/frontend/src/entities/user/model/user.types.ts @@ -21,5 +21,5 @@ export interface UsersResponseDto { export interface User { id: number; username: string; - avatarUrl: string; // DTO의 imageUrl을 매핑 + imageUrl: string; } diff --git a/frontend/src/entities/user/useUserList.ts b/frontend/src/entities/user/useUserList.ts deleted file mode 100644 index 16334e9c1..000000000 --- a/frontend/src/entities/user/useUserList.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useEffect, useState } from 'react'; -// src/entities/user/hooks/useUserList.ts -import type { UserListData } from './userApi'; -import { fetchUsers } from './userApi'; - -export function useUserList() { - const [data, setData] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - let mounted = true; - - async function load() { - setIsLoading(true); - try { - const result = await fetchUsers(); - if (mounted) { - setData(result); - } - } catch (err: unknown) { - if (mounted) { - setError(err as Error); - } - } finally { - if (mounted) { - setIsLoading(false); - } - } - } - - load(); - - return () => { - mounted = false; - }; - }, []); - - return { data, isLoading, error }; -} diff --git a/frontend/src/entities/user/userApi.ts b/frontend/src/entities/user/userApi.ts deleted file mode 100644 index 213d89ec8..000000000 --- a/frontend/src/entities/user/userApi.ts +++ /dev/null @@ -1,24 +0,0 @@ -// src/entities/user/api/userApi.ts -import { getJSON } from '@/shared/api/client'; -import type { ApiResponse } from '@/shared/api/types'; -import type { UserApiDto, UsersResponseDto } from './user.types'; - -/** API에서 반환되는 data 필드 타입 */ -export type UserListData = UsersResponseDto['data']; - -/** - * 전체 유저 목록을 가져옵니다. - * - page, perPage 같은 파라미터가 필요하다면 URL에 쿼리스트링을 추가해 주세요. - */ -export async function fetchUsers(): Promise { - const res = await getJSON>('/api/users'); - return res.data; -} - -/** - * 단일 유저를 ID로 조회합니다. - */ -export async function fetchUserById(id: number): Promise { - const res = await getJSON>(`/api/users/${id}`); - return res.data; -} diff --git a/frontend/src/entities/user/userFixtures.ts b/frontend/src/entities/user/userFixtures.ts deleted file mode 100644 index b3329e880..000000000 --- a/frontend/src/entities/user/userFixtures.ts +++ /dev/null @@ -1,30 +0,0 @@ -// src/entities/user/userFixtures.ts -import type { ApiResponse } from '@/shared/api/types'; -import type { UserApiDto } from './user.types'; -import type { UserListData } from './userApi'; - -// Mock user data -const users: UserApiDto[] = [ - { - id: 1, - username: 'alice', - imageUrl: 'https://example.com/alice.png', - }, - { - id: 2, - username: 'bob', - imageUrl: 'https://example.com/bob.png', - }, -]; - -// Mock API response for user list -export const mockUserListResponse: ApiResponse = { - success: true, - data: { - total: users.length, - page: 0, - perPage: users.length, - users: users, - }, - error: null, -}; diff --git a/frontend/src/features/auth/api/authApi.ts b/frontend/src/features/auth/api/authApi.ts new file mode 100644 index 000000000..b7be57a95 --- /dev/null +++ b/frontend/src/features/auth/api/authApi.ts @@ -0,0 +1,38 @@ +import type { + LoginRequest, + LoginResponse, + RegisterRequest, + RegisterResponse, +} from '../model/auth.types'; + +export async function register( + request: RegisterRequest, +): Promise { + const res = await fetch('/api/auth/signup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }); + + const result: RegisterResponse = await res.json(); + if (!result.success) { + throw new Error(result.error.message); + } + return result.data; +} + +export async function login( + request: LoginRequest, +): Promise { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }); + + const result: LoginResponse = await res.json(); + if (!result.success) { + throw new Error(result.error.message); + } + return result.data; +} diff --git a/frontend/src/features/auth/hooks/useLogin.ts b/frontend/src/features/auth/hooks/useLogin.ts new file mode 100644 index 000000000..439025475 --- /dev/null +++ b/frontend/src/features/auth/hooks/useLogin.ts @@ -0,0 +1,29 @@ +import { useMutation } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; +import { login } from '../api/authApi'; +import type { LoginRequest, LoginResponse } from '../model/auth.types'; + +export function useLogin(onSuccess?: (data: LoginResponse['data']) => void) { + const navigate = useNavigate(); + const { mutate, isPending, isSuccess, isError, data, error, reset } = + useMutation({ + mutationFn: (payload: LoginRequest) => login(payload), + onSuccess: (data) => { + if (data?.accessToken) { + localStorage.setItem('token', data.accessToken); + navigate('/'); + } + onSuccess?.(data); + }, + }); + + return { + mutate, + isPending, + isSuccess, + isError, + data, + error, + reset, + }; +} diff --git a/frontend/src/features/auth/hooks/useRegister.ts b/frontend/src/features/auth/hooks/useRegister.ts new file mode 100644 index 000000000..2da734424 --- /dev/null +++ b/frontend/src/features/auth/hooks/useRegister.ts @@ -0,0 +1,29 @@ +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { register } from '../api/authApi'; +import type { RegisterRequest, RegisterResponse } from '../model/auth.types'; + +export function useRegister( + onSuccess?: (data: RegisterResponse['data']) => void, +) { + const { mutate, isPending, isSuccess, isError, data, error, reset } = + useMutation({ + mutationFn: (payload: RegisterRequest) => register(payload), + onSuccess, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : '회원가입에 실패했습니다.', + ); + }, + }); + + return { + mutate, + isPending, + isSuccess, + isError, + data, + error, + reset, + }; +} diff --git a/frontend/src/features/auth/model/auth.types.ts b/frontend/src/features/auth/model/auth.types.ts new file mode 100644 index 000000000..f6b07ee76 --- /dev/null +++ b/frontend/src/features/auth/model/auth.types.ts @@ -0,0 +1,35 @@ +// 회원가입 요청 +export interface RegisterRequest { + username: string; + email: string; + imageUrl: string; + password: string; +} + +// 회원가입 응답 +export interface RegisterResponse { + success: boolean; + data: string | null; + error: { + message: string; + code: number; + }; +} + +// 로그인 요청 +export interface LoginRequest { + email: string; + password: string; +} + +// 로그인 응답 +export interface LoginResponse { + success: boolean; + data: { + accessToken: string; + }; + error: { + message: string; + code: number; + }; +} diff --git a/frontend/src/features/auth/ui/LoginForm.tsx b/frontend/src/features/auth/ui/LoginForm.tsx new file mode 100644 index 000000000..f14e5539b --- /dev/null +++ b/frontend/src/features/auth/ui/LoginForm.tsx @@ -0,0 +1,55 @@ +import { Input } from '@/shared/ui/Input'; +import { Button } from '@/shared/ui/button'; +import { useState } from 'react'; +import { useLogin } from '../hooks/useLogin'; + +export function LoginForm() { + const { mutate, isPending, isError, error } = useLogin(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + mutate({ email, password }); + }; + + return ( +
+ setEmail(e.target.value)} + required + autoComplete='email' + /> + setPassword(e.target.value)} + type='password' + required + autoComplete='current-password' + /> + {isError && error instanceof Error && ( +
+ {error.message} +
+ )} + +
+ ); +} + +export default LoginForm; diff --git a/frontend/src/features/auth/ui/LogoutButton.tsx b/frontend/src/features/auth/ui/LogoutButton.tsx new file mode 100644 index 000000000..37d8bd2fd --- /dev/null +++ b/frontend/src/features/auth/ui/LogoutButton.tsx @@ -0,0 +1,24 @@ +import { LogOut } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; + +export function LogoutButton() { + const navigate = useNavigate(); + + const handleLogout = () => { + localStorage.removeItem('token'); + navigate('/login', { replace: true }); + }; + + return ( + + ); +} + +export default LogoutButton; diff --git a/frontend/src/features/auth/ui/SignUpButton.tsx b/frontend/src/features/auth/ui/SignUpButton.tsx new file mode 100644 index 000000000..4ce8d9ce7 --- /dev/null +++ b/frontend/src/features/auth/ui/SignUpButton.tsx @@ -0,0 +1,22 @@ +import { Button } from '@/shared/ui/button'; +import { useNavigate } from 'react-router-dom'; + +export function SignUpButton() { + const navigate = useNavigate(); + + const handleClick = () => { + navigate('/signup'); + }; + + return ( + + ); +} diff --git a/frontend/src/features/auth/ui/SignUpForm.tsx b/frontend/src/features/auth/ui/SignUpForm.tsx new file mode 100644 index 000000000..8918ca7f4 --- /dev/null +++ b/frontend/src/features/auth/ui/SignUpForm.tsx @@ -0,0 +1,120 @@ +import { Input } from '@/shared/ui/Input'; +import { Button } from '@/shared/ui/button'; +import { useState } from 'react'; +import { useRegister } from '../hooks/useRegister'; +import type { RegisterRequest } from '../model/auth.types'; + +interface SignUpFormProps { + onSuccess?: () => void; +} + +export function SignUpForm({ onSuccess }: SignUpFormProps) { + const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); + const [imageUrl, setImageUrl] = useState( + 'https://picsum.photos/id/237/200/300', + ); + const [password, setPassword] = useState(''); + const [passwordCheck, setPasswordCheck] = useState(''); + const [error, setError] = useState(null); + + const { mutate, isPending } = useRegister(() => { + onSuccess?.(); + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + if (password !== passwordCheck) { + setError('비밀번호가 일치하지 않습니다.'); + return; + } + if (username.length < 6 || username.length > 16) { + setError('아이디는 6~16자여야 합니다.'); + return; + } + if (password.length < 6 || password.length > 12) { + setError('비밀번호는 6~12자여야 합니다.'); + return; + } + if (!email || !/^[\w-.]+@([\w-]+\.)+[\w-]{2,}$/.test(email)) { + setError('유효한 이메일을 입력하세요.'); + return; + } + mutate({ username, email, imageUrl, password } as RegisterRequest); + }; + + return ( +
+

회원가입

+ setUsername(e.target.value)} + required + minLength={6} + maxLength={16} + autoComplete='username' + /> + setEmail(e.target.value)} + required + // type='email' + autoComplete='email' + /> + {/* setImageUrl(e.target.value)} + autoComplete='off' + /> */} + setPassword(e.target.value)} + required + minLength={6} + maxLength={12} + autoComplete='new-password' + /> + setPasswordCheck(e.target.value)} + required + minLength={6} + maxLength={12} + autoComplete='new-password' + /> + {error && ( +
+ {error} +
+ )} + +
+ ); +} diff --git a/frontend/src/features/issueList/index.ts b/frontend/src/features/issueList/index.ts deleted file mode 100644 index 38aa43f8e..000000000 --- a/frontend/src/features/issueList/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './ui/IssueList'; -export * from './model/useIssueList'; diff --git a/frontend/src/features/issueList/model/useIssueList.ts b/frontend/src/features/issueList/model/useIssueList.ts deleted file mode 100644 index e2b4b8918..000000000 --- a/frontend/src/features/issueList/model/useIssueList.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { IssueListData } from '@/entities/issue/issue.types'; -import { fetchIssues } from '@/entities/issue/issueAPI'; -import { useEffect, useState } from 'react'; - -export function useIssueList() { - const [data, setData] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - let mounted = true; - - async function load() { - setIsLoading(true); - try { - const result = await fetchIssues(); - if (mounted) { - setData(result); - } - } catch (err: unknown) { - if (mounted) { - setError(err as Error); - } - } finally { - if (mounted) { - setIsLoading(false); - } - } - } - - load(); - - return () => { - mounted = false; - }; - }, []); - - return { data, isLoading, error }; -} diff --git a/frontend/src/features/issueList/ui/IssueItem.tsx b/frontend/src/features/issueList/ui/IssueItem.tsx deleted file mode 100644 index f9cc7193d..000000000 --- a/frontend/src/features/issueList/ui/IssueItem.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import IconInfo from '@/assets/icon_info.svg?react'; -import IconMilestone from '@/assets/milestone.svg?react'; -import type { Issue } from '@/entities/issue/issue.types'; -import { Avatar, AvatarFallback, AvatarImage } from '@/shared/ui/avatar'; -import { Badge } from '@/shared/ui/badge'; -import { formatDistanceToNow } from 'date-fns'; -import { ko } from 'date-fns/locale/ko'; -import { Checkbox } from '../shared/CheckBox'; - -interface IssueItemProps { - issue: Issue; -} - -export function IssueItem({ issue }: IssueItemProps) { - const when = formatDistanceToNow(new Date(issue.createdAt), { - addSuffix: true, - locale: ko, - }); // e.g. "8분 전" - - return ( -
- {/* 1. 체크박스 영역 */} -
- -
- - {/* 2. 정보 영역 */} -
- {/* 첫 번째 줄: 아이콘, 제목, 라벨 */} -
- - - {issue.title} - - {issue.labels.map((lbl) => ( - - {lbl.name} - - ))} -
- - {/* 두 번째 줄: 메타 정보 */} -
- #{issue.id} - - {when}, {issue.author.username}님에 의해 작성되었습니다 - - {issue.milestone && ( - - - {issue.milestone.title} - - )} -
-
- - {/* 3. 프로필 아이콘 영역 (겹쳐서) */} -
-
- {/* 여러명일 때 map; here single author */} - - - - {issue.author.username[0].toUpperCase()} - - -
-
-
- ); -} diff --git a/frontend/src/features/issueList/ui/IssueList.tsx b/frontend/src/features/issueList/ui/IssueList.tsx deleted file mode 100644 index c44a7cae5..000000000 --- a/frontend/src/features/issueList/ui/IssueList.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { Issue } from '@/entities/issue/issue.types'; -import { IssueListHeader } from '../widget'; -import { IssueItem } from './IssueItem'; - -interface IssueListProps { - issues: Issue[]; -} - -export function IssueList({ issues }: IssueListProps) { - return ( -
- - {issues.map((issue) => ( -
- -
- ))} -
- ); -} diff --git a/frontend/src/features/issueList/widget/Buttons/NavigationButton.tsx b/frontend/src/features/issueList/widget/Buttons/NavigationButton.tsx deleted file mode 100644 index 82d458252..000000000 --- a/frontend/src/features/issueList/widget/Buttons/NavigationButton.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import LabelIcon from '@/assets/label.svg?react'; -import MilestonIcon from '@/assets/milestone.svg?react'; -import { Button } from '@/shared/ui/button'; -import { useNavigate } from 'react-router-dom'; - -export function LabelListButton() { - const navigate = useNavigate(); - const handleClick = () => { - navigate('/labels'); - }; - - return ( - - ); -} - -export function MilestoneListButton() { - const navigate = useNavigate(); - const handleClick = () => { - navigate('/milestones'); - }; - - return ( - - ); -} diff --git a/frontend/src/features/issueList/widget/Buttons/index.ts b/frontend/src/features/issueList/widget/Buttons/index.ts deleted file mode 100644 index b26202e27..000000000 --- a/frontend/src/features/issueList/widget/Buttons/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './IssueCreationButton'; -export * from './NavigationButton'; diff --git a/frontend/src/features/issueList/widget/FilteringPanel/AssigneeDropdown.tsx b/frontend/src/features/issueList/widget/FilteringPanel/AssigneeDropdown.tsx deleted file mode 100644 index b6bf45d23..000000000 --- a/frontend/src/features/issueList/widget/FilteringPanel/AssigneeDropdown.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { useUserList } from '@/entities/user/useUserList'; -// src/entities/user/ui/AssigneeDropdown.tsx -import { - CustomDropdownPanel, - type DropdownOption, -} from '@/shared/ui/CustomDropdownPanel'; -import { useMemo, useState } from 'react'; - -export default function AssigneeDropdown() { - const [selected, setSelected] = useState(null); - const { data, isLoading, error } = useUserList(); - - // ✅ useMemo를 항상 호출하도록 최상단에 선언 - const userOptions = useMemo(() => { - const noneOption: DropdownOption = { - id: 0, - value: 'none', - display: '담당자가 없는 이슈', - }; - - const fetchedOptions: DropdownOption[] = - data?.users.map((user) => ({ - id: user.id, - value: user.username, - display: user.username, - imageUrl: user.imageUrl, - })) ?? []; - - return [noneOption, ...fetchedOptions]; - }, [data]); - - // 로딩·에러 UI는 그 다음에 처리 - if (isLoading) { - return
담당자 목록 로딩 중…
; - } - if (error) { - return ( -
- 담당자 목록을 불러오는 중 에러가 발생했습니다. -
- ); - } - - return ( -
- -
- ); -} diff --git a/frontend/src/features/issueList/widget/IssueCreateModal/IssueCreateModal.tsx b/frontend/src/features/issueList/widget/IssueCreateModal/IssueCreateModal.tsx deleted file mode 100644 index 7a6d52408..000000000 --- a/frontend/src/features/issueList/widget/IssueCreateModal/IssueCreateModal.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { FC } from 'react'; - -export const IssueCreateModal: FC = () => { - return ( -
-

이슈 생성

- {/* TODO: 이슈 생성 컴포넌트 구현 */} -
- ); -}; diff --git a/frontend/src/features/issueList/widget/IssueCreateModal/index.ts b/frontend/src/features/issueList/widget/IssueCreateModal/index.ts deleted file mode 100644 index d19b832f4..000000000 --- a/frontend/src/features/issueList/widget/IssueCreateModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './IssueCreateModal'; diff --git a/frontend/src/features/issueList/widget/IssueFilterBar/IssueFilter.tsx b/frontend/src/features/issueList/widget/IssueFilterBar/IssueFilter.tsx deleted file mode 100644 index d6875a798..000000000 --- a/frontend/src/features/issueList/widget/IssueFilterBar/IssueFilter.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { type FilterOption, FilterSelect } from '@/shared/ui/FilterSelect'; -import { useSearchParams } from 'react-router-dom'; - -// “열린 이슈” 등 옵션 목록 -const statusOptions: FilterOption[] = [ - { value: 'open', label: '열린 이슈' }, - { value: 'mine', label: '내가 작성한 이슈' }, - { value: 'assigned', label: '나에게 할당된 이슈' }, - { value: 'commented', label: '내가 댓글 남긴 이슈' }, - { value: 'closed', label: '닫힌 이슈' }, -]; - -export function IssueFilter() { - const [searchParams, setSearchParams] = useSearchParams(); - - // 쿼리에서 status 읽어오기 (없으면 빈 문자열) - const current = searchParams.get('status') ?? ''; - - // 값 변경 시 URL 업데이트 - const onStatusChange = (value: string) => { - if (value) { - searchParams.set('status', value); - } else { - searchParams.delete('status'); - } - setSearchParams(searchParams, { replace: true }); - }; - - return ( -
- -
- ); -} diff --git a/frontend/src/features/issueList/widget/IssueFilterBar/index.ts b/frontend/src/features/issueList/widget/IssueFilterBar/index.ts deleted file mode 100644 index 21ecbd621..000000000 --- a/frontend/src/features/issueList/widget/IssueFilterBar/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './IssueFilter'; diff --git a/frontend/src/features/issueList/widget/IssueListHeader/IssueListHeader.tsx b/frontend/src/features/issueList/widget/IssueListHeader/IssueListHeader.tsx deleted file mode 100644 index fbafebd2c..000000000 --- a/frontend/src/features/issueList/widget/IssueListHeader/IssueListHeader.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import ExampleAssigneeDropdown from '@/features/issueList/widget/FilteringPanel/AssigneeDropdown'; -import { Checkbox } from '../../shared/CheckBox'; - -export function IssueListHeader() { - return ( -
-
- - 열린이슈/닫힌이슈 -
-
- -
-
- ); -} diff --git a/frontend/src/features/issueList/widget/IssueListHeader/index.ts b/frontend/src/features/issueList/widget/IssueListHeader/index.ts deleted file mode 100644 index d5633575d..000000000 --- a/frontend/src/features/issueList/widget/IssueListHeader/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './IssueListHeader'; diff --git a/frontend/src/features/issueList/widget/IssueSearchBar/IssueSearch.tsx b/frontend/src/features/issueList/widget/IssueSearchBar/IssueSearch.tsx deleted file mode 100644 index fb347c3c3..000000000 --- a/frontend/src/features/issueList/widget/IssueSearchBar/IssueSearch.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Input } from '@/shared/ui/input'; -import { SearchIcon } from 'lucide-react'; -import { useSearchParams } from 'react-router-dom'; -export function IssueSearch() { - const [searchParams] = useSearchParams(); - const queryParams = searchParams.get('status') ?? ''; - - return ( -
- {/* 왼쪽에 검색 아이콘 */} - - - {/* shadcn Input */} - -
- ); -} diff --git a/frontend/src/features/issueList/widget/IssueSearchBar/index.ts b/frontend/src/features/issueList/widget/IssueSearchBar/index.ts deleted file mode 100644 index 53a334608..000000000 --- a/frontend/src/features/issueList/widget/IssueSearchBar/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './IssueSearch'; diff --git a/frontend/src/features/issueList/widget/index.ts b/frontend/src/features/issueList/widget/index.ts deleted file mode 100644 index 84f03292b..000000000 --- a/frontend/src/features/issueList/widget/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './IssueCreateModal'; -export * from './IssueFilterBar'; -export * from './IssueSearchBar'; -export * from './IssueListHeader'; -export * from './Buttons'; diff --git a/frontend/src/features/oauthLogin/api/oauthApi.ts b/frontend/src/features/oauthLogin/api/oauthApi.ts new file mode 100644 index 000000000..beacd8a6c --- /dev/null +++ b/frontend/src/features/oauthLogin/api/oauthApi.ts @@ -0,0 +1,19 @@ +export const GITHUB_OAUTH_URL = + 'https://github.com/login/oauth/authorize?client_id=Ov23liuOX60WtYLKOZUp&redirect_uri=https://www.issue-tracker.online/auth/github/callback&scope=user:email'; +export async function exchangeGithubCode(code: string) { + const res = await fetch(`/api/auth/github/callback?code=${code}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }); + + if (!res.ok) throw new Error('OAuth 인증 실패'); + + const json = await res.json(); + + if (!json.success) throw new Error(json.error || 'OAuth 인증 실패'); + + return json.data; +} diff --git a/frontend/src/features/oauthLogin/hooks/useOAuthLogin.ts b/frontend/src/features/oauthLogin/hooks/useOAuthLogin.ts new file mode 100644 index 000000000..b15f527be --- /dev/null +++ b/frontend/src/features/oauthLogin/hooks/useOAuthLogin.ts @@ -0,0 +1,23 @@ +import { useQuery } from '@tanstack/react-query'; +import { exchangeGithubCode } from '../api/oauthApi'; + +export function useOAuthLogin(code: string) { + const { + data: authData, + isLoading, + isError, + error, + isSuccess, + } = useQuery({ + queryKey: ['github-oauth', code], + queryFn: () => exchangeGithubCode(code), + }); + + return { + authData, + isLoading, + isError, + error, + isSuccess, + }; +} diff --git a/frontend/src/features/oauthLogin/index.ts b/frontend/src/features/oauthLogin/index.ts new file mode 100644 index 000000000..b2b1afd8f --- /dev/null +++ b/frontend/src/features/oauthLogin/index.ts @@ -0,0 +1,3 @@ +export { OAuthLoginButton } from './ui/OAuthLoginButton'; +export { useOAuthLogin } from './hooks/useOAuthLogin'; +export { GITHUB_OAUTH_URL } from './api/oauthApi'; diff --git a/frontend/src/features/oauthLogin/ui/OAuthLoginButton.tsx b/frontend/src/features/oauthLogin/ui/OAuthLoginButton.tsx new file mode 100644 index 000000000..50f3b10b6 --- /dev/null +++ b/frontend/src/features/oauthLogin/ui/OAuthLoginButton.tsx @@ -0,0 +1,21 @@ +import { Button } from '@/shared/ui/button'; +import { GITHUB_OAUTH_URL } from '../api/oauthApi'; + +export function OAuthLoginButton() { + const handleClick = () => { + window.location.href = GITHUB_OAUTH_URL; + }; + return ( + + ); +} + +export default OAuthLoginButton; diff --git a/frontend/src/pages/IssueDetailPage/index.tsx b/frontend/src/pages/IssueDetailPage/index.tsx new file mode 100644 index 000000000..2cdac405c --- /dev/null +++ b/frontend/src/pages/IssueDetailPage/index.tsx @@ -0,0 +1,134 @@ +import PlusIcon from '@/assets/plus.svg?react'; +import TrashIcon from '@/assets/trash.svg?react'; +import { useCreateComment } from '@/entities/comment/hooks/useCreateComment'; +import { useDeleteIssue } from '@/entities/issue/hooks/useDeleteIssue'; +import { useFetchIssueDetail } from '@/entities/issue/hooks/useFetchIssueDetail'; +import { useFetchIssueList } from '@/entities/issue/hooks/useFetchIssueList'; +import { ConfirmModal } from '@/shared/ui/ConfirmModal'; +import { TextArea } from '@/shared/ui/TextArea'; +import { Button } from '@/shared/ui/button'; +import { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; +import { toast } from 'sonner'; +import { Comment } from './ui/Comment'; +import { Header } from './ui/Header'; +import { Sidebar } from './ui/Sidebar'; + +const IssueDetailPage = () => { + const navigate = useNavigate(); + const { id } = useParams<{ id: string }>(); + + const [openConfirm, setOpenConfirm] = useState(false); + const [inputValue, setInputValue] = useState(''); + + const { refetch: issuesRefetch } = useFetchIssueList(''); + const { data: issue, refetch: issueDetailRefetch } = useFetchIssueDetail( + Number(id), + ); + const { mutate: commentCreateMutate } = useCreateComment(() => { + issueDetailRefetch(); + setInputValue(''); + }); + const { mutate: issueDeleteMutate } = useDeleteIssue(() => { + issuesRefetch(); + navigate('/issues'); + }); + + const isEnabled = inputValue.trim().length > 0; + + if (!issue) return; + + const onCreateContent = () => + commentCreateMutate({ + issueId: Number(id), + payload: { content: inputValue }, + }); + + const onDeleteIssue = () => issueDeleteMutate(Number(id)); + + return ( +
+ {/** Title, Buttons, Information */} +
+ {/** Division */} + +
+ {/** Comment, TextArea, CreateButton */} +
+ {issue.comments?.map((comment) => { + return ( + + ); + })} + {/** TextArea, CreateButton */} +
+