diff --git a/README.md b/README.md new file mode 100644 index 00000000..8f94780e --- /dev/null +++ b/README.md @@ -0,0 +1,135 @@ +## 과제 체크포인트 + +### 기본과제 + +#### 1) 라우팅 구현: + +- [x] History API를 사용하여 SPA 라우터 구현 + - [x] '/' (홈 페이지) + - [x] '/login' (로그인 페이지) + - [x] '/profile' (프로필 페이지) +- [x] 각 라우트에 해당하는 컴포넌트 렌더링 함수 작성 +- [x] 네비게이션 이벤트 처리 (링크 클릭 시 페이지 전환) +- [x] 주소가 변경되어도 새로고침이 발생하지 않아야 한다. + +#### 2) 사용자 관리 기능: + +- [x] LocalStorage를 사용한 간단한 사용자 데이터 관리 + - [x] 사용자 정보 저장 (이름, 간단한 소개) + - [x] 로그인 상태 관리 (로그인/로그아웃 토글) +- [x] 로그인 폼 구현 + - [x] 사용자 이름 입력 및 검증 + - [x] 로그인 버튼 클릭 시 LocalStorage에 사용자 정보 저장 +- [x] 로그아웃 기능 구현 + - [x] 로그아웃 버튼 클릭 시 LocalStorage에서 사용자 정보 제거 + +#### 3) 프로필 페이지 구현: + +- [x] 현재 로그인한 사용자의 정보 표시 + - [x] 사용자 이름 + - [x] 간단한 소개 +- [x] 프로필 수정 기능 + - [x] 사용자 소개 텍스트 수정 가능 + - [x] 수정된 정보 LocalStorage에 저장 + +#### 4) 컴포넌트 기반 구조 설계: + +- [ ] 재사용 가능한 컴포넌트 작성 + - [ ] Header 컴포넌트 + - [ ] Footer 컴포넌트 +- [x] 페이지별 컴포넌트 작성 + - [x] HomePage 컴포넌트 + - [x] ProfilePage 컴포넌트 + - [x] NotFoundPage 컴포넌트 + +#### 5) 상태 관리 초기 구현: + +- [ ] 간단한 상태 관리 시스템 설계 + - [ ] 전역 상태 객체 생성 (예: 현재 로그인한 사용자 정보) +- [ ] 상태 변경 함수 구현 + - [ ] 상태 업데이트 시 관련 컴포넌트 리렌더링 + +#### 6) 이벤트 처리 및 DOM 조작: + +- [ ] 사용자 입력 처리 (로그인 폼, 프로필 수정 등) +- [ ] 동적 컨텐츠 렌더링 (사용자 정보 표시, 페이지 전환 등) + +#### 7) 기본적인 에러 처리: + +- [ ] 잘못된 라우트 접근 시 404 페이지 표시 +- [ ] 로그인 실패 시 에러 메시지 표시 + +### 심화과제 + +#### 1) 해시 라우터 구현 + +- [ ] location.hash를 이용하여 SPA 라우터 구현 + - [ ] '/#/' (홈 페이지) + - [ ] '/#/login' (로그인 페이지) + - [ ] '/#/profile' (프로필 페이지) + +#### 2) 라우트 가드 구현 + +- [ ] 로그인 상태에 따른 접근 제어 +- [ ] 비로그인 사용자의 특정 페이지 접근 시 로그인 페이지로 리다이렉션 + +#### 3) 이벤트 위임 + +- [ ] 이벤트 위임 방식으로 이벤트를 관리하고 있다. + +## 과제 셀프회고 + + + +### 기술적 성장 + + + +### 코드 품질 + + + +### 학습 효과 분석 + + + +### 과제 피드백 + + + +## 리뷰 받고 싶은 내용 + + diff --git a/package-lock.json b/package-lock.json index 8a563559..3ac04d56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "front-4th-chapter1-1", "version": "0.0.0", + "dependencies": { + "playwright": "^1.49.1" + }, "devDependencies": { "@eslint/js": "^9.16.0", "@playwright/test": "^1.49.1", @@ -2932,7 +2935,6 @@ "version": "1.49.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz", "integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==", - "dev": true, "dependencies": { "playwright-core": "1.49.1" }, @@ -2950,7 +2952,6 @@ "version": "1.49.1", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz", "integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==", - "dev": true, "bin": { "playwright-core": "cli.js" }, @@ -2962,7 +2963,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "optional": true, "os": [ diff --git a/package.json b/package.json index e535bf69..25dcfb3f 100644 --- a/package.json +++ b/package.json @@ -42,5 +42,8 @@ "prettier": "^3.4.2", "vite": "^6.0.3", "vitest": "^2.1.8" + }, + "dependencies": { + "playwright": "^1.49.1" } } diff --git a/src/components/Footer.js b/src/components/Footer.js new file mode 100644 index 00000000..fb4fd659 --- /dev/null +++ b/src/components/Footer.js @@ -0,0 +1,13 @@ +export const Footer = () => { + const template = ` + + `; + + const init = () => { + console.log("Footer init"); + }; + + return { template, init }; +}; diff --git a/src/components/Header.js b/src/components/Header.js new file mode 100644 index 00000000..fda00068 --- /dev/null +++ b/src/components/Header.js @@ -0,0 +1,71 @@ +import { router } from "../router/router.js"; +import { userStore } from "../store/userStore.js"; + +const navItems = [ + { label: "홈", path: "/" }, + { label: "프로필", path: "/profile" }, + { label: "로그아웃", path: "/", action: "logout", authRequired: true }, + { label: "로그인", path: "/login", guestOnly: true }, +]; + +export const Header = () => { + const getNavItems = () => { + const isLoggedIn = userStore.isLogin(); + return navItems.filter((item) => { + if (item.authRequired && !isLoggedIn) return false; + if (item.guestOnly && isLoggedIn) return false; + return true; + }); + }; + + const template = ` +
+

