[S2U11] 마무리 - 솔로 프로젝트 : 나만의 아고라 스테이츠 클라이언트/서버 만들기

👽·2024년 3월 29일
0
post-thumbnail

⭐ 완성본 ⭐

Client

파일구조

fe-sprint-my-agora-states
   ├─ data.js
   ├─ discussion.d.ts
   ├─ index.html
   ├─ script.js
   └─ style.css

index.html

<!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>

script.js

// 목록 구현
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)
  })

style.css

* {
    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;
}

Server

파일 구조

my-agora-states-server
├─ __test__
│  └─ index.test.js
├─ app.js
├─ controller
│  └─ index.js
├─ repository
│  └─ discussions.js
└─ router
   └─ discussions.js

app.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') : 최소화된 로그 출력

index.js

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,
};

repository/discussions.js

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 요소를 조작할 수 있음.

router/discussions.js

// 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;
profile
코린이👽

0개의 댓글