diff --git a/package.json b/package.json index 07ddfe4..a30c007 100644 --- a/package.json +++ b/package.json @@ -7,12 +7,14 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "generate:api": "openapi-typescript http://3.34.130.196:8081/v3/api-docs -o src/shared/types/schema.ts" }, "dependencies": { "@tanstack/react-query": "^5.90.17", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "jwt-decode": "^4.0.0", "ky": "^1.14.2", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -40,6 +42,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "openapi-typescript": "^7.13.0", "postcss": "^8.5.6", "prettier": "^3.7.4", "tailwindcss": "^3.4.19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de38cdd..baafd03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + jwt-decode: + specifier: ^4.0.0 + version: 4.0.0 ky: specifier: ^1.14.2 version: 1.14.2 @@ -93,6 +96,9 @@ importers: globals: specifier: ^16.5.0 version: 16.5.0 + openapi-typescript: + specifier: ^7.13.0 + version: 7.13.0(typescript@5.9.3) postcss: specifier: ^8.5.6 version: 8.5.6 @@ -458,6 +464,16 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@redocly/ajv@8.17.4': + resolution: {integrity: sha512-BieiCML/IgP6x99HZByJSt7fJE4ipgzO7KAFss92Bs+PEI35BhY7vGIysFXLT+YmS7nHtQjZjhOQyPPEf7xGHA==} + + '@redocly/config@0.22.2': + resolution: {integrity: sha512-roRDai8/zr2S9YfmzUfNhKjOF0NdcOIqF7bhf4MVC5UxpjIysDjyudvlAiVbpPHp3eDRWbdzUgtkK1a7YiDNyQ==} + + '@redocly/openapi-core@1.34.6': + resolution: {integrity: sha512-2+O+riuIUgVSuLl3Lyh5AplWZyVMNuG2F98/o6NrutKJfW4/GTZdPpZlIphS0HGgcOHgmWcCSHj+dWFlZaGSHw==} + engines: {node: '>=18.17.0', npm: '>=9.5.0'} + '@rolldown/pluginutils@1.0.0-beta.53': resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} @@ -932,9 +948,17 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -1056,6 +1080,9 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -1074,6 +1101,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + colorette@1.4.0: + resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -1385,6 +1415,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -1521,6 +1554,10 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1537,6 +1574,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -1665,6 +1706,10 @@ packages: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true + js-levenshtein@1.1.6: + resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} + engines: {node: '>=0.10.0'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1686,6 +1731,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -1702,6 +1750,10 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + jwt-decode@4.0.0: + resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} + engines: {node: '>=18'} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1822,6 +1874,10 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@5.1.6: + resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} + engines: {node: '>=10'} + minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} @@ -1894,6 +1950,12 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + openapi-typescript@7.13.0: + resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==} + hasBin: true + peerDependencies: + typescript: ^5.x + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1918,6 +1980,10 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1952,6 +2018,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -2084,6 +2154,10 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2222,6 +2296,10 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -2295,6 +2373,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -2426,6 +2508,13 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml-ast-parser@0.0.43: + resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -2464,7 +2553,7 @@ snapshots: '@babel/types': 7.28.5 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -2546,7 +2635,7 @@ snapshots: '@babel/parser': 7.28.5 '@babel/template': 7.27.2 '@babel/types': 7.28.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -2659,7 +2748,7 @@ snapshots: '@eslint/config-array@0.21.1': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -2675,7 +2764,7 @@ snapshots: '@eslint/eslintrc@3.3.3': dependencies: ajv: 6.12.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -2746,6 +2835,29 @@ snapshots: '@pkgr/core@0.2.9': {} + '@redocly/ajv@8.17.4': + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + '@redocly/config@0.22.2': {} + + '@redocly/openapi-core@1.34.6(supports-color@10.2.2)': + dependencies: + '@redocly/ajv': 8.17.4 + '@redocly/config': 0.22.2 + colorette: 1.4.0 + https-proxy-agent: 7.0.6(supports-color@10.2.2) + js-levenshtein: 1.1.6 + js-yaml: 4.1.1 + minimatch: 5.1.6 + pluralize: 8.0.0 + yaml-ast-parser: 0.0.43 + transitivePeerDependencies: + - supports-color + '@rolldown/pluginutils@1.0.0-beta.53': {} '@rollup/pluginutils@5.3.0(rollup@4.54.0)': @@ -2991,7 +3103,7 @@ snapshots: '@typescript-eslint/types': 8.51.0 '@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.51.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: @@ -3003,7 +3115,7 @@ snapshots: '@typescript-eslint/types': 8.52.0 '@typescript-eslint/typescript-estree': 8.52.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.52.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: @@ -3013,7 +3125,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.51.0(typescript@5.9.3) '@typescript-eslint/types': 8.51.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -3022,7 +3134,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.52.0(typescript@5.9.3) '@typescript-eslint/types': 8.52.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -3050,7 +3162,7 @@ snapshots: '@typescript-eslint/types': 8.51.0 '@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3) '@typescript-eslint/utils': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) eslint: 9.39.2(jiti@1.21.7) ts-api-utils: 2.3.0(typescript@5.9.3) typescript: 5.9.3 @@ -3062,7 +3174,7 @@ snapshots: '@typescript-eslint/types': 8.52.0 '@typescript-eslint/typescript-estree': 8.52.0(typescript@5.9.3) '@typescript-eslint/utils': 8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) eslint: 9.39.2(jiti@1.21.7) ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 @@ -3079,7 +3191,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.51.0(typescript@5.9.3) '@typescript-eslint/types': 8.51.0 '@typescript-eslint/visitor-keys': 8.51.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) minimatch: 9.0.5 semver: 7.7.3 tinyglobby: 0.2.15 @@ -3094,7 +3206,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.52.0(typescript@5.9.3) '@typescript-eslint/types': 8.52.0 '@typescript-eslint/visitor-keys': 8.52.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) minimatch: 9.0.5 semver: 7.7.3 tinyglobby: 0.2.15 @@ -3212,6 +3324,8 @@ snapshots: acorn@8.15.0: {} + agent-base@7.1.4: {} + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -3219,6 +3333,8 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-colors@4.1.3: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 @@ -3373,6 +3489,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + change-case@5.4.4: {} + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -3397,6 +3515,8 @@ snapshots: color-name@1.1.4: {} + colorette@1.4.0: {} + commander@4.1.1: {} concat-map@0.0.1: {} @@ -3446,9 +3566,11 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.4.3: + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 deep-is@0.1.4: {} @@ -3653,7 +3775,7 @@ snapshots: eslint-import-resolver-typescript@4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@1.21.7)): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) eslint: 9.39.2(jiti@1.21.7) eslint-import-context: 0.1.9(unrs-resolver@1.11.1) get-tsconfig: 4.13.0 @@ -3778,7 +3900,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -3838,6 +3960,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-uri@3.1.0: {} + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -3969,6 +4093,13 @@ snapshots: dependencies: hermes-estree: 0.25.1 + https-proxy-agent@7.0.6(supports-color@10.2.2): + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + ignore@5.3.2: {} ignore@7.0.5: {} @@ -3980,6 +4111,8 @@ snapshots: imurmurhash@0.1.4: {} + index-to-position@1.2.0: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -4119,6 +4252,8 @@ snapshots: jiti@1.21.7: {} + js-levenshtein@1.1.6: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -4133,6 +4268,8 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@1.0.2: @@ -4148,6 +4285,8 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + jwt-decode@4.0.0: {} + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -4244,6 +4383,10 @@ snapshots: dependencies: brace-expansion: 1.1.12 + minimatch@5.1.6: + dependencies: + brace-expansion: 2.0.2 + minimatch@9.0.5: dependencies: brace-expansion: 2.0.2 @@ -4317,6 +4460,16 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + openapi-typescript@7.13.0(typescript@5.9.3): + dependencies: + '@redocly/openapi-core': 1.34.6(supports-color@10.2.2) + ansi-colors: 4.1.3 + change-case: 5.4.4 + parse-json: 8.3.0 + supports-color: 10.2.2 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -4351,6 +4504,12 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.27.1 + index-to-position: 1.2.0 + type-fest: 4.41.0 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -4369,6 +4528,8 @@ snapshots: pirates@4.0.7: {} + pluralize@8.0.0: {} + possible-typed-array-names@1.1.0: {} postcss-import@15.1.0(postcss@8.5.6): @@ -4487,6 +4648,8 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -4692,6 +4855,8 @@ snapshots: tinyglobby: 0.2.15 ts-interface-checker: 0.1.13 + supports-color@10.2.2: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -4778,6 +4943,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@4.41.0: {} + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -4882,7 +5049,7 @@ snapshots: vite-tsconfig-paths@6.0.4(typescript@5.9.3)(vite@7.3.0(@types/node@24.10.4)(jiti@1.21.7)(lightningcss@1.30.2)): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: @@ -4954,6 +5121,10 @@ snapshots: yallist@3.1.1: {} + yaml-ast-parser@0.0.43: {} + + yargs-parser@21.1.1: {} + yocto-queue@0.1.0: {} zod-validation-error@4.0.2(zod@4.3.4): diff --git a/src/app/router/lazy.ts b/src/app/router/lazy.ts index e378aa9..57fcaee 100644 --- a/src/app/router/lazy.ts +++ b/src/app/router/lazy.ts @@ -2,6 +2,11 @@ import { lazy } from 'react'; export const MainPage = lazy(() => import('@page/main-page')); export const MyPage = lazy(() => import('@page/my/my-page')); -export const NickNamePAge = lazy(() => import('@page/my/nickName-change')); +export const NickNamePage = lazy(() => import('@page/my/nickName-change')); +export const NickNameChagePage = lazy(() => import('@page/my/nickName-change')); export const CreatPage = lazy(() => import('@page/create-page')); export const PostDetailPage = lazy(() => import('@page/post-detail-page')); +export const OnboardingPage = lazy(() => import('@page/onboarding-page')); +export const LoginCallbackPage = lazy( + () => import('@page/login-callback-page'), +); diff --git a/src/app/router/path.ts b/src/app/router/path.ts index 3ff7b68..b6f9ccd 100644 --- a/src/app/router/path.ts +++ b/src/app/router/path.ts @@ -1,9 +1,11 @@ export const routePath = { - MAIN: '/', + ONBOARDING: '/', + MAIN: '/main', MY: '/my', CREATE: '/create', - DETAIL: '/posts/:id', + DETAIL: '/posts/:feedId', NICKNAMECHAGE: '/my/nickname', + LOGIN_CALLBACK: '/oauth/success', } as const; export type Routes = (typeof routePath)[keyof typeof routePath]; diff --git a/src/app/router/private-route.tsx b/src/app/router/private-route.tsx new file mode 100644 index 0000000..d360a90 --- /dev/null +++ b/src/app/router/private-route.tsx @@ -0,0 +1,12 @@ +import { routePath } from '@app/router/path'; +import { Navigate, Outlet } from 'react-router-dom'; + +export default function PrivateRoute() { + const token = localStorage.getItem('accessToken'); + + if (!token) { + return ; + } + + return ; +} diff --git a/src/app/router/routes/global-routes.tsx b/src/app/router/routes/global-routes.tsx index 5ec5ddb..7c918d6 100644 --- a/src/app/router/routes/global-routes.tsx +++ b/src/app/router/routes/global-routes.tsx @@ -1,26 +1,48 @@ import { routePath } from '@app/router/path'; -import { CreatPage, MainPage, MyPage, PostDetailPage } from '@app/router/lazy'; -import NickNameChage from '@page/my/nickName-change'; +import { + CreatPage, + LoginCallbackPage, + MainPage, + MyPage, + NickNameChagePage, + OnboardingPage, + PostDetailPage, +} from '@app/router/lazy'; + +import PrivateRoute from '@app/router/private-route'; export const globalRoutes = [ { - path: routePath.MAIN, - element: , - }, - { - path: '/my', - element: , - }, - { - path: '/create', - element: , + path: routePath.ONBOARDING, + element: , }, { - path: '/posts/:id', - element: , + path: routePath.LOGIN_CALLBACK, + element: , }, { - path: '/my/nickname', - element: , + element: , + children: [ + { + path: routePath.MAIN, + element: , + }, + { + path: routePath.MY, + element: , + }, + { + path: routePath.CREATE, + element: , + }, + { + path: routePath.DETAIL, + element: , + }, + { + path: routePath.NICKNAMECHAGE, + element: , + }, + ], }, ]; diff --git a/src/features/.gitkeep b/src/features/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/features/auth/login-callback.tsx b/src/features/auth/login-callback.tsx new file mode 100644 index 0000000..357b8c3 --- /dev/null +++ b/src/features/auth/login-callback.tsx @@ -0,0 +1,27 @@ +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { routePath } from '@app/router/path'; + +export default function LoginCallback() { + const navigate = useNavigate(); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const token = params.get('token'); + + const finalToken = token ?? localStorage.getItem('accessToken'); + + if (!finalToken) { + navigate(routePath.ONBOARDING, { replace: true }); + return; + } + + if (token) { + localStorage.setItem('accessToken', token); + } + + navigate(routePath.MAIN, { replace: true }); + }, [navigate]); + + return
로그인 처리중...
; +} diff --git a/src/features/location-picker/types.ts b/src/features/location-picker/types.ts new file mode 100644 index 0000000..7368f61 --- /dev/null +++ b/src/features/location-picker/types.ts @@ -0,0 +1,6 @@ +export interface LocationSelection { + name: string; + address: string; + latitude: number; + longitude: number; +} diff --git a/src/features/location-picker/ui/location-picker.tsx b/src/features/location-picker/ui/location-picker.tsx new file mode 100644 index 0000000..f68fc00 --- /dev/null +++ b/src/features/location-picker/ui/location-picker.tsx @@ -0,0 +1,287 @@ +import { useEffect, useRef, useState } from 'react'; +import Input, { type InputSize } from '@shared/ui/input'; +import { FloatingActionButton } from '@shared/ui/floatingActionButton'; +import LocationIcon from '@shared/assets/icon/material-symbols_my-location-outline-rounded.svg?react'; +import { loadKakaoMap } from '@shared/lib/kakao-map/load-kakao-map'; +import type { LocationSelection } from '@features/location-picker/types'; + +const DEFAULT_CENTER = { + latitude: 37.5665, + longitude: 126.978, +}; + +interface LocationPickerProps { + value: LocationSelection | null; + onChange: (value: LocationSelection) => void; + inputSize?: InputSize; + inputPlaceholder?: string; + containerClassName?: string; + searchRowClassName?: string; + mapClassName?: string; +} + +export function LocationPicker({ + value, + onChange, + inputSize = 'sm', + inputPlaceholder = '동을 입력해주세요. 예) 역삼동', + containerClassName = 'flex flex-col gap-[1.6rem] px-[2.4rem] pb-[2.4rem]', + searchRowClassName = 'flex items-center gap-[1.2rem]', + mapClassName = 'relative h-[24rem] w-full overflow-hidden rounded-[16px] border border-gray-200', +}: LocationPickerProps) { + const mapContainerRef = useRef(null); + const mapRef = useRef(null); + const placesRef = useRef(null); + const geocoderRef = useRef(null); + const syncTokenRef = useRef(0); + const idleTimeoutRef = useRef(null); + const ignoreNextIdleSyncRef = useRef(false); + const suppressSearchRef = useRef(false); + const [keyword, setKeyword] = useState(value?.name || value?.address || ''); + const [isMapReady, setIsMapReady] = useState(false); + + const syncCenterToSelection = ( + latitude: number, + longitude: number, + fallbackName?: string, + ) => { + if (!geocoderRef.current || !window.kakao?.maps?.services) { + onChange({ + name: fallbackName || '선택한 위치', + address: fallbackName || '선택한 위치', + latitude, + longitude, + }); + return; + } + + syncTokenRef.current += 1; + const currentToken = syncTokenRef.current; + + geocoderRef.current.coord2Address( + longitude, + latitude, + (result: any[], status: string) => { + if (currentToken !== syncTokenRef.current) return; + + const address = + status === window.kakao.maps.services.Status.OK + ? result[0]?.road_address?.address_name || + result[0]?.address?.address_name || + fallbackName || + '선택한 위치' + : fallbackName || '선택한 위치'; + + onChange({ + name: fallbackName || address, + address, + latitude, + longitude, + }); + }, + ); + }; + + const moveMapCenter = ( + latitude: number, + longitude: number, + fallbackName?: string, + ) => { + if (!window.kakao?.maps || !mapRef.current) return; + + ignoreNextIdleSyncRef.current = true; + const position = new window.kakao.maps.LatLng(latitude, longitude); + mapRef.current.panTo(position); + syncCenterToSelection(latitude, longitude, fallbackName); + }; + + useEffect(() => { + let isMounted = true; + + loadKakaoMap() + .then((kakao) => { + if (!isMounted || !mapContainerRef.current) return; + + const center = new kakao.maps.LatLng( + value?.latitude ?? DEFAULT_CENTER.latitude, + value?.longitude ?? DEFAULT_CENTER.longitude, + ); + + const map = new kakao.maps.Map(mapContainerRef.current, { + center, + level: 3, + }); + + kakao.maps.event.addListener(map, 'idle', () => { + if (ignoreNextIdleSyncRef.current) { + ignoreNextIdleSyncRef.current = false; + return; + } + + if (idleTimeoutRef.current) { + window.clearTimeout(idleTimeoutRef.current); + } + + const nextCenter = map.getCenter(); + + idleTimeoutRef.current = window.setTimeout(() => { + syncCenterToSelection(nextCenter.getLat(), nextCenter.getLng()); + }, 180); + }); + + mapRef.current = map; + placesRef.current = new kakao.maps.services.Places(); + geocoderRef.current = new kakao.maps.services.Geocoder(); + setIsMapReady(true); + }) + .catch((error) => { + console.error(error); + }); + + return () => { + isMounted = false; + + if (idleTimeoutRef.current) { + window.clearTimeout(idleTimeoutRef.current); + } + }; + }, []); + + useEffect(() => { + if (!isMapReady || !value || !mapRef.current || !window.kakao?.maps) return; + + const center = mapRef.current.getCenter(); + const lat = center.getLat(); + const lng = center.getLng(); + + if (Math.abs(lat - value.latitude) < 0.000001 && Math.abs(lng - value.longitude) < 0.000001) { + return; + } + + ignoreNextIdleSyncRef.current = true; + const position = new window.kakao.maps.LatLng( + value.latitude, + value.longitude, + ); + mapRef.current.panTo(position); + }, [isMapReady, value]); + + useEffect(() => { + if (!isMapReady || !placesRef.current || !window.kakao?.maps?.services) return; + + const trimmedKeyword = keyword.trim(); + + if (trimmedKeyword.length < 2) { + return; + } + + const timeoutId = window.setTimeout(() => { + if (suppressSearchRef.current) { + suppressSearchRef.current = false; + return; + } + + geocoderRef.current?.addressSearch( + trimmedKeyword, + (addressData: any[], addressStatus: string) => { + if ( + addressStatus === window.kakao.maps.services.Status.OK && + addressData[0] + ) { + const firstAddress = addressData[0]; + moveMapCenter( + Number(firstAddress.y), + Number(firstAddress.x), + firstAddress.address_name, + ); + return; + } + + placesRef.current.keywordSearch( + trimmedKeyword, + (placeData: any[], placeStatus: string) => { + if ( + placeStatus !== window.kakao.maps.services.Status.OK || + !placeData[0] + ) { + return; + } + + const firstPlace = placeData[0]; + moveMapCenter( + Number(firstPlace.y), + Number(firstPlace.x), + firstPlace.place_name, + ); + }, + ); + }, + ); + }, 400); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [isMapReady, keyword]); + + const handleCurrentLocation = () => { + if (!navigator.geolocation) { + window.alert('현재 위치를 지원하지 않는 환경입니다.'); + return; + } + + navigator.geolocation.getCurrentPosition( + (position) => { + moveMapCenter( + position.coords.latitude, + position.coords.longitude, + '현재 위치', + ); + }, + (error) => { + console.error(error); + + if (error.code === error.PERMISSION_DENIED) { + window.alert('위치 권한이 꺼져 있어요. 브라우저 위치 권한을 허용해주세요.'); + return; + } + + window.alert('현재 위치를 가져오지 못했습니다. 위치 권한과 네트워크를 확인해주세요.'); + }, + { + enableHighAccuracy: true, + timeout: 10000, + maximumAge: 0, + }, + ); + }; + + return ( +
+
+ setKeyword(event.target.value)} + placeholder={inputPlaceholder} + /> + } + /> +
+ +
+
+
+ +
+
+
+ ); +} diff --git a/src/page/create-page.tsx b/src/page/create-page.tsx index 01634d1..8a65b6a 100644 --- a/src/page/create-page.tsx +++ b/src/page/create-page.tsx @@ -13,6 +13,11 @@ import { AddImage } from '@widgets/create/add-image'; import Modal from '@widgets/create/modal/modal'; import { ModalLocationSearch } from '@widgets/create/modal/contents/modal-location-search'; import { DateTimePicker } from '@widgets/create/modal/contents/wheel/date-time-picker'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { FEED_MUTATION_OPTIONS } from '@shared/api/domain/feeds/query'; // 네가 만든 위치 +import { FEED_QUERY_KEY } from '@shared/api/query-key'; +import { getPresignedUpload } from '@shared/api/domain/controller/query'; +import type { LocationSelection } from '@features/location-picker/types'; type CreateModalType = 'location' | 'datetime' | null; const CreatPage = () => { @@ -23,19 +28,58 @@ const CreatPage = () => { const [time, setTime] = useState(''); const [openModal, setOpenModal] = useState(null); const navigate = useNavigate(); - const [location, setLocation] = useState(''); // 최종 확정 값 - const [tempLocation, setTempLocation] = useState(''); // 모달용 임시 값 + const [location, setLocation] = useState(null); + const [tempLocation, setTempLocation] = useState( + null, + ); const [dateTime, setDateTime] = useState<{ dateText: string; hour: string; minute: string; } | null>(null); + const [imageUrl, setImageUrl] = useState(''); const [tempDateTime, setTempDateTime] = useState<{ dateText: string; hour: string; minute: string; } | null>(null); + const queryClient = useQueryClient(); + + const { mutate } = useMutation({ + ...FEED_MUTATION_OPTIONS.CREATE(), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: FEED_QUERY_KEY.LIST(), + }); + + navigate('/main'); + }, + }); + const handleImageUpload = async (file: File) => { + try { + // 1️⃣ presigned 요청 (우리 서버) + const { uploadUrl, fileUrl } = await getPresignedUpload( + 'feed', + file.type, + ); + + // 2️⃣ S3 업로드 (외부 URL) + await fetch(uploadUrl, { + method: 'put', + headers: { + 'Content-Type': file.type, + }, + body: file, + }); + + // 3️⃣ 상태 저장 + setImageUrl(fileUrl); + } catch (e) { + console.error('이미지 업로드 실패', e); + } + }; + return (
{ /> - - 클릭하여 장소 선택 - - ) + + + 클릭하여 장소 선택 + } onClick={() => { setTempLocation(location); @@ -95,11 +138,12 @@ const CreatPage = () => { setOpenModal('datetime')} />
@@ -112,7 +156,7 @@ const CreatPage = () => { onChange={(e) => setText(e.target.value)} />
- +
+
+
+ ); +}; +export default OnboardingPage; diff --git a/src/page/post-detail-page.tsx b/src/page/post-detail-page.tsx index 893da7c..a357c24 100644 --- a/src/page/post-detail-page.tsx +++ b/src/page/post-detail-page.tsx @@ -1,93 +1,27 @@ import { TopNavigation } from '@shared/ui/topNavigation'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import ArrowLeftIcon from '@shared/assets/icon/arrow-left.svg?react'; import UploadIcon from '@shared/assets/icon/upload.svg?react'; import { Carousel } from '@widgets/postDetail/carousel/carousel'; import { DetailInfo } from '@widgets/postDetail/detail-info'; import { Comment } from '@widgets/postDetail/comment/comment'; -import type { CommentItemProps } from '@widgets/postDetail/comment/comment-item'; import Input from '@shared/ui/input'; import { FloatingActionButton } from '@shared/ui/floatingActionButton'; import SendIcon from '@shared/assets/icon/send.svg?react'; import { Button } from '@shared/ui/button'; -export const mockComments: CommentItemProps[] = [ - // ===== 시스템 메시지 (최상단 고정 대상) ===== - { - id: 100, - author: '시스템', - time: '방금 전', - value: '홍길동님의 참가 신청이 승인 대기중입니다.', - parentId: null, - type: 'system', - status: 'pending', - }, - { - id: 101, - author: '시스템', - time: '10분 전', - value: '김철수님의 참가 신청이 승인되었습니다.', - parentId: null, - type: 'system', - status: 'approved', - }, - { - id: 102, - author: '시스템', - time: '30분 전', - value: '이영희님의 참가 신청이 거절되었습니다.', - parentId: null, - type: 'system', - status: 'rejected', - }, - - // ===== 일반 댓글 ===== - { - id: 1, - author: '승택', - time: '19시간 전', - value: '제발 저요!!!', - parentId: null, - type: 'user', - }, - - // ===== 대댓글 (id:1의 답글) ===== - { - id: 2, - author: '작성자', - time: '18시간 전', - value: '확인했어요! 잠시만 기다려주세요.', - parentId: 1, - type: 'user', - }, - { - id: 3, - author: '승택', - time: '17시간 전', - value: '감사합니다 🙏', - parentId: 1, - type: 'user', - }, - - // ===== 다른 일반 댓글 ===== - { - id: 4, - author: '홍길동', - time: '2시간 전', - value: '저도 가능할까요?', - parentId: null, - type: 'user', - }, - - // ===== 대댓글 (id:4의 답글) ===== - { - id: 5, - author: '작성자', - time: '1시간 전', - value: '네! 신청 남겨주세요.', - parentId: 4, - type: 'user', - }, -]; +import { useQuery } from '@tanstack/react-query'; +import { FEED_QUERY_OPTIONS } from '@shared/api/domain/feeds/query'; +import { useMutation } from '@tanstack/react-query'; +import { + PARTICIPATION_MUTATION_OPTIONS, + PARTICIPATION_QUERY_OPTIONS, +} from '@shared/api/domain/participations/query'; +import { getMyMemberId } from '@shared/utils/auth'; +import { COMMENT_QUERY_OPTIONS } from '@shared/api/domain/comments/query'; +import { queryClient } from '@app/providers/query-client'; +import { COMMENT_MUTATION_OPTIONS } from '@shared/api/domain/comments/query'; +import { useState } from 'react'; +import XIcon from '@shared/assets/icon/x.svg?react'; const handleShare = async () => { const url = window.location.href; @@ -95,26 +29,102 @@ const handleShare = async () => { if (navigator.share) { try { await navigator.share({ - title: '역삼동 공터에서 경도 할 사람 찾고 있어요!', - text: '같이 경도 하실 분 구해요!', + title: '공유하기', + text: '게시글을 확인해보세요!', url, }); - } catch (error) { + } catch { console.log('공유 취소'); } - } else { - // fallback은 아래에서 설명 } }; -const isOwner = false; -const isApplied = false; -const isClosed = false; - -const canApply = !isOwner && !isApplied && !isClosed; - const PostDetailPage = () => { const navigate = useNavigate(); + const { feedId } = useParams(); + const numericFeedId = Number(feedId); + const [comment, setComment] = useState(''); + const [parentId, setParentId] = useState(null); + const [replyTarget, setReplyTarget] = useState(null); + + const { mutate: applyParticipation } = useMutation({ + ...PARTICIPATION_MUTATION_OPTIONS.APPLY(), + onSuccess: () => { + console.log('✅ 참가 신청 성공'); + + queryClient.invalidateQueries({ + queryKey: ['participations', numericFeedId], + }); + + queryClient.invalidateQueries({ + queryKey: ['comments', numericFeedId], + }); + }, + + onError: (error) => { + console.error('❌ 참가 신청 실패:', error); + }, + }); + const { data: participants } = useQuery( + PARTICIPATION_QUERY_OPTIONS.LIST(numericFeedId), + ); + const { data: commentsData } = useQuery( + COMMENT_QUERY_OPTIONS.LIST(numericFeedId), + ); + const { mutate: approveParticipation } = useMutation({ + ...PARTICIPATION_MUTATION_OPTIONS.APPROVE(), + + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['participations', numericFeedId], + }); + + queryClient.invalidateQueries({ + queryKey: ['comments', numericFeedId], + }); + }, + }); + + const { mutate: rejectParticipation } = useMutation({ + ...PARTICIPATION_MUTATION_OPTIONS.REJECT(), + + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['participations', numericFeedId], + }); + + queryClient.invalidateQueries({ + queryKey: ['comments', numericFeedId], + }); + }, + }); + const { mutate: createComment } = useMutation({ + ...COMMENT_MUTATION_OPTIONS.CREATE(), + onSuccess: () => { + setComment(''); + setParentId(null); + setReplyTarget(null); + + queryClient.invalidateQueries({ + queryKey: ['comments', numericFeedId], + }); + }, + }); + + const { data, isLoading } = useQuery( + FEED_QUERY_OPTIONS.DETAIL(numericFeedId), + ); + if (isLoading) return
loading...
; + + if (!data) return
no data
; + + const myId = getMyMemberId(); + + const isOwner = data.writer?.writerId === myId; + const isApplied = participants?.some((p) => p.applicantId === myId) ?? false; + const isClosed = false; + + const canApply = !isOwner && !isApplied && !isClosed; return (
@@ -124,37 +134,97 @@ const PostDetailPage = () => { onLeftClick={() => navigate(-1)} onRightClick={handleShare} /> + + {/* 🔥 이미지 */} - - - + + + {/* 🔥 제목 */}

- 역삼동 공터에서 경도 할 사람 찾고 있어요!!(성인만) + {data.title}

-
- -

- { - '역삼동 공터에서 경도 하실 분 구합니다~~ 20세 이상 성인분들만 모집하고 있어요! 즐겁게 하실 분들만 신청해주셨으면 좋겠어요~' - } + + {/* 🔥 상세 정보 */} +

+ +

+ {data.description}

+
- + { + setParentId(commentId); + setReplyTarget(nickname ?? null); + }} + onChangeApproval={(participationId, status) => { + if (status === 'approved') { + approveParticipation(participationId); + } else { + rejectParticipation(participationId); + } + }} + />
+ {canApply && (
- -
- - } - /> -
+
)} +
+ {replyTarget && ( +
+ ↳{replyTarget}님에게 답글 작성중... + + +
+ )} + +
+ setComment(e.target.value)} + /> + + } + onClick={() => { + if (!comment.trim()) return; + + createComment({ + feedId: numericFeedId, + body: { + description: comment, + parentId: parentId ?? undefined, + }, + }); + }} + /> +
+
); }; diff --git a/src/shared/api/config/instance.ts b/src/shared/api/config/instance.ts index 2884239..776bc31 100644 --- a/src/shared/api/config/instance.ts +++ b/src/shared/api/config/instance.ts @@ -1,6 +1,15 @@ import ky from 'ky'; +import { + handleCheckAndSetToken, + handleUnauthorizedResponse, +} from './interceptor'; export const api = ky.create({ prefixUrl: import.meta.env.VITE_BASE_URL, + credentials: 'include', // 쿠키 retry: 0, + hooks: { + beforeRequest: [handleCheckAndSetToken], + afterResponse: [handleUnauthorizedResponse], + }, }); diff --git a/src/shared/api/config/interceptor.ts b/src/shared/api/config/interceptor.ts new file mode 100644 index 0000000..a4dfea0 --- /dev/null +++ b/src/shared/api/config/interceptor.ts @@ -0,0 +1,57 @@ +import { routePath } from '@app/router/path'; +import { api } from './instance'; + +let refreshPromise: Promise | null = null; + +/** + * 🔐 accessToken 자동 주입 + */ +export const handleCheckAndSetToken = (request: Request) => { + const token = localStorage.getItem('accessToken'); + + if (token) { + request.headers.set('Authorization', `Bearer ${token}`); + } +}; + +/** + * 🔁 401 → refresh 시도 + */ +export const handleUnauthorizedResponse = async ( + request: Request, + options: any, + response: Response, +) => { + if (response.status !== 401) return response; + + try { + if (!refreshPromise) { + refreshPromise = fetch(`${import.meta.env.VITE_BASE_URL}/token/refresh`, { + method: 'POST', + credentials: 'include', // 🔥 쿠키 자동 포함 + }) + .then(async (res) => { + if (!res.ok) throw new Error('refresh 실패'); + const data = await res.json(); + localStorage.setItem('accessToken', data.accessToken); + return data.accessToken; + }) + .finally(() => { + refreshPromise = null; + }); + } + + const newAccessToken = await refreshPromise; + + request.headers.set('Authorization', `Bearer ${newAccessToken}`); + + return api(request, options); + } catch { + logout(); + } +}; + +function logout() { + localStorage.removeItem('accessToken'); + window.location.href = routePath.ONBOARDING; +} diff --git a/src/shared/api/domain/comments/query.ts b/src/shared/api/domain/comments/query.ts new file mode 100644 index 0000000..9816f7b --- /dev/null +++ b/src/shared/api/domain/comments/query.ts @@ -0,0 +1,38 @@ +import { queryOptions, mutationOptions } from '@tanstack/react-query'; +import { api } from '@shared/api/config/instance'; +import { END_POINT } from '@shared/api/end-point'; +import type { + CreateCommentRequest, + GetCommentsResponse, +} from '@shared/types/comments/type'; + +const getComments = async (feedId: number) => { + return api.get(END_POINT.FEED.COMMENTS(feedId)).json(); +}; + +const createComment = async (feedId: number, body: CreateCommentRequest) => { + return api.post(END_POINT.FEED.COMMENTS(feedId), { + json: body, + }); +}; + +export const COMMENT_QUERY_OPTIONS = { + LIST: (feedId: number) => + queryOptions({ + queryKey: ['comments', feedId], + queryFn: () => getComments(feedId), + }), +}; + +export const COMMENT_MUTATION_OPTIONS = { + CREATE: () => + mutationOptions({ + mutationFn: ({ + feedId, + body, + }: { + feedId: number; + body: CreateCommentRequest; + }) => createComment(feedId, body), + }), +}; diff --git a/src/shared/api/domain/controller/query.ts b/src/shared/api/domain/controller/query.ts new file mode 100644 index 0000000..9a30509 --- /dev/null +++ b/src/shared/api/domain/controller/query.ts @@ -0,0 +1,14 @@ +import { api } from '@shared/api/config/instance'; +import { END_POINT } from '@shared/api/end-point'; +import type { GetPresignedUploadResponse } from '@shared/types/controller/type'; + +export const getPresignedUpload = async ( + type: string, + contentType: string, +): Promise => { + return api + .get(END_POINT.S3.PRESIGNED_UPLOAD, { + searchParams: { type, contentType }, + }) + .json(); +}; diff --git a/src/shared/api/domain/feeds/query.ts b/src/shared/api/domain/feeds/query.ts new file mode 100644 index 0000000..648e800 --- /dev/null +++ b/src/shared/api/domain/feeds/query.ts @@ -0,0 +1,55 @@ +import { mutationOptions, queryOptions } from '@tanstack/react-query'; +import { api } from '@shared/api/config/instance'; +import { END_POINT } from '@shared/api/end-point'; +import { FEED_QUERY_KEY } from '@shared/api/query-key'; +import type { + CreateFeedRequest, + GetFeedDetailResponse, + GetFeedsResponse, +} from '@shared/types/feeds/type'; + +interface GetFeedsParams extends Record { + sort?: 'LATEST' | 'DISTANCE'; + latitude?: number; + longitude?: number; +} + +const getFeeds = async (params?: GetFeedsParams): Promise => { + return api + .get(END_POINT.FEED.LIST, { + searchParams: params, + }) + .json(); +}; + +const getFeedDetail = async ( + feedId: number, +): Promise => { + return api.get(END_POINT.FEED.DETAIL(feedId)).json(); +}; + +const postFeed = async (body: CreateFeedRequest) => { + return api.post(END_POINT.FEED.LIST, { json: body }).json(); +}; + +export const FEED_QUERY_OPTIONS = { + LIST: (params?: GetFeedsParams) => + queryOptions({ + queryKey: FEED_QUERY_KEY.LIST(params), + queryFn: () => getFeeds(params), + }), + DETAIL: (feedId: number) => + queryOptions({ + queryKey: FEED_QUERY_KEY.DETAIL(feedId), + queryFn: () => getFeedDetail(feedId), + enabled: !!feedId, + staleTime: 0, + }), +}; + +export const FEED_MUTATION_OPTIONS = { + CREATE: () => + mutationOptions({ + mutationFn: postFeed, + }), +}; diff --git a/src/shared/api/domain/notifications/query.ts b/src/shared/api/domain/notifications/query.ts new file mode 100644 index 0000000..478525e --- /dev/null +++ b/src/shared/api/domain/notifications/query.ts @@ -0,0 +1,30 @@ +import { mutationOptions, queryOptions } from '@tanstack/react-query'; +import { api } from '@shared/api/config/instance'; +import { END_POINT } from '@shared/api/end-point'; +import { NOTIFICATION_QUERY_KEY } from '@shared/api/query-key'; +import type { GetNotificationsResponse } from '@shared/types/notifications/type'; + +const getNotifications = async (): Promise => { + return api + .get(END_POINT.NOTIFICATION.LIST) + .json(); +}; + +const deleteNotifications = async () => { + await api.delete(END_POINT.NOTIFICATION.LIST); +}; + +export const NOTIFICATION_QUERY_OPTIONS = { + LIST: () => + queryOptions({ + queryKey: NOTIFICATION_QUERY_KEY.LIST(), + queryFn: getNotifications, + }), +}; + +export const NOTIFICATION_MUTATION_OPTIONS = { + DELETE_ALL: () => + mutationOptions({ + mutationFn: deleteNotifications, + }), +}; diff --git a/src/shared/api/domain/participations/query.ts b/src/shared/api/domain/participations/query.ts new file mode 100644 index 0000000..81641f3 --- /dev/null +++ b/src/shared/api/domain/participations/query.ts @@ -0,0 +1,47 @@ +import { mutationOptions, queryOptions } from '@tanstack/react-query'; +import { api } from '@shared/api/config/instance'; +import { END_POINT } from '@shared/api/end-point'; +import type { GetParticipantsResponse } from '@shared/types/participations/type'; +const applyParticipation = async (feedId: number) => { + return api.post(END_POINT.FEED.PARTICIPATION(feedId)).json(); +}; +const approveParticipation = async (id: number) => { + return api.patch(END_POINT.PARTICIPATION.APPROVE(id)).json(); +}; + +const rejectParticipation = async (id: number) => { + return api.patch(END_POINT.PARTICIPATION.REJECT(id)).json(); +}; + +const getParticipants = async ( + feedId: number, +): Promise => { + return api + .get(END_POINT.FEED.PARTICIPATION(feedId)) + .json(); +}; + +export const PARTICIPATION_MUTATION_OPTIONS = { + APPLY: () => + mutationOptions({ + mutationFn: applyParticipation, + }), + + APPROVE: () => + mutationOptions({ + mutationFn: approveParticipation, + }), + + REJECT: () => + mutationOptions({ + mutationFn: rejectParticipation, + }), +}; + +export const PARTICIPATION_QUERY_OPTIONS = { + LIST: (feedId: number) => + queryOptions({ + queryKey: ['participations', feedId], + queryFn: () => getParticipants(feedId), + }), +}; diff --git a/src/shared/api/end-point.ts b/src/shared/api/end-point.ts new file mode 100644 index 0000000..aeed2ba --- /dev/null +++ b/src/shared/api/end-point.ts @@ -0,0 +1,20 @@ +export const END_POINT = { + FEED: { + LIST: 'api/feeds', + DETAIL: (feedId: number) => `api/feeds/${feedId}`, + PARTICIPATION: (feedId: number) => `api/feeds/${feedId}/participations`, + COMMENTS: (feedId: number) => `api/feeds/${feedId}/comments`, + CREATE: (feedId: number) => `api/feeds/${feedId}/comments`, + }, + PARTICIPATION: { + APPROVE: (id: number) => `api/participations/${id}/approve`, + REJECT: (id: number) => `api/participations/${id}/reject`, + }, + + S3: { + PRESIGNED_UPLOAD: 'api/s3/presigned-upload', + }, + NOTIFICATION: { + LIST: 'api/notifications', + }, +} as const; diff --git a/src/shared/api/query-key.ts b/src/shared/api/query-key.ts new file mode 100644 index 0000000..9803412 --- /dev/null +++ b/src/shared/api/query-key.ts @@ -0,0 +1,12 @@ +export const FEED_QUERY_KEY = { + LIST: (params?: { + sort?: 'LATEST' | 'DISTANCE'; + latitude?: number; + longitude?: number; + }) => ['feeds', params ?? {}] as const, + DETAIL: (feedId: number) => ['feed', feedId] as const, +}; + +export const NOTIFICATION_QUERY_KEY = { + LIST: () => ['notifications'] as const, +}; diff --git a/src/shared/assets/login.svg b/src/shared/assets/login.svg new file mode 100644 index 0000000..691c38e --- /dev/null +++ b/src/shared/assets/login.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/shared/lib/kakao-map/load-kakao-map.ts b/src/shared/lib/kakao-map/load-kakao-map.ts new file mode 100644 index 0000000..091b695 --- /dev/null +++ b/src/shared/lib/kakao-map/load-kakao-map.ts @@ -0,0 +1,56 @@ +let kakaoMapLoaderPromise: Promise | null = null; + +export function loadKakaoMap() { + if (typeof window === 'undefined') { + return Promise.reject(new Error('Window is not available.')); + } + + if (window.kakao?.maps) { + return new Promise((resolve) => { + window.kakao.maps.load(() => resolve(window.kakao)); + }); + } + + if (kakaoMapLoaderPromise) { + return kakaoMapLoaderPromise; + } + + const appKey = import.meta.env.VITE_KAKAO_MAP_JS_KEY; + + if (!appKey) { + return Promise.reject( + new Error('VITE_KAKAO_MAP_JS_KEY is not configured.'), + ); + } + + kakaoMapLoaderPromise = new Promise((resolve, reject) => { + const existingScript = document.querySelector( + 'script[data-kakao-map-sdk="true"]', + ); + + if (existingScript) { + existingScript.addEventListener('load', () => { + window.kakao.maps.load(() => resolve(window.kakao)); + }); + existingScript.addEventListener('error', () => { + reject(new Error('Failed to load Kakao Map SDK.')); + }); + return; + } + + const script = document.createElement('script'); + script.src = `https://dapi.kakao.com/v2/maps/sdk.js?autoload=false&libraries=services&appkey=${appKey}`; + script.async = true; + script.dataset.kakaoMapSdk = 'true'; + script.onload = () => { + window.kakao.maps.load(() => resolve(window.kakao)); + }; + script.onerror = () => { + reject(new Error('Failed to load Kakao Map SDK.')); + }; + + document.head.appendChild(script); + }); + + return kakaoMapLoaderPromise; +} diff --git a/src/shared/types/.gitkeep b/src/shared/types/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/shared/types/comments/type.ts b/src/shared/types/comments/type.ts new file mode 100644 index 0000000..6bad85c --- /dev/null +++ b/src/shared/types/comments/type.ts @@ -0,0 +1,7 @@ +import type { paths } from '@shared/types/schema'; + +export type GetCommentsResponse = + paths['/api/feeds/{feedId}/comments']['get']['responses']['200']['content']['*/*']; + +export type CreateCommentRequest = + paths['/api/feeds/{feedId}/comments']['post']['requestBody']['content']['application/json']; diff --git a/src/shared/types/controller/type.ts b/src/shared/types/controller/type.ts new file mode 100644 index 0000000..8791adc --- /dev/null +++ b/src/shared/types/controller/type.ts @@ -0,0 +1,4 @@ +import type { paths } from '@shared/types/schema'; + +export type GetPresignedUploadResponse = + paths['/api/s3/presigned-upload']['get']['responses']['200']['content']['*/*']; diff --git a/src/shared/types/feeds/type.ts b/src/shared/types/feeds/type.ts new file mode 100644 index 0000000..f62e05f --- /dev/null +++ b/src/shared/types/feeds/type.ts @@ -0,0 +1,10 @@ +import type { paths } from '@shared/types/schema'; + +export type GetFeedsResponse = + paths['/api/feeds']['get']['responses']['200']['content']['application/json']; + +export type CreateFeedRequest = + paths['/api/feeds']['post']['requestBody']['content']['application/json']; + +export type GetFeedDetailResponse = + paths['/api/feeds/{feedId}']['get']['responses']['200']['content']['application/json']; diff --git a/src/shared/types/notifications/type.ts b/src/shared/types/notifications/type.ts new file mode 100644 index 0000000..cf21249 --- /dev/null +++ b/src/shared/types/notifications/type.ts @@ -0,0 +1,5 @@ +import type { paths } from '@shared/types/schema'; + +export type GetNotificationsResponse = + paths['/api/notifications']['get']['responses']['200']['content']['*/*']; + diff --git a/src/shared/types/participations/type.ts b/src/shared/types/participations/type.ts new file mode 100644 index 0000000..ec0f8bc --- /dev/null +++ b/src/shared/types/participations/type.ts @@ -0,0 +1,4 @@ +import type { paths } from '@shared/types/schema'; + +export type GetParticipantsResponse = + paths['/api/feeds/{feedId}/participations']['get']['responses']['200']['content']['*/*']; diff --git a/src/shared/types/schema.ts b/src/shared/types/schema.ts new file mode 100644 index 0000000..638ff7a --- /dev/null +++ b/src/shared/types/schema.ts @@ -0,0 +1,951 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/api/feeds/{feedId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 피드 상세 조회 + * @description 피드 ID로 상세 정보를 조회합니다. + */ + get: operations["getFeed"]; + /** + * 피드 수정 + * @description 기존 피드를 수정합니다. + */ + put: operations["updateFeed"]; + post?: never; + /** + * 피드 삭제 + * @description 피드를 삭제합니다. + */ + delete: operations["deleteFeed"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/token/refresh": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["refresh"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/token/logout": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["logout"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/members/fcm-token": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["saveFcmToken"]; + delete: operations["deleteFcmToken"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/feeds": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 피드 목록 조회 + * @description 조건에 따라 피드 목록을 조회합니다. + */ + get: operations["getFeeds"]; + put?: never; + /** + * 피드 생성 + * @description 새로운 피드를 생성합니다. + */ + post: operations["createFeed"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/feeds/{feedId}/participations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 참가자 목록 조회 + * @description 특정 피드의 전체 참가자 목록을 조회합니다. + */ + get: operations["getParticipations"]; + put?: never; + /** + * 참가 신청 + * @description 특정 피드에 참가 신청을 합니다. + */ + post: operations["apply"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/feeds/{feedId}/comments": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 댓글 목록 조회 + * @description 특정 피드의 댓글 목록을 조회합니다. + */ + get: operations["getComments"]; + put?: never; + /** + * 댓글/대댓글 작성 + * @description 특정 피드에 댓글 또는 대댓글을 작성합니다. + */ + post: operations["createComment"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/participations/{id}/reject": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * 참가 거절 + * @description 참가 신청을 거절합니다. + */ + patch: operations["reject"]; + trace?: never; + }; + "/api/participations/{id}/approve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * 참가 승인 + * @description 참가 신청을 승인합니다. + */ + patch: operations["approve"]; + trace?: never; + }; + "/api/members/nickname": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch: operations["updateNickname"]; + trace?: never; + }; + "/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["health"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/s3/presigned-upload": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getPreSignedUploadUrl"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/notifications": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getMyNotifications"]; + put?: never; + post?: never; + delete: operations["deleteMyNotifications"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/me/participations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 내 경기 참가 기록 조회 (커서 기반 무한 스크롤) + * @description 로그인한 사용자의 경기 참가 기록을 조회합니다. + * + * - cursorTime, cursorId가 없으면 최초 조회 + * - cursorTime, cursorId가 있으면 다음 페이지 조회 + * - status 파라미터로 참가 상태별 필터링 가능 + * - 정렬 기준: createdAt DESC, id DESC + */ + get: operations["getMyParticipations"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/feeds/{feedId}/comments/{commentId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * 댓글 삭제 + * @description 댓글 ID를 기준으로 댓글을 삭제합니다. + */ + delete: operations["deleteComment"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + FeedUpdateRequest: { + title?: string; + image?: string; + description?: string; + /** Format: int32 */ + timer?: number; + playGround?: string; + /** Format: date-time */ + playDate?: string; + /** Format: int32 */ + round?: number; + address?: string; + /** Format: double */ + latitude?: number; + /** Format: double */ + longitude?: number; + allNull?: boolean; + }; + FcmTokenRequest: { + fcmToken: string; + }; + FeedCreateRequest: { + image?: string; + title: string; + playGround: string; + /** Format: date-time */ + playDate: string; + /** Format: int32 */ + round?: number; + /** Format: int32 */ + playCount?: number; + description?: string; + /** Format: int32 */ + timer?: number; + address?: string; + /** Format: double */ + latitude?: number; + /** Format: double */ + longitude?: number; + }; + CommentCreateRequest: { + description: string; + /** Format: int64 */ + parentId?: number; + }; + NicknameUpdateRequest: { + nickname: string; + }; + NotificationResponse: { + /** Format: int64 */ + notificationId?: number; + message?: string; + read?: boolean; + /** Format: date-time */ + createdAt?: string; + /** @enum {string} */ + targetType?: "PARTICIPATION_APPROVED" | "PARTICIPATION_REJECTED" | "GAME_STARTED" | "GAME_FINISHED"; + /** Format: int64 */ + targetId?: number; + }; + MyParticipationResponse: { + /** Format: int64 */ + participationId?: number; + /** Format: int64 */ + feedId?: number; + feedTitle?: string; + playGround?: string; + /** Format: date-time */ + playDate?: string; + /** @enum {string} */ + status?: "PENDING" | "APPROVED" | "REJECTED" | "CANCELED"; + }; + FeedCursorResponse: { + feeds?: components["schemas"]["FeedResponse"][]; + /** Format: int64 */ + nextCursorId?: number; + /** Format: date-time */ + nextCursorCreatedAt?: string; + hasNext?: boolean; + }; + FeedResponse: { + /** Format: int64 */ + feedId?: number; + title?: string; + image?: string; + playGround?: string; + /** Format: date-time */ + playDate?: string; + /** Format: int32 */ + playCount?: number; + /** Format: date-time */ + createdAt?: string; + }; + FeedDetailResponse: { + /** Format: int64 */ + feedId?: number; + image?: string; + title?: string; + playGround?: string; + /** Format: date-time */ + playDate?: string; + /** Format: int32 */ + round?: number; + /** Format: int32 */ + playCount?: number; + description?: string; + /** Format: int32 */ + timer?: number; + writer?: components["schemas"]["WriterResponse"]; + }; + WriterResponse: { + /** Format: int64 */ + writerId?: number; + nickname?: string; + }; + ParticipationResponse: { + /** Format: int64 */ + participationId?: number; + /** Format: int64 */ + applicantId?: number; + applicantNickname?: string; + /** @enum {string} */ + status?: "PENDING" | "APPROVED" | "REJECTED" | "CANCELED"; + /** Format: date-time */ + appliedAt?: string; + }; + CommentResponse: { + /** Format: int64 */ + commentId?: number; + description?: string; + /** Format: int64 */ + parentId?: number; + /** Format: int32 */ + depth?: number; + commentType?: string; + /** Format: int64 */ + memberId?: number; + nickname?: string; + /** Format: date-time */ + createdAt?: string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + getFeed: { + parameters: { + query?: never; + header?: never; + path: { + /** @description 피드 ID */ + feedId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 피드 상세 조회 성공 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FeedDetailResponse"]; + }; + }; + }; + }; + updateFeed: { + parameters: { + query?: never; + header?: never; + path: { + /** @description 피드 ID */ + feedId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["FeedUpdateRequest"]; + }; + }; + responses: { + /** @description 피드 수정 성공 */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + deleteFeed: { + parameters: { + query?: never; + header?: never; + path: { + /** @description 피드 ID */ + feedId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 피드 삭제 성공 */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + refresh: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": Record; + }; + }; + }; + }; + logout: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + saveFcmToken: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["FcmTokenRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + deleteFcmToken: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + getFeeds: { + parameters: { + query?: { + /** @description 커서 ID */ + cursorId?: number; + /** @description 커서 생성 시간 */ + cursorCreatedAt?: string; + /** @description 정렬 기준 (LATEST / DISTANCE) */ + sort?: string; + /** @description 위도 */ + latitude?: number; + /** @description 경도 */ + longitude?: number; + /** @description 조회 개수 */ + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 피드 목록 조회 성공 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FeedCursorResponse"]; + }; + }; + }; + }; + createFeed: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["FeedCreateRequest"]; + }; + }; + responses: { + /** @description 피드 생성 성공 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": number; + }; + }; + }; + }; + getParticipations: { + parameters: { + query?: never; + header?: never; + path: { + /** @description 피드 ID */ + feedId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 참가자 목록 조회 성공 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ParticipationResponse"][]; + }; + }; + }; + }; + apply: { + parameters: { + query?: never; + header?: never; + path: { + /** @description 피드 ID */ + feedId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 참가 신청 성공 */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + getComments: { + parameters: { + query?: never; + header?: never; + path: { + /** @description 피드 ID */ + feedId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 댓글 목록 조회 성공 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["CommentResponse"][]; + }; + }; + }; + }; + createComment: { + parameters: { + query?: never; + header?: never; + path: { + /** @description 피드 ID */ + feedId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CommentCreateRequest"]; + }; + }; + responses: { + /** @description 댓글 작성 성공 */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + reject: { + parameters: { + query?: never; + header?: never; + path: { + /** @description 참가 ID */ + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 참가 거절 성공 */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + approve: { + parameters: { + query?: never; + header?: never; + path: { + /** @description 참가 ID */ + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 참가 승인 성공 */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + updateNickname: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NicknameUpdateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + health: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; + getPreSignedUploadUrl: { + parameters: { + query: { + type: string; + contentType: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": { + [key: string]: string; + }; + }; + }; + }; + }; + getMyNotifications: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["NotificationResponse"][]; + }; + }; + }; + }; + deleteMyNotifications: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + getMyParticipations: { + parameters: { + query?: { + /** @description 참가 상태 (PENDING, APPROVED, REJECTED) */ + status?: string; + /** @description 커서 기준 시간 (마지막 요소의 createdAt) */ + cursorTime?: string; + /** @description 커서 기준 ID (마지막 요소의 id) */ + cursorId?: number; + /** @description 조회 개수 (기본값 10) */ + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 내 경기 참가 기록 조회 성공 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["MyParticipationResponse"][]; + }; + }; + }; + }; + deleteComment: { + parameters: { + query?: never; + header?: never; + path: { + /** @description 댓글 ID */ + commentId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 댓글 삭제 성공 */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; +} diff --git a/src/shared/utils/auth.ts b/src/shared/utils/auth.ts new file mode 100644 index 0000000..98c4882 --- /dev/null +++ b/src/shared/utils/auth.ts @@ -0,0 +1,9 @@ +import { jwtDecode } from 'jwt-decode'; + +export const getMyMemberId = () => { + const token = localStorage.getItem('accessToken'); + if (!token) return null; + + const decoded: any = jwtDecode(token); + return decoded.memberId; +}; diff --git a/src/shared/utils/date.ts b/src/shared/utils/date.ts index 049553b..9def209 100644 --- a/src/shared/utils/date.ts +++ b/src/shared/utils/date.ts @@ -15,3 +15,35 @@ export function generateFutureDates(days = 30) { return result; } + +export const formatDate = (date: string) => + new Date(date).toLocaleString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: true, + }); + +export const formatRelativeTime = (date: string) => { + const diffMs = new Date(date).getTime() - Date.now(); + const rtf = new Intl.RelativeTimeFormat('ko', { numeric: 'auto' }); + const diffMinutes = Math.round(diffMs / (1000 * 60)); + + if (Math.abs(diffMinutes) < 60) { + return rtf.format(diffMinutes, 'minute'); + } + + const diffHours = Math.round(diffMinutes / 60); + if (Math.abs(diffHours) < 24) { + return rtf.format(diffHours, 'hour'); + } + + const diffDays = Math.round(diffHours / 24); + if (Math.abs(diffDays) < 7) { + return rtf.format(diffDays, 'day'); + } + + return formatDate(date); +}; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 5be55a6..e28cfdd 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,5 +1,19 @@ /// +interface ImportMetaEnv { + readonly VITE_BASE_URL: string; + readonly VITE_KAKAO_LOGIN_URL: string; + readonly VITE_KAKAO_MAP_JS_KEY: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} + +interface Window { + kakao?: any; +} + declare module '*.svg?react' { import * as React from 'react'; const ReactComponent: React.FC>; diff --git a/src/widgets/create/add-image.tsx b/src/widgets/create/add-image.tsx index 47eff17..76a30d3 100644 --- a/src/widgets/create/add-image.tsx +++ b/src/widgets/create/add-image.tsx @@ -5,9 +5,10 @@ import XIcon from '@shared/assets/icon/x.svg?react'; interface AddImageProps { max?: number; // 최대 업로드 개수 (기본 5) disabled?: boolean; + onChange?: (file: File) => void; } -export function AddImage({ max = 5, disabled }: AddImageProps) { +export function AddImage({ max = 5, disabled, onChange }: AddImageProps) { const [images, setImages] = useState([]); const handleFileChange = (e: React.ChangeEvent) => { @@ -18,6 +19,9 @@ export function AddImage({ max = 5, disabled }: AddImageProps) { const url = URL.createObjectURL(file); setImages((prev) => [...prev, url]); + + onChange?.(file); // 🔥 부모로 file 전달 + e.target.value = ''; }; diff --git a/src/widgets/create/modal/contents/modal-location-search.tsx b/src/widgets/create/modal/contents/modal-location-search.tsx index 9f16c86..fb663bb 100644 --- a/src/widgets/create/modal/contents/modal-location-search.tsx +++ b/src/widgets/create/modal/contents/modal-location-search.tsx @@ -1,10 +1,9 @@ -import { FloatingActionButton } from '@shared/ui/floatingActionButton'; -import Input from '@shared/ui/input'; -import LocationIcon from '@shared/assets/icon/material-symbols_my-location-outline-rounded.svg?react'; +import { LocationPicker } from '@features/location-picker/ui/location-picker'; +import type { LocationSelection } from '@features/location-picker/types'; interface ModalLocationSearchProps { - value: string; - onChange: (value: string) => void; + value: LocationSelection | null; + onChange: (value: LocationSelection) => void; } export function ModalLocationSearch({ @@ -12,17 +11,14 @@ export function ModalLocationSearch({ onChange, }: ModalLocationSearchProps) { return ( -
- onChange(e.target.value)} - placeholder="예) 역삼동" - /> - } - /> -
+ ); } diff --git a/src/widgets/main/bottom-sheet/bottom-sheet.tsx b/src/widgets/main/bottom-sheet/bottom-sheet.tsx index 644368f..c59a874 100644 --- a/src/widgets/main/bottom-sheet/bottom-sheet.tsx +++ b/src/widgets/main/bottom-sheet/bottom-sheet.tsx @@ -2,7 +2,7 @@ import { BottomSheetContext, useBottomSheetContext, } from '@shared/hooks/use-bottom-sheet-context'; -import type { ReactNode } from 'react'; +import { useEffect, type ReactNode } from 'react'; import { cva } from 'class-variance-authority'; import { cn } from '@shared/utils/cn'; @@ -18,11 +18,25 @@ function Overlay() { return
; } +function BodyScrollLock() { + useEffect(() => { + const originalStyle = window.getComputedStyle(document.body).overflow; + document.body.style.overflow = 'hidden'; + + return () => { + document.body.style.overflow = originalStyle; + }; + }, []); + + return null; +} + function Root({ isOpen, onClose, children }: BottomSheetProps) { if (!isOpen) return null; return ( +
{children}
); diff --git a/src/widgets/main/bottom-sheet/contents/bottom-sheet-location-search.tsx b/src/widgets/main/bottom-sheet/contents/bottom-sheet-location-search.tsx index e089769..399865a 100644 --- a/src/widgets/main/bottom-sheet/contents/bottom-sheet-location-search.tsx +++ b/src/widgets/main/bottom-sheet/contents/bottom-sheet-location-search.tsx @@ -1,28 +1,14 @@ -import { FloatingActionButton } from '@shared/ui/floatingActionButton'; -import Input from '@shared/ui/input'; -import LocationIcon from '@shared/assets/icon/material-symbols_my-location-outline-rounded.svg?react'; +import { LocationPicker } from '@features/location-picker/ui/location-picker'; +import type { LocationSelection } from '@features/location-picker/types'; interface BottomSheetLocationSearchProps { - value: string; - onChange: (value: string) => void; + value: LocationSelection | null; + onChange: (value: LocationSelection) => void; } export function BottomSheetLocationSearch({ value, onChange, }: BottomSheetLocationSearchProps) { - return ( -
- onChange(e.target.value)} - placeholder="동을 입력해주세요. 예) 역삼동" - /> - } - /> -
- ); + return ; } diff --git a/src/widgets/main/card/card.tsx b/src/widgets/main/card/card.tsx index 808c87a..1d8d704 100644 --- a/src/widgets/main/card/card.tsx +++ b/src/widgets/main/card/card.tsx @@ -23,8 +23,12 @@ export function Card({ onClick={onClick} className="flex flex-low gap-[1.6rem] w-[32.7rem] h-[14.8rem] pb-[2rem] border-b border-gray-100" > -
- +
+ {title}
diff --git a/src/widgets/main/notification/notification-card.tsx b/src/widgets/main/notification/notification-card.tsx index 8f9ade6..30f2942 100644 --- a/src/widgets/main/notification/notification-card.tsx +++ b/src/widgets/main/notification/notification-card.tsx @@ -1,5 +1,6 @@ import ChevronRightIcon from '@shared/assets/icon/chevron-right.svg?react'; export interface NotificationCardProps { + id: number; value: string; time: string; } diff --git a/src/widgets/main/notification/notification-popover.tsx b/src/widgets/main/notification/notification-popover.tsx new file mode 100644 index 0000000..7c11862 --- /dev/null +++ b/src/widgets/main/notification/notification-popover.tsx @@ -0,0 +1,60 @@ +import { + NOTIFICATION_MUTATION_OPTIONS, + NOTIFICATION_QUERY_OPTIONS, +} from '@shared/api/domain/notifications/query'; +import { formatRelativeTime } from '@shared/utils/date'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { NotificationPanel } from '@widgets/main/notification/notificationPanel'; + +interface NotificationPopoverProps { + isOpen: boolean; + onClose: () => void; +} + +export function NotificationPopover({ + isOpen, + onClose, +}: NotificationPopoverProps) { + const queryClient = useQueryClient(); + const { data: notifications = [] } = useQuery({ + ...NOTIFICATION_QUERY_OPTIONS.LIST(), + enabled: isOpen, + }); + const { mutate: deleteAllNotifications, isPending } = useMutation({ + ...NOTIFICATION_MUTATION_OPTIONS.DELETE_ALL(), + onSuccess: () => { + queryClient.setQueryData( + NOTIFICATION_QUERY_OPTIONS.LIST().queryKey, + [], + ); + }, + }); + + if (!isOpen) { + return null; + } + + const mappedNotifications = notifications.map((notification) => ({ + id: notification.notificationId ?? 0, + value: notification.message ?? '', + time: notification.createdAt + ? formatRelativeTime(notification.createdAt) + : '', + })); + + return ( + <> +
+
e.stopPropagation()} + > + deleteAllNotifications()} + /> +
+ + ); +} diff --git a/src/widgets/main/notification/notificationPanel.tsx b/src/widgets/main/notification/notificationPanel.tsx index 263dd69..79ea9f2 100644 --- a/src/widgets/main/notification/notificationPanel.tsx +++ b/src/widgets/main/notification/notificationPanel.tsx @@ -5,23 +5,44 @@ import { interface NotificationPanelProps { notifications: NotificationCardProps[]; + isDeleting?: boolean; + onDeleteAll?: () => void; } -export function NotificationPanel({ notifications }: NotificationPanelProps) { +export function NotificationPanel({ + notifications, + isDeleting = false, + onDeleteAll, +}: NotificationPanelProps) { return (
알림 내역 -
- {notifications.map((item, index) => ( - - ))} + {notifications.length === 0 ? ( +
+ 도착한 알림이 없어요. +
+ ) : ( + notifications.map((item) => ( + + )) + )}
); diff --git a/src/widgets/postDetail/comment/comment-item.tsx b/src/widgets/postDetail/comment/comment-item.tsx index 2d093bd..43b0fa1 100644 --- a/src/widgets/postDetail/comment/comment-item.tsx +++ b/src/widgets/postDetail/comment/comment-item.tsx @@ -1,69 +1,95 @@ import MessageIcon from '@shared/assets/icon/message-square.svg?react'; import { Chip, type ApprovalStatus } from '@widgets/postDetail/chip/chip'; + export interface CommentItemProps { - id: number; - author: string; - time: string; - value: string; + commentId?: number; + nickname?: string; + description?: string; parentId?: number | null; - type?: 'user' | 'system'; + commentType?: string; + depth?: number; + memberId?: number; + participationId?: number; + createdAt?: string; + children?: CommentItemProps[]; +} - status?: ApprovalStatus; - applicantId?: number; +export interface Participant { + participationId?: number; + status?: 'PENDING' | 'APPROVED' | 'REJECTED' | 'CANCELED'; } interface CommentItemUIProps extends CommentItemProps { isOwner?: boolean; + participants?: Participant[]; + onReply?: (commentId: number, nickname?: string) => void; onChangeApproval?: ( - commentId: number, + participationId: number, status: Exclude, ) => void; } export function CommentItem({ - author, - time, - value, - type = 'user', - status = 'pending', + nickname, + description, + commentType = 'USER', isOwner = false, + depth, onChangeApproval, - id, + onReply, + commentId, + children, + participationId, + participants, }: CommentItemUIProps) { - const isSystem = type === 'system'; + const isSystem = commentType !== 'USER'; + + const participation = participants?.find( + (p) => p.participationId === participationId, + ); + + let computedStatus: ApprovalStatus = 'pending'; + + if (participation?.status === 'APPROVED') computedStatus = 'approved'; + if (participation?.status === 'REJECTED') computedStatus = 'rejected'; return (
- {/* 상단 라인: 작성자/시간 + (system이면 chip 우측) */}
- {author} - {time} + {nickname}
{isSystem && (isOwner ? ( onChangeApproval?.(id, next)} + status={computedStatus} + onChange={(next) => { + if (!participationId) return; + + console.log('🔥 participationId:', participationId); + onChangeApproval?.(participationId, next); + }} /> ) : ( - + ))}
- {/* 본문 */}
-

{value}

+

{description}

- {!isSystem && ( - )}
diff --git a/src/widgets/postDetail/comment/comment.tsx b/src/widgets/postDetail/comment/comment.tsx index c873bf1..de9bfb2 100644 --- a/src/widgets/postDetail/comment/comment.tsx +++ b/src/widgets/postDetail/comment/comment.tsx @@ -1,40 +1,75 @@ +import type { ApprovalStatus } from '@widgets/postDetail/chip/chip'; import { CommentItem, type CommentItemProps, + type Participant, } from '@widgets/postDetail/comment/comment-item'; interface CommentProps { comments: CommentItemProps[]; + participants?: Participant[]; + isOwner?: boolean; + onReply?: (commentId: number, nickname?: string) => void; // 추가 + onChangeApproval?: ( + participationId: number, + status: Exclude, + ) => void; } -export function Comment({ comments }: CommentProps) { +export function Comment({ + comments, + participants, + isOwner, + onReply, + onChangeApproval, +}: CommentProps) { const systemRoot = comments.filter( - (c) => c.parentId === null && c.type === 'system', + (c) => c.parentId === null && c.commentType === 'APPLY', ); const userRoot = comments.filter( - (c) => c.parentId === null && c.type !== 'system', + (c) => c.parentId === null && c.commentType !== 'APPLY', ); return (
댓글 {comments.length} +
{systemRoot.map((comment) => ( - + ))} {userRoot.map((comment) => ( -
- +
+ -
- {comments - .filter((c) => c.parentId === comment.id) - .map((reply) => ( - + {comment.children && comment.children.length > 0 && ( +
+ {comment.children.map((reply) => ( + ))} -
+
+ )}
))}
diff --git a/src/widgets/postDetail/detail-info.tsx b/src/widgets/postDetail/detail-info.tsx index 62f0900..42e09b3 100644 --- a/src/widgets/postDetail/detail-info.tsx +++ b/src/widgets/postDetail/detail-info.tsx @@ -1,21 +1,37 @@ import { CardInfo } from '@widgets/main/card/card-info'; +import { formatDate } from '@shared/utils/date'; -export function DetailInfo({}) { +interface DetailInfoProps { + playDate?: string; + playCount?: number; + location?: string; + writerNickname?: string; +} + +export function DetailInfo({ + playDate, + playCount, + location, + writerNickname, +}: DetailInfoProps) { return ( -
+
- {'게시글 작성자'} - {'게시글 시간'} + {writerNickname} + + {playDate ? formatDate(playDate) : ''} +
+