항해플러스

+
+ + `; + + const init = () => { + const nav = document.querySelector("nav"); + if (nav) { + nav.addEventListener("click", (e) => { + if (e.target.tagName === "A") { + e.preventDefault(); + const path = e.target.getAttribute("href"); + const action = e.target.dataset.action; + + if (action === "logout") { + userStore.logout(); + router.navigate("/"); + return; + } + + if (path === "/profile" && !userStore.isLogin()) { + alert("로그인이 필요한 서비스입니다."); + router.navigate("/login"); + return; + } + + router.navigate(path); + } + }); + } + }; + + return { template, init }; +}; diff --git a/src/components/index.js b/src/components/index.js new file mode 100644 index 00000000..758528c0 --- /dev/null +++ b/src/components/index.js @@ -0,0 +1,2 @@ +export * from "./Header.js"; +export * from "./Footer.js"; diff --git a/src/main.js b/src/main.js index 036c2a38..c1d42230 100644 --- a/src/main.js +++ b/src/main.js @@ -1,241 +1,5 @@ -const MainPage = () => ` -
-
-
-

항해플러스

-
+import { router } from "./router/router.js"; - - -
-
- - -
- -
- -
-
- 프로필 -
-

홍길동

-

5분 전

-
-
-

오늘 날씨가 정말 좋네요. 다들 좋은 하루 보내세요!

-
- - - -
-
- -
-
- 프로필 -
-

김철수

-

15분 전

-
-
-

새로운 프로젝트를 시작했어요. 열심히 코딩 중입니다!

-
- - - -
-
- -
-
- 프로필 -
-

이영희

-

30분 전

-
-
-

오늘 점심 메뉴 추천 받습니다. 뭐가 좋을까요?

-
- - - -
-
- -
-
- 프로필 -
-

박민수

-

1시간 전

-
-
-

주말에 등산 가실 분 계신가요? 함께 가요!

-
- - - -
-
- -
-
- 프로필 -
-

정수연

-

2시간 전

-
-
-

새로 나온 영화 재미있대요. 같이 보러 갈 사람?

-
- - - -
-
-
-
- - -
-
-`; - -const ErrorPage = () => ` -
-
-

항해플러스

-

404

-

페이지를 찾을 수 없습니다

-

- 요청하신 페이지가 존재하지 않거나 이동되었을 수 있습니다. -

- - 홈으로 돌아가기 - -
-
-`; - -const LoginPage = () => ` -
-
-

항해플러스

-
-
- -
-
- -
- -
- -
-
- -
-
-
-`; - -const ProfilePage = () => ` -
-
-
-
-

항해플러스

-
- - - -
-
-

- 내 프로필 -

-
-
- - -
-
- - -
-
- - -
- -
-
-
- - -
-
-
-`; - -document.body.innerHTML = ` - ${MainPage()} - ${ProfilePage()} - ${LoginPage()} - ${ErrorPage()} -`; +window.addEventListener("load", () => { + router.render(); +}); diff --git a/src/pages/ErrorPage.js b/src/pages/ErrorPage.js new file mode 100644 index 00000000..b37f2d46 --- /dev/null +++ b/src/pages/ErrorPage.js @@ -0,0 +1,23 @@ +export const ErrorPage = () => { + const template = ` +
+
+

