fe-sprint-my-agora-states
├─ data.js
├─ discussion.d.ts
├─ index.html
├─ script.js
└─ style.css
<!DOCTYPE html>
<html lang="en">
<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>My Agora States</title>
<link rel="stylesheet" href="style.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/2.3.8/purify.js"
integrity="sha512-QaF+0tDlqVmwZaQSc0kImgYmw+Cd66TxA5D9X70I5V9BNSqk6yBTbyqw2VEUsVYV5OTbxw8HD9d45on1wvYv7g=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
</head>
<body>
<main>
<h1>My Agora States</h1>
<section class="form__container">
<form action="" method="get" class="form">
<div class="form__input--wrapper">
<div class="form__input--name">
<label for="name">Enter your name: </label>
<input type="text" name="name" id="name" required>
</div>
<div class="form__input--title">
<label for="title">Enter your title: </label>
<input type="text" name="title" id="title" required>
</div>
<div class="form__textbox">
<label for="story">Your question: </label>
<textarea id="story" name="story" placeholder="질문을 작성하세요" required></textarea>
</div>
</div>
<div class="form__submit">
<input type="submit" value="submit">
</div>
</form>
</section>
<section class="buttons">
<button> ← </button>
<button> → </button>
</section>
<section class="discussion__wrapper">
<ul class="discussions__container">
</ul>
</section>
</main>
</body>
<script src="data.js"></script>
<script src="script.js"></script>
</html>
// 목록 구현
let data
const dataFromLocalStorage = localStorage.getItem("agoraStatesDiscussions")
if(dataFromLocalStorage) {
data = JSON.parse(dataFromLocalStorage)
} else {
data = agoraStatesDiscussions.slice()
}
// convertToDiscussion은 아고라 스테이츠 데이터를 DOM으로 바꿔줍니다.
const convertToDiscussion = (obj) => {
const li = document.createElement("li"); // li 요소 생성
li.className = "discussion__container"; // 클래스 이름 지정
const avatarWrapper = document.createElement("div");
avatarWrapper.className = "discussion__avatar--wrapper";
const discussionContent = document.createElement("div");
discussionContent.className = "discussion__content";
const discussionAnswered = document.createElement("div");
discussionAnswered.className = "discussion__answered";
// TODO: 객체 하나에 담긴 정보를 DOM에 적절히 넣어주세요.
// img
const avatarImg = document.createElement("img");
avatarImg.className = 'discussion__avatar--image'
avatarImg.src = obj.avatarUrl
avatarImg.alt = 'avartar of ' + obj.author
avatarWrapper.append(avatarImg)
// 시간 표현
const formatDate = new Date(obj.createdAt).toLocaleString('ko-KR', { timeZone: 'UTC' });
// discussion info
const discussionTitle = document.createElement("h2")
const discussionLink = document.createElement("a")
discussionLink.href = obj.url
discussionLink.textContent = `${obj.title}`
discussionTitle.append(discussionLink)
const discussionInfo = document.createElement('div')
discussionInfo.className = 'discussion__information'
discussionInfo.textContent = `${obj.author} / ${formatDate}`
discussionContent.append(discussionTitle)
discussionContent.append(discussionInfo)
// answered
const discussionAns = document.createElement('div')
discussionAns.textContent = obj.answer ? "✅" : "❌"
discussionAnswered.append(discussionAns)
// answer toggle
const discussionAnsContent = document.createElement('div')
discussionAnsContent.className = 'discussion__answered--contents'
// discussionAnsContent.textContent = obj.answer ? obj.answer.bodyHTML : '';
// discussionAnswered.append(discussionAnsContent)
// 문자열을 DOM으로 변환.
if(obj.answer) {
const parsedHTML = new DOMParser().parseFromString(obj.answer.bodyHTML, 'text/html')
for (const child of parsedHTML.body.childNodes) {
discussionAnsContent.appendChild(child.cloneNode(true));
}
discussionAnswered.appendChild(discussionAnsContent);
}
// click toggle event
li.addEventListener('click', (e) => {
e.preventDefault()
li.classList.toggle("active")
li.scrollIntoView({ behavior: "smooth", block: "center"})
})
li.append(avatarWrapper, discussionContent, discussionAnswered);
return li;
};
// agoraStatesDiscussions 배열의 모든 데이터를 화면에 렌더링하는 함수입니다.
const render = (element, from, to) => {
// for (let i = 0; i < agoraStatesDiscussions.length; i += 1) {
// element.append(convertToDiscussion(agoraStatesDiscussions[i]));
// }
// return;
if(!from && !to) {
from = 0
to = data.length - 1
}
// 기존의 내용 다 지우고 배열에 있는 내용 다 보여주기 --> 새로운 데이터를 표시하기 위한 준비 과정
while (element.firstChild) {
element.removeChild(element.firstChild)
}
for (let i = from; i < to; i += 1) {
element.append(convertToDiscussion(data[i]))
}
return;
};
// 페이지네이션을 위한 변수
let limit = 10
page = 1;
// ul 요소에 agoraStatesDiscussions 배열의 모든 데이터를 화면에 렌더링합니다.
const ul = document.querySelector("ul.discussions__container");
render(ul, 0, limit);
// 페이지네이션 기능 구현
const getPageStartEnd = (limit, page) => {
const len = data.length - 1;
let pageStart = Number(page - 1) * Number(limit) // 해당 페이지의 첫번째 요소 인덱스
let pageEnd = Number(pageStart) + Number(limit); // 해당 페이지의 마지막 요소 인덱스
if(page <= 0) {
pageStart = 0;
}
if(pageEnd >= len) {
pageEnd = len
}
return { pageStart, pageEnd }
}
const buttons = document.querySelector(".buttons")
buttons.children[0].addEventListener("click", () => {
if(page > 1) {
page = page - 1
}
const { pageStart, pageEnd } = getPageStartEnd(limit, page);
render(ul, pageStart, pageEnd)
})
buttons.children[1].addEventListener("click", () => {
if (limit * page < data.length - 1) {
page = page + 1;
}
const { pageStart, pageEnd } = getPageStartEnd(limit, page);
render(ul, pageStart, pageEnd);
});
// form
const form = document.querySelector('.form');
const nameInput = document.querySelector('#name');
const titleInput = document.querySelector('#title');
const storyInput = document.querySelector('#story');
form.addEventListener('submit', function (event) {
event.preventDefault();
const newDiscussion = {
author: nameInput.value,
title: titleInput.value,
bodyHTML: storyInput.value,
avatarUrl: "https://avatars.githubusercontent.com/u/129926357?s=400&u=510f31940547e71fa8d3e5567d609148b8f9bb26&v=4",
answer: null,
createdAt: new Date()
}
// data.unshift(newDiscussion)
// 로컬스토리지에 저장
// localStorage.setItem("agoraStatesDiscussions", JSON.stringify(data))
// render(ul, 0, limit)
titleInput.value = '';
storyInput.value = '';
// 서버 연결
fetch(`http://localhost:4000/discussions`, {
method: 'POST',
body: JSON.stringify(newDiscussion),
headers: { 'Content-Type' : 'application/json' }
})
.then(res => res.json())
.then(res => {
console.log(res)
data.unshift(res)
localStorage.setItem("agoraStatesDiscussions", JSON.stringify(data))
render(ul, 0, limit)
})
})
// 서버와 연결하기
fetch("http://localhost:4000/discussions")
.then(res => res.json())
.then(res => {
data = res;
localStorage.setItem("agoraStatesDiscussions", JSON.stringify(data))
})
.catch(err => {
console.log('Error: ', err)
})
* {
box-sizing: border-box;
list-style: none;
text-decoration: none;
font-size: 20px;
font-family: 'NeoDunggeunmoPro-Regular';
text-align: center;
}
body {
margin: 0 auto;
padding: 0;
display: flex;
justify-content: center;
background-color: rgb(250, 242, 254);
}
main {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
h1 {
color:rgb(206, 83, 189);
}
.form__container {
width: 400px;
padding: 10px;
background-image: linear-gradient(120deg, #fccb90 0%, #d57eeb 100%);
color: rgb(248, 252, 253);
border-radius: 10px;
box-shadow: 2px 4px 10px rgba(72, 52, 112, 0.57);
margin: 0 auto;
}
.form__input--name {
margin-bottom: 10px;
}
.form__input--title {
margin-bottom: 10px;
}
.form__textbox {
margin-bottom: 10px;
}
.form__submit input{
width: 90px;
height: 30px;
font-size: 18px;
font-weight: 300;
border: none;
border-radius: 50px;
background-image: linear-gradient(to right, #fbc2eb 0%, #a6c1ee 51%, #fbc2eb 100%);
background-size: 200% auto;
color: white;
transition: 0.5s;
box-shadow: 0 0 3px #eeddbf;
}
.form__submit input:hover {
background-position: right center;
cursor: pointer;
}
#name,
#title,
#story {
width: 100%;
padding: 7px;
border: none;
border-radius: 5px;
}
#story {
resize: none;
}
.discussions__container{
padding: 0;
}
.discussion__container {
display: flex;
justify-content: space-between;
align-items: center;
margin: 20px 0px;
width: 540px;
gap: 10px;
cursor: pointer;
background-image: linear-gradient(120deg, #f4e2cb 0%, #ffc0fc 100%);
border-radius: 10px;
box-shadow: 2px 4px 8px rgba(119, 106, 144, 0.57);
}
.discussion__answered{
width: 100%;
flex:1;
}
/*제목을 클릭하면 답변 토글*/
.discussion__container.active {
display: flex;
padding: 10px;
flex-direction: column;
}
.discussion__container.active .discussion__answered--contents {
display: block;
margin-top: 10px;
}
.discussion__answered--contents{
display: none;
font-size: 20px;
}
.discussion__content{
display: flex;
flex: 10;
flex-direction: column;
position: relative;
}
.discussion__title {
margin: 0;
position: absolute;
top: 0;
left: 0;
}
a {
color:#232323;
font-size: 18px;
}
.discussion__information {
color:#555555;
font-size: 14px;
/* position: absolute;
right: 0;
bottom: 0; */
}
.discussion__avatar--wrapper{
flex:2;
display: flex;
}
.discussion__avatar--image {
border-radius: 100%;
width: 50px;
position: relative;
left: 5px;
}
.buttons{
margin-top: 20px;
display: flex;
gap: 50px;
}
.buttons > button {
font-size: 30px;
font-weight: 900;
border: none;
border-radius: 50%;
background-image: linear-gradient(to right, #fbc2eb 0%, #a6c1ee 51%, #fbc2eb 100%);
background-size: 200% auto;
color: white;
transition: 0.5s;
box-shadow: 0 0 3px #eeddbf;
height: 40px;
line-height: 40px;
vertical-align: middle;
text-align: center;
}
.buttons > button:hover {
background-position: right center;
cursor: pointer;
}
@font-face {
font-family: 'NeoDunggeunmoPro-Regular';
src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/noonfonts_2302@1.0/NeoDunggeunmoPro-Regular.woff2') format('woff2');
font-weight: normal;
font-style: normal;
}
my-agora-states-server
├─ __test__
│ └─ index.test.js
├─ app.js
├─ controller
│ └─ index.js
├─ repository
│ └─ discussions.js
└─ router
└─ discussions.js
const express = require('express');
const app = express();
const cors = require('cors');
const morgan = require('morgan');
// morgan 미들웨어 : HTTP 요청 logger를 편리하게 사용할 수 있는 미들웨어.
app.use(morgan('tiny')); // tiny = 최소화된 로그 출력
// TODO: cors를 적용합니다.
app.use(cors())
// TODO: Express 내장 미들웨어인 express.json()을 적용합니다.
app.use(express.json({strict : false}))
const port = 4000;
const discussionsRouter = require('./router/discussions');
// TODO: app.use()를 활용하여 /discussions 경로로 라우팅합니다.
app.use('/discussions', discussionsRouter)
// 경로가 '/discussions'일때 discussionsRouter로 감.
app.get('/', (req, res) => {
// 서버 상태 확인을 위해 상태 코드 200과 함께 응답을 보냅니다.
res.status(200).send('fe-sprint-my-agora-states-server');
});
const server = app.listen(port, () => {
console.log(`[RUN] My Agora States Server... | http://localhost:${port}`);
});
module.exports.app = app;
module.exports.server = server;
🔸 morgan : HTTP 요청에 대한 log를 남겨주는 미들웨어
morgan('tiny')
: 최소화된 로그 출력const { agoraStatesDiscussions } = require("../repository/discussions");
const discussionsData = agoraStatesDiscussions;
// POST /discussions id 값 1씩 증가
let lastId = 45;
const generateId = () => {
lastId+= 1;
return lastId
}
const discussionsController = {
findAll: (req, res) => {
// TODO: 모든 discussions 목록을 응답합니다.
// /discussions?author={author} 요청을 수행
const {author} = req.query;
if (author) {
res.status(200).json(discussionsData.filter(discussion => discussion.author === author))
} else {
res.json(discussionsData)
}
},
findById: (req, res) => {
// TODO: 요청으로 들어온 id와 일치하는 discussion을 응답합니다.
const { id } = req.params;
const filteredDataById = discussionsData.filter(discussion => discussion.id === Number(id)) // req.params.id가 string 형태로 들어오기때문
if(filteredDataById.length > 0) { // id와 일치하는 데이터가 존재하는 경우
return res.status(200).json(filteredDataById[0])
} else { // id와 일치하는 데이터가 존재하지 않는 경우
return res.status(404).json({ error: "Discussion not found" })
}
},
addById: (req, res) => {
const discussionId = {...req.body, "id" : generateId() }; // id 1씩 증가
discussionsData.unshift(discussionId);
return res.status(201).json(discussionId);
},
updatedById: (req, res) => {
const { id } = req.params;
const bodyData = req.body;
if(id) {
const discussionIdx = discussionsData.findIndex(discussion => discussion.id === Number(id)) // 수정하고자 하는 id의 데이터의 인덱스를 찾기
if(discussionIdx !== -1) {// 일치하는 idx가 있으면
discussionsData[discussionIdx] = {...discussionsData[discussionIdx], ...bodyData} // 해당 인덱스에 맞는 데이터를 요청된 데이터로 업데이트
return res.status(200).json(discussionsData[discussionIdx])
}
} else {
return res.status(404).json({ error: "Discussion not found" })
}
},
deleteById: (req, res) => {
const { id } = req.params;
if (id) {
const filteredDataById = discussionsData.filter(discussion => discussion.id !== Number(id)) // 요청한 id와 맞지 않는 데이터만 배열에 담아
return res.status(200).json(filteredDataById) // 삭제된 데이터를 제외한 나머지를 보여줌.
} else {
return res.status(404).json({ error: "Discussion not found" })
}
}
};
module.exports = {
discussionsController,
};
const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
module.exports.agoraStatesDiscussions = [
{
id: 45,
createdAt: "2022-05-16T01:02:17Z",
updatedAt: "2022-05-16T01:02:17Z",
title: "koans 과제 진행 중 npm install 오류로 인해 정상 작동 되지 않습니다",
url: "https://github.com/codestates-seb/agora-states-fe/discussions/45",
author: "dubipy",
answer: {
id: "DC_kwDOHOApLM4AKg6M",
createdAt: "2022-05-16T02:09:52Z",
url: "https://github.com/codestates-seb/agora-states-fe/discussions/45#discussioncomment-2756236",
author: "Kingsenal",
bodyHTML:
'...',
avatarUrl: "https://avatars.githubusercontent.com/u/79903256?s=64&v=4",
},
bodyHTML:
'...',
avatarUrl:
"https://avatars.githubusercontent.com/u/97888923?s=64&u=12b18768cdeebcf358b70051283a3ef57be6a20f&v=4",
},
// ...
]
🔸 DOMPurify : XSS 공격을 방지하기 위해 HTML을 정리하고 필터링하여 보안 취약점을 방지.
🔸 JSDOM : JavaScript의 DOM과 브라우저 환경을 모방한 라이브러리. 주로 Node.js 환경에서 사용되며, 서버쪽에서 HTML 문서를 파싱하고 DOM 요소를 조작할 수 있음.
// TODO: discussions 라우터를 완성합니다.
const { discussionsController } = require('../controller');
const { findAll, findById, addById, updatedById, deleteById } = discussionsController;
const express = require('express');
const router = express.Router();
// TODO: 모든 discussions 목록을 조회하는 라우터를 작성합니다.
router.get('/', findAll)
// TODO: :id에 맞는 discussion을 조회하는 라우터를 작성합니다.
router.get('/:id', findById)
router.post('/', addById) // 디스커션 새롭게 추가
router.put('/:id', updatedById) // 디스커션 수정
router.delete('/:id', deleteById) // 디스커션 삭제
module.exports = router;