diff --git a/index.html b/index.html index 67a379fc..83ddd2a5 100644 --- a/index.html +++ b/index.html @@ -36,6 +36,7 @@
+ diff --git a/package-lock.json b/package-lock.json index 43e31372..39b597af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,9 +17,12 @@ "dotenv": "^16.4.0", "embla-carousel-autoplay": "^8.0.1", "embla-carousel-react": "^8.0.1", + "file-saver": "^2.0.5", "framer-motion": "^11.0.2", + "jszip": "^3.10.1", "qs": "^6.12.1", "react": "^18.2.0", + "react-beautiful-dnd": "^13.1.1", "react-datepicker": "^4.21.0", "react-dom": "^18.2.0", "react-hook-form": "^7.49.3", @@ -39,6 +42,7 @@ "@types/node": "^20.11.6", "@types/qs": "^6.9.15", "@types/react": "^18.2.15", + "@types/react-beautiful-dnd": "^13.1.8", "@types/react-datepicker": "^4.19.3", "@types/react-dom": "^18.2.7", "@typescript-eslint/eslint-plugin": "^6.10.0", @@ -2190,6 +2194,15 @@ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==" }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz", + "integrity": "sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==", + "dependencies": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -2280,6 +2293,15 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-beautiful-dnd": { + "version": "13.1.8", + "resolved": "https://registry.npmjs.org/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.1.8.tgz", + "integrity": "sha512-E3TyFsro9pQuK4r8S/OL6G99eq7p8v29sX0PM7oT8Z+PJfZvSQTx4zTQbUJ+QZXioAF0e7TGBEcA1XhYhCweyQ==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-datepicker": { "version": "4.19.3", "resolved": "https://registry.npmjs.org/@types/react-datepicker/-/react-datepicker-4.19.3.tgz", @@ -2317,6 +2339,17 @@ "@types/react": "*" } }, + "node_modules/@types/react-redux": { + "version": "7.1.33", + "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.33.tgz", + "integrity": "sha512-NF8m5AjWCkert+fosDsN3hAlHzpjSiXlVy9EgQEmLoBhaNXbmyeGs/aj5dQzKuF+/q+S7JQagorGDW8pJ28Hmg==", + "dependencies": { + "@types/hoist-non-react-statics": "^3.3.0", + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0", + "redux": "^4.0.0" + } + }, "node_modules/@types/react-transition-group": { "version": "4.4.10", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", @@ -3410,10 +3443,9 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" }, "node_modules/core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", - "dev": true + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" }, "node_modules/cosmiconfig": { "version": "8.3.6", @@ -3454,6 +3486,14 @@ "node": ">= 8" } }, + "node_modules/css-box-model": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", + "integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==", + "dependencies": { + "tiny-invariant": "^1.0.6" + } + }, "node_modules/css-color-keywords": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", @@ -4651,6 +4691,11 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -5138,7 +5183,6 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "devOptional": true, "dependencies": { "react-is": "^16.7.0" } @@ -5195,6 +5239,11 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -5241,8 +5290,7 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ini": { "version": "2.0.0", @@ -5954,6 +6002,17 @@ "node": ">=4.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5985,6 +6044,14 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -6154,6 +6221,11 @@ "node": ">=10" } }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -6531,6 +6603,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, "node_modules/parchment": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz", @@ -6763,6 +6840,11 @@ "node": ">= 0.6.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -6874,6 +6956,11 @@ "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz", "integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==" }, + "node_modules/raf-schd": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", + "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==" + }, "node_modules/react": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", @@ -6885,6 +6972,24 @@ "node": ">=0.10.0" } }, + "node_modules/react-beautiful-dnd": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.1.1.tgz", + "integrity": "sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ==", + "dependencies": { + "@babel/runtime": "^7.9.2", + "css-box-model": "^1.2.0", + "memoize-one": "^5.1.1", + "raf-schd": "^4.0.2", + "react-redux": "^7.2.0", + "redux": "^4.0.4", + "use-memo-one": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8.5 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.5 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-datepicker": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-4.21.0.tgz", @@ -7004,6 +7109,35 @@ "react-dom": "^16 || ^17 || ^18" } }, + "node_modules/react-redux": { + "version": "7.2.9", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz", + "integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==", + "dependencies": { + "@babel/runtime": "^7.15.4", + "@types/react-redux": "^7.1.20", + "hoist-non-react-statics": "^3.3.2", + "loose-envify": "^1.4.0", + "prop-types": "^15.7.2", + "react-is": "^17.0.2" + }, + "peerDependencies": { + "react": "^16.8.3 || ^17 || ^18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/react-redux/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + }, "node_modules/react-router": { "version": "6.18.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.18.0.tgz", @@ -7058,6 +7192,30 @@ "react-dom": ">=16.6.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, "node_modules/recoil": { "version": "0.7.7", "resolved": "https://registry.npmjs.org/recoil/-/recoil-0.7.7.tgz", @@ -7085,6 +7243,14 @@ "recoil": "^0.7.2" } }, + "node_modules/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz", @@ -7495,6 +7661,11 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" + }, "node_modules/shallowequal": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", @@ -7639,6 +7810,19 @@ "node": ">=8" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -7852,6 +8036,11 @@ "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", "dev": true }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" + }, "node_modules/titleize": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/titleize/-/titleize-3.0.0.tgz", @@ -8158,6 +8347,19 @@ "react": ">=16.13" } }, + "node_modules/use-memo-one": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.3.tgz", + "integrity": "sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.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==" + }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -8181,6 +8383,12 @@ "extsprintf": "^1.2.0" } }, + "node_modules/verror/node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true + }, "node_modules/vite": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.0.tgz", diff --git a/package.json b/package.json index 17605b4d..d5a32752 100644 --- a/package.json +++ b/package.json @@ -20,9 +20,12 @@ "dotenv": "^16.4.0", "embla-carousel-autoplay": "^8.0.1", "embla-carousel-react": "^8.0.1", + "file-saver": "^2.0.5", "framer-motion": "^11.0.2", + "jszip": "^3.10.1", "qs": "^6.12.1", "react": "^18.2.0", + "react-beautiful-dnd": "^13.1.1", "react-datepicker": "^4.21.0", "react-dom": "^18.2.0", "react-hook-form": "^7.49.3", @@ -42,6 +45,7 @@ "@types/node": "^20.11.6", "@types/qs": "^6.9.15", "@types/react": "^18.2.15", + "@types/react-beautiful-dnd": "^13.1.8", "@types/react-datepicker": "^4.19.3", "@types/react-dom": "^18.2.7", "@typescript-eslint/eslint-plugin": "^6.10.0", diff --git a/src/assets/HamburgerMenuIcon.svg b/src/assets/HamburgerMenuIcon.svg new file mode 100644 index 00000000..c72b3272 --- /dev/null +++ b/src/assets/HamburgerMenuIcon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/assets/Pencil.svg b/src/assets/Pencil.svg new file mode 100644 index 00000000..48ad1227 --- /dev/null +++ b/src/assets/Pencil.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/assets/Refresh.svg b/src/assets/Refresh.svg new file mode 100644 index 00000000..9f20dcb9 --- /dev/null +++ b/src/assets/Refresh.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/assets/index.ts b/src/assets/index.ts index 8b84cb25..3a0dae78 100644 --- a/src/assets/index.ts +++ b/src/assets/index.ts @@ -36,6 +36,9 @@ import Test1 from './Test1.png'; import Test2 from './Test2.png'; import Test3 from './Test3.png'; import Test4 from './Test4.png'; +import Refresh from './Refresh.svg'; +import HambergerMenuIcon from './HamburgerMenuIcon.svg'; +import Pencil from './Pencil.svg'; import FilledBookmark from './FilledBookmark.svg'; import UnfilledBookmark from './UnfilledBookmark.svg'; import Edit from './Edit.svg'; @@ -98,6 +101,9 @@ export { Test2, Test3, Test4, + Refresh, + HambergerMenuIcon, + Pencil, DATE_ICON, Clear, BookmarkRight, diff --git a/src/atom.tsx b/src/atom.tsx index 737940ca..cda68aa5 100644 --- a/src/atom.tsx +++ b/src/atom.tsx @@ -1,6 +1,6 @@ import { atom } from 'recoil'; import { SessionStorageEffect, simpleDate } from './utils'; -import { User, InputState, ApplyRole, RecruitFilter, DetailedFilter } from './types'; +import { User, InputState, ApplyRole, RecruitFilter, DetailedFilter, Image } from './types'; import { LocalStorageEffect } from './utils'; import { Account } from './pages'; @@ -186,9 +186,14 @@ export const applicantFilter = atom({ default: null as number | null, }); -export const imageNameState = atom({ - key: 'imageNameState', - default: '', +export const uploadImageListState = atom({ + key: 'uploadImageListState', + default: [], +}); + +export const uploadImageState = atom({ + key: 'uploadImageState', + default: null, }); export const openChatModalState = atom({ diff --git a/src/components/button/Button.styled.ts b/src/components/button/Button.styled.ts index 7a2c964d..ed17401c 100644 --- a/src/components/button/Button.styled.ts +++ b/src/components/button/Button.styled.ts @@ -30,6 +30,7 @@ const DefaultButtonLayout = styled.button` padding: ${props => (props.$small ? '1.2rem 2rem' : '1.2rem 3.2rem')}; justify-content: center; align-items: center; + column-gap: 0.75rem; border: 1px solid var(--ButtonColors-Default-outline-defaultLine, #e3e3e3); border-radius: 0.6rem; color: var(--text-color-2, #373f41); @@ -90,7 +91,7 @@ const FormButtonLayout = styled.button` flex-direction: row; column-gap: 0.8rem; width: 100%; - padding: 1.7rem 0; + padding-bottom: 2.7rem; align-items: center; color: var(--Text-textColor2, var(--text-color-2, #373f41)); cursor: pointer; diff --git a/src/components/button/DefaultBtn.tsx b/src/components/button/DefaultBtn.tsx index 6ba80f05..43c3e8db 100644 --- a/src/components/button/DefaultBtn.tsx +++ b/src/components/button/DefaultBtn.tsx @@ -4,12 +4,13 @@ import S from './Button.styled'; interface Button { type: 'button' | 'submit'; title: string; + icon?: string; disabled?: boolean; small?: boolean; handleClick?: React.MouseEventHandler; } -const DefaultBtn = ({ type, title, disabled, small, handleClick }: Button) => { +const DefaultBtn = ({ type, title, icon, disabled, small, handleClick }: Button) => { return ( { $small={small} onClick={handleClick} > + {icon && 아이콘} {title} ); diff --git a/src/components/button/PrimaryBtn.tsx b/src/components/button/PrimaryBtn.tsx index 545f986e..d7c65760 100644 --- a/src/components/button/PrimaryBtn.tsx +++ b/src/components/button/PrimaryBtn.tsx @@ -4,12 +4,13 @@ import S from './Button.styled'; interface Button { type: 'button' | 'submit'; title: string; + icon?: string; disabled?: boolean; small?: boolean; handleClick?: React.MouseEventHandler; } -const PrimaryBtn = ({ type, title, disabled, small, handleClick }: Button) => { +const PrimaryBtn = ({ type, title, icon, disabled, small, handleClick }: Button) => { return ( { $small={small} onClick={handleClick} > + {icon && 아이콘} {title} ); diff --git a/src/components/comboBox/ComboBox.tsx b/src/components/comboBox/ComboBox.tsx index f56fe2e6..7fe90c80 100644 --- a/src/components/comboBox/ComboBox.tsx +++ b/src/components/comboBox/ComboBox.tsx @@ -60,23 +60,15 @@ const ComboBox = ({ const inputValue = getValues?.(name as Path); if (!optionList?.find(option => option.name === inputValue)) { setValue(name as Path, '' as PathValue>); - sessionStorage.removeItem(name); } }; - useEffect(() => { - const inputValue = getValues?.(name as Path); - const defaultOptionId = optionList?.find(option => option.name === inputValue)?.id; - defaultOptionId && sessionStorage.setItem(name, defaultOptionId); - }, [optionList]); - useEffect(() => { const handleOutsideClick = (e: MouseEvent) => { clearInput(); const target = e.target as HTMLDivElement; if (isOpen && inputRef.current && !inputRef.current.contains(target)) { setIsOpen(false); - // clearInput(); } }; @@ -89,17 +81,14 @@ const ComboBox = ({ setIsOpen(true); }; - const handleOptionClick = (name: Path, optionName: PathValue>, id?: string) => { - setValue(name, optionName, { shouldDirty: true }); - id && sessionStorage.setItem(name, id); + const handleOptionClick = (name: Path, optionName: PathValue>) => { + setValue(name, optionName); clickOption?.(name); - // setFocus?.(name); }; const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Enter') { downKey?.(name); - // setIsOpen(true); } }; diff --git a/src/components/goBack/GoBack.tsx b/src/components/goBack/GoBack.tsx index 4484fe28..116e67ed 100644 --- a/src/components/goBack/GoBack.tsx +++ b/src/components/goBack/GoBack.tsx @@ -12,7 +12,6 @@ const GoBack = ({ const navigate = useNavigate(); const handleClick = () => { - window.sessionStorage.removeItem('contentState'); navigate(-1); }; diff --git a/src/components/index.ts b/src/components/index.ts index e5280413..c50fa8d0 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -126,6 +126,9 @@ import LinkDetails from './link/details/LinkDetails'; import PortfolioInformation from './portfolio/information/PortfolioInformation'; import PortfolioList from './portfolio/list/PortfolioList'; import ImageCarousel from './carousel/ImageCarousel'; +import PortfolioImageUpload from './portfolio/image/upload/PortfolioImageUpload'; +import ModalPortal from './modal/ModalPortal'; +import PortfolioImageModal from './portfolio/image/modal/PortfolioImageModal'; import WriterFooter from './recruit/recruitDetail/footer/WriterFooter'; import ApplierFooter from './recruit/recruitDetail/footer/ApplierFooter'; import ClosedFooter from './recruit/recruitDetail/footer/ClosedFooter'; @@ -258,6 +261,9 @@ export { PortfolioInformation, PortfolioList, ImageCarousel, + PortfolioImageUpload, + ModalPortal, + PortfolioImageModal, WriterFooter, ApplierFooter, ApplyModal, diff --git a/src/components/input/Input.styled.ts b/src/components/input/Input.styled.ts index 8264ae77..771d5211 100644 --- a/src/components/input/Input.styled.ts +++ b/src/components/input/Input.styled.ts @@ -1,14 +1,14 @@ import styled from 'styled-components'; interface InputStyle { - default?: string; + $default?: string; $focus?: string; $arrow?: string; disabled?: boolean; invalid?: boolean; } -const InputLabel = styled.label<{ $width?: string }>` +const InputLayout = styled.label<{ $width?: string }>` min-width: 0; display: flex; flex-direction: column; @@ -24,8 +24,6 @@ const InputLabel = styled.label<{ $width?: string }>` letter-spacing: 0.0032rem; h6 { - margin-bottom: 0.8rem; - /* Body/body2/semibold */ font-size: 1.4rem; font-style: normal; @@ -35,6 +33,17 @@ const InputLabel = styled.label<{ $width?: string }>` } `; +const InputLabel = styled.h6<{ $required?: boolean }>` + margin-bottom: 0.8rem; + + ${props => + props.$required && + `&:: after { + content: ' *'; + color: #f85858; + }`} +`; + const InputContainer = styled.div` position: relative; display: flex; @@ -45,6 +54,21 @@ const InputContainer = styled.div` margin-left: auto; color: var(--State-unactive, #8e8e8e); } + + small { + position: absolute; + top: 5.4rem; + left: 1rem; + white-space: nowrap; // 줄바꿈 방지 + color: var(--ButtonColors-Caution-outline-defaultLine, #f85858); + + /* Text/t4 */ + font-size: 1rem; + font-style: normal; + font-weight: 500; + line-height: 1.2rem; /* 120% */ + letter-spacing: 0.002rem; + } `; const Input = styled.input` @@ -60,7 +84,7 @@ const Input = styled.input` var(--Form-border-default, ${props => (props.disabled ? '#8E8E8E' : '#e3e3e3')}); background: ${props => props.disabled ? 'var(--Form-fill-disabled, #E3E3E3)' : 'var(--Form-fill-others, #fff)'}; - ${props => props.default && `background-image: url(${props.default}); `} + ${props => props.$default && `background-image: url(${props.$default}); `} background-repeat: no-repeat; // 배경 아이콘 반복 X background-position: ${props => props.$arrow === 'left' ? 'center left 1.6rem' : 'center right 1.6rem'}; @@ -108,6 +132,6 @@ const InputErrorMessage = styled.small` letter-spacing: 0.002rem; `; -const S = { InputLabel, InputContainer, Input, InputErrorMessage }; +const S = { InputLayout, InputLabel, InputContainer, Input, InputErrorMessage }; export default S; diff --git a/src/components/input/Input.tsx b/src/components/input/Input.tsx index cda34e48..b1b33293 100644 --- a/src/components/input/Input.tsx +++ b/src/components/input/Input.tsx @@ -10,7 +10,7 @@ import { } from 'react-hook-form'; export interface Icon { - default: string; + $default: string; $focus?: string; $arrow: string; } @@ -56,8 +56,10 @@ const Input = ({ const { ref, ...rest } = register(name as Path, validation?.disabled ? undefined : validation); return ( - - {label &&
{label}
} + + {label && ( + {label} + )} ({ )} -
+ ); }; diff --git a/src/components/modal/ModalPortal.tsx b/src/components/modal/ModalPortal.tsx new file mode 100644 index 00000000..f1deb1cb --- /dev/null +++ b/src/components/modal/ModalPortal.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; + +interface PortalProps { + children: React.ReactNode; +} + +const ModalPortal = ({ children }: PortalProps) => { + const modalRoot = document.getElementById('modal') as HTMLElement; + return ReactDOM.createPortal(children, modalRoot); +}; + +export default ModalPortal; diff --git a/src/components/optionList/OptionList.styled.ts b/src/components/optionList/OptionList.styled.ts index 123e9fc0..738cd21a 100644 --- a/src/components/optionList/OptionList.styled.ts +++ b/src/components/optionList/OptionList.styled.ts @@ -1,10 +1,16 @@ import styled from 'styled-components'; -const OptionList = styled.ul<{ $label?: string }>` +interface OptionStyle { + $label?: string; + $style?: string; +} + +const OptionList = styled.ul` position: absolute; width: 100%; z-index: 5; top: ${props => (props.$label ? '7.9rem' : '5.4rem')}; + ${props => props.$style} display: flex; flex-direction: column; diff --git a/src/components/optionList/OptionList.tsx b/src/components/optionList/OptionList.tsx index 6822a0d3..a8b06baf 100644 --- a/src/components/optionList/OptionList.tsx +++ b/src/components/optionList/OptionList.tsx @@ -7,15 +7,22 @@ interface Option { } interface OptionListProps { - label?: T; name: T; handleOptionClick: (...rest: T[]) => void; optionList: Option[]; + label?: T; + $style?: T; } -const OptionList = ({ label, name, handleOptionClick, optionList }: OptionListProps) => { +const OptionList = ({ + label, + name, + handleOptionClick, + optionList, + ...props +}: OptionListProps) => { return ( - + {optionList.map(({ name: optionName, id }) => ( ` + position: relative; display: flex; flex-direction: column; row-gap: 0.8rem; cursor: pointer; + ${props => + !props.$open && + ` + button:last-of-type { + display: none; + } + + &:hover { + button:last-of-type { + display: flex; + } + } + `} `; -const PortfolioCardBox = styled.div<{ $url?: string }>` +const PortfolioCardBox = styled.div<{ $isEditable?: boolean }>` position: relative; - border-radius: 0.75rem; + border-radius: 1rem; + border: ${props => props.$isEditable && '0.1rem solid var(--Form-border-default, #e3e3e3)'}; overflow: hidden; aspect-ratio: 183 / 103; // 포트폴리오 비율 + + button:first-of-type { + display: flex; + } `; const PortfolioCardImage = styled.img` @@ -49,7 +72,7 @@ const PortfolioTagRow = styled.div` column-gap: 0.8rem; `; -const PortfolioCardTag = styled.span<{ $color: string }>` +const PortfolioCardTag = styled.span<{ $color?: string }>` align-items: center; display: flex; @@ -66,9 +89,16 @@ const PortfolioCardTag = styled.span<{ $color: string }>` font-weight: 500; line-height: 1.4rem; /* 116.667% */ letter-spacing: 0.0024rem; + + &.main-image-tag { + padding: 0.6rem 0.8rem; + border-radius: 1.5rem; + border: 1px solid var(--Purplescale-200, #e0e6ff); + background: var(--Grayscale-000, #fff); + } `; -const PortfolioCardButton = styled.button<{ $checked?: boolean }>` +const PortfolioCardNumberButton = styled.button<{ $checked?: boolean }>` position: absolute; bottom: 1rem; right: 1rem; @@ -91,6 +121,16 @@ const PortfolioCardButton = styled.button<{ $checked?: boolean }>` letter-spacing: 0.0032rem; `; +const PortfolioCardIconButton = styled(PortfolioCardNumberButton)` + top: -1rem; + left: -1rem; +`; + +const PortfolioImageInput = styled.input` + position: absolute; + display: none; +`; + const S = { PortfolioCardLayout, PortfolioCardBox, @@ -98,7 +138,9 @@ const S = { PortfolioCardTitle, PortfolioTagRow, PortfolioCardTag, - PortfolioCardButton, + PortfolioCardNumberButton, + PortfolioCardIconButton, + PortfolioImageInput, }; export default S; diff --git a/src/components/portfolio/card/PortfolioCard.tsx b/src/components/portfolio/card/PortfolioCard.tsx index 529afd99..788cc7a7 100644 --- a/src/components/portfolio/card/PortfolioCard.tsx +++ b/src/components/portfolio/card/PortfolioCard.tsx @@ -1,55 +1,144 @@ -import React from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import S from './PortfolioCard.styled'; import { useNavigate } from 'react-router'; -import { DefaultPortfolioImage } from '../../../assets'; +import { DefaultPortfolioImage, Pencil } from '../../../assets'; +import { OptionList } from '../..'; +import { useRecoilState } from 'recoil'; +import { uploadImageListState } from '../../../atom'; +import { Image } from '../../../types'; interface PortfolioCard { - id: string; - title: string; + id?: string; + title?: string; mainImageUrl?: string; - field: string; - role: string; + field?: string; + role?: string; + isMainImage?: boolean; isEditable?: boolean; + isImageEditable?: boolean; clickNumber?: number; handleClick?: (id: string) => void; } +const MAX_IMAGE_SIZE_BYTES = 30 * 1024 * 1024; // 30MB +const imageEditOptionList = [{ name: '이미지 변경' }, { name: '이미지 삭제' }]; + const PortfolioCard = ({ id, title, mainImageUrl, field, role, + isMainImage, isEditable, + isImageEditable, clickNumber, handleClick, }: PortfolioCard) => { const navigate = useNavigate(); + const [isOpen, setIsOpen] = useState(false); + const buttonRef = useRef(null); + + useEffect(() => { + const handleOutsideClick = (e: MouseEvent) => { + const target = e.target as HTMLDivElement; + if (isOpen && buttonRef.current && !buttonRef.current.contains(target)) { + setIsOpen(false); + } + }; + + document.addEventListener('click', handleOutsideClick); + return () => document.removeEventListener('click', handleOutsideClick); + }, [isOpen]); + + const inputRef = useRef(null); + const [uploadImageList, setUploadImageList] = useRecoilState(uploadImageListState); + + const changeImage = (event: React.BaseSyntheticEvent) => { + const image = event.target?.files[0]; + if ( + uploadImageList.find(({ fileName }) => fileName === image.name) || + image.size > MAX_IMAGE_SIZE_BYTES + ) { + return; + } + + const urlReader = new FileReader(); + urlReader.readAsDataURL(image); + urlReader.onload = () => { + const uploadImage = { + fileName: image.name, + url: urlReader.result, + file: image, + } as Image; + const imageList = [...uploadImageList]; + imageList.splice((clickNumber as number) - 1, 1, uploadImage); + setUploadImageList(imageList); + }; + }; + + const deleteImage = () => { + const imageList = [...uploadImageList]; + imageList.splice((clickNumber as number) - 1, 1); + setUploadImageList(imageList); + }; + + const handleOptionClick = (name: string, optionName: string) => { + if (optionName === '이미지 변경') { + inputRef.current?.click(); + } else if (optionName === '이미지 삭제') { + deleteImage(); + } + }; + return ( (isEditable ? handleClick?.(id) : navigate(`/portfolio/${id}`))} + $open={isOpen} + onClick={() => (isEditable ? () => id && handleClick?.(id) : navigate(`/portfolio/${id}`))} > - + - {field} - {role} + {field && {field}} + {role && {role}} + {isMainImage && 메인} {isEditable && ( - handleClick?.(id)} + onClick={() => id && handleClick?.(id)} > {clickNumber !== 0 && clickNumber} - + )} - {title} + {title && {title}} + {isImageEditable && ( + <> + setIsOpen(true)} + > + 연필아이콘 + + {isOpen && ( + + )} + + )} + ); }; diff --git a/src/components/portfolio/image/modal/PortfolioImageModal.styled.ts b/src/components/portfolio/image/modal/PortfolioImageModal.styled.ts new file mode 100644 index 00000000..6e1a3881 --- /dev/null +++ b/src/components/portfolio/image/modal/PortfolioImageModal.styled.ts @@ -0,0 +1,157 @@ +import styled from 'styled-components'; + +const PortfolioImageModalLayout = styled.div` + position: fixed; + left: 0; + top: 0; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + background: rgba(21, 21, 21, 0.4); +`; + +const PortfolioImageModalContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + padding: 4rem; + width: clamp(30%, 52rem, 75%); + border-radius: 1rem; + border: 0.1rem solid var(--box_stroke, #e3e3e3); + background: var(--Grayscale-100, #f8fafb); + color: var(--Text-textColor2, var(--text-color-2, #373f41)); + + /* Body/body1/medium */ + font-size: 1.6rem; + font-weight: 500; + line-height: 1.9rem; + letter-spacing: 0.0032rem; + + h2 { + color: var(--Text-textColor1, #151515); + + /* Headline/h2 */ + font-size: 2.4rem; + font-weight: 700; + line-height: 2.9rem; /* 120.833% */ + letter-spacing: 0.0048rem; + } +`; + +const PortfolioImageModalHeader = styled.header` + display: flex; + flex-direction: column; + width: 100%; + + h2 { + margin-bottom: 2rem; + } +`; + +const PortfolioImageList = styled.ul` + flex: 1; + display: flex; + flex-direction: column; + row-gap: 0.4rem; + margin-bottom: 2.8rem; + max-height: 38rem; + + /* 스크롤바 스타일링 */ + overflow-y: auto; + &::-webkit-scrollbar { + width: 1.8rem; + } + &::-webkit-scrollbar-thumb { + background-color: #e3e3e3; + border-radius: 1rem; + background-clip: padding-box; + border: 0.5rem solid transparent; + } +`; + +const PortfolioImageItem = styled.li` + display: flex; + flex-direction: row; + height: 9.2rem; + align-items: center; + border-radius: 1rem; + border: 0.1rem solid var(--box_stroke, #e3e3e3); + background: var(--Form-fill-others, #fff); +`; + +const PortfolioImageListIcon = styled.span` + display: flex; + padding: 1.6rem; + align-items: center; + height: 100%; + border-right: 0.1rem solid var(--box_stroke, #e3e3e3); +`; + +const PortfolioImageWrapper = styled.div` + display: flex; + width: 10rem; + flex-direction: row; + margin: 1.6rem; + align-items: center; +`; + +const PortfolioImageModalRow = styled.div<{ $gap?: string }>` + flex: 1; + display: flex; + flex-direction: row; + column-gap: ${props => props.$gap}; + align-items: center; +`; + +const PortfolioImageModalColumn = styled.div` + display: flex; + flex-direction: column; + width: 100%; +`; + +const PortfolioImageTitle = styled.span` + display: flex; + flex: 1; + text-overflow: ellipsis; + overflow: hidden; + word-break: break-word; + display: -webkit-box; + -webkit-line-clamp: 2; // 원하는 라인수 + -webkit-box-orient: vertical; +`; + +const PortfolioImageNumberIcon = styled.span` + box-sizing: border-box; + display: flex; + justify-content: center; + align-items: center; + margin: 0 1.6rem; + width: 2.8rem; + height: 2.8rem; + border-radius: 10rem; + background: var(--main-color, #5877fc); + color: var(--ButtonColors-Primary-outline-default, #fff); + + font-size: 1.6rem; + font-weight: 600; + line-height: 2.4rem; + letter-spacing: 0.0032rem; +`; + +const S = { + PortfolioImageModalLayout, + PortfolioImageModalContainer, + PortfolioImageModalHeader, + PortfolioImageList, + PortfolioImageItem, + PortfolioImageListIcon, + PortfolioImageWrapper, + PortfolioImageModalRow, + PortfolioImageModalColumn, + PortfolioImageTitle, + PortfolioImageNumberIcon, +}; + +export default S; diff --git a/src/components/portfolio/image/modal/PortfolioImageModal.tsx b/src/components/portfolio/image/modal/PortfolioImageModal.tsx new file mode 100644 index 00000000..d624cea2 --- /dev/null +++ b/src/components/portfolio/image/modal/PortfolioImageModal.tsx @@ -0,0 +1,104 @@ +import React, { useState, useEffect } from 'react'; +import S from './PortfolioImageModal.styled'; +import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd'; +import { PortfolioCard, PrimaryBtn, DefaultBtn } from '../../..'; +import { useRecoilState } from 'recoil'; +import { uploadImageListState } from '../../../../atom'; +import { HambergerMenuIcon } from '../../../../assets'; + +const PortfolioImageModal = ({ onClose }: { onClose: () => void }) => { + const [uploadImageList, setUploadImageList] = useRecoilState(uploadImageListState); + const [changeImageList, setChangeImageList] = useState(uploadImageList); + + const orderImageList = () => { + setUploadImageList(changeImageList); + onClose(); + }; + + const onDragEnd = ({ source, destination }: DropResult) => { + if (!destination) return; + + const _items = [...changeImageList]; + const [targetItem] = _items.splice(source.index, 1); + _items.splice(destination.index, 0, targetItem); + + setChangeImageList(_items); + }; + + // --- requestAnimationFrame 초기화 + const [enabled, setEnabled] = useState(false); + + useEffect(() => { + const animation = requestAnimationFrame(() => setEnabled(true)); + + return () => { + cancelAnimationFrame(animation); + setEnabled(false); + }; + }, []); + + if (!enabled) { + return null; + } + // --- requestAnimationFrame 초기화 END + + return ( + + + +

슬라이드 순서 변경

+
+ + + {provided => ( + + + {[...changeImageList].map( + ({ fileName, url }, index) => + fileName && ( + + {provided => ( + + + 햄버거메뉴아이콘 + + + + + + {fileName} + + {index + 1} + + )} + + ) + )} + + {provided.placeholder} + + )} + + + +
+ +
+
+ +
+
+
+
+ ); +}; + +export default PortfolioImageModal; diff --git a/src/components/portfolio/image/upload/PortfolioImageUpload.styled.ts b/src/components/portfolio/image/upload/PortfolioImageUpload.styled.ts new file mode 100644 index 00000000..53d19307 --- /dev/null +++ b/src/components/portfolio/image/upload/PortfolioImageUpload.styled.ts @@ -0,0 +1,76 @@ +import styled from 'styled-components'; + +const PortfolioImageUploadLayout = styled.article` + display: flex; + flex-direction: column; +`; + +const PortfolioImageGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fill, minmax(24.4rem, 1fr)); + grid-auto-rows: minmax(13.7rem, auto); + row-gap: 1.6rem; + column-gap: 1.6rem; +`; + +const PortfolioImageUpload = styled.div` + position: relative; + display: flex; + align-items: center; + justify-content: center; + border-radius: 1rem; + border: 0.1rem solid var(--Form-border-default, #8e8e8e); + background: var(--Grayscale-200, #f6f6f6); + color: var(--Text-textColor2, var(--text-color-2, #373f41)); + cursor: pointer; + aspect-ratio: 183 / 103; // 포트폴리오 비율 + + /* Body/body1/semibold */ + font-size: 1.6rem; + font-style: normal; + font-weight: 600; + line-height: 1.9rem; + letter-spacing: 0.0032rem; + + small { + color: #8e8e8e; + + /* Text/t2 */ + font-size: 1.2rem; + font-style: normal; + font-weight: 500; + line-height: 1.4rem; + letter-spacing: 0.0024rem; + } +`; + +const PortfolioImageUploadRow = styled.div` + display: flex; + flex-direction: row; + column-gap: 0.6rem; +`; + +const PortfolioImageUploadColumn = styled.div` + display: flex; + align-items: center; + flex-direction: column; + row-gap: 0.8rem; +`; + +const PortfolioImageInput = styled.input` + position: absolute; + width: 100%; + height: 100%; + display: none; +`; + +const S = { + PortfolioImageUploadLayout, + PortfolioImageGrid, + PortfolioImageUpload, + PortfolioImageUploadRow, + PortfolioImageUploadColumn, + PortfolioImageInput, +}; + +export default S; diff --git a/src/components/portfolio/image/upload/PortfolioImageUpload.tsx b/src/components/portfolio/image/upload/PortfolioImageUpload.tsx new file mode 100644 index 00000000..35919d5c --- /dev/null +++ b/src/components/portfolio/image/upload/PortfolioImageUpload.tsx @@ -0,0 +1,136 @@ +import React, { useRef, useEffect } from 'react'; +import S from './PortfolioImageUpload.styled'; +import { Plus } from '../../../../assets'; +import { useRecoilState } from 'recoil'; +import { uploadImageListState } from '../../../../atom'; +import { PortfolioCard } from '../../..'; +import { BlobFile, Image } from '../../../../types'; +import { unzipFile } from '../../../../utils'; + +const MAX_IMAGE_SIZE_BYTES = 30 * 1024 * 1024; // 30MB +const MAX_IMAGE_COUNT = 15; + +interface PortfolioImage { + zipFileUrl?: string; + fileOrder?: string[]; +} + +const PortfolioImageUpload = ({ zipFileUrl, fileOrder }: PortfolioImage) => { + const inputRef = useRef(null); + const addImageList = () => { + inputRef.current?.click(); + }; + + const [uploadImageList, setUploadImageList] = useRecoilState(uploadImageListState); + + const extractPromise = (imageList: BlobFile[]) => + new Promise(resolve => { + let extractedImageList: Image[] = []; + for (let i = 0; i < imageList.length; i++) { + const urlReader = new FileReader(); + urlReader.readAsDataURL(imageList[i].blob as Blob); + urlReader.onload = () => { + const extractedImage = { + fileName: imageList[i].fileName, + url: urlReader.result, + file: imageList[i].blob, + } as Image; + extractedImageList = [...extractedImageList, extractedImage]; + if (i === imageList.length - 1) { + resolve(extractedImageList); + } + }; + } + }); + + const reorderPromise = (extractedImageList: Image[]) => + new Promise(() => { + if (fileOrder) { + let reorderedImageList: Image[] = []; + for (let i = 0; i < fileOrder.length; i++) { + const reorderedImage = extractedImageList.find( + extractedImage => extractedImage.fileName === fileOrder[i] + ) as Image; + reorderedImageList = [...reorderedImageList, reorderedImage]; + if (i === fileOrder.length - 1) { + setUploadImageList(reorderedImageList); + } + } + } + }); + + useEffect(() => { + if (zipFileUrl && fileOrder) { + // 이미지 리사이징 추후 적용 + unzipFile(zipFileUrl) + .then(imageList => { + return extractPromise(imageList); + }) + .then(extractedImageList => { + return reorderPromise(extractedImageList); + }); + } + }, [zipFileUrl]); + + const changeImageList = (event: React.BaseSyntheticEvent) => { + const imageList = event.target?.files; + for (let i = 0; i < imageList.length && uploadImageList.length + i < MAX_IMAGE_COUNT; i++) { + if ( + uploadImageList.find(image => image.fileName === imageList[i].name) || + [...imageList].find((image, index) => index !== i && image.name === imageList[i].name) + ) { + continue; + } + if (imageList[i].size > MAX_IMAGE_SIZE_BYTES) { + continue; + } + + const urlReader = new FileReader(); + urlReader.readAsDataURL(imageList[i]); + urlReader.onload = () => { + const uploadImage = { + fileName: imageList[i].name, + url: urlReader.result, + file: imageList[i], + } as Image; + setUploadImageList(prev => [...prev, uploadImage]); + }; + } + }; + + return ( + + + {[...uploadImageList].map((uploadImage, index) => ( + + ))} + {/* 이미지 추가 버튼 */} + + + + 포트폴리오 이미지 추가 + 이미지 추가 (최대 15장) + + (1920px X 1080px) + + + + + + ); +}; + +export default PortfolioImageUpload; diff --git a/src/components/portfolio/list/PortfolioList.styled.ts b/src/components/portfolio/list/PortfolioList.styled.ts index 6060f7fe..b89b1770 100644 --- a/src/components/portfolio/list/PortfolioList.styled.ts +++ b/src/components/portfolio/list/PortfolioList.styled.ts @@ -41,7 +41,7 @@ const PortfolioList = styled.div` } article { - min-width: 27.4rem; + width: 27.4rem; } `; diff --git a/src/components/profile/image/ProfileImage.tsx b/src/components/profile/image/ProfileImage.tsx index 816f0a90..7d6b24bd 100644 --- a/src/components/profile/image/ProfileImage.tsx +++ b/src/components/profile/image/ProfileImage.tsx @@ -1,9 +1,10 @@ -import React, { useRef, useState } from 'react'; +import React, { useEffect, useRef } from 'react'; import S from './ProfileImage.styled'; import { AddProfile, DefaultProfileImage } from '../../../assets'; import { useNavigate } from 'react-router'; -import { useSetRecoilState } from 'recoil'; -import { imageNameState } from '../../../atom'; +import { Image } from '../../../types'; +import { useRecoilState } from 'recoil'; +import { uploadImageState } from '../../../atom'; interface ProfileImage { isEditable?: boolean; @@ -19,31 +20,39 @@ const ProfileImage = ({ isEditable, userId, size, url }: ProfileImage) => { navigate(`/profile/${userId}`); }; + const [uploadImage, setUploadImage] = useRecoilState(uploadImageState); + useEffect(() => { + setUploadImage({ url: url }); + }, []); + const inputRef = useRef(null); + const addImage = () => { inputRef.current?.click(); }; - const [imageSrc, setImageSrc] = useState(url ? url : null); - const setImageNameState = useSetRecoilState(imageNameState); - const changeImage = (event: React.BaseSyntheticEvent) => { - const uploadImage = event.target?.files[0]; - setImageNameState(uploadImage.name); - - const reader = new FileReader(); - reader.readAsDataURL(uploadImage); - reader.onload = () => setImageSrc(reader.result as string); + const image = event.target?.files[0]; + const urlReader = new FileReader(); + urlReader.readAsDataURL(image); + urlReader.onload = () => { + const newImage = { + fileName: image.name, + url: urlReader.result, + file: image, + } as Image; + setUploadImage(newImage); + }; }; return ( - + {isEditable && ( <> diff --git a/src/constant/validation.ts b/src/constant/validation.ts index 38f8a68f..cf723756 100644 --- a/src/constant/validation.ts +++ b/src/constant/validation.ts @@ -33,6 +33,33 @@ export const INPUT_VALIDATION = { introduction: { maxLength: 20, }, + portfolioImage: { + required: '포트폴리오를 대표할 이미지를 업로드해주세요', + }, + portfolioTitle: { + required: '포트폴리오 제목을 작성해주세요', + }, + portfolioDescription: { + required: '포트폴리오 한줄 소개를 작성해주세요', + }, + field: { + required: '분야를 선택해주세요', + }, + role: { + required: '분야를 선택해주세요', + }, + startDate: { + required: '진행 시작일을 설정해주세요', + }, + endDate: { + required: '진행 마감일을 설정해주세요', + }, + proceedType: { + required: '진행방식을 설정해주세요', + }, + content: { + required: true, + }, }; export const TEXTAREA_VALIDATION = { diff --git a/src/hooks/index.ts b/src/hooks/index.ts index beb3c95e..573f02e4 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -11,7 +11,17 @@ import { useReadProfile, useUpdateProfile } from './useProfile'; import useDebounce from './useDebounce'; import useValid from './useValid'; import { useReadSkillList, useReadRoleList } from './useSearch'; -import { useReadPortfolio, useReadPortfolioList } from './usePortfolio'; +import { + useReadPortfolioList, + useReadPortfolio, + useCreatePortfolio, + useUpdatePortfolio, +} from './usePortfolio'; +import { + useReadImagePresignedUrl, + useReadImageListPresignedUrl, + useUploadImageFile, +} from './useImage'; import useIntersection from './useIntersection'; import { useBookmark } from './useBookMark'; import useLogin from './useLogin'; @@ -32,6 +42,11 @@ export { useReadSkillList, useReadRoleList, useReadPortfolio, + useCreatePortfolio, + useUpdatePortfolio, + useReadImagePresignedUrl, + useReadImageListPresignedUrl, + useUploadImageFile, useReadPortfolioList, useIntersection, useBookmark, diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index c7bd2061..16edb678 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -101,10 +101,10 @@ export const useReadUniversityList = () => { /** * @description 학과 목록 조회 API를 호출하는 hook입니다. */ -export const useReadDepartmentList = () => { +export const useReadDepartmentList = (universityId: string) => { return useQuery({ - queryKey: authKeys.readDepartmentList(sessionStorage.university), - queryFn: () => sessionStorage.university && readDepartmentList(sessionStorage.university), + queryKey: authKeys.readDepartmentList(universityId), + queryFn: () => readDepartmentList(universityId), enabled: false, }); }; diff --git a/src/hooks/useImage.ts b/src/hooks/useImage.ts new file mode 100644 index 00000000..1254fd1f --- /dev/null +++ b/src/hooks/useImage.ts @@ -0,0 +1,48 @@ +import { useQuery, useMutation } from '@tanstack/react-query'; +import { readImagePresignedUrl, readImageListPresignedUrl, uploadImageFile } from '../service'; + +const imageKeys = { + readImagePresignedUrl: (fileName: string) => ['readImagePresignedUrl', fileName], + readImageListPresignedUrl: ({ + fileName, + portfolioId, + }: { + fileName: string; + portfolioId?: string; + }) => ['readImageListPresignedUrl', fileName, portfolioId], + uploadImageFile: ['useUploadImageFile'], +}; + +/** + * @description 단일 이미지 업로드를 위한 presignedURL 조회 API를 호출하는 hook입니다. + */ +export const useReadImagePresignedUrl = (fileName: string) => { + return useQuery({ + queryKey: imageKeys.readImagePresignedUrl(fileName), + queryFn: () => readImagePresignedUrl(fileName), + enabled: false, + }); +}; + +/** + * @description 다중 이미지 업로드를 위한 presignedURL 조회 API를 호출하는 hook입니다. + */ +export const useReadImageListPresignedUrl = (fileName: string, portfolioId?: string) => { + return useQuery({ + queryKey: imageKeys.readImageListPresignedUrl({ fileName, portfolioId }), + queryFn: () => readImageListPresignedUrl({ fileName, portfolioId }), + enabled: false, + }); +}; + +/** + * @description S3 에 이미지 바이너리 파일 업로드 API를 호출하는 hook입니다. + */ +export const useUploadImageFile = ({ onSuccess }: { onSuccess: () => void }) => { + return useMutation({ + mutationFn: uploadImageFile, + onSuccess: () => { + onSuccess?.(); + }, + }); +}; diff --git a/src/hooks/usePortfolio.ts b/src/hooks/usePortfolio.ts index 575dd7cc..82b375bb 100644 --- a/src/hooks/usePortfolio.ts +++ b/src/hooks/usePortfolio.ts @@ -1,9 +1,9 @@ -import { useQuery, useInfiniteQuery } from '@tanstack/react-query'; -import { readPortfolio, readPortfolioList } from '../service'; +import { useQuery, useMutation, useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; +import { readPortfolioList, createPortfolio, readPortfolio, updatePortfolio } from '../service'; const portfolioKeys = { readPortfolio: (portfolioId: string) => ['readPortfolio', portfolioId], - readPortfolioList: (size: number) => ['readProfile', size], + readPortfolioList: (size: number) => ['readProfile', size], }; /** @@ -16,9 +16,46 @@ export const useReadPortfolio = (portfolioId: string) => { }); }; +/** + * @description 포트폴리오 등록 API를 호출하는 hook입니다. + */ +export const useCreatePortfolio = ({ onSuccess }: { onSuccess: (data: string) => void }) => { + return useMutation({ + mutationFn: createPortfolio, + onSuccess: data => { + if (data) { + onSuccess?.(data); + } + }, + }); +}; + +/** + * @description 포트폴리오 편집 API를 호출하는 hook입니다. + */ +export const useUpdatePortfolio = ({ + onSuccess, + portfolioId, +}: { + onSuccess: (data: string) => void; + portfolioId: string; +}) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: updatePortfolio, + onSuccess: async data => { + if (data) { + await queryClient.invalidateQueries({ queryKey: portfolioKeys.readPortfolio(portfolioId) }); + onSuccess?.(data); + } + }, + }); +}; + /** * @description 포트폴리오 목록 무한스크롤 조회 API를 호출하는 hook입니다. */ + export const useReadPortfolioList = (size: number) => { return useInfiniteQuery({ queryKey: portfolioKeys.readPortfolioList(size), diff --git a/src/hooks/useProfile.ts b/src/hooks/useProfile.ts index 9b53a8ba..4c1d691f 100644 --- a/src/hooks/useProfile.ts +++ b/src/hooks/useProfile.ts @@ -1,4 +1,4 @@ -import { useQuery, useMutation } from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { readProfile, updateProfile } from '../service'; const profileKeys = { @@ -18,10 +18,18 @@ export const useReadProfile = (userId: string) => { /** * @description 유저 프로필 작성 API를 호출하는 hook입니다. 기존 회원인 경우 access token 을 로컬 스토리지에 저장합니다. 회원이 아닌 경우, 회원가입 페이지로 이동합니다. */ -export const useUpdateProfile = ({ onSuccess }: { onSuccess: () => void }) => { +export const useUpdateProfile = ({ + onSuccess, + userId, +}: { + onSuccess: () => void; + userId: string; +}) => { + const queryClient = useQueryClient(); return useMutation({ mutationFn: updateProfile, - onSuccess: () => { + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: profileKeys.readProfile(userId) }); onSuccess?.(); }, }); diff --git a/src/main.tsx b/src/main.tsx index 19881c81..f5e3183d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -14,6 +14,7 @@ import { ProfileDetailsPage, ProfileEditPage, PortfolioDetailsPage, + PortfolioEditPage, ApplierManagePage, RecruitManageWrapper, RecruitPostingBookmark, @@ -98,6 +99,10 @@ const router = createBrowserRouter([ path: 'portfolio/:portfolioId?', element: , }, + { + path: 'portfolio/edit/:portfolioId?', + element: , // 생성 및 편집 + }, ], }, ]); diff --git a/src/pages/account/schoolCertification/SchoolCertificationData.ts b/src/pages/account/schoolCertification/SchoolCertificationData.ts index e53c28f3..32099108 100644 --- a/src/pages/account/schoolCertification/SchoolCertificationData.ts +++ b/src/pages/account/schoolCertification/SchoolCertificationData.ts @@ -10,7 +10,7 @@ const SCHOOL_CERTIFICATION_DATA = [ validation: INPUT_VALIDATION.year, isNext: false, icon: { - default: ArrowBottom, + $default: ArrowBottom, $focus: ArrowTop, $arrow: 'right', }, @@ -23,7 +23,7 @@ const SCHOOL_CERTIFICATION_DATA = [ validation: INPUT_VALIDATION.university, isNext: false, icon: { - default: Search, + $default: Search, $arrow: 'right', }, }, diff --git a/src/pages/account/schoolCertification/SchoolCertificationPage.tsx b/src/pages/account/schoolCertification/SchoolCertificationPage.tsx index 859b5c30..5c023fc9 100644 --- a/src/pages/account/schoolCertification/SchoolCertificationPage.tsx +++ b/src/pages/account/schoolCertification/SchoolCertificationPage.tsx @@ -21,12 +21,24 @@ interface FormValues { } const SchoolCertificationPage = () => { - const { data: universityList, refetch: readUniversityList } = useReadUniversityList(); - const { data: departmentList, refetch: readDepartmentList } = useReadDepartmentList(); + const [next, setNext] = useState(false); + const [submitEmail, setSubmitEmail] = useState(false); + const [domain, setDomain] = useState(); + const [universityId, setUniversityId] = useState(); - useEffect(() => { - readUniversityList(); - }, []); + const nextHandler = (e: React.MouseEvent) => { + // 학과 리스트 넘겨줄 때 domain 만 따로 넘겨주는 거 변경 요청 시도 + e.preventDefault(); + setDomain( + universityList?.find(university => university.universityName === getValues('university')) + ?.universityDomain + ); + setUniversityId( + universityList?.find(university => university.universityName === getValues('university')) + ?.universityId + ); + setNext(prev => !prev); + }; useEffect(() => { sessionStorage?.university && readDepartmentList(); @@ -52,25 +64,26 @@ const SchoolCertificationPage = () => { mutate({ platformId: localStorage.PLATFORM_ID, year: data.year, - universityId: sessionStorage.university, - departmentId: sessionStorage.department, + universityId: universityId, + departmentId: departmentList?.find( + department => department.departmentName === getValues('department') + )?.departmentId, emailId: data.email, }); }; - const [next, setNext] = useState(false); - const [submitEmail, setSubmitEmail] = useState(false); - const [domain, setDomain] = useState(); + const { data: universityList, refetch: readUniversityList } = useReadUniversityList(); + const { data: departmentList, refetch: readDepartmentList } = useReadDepartmentList( + universityId as string + ); - const nextHandler = (e: React.MouseEvent) => { - // 학과 리스트 넘겨줄 때 domain 만 따로 넘겨주는 거 변경 요청 시도 - e.preventDefault(); - setDomain( - universityList?.find(university => university.universityName === getValues('university')) - ?.universityDomain - ); - setNext(prev => !prev); - }; + useEffect(() => { + readUniversityList(); + }, []); + + useEffect(() => { + domain && readDepartmentList(); + }, [domain]); const optionList = (name: string) => { if (name === 'year') { diff --git a/src/pages/index.ts b/src/pages/index.ts index 32909657..a534b4a1 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -12,15 +12,17 @@ import NicknameSettingPage from './account/nicknameSetting/NicknameSettingPage'; import type { Account, User } from './account/signUp/SignUpData'; import PassWordFindingPage from './account/passWordFindingPage/PassWordFindingPage'; import { PASSWORD_DATA } from './account/passWordFindingPage/PassWordData'; +import ProfileDetailsPage from './profile/details/ProfileDetailsPage'; +import { userData } from './profile/userData'; import ProfileEditPage from './profile/edit/ProfileEditPage'; import PROFILE_EDIT_DATA from './profile/edit/ProfileEditData'; import { portfolioData } from './portfolio/portfolioData'; import PortfolioDetailsPage from './portfolio/details/PortfolioDetailsPage'; +import PORTFOLIO_EDIT_DATA from './portfolio/edit/portfolioEditData'; +import PortfolioEditPage from './portfolio/edit/PortfolioEditPage'; import ApplierManagePage from './recruit/applierManagePage/ApplierManagePage'; import RecruitManageWrapper from './recruit/recruitManagePage/RecruitManageWrapper'; import RecruitPostingBookmark from './recruit/recruitManagePage/RecruitPostingBookmark'; -import ProfileDetailsPage from './profile/details/ProfileDetailsPage'; -import { userData } from './profile/userData'; import RecruitPostingApply from './recruit/recruitManagePage/RecruitPostingApply'; import RecruitMyPostings from './recruit/recruitManagePage/RecruitMyPostings'; import CompleteSignUpPage from './account/complete/CompleteSignUpPage'; @@ -49,6 +51,8 @@ export { PROFILE_EDIT_DATA, portfolioData, PortfolioDetailsPage, + PORTFOLIO_EDIT_DATA, + PortfolioEditPage, ApplierManagePage, RecruitPostingApply, RecruitMyPostings, diff --git a/src/pages/portfolio/details/PortfolioDetailsPage.tsx b/src/pages/portfolio/details/PortfolioDetailsPage.tsx index 29739dc4..e8f971ca 100644 --- a/src/pages/portfolio/details/PortfolioDetailsPage.tsx +++ b/src/pages/portfolio/details/PortfolioDetailsPage.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import S from './PortfolioDetails.styled'; import { DefaultBtn, @@ -8,8 +8,11 @@ import { PortfolioList, } from '../../../components'; import { useNavigate, useParams } from 'react-router-dom'; -import { imageList } from '../../../components/carousel/imageList'; import { useReadPortfolio } from '../../../hooks'; +import { Image, BlobFile } from '../../../types'; +import { unzipFile } from '../../../utils'; +import { useRecoilState } from 'recoil'; +import { uploadImageListState } from '../../../atom'; const PortfolioDetailsPage = () => { const { portfolioId } = useParams() as { portfolioId: string }; @@ -17,6 +20,57 @@ const PortfolioDetailsPage = () => { const navigate = useNavigate(); + const [uploadImageList, setUploadImageList] = useRecoilState(uploadImageListState); // 추후에 받아온 정보 reduce로 조합해서 초기화 + + const extractPromise = (imageList: BlobFile[]) => + new Promise(resolve => { + let extractedImageList: Image[] = []; + for (let i = 0; i < imageList.length; i++) { + const urlReader = new FileReader(); + urlReader.readAsDataURL(imageList[i].blob as Blob); + urlReader.onload = () => { + const extractedImage = { + fileName: imageList[i].fileName, + url: urlReader.result, + file: imageList[i].blob, + } as Image; + extractedImageList = [...extractedImageList, extractedImage]; + if (i === imageList.length - 1) { + resolve(extractedImageList); + } + }; + } + }); + + const reorderPromise = (extractedImageList: Image[]) => + new Promise(() => { + if (portfolio?.fileOrder) { + let reorderedImageList: Image[] = []; + for (let i = 0; i < portfolio?.fileOrder.length; i++) { + const reorderedImage = extractedImageList.find( + extractedImage => extractedImage.fileName === portfolio?.fileOrder[i] + ) as Image; + reorderedImageList = [...reorderedImageList, reorderedImage]; + if (i === portfolio?.fileOrder.length - 1) { + setUploadImageList(reorderedImageList); + } + } + } + }); + + useEffect(() => { + if (portfolio?.zipFileUrl && portfolio?.fileOrder) { + // 이미지 리사이징 추후 적용 + unzipFile(portfolio?.zipFileUrl) + .then(imageList => { + return extractPromise(imageList); + }) + .then(extractedImageList => { + return reorderPromise(extractedImageList); + }); + } + }, [portfolio?.zipFileUrl]); + return ( isSuccess && ( @@ -30,14 +84,14 @@ const PortfolioDetailsPage = () => { navigate(`/portfolio/${portfolioId}/edit`)} + handleClick={() => navigate(`/portfolio/edit/${portfolioId}`)} /> )} - + diff --git a/src/pages/portfolio/edit/PortfolioEdit.styled.ts b/src/pages/portfolio/edit/PortfolioEdit.styled.ts new file mode 100644 index 00000000..583a6a39 --- /dev/null +++ b/src/pages/portfolio/edit/PortfolioEdit.styled.ts @@ -0,0 +1,201 @@ +import styled from 'styled-components'; +import ReactQuill from 'react-quill'; + +interface PortfolioBoxStyle { + $gap?: string; + $width?: string; +} + +const PortfolioEditLayout = styled.form` + display: flex; + flex-direction: column; + margin: 0 auto; + margin-bottom: 15rem; + width: clamp(45%, 96rem, 75%); // width: 96rem; + + color: var(--Light-Black, #373f41); + + /* Body/body1/medium */ + font-size: 1.6rem; + font-style: normal; + font-weight: 600; + line-height: 1.9rem; + letter-spacing: 0.0032rem; + + h2 { + color: var(--Text-textColor1, #151515); + + /* Headline/h2 */ + font-size: 2.4rem; + font-weight: 700; + line-height: 2.9rem; /* 120.833% */ + letter-spacing: 0.0048rem; + } + + h4 { + /* Headline/h4 */ + font-size: 1.8rem; + font-style: normal; + font-weight: 600; + line-height: 2.1rem; /* 116.667% */ + letter-spacing: 0.0036rem; + } + + h6 { + /* Body/body2/semibold */ + font-size: 1.4rem; + font-style: normal; + font-weight: 600; + line-height: 1.7rem; /* 121.429% */ + letter-spacing: 0.0028rem; + } + + /* 수평선 */ + hr { + all: unset; + margin-top: 4rem; + height: 0.075rem; + background: #e3e3e3; + } +`; + +const PortfolioEditColumn = styled.div` + display: flex; + flex-direction: column; + row-gap: ${props => props.$gap}; + ${props => (props.$width ? `width: ${props.$width}` : `flex: 1;`)}; +`; + +const PortfolioEditRow = styled.div` + display: flex; + flex-direction: row; + column-gap: ${props => props.$gap}; + ${props => (props.$width ? `width: ${props.$width}` : `flex: 1;`)}; + + /* 반응형 대비 */ + flex-wrap: wrap; + row-gap: ${props => props.$gap}; +`; + +const PortfolioEditHeader = styled.header` + display: flex; + flex-direction: column; + margin-top: 8rem; + + /* 반응형 대비 */ + flex-wrap: wrap; + column-gap: 2.4rem; + + h2 { + margin-bottom: 1.2rem; + } + + hr { + margin-top: 2rem; + background: #000000; + } +`; + +const PortfolioEditTitle = styled.h4` + display: flex; +`; + +const PortfolioEditLabel = styled.h6<{ $required?: boolean }>` + margin-bottom: 0.8rem; + + ${props => + props.$required && + `&:: after { + content: ' *'; + color: #f85858; + }`} +`; + +const PortfolioEditArticle = styled.article` + display: flex; + flex-direction: row; + justify-content: space-between; + white-space: pre-wrap; // 줄바꿈 + + /* 반응형 대비 */ + flex-wrap: wrap; + row-gap: 2.4rem; +`; + +const PortfolioEditor = styled(ReactQuill)` + display: flex; + flex-direction: column; + min-height: 27.1rem; + border-radius: 0.75rem; + border: 0.1rem solid #e3e3e3; + overflow: hidden; + + &:hover, + focus { + border-color: var(--Form-border-focus, #5877fc); + } + + .ql-toolbar { + border: 0; + border-bottom: 0.1rem solid #e3e3e3; + } + + .ql-container { + display: flex; + border: 0; + color: var(--Light-Black, #373f41); + white-space: pre-wrap; // 줄바꿈 + + /* 스크롤바 스타일링 */ + overflow-y: auto; + &::-webkit-scrollbar { + width: 1.8rem; + } + &::-webkit-scrollbar-thumb { + background-color: #e3e3e3; + border-radius: 1rem; + background-clip: padding-box; + border: 0.5rem solid transparent; + } + + /* Body/body1/medium */ + font-family: Pretendard; + font-size: 1.6rem; + font-style: normal; + font-weight: 500; + line-height: 1.9rem; + letter-spacing: 0.0032rem; + } + + .ql-editor { + width: 100%; + padding: 1.5rem 1.7rem; + } + + &.quill > .ql-container > .ql-editor.ql-blank::before { + display: flex; + color: #8e8e8e; + } +`; + +const PortfolioEditButtonBox = styled.div` + display: flex; + flex-direction: row; + column-gap: 1.6rem; + margin-top: 12rem; + margin-left: auto; +`; + +const S = { + PortfolioEditLayout, + PortfolioEditColumn, + PortfolioEditRow, + PortfolioEditHeader, + PortfolioEditTitle, + PortfolioEditLabel, + PortfolioEditArticle, + PortfolioEditor, + PortfolioEditButtonBox, +}; + +export default S; diff --git a/src/pages/portfolio/edit/PortfolioEditPage.tsx b/src/pages/portfolio/edit/PortfolioEditPage.tsx new file mode 100644 index 00000000..0978eac1 --- /dev/null +++ b/src/pages/portfolio/edit/PortfolioEditPage.tsx @@ -0,0 +1,436 @@ +import React, { useEffect, useRef, useState } from 'react'; +import S from './PortfolioEdit.styled'; +import { DevTool } from '@hookform/devtools'; +import { + Input, + ComboBox, + Radio, + SkillTag, + AddFormBtn, + LinkForm, + DefaultBtn, + PrimaryBtn, + MuiDatepickerController, + PortfolioImageUpload, + PortfolioImageModal, + ModalPortal, +} from '../../../components'; +import { useParams, useNavigate } from 'react-router-dom'; +import { useForm, useFieldArray, SubmitHandler } from 'react-hook-form'; +import { Link, PortfolioPayload, Skill } from '../../../types'; +import { + useCreatePortfolio, + useDebounce, + useReadImageListPresignedUrl, + useReadPortfolio, + useReadRoleList, + useReadSkillList, + useUpdatePortfolio, + useUploadImageFile, +} from '../../../hooks'; +import PORTFOLIO_EDIT_DATA from './portfolioEditData'; +import { modules, formats, fixModalBackground, zipFile } from '../../../utils'; +import { Refresh } from '../../../assets'; +import type ReactQuill from 'react-quill'; +import { useRecoilValue } from 'recoil'; +import { uploadImageListState } from '../../../atom'; + +interface FormValues { + title?: string; + description?: string; + field?: string; + role?: string; + startDate?: string; + endDate?: string; + skills?: string | null; + content?: string; + links?: Link[]; +} + +const LABEL = { + image: `최소 한 장 이상의 이미지가 업로드 되어야하며 첫 번째 이미지가 메인 이미지가 됩니다.\n단, 한 장당 30MB 이하로 최대 15장까지 업로드 가능합니다.`, + content: `진행 했던 내용을 자유롭게 작성해주세요.`, +}; +const PROCEED_TYPE = ['오프라인', '온라인', '상관없음']; + +const PortfolioEditPage = () => { + const { portfolioId } = useParams() as { portfolioId: string }; // undefined 인 경우(생성하는 경우) 로직 필요 + const navigate = useNavigate(); + + const { data: portfolio, isSuccess: isSuccessReadPortfolio } = useReadPortfolio(portfolioId); + // 작성자가 아닌 경우, 편집 방지(상세페이지로 이동) + useEffect(() => { + if (isSuccessReadPortfolio) { + portfolioId && !portfolio?.isWriter && navigate(`/portfolio/${portfolioId}`); + } + }, [isSuccessReadPortfolio]); + + const { register, formState, handleSubmit, control, watch, getValues, setValue } = + useForm({ + mode: 'onChange', + values: { + ...portfolio, + skills: null, + }, + resetOptions: { + keepDirtyValues: true, + keepErrors: true, + }, + }); + + const createPortfolioInSuccess = (newPortfolioId: string) => { + navigate(`/portfolio/${newPortfolioId}`); + }; + const updatePortfolioInSuccess = (portfolioId: string) => { + navigate(`/portfolio/${portfolioId}`); + }; + + const { mutate: createPortfolio } = useCreatePortfolio({ + onSuccess: createPortfolioInSuccess, + }); + const { mutate: updatePortfolio } = useUpdatePortfolio({ + onSuccess: updatePortfolioInSuccess, + portfolioId: portfolioId, + }); + + // 이미지 업로드 + const [readPresignedUrlList, setReadPresignedUrlList] = useState(false); + const [isSubmitted, setIsSubmitted] = useState(false); + + const uploadImageList = useRecoilValue(uploadImageListState); + const { data: imageResponse, refetch: readImageListPresignedUrl } = useReadImageListPresignedUrl( + uploadImageList[0]?.fileName as string, + portfolioId + ); + + const uploadImageFileInSuccess = () => { + if (!isSubmitted && imageResponse) { + setIsSubmitted(true); + + // 메인 이미지 업로드 + uploadImageFile({ + presignedUrl: imageResponse[1].url, + imageFile: uploadImageList[0].file as File, + }); + + // 폼 제출 + const formData = getValues(); + const portfolioData = { + ...formData, + mainImageFileName: imageResponse?.[1].fileName, + zipFileName: imageResponse?.[0].fileName, + fileOrder: uploadImageList.map(image => image.fileName), + field: fields?.find(field => field.name === getValues('field'))?.id, + role: roles?.find(role => role.name === getValues('role'))?.id, + proceedType: proceedType, + skills: skillList.map(skill => skill.id), + } as PortfolioPayload; + + if (portfolioId) { + updatePortfolio({ + portfolioId: portfolioId, + portfolio: portfolioData, + }); + } else { + createPortfolio({ ...portfolioData }); + } + } + }; + + const { mutate: uploadImageFile } = useUploadImageFile({ + onSuccess: uploadImageFileInSuccess, + }); + + useEffect(() => { + if (readPresignedUrlList && imageResponse) { + zipFile(uploadImageList).then((blob: Blob) => { + const imageListZipFile = new File([blob], imageResponse[0].fileName, { + type: 'application/zip', + }); + + // 다중 이미지 압축 파일 업로드 + uploadImageFile({ + presignedUrl: imageResponse[0].url, + imageFile: imageListZipFile, + }); + }); + } + }, [imageResponse]); + + const submitHandler: SubmitHandler = () => { + readImageListPresignedUrl(); // presignedUrl 발급 + setReadPresignedUrlList(true); + }; + + // 이미지 순서 변경 모달 + const [modalOpen, setModalOpen] = useState(false); + + useEffect(() => { + fixModalBackground(modalOpen); + }, [modalOpen]); + + // 분야 + const fields = [{ id: '1', name: '개발' }]; + + // 역할 + const role = useDebounce(watch('role')) as string; + const { data: roles } = useReadRoleList(role); + + // 진행 방식 + const [proceedType, setProceedType] = useState(portfolio?.proceedType); + const handleRadioClick = (id: string) => { + setProceedType(id); + }; + + // 스킬 + const skill = useDebounce(watch('skills')) as string; + const { data: skills } = useReadSkillList(skill); + + const [skillList, setSkillList] = useState(portfolio?.skills ? portfolio?.skills : []); + + const addSkill = () => { + const newSkill = { + id: skills?.find(skill => skill.name === getValues('skills'))?.id, + name: getValues('skills'), + } as Skill; + if (getValues('skills')?.length === 0) return; + if (!skillList.find(skill => newSkill.name === skill.name)) { + setSkillList(prev => [...prev, newSkill]); + } + setValue('skills', ''); + }; + + const deleteSkill = (skillName: string) => { + setSkillList(() => skillList.filter(skill => skill.name !== skillName)); + }; + + // 링크 + const { + fields: links, + prepend: prependLink, + remove: removeLink, + } = useFieldArray({ + name: 'links', + control: control, + }); + + const addLink = (index: number) => { + if (index === -1 || getValues(`links.0.url`)) { + prependLink({ description: 'Link', url: '' }); + } + }; + + // 상세 내용 + const quillRef = useRef(null); + const { ref } = register('content', PORTFOLIO_EDIT_DATA.content.validation); + + const handleChangeEditor = (value: string) => { + setValue('content', value); + }; + + useEffect(() => { + if (isSuccessReadPortfolio) { + setProceedType(portfolio?.proceedType); + setSkillList(portfolio?.skills ? portfolio?.skills : []); + } + }, [isSuccessReadPortfolio]); + + const checkKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') e.preventDefault(); + }; + + return ( + <> + checkKeyDown(e)} + > + + +

포트폴리오 작성

+

+ 작성하신 포트폴리오는 프로필을 통해 보여집니다. 진행했던 내용을 자유롭게 작성해보세요! +

+
+
+ + + + 슬라이드 이미지 + + + {LABEL.image} + setModalOpen(true)} + /> + {modalOpen && ( + + setModalOpen(false)} /> + + )} + + + + +
+
+ + + + 기본 정보 + + {/* 포트폴리오 제목 */} + + {/* 포트폴리오 한줄 소개 */} + + + {/* 분야 */} + + {/* 역할 */} + + + {/* 진행기간 */} + + 진행기간 + + + + + + {/* 진행방식 */} + + 진행방식 + + {PROCEED_TYPE.map(type => ( + +
+ {type} +
+
+ ))} +
+
+ {/* 스킬 */} + + + + {skillList?.map(({ ...props }, index) => ( + + ))} + + +
+
+
+
+ + + + 상세 내용 + + {LABEL.content} + { + ref(e); + if (quillRef) quillRef.current = e; + }} + value={portfolio?.content} + onChange={handleChangeEditor} + modules={modules} + formats={formats} + {...PORTFOLIO_EDIT_DATA.content} + /> + + +
+
+ + + + 링크 + + addLink(links.length - 1)} /> + + {links?.map((link, index) => ( + + ))} + + + +
+
+ + + navigate(`/portfolio/${portfolioId}`)} + /> + + +
+
+ + + ); +}; + +export default PortfolioEditPage; diff --git a/src/pages/portfolio/edit/portfolioEditData.ts b/src/pages/portfolio/edit/portfolioEditData.ts new file mode 100644 index 00000000..308a149b --- /dev/null +++ b/src/pages/portfolio/edit/portfolioEditData.ts @@ -0,0 +1,76 @@ +import { ArrowBottom, ArrowTop, Search } from '../../../assets'; +import { INPUT_VALIDATION } from '../../../constant'; + +const title = { + label: '포트폴리오 제목', + type: 'text', + placeholder: '20자 이내로 제목을 작성해주세요', + name: 'title', + validation: INPUT_VALIDATION.portfolioTitle, + maxLength: 20, +}; + +const description = { + label: '포트폴리오 한줄 소개', + type: 'text', + placeholder: '20자 이내로 한줄 소개를 작성해주세요', + name: 'description', + validation: INPUT_VALIDATION.portfolioDescription, + maxLength: 20, +}; + +const field = { + label: '분야', + type: 'text', + placeholder: '분야', + name: 'field', + validation: INPUT_VALIDATION.field, + icon: { + $default: ArrowBottom, + $focus: ArrowTop, + $arrow: 'right', + }, +}; + +const role = { + label: '역할', + type: 'text', + placeholder: '역할', + name: 'role', + validation: INPUT_VALIDATION.role, + icon: { + $default: ArrowBottom, + $focus: ArrowTop, + $arrow: 'right', + }, +}; + +const skills = { + label: '스킬', + type: 'text', + placeholder: '보유 스킬을 검색해주세요', + name: 'skills', + icon: { + $default: Search, + $arrow: 'right', + }, +}; + +const content = { + label: '상세 내용', + type: 'text', + placeholder: `나의 경험 및 경력 및 맡게 되는 역할을 작성해주세요.\n\n﹒재직시 전문적으로 담당한 업무나, 별도로 진행하신 팀 프로젝트가 있으시다면 적어주세요.\n(ex. 재직중인 회사에서, 사업기획 및 PM을 담당했습니다. 사람간의 일정조율 , 요구사항 조절에 자신이 있습니다.\n이와 별개로 총 10명정도의 규모의 팀에서 부팀장으로 역할을 담당하였고, 출시까지 한 경험이 있습니다.)\n\n﹒이 프로젝트에서 나(리더) 역할을 적어주세요.\n(ex. 전체 프로덕트의 기획 및 프로젝트 매니징을 담당하게 됩니다. 다만 한분이 더 같이 들어오셔서, 논의를 같이 했으면 좋겠습니다.)`, + name: 'content', + validation: INPUT_VALIDATION.content, +}; + +const PORTFOLIO_EDIT_DATA = { + title, + description, + field, + role, + skills, + content, +}; + +export default PORTFOLIO_EDIT_DATA; diff --git a/src/pages/profile/edit/ProfileEditData.ts b/src/pages/profile/edit/ProfileEditData.ts index e3f5d41a..0b568c21 100644 --- a/src/pages/profile/edit/ProfileEditData.ts +++ b/src/pages/profile/edit/ProfileEditData.ts @@ -32,7 +32,7 @@ const interest = { placeholder: '역할', name: 'interest', icon: { - default: ArrowBottom, + $default: ArrowBottom, $focus: ArrowTop, $arrow: 'right', }, @@ -59,7 +59,7 @@ const phone = { placeholder: '휴대폰 번호', name: 'phone', icon: { - default: GrayPhone, + $default: GrayPhone, $focus: BlackPhone, $arrow: 'left', }, @@ -70,7 +70,7 @@ const universityEmail = { placeholder: '학교 이메일', name: 'universityEmail', icon: { - default: GrayEmail, + $default: GrayEmail, $focus: BlackEmail, $arrow: 'left', }, @@ -84,7 +84,7 @@ const subEmail = { placeholder: '이메일', name: 'subEmail', icon: { - default: GrayEmail, + $default: GrayEmail, $focus: BlackEmail, $arrow: 'left', }, @@ -128,7 +128,7 @@ const maxGpa = { placeholder: '최대학점', name: 'maxGpa', icon: { - default: ArrowBottom, + $default: ArrowBottom, $focus: ArrowTop, $arrow: 'right', }, @@ -139,7 +139,7 @@ const skills = { placeholder: '보유 스킬을 검색해주세요', name: 'skills', icon: { - default: Search, + $default: Search, $arrow: 'right', }, }; @@ -148,7 +148,7 @@ const linkDescription = { type: 'button', placeholder: 'Link', icon: { - default: ArrowBottom, + $default: ArrowBottom, $focus: ArrowTop, $arrow: 'right', }, @@ -162,7 +162,7 @@ const linkUrl = { const awardDate = { type: 'string', icon: { - default: GrayCalendar, + $default: GrayCalendar, $focus: BlackCalendar, $arrow: 'right', }, diff --git a/src/pages/profile/edit/ProfileEditPage.tsx b/src/pages/profile/edit/ProfileEditPage.tsx index 70a27298..df777eea 100644 --- a/src/pages/profile/edit/ProfileEditPage.tsx +++ b/src/pages/profile/edit/ProfileEditPage.tsx @@ -20,7 +20,7 @@ import { AddFormBtn, } from '../../../components'; import { useRecoilValue } from 'recoil'; -import { imageNameState, userState } from '../../../atom'; +import { uploadImageState, userState } from '../../../atom'; import { Skill, Award, Link } from '../../../types'; import { useDebounce, @@ -29,6 +29,8 @@ import { useReadRoleList, useReadSkillList, useIntersection, + useUploadImageFile, + useReadImagePresignedUrl, } from '../../../hooks'; import { useNavigate } from 'react-router-dom'; import { useReadPortfolioList } from '../../../hooks/usePortfolio'; @@ -68,8 +70,6 @@ const gpaList = [{ name: '4.5' }, { name: '4.3' }]; const ProfileEditPage = () => { const userId = useRecoilValue(userState)?.userId as string; const { data: user, isSuccess: isUserSuccess } = useReadProfile(userId); // 새로고침 시, 렌더링 지연 및 콘솔 에러 - // const user = useRecoilValue(userState); - const profileImageName = useRecoilValue(imageNameState); const navigate = useNavigate(); @@ -77,17 +77,26 @@ const ProfileEditPage = () => { return navigate(`/profile/${userId}`); }; - const { mutate } = useUpdateProfile({ + const { mutate: updateProfile } = useUpdateProfile({ onSuccess: updateProfileInSuccess, + userId: userId, }); - const submitHandler: SubmitHandler = data => { - // const { imageUrl, interest, ...updateData } = data; - mutate({ - ...data, // updateData -> data - imageFileName: profileImageName, + // 이미지 업로드 + const profileImage = useRecoilValue(uploadImageState); + const { + data: imageResponse, + refetch: readImagePresignedUrl, + isSuccess: isSuccessReadUrl, + } = useReadImagePresignedUrl(profileImage?.fileName as string); + + const uploadImageFileInSuccess = () => { + const formData = getValues(); + updateProfile({ + ...formData, + imageFileName: imageResponse?.fileName, isUserNamePublic: isUserNamePublic, - interestId: sessionStorage.interest, + interestId: roles?.find(role => role.name === getValues('interest'))?.id, isPhonePublic: isPhonePublic, isUniversityMain: isUniversityMain, isUniversityEmailPublic: isUniversityEmailPublic, @@ -95,8 +104,38 @@ const ProfileEditPage = () => { skills: skillList.map(skill => skill.id), portfolios: pinnedPortfolioList as string[], }); - sessionStorage.removeItem('interest'); - sessionStorage.removeItem('skill'); + }; + + const { mutate: uploadImageFile } = useUploadImageFile({ + onSuccess: uploadImageFileInSuccess, + }); + + useEffect(() => { + if (isSuccessReadUrl && imageResponse) { + uploadImageFile({ + presignedUrl: imageResponse.url, + imageFile: profileImage?.file as File, + }); + } + }, [isSuccessReadUrl]); + + const submitHandler: SubmitHandler = data => { + if (profileImage?.fileName) { + // 이미지를 처음 업로드 및 변경하는 경우에만 S3에 업로드(기존!==지금) + readImagePresignedUrl(); // presignedUrl 발급 + } else { + updateProfile({ + ...data, + isUserNamePublic: isUserNamePublic, + interestId: roles?.find(role => role.name === getValues('interest'))?.id, + isPhonePublic: isPhonePublic, + isUniversityMain: isUniversityMain, + isUniversityEmailPublic: isUniversityEmailPublic, + isSubEmailPublic: isSubEmailPublic, + skills: skillList.map(skill => skill.id), + portfolios: pinnedPortfolioList as string[], + }); + } }; const { register, formState, handleSubmit, control, watch, getValues, setValue } = @@ -113,8 +152,8 @@ const ProfileEditPage = () => { awards: user?.awards, }, resetOptions: { - keepDirtyValues: true, // user-interacted input will be retained - keepErrors: true, // input errors will be retained with value update + keepDirtyValues: true, + keepErrors: true, }, }); @@ -157,7 +196,10 @@ const ProfileEditPage = () => { const [skillList, setSkillList] = useState(user?.skills ? user?.skills : []); const addSkill = () => { - const newSkill = { id: sessionStorage.skills, name: getValues('skills') } as Skill; + const newSkill = { + id: skills?.find(skill => skill.name === getValues('skills'))?.id, + name: getValues('skills'), + } as Skill; if (getValues('skills')?.length === 0) return; if (!skillList.find(skill => newSkill.name === skill.name)) { setSkillList(prev => [...prev, newSkill]); @@ -180,7 +222,7 @@ const ProfileEditPage = () => { }); const addLink = (index: number) => { - if (index === -1 || getValues(`links.${index}.url`)) { + if (index === -1 || getValues(`links.0.url`)) { prependLink({ description: 'Link', url: '' }); } }; @@ -325,7 +367,6 @@ const ProfileEditPage = () => { 자기 소개