항해플러스

+

404

+

페이지를 찾을 수 없습니다

+

+ 요청하신 페이지가 존재하지 않거나 이동되었을 수 있습니다. +

+ + 홈으로 돌아가기 + +
+
+ `; + + const init = () => { + console.log("ErrorPage init"); + }; + + return { template, init }; +}; diff --git a/src/pages/LoginPage.js b/src/pages/LoginPage.js new file mode 100644 index 00000000..91d68bc1 --- /dev/null +++ b/src/pages/LoginPage.js @@ -0,0 +1,49 @@ +import { userStore } from "../store/userStore.js"; + +export const LoginPage = () => { + const template = ` +
+
+

항해플러스

+
+
+ +
+
+ +
+ +
+ +
+
+ +
+
+
+ `; + + const init = () => { + const loginForm = document.getElementById("login-form"); + + if (loginForm) { + loginForm.addEventListener("submit", (event) => { + event.preventDefault(); + const username = document.getElementById("username").value.trim(); + const password = document.getElementById("password").value.trim(); + + if (username && password) { + userStore.login({ username }); + alert("로그인 성공"); + window.location.href = "/profile"; + } else { + alert("유효한 사용자 이름과 비밀번호를 입력하세요."); + } + }); + } + }; + + return { template, init }; +}; diff --git a/src/pages/MainPage.js b/src/pages/MainPage.js new file mode 100644 index 00000000..893970e8 --- /dev/null +++ b/src/pages/MainPage.js @@ -0,0 +1,109 @@ +import { Header, Footer } from "../components/index.js"; + +export const MainPage = () => { + const header = Header(); + const footer = Footer(); + const template = ` +
+
+ ${header.template} +
+
+ + +
+ +
+ +
+
+ 프로필 +
+

홍길동

+

5분 전

+
+
+

오늘 날씨가 정말 좋네요. 다들 좋은 하루 보내세요!

+
+ + + +
+
+ +
+
+ 프로필 +
+

김철수

+

15분 전

+
+
+

새로운 프로젝트를 시작했어요. 열심히 코딩 중입니다!

+
+ + + +
+
+ +
+
+ 프로필 +
+

이영희

+

30분 전

+
+
+

오늘 점심 메뉴 추천 받습니다. 뭐가 좋을까요?

+
+ + + +
+
+ +
+
+ 프로필 +
+

박민수

+

1시간 전

+
+
+

주말에 등산 가실 분 계신가요? 함께 가요!

+
+ + + +
+
+ +
+
+ 프로필 +
+

정수연

+

2시간 전

+
+
+

새로 나온 영화 재미있대요. 같이 보러 갈 사람?

+
+ + + +
+
+
+
+ ${footer.template} +
+
+ `; + const init = () => { + console.log("MainPage init"); + header.init(); + footer.init(); + }; + return { template, init }; +}; diff --git a/src/pages/ProfilePage.js b/src/pages/ProfilePage.js new file mode 100644 index 00000000..306a3b52 --- /dev/null +++ b/src/pages/ProfilePage.js @@ -0,0 +1,117 @@ +import { Header, Footer } from "../components/index.js"; +import { userStore } from "../store/userStore.js"; +import { router } from "../router/router.js"; + +export const ProfilePage = () => { + const header = Header(); + const footer = Footer(); + const template = ` +
+
+
+ ${header.template} +
+
+

