diff --git a/README.md b/README.md index b616c22cb..2c917be3d 100644 --- a/README.md +++ b/README.md @@ -41,3 +41,40 @@ state 관리하고 있는 params과 usePageSize로 관리하는 pageSize의 변 - useList에서 보정을 하려면, pagination 훅에서 관리하는 값을 가져와야했고, - usePageSize에서 보정을 하려면 List컴포넌트에서 관리하고 있는 params state의 정보와 관리하는 함수들을 가져와야했습니다. - 최종적으로 내린 결론은, 통신쪽을 수정하여 이전 요청을 취소하고 통신을 하는 방향으로 수정을 했습니다. + +#### useList vs useFetch vs useAsync + +데이터를 받는 훅을 만들때, 처음에는 리스트에 특화된 훅을 만드려고 했는데 다른곳에서는 재사용하기 힘들어서 +범용적인 데이터를 받는 훅을 만드는게 좋을것 같아서 고민을 했습니다. + +1. 첫번째 시도(기존코드) (리스트형 데이터 패칭에 특화된 useList) + +- 장점 + + - useList를 호출할때 넣어야되는 필요로하는 인자가 정해져 있어서 편리(눈에 잘보임) + - params 객체를 넘겨받아도 인자가 정해져있으니 구조분해하여 useEffect의 의존성배열에 넣기 수월 + +- 단점 + - 하지만 모든 요청에 동일한 인자를 보내는건 아니므로 범용성이 떨어져보임 + - 좀더 일반적인 함수로 바꿔보려고 두번째 시도인 useFetch를 생각 + +2. 두번째 시도 (데이터요청함수를 실행만 하고 그 함수에 보낼 인자들을 넘겨주기만 하는 useFetch) + +- 장점 + - 훅 자체는 요청함수, 인자들에대해 모르고 있어도 되어서 범용성이 좋은것 같음 +- 단점 + - 그대신 넘어가는 인자들을 객체로 보내는데 useMemo로 참조를 보정해줘야함(어떤게 올지 몰라서, 구조분해로 분리못함) + - 그리고 무슨 인자로 요청하는지는 파악이 안됨 (사용하는 입장에서) + +3. 세번째 시도 (둘다 이용해볼까?) + +- useList가 useFetch를 이용하면 안될까? 필요한곳에서 useFetch를 재사용도 가능짐 +- useList가 받은 객체를 메모지이에션해서 useFetch로 넘겨주는 방식으로 작업했습니다. +- 효과 : useList에서 받아야하는 인자값을 나타낼수 있고, useFetch를 useList 뿐만 아니라 다른곳에서도 사용 가능 + +4. 네번째 마지막 시도 ㅜㅜ(useFetch와 useAsync를 쓰는 방법, 차이점) + +- 생각해보니 useFetch는 '주소'를 받아서 fetch처리하는 훅으로 사용되어야할 것 같음 (구글링의 예제 코드들을 분석해봄) +- url주소 기반으로 처리하는 useFetch를 쓰게되면, 만약에 api url이 변경되거나 요청전 작업이 수정되면 수정하러 이곳저곳을 돌아다녀야 할 것 같음. +- 비동기 요청함수를 받아서 처리하는 훅으로 만들어둔 useAsync를 쓰는게 더 적합해보임 +- 주소를 기반으로 fetch만을 처리하는 훅으로 useFetch를 수정하고 남겨두기로 결정 diff --git a/jsconfig.json b/jsconfig.json index 33d7e918c..677adb23c 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -2,7 +2,6 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "@/*": ["src/*"], "@components/*": ["src/components/*"], "@hooks/*": ["src/hooks/*"], "@context/*": ["src/context/*"], diff --git a/package-lock.json b/package-lock.json index 9cc152d42..a8a5ee206 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "clsx": "^2.1.1", + "lodash": "^4.17.21", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.28.0" @@ -1704,6 +1705,12 @@ "node": ">=6" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", diff --git a/package.json b/package.json index a6de770b1..34cfb71bd 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "dependencies": { "clsx": "^2.1.1", + "lodash": "^4.17.21", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.28.0" diff --git a/src/App.jsx b/src/App.jsx index d155985fc..c9b0bd433 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,9 +1,13 @@ -import { Outlet } from "react-router-dom"; +import { AuthProvider } from "./context/AuthContext"; +import { RouterProvider } from "react-router-dom"; +import { router } from "./router"; export default function App() { return ( <> - + + + ); } diff --git a/src/assets/img/icon/icon_double_arrow_left.svg b/src/assets/img/icon/icon_double_arrow_left.svg new file mode 100644 index 000000000..08c498ea0 --- /dev/null +++ b/src/assets/img/icon/icon_double_arrow_left.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/img/icon/icon_double_arrow_right.svg b/src/assets/img/icon/icon_double_arrow_right.svg new file mode 100644 index 000000000..4dcffd2ed --- /dev/null +++ b/src/assets/img/icon/icon_double_arrow_right.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/img/icon/icon_error.svg b/src/assets/img/icon/icon_error.svg new file mode 100644 index 000000000..83af6103d --- /dev/null +++ b/src/assets/img/icon/icon_error.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/img/icon/icon_plus.svg b/src/assets/img/icon/icon_plus.svg new file mode 100644 index 000000000..5bb9abf55 --- /dev/null +++ b/src/assets/img/icon/icon_plus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/img/icon/icon_warn.svg b/src/assets/img/icon/icon_warn.svg new file mode 100644 index 000000000..f08401d1e --- /dev/null +++ b/src/assets/img/icon/icon_warn.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/scss/base/_reset.scss b/src/assets/scss/base/_reset.scss index 043cc07f8..8d5cf4b78 100644 --- a/src/assets/scss/base/_reset.scss +++ b/src/assets/scss/base/_reset.scss @@ -74,7 +74,8 @@ button { img { vertical-align: top; } -input { +input, +textarea { padding: 0; margin: 0; border: none; diff --git a/src/assets/scss/common/_index.scss b/src/assets/scss/common/_index.scss new file mode 100644 index 000000000..3b9a7c4f3 --- /dev/null +++ b/src/assets/scss/common/_index.scss @@ -0,0 +1 @@ +@forward "input"; diff --git a/src/assets/scss/common/_input.scss b/src/assets/scss/common/_input.scss new file mode 100644 index 000000000..76ae5927a --- /dev/null +++ b/src/assets/scss/common/_input.scss @@ -0,0 +1,38 @@ +.field { + position: relative; + + .field-box { + display: block; + width: 100%; + height: 5.6rem; + padding: 0 2.4rem; + border: 1px solid transparent; + border-radius: 12px; + background: var(--color-secondary-100); + + &:focus, + &.valid { + border: 1px solid var(--color-primary-100); + outline: none; + } + + &.error { + border: 1px solid var(--color-error); + outline: none; + } + + &::placeholder { + color: var(--color-secondary-400); + } + + &:has(~ button) { + padding-right: 5rem; + } + } + + textarea.field-box { + height: 28.2rem; + padding: 2.4rem; + resize: none; + } +} diff --git a/src/assets/scss/style.scss b/src/assets/scss/style.scss index 271103ab3..c09ad6faf 100644 --- a/src/assets/scss/style.scss +++ b/src/assets/scss/style.scss @@ -1 +1,2 @@ @use "base"; +@use "common"; diff --git a/src/components/Container/index.jsx b/src/components/Container/index.jsx deleted file mode 100644 index 56eb3616b..000000000 --- a/src/components/Container/index.jsx +++ /dev/null @@ -1,5 +0,0 @@ -import styles from "./styles.module.scss"; - -export default function Container({ children }) { - return
{children}
; -} diff --git a/src/components/Field/Error.jsx b/src/components/Field/Error.jsx new file mode 100644 index 000000000..d9e80013c --- /dev/null +++ b/src/components/Field/Error.jsx @@ -0,0 +1,12 @@ +import styles from "./Error.module.scss"; + +export function Error({ error }) { + if (!error) { + return null; + } + return ( +
+ {error || "오류가 발생했습니다."} +
+ ); +} diff --git a/src/components/Field/Error.module.scss b/src/components/Field/Error.module.scss new file mode 100644 index 000000000..4d6717a64 --- /dev/null +++ b/src/components/Field/Error.module.scss @@ -0,0 +1,7 @@ +.item-error { + margin-top: 0.8rem; + padding-left: 1.6rem; + font-size: 1.4rem; + font-weight: 600; + color: var(--color-error); +} diff --git a/src/components/Field/FieldItem.jsx b/src/components/Field/FieldItem.jsx new file mode 100644 index 000000000..ab2466a17 --- /dev/null +++ b/src/components/Field/FieldItem.jsx @@ -0,0 +1,15 @@ +import styles from "./FieldItem.module.scss"; + +export function FieldItem({ children }) { + return
{children}
; +} + +function Label({ htmlFor, children }) { + return ( + + ); +} + +FieldItem.Label = Label; diff --git a/src/components/Field/FieldItem.module.scss b/src/components/Field/FieldItem.module.scss new file mode 100644 index 000000000..2a7170406 --- /dev/null +++ b/src/components/Field/FieldItem.module.scss @@ -0,0 +1,26 @@ +@use "@assets/scss/base/mixins"; + +.form-item { + margin-bottom: 2.4rem; + position: relative; + + @include mixins.mobile { + margin-bottom: 1.6rem; + } + + .item-label { + display: inline-block; + margin-bottom: 1.6rem; + font-size: 1.8rem; + font-weight: 700; + + @include mixins.mobile { + margin-bottom: 0.8rem; + font-size: 1.4rem; + } + } + + .item-field { + position: relative; + } +} diff --git a/src/components/Field/Form.jsx b/src/components/Field/Form.jsx new file mode 100644 index 000000000..c964b6801 --- /dev/null +++ b/src/components/Field/Form.jsx @@ -0,0 +1,13 @@ +import { Alert, LoadingSpinner } from "@components/ui"; + +export function Form({ isLoading, error, onSubmit, children }) { + return ( + <> + {isLoading && } + {error && ( + {error.message || "오류가 발생했습니다."} + )} +
{children}
+ + ); +} diff --git a/src/components/Field/ImageUpload.jsx b/src/components/Field/ImageUpload.jsx new file mode 100644 index 000000000..3a3e0c219 --- /dev/null +++ b/src/components/Field/ImageUpload.jsx @@ -0,0 +1,55 @@ +import useSingleFile from "@hooks/useSingleFile"; +import { Thumbnail } from "@components/ui"; +import { Error } from "@components/Field"; +import iconPlus from "@assets/img/icon/icon_plus.svg"; +import styles from "./ImageUpload.module.scss"; + +const LIMIT_SIZE_MB = 2; + +export function ImageUpload({ + error, + value, + id, + name, + onChange, + placeholder = "이미지 등록", +}) { + const { fileProps, fileError, handleRemove, preview } = useSingleFile({ + name, + value, + accept: "image/*", + limiSize: LIMIT_SIZE_MB, + onChange: (file) => onChange(name, file), + errorMessage: { + max: "이미지 등록은 최대 1개까지 가능합니다.", + accept: "이미지 파일만 업로드 가능합니다.", + }, + }); + + // 두가지 에러 동시에 보내려고 문자열로 합침 + const fileInputError = [fileError, error].filter((err) => err).join(" / "); + + return ( + <> +
+ +
+ {preview && ( + + )} +
+
+ + + ); +} diff --git a/src/components/Field/ImageUpload.module.scss b/src/components/Field/ImageUpload.module.scss new file mode 100644 index 000000000..c33f14dcd --- /dev/null +++ b/src/components/Field/ImageUpload.module.scss @@ -0,0 +1,41 @@ +.thumbnail-list { + display: flex; + gap: 2.4rem; +} + +.upload-button { + overflow: hidden; + position: relative; + display: block; + width: 100%; + max-width: 28.2rem; + border-radius: 1.6rem; + background: var(--color-secondary-100); + cursor: pointer; + + &:before { + content: ""; + display: block; + width: 100%; + height: 0; + padding-bottom: 100%; + } + + .upload-label { + position: absolute; + left: 0; + top: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1.3rem; + width: 100%; + height: 100%; + color: var(--color-secondary-400); + } +} + +.preview { + max-width: 28.2rem; +} diff --git a/src/components/Input/index.jsx b/src/components/Field/Input.jsx similarity index 68% rename from src/components/Input/index.jsx rename to src/components/Field/Input.jsx index 19d5a5cfd..329c070cb 100644 --- a/src/components/Input/index.jsx +++ b/src/components/Field/Input.jsx @@ -1,17 +1,11 @@ import { useState } from "react"; import clsx from "clsx"; +import { Error } from "@components/Field"; import iconViewOn from "@assets/img/icon/icon_view_on.svg"; import iconViewOff from "@assets/img/icon/icon_view_off.svg"; -import styles from "./styles.module.scss"; +import styles from "./Input.module.scss"; -export default function Input({ - type = "text", - id, - label, - error, - value, - ...props -}) { +export function Input({ type = "text", error, value, ...props }) { const [currentType, setCurrentType] = useState(type); const valid = value && !error; @@ -20,14 +14,11 @@ export default function Input({ } return ( -
- -
+ <> +
@@ -48,7 +39,7 @@ export default function Input({ )}
- {error &&
{error}
} -
+ + ); } diff --git a/src/components/Field/Input.module.scss b/src/components/Field/Input.module.scss new file mode 100644 index 000000000..8feebc338 --- /dev/null +++ b/src/components/Field/Input.module.scss @@ -0,0 +1,20 @@ +.item-btn { + position: absolute; + top: 50%; + right: 2.4rem; + transform: translateY(-50%); + width: 2.4rem; + height: 2.4rem; + background-repeat: no-repeat; + background-size: 100% auto; + background-position: center; +} + +.visibility-btn .icon-visibility-off, +.visibility-btn.on .icon-visibility-on { + display: none; +} + +.visibility-btn.on .icon-visibility-off { + display: block; +} diff --git a/src/components/Field/NumberInput.jsx b/src/components/Field/NumberInput.jsx new file mode 100644 index 000000000..5e1ec5c51 --- /dev/null +++ b/src/components/Field/NumberInput.jsx @@ -0,0 +1,36 @@ +import { useState } from "react"; +import clsx from "clsx"; +import { Error } from "@components/Field"; + +export function NumberInput({ + type = "number", + error, + value, + formatter, + ...props +}) { + const [currentType, setCurrentType] = useState(type); + const valid = value && !error; + + let formattedValue = value || ""; + + if (formatter && formattedValue && currentType === "text") { + formattedValue = formatter(value); + } + + return ( + <> +
+ setCurrentType("number")} + onBlur={() => setCurrentType("text")} + {...props} + /> +
+ + + ); +} diff --git a/src/components/Field/TagsInput.jsx b/src/components/Field/TagsInput.jsx new file mode 100644 index 000000000..369d8c3e1 --- /dev/null +++ b/src/components/Field/TagsInput.jsx @@ -0,0 +1,46 @@ +import { useRef } from "react"; +import clsx from "clsx"; +import { Tags } from "@components/ui"; +import { Error } from "@components/Field"; + +export function TagsInput({ error, value, id, name, onChange, placeholder }) { + const inputRef = useRef(null); + const valid = value.length && !error; + + function handleKeyDown(e) { + if (e.nativeEvent.isComposing) return; + + if (e.key === "Enter") { + e.preventDefault(); + const tag = inputRef.current.value.trim(); + if (tag && !value.includes(tag)) { + const newTags = [...value, tag]; + onChange(name, newTags); + } + inputRef.current.value = ""; + } + } + + function handleRemove(tag) { + const newTags = value.filter((item) => item !== tag); + onChange(name, newTags); + } + + return ( + <> +
+ +
+ + + + ); +} diff --git a/src/components/Field/Textarea.jsx b/src/components/Field/Textarea.jsx new file mode 100644 index 000000000..60b946bd4 --- /dev/null +++ b/src/components/Field/Textarea.jsx @@ -0,0 +1,19 @@ +import clsx from "clsx"; +import { Error } from "@components/Field"; + +export function Textarea({ error, value, ...props }) { + const valid = value && !error; + + return ( + <> +
+