Skip to content
Open
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
cb14e8e
chore: 폴더 구조 세팅, npm 스크립트 추가
glassk Nov 10, 2022
cc2d8f2
feat: Sidebar, DocumentList 컴포넌트 구현
glassk Nov 10, 2022
2b4cbc9
feat: DocumentEditPage, Editor 컴포넌트 구현
glassk Nov 10, 2022
b6a5123
feat: title이 없는 document Untitled로 표기
glassk Nov 10, 2022
ac3673c
feat: Document 생성 request body 형식 변경
glassk Nov 10, 2022
3828078
design: 컴포넌트 기본 스타일링
glassk Nov 11, 2022
0b18965
refactor: Documents fetch 호출 형식 변경
glassk Nov 14, 2022
a234e3f
feat: 글 제목과 document.title 동기화
glassk Nov 14, 2022
ce9007d
rename: constants.js
glassk Nov 14, 2022
e762025
feat: DocumentList 조회, 삭제, 추가 기능 구현
glassk Nov 15, 2022
0a89db0
feat: 루트 문서 추가 버튼 구현
glassk Nov 15, 2022
3f76150
feat: DocumentHeader 컴포넌트 구현
glassk Nov 15, 2022
cade10b
feat: DocumentList 문서 제목 ellipsis 처리
glassk Nov 15, 2022
91bd0a6
refactor: App 컴포넌트로 핸들러 분리
glassk Nov 16, 2022
f1ea665
fix: 하위 문서 수정 시 전달되는 setState 인자 수정
glassk Nov 16, 2022
b8e6874
fix: 스토리지에 동일한 id가 중복 저장되는 에러 해결
glassk Nov 16, 2022
e096a39
feat: DocumentFooter 컴포넌트 구현
glassk Nov 16, 2022
454af02
feat: 404 Error, 뒤로가기 처리
glassk Nov 16, 2022
837e14e
feat: Sidebar Header와 스크롤바 추가
glassk Nov 16, 2022
43b4583
feat: 문서 목록에서 선택된 문서 하이라이트 처리
glassk Nov 16, 2022
5abc075
feat: storage id 변경
glassk Nov 16, 2022
8a16df8
fix: 현재 페이지의 하위 페이지 삭제 시 DocumentFooter 업데이트
glassk Nov 16, 2022
be54a9b
feat: 아이콘 툴팁
glassk Nov 16, 2022
c5506e9
refactor: generateTitle 함수 분리
glassk Nov 16, 2022
0739c20
feat: 페이지 추가 버튼 DocumentList에서 렌더링하도록 수정
glassk Nov 16, 2022
9bf41b3
rename: components 구조 변경
glassk Nov 16, 2022
17d8cd2
chore: npm 스크립트 명령어 수정
glassk Nov 17, 2022
d62ae8e
design: Sidebar 스타일 수정
glassk Nov 17, 2022
05c414a
docs: README.md 수정
glassk Nov 18, 2022
320b486
chore: deploy
glassk Nov 24, 2022
94b90f3
fix: api endpoint
glassk Nov 24, 2022
f3e9c9d
chore: deploy 방식 변경
glassk Nov 25, 2022
27e4bf5
refactor: 열린 토글 목록 관리 코드 개선
glassk Nov 25, 2022
56c2448
refactor: 문서 삭제 핸들러 인자 변경
glassk Nov 25, 2022
eb5b0a4
fix: 문서 편집 중에도 focusout되는 에러 해결
glassk Nov 25, 2022
4480d22
refactor: storage 메서드명 변경
glassk Nov 25, 2022
6631da8
refactor: 문자열 변수로 변경
glassk Nov 25, 2022
81a9ddd
refactor: route url 생성 메서드 분리
glassk Nov 25, 2022
7c67f9a
fix: 사이드바에 커서 올렸을 때 움직이는 현상 해결
glassk Nov 25, 2022
aa363d3
docs: README.md 수정
glassk Nov 25, 2022
3ee56a5
docs: README.md 수정
glassk Nov 27, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 53 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,60 @@
# 📌 4주차 프로젝트[Project1]

