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 (
+
+ );
+};
+
+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 (
+
+ );
+};
+
+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;