From e7bf58932ac1679125a5b146426f08be5c4ea522 Mon Sep 17 00:00:00 2001 From: "chanki.kim" Date: Mon, 25 Nov 2024 18:05:37 +0900 Subject: [PATCH 01/76] =?UTF-8?q?feat:=20ProtectedRoute=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EC=83=81=ED=92=88=EB=93=B1=EB=A1=9D=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EC=97=90=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/ProtectedRoute/index.jsx | 15 +++++++++++++++ src/router.jsx | 7 ++++++- 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 src/pages/ProtectedRoute/index.jsx diff --git a/src/pages/ProtectedRoute/index.jsx b/src/pages/ProtectedRoute/index.jsx new file mode 100644 index 000000000..22e198964 --- /dev/null +++ b/src/pages/ProtectedRoute/index.jsx @@ -0,0 +1,15 @@ +import { Navigate } from "react-router-dom"; +import { useAuth } from "@/context/useAuth"; + +export default function ProtectedRoute({ children }) { + const { + auth: { user }, + } = useAuth(); + + if (!user) { + alert("로그인이 필요합니다."); + return ; + } + + return children; +} diff --git a/src/router.jsx b/src/router.jsx index 90991d191..eab1c1657 100644 --- a/src/router.jsx +++ b/src/router.jsx @@ -1,6 +1,7 @@ import { createBrowserRouter } from "react-router-dom"; import Layout from "./components/Layout"; import App from "./App"; +import ProtectedRoute from "./pages/ProtectedRoute"; import Landing from "./pages/landing"; import Login from "./pages/auth/login"; import Signup from "./pages/auth/signup"; @@ -58,7 +59,11 @@ export const router = createBrowserRouter([ }, { path: "addItem", - element: , + element: ( + + + + ), }, { path: "boards", From 6f1ef4985ef5f75601aff38d96b68d14adf68513 Mon Sep 17 00:00:00 2001 From: "chanki.kim" Date: Mon, 25 Nov 2024 23:07:57 +0900 Subject: [PATCH 02/76] =?UTF-8?q?feat:=20useForm=20hook=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20isFormValid=20=EC=B2=B4=ED=81=AC=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95=20(?= =?UTF-8?q?=EB=AC=B8=EC=9E=90=EC=97=B4=EC=9D=B8=EC=A7=80,=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EA=B0=99=EC=9D=80=20=EA=B0=92=EC=9D=B8=EC=A7=80?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=9D=BC=20=EC=B2=B4=ED=81=AC=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D)=20-=20register=EB=A1=9C=20=EB=B3=B4?= =?UTF-8?q?=EB=82=B4=EB=8A=94=20onChange=EB=8A=94=20input=EC=9D=98=20onCha?= =?UTF-8?q?nge=20=EC=A0=84=EC=9A=A9=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20-=20hook=20=EC=97=90=EC=84=9C=20=EB=B0=98=ED=99=98=ED=95=98?= =?UTF-8?q?=EB=8A=94=20onChange=EB=8A=94=20name,=20value=EB=A5=BC=20?= =?UTF-8?q?=EB=B0=9B=EC=95=84=EC=84=9C=20=EC=A7=81=EC=A0=91=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=ED=95=98=EB=8A=94=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=EB=A1=9C=20=EC=88=98=EC=A0=95=20-=20required=20?= =?UTF-8?q?=EB=B2=A8=EB=A6=AC=EB=8D=B0=EC=9D=B4=EC=85=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(=EB=AC=B8=EC=9E=90=EC=97=B4=EC=9D=B8=EC=A7=80=20?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=ED=9B=84=20=EA=B2=80=EC=A6=9D)=20-=20rule?= =?UTF-8?q?=EC=9D=B4=20=EC=97=86=EB=8A=94=20=ED=95=84=EB=93=9C=EC=9D=BC=20?= =?UTF-8?q?=EA=B2=BD=EC=9A=B0=20=EC=97=90=EB=9F=AC=EA=B0=80=20=EC=95=88?= =?UTF-8?q?=EB=82=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useForm.js | 39 +++++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/src/hooks/useForm.js b/src/hooks/useForm.js index cc9387a18..17315cce8 100644 --- a/src/hooks/useForm.js +++ b/src/hooks/useForm.js @@ -19,23 +19,29 @@ export default function useForm(formSchema) { const [formState, setFormState] = useState(initialState); const [isLoading, setIsLoading] = useState(false); const [formError, setFormError] = useState(null); - const isFormValid = Object.values(formState).every( - (item) => item.error === null && item.value.length + const isFormValid = Object.entries(formState).every( + ([key, item]) => + item.error === null && + (rules[key]?.required + ? typeof item.value === "string" + ? item.value.trim().length > 0 + : item.value + : true) ); - function handleChange(e) { + function handleInputChange(e) { const name = e.target.name; const value = e.target.value; - updateFormFieldState(name, value); + handleChange(name, value); } function trigger(name) { const value = formState[name].value || ""; - updateFormFieldState(name, value); + handleChange(name, value); } - function updateFormFieldState(name, value) { + function handleChange(name, value) { const { isValid, message } = validate(name, value); setFormState((prev) => ({ @@ -54,12 +60,17 @@ export default function useForm(formSchema) { return { isValid: true, message: null }; } - if (rule.required && !value.trim()) { - return { - isValid: false, - message: - typeof rule.required === "string" ? rule.required : "필수값입니다.", - }; + if (rule.required) { + const isEmpty = + value === null || (typeof value === "string" && !value.trim()); + + if (isEmpty) { + return { + isValid: false, + message: + typeof rule.required === "string" ? rule.required : "필수값입니다.", + }; + } } if (rule.patterns) { @@ -125,8 +136,8 @@ export default function useForm(formSchema) { name, value: formState[name].value, error: formState[name].error, - onChange: handleChange, - required: rules[name].required ? true : false, + onChange: handleInputChange, + required: rules[name]?.required ? true : false, }; } From 36ba19c8e6201b93f359bc2e8557b4dd62006844 Mon Sep 17 00:00:00 2001 From: "chanki.kim" Date: Mon, 25 Nov 2024 23:08:08 +0900 Subject: [PATCH 03/76] =?UTF-8?q?feat:=20Chip=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=9E=91=EC=97=85=20(=EA=B3=B5=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Chip/index.jsx | 15 +++++++++++++++ src/components/Chip/styles.module.scss | 10 ++++++++++ 2 files changed, 25 insertions(+) create mode 100644 src/components/Chip/index.jsx create mode 100644 src/components/Chip/styles.module.scss diff --git a/src/components/Chip/index.jsx b/src/components/Chip/index.jsx new file mode 100644 index 000000000..f34db2bc6 --- /dev/null +++ b/src/components/Chip/index.jsx @@ -0,0 +1,15 @@ +import clearIcon from "@assets/img/icon/icon_clear.svg"; +import styles from "./styles.module.scss"; + +export default function Chip({ removable, text, onClick }) { + return ( +
+ {text} + {removable && ( + + )} +
+ ); +} diff --git a/src/components/Chip/styles.module.scss b/src/components/Chip/styles.module.scss new file mode 100644 index 000000000..244466aa1 --- /dev/null +++ b/src/components/Chip/styles.module.scss @@ -0,0 +1,10 @@ +.chip { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.8rem; + padding: 0 1.2rem 0 1.6rem; + height: 3.6rem; + background: var(--color-secondary-100); + border-radius: 9999px; +} From 8cbb0354464a9fadfd1508640598df9e8040e38e Mon Sep 17 00:00:00 2001 From: "chanki.kim" Date: Mon, 25 Nov 2024 23:08:31 +0900 Subject: [PATCH 04/76] =?UTF-8?q?feat:=20Tags=20List=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Tags/index.jsx | 17 +++++++++++++++++ src/components/Tags/styles.module.scss | 6 ++++++ 2 files changed, 23 insertions(+) create mode 100644 src/components/Tags/index.jsx create mode 100644 src/components/Tags/styles.module.scss diff --git a/src/components/Tags/index.jsx b/src/components/Tags/index.jsx new file mode 100644 index 000000000..7634caddb --- /dev/null +++ b/src/components/Tags/index.jsx @@ -0,0 +1,17 @@ +import Chip from "../Chip"; +import styles from "./styles.module.scss"; + +export default function Tags({ tags, onRemoveItem }) { + return ( +
+ {tags?.map((tag) => ( + onRemoveItem(tag)} + removable + /> + ))} +
+ ); +} diff --git a/src/components/Tags/styles.module.scss b/src/components/Tags/styles.module.scss new file mode 100644 index 000000000..fa8ba2268 --- /dev/null +++ b/src/components/Tags/styles.module.scss @@ -0,0 +1,6 @@ +.item-tags { + display: flex; + flex-wrap: wrap; + gap: 1.2rem; + margin-top: 1.4rem; +} From 18366b11b2d18200489e0c3a7725e2d66f3ff1ce Mon Sep 17 00:00:00 2001 From: "chanki.kim" Date: Mon, 25 Nov 2024 23:09:02 +0900 Subject: [PATCH 05/76] =?UTF-8?q?feat:=20Input=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EA=B0=9C=EC=84=A0=20(textarea=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/scss/base/_reset.scss | 3 ++- src/components/Input/index.jsx | 10 ++++++++-- src/components/Input/styles.module.scss | 16 +++++++++++----- 3 files changed, 21 insertions(+), 8 deletions(-) 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/components/Input/index.jsx b/src/components/Input/index.jsx index 19d5a5cfd..00ec7a947 100644 --- a/src/components/Input/index.jsx +++ b/src/components/Input/index.jsx @@ -6,6 +6,7 @@ import styles from "./styles.module.scss"; export default function Input({ type = "text", + as = "input", id, label, error, @@ -14,6 +15,7 @@ export default function Input({ }) { const [currentType, setCurrentType] = useState(type); const valid = value && !error; + const Component = as; function handleVisibility() { setCurrentType((prev) => (prev === "password" ? "text" : "password")); @@ -25,9 +27,13 @@ export default function Input({ {label}
- diff --git a/src/components/Input/styles.module.scss b/src/components/Input/styles.module.scss index 47306ddef..a935b476c 100644 --- a/src/components/Input/styles.module.scss +++ b/src/components/Input/styles.module.scss @@ -23,7 +23,7 @@ position: relative; } - .input-box { + .item-box { display: block; width: 100%; height: 5.6rem; @@ -33,18 +33,18 @@ background: var(--color-secondary-100); } - .input-box:focus, - .input-box.valid { + .item-box:focus, + .item-box.valid { border: 1px solid var(--color-primary-100); outline: none; } - &:has(.item-error) .input-box { + &:has(.item-error) .item-box { border: 1px solid var(--color-error); outline: none; } - .input-box::placeholder { + .item-box::placeholder { color: var(--color-secondary-400); } @@ -52,6 +52,12 @@ padding-right: 5rem; } + .textarea-box { + height: 28.2rem; + padding: 2.4rem; + resize: none; + } + .item-error { margin-top: 0.8rem; padding-left: 1.6rem; From 028c1afe41adf06773ad6d3ba3a040ad82c33bb6 Mon Sep 17 00:00:00 2001 From: "chanki.kim" Date: Mon, 25 Nov 2024 23:09:24 +0900 Subject: [PATCH 06/76] =?UTF-8?q?feat:=20Tag=20input=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/TagsInput/index.jsx | 60 +++++++++++++++++++++ src/components/TagsInput/styles.module.scss | 58 ++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 src/components/TagsInput/index.jsx create mode 100644 src/components/TagsInput/styles.module.scss diff --git a/src/components/TagsInput/index.jsx b/src/components/TagsInput/index.jsx new file mode 100644 index 000000000..6b7caf844 --- /dev/null +++ b/src/components/TagsInput/index.jsx @@ -0,0 +1,60 @@ +import { useRef } from "react"; +import clsx from "clsx"; +import styles from "./styles.module.scss"; +import Tags from "../Tags"; + +export default function TagsInput({ + id, + label, + error, + value, + name, + onChange, + placeholder, +}) { + const inputRef = useRef(null); + const valid = value.length && !error; + + function handleKeyDown(e) { + if (e.key === "Enter") { + const tag = inputRef.current.value.trim(); + if (tag && !value.includes(tag)) { + const newTags = value ? `${value} ${tag}` : tag; + onChange(name, newTags); + } + inputRef.current.value = ""; + inputRef.current.blur(); + } + } + + function handleRemove(tag) { + const newTags = value + .split(" ") + .filter((item) => item !== tag) + .join(" "); + onChange(name, newTags); + } + + const tags = value.length ? value.split(" ") : []; + + return ( +
+ +
+ +
+ + {error &&
{error}
} +
+ ); +} diff --git a/src/components/TagsInput/styles.module.scss b/src/components/TagsInput/styles.module.scss new file mode 100644 index 000000000..22c7beea9 --- /dev/null +++ b/src/components/TagsInput/styles.module.scss @@ -0,0 +1,58 @@ +@use "@assets/scss/base/mixins"; + +.form-item { + margin-bottom: 2.4rem; + + @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; + } + + .item-box { + display: block; + width: 100%; + height: 5.6rem; + padding: 0 2.4rem; + border: 1px solid transparent; + border-radius: 12px; + background: var(--color-secondary-100); + } + + .item-box:focus, + .item-box.valid { + border: 1px solid var(--color-primary-100); + outline: none; + } + + &:has(.item-error) .item-box { + border: 1px solid var(--color-error); + outline: none; + } + + .item-box::placeholder { + color: var(--color-secondary-400); + } + + .item-error { + margin-top: 0.8rem; + padding-left: 1.6rem; + font-size: 1.4rem; + font-weight: 600; + color: var(--color-error); + } +} From 093a2546ab4b558c50c879d07e19bbae791fd651 Mon Sep 17 00:00:00 2001 From: "chanki.kim" Date: Tue, 26 Nov 2024 09:19:42 +0900 Subject: [PATCH 07/76] =?UTF-8?q?feat:=20useForm=20hook=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20=EC=88=AB=EC=9E=90=ED=98=95=ED=83=9C=EB=8F=84?= =?UTF-8?q?=20=EA=B0=92=EC=9D=84=20=EB=B0=9B=EC=9D=84=20=EC=88=98=20?= =?UTF-8?q?=EC=9E=88=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20-=20?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=ED=9E=98=EB=93=A0=20=EC=82=BC=ED=95=AD?= =?UTF-8?q?=EC=97=B0=EC=82=B0=EC=9E=90=20=EC=A4=91=EC=B2=A9=20=EB=B6=80?= =?UTF-8?q?=EB=B6=84=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20-=20trigger?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B9=88=EA=B0=92=EC=9D=B4=20=EB=93=A4?= =?UTF-8?q?=EC=96=B4=EA=B0=80=EB=8A=94=20=EB=B6=80=EB=B6=84=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useForm.js | 44 ++++++++++++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/src/hooks/useForm.js b/src/hooks/useForm.js index 17315cce8..70cdff103 100644 --- a/src/hooks/useForm.js +++ b/src/hooks/useForm.js @@ -19,25 +19,45 @@ export default function useForm(formSchema) { const [formState, setFormState] = useState(initialState); const [isLoading, setIsLoading] = useState(false); const [formError, setFormError] = useState(null); - const isFormValid = Object.entries(formState).every( - ([key, item]) => - item.error === null && - (rules[key]?.required - ? typeof item.value === "string" - ? item.value.trim().length > 0 - : item.value - : true) + const isFormValid = Object.entries(formState).every(([key, item]) => + isValidField(rules[key], item) ); + function isValidField(rule, item) { + if (item.error !== null) return false; + + if (rule?.required) { + const value = item.value; + + if (typeof value === "string" && value.trim().length === 0) { + return false; + } + + if (typeof value === "number" && isNaN(value)) { + return false; + } + + if (value === null || value === undefined) return false; + } + + return true; + } + function handleInputChange(e) { - const name = e.target.name; - const value = e.target.value; + const { type, name, value } = e.target; + let nextValue; - handleChange(name, value); + if (type === "number") { + nextValue = value === "" ? "" : Number(value); + } else { + nextValue = value; + } + + handleChange(name, nextValue); } function trigger(name) { - const value = formState[name].value || ""; + const value = formState[name].value; handleChange(name, value); } From a92f5f82f51db3898a301b94926ae04eb46150f2 Mon Sep 17 00:00:00 2001 From: "chanki.kim" Date: Tue, 26 Nov 2024 09:19:56 +0900 Subject: [PATCH 08/76] =?UTF-8?q?feat:=20FileInput=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/img/icon/icon_plus.svg | 4 + src/components/FileInput/Preview/index.jsx | 13 +++ .../FileInput/Preview/styles.module.scss | 35 +++++++ src/components/FileInput/index.jsx | 82 ++++++++++++++++ src/components/FileInput/styles.module.scss | 97 +++++++++++++++++++ 5 files changed, 231 insertions(+) create mode 100644 src/assets/img/icon/icon_plus.svg create mode 100644 src/components/FileInput/Preview/index.jsx create mode 100644 src/components/FileInput/Preview/styles.module.scss create mode 100644 src/components/FileInput/index.jsx create mode 100644 src/components/FileInput/styles.module.scss 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/components/FileInput/Preview/index.jsx b/src/components/FileInput/Preview/index.jsx new file mode 100644 index 000000000..ceb1c4068 --- /dev/null +++ b/src/components/FileInput/Preview/index.jsx @@ -0,0 +1,13 @@ +import clearIcon from "@assets/img/icon/icon_clear.svg"; +import styles from "./styles.module.scss"; + +export default function Preview({ src, onReset }) { + return ( +
+ + 상품 이미지 +
+ ); +} diff --git a/src/components/FileInput/Preview/styles.module.scss b/src/components/FileInput/Preview/styles.module.scss new file mode 100644 index 000000000..9d061782f --- /dev/null +++ b/src/components/FileInput/Preview/styles.module.scss @@ -0,0 +1,35 @@ +.cover { + overflow: hidden; + position: relative; + display: block; + margin-bottom: 1.6rem; + width: 100%; + max-width: 28.2rem; + border-radius: 1.6rem; + background: var(--color-secondary-100); + + &:before { + content: ""; + display: block; + width: 100%; + height: 0; + padding-bottom: 100%; + } + + .img { + position: absolute; + left: 0; + top: 0; + max-width: 100%; + width: 100%; + height: 100%; + object-fit: cover; + } + + .button { + z-index: 1; + position: absolute; + top: 1.2rem; + right: 1.2rem; + } +} diff --git a/src/components/FileInput/index.jsx b/src/components/FileInput/index.jsx new file mode 100644 index 000000000..38900ef2f --- /dev/null +++ b/src/components/FileInput/index.jsx @@ -0,0 +1,82 @@ +import { useEffect, useRef, useState } from "react"; +import Preview from "./Preview"; +import iconPlus from "@assets/img/icon/icon_plus.svg"; +import styles from "./styles.module.scss"; + +const LIMIT_SIZE_MB = 2; + +export default function FileInput({ id, label, error, value, name, onChange }) { + const [preview, setPreview] = useState(null); + const [fileError, setFileError] = useState(null); + const fileRef = useRef(null); + + useEffect(() => { + if (!value) return; + const objectURL = URL.createObjectURL(value); + setPreview(objectURL); + + return () => { + setPreview(null); + URL.revokeObjectURL(objectURL); + }; + }, [value]); + + function handleChange(e) { + const currentFile = e.target.files[0]; + setFileError(null); + + if (value) { + return setFileError("이미지 등록은 최대 1개까지 가능합니다."); + } + + if (!currentFile.type.startsWith("image/")) { + return setFileError("이미지 파일만 업로드 가능합니다."); + } + + if (currentFile.size > LIMIT_SIZE_MB * 1024 * 1024) { + return setFileError( + `사이즈가 너무 큽니다. ${LIMIT_SIZE_MB}mb 이하로 업로드 해주세요.` + ); + } + + fileRef.current.value = ""; + onChange(name, currentFile); + } + + function handleReset() { + if (!fileRef.current) return; + + fileRef.current.value = ""; + setFileError(null); + onChange(name, undefined); + } + + return ( +
+ +
+
+ + {preview && } +
+
+ {fileError &&
{fileError}
} + {error &&
{error}
} +
+ ); +} diff --git a/src/components/FileInput/styles.module.scss b/src/components/FileInput/styles.module.scss new file mode 100644 index 000000000..f91c83252 --- /dev/null +++ b/src/components/FileInput/styles.module.scss @@ -0,0 +1,97 @@ +@use "@assets/scss/base/mixins"; + +.form-item { + margin-bottom: 2.4rem; + + @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; + } + + .item-box { + display: block; + width: 100%; + height: 5.6rem; + padding: 0 2.4rem; + border: 1px solid transparent; + border-radius: 12px; + background: var(--color-secondary-100); + } + + .item-box:focus, + .item-box.valid { + border: 1px solid var(--color-primary-100); + outline: none; + } + + &:has(.item-error) .item-box { + border: 1px solid var(--color-error); + outline: none; + } + + .item-box::placeholder { + color: var(--color-secondary-400); + } + + .item-error { + margin-top: 0.8rem; + padding-left: 1.6rem; + font-size: 1.4rem; + font-weight: 600; + color: var(--color-error); + } +} + +.thumbnail-list { + display: flex; + gap: 2.4rem; +} + +.upload-button { + overflow: hidden; + position: relative; + display: block; + margin-bottom: 1.6rem; + 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); + } +} From 04dac8d7049ddb78f83ab665cd462be3aeca6ab9 Mon Sep 17 00:00:00 2001 From: "chanki.kim" Date: Tue, 26 Nov 2024 09:20:16 +0900 Subject: [PATCH 09/76] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=ED=8E=98=EC=9D=B4=EC=A7=80=201=EC=B0=A8=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20(=ED=86=B5=EC=8B=A0=EC=97=86=EC=9D=B4=20?= =?UTF-8?q?=EC=BD=98=EC=86=94=EB=A1=9C=EB=A7=8C=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=B0=8D=ED=9E=88=EB=8F=84=EB=A1=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/items/add/index.jsx | 110 +++++++++++++++++++++++++++++++++- 1 file changed, 108 insertions(+), 2 deletions(-) diff --git a/src/pages/items/add/index.jsx b/src/pages/items/add/index.jsx index 51896eb81..0999319db 100644 --- a/src/pages/items/add/index.jsx +++ b/src/pages/items/add/index.jsx @@ -1,5 +1,111 @@ -import Temporary from "@components/Temp"; +import Button from "@/components/Button"; +import Container from "@/components/Container"; +import Input from "@/components/Input"; +import TagsInput from "@/components/TagsInput"; +import Section from "@/components/Section"; +import useForm from "@/hooks/useForm"; +import FileInput from "@/components/FileInput"; + +const formSchema = { + images: { + value: undefined, + // rule: { + // required: "이미지를 업로드해주세요", + // }, + }, + tags: { + value: "", + rule: { + required: "태그를 입력해주세요", + patterns: [ + { + regex: /^[^!@#$%^&*(),.?":{}|<>_\-+=\[\]\\\/`~';]*$/, + message: "특수문자는 사용할 수 없습니다.", + }, + ], + }, + }, + name: { + value: "", + rule: { + required: "상품명을 입력해주세요", + }, + }, + description: { + value: "", + rule: { + required: "상품소개를 입력해주세요", + }, + }, + price: { + value: 0, + rule: { + required: "판매 가격을 입력해주세요", + patterns: [ + { + regex: /^[0-9]*$/, + message: "숫자만 입력해주세요", + }, + ], + }, + }, +}; export default function ItemAdd() { - return ; + const { register, isFormValid, handleChange, handleSubmit } = + useForm(formSchema); + + function onSubmit(data) { + const formattedData = { + ...data, + images: data.images && [data.images], + tags: data.tags.split(" "), + }; + console.log(formattedData); + } + + return ( + +
+
+ + + + + + + + + + +
+
+
+ ); } From 4aa9d2a97215f5c12fec6ecc75f3bc15dba4ebf5 Mon Sep 17 00:00:00 2001 From: "chanki.kim" Date: Tue, 26 Nov 2024 10:18:49 +0900 Subject: [PATCH 10/76] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=B0=8F=20=EC=83=81=ED=92=88?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20api=20=ED=98=B8=EC=B6=9C=20=EC=9E=91?= =?UTF-8?q?=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/service/product.js | 56 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/service/product.js b/src/service/product.js index 2bc5692f0..06e81f686 100644 --- a/src/service/product.js +++ b/src/service/product.js @@ -28,3 +28,59 @@ export async function getBestProducts({ pageSize }, { signal }) { return data; } + +export async function uploadProductImage(formData, accessToken) { + const res = await fetch(`${VITE_API_URL}/images/upload`, { + method: "POST", + headers: { + "Content-Type": "multipart/form-data", + Authorization: `Bearer ${accessToken}`, + }, + body: formData, + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.message || "에러가 발생했습니다."); + } + + return data; +} + +export async function addProduct(productData, accessToken) { + const res = await fetch(`${VITE_API_URL}/products`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify(productData), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.message || "에러가 발생했습니다."); + } + + return data; +} + +export async function deleteProduct(productId, accessToken) { + const res = await fetch(`${VITE_API_URL}/products/${productId}`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.message || "에러가 발생했습니다."); + } + + return data; +} From 6c08c56c17e186b2ec84aa6ee3b53dae76b26e0c Mon Sep 17 00:00:00 2001 From: "chanki.kim" Date: Tue, 26 Nov 2024 10:19:01 +0900 Subject: [PATCH 11/76] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=ED=8F=BC=EC=A0=9C?= =?UTF-8?q?=EC=B6=9C=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/items/add/index.jsx | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/src/pages/items/add/index.jsx b/src/pages/items/add/index.jsx index 0999319db..6a4cd384f 100644 --- a/src/pages/items/add/index.jsx +++ b/src/pages/items/add/index.jsx @@ -1,3 +1,5 @@ +import { useNavigate } from "react-router-dom"; +import { useAuth } from "@/context/useAuth"; import Button from "@/components/Button"; import Container from "@/components/Container"; import Input from "@/components/Input"; @@ -5,6 +7,7 @@ import TagsInput from "@/components/TagsInput"; import Section from "@/components/Section"; import useForm from "@/hooks/useForm"; import FileInput from "@/components/FileInput"; +import { addProduct, uploadProductImage } from "@/service/product"; const formSchema = { images: { @@ -54,14 +57,30 @@ const formSchema = { export default function ItemAdd() { const { register, isFormValid, handleChange, handleSubmit } = useForm(formSchema); + const { + auth: { accessToken }, + } = useAuth(); + const navigate = useNavigate(); - function onSubmit(data) { - const formattedData = { - ...data, - images: data.images && [data.images], - tags: data.tags.split(" "), - }; - console.log(formattedData); + async function onSubmit(data) { + try { + if (data.images) { + const imgFormData = new FormData(); + imgFormData.append("image", data.images); + const { url } = await uploadProductImage(imgFormData, accessToken); + data.images = [url]; + } + + if (tags) { + data.tags = data.tags.split(" "); + } + + const res = await addProduct(data, accessToken); + alert("성공적으로 작성했습니다."); + navigate("/items"); + } catch (err) { + throw err; + } } return ( From dc4855a6cbc4d6a15f583ac4bafcaa7f9b83f4e9 Mon Sep 17 00:00:00 2001 From: "chanki.kim" Date: Tue, 26 Nov 2024 12:00:05 +0900 Subject: [PATCH 12/76] =?UTF-8?q?refactor:=20service=20error=20=EA=B0=9D?= =?UTF-8?q?=EC=B2=B4=20=EC=88=98=EC=A0=95=20(status=EB=8F=84=20=EB=B0=9B?= =?UTF-8?q?=EC=9D=84=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/service/auth.js | 21 ++++++++++++++++----- src/service/product.js | 25 ++++++++++++++++++++----- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/service/auth.js b/src/service/auth.js index 6ff7e272a..80261be76 100644 --- a/src/service/auth.js +++ b/src/service/auth.js @@ -15,7 +15,10 @@ export async function login({ email, password }) { const data = await res.json(); if (!res.ok) { - throw new Error(data.message || "에러가 발생했습니다."); + throw { + status: res.status, + message: data.message || "에러가 발생했습니다.", + }; } return data; @@ -42,7 +45,10 @@ export async function signUp({ const data = await res.json(); if (!res.ok) { - throw new Error(data.message || "에러가 발생했습니다."); + throw { + status: res.status, + message: data.message || "에러가 발생했습니다.", + }; } return data; @@ -61,7 +67,10 @@ export async function refreshAccessToken(refreshToken) { const data = await res.json(); if (!res.ok) { - throw new Error(data.message || "에러가 발생했습니다."); + throw { + status: res.status, + message: data.message || "에러가 발생했습니다.", + }; } return data; @@ -76,9 +85,11 @@ export async function getUser(accessToken) { const data = await res.json(); if (!res.ok) { - throw new Error(data.message || "에러가 발생했습니다."); + throw { + status: res.status, + message: data.message || "에러가 발생했습니다.", + }; } return data; - s; } diff --git a/src/service/product.js b/src/service/product.js index 06e81f686..280934bc3 100644 --- a/src/service/product.js +++ b/src/service/product.js @@ -10,7 +10,10 @@ export async function getProducts( const data = await res.json(); if (!res.ok) { - throw new Error(data.message || "에러가 발생했습니다."); + throw { + status: res.status, + message: data.message || "에러가 발생했습니다.", + }; } return data; @@ -23,7 +26,10 @@ export async function getBestProducts({ pageSize }, { signal }) { const data = await res.json(); if (!res.ok) { - throw new Error(data.message || "에러가 발생했습니다."); + throw { + status: res.status, + message: data.message || "에러가 발생했습니다.", + }; } return data; @@ -42,7 +48,10 @@ export async function uploadProductImage(formData, accessToken) { const data = await res.json(); if (!res.ok) { - throw new Error(data.message || "에러가 발생했습니다."); + throw { + status: res.status, + message: data.message || "에러가 발생했습니다.", + }; } return data; @@ -61,7 +70,10 @@ export async function addProduct(productData, accessToken) { const data = await res.json(); if (!res.ok) { - throw new Error(data.message || "에러가 발생했습니다."); + throw { + status: res.status, + message: data.message || "에러가 발생했습니다.", + }; } return data; @@ -79,7 +91,10 @@ export async function deleteProduct(productId, accessToken) { const data = await res.json(); if (!res.ok) { - throw new Error(data.message || "에러가 발생했습니다."); + throw { + status: res.status, + message: data.message || "에러가 발생했습니다.", + }; } return data; From b006c7cce20e05e2b13313d28de53e2c5da426fc Mon Sep 17 00:00:00 2001 From: "chanki.kim" Date: Tue, 26 Nov 2024 12:08:47 +0900 Subject: [PATCH 13/76] =?UTF-8?q?feat:=20auth=20context=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20-=20=EC=9A=94=EC=B2=AD=EC=8B=A4=ED=8C=A8=EC=8B=9C?= =?UTF-8?q?=20=EB=A6=AC=ED=94=84=EB=A0=88=EC=8B=9C=ED=86=A0=ED=81=B0?= =?UTF-8?q?=EC=9D=B4=20=EC=9E=88=EC=9C=BC=EB=A9=B4=20=ED=86=A0=ED=81=B0?= =?UTF-8?q?=EC=9E=AC=EB=B0=9C=EA=B8=89=ED=9B=84=20=EC=9E=AC=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20-=20?= =?UTF-8?q?=EC=97=91=EC=84=B8=EC=8A=A4=ED=86=A0=ED=81=B0=EC=9D=84=20?= =?UTF-8?q?=EB=8B=B4=EC=95=84=EC=84=9C=20=EC=9A=94=EC=B2=AD=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=ED=86=B5=EC=8B=A0=20=ED=95=A8=EC=88=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20accesstoken=EC=9D=B4=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EB=90=98=EB=A9=B4=20=EC=9C=A0=EC=A0=80=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=EB=A5=BC=20=EA=B0=80=EC=A0=B8=EC=98=A4=EB=8F=84=EB=A1=9D=20use?= =?UTF-8?q?Effect=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/context/useAuth.jsx | 57 ++++++++++++++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 9 deletions(-) diff --git a/src/context/useAuth.jsx b/src/context/useAuth.jsx index 5464dd95e..325cc231a 100644 --- a/src/context/useAuth.jsx +++ b/src/context/useAuth.jsx @@ -1,5 +1,5 @@ import { createContext, useContext, useEffect, useState } from "react"; -import { getUser, login } from "@service/auth"; +import { getUser, login, refreshAccessToken } from "@service/auth"; const AuthContext = createContext(); @@ -11,19 +11,37 @@ export function AuthProvider({ children }) { })); useEffect(() => { - //마운트시 토큰으로 유저정보 가져오기 (실패하면 로그아웃 처리) + if (!auth.accessToken) return; (async function getUserData() { - if (auth.accessToken) { - try { - const userData = await getUser(auth.accessToken); - setAuth((prev) => ({ ...prev, user: userData })); - } catch (err) { - console.error(err); + try { + const userData = await getUser(auth.accessToken); + setAuth((prev) => ({ ...prev, user: userData })); + } catch (err) { + if (err.status === 401 && auth.refreshToken) { + await handleRefreshToken(); + } else { + console.log(err); clear(); } } })(); - }, []); + }, [auth.accessToken]); + + async function handleRefreshToken() { + try { + const { accessToken: newAccessToken } = await refreshAccessToken( + auth.refreshToken + ); + + localStorage.setItem("accessToken", newAccessToken); + setAuth((prev) => ({ ...prev, accessToken: newAccessToken })); + + return newAccessToken; + } catch (err) { + console.log(err); + clear(); + } + } async function handleLogin({ email, password }) { try { @@ -64,10 +82,31 @@ export function AuthProvider({ children }) { window.location.replace("/"); } + async function asyncWithAuth(asyncFn, data) { + try { + return await asyncFn(data, auth.accessToken); + } catch (err) { + if (err.status === 401 && auth.refreshToken) { + try { + const newAccessToken = await handleRefreshToken(); + return await asyncFn(data, newAccessToken); + } catch (refreshErr) { + console.error("리프레시 요청 실패"); + clear(); + throw refreshErr; + } + } else { + console.error(err); + throw err; + } + } + } + const value = { auth, handleLogin, handleLogout, + asyncWithAuth, }; return {children}; From c0506aca7873847c534000c4f0b3370697ec4af6 Mon Sep 17 00:00:00 2001 From: "chanki.kim" Date: Tue, 26 Nov 2024 12:09:59 +0900 Subject: [PATCH 14/76] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?-=20useAuth=EC=97=90=EC=84=9C=20asnycWithAuth=EB=A5=BC=20?= =?UTF-8?q?=EA=B0=80=EC=A0=B8=EC=99=80=EC=84=9C=20=EC=A0=9C=EC=B6=9C?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/items/add/index.jsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/pages/items/add/index.jsx b/src/pages/items/add/index.jsx index 6a4cd384f..cb12557bf 100644 --- a/src/pages/items/add/index.jsx +++ b/src/pages/items/add/index.jsx @@ -57,9 +57,7 @@ const formSchema = { export default function ItemAdd() { const { register, isFormValid, handleChange, handleSubmit } = useForm(formSchema); - const { - auth: { accessToken }, - } = useAuth(); + const { asyncWithAuth } = useAuth(); const navigate = useNavigate(); async function onSubmit(data) { @@ -67,7 +65,7 @@ export default function ItemAdd() { if (data.images) { const imgFormData = new FormData(); imgFormData.append("image", data.images); - const { url } = await uploadProductImage(imgFormData, accessToken); + const { url } = await asyncWithAuth(uploadProductImage, imgFormData); data.images = [url]; } @@ -75,7 +73,7 @@ export default function ItemAdd() { data.tags = data.tags.split(" "); } - const res = await addProduct(data, accessToken); + const res = await asyncWithAuth(addProduct, data); alert("성공적으로 작성했습니다."); navigate("/items"); } catch (err) { From 6d438e0ca6728bce119abfb811d91291112309be Mon Sep 17 00:00:00 2001 From: "chanki.kim" Date: Tue, 26 Nov 2024 12:10:07 +0900 Subject: [PATCH 15/76] =?UTF-8?q?chore:=20=EC=82=AC=EC=9A=A9=EC=95=88?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Header/index.jsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/components/Header/index.jsx b/src/components/Header/index.jsx index fe88eb29c..3af387028 100644 --- a/src/components/Header/index.jsx +++ b/src/components/Header/index.jsx @@ -1,14 +1,9 @@ -import { useAuth } from "@context/useAuth"; import Logo from "./Logo"; import Nav from "./Nav"; import Util from "./Util"; import styles from "./styles.module.scss"; export default function Header({ showNav = false }) { - const { - auth: { user }, - handleLogout, - } = useAuth(); return (