## 필수 프로젝트

- 프로젝트 기한
- 프로젝트 수행 기간 : 2022년 11월 8일(화) ~ 2022년 11월 16일(수)
- 멘티 코드 리뷰 기간 : 2022년 11월 17일(목) ~ 2022년 11월 20일(일)
- 멘토 코드 리뷰 기간 : 2022년 11월 17일(목) ~ 2022년 11월 22일(화)
- 코드 리뷰 반영 기간 : 2022년 11월 23일(수) ~ 2022년 11월 25일(금)
- 내용
- **Day 17 [프로젝트] 노션 클로닝 요구사항** 확인 부탁드립니다.


## 📌 과제 설명

- VanillaJS로 [노션(Notion)](https://www.notion.so/ko-kr)을 클로닝했습니다.

### 실행 방법
1. /src/utils/api.js의 1라인에서 `API_END_POINT` 값을 설정해 주세요.
2. 루트 디렉토리에서 터미널로 `npm start` 또는 `npx serve -s`를 실행하여 개발 서버를 띄울 수 있습니다.

### 디렉토리 구조
<img width="30%" src="https://user-images.githubusercontent.com/63575891/202363329-abd996ae-f330-4bee-81a7-05cb50a3ed8b.png">

## 👩‍💻 요구 사항과 구현 내용

- 화면 좌측에 루트 Documents를 불러오는 API를 통해 루트 Documents를 렌더링합니다.
- 루트 Document를 클릭하면 오른쪽 편집기 영역에 해당 Document를 렌더링합니다.
- 해당 루트 Document에 하위 Document가 있는 경우 해당 Document 아래에 트리 형태로 렌더링합니다.
- Document Tree에서 각 Document 우측의 `+` 버튼을 클릭하면, 클릭한 Document의 하위 Document로 새 Document를 생성하고 편집화면으로 넘깁니다.
- 열린 Document 목록의 documentId를 localStorage에 저장하여 새로고침해도 열린 상태를 유지합니다.
- 현재 접속 중인 Document를 하이라이트합니다.
- 루트 Documents나 Sidebar 하단의 버튼을 클릭하여 루트 Document를 추가합니다.
- Document 제목이 긴 경우 생략 처리(`...`)합니다.
- Document Save API를 이용해 지속적으로 서버에 저장합니다.
- Document의 제목을 수정하면 Sidebar와 Document.title에 반영됩니다.
- History API를 이용해 SPA 형태로 만듭니다.
- 루트 URL 접속 시 별도의 편집기를 선택하지 않습니다.
- `/documents/{documentId}` 로 접속 시 해당 Document를 불러와 편집기에 로딩합니다.
- 존재하지 않는 documentId로 접속 시 첫 Document를 렌더링합니다.
- 편집기 최하단에는 현재 편집 중인 Document의 하위 Document를 렌더링합니다.
- 삭제(휴지통) 버튼을 클릭하면 Document를 삭제합니다.
- 현재 편집 중인 Document를 삭제하면 첫 Document를 렌더링하고 Document Tree를 변화합니다.
- 현재 편집 중이 아닌 Document를 삭제하면 Document Tree만 변화합니다.

### 루트 Document 추가 및 수정

<img src="https://user-images.githubusercontent.com/63575891/202364444-b1e0a2b8-8d51-4718-afc0-aeaea15bbca2.gif" />

### 하위 Document 추가 및 수정

<img src="https://user-images.githubusercontent.com/63575891/202364754-470346e2-fce6-473b-9a05-0fe42d68705c.gif" />

### Document 삭제

<img src="https://user-images.githubusercontent.com/63575891/202365057-a118535a-a4ea-49b3-b4d7-598c3d73db0f.gif" />

### 존재하지 않은 Document 접속

<img src="https://user-images.githubusercontent.com/63575891/202364872-e6764c3c-163f-425d-91c0-6cd6e38f4b12.gif" />

### 현재 편집 중인 Document의 하위 Document 렌더링

<img src="https://user-images.githubusercontent.com/63575891/202368323-26148d38-93d6-4fed-b6ce-d322cab10689.gif" />
16 changes: 16 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Notion</title>
<link rel="stylesheet" href="/src/styles/reset.css">
<link rel="stylesheet" href="/src/styles/style.css">
<script src="https://kit.fontawesome.com/0ab4707042.js" crossorigin="anonymous"></script>
</head>
<body>
<main id="app"></main>
<script src="/src/main.js" type="module"></script>
</body>
</html>
14 changes: 14 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "fedc3-4_project_notion_vanillajs",
"version": "1.0.0",
"description": "Notion 클로닝",
"scripts": {
"start": "npx serve -s"
},
"repository": {
"type": "git",
"url": "git+https://github.com/prgrms-fe-devcourse/FEDC3-4_Project_Notion_VanillaJS.git"
},
"author": "yurikim",
"license": "ISC"
}
146 changes: 146 additions & 0 deletions src/components/App.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import Sidebar from './Sidebar/Sidebar.js';
import DocumentEditPage from './DocumentEditPage/DocumentEditPage.js';

