diff --git a/README.md b/README.md index 39a34a4535..ec32124886 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,36 @@ -# 13주차 +# 15 주차 ## 기본 구현 사항 -- [x] TypeScript를 활용해 프로젝트의 필요한 곳에 타입을 명시해 주세요. -- [x] 검색어를 입력하면 현재 폴더에 있는 링크들 중 “url”, “title”, “description”에 검색어가 포함된 링크들만 필터해서 보이게 해주세요. -- [x] x 버튼을 클릭하면 입력값이 없던 ui 상태로 돌아갑니다. +- [x] 로그인/회원가입시 성공 응답으로 받은 accessToken을 로컬 스토리지에 저장합니다. +- [x] 로그인/회원가입 페이지에 접근시 로컬 스토리지에 accessToken이 있는 경우 “/folder” 페이지로 이동합니다. +- [x] “회원 가입하기”를 클릭하면 ‘/signup’ 페이지로 이동합니다. +- [x] 이메일 input에 placeholder는 “이메일을 입력해 주세요.”비밀번호 input에 placeholder는 “비밀번호를 입력해 주세요.”로 설정해 주세요. +- [x] 이메일 input에서 focus out 할 때, 값이 없을 경우 아래에 “이메일을 입력해 주세요.” 에러 메세지를 보입니다. +- [x] 이메일 input에서 focus out 할 때, 이메일 형식에 맞지 않는 값이 있는 경우 아래에 “올바른 이메일 주소가 아닙니다.” 에러 메세지를 보입니다. +- [x] 비밀번호 input에서 focus out 할 때, 값이 없을 경우 아래에 “비밀번호를 입력해 주세요.” 에러 메세지를 보입니다. +- [x] 로그인 실패하는 경우, 이메일 input 아래에 “이메일을 확인해 주세요.”, 비밀번호 input 아래에 “비밀번호를 확인해 주세요.” 에러 메세지를 보입니다. +- [x] 로그인 버튼 클릭 또는 Enter키 입력으로 로그인 실행돼야 합니다. +- [x] https://bootcamp-api.codeit.kr/docs 에 명세된 “/api/sign-in”으로 { “email”: “test@codeit.com”, “password”: “sprint101” } POST 요청해서 성공 응답을 받으면 “/folder”로 이동합니다. +- [ ] 소셜 로그인에 구글 아이콘 클릭시 ‘https://www.google.com’카카오 아이콘 클릭시 ‘https://www.kakaocorp.com/page’로 이동하게 해주세요. +- [x] 눈 모양 아이콘 클릭시 비밀번호의 문자열이 보이기도 하고, 가려지기도 합니다. +- [x] 비밀번호의 문자열이 가려질 때는 눈 모양 아이콘에는 사선이 그어져있고, 비밀번호의 문자열이 보일 때는 사선이 없는 눈 모양 아이콘이 보이도록 합니다. ## 심화 구현 사항 -- [ ] 상단에 있던 링크 추가하기 영역이 가려져 보이지 않을 때 최하단에 링크 추가하기 영역을 고정하도록 만들어 주세요. -- [ ] 푸터가 시작되는 지점에서는 최하단에 고정된 링크 추가하기 영역이 보이지 않게 해주세요.(IntersectionObserver를 활용해 보세요.) +- [x] 로그인, 회원가입 기능에 react-hook-form을 활용해 주세요. -# 14주차 +# 16 주차 ## 기본 구현 사항 -- [x] 기존 React 프로젝트에서 진행했던 작업물을 Next.js 프로젝트에 맞게 변경 및 이전해 주세요. -- [x] next/link의 Link를 활용해 Linkbrary 아이콘을 클릭하면 ‘/’ 페이지로 이동하게 해주세요. +- [ ] 링크 공유 페이지의 url path를 ‘/shared’에서 ‘/shared/{folderId}’로 변경해 주세요. +- [ ] 폴더의 정보는 ‘/api/folders/{folderId}’, 폴더 소유자의 정보는 ‘/api/users/{userId}’를 활용해 주세요. +- [ ] 링크 공유 페이지에서 폴더의 링크 데이터는 ‘/api/users/{userId}/links?folderId={folderId}’를 사용해 주세요. +- [ ] 폴더 페이지에서 유저가 access token이 없는 경우 ‘/signin’페이지로 이동하게 해주세요. +- [ ] 테스트 유저는 id: “codeit@codeit.com”, pw: “sprint101” 를 활용해 보세요. +- [ ] 폴더 페이지의 url path가 ‘/folder’일 경우 폴더 목록에서 “전체” 가 선택되어 있고, ‘/folder/{folderId}’일 경우 폴더 목록에서 {folderId} 에 해당하는 폴더가 선택되어 있고 폴더에 있는 링크들을 볼 수 있게 해주세요. +- [ ] 폴더 페이지에서 현재 유저의 폴더 목록 데이터를 받아올 때 ‘/api/folders’를 활용해 주세요. +- [ ] 폴더 페이지에서 전체 링크 데이터를 받아올 때 ‘/api/links’, 특정 폴더의 링크를 받아올 때 ‘/api/links?folderId={folderId}’를 활용해 주세요. +- [ ] 유효한 access token이 있는 경우 ‘/api/users’로 현재 로그인한 유저 정보를 받아 상단 네비게이션 유저 프로필을 보이게 해주세요. -# css 적용 -- [x] css 적용 방식을 선택한다. (css in js vs tailwind) - -[x] 테일윈드의 경우, 컴포넌트 단위부터 조금씩 바꾼다. -- [x] css in js를 사용한 경우, 그 이유를 분명히 한다. -- next는 css in js를 선호하지 않는다. 이를 고찰한다. - -# app Router 적용 -- [x] 앱 라우터를 적용한다. - - [x] pages를 제거한다. - - [x] pages의 _app.tsx, _documents.tsx 내용은 app/layout.tsx로 이전한다. - - [x] pages의 index.tsx는 app/page.tsx에 이전한다. - - [x] styles를 제거한다. -- [x] 만약, pr 머지 충돌이 일어날 경우, 원격 저장소의 내용을 위와 같은 절차로 제거한다. - -# 서버 컴포넌트 사용 -- [x] 데이터를 호출해야 하는 경우, 서버 컴포넌트로 분류한다. - - [x] 서버 컴포넌트는 클라이언트 컴포넌트에서 호출(import)이 불가능하다. -> {children}으로 호출한다. 이는, 비동기 렌더링을 적용하기 위함이다. - - [x] useState, useEffect와 같은 생명 주기, 상태를 가질 수 없다. 즉, 1번 렌더링 된다. -- [x] 클라이언트 컴포넌트와 서버 컴포넌트를 엄격히 분리한다. +## 심화 구현 사항 +- [ ] 리퀘스트 헤더에 인증 토큰을 첨부할 때 axios interceptors 또는 이와 유사한 기능을 활용해 주세요. -# 클라이언트 컴포넌트 사용 -- [x] 클라이언트 컴포넌트 내부에서는 데이터를 호출하지 않는다. -- [x] 컴포넌트 포함 관계를 통해, 서버에서 렌더링이 되고 있는지, 브라우저에서 렌더링이 되고 있는지를 분명히 구별한다. 이를 통해, 웹 사이트를 최적화한다. \ No newline at end of file +## 추가 구현 사항 +- [ ] search 부드럽게 적용하기. (spa로 이용) \ No newline at end of file diff --git a/apis/api.ts b/apis/api.ts index 58ec966742..a701636c69 100644 --- a/apis/api.ts +++ b/apis/api.ts @@ -1,12 +1,21 @@ +const API_URL = { + USER: "https://bootcamp-api.codeit.kr/api/users/1", + LINK: "https://bootcamp-api.codeit.kr/api/users/1/links", + FOLDER_LIST: "https://bootcamp-api.codeit.kr/api/users/1/folders", + SHARED: "https://bootcamp-api.codeit.kr/api/sample/folder", + USER_CHECK: "https://bootcamp-api.codeit.kr/api/check-email", + SIGN_IN: "https://bootcamp-api.codeit.kr/api/sign-in", +}; + export async function getUser() { - const response = await fetch("https://bootcamp-api.codeit.kr/api/users/1"); + const response = await fetch(API_URL.USER); const body = await response.json(); return body; } export async function getLink(folderId: string | null) { if (!folderId) { - const response = await fetch("https://bootcamp-api.codeit.kr/api/users/1/links"); + const response = await fetch(API_URL.LINK); const body = await response.json(); return body; } @@ -16,13 +25,45 @@ export async function getLink(folderId: string | null) { } export async function getFolderList() { - const response = await fetch("https://bootcamp-api.codeit.kr/api/users/1/folders"); + const response = await fetch(API_URL.FOLDER_LIST); const body = await response.json(); return body; } export async function getShared() { - const response = await fetch("https://bootcamp-api.codeit.kr/api/sample/folder"); + const response = await fetch(API_URL.SHARED); const body = await response.json(); return body; } + +export async function duplicationCheck(email: string) { + const response = await fetch(API_URL.USER_CHECK, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ email: email }), + }); + + if (response.ok === false) { + throw new Error("duplication"); + } + + return response; +} + +export async function signIn(email: string, password: string) { + const response = await fetch(API_URL.SIGN_IN, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ email: email, password: password}), + }); + + if (response.ok === false) { + throw new Error("login failed"); + } + + return response; +} diff --git a/app/(sign)/layout.module.scss b/app/(sign)/layout.module.scss new file mode 100644 index 0000000000..babd61725a --- /dev/null +++ b/app/(sign)/layout.module.scss @@ -0,0 +1,26 @@ +@import "@/styles/global.scss"; + +.container { + display: flex; + justify-content: center; + min-height: 100vh; + padding: calc(120 / 844 * 100vh) 3.2rem 5rem; + background-color: $color-light-blue; + + @include tablet { + padding-top: calc(200 / 982 * 100vh); + } + + @include desktop { + padding-top: calc(238 / 982 * 100vh); + } +} + +.items { + display: flex; + flex-direction: column; + align-items: center; + row-gap: 3rem; + width: 100%; + max-width: 40rem; +} diff --git a/app/(sign)/layout.tsx b/app/(sign)/layout.tsx new file mode 100644 index 0000000000..f5ab7015de --- /dev/null +++ b/app/(sign)/layout.tsx @@ -0,0 +1,16 @@ +import classNames from "classnames/bind"; +import styles from "./layout.module.scss"; + +const cx = classNames.bind(styles); + +const signLayout = ({ children }: { children: React.ReactNode }) => { + return ( +
+
+ {children} +
+
+ ); +}; + +export default signLayout; diff --git a/app/(sign)/signin/page.tsx b/app/(sign)/signin/page.tsx new file mode 100644 index 0000000000..e7e51a96ec --- /dev/null +++ b/app/(sign)/signin/page.tsx @@ -0,0 +1,18 @@ +import { ROUTE } from "@/lib/constant"; + +import SignHeader from "@/components/sign/SignHeader"; +import SignInForm from "@/components/sign/SignInForm"; + +const page = () => { + return ( + <> + + + + ); +}; + +export default page; diff --git a/app/(sign)/signup/page.tsx b/app/(sign)/signup/page.tsx new file mode 100644 index 0000000000..a2fc9e8cc6 --- /dev/null +++ b/app/(sign)/signup/page.tsx @@ -0,0 +1,18 @@ +import { ROUTE } from "@/lib/constant"; + +import SignHeader from "@/components/sign/SignHeader"; +import SignUpForm from "@/components/sign/SignUpForm"; + +const page = () => { + return ( + <> + + + + ); +}; + +export default page; diff --git a/app/layout.tsx b/app/layout.tsx index cf27e5f6c9..d8bda1a6a4 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,7 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; import './globals.css' +import "@/styles/reset.css"; const inter = Inter({ subsets: ["latin"] }); diff --git a/app/shared/page.tsx b/app/shared/page.tsx index e69de29bb2..983ebb85bc 100644 --- a/app/shared/page.tsx +++ b/app/shared/page.tsx @@ -0,0 +1,9 @@ +import React from 'react' + +const page = () => { + return ( +
page
+ ) +} + +export default page \ No newline at end of file diff --git a/components/sign/Cta.module.scss b/components/sign/Cta.module.scss new file mode 100644 index 0000000000..bcfc246560 --- /dev/null +++ b/components/sign/Cta.module.scss @@ -0,0 +1,12 @@ +@import "@/styles/global.scss"; + +.container { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + border-radius: 0.8rem; + background: linear-gradient(91deg, $color-primary 0.12%, #6ae3fe 101.84%); + color: $color-gray-light; +} diff --git a/components/sign/Cta.tsx b/components/sign/Cta.tsx new file mode 100644 index 0000000000..9c7285e455 --- /dev/null +++ b/components/sign/Cta.tsx @@ -0,0 +1,16 @@ +import { ReactNode } from "react"; + +import styles from "./Cta.module.scss"; +import classNames from "classnames/bind"; + +const cx = classNames.bind(styles); + +type CtaProps = { + children: ReactNode; +}; + +const Cta = ({ children }: CtaProps) => { + return
{children}
; +}; + +export default Cta \ No newline at end of file diff --git a/components/sign/SignHeader.module.scss b/components/sign/SignHeader.module.scss new file mode 100644 index 0000000000..158b161d40 --- /dev/null +++ b/components/sign/SignHeader.module.scss @@ -0,0 +1,30 @@ +@import "@/styles/global.scss"; + +.container { + display: flex; + flex-direction: column; + align-items: center; + row-gap: 1.6rem; +} + +.logo { + width: fit-content; + height: 3.8rem; +} + +.message-box { + display: flex; + column-gap: 0.8rem; + font-size: 1.6rem; +} + +.message { + line-height: 150%; +} + +.link { + height: fit-content; + font-weight: 600; + color: $color-primary; + border-bottom: solid 0.1rem $color-primary; +} diff --git a/components/sign/SignHeader.tsx b/components/sign/SignHeader.tsx new file mode 100644 index 0000000000..ca5ceb62b5 --- /dev/null +++ b/components/sign/SignHeader.tsx @@ -0,0 +1,39 @@ +import Image from "next/image"; +import Link from "next/link"; +import { Url } from "next/dist/shared/lib/router/router"; + +import LinkbraryIcon from "@/public/logo.svg"; +import { ROUTE } from "@/lib/constant"; + +import classNames from "classnames/bind"; +import styles from "./SignHeader.module.scss"; + +const cx = classNames.bind(styles); + +type SignHeaderProps = { + message: string; + link: { + href: Url; + text: string; + }; +}; + +const SignHeader = ({ message, link }: SignHeaderProps) => { + const { href, text } = link; + + return ( +
+ + 로고 + +

+ {message} + + {text} + +

+
+ ); +}; + +export default SignHeader; diff --git a/components/sign/SignInForm.module.scss b/components/sign/SignInForm.module.scss new file mode 100644 index 0000000000..9832427d2c --- /dev/null +++ b/components/sign/SignInForm.module.scss @@ -0,0 +1,23 @@ +.form { + display: flex; + flex-direction: column; + row-gap: 2.4rem; + width: 100%; +} + +.label { + font-size: 1.4rem; +} + +.input-box { + display: flex; + flex-direction: column; + row-gap: 1.2rem; +} + +.button { + margin-top: 0.6rem; + height: 5.4rem; + font-size: 1.8rem; + font-weight: 600; +} diff --git a/components/sign/SignInForm.tsx b/components/sign/SignInForm.tsx new file mode 100644 index 0000000000..bc8ccef19f --- /dev/null +++ b/components/sign/SignInForm.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; + +import { PLACEHOLDER, ERROR_MESSAGE, REGEX } from "@/lib/constant"; +import { signIn } from "@/apis/api"; +import { useTokenToRedirect } from "@/hooks/useTokenToRedirect"; + +import Input from "./form/Input"; +import PasswordInput from "./form/PasswordInput"; +import Cta from "./Cta"; + +import styles from "./SignInForm.module.scss"; +import classNames from "classnames/bind"; + +const cx = classNames.bind(styles); + +const SignInForm = () => { + const { control, handleSubmit } = useForm({ + defaultValues: { email: "", password: "" }, + mode: "onBlur", + }); + const [token, setToken] = useState(''); + useTokenToRedirect(token); + + const onSubmitForSingIn = async (data: any) => { + try { + const loginResponse = await signIn(data?.email, data?.password); + const json = await loginResponse.json(); + + if (json?.data.accessToken) { + localStorage.setItem("accessToken", json.data.accessToken); + } + + setToken(json?.data.accessToken); + } catch (err) { + return false; + } + } + + return ( +
+
+ + ( + + )} + /> +
+
+ + ( + + )} + /> +
+ +
+ ); +}; + +export default SignInForm; diff --git a/components/sign/SignUpForm.module.scss b/components/sign/SignUpForm.module.scss new file mode 100644 index 0000000000..9832427d2c --- /dev/null +++ b/components/sign/SignUpForm.module.scss @@ -0,0 +1,23 @@ +.form { + display: flex; + flex-direction: column; + row-gap: 2.4rem; + width: 100%; +} + +.label { + font-size: 1.4rem; +} + +.input-box { + display: flex; + flex-direction: column; + row-gap: 1.2rem; +} + +.button { + margin-top: 0.6rem; + height: 5.4rem; + font-size: 1.8rem; + font-weight: 600; +} diff --git a/components/sign/SignUpForm.tsx b/components/sign/SignUpForm.tsx new file mode 100644 index 0000000000..cd200978f9 --- /dev/null +++ b/components/sign/SignUpForm.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; + +import { PLACEHOLDER, ERROR_MESSAGE, REGEX } from "@/lib/constant"; +import { duplicationCheck } from "@/apis/api"; +import { useTokenToRedirect } from "@/hooks/useTokenToRedirect"; + +import Input from "./form/Input"; +import PasswordInput from "./form/PasswordInput"; +import Cta from "./Cta"; + +import styles from "./SignUpForm.module.scss"; +import classNames from "classnames/bind"; + +const cx = classNames.bind(styles); + +const SignUpForm = () => { + const { control, handleSubmit, watch } = useForm({ + defaultValues: { email: "", password: "", confirmedPassword: "" }, + mode: "onBlur", + reValidateMode: "onBlur", + }); + + const [token, setToken] = useState(''); + useTokenToRedirect(token); + + const onSubmitForSingUp = async (data: any) => { + // try { + // const loginResponse = await signIn(data?.email, data?.password); + // const json = await loginResponse.json(); + + // if (json?.data.accessToken) { + // localStorage.setItem("accessToken", json.data.accessToken); + // } + + // setToken(json?.data.accessToken); + // } catch (err) { + // return false; + // } + } + + return ( +
+
+ + { + try { + const response = await duplicationCheck(watch("email")); + return true; + } catch (err) { + return ERROR_MESSAGE.signup.emailAlreadyExist; + } + }, + }, + }} + render={({ field, fieldState }) => ( + + )} + /> +
+
+ + ( + + )} + /> +
+
+ + { + if (value !== watch("password")) { + return ERROR_MESSAGE.signup.confirmedPasswordNotMatch; + } + return true; + }, + }, + }} + render={({ field, fieldState }) => ( + + )} + /> +
+ +
+ ); +}; + +export default SignUpForm diff --git a/components/sign/form/Input.module.scss b/components/sign/form/Input.module.scss new file mode 100644 index 0000000000..e97f2b823c --- /dev/null +++ b/components/sign/form/Input.module.scss @@ -0,0 +1,38 @@ +@import "@/styles/global.scss"; + +.container { + width: 100%; + display: flex; + flex-direction: column; + row-gap: 0.6rem; +} + +.input { + width: 100%; + border: 0.1rem solid $color-gray20; + border-radius: 0.8rem; + font-size: 1.6rem; + color: $color-gray100; + padding: 1.8rem 1.5rem; + transition: border-color 0.2s ease-in-out; + + &::placeholder { + color: $color-gray60; + } + + &:focus { + border-color: $color-primary; + } + + &.error { + border-color: $color-red; + } +} + +.helper-text { + font-size: 1.4rem; + + &.error { + color: $color-red; + } +} diff --git a/components/sign/form/Input.tsx b/components/sign/form/Input.tsx new file mode 100644 index 0000000000..52d925111b --- /dev/null +++ b/components/sign/form/Input.tsx @@ -0,0 +1,39 @@ +import { ChangeEventHandler, FocusEventHandler, HTMLInputTypeAttribute, forwardRef } from "react"; + +import styles from "./Input.module.scss"; +import classNames from "classnames/bind"; + +const cx = classNames.bind(styles); + +export type InputProps = { + value: string | number; + placeholder?: string; + type?: HTMLInputTypeAttribute; + hasError?: boolean; + helperText?: string; + onChange: ChangeEventHandler; + onBlur?: FocusEventHandler; +}; + +const Input = forwardRef( + ({ value, placeholder, type = "text", hasError = false, helperText, onChange, onBlur }, ref) => { + return ( +
+ + {helperText &&

{helperText}

} +
+ ); + } +); + +Input.displayName = "Input"; + +export default Input \ No newline at end of file diff --git a/components/sign/form/PasswordInput.module.scss b/components/sign/form/PasswordInput.module.scss new file mode 100644 index 0000000000..83a3838d95 --- /dev/null +++ b/components/sign/form/PasswordInput.module.scss @@ -0,0 +1,13 @@ +.container { + position: relative; + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; +} + +.button { + position: absolute; + top: 2.2rem; + right: 1.5rem; +} diff --git a/components/sign/form/PasswordInput.tsx b/components/sign/form/PasswordInput.tsx new file mode 100644 index 0000000000..8c2a5b9d87 --- /dev/null +++ b/components/sign/form/PasswordInput.tsx @@ -0,0 +1,61 @@ +'use client' + +import Image from "next/image"; + +import { forwardRef, useMemo, useState } from "react"; + +import EyeOnIcon from "@/public/eye-on.svg"; +import EyeOffIcon from "@/public/eye-off.svg"; + +import Input, { InputProps } from "./Input"; + +import classNames from "classnames/bind"; +import styles from "./PasswordInput.module.scss"; + +const cx = classNames.bind(styles); + +type PasswordInputProps = { + hasEyeIcon?: boolean; +} & Omit; + +const PasswordInput = forwardRef( + ( + { hasEyeIcon = false, value, placeholder, hasError = false, helperText, onChange, onBlur }, + ref + ) => { + const [isPasswordVisible, setIsPasswordVisible] = useState(false); + const inputType = useMemo(() => (isPasswordVisible ? "text" : "password"), [isPasswordVisible]); + const EyeIcon = useMemo( + () => ( + + ), + [isPasswordVisible] + ); + + return ( +
+ + {hasEyeIcon && EyeIcon} +
+ ); + } +); + +PasswordInput.displayName = "PasswordInput"; + +export default PasswordInput \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000000..39a34a4535 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,38 @@ +# 13주차 +## 기본 구현 사항 +- [x] TypeScript를 활용해 프로젝트의 필요한 곳에 타입을 명시해 주세요. +- [x] 검색어를 입력하면 현재 폴더에 있는 링크들 중 “url”, “title”, “description”에 검색어가 포함된 링크들만 필터해서 보이게 해주세요. +- [x] x 버튼을 클릭하면 입력값이 없던 ui 상태로 돌아갑니다. + +## 심화 구현 사항 +- [ ] 상단에 있던 링크 추가하기 영역이 가려져 보이지 않을 때 최하단에 링크 추가하기 영역을 고정하도록 만들어 주세요. +- [ ] 푸터가 시작되는 지점에서는 최하단에 고정된 링크 추가하기 영역이 보이지 않게 해주세요.(IntersectionObserver를 활용해 보세요.) + +# 14주차 +## 기본 구현 사항 +- [x] 기존 React 프로젝트에서 진행했던 작업물을 Next.js 프로젝트에 맞게 변경 및 이전해 주세요. +- [x] next/link의 Link를 활용해 Linkbrary 아이콘을 클릭하면 ‘/’ 페이지로 이동하게 해주세요. + +# css 적용 +- [x] css 적용 방식을 선택한다. (css in js vs tailwind) + -[x] 테일윈드의 경우, 컴포넌트 단위부터 조금씩 바꾼다. +- [x] css in js를 사용한 경우, 그 이유를 분명히 한다. +- next는 css in js를 선호하지 않는다. 이를 고찰한다. + +# app Router 적용 +- [x] 앱 라우터를 적용한다. + - [x] pages를 제거한다. + - [x] pages의 _app.tsx, _documents.tsx 내용은 app/layout.tsx로 이전한다. + - [x] pages의 index.tsx는 app/page.tsx에 이전한다. + - [x] styles를 제거한다. +- [x] 만약, pr 머지 충돌이 일어날 경우, 원격 저장소의 내용을 위와 같은 절차로 제거한다. + +# 서버 컴포넌트 사용 +- [x] 데이터를 호출해야 하는 경우, 서버 컴포넌트로 분류한다. + - [x] 서버 컴포넌트는 클라이언트 컴포넌트에서 호출(import)이 불가능하다. -> {children}으로 호출한다. 이는, 비동기 렌더링을 적용하기 위함이다. + - [x] useState, useEffect와 같은 생명 주기, 상태를 가질 수 없다. 즉, 1번 렌더링 된다. +- [x] 클라이언트 컴포넌트와 서버 컴포넌트를 엄격히 분리한다. + +# 클라이언트 컴포넌트 사용 +- [x] 클라이언트 컴포넌트 내부에서는 데이터를 호출하지 않는다. +- [x] 컴포넌트 포함 관계를 통해, 서버에서 렌더링이 되고 있는지, 브라우저에서 렌더링이 되고 있는지를 분명히 구별한다. 이를 통해, 웹 사이트를 최적화한다. \ No newline at end of file diff --git a/hooks/useTokenToRedirect.ts b/hooks/useTokenToRedirect.ts new file mode 100644 index 0000000000..286c57e6e7 --- /dev/null +++ b/hooks/useTokenToRedirect.ts @@ -0,0 +1,24 @@ +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +import { ROUTE } from "@/lib/constant"; + +export function useTokenToRedirect(tokenResponse?: string) { + const router = useRouter(); + + useEffect(() => { + const accessTokenInLocalStorage = localStorage.getItem("accessToken"); + const routeToFolderPage = () => { + router.replace(ROUTE.폴더); + }; + + if (tokenResponse) { + routeToFolderPage(); + return; + } + + if (accessTokenInLocalStorage) { + routeToFolderPage(); + } + }, [tokenResponse, router]); +} \ No newline at end of file diff --git a/lib/constant.ts b/lib/constant.ts new file mode 100644 index 0000000000..9a37d3394f --- /dev/null +++ b/lib/constant.ts @@ -0,0 +1,44 @@ +export const ROUTE = { + 랜딩: "/", + 로그인: "/signin", + 회원가입: "/signup", + 폴더: "/folder", + 개인정보처리방침: "/privacy", + FAQ: "/faq", +}; + +export const PLACEHOLDER = { + signin: { + email: "이메일을 입력해 주세요.", + password: "비밀번호를 입력해 주세요.", + }, + + signup: { + email: "이메일을 입력해 주세요.", + password: "영문, 숫자를 조합해 8자 이상 입력해 주세요.", + confirmedPassword: "비밀번호와 일치하는 값을 입력해 주세요.", + } +} + +export const ERROR_MESSAGE = { + signin: { + emailRequired: "이메일을 입력해 주세요.", + emailInvalid: "올바른 이메일 주소가 아닙니다.", + emailCheck: "이메일을 확인해 주세요.", + passwordRequired: "비밀번호를 입력해 주세요.", + passwordCheck: "비밀번호를 확인해 주세요.", + }, + + signup: { + emailRequired: "이메일을 입력해 주세요.", + emailInvalid: "올바른 이메일 주소가 아닙니다.", + emailAlreadyExist: "이미 사용 중인 이메일입니다.", + passwordInvalid: "비밀번호는 영문, 숫자 조합 8자 이상 입력해 주세요.", + confirmedPasswordNotMatch: "비밀번호가 일치하지 않아요.", + } +} + +export const REGEX = { + EMAIL: /\S+@\S+\.\S+/, + PASSWORD: /^(?=.*[A-Za-z])(?=.*\d).{8,}$/ +} diff --git a/lib/searchData.ts b/lib/searchData.ts index 87f8f44478..0d9d8e0d5e 100644 --- a/lib/searchData.ts +++ b/lib/searchData.ts @@ -4,7 +4,7 @@ interface LinksData { description: string | null; } -export function filterByKeyword(links, keyword: string) { +export function filterByKeyword(links: any, keyword: string) { const lowered = keyword.toLowerCase(); const filteredLinks = links.filter(({ url, title, description }: LinksData) => diff --git a/package-lock.json b/package-lock.json index 2afd9028b2..81f1e8f6ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,13 @@ "dependencies": { "autoprefixer": "^10.4.18", "babel-plugin-styled-components": "^2.1.4", + "classnames": "^2.5.1", "next": "^14.1.3", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.51.2", + "sass": "^1.74.1", + "scss": "^0.2.4", "styled-component": "^2.8.0", "styled-components": "^6.1.8", "tailwindcss": "^3.4.1" @@ -4726,6 +4730,11 @@ "node": ">= 0.4" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" + }, "node_modules/cli-boxes": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", @@ -8033,6 +8042,11 @@ "node": ">= 4" } }, + "node_modules/immutable": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.5.tgz", + "integrity": "sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==" + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -11014,6 +11028,14 @@ "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==" }, + "node_modules/ometa": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/ometa/-/ometa-0.2.2.tgz", + "integrity": "sha512-LZuoK/yjU3FvrxPjUXUlZ1bavCfBPqauA7fsNdwi+AVhRdyk2IzgP3JRnevvjzQ6fKHdUw8YISshf53FmpHrng==", + "engines": { + "node": ">= 0.2.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -11997,6 +12019,21 @@ "react": "^18.2.0" } }, + "node_modules/react-hook-form": { + "version": "7.51.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.2.tgz", + "integrity": "sha512-y++lwaWjtzDt/XNnyGDQy6goHskFualmDlf+jzEZvjvz6KWDf7EboL7pUvRCzPTJd0EOPpdekYaQLEvvG6m6HA==", + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -13001,6 +13038,22 @@ "which": "bin/which" } }, + "node_modules/sass": { + "version": "1.74.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.74.1.tgz", + "integrity": "sha512-w0Z9p/rWZWelb88ISOLyvqTWGmtmu2QJICqDBGyNnfG4OUnPX9BBjjYIXUpXCMOOg5MQWNpqzt876la1fsTvUA==", + "dependencies": { + "chokidar": ">=3.0.0 <4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/saxes": { "version": "3.1.11", "resolved": "https://registry.npmjs.org/saxes/-/saxes-3.1.11.tgz", @@ -13037,6 +13090,17 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/scss": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/scss/-/scss-0.2.4.tgz", + "integrity": "sha512-4u8V87F+Q/upVhUmhPnB4C1R11xojkRkWjExL2v0CX2EXTg18VrKd+9JWoeyCp2VEMdSpJsyAvVU+rVjogh51A==", + "dependencies": { + "ometa": "0.2.2" + }, + "engines": { + "node": ">= 0.2.0" + } + }, "node_modules/select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", diff --git a/package.json b/package.json index 3813ba5bbd..1b613f68fa 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,13 @@ "dependencies": { "autoprefixer": "^10.4.18", "babel-plugin-styled-components": "^2.1.4", + "classnames": "^2.5.1", "next": "^14.1.3", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.51.2", + "sass": "^1.74.1", + "scss": "^0.2.4", "styled-component": "^2.8.0", "styled-components": "^6.1.8", "tailwindcss": "^3.4.1" diff --git a/public/eye-off.svg b/public/eye-off.svg new file mode 100644 index 0000000000..802730c565 --- /dev/null +++ b/public/eye-off.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/eye-on.svg b/public/eye-on.svg new file mode 100644 index 0000000000..61350f1315 --- /dev/null +++ b/public/eye-on.svg @@ -0,0 +1,4 @@ + + + + diff --git a/styles/colors.scss b/styles/colors.scss new file mode 100644 index 0000000000..30ec81d0f5 --- /dev/null +++ b/styles/colors.scss @@ -0,0 +1,16 @@ +$color-primary: #6d6afe; +$color-red: #ff5b56; +$color-black: #111322; +$color-white: #ffffff; + +$color-gray100: #373740; +$color-gray60: #9fa6b2; +$color-gray20: #ccd5e3; +$color-gray10: #e7effb; +$color-gray-light: #f5f5f5; + +$color-light-blue: #f0f6ff; + +$color-text-gray: #676767; +$color-text-content-gray: #666666; +$color-text-content-black: #333333; \ No newline at end of file diff --git a/styles/global.scss b/styles/global.scss new file mode 100644 index 0000000000..7ea93de248 --- /dev/null +++ b/styles/global.scss @@ -0,0 +1,3 @@ +@import "./colors.scss"; +@import "./variables.scss"; +@import "./mixin.scss"; diff --git a/styles/mixin.scss b/styles/mixin.scss new file mode 100644 index 0000000000..721cfd6b97 --- /dev/null +++ b/styles/mixin.scss @@ -0,0 +1,24 @@ +@mixin desktop { + @media (min-width: 1200px) { + @content; + } +} + +@mixin tablet { + @media (min-width: 768px) { + @content; + } +} + +@mixin ellipsis($line: 1) { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap !important; + + @if $line > 1 { + display: -webkit-box; + -webkit-line-clamp: $line; + white-space: initial !important; + -webkit-box-orient: vertical; + } +} diff --git a/styles/reset.css b/styles/reset.css new file mode 100644 index 0000000000..68ef2afe73 --- /dev/null +++ b/styles/reset.css @@ -0,0 +1,38 @@ +* { + box-sizing: border-box; + margin: 0; + font-family: "Pretendard"; + word-break: keep-all; +} + +html, +body { + font-size: 62.5%; +} + +a { + color: inherit; + text-decoration: none; + cursor: pointer; +} + +input { + border: none; + padding: none; +} +input:focus { + outline: none; +} +input[type="search"]::-webkit-search-decoration, +input[type="search"]::-webkit-search-cancel-button, +input[type="search"]::-webkit-search-results-button, +input[type="search"]::-webkit-search-results-decoration { + display: none; +} + +button { + border: none; + padding: unset; + background-color: unset; + cursor: pointer; +} diff --git a/styles/variables.scss b/styles/variables.scss new file mode 100644 index 0000000000..3308e47849 --- /dev/null +++ b/styles/variables.scss @@ -0,0 +1,4 @@ +$z-index-popover: 50; +$z-index-nav: 100; +$z-index-fab: 100; +$z-index-modal: 1000;