+ 내 프로필 +

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ ${footer.template} +
+
+
+ `; + + const init = () => { + header.init(); + footer.init(); + + const form = document.getElementById("profile-form"); + const usernameInput = document.getElementById("username"); + const emailInput = document.getElementById("email"); + const bioTextarea = document.getElementById("bio"); + + // 초기값 설정 + const user = userStore.getUser(); + if (user) { + usernameInput.value = user.username || ""; + emailInput.value = user.email || ""; + bioTextarea.value = user.bio || ""; + } + + // 폼 제출 처리 + form.addEventListener("submit", (e) => { + e.preventDefault(); + + const updatedUser = { + username: usernameInput.value.trim(), + email: emailInput.value.trim(), + bio: bioTextarea.value.trim(), + }; + + // 유효성 검사 + if (!updatedUser.username || !updatedUser.email) { + alert("사용자 이름과 이메일은 필수 항목입니다."); + return; + } + + // 사용자 정보 업데이트 + userStore.saveUserToStorage(updatedUser); + alert("프로필이 성공적으로 업데이트되었습니다!"); + router.navigate("/"); + }); + }; + + return { template, init }; +}; diff --git a/src/pages/index.js b/src/pages/index.js new file mode 100644 index 00000000..bd8260af --- /dev/null +++ b/src/pages/index.js @@ -0,0 +1,4 @@ +export * from "./ErrorPage"; +export * from "./LoginPage"; +export * from "./MainPage"; +export * from "./ProfilePage"; diff --git a/src/router/Router.js b/src/router/Router.js new file mode 100644 index 00000000..3ec26030 --- /dev/null +++ b/src/router/Router.js @@ -0,0 +1,60 @@ +import { MainPage, ProfilePage, LoginPage, ErrorPage } from "../pages"; +import { attachProfileFormListeners } from "../pages/ProfilePage"; + +export class Router { + constructor() { + this.routes = {}; + window.addEventListener("popstate", () => this.handlePopState()); + } + + addRoute(path, handler) { + this.routes[path] = handler; + } + + navigateTo(path) { + history.pushState(null, "", path); + this.handleRoute(path); + } + + handlePopState() { + this.handleRoute(window.location.pathname); + } + + handleRoute(path) { + const handler = this.routes[path]; + const appRoot = document.getElementById("root"); + + if (handler && appRoot) { + appRoot.innerHTML = handler(); + + // 각 페이지 렌더링 후 필요한 작업 수행 + if (path === "/profile") { + attachProfileFormListeners(); // 프로필 페이지에 이벤트 리스너 추가 + } + } else if (appRoot) { + appRoot.innerHTML = ErrorPage(); // 404 페이지 처리 + } + } + beforeNavigate(path) { + const userId = localStorage.getItem("userId"); + + // 비로그인 상태에서 /profile 접근 시 로그인 페이지로 리다이렉트 + if (path === "/profile" && !userId) { + this.navigateTo("/login"); + return false; // 현재 경로를 중단 + } + return true; + } +} + +export const router = new Router(); + +const routes = [ + { path: "/", handler: MainPage }, + { path: "/profile", handler: ProfilePage }, + { path: "/login", handler: LoginPage }, +]; + +routes.forEach((route) => { + router.addRoute(route.path, route.handler); +}); diff --git a/src/router/index.js b/src/router/index.js new file mode 100644 index 00000000..b9e37734 --- /dev/null +++ b/src/router/index.js @@ -0,0 +1 @@ +export * from "./router.js"; diff --git a/src/router/router.js b/src/router/router.js new file mode 100644 index 00000000..10f127ed --- /dev/null +++ b/src/router/router.js @@ -0,0 +1,39 @@ +import { MainPage, LoginPage, ProfilePage, ErrorPage } from "../pages"; + +export class Router { + constructor(routes) { + this.routes = routes; + this.currentRoute = window.location.pathname; + this.init(); + } + + init() { + window.addEventListener("popstate", () => { + this.currentRoute = window.location.pathname; + this.render(); + }); + this.render(); + } + + navigate(path) { + this.currentRoute = path; + history.pushState(null, "", path); + this.render(); + } + + render() { + const route = this.routes[this.currentRoute] || this.routes["/NotFound"]; + const { template, init } = route(); + document.body.innerHTML = template; + if (typeof init === "function") init(); + } +} + +const routes = { + "/": MainPage, + "/login": LoginPage, + "/profile": ProfilePage, + "/NotFound": ErrorPage, +}; + +export const router = new Router(routes); diff --git a/src/store/userStore.js b/src/store/userStore.js new file mode 100644 index 00000000..b92689d8 --- /dev/null +++ b/src/store/userStore.js @@ -0,0 +1,43 @@ +class UserStore { + constructor() { + this.user = this.getUserFromStorage(); + this.isLoggedIn = !!this.user; + } + + getUserFromStorage() { + const user = localStorage.getItem("user"); + return user ? JSON.parse(user) : null; + } + isLogin() { + return this.isLoggedIn; + } + saveUserToStorage(user) { + localStorage.setItem("user", JSON.stringify(user)); + this.user = user; + this.isLoggedIn = true; + } + + clearUserFromStorage() { + localStorage.removeItem("user"); + this.user = null; + this.isLoggedIn = false; + } + + login(user) { + this.saveUserToStorage(user); + } + + logout() { + this.clearUserFromStorage(); + } + + getUser() { + return this.user; + } + + isAuthenticated() { + return this.isLoggedIn; + } +} + +export const userStore = new UserStore();