import {
DEFAULT_DOCUMENT_ID,
NEW,
NEW_PARENT,
OPENED_ITEMS,
ROUTE_DOCUMENTS,
} from '../utils/constants.js';
import { isNew, setDocumentTitle } from '../utils/helper.js';
import { initRouter, push } from '../utils/router.js';
import { fetchDocuments } from '../utils/api.js';
import { getItem, removeItem, setItem } from '../utils/storage.js';

export default function App({ $target }) {
isNew(new.target);

let timer = null;

const onAdd = async () => {
push(`${ROUTE_DOCUMENTS}/${NEW}`);

const createdDocument = await fetchDocuments('', {
method: 'POST',
body: JSON.stringify({
title: '',
parent: getItem(NEW_PARENT, null),
}),
});

history.replaceState(
null,
null,
`${ROUTE_DOCUMENTS}/${createdDocument.id}`
);
removeItem(NEW_PARENT);

documentEditPage.setState({ documentId: createdDocument.id });

sidebar.setState({
selectedId: parseInt(createdDocument.id),
});
};

const onDelete = async (documentId) => {
if (documentId === DEFAULT_DOCUMENT_ID) {
alert('첫 페이지는 지우지 말아주세요 :D');
return;
}

if (!confirm('페이지를 삭제하시겠습니까?')) return;

await fetchDocuments(documentId, {
method: 'DELETE',
});

const openedItems = getItem(OPENED_ITEMS, []);
const index = openedItems.indexOf(documentId);
if (index > -1) {
setItem(OPENED_ITEMS, [
...openedItems.slice(0, index),
...openedItems.slice(index + 1),
]);
}

const currentId = documentEditPage.state.documentId;
if (currentId === documentId) {
documentEditPage.setState({ documentId: DEFAULT_DOCUMENT_ID });
push(`${ROUTE_DOCUMENTS}/${DEFAULT_DOCUMENT_ID}`);
} else {
documentEditPage.setState({ documentId: currentId });
}

sidebar.render();
};

const onEdit = ({ id, title, content }) => {
if (timer !== null) {
clearTimeout(timer);
}
timer = setTimeout(async () => {
const editedDocument = await fetchDocuments(id, {
method: 'PUT',
body: JSON.stringify({ title, content }),
});

documentEditPage.setState({
documentId: editedDocument.id,
document: editedDocument,
});

sidebar.render();
}, 1000);
};

const sidebar = new Sidebar({
$target,
initialState: {
selectedId: null,
},
onAdd,
onDelete,
});

const documentEditPage = new DocumentEditPage({
$target,
initialState: {
documentId: null,
document: {
title: '',
content: '',
},
},
onDelete,
onEdit,
});

this.route = () => {
const { pathname } = window.location;

if (pathname === '/') {
setDocumentTitle('Notion');
return;
}

if (pathname.indexOf(ROUTE_DOCUMENTS) !== 0) return;

const [, , documentId] = pathname.split('/');
documentEditPage.setState({
documentId: isNaN(documentId) ? documentId : parseInt(documentId),
});

if (!isNaN(documentId)) {
sidebar.setState({
selectedId: parseInt(documentId),
});
}
};

window.addEventListener('popstate', () => this.route());

this.route();

initRouter(() => this.route());
}
105 changes: 105 additions & 0 deletions src/components/DocumentEditPage/DocumentEditPage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import Editor from './Editor.js';
import DocumentHeader from './DocumentHeader.js';
import DocumentFooter from './DocumentFooter.js';

import { fetchDocuments } from '../../utils/api.js';
import {
NEW,
ROUTE_DOCUMENTS,
DEFAULT_DOCUMENT_ID,
} from '../../utils/constants.js';
import { isNew, setDocumentTitle } from '../../utils/helper.js';
import { push } from '../../utils/router.js';

export default function DocumentEditPage({
$target,
initialState,
onDelete,
onEdit,
}) {
isNew(new.target);

const $page = document.createElement('div');
$page.className = 'document-edit-page';

this.state = initialState;

const documentHeader = new DocumentHeader({
$target: $page,
initialState: this.state,
onDelete,
});

const editor = new Editor({
$target: $page,
initialState: {
title: '',
content: '',
},
onEdit,
});

const documentFooter = new DocumentFooter({
$target: $page,
initialState: {
document: null,
},
});

this.setState = async (nextState) => {
if (this.state.documentId === nextState.documentId && nextState.document) {
this.state = { ...this.state, ...nextState };
editor.setState(
this.state.document || {
title: '',
content: '',
}
);
documentHeader.setState(this.state);
documentFooter.setState({
document: this.state.document,
});
this.render();
return;
}

this.state = { ...this.state, ...nextState };

if (this.state.documentId === NEW) {
editor.setState({
title: '',
content: '',
});
documentHeader.setState(this.state);
documentFooter.setState({
document: null,
});
this.render();
} else {
await loadDocument();
}
};

const loadDocument = async () => {
const document = await fetchDocuments(this.state.documentId);
if (!document) {
alert('존재하지 않는 페이지입니다. 첫 페이지로 이동합니다.');
push(`${ROUTE_DOCUMENTS}/${DEFAULT_DOCUMENT_ID}`);
return;
}

this.setState({
...this.state,
document,
});
documentFooter.setState({
document: this.state.document,
});
setDocumentTitle(this.state.document?.title || '');
};

this.render = () => {
$target.appendChild($page);
setDocumentTitle(this.state.document?.title || '');
};
}
44 changes: 44 additions & 0 deletions src/components/DocumentEditPage/DocumentFooter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { ROUTE_DOCUMENTS } from '../../utils/constants.js';
import { push } from '../../utils/router.js';
import { generateTitle } from '../../utils/helper.js';

export default function DocumentFooter({ $target, initialState }) {
const $footer = document.createElement('footer');
$footer.className = 'document-footer';

$target.appendChild($footer);

this.state = initialState;

this.setState = (nextState) => {
this.state = { ...this.state, ...nextState };
this.render();
};

this.render = () => {
if (!this.state.document || !this.state.document.documents) return;

const { documents } = this.state.document;

$footer.innerHTML = `
<div class="titles">
${documents
.map(
({ id, title }) =>
`<p data-id="${id}" class="title">${generateTitle(title)}</p>`
)
.join('')}
</div>
`;
};

$footer.addEventListener('click', (e) => {
const $title = e.target.closest('.title');
if (!$title) return;

const { id } = $title.dataset;
push(`${ROUTE_DOCUMENTS}/${parseInt(id)}`);
});

this.render();
}
Loading