[REAL Python – Flask] – “인스타그램 클론코딩 – Instagram Clone (9)”

[REAL Python – Flask] – “인스타그램 클론코딩 – Instagram Clone (9)”

12월 17, 2022

Frontend 단에서 자바스크립트 코드 개선하기

기존에 있던 js 폴더 구조를 위와 같이 수정합니다. 중복되는 로직과 변수들을 한데 모으고, 분리하여 관리하겠습니다.

auth/hander.js 에서는 하나의 함수만 옮겨두겠습니다. 그리고 이것은 로그인/회원가입을 제외한 모든 페이지에서 호출될 것이기도 합니다. 우리의 서비스는 모두 “로그인을 해야” 이용할 수 있고, “로그인을 했다” 라는 것은 “클라이언트 단에서, 내가 누구인지를 서버에 알린다” 가 되겠죠? 그러면 로컬 스토리지에 액세스 토큰이 없으면 사용자에게 로그인 페이지로 리다이렉트를 시켜 주는 함수를 작성합니다.

/**
 * 액세스 토큰이 localStorage 에 존재하지 않으면,
 * 로그인 페이지로 리다이렉트 처리합니다.
 */
function userLoginRedirectHandler() {
  if (!ACCESS_TOKEN) {
    if (window.location.href == LOGIN_FRONTEND_URL) {
    } else {
      window.location.href = LOGIN_FRONTEND_URL;
    }
  }
}

userLoginRedirectHandler();

auth/login.js 에서는 로그인 관련 로직들을 처리하겠습니다.

/**
 * form 을 선택하고 직렬화된 JSON 을 반환합니다.
 */
function getFormJson() {
  let loginForm = document.querySelector(".login-form");
  let data = new FormData(loginForm);
  let serializedFormData = serialize(data);
  return JSON.stringify(serializedFormData);
}

/**
 * form 데이터를 받아서 JSON으로 직렬화합니다.
 */
function serialize(rawFormData) {
  let result = {};
  for (let [key, value] of rawFormData) {
    let sel = document.querySelectorAll("[name=" + key + "]");
    if (sel.length > 1) {
      if (result[key] === undefined) {
        result[key] = [];
      }
      result[key].push(value);
    } else {
      result[key] = value;
    }
  }
  return result;
}

/**
 * 로그인 정보를 서버에 전송하고, 로컬 스토리지에 JWT를 저장합니다.
 */
async function submitLoginData() {
  var myHeaders = new Headers();
  myHeaders.append("Content-Type", "application/json");

  var requestOptions = {
    method: "POST",
    headers: myHeaders,
    body: getFormJson(),
    redirect: "follow",
  };
  const response = await fetch(LOGIN_API_URL, requestOptions);
  if (response.status == 200) {
    loginResponse = await response.json();
    const access_token = loginResponse["access_token"];
    const refresh_token = loginResponse["refresh_token"];
    localStorage.setItem("ACCESS_TOKEN", access_token);
    localStorage.setItem("REFRESH_TOKEN", refresh_token);
    window.location.href = FRONTEND_SERVER_BASE_URL + "/flastagram/posts";
  } else {
    alert(JSON.stringify(await response.json()));
  }
}

post/post_create.js 에서는 게시물 생성 관련 로직을 처리하겠습니다.

/**
 * 사용자가 이미지 선택을 완료하면,
 * 1. 업로드한 이미지를 띄워주고
 * 2. 서버에 이미지를 업로드합니다.
 * 3. 이미지 업로드의 성공 여부에 따라 에러 메시지를 띄워줍니다.
 * 4. 이미지 업로드가 성공한다면 그것의 path 를 숨겨져 있는 input 태그의 value 로 넣어줍니다.
 */
async function getImageResponse(event) {
  loadPreviewImage(event);
  result = await submitImage();
  // 201로 성공적으로 이미지가 업로드되었다면,
  // 성공 메시지를 띄워주고 해당 이미지의 경로를 반환
  // 그렇지 않다면, 에러 메시지를 띄워줌
  let response = await result.json();
  if (result.status == 201) {
    alert(response["message"]);
    const path = response["path"];
    const imageInput = document.querySelector("#image");
    imageInput.setAttribute("value", path);
  } else {
    alert(JSON.stringify(response));
  }
}

/**
 * 업로드한 이미지를 미리 확인합니다.
 */
function loadPreviewImage(event) {
  var reader = new FileReader();
  reader.onload = function (event) {
    var img = document.createElement("img");
    img.setAttribute("src", event.target.result);
    document.querySelector("div#preview-image").appendChild(img);
  };
  reader.readAsDataURL(event.target.files[0]);
}

/**
 * input 태그에서 선택한 이미지를 서버에 전송합니다.
 * fetch() 의 결과를 반환합니다.
 */
async function submitImage() {
  // 이미지 파일을 서버에 전송하기 위해 form 생성
  const fileInput = document.querySelector("#imagefile");
  const formData = new FormData();
  formData.append("image", fileInput.files[0]);
  const options = {
    method: "POST",
    body: formData,
  };

  // 이미지 업로드 API 요청
  const result = await fetch(POST_IMAGE_UPLOAD_API_URL, options);
  return result;
}

/**
 * form 태그 안에 있는 내용을 JSON 으로 변환합니다.
 */
function getFormJson() {
  let form = document.querySelector("#post-form");
  let data = new FormData(form);
  let serializedFormData = serialize(data);
  return JSON.stringify(serializedFormData);
}

/**
 * form 태그 안에 있는 내용을 dictionary 형태로 반환합니다.
 */
function serialize(rawData) {
  let serializedData = {};
  for (let [key, value] of rawData) {
    if (key == "imagefile") {
      continue;
    }
    if (value == "") {
      serializedData[key] = null;
    }
    serializedData[key] = value;
  }
  return serializedData;
}

/**
 * 정제된 데이터를 넣어 게시물 작성 요청을 보냅니다.
 */
async function submitPostData() {
  // 인증을 위한 header 설정
  var myHeaders = new Headers();
  myHeaders.append("Authorization", `Bearer ${ACCESS_TOKEN}`);
  myHeaders.append("Content-Type", "application/json");

  // 보낼 데이터 설정
  var raw = getFormJson();

  // 최종 옵션 설정
  var requestOptions = {
    method: "POST",
    headers: myHeaders,
    body: raw,
    redirect: "follow",
  };

  // 게시물 저장 요청
  const response = await fetch(POST_LIST_API_URL, requestOptions);
  console.log(response.status);
  if (response.status == 201) {
    window.close();
  } else {
    alert(JSON.stringify(await response.json()));
  }
}

post/post_list.js 에서는 게시물 목록 관련 기능들을 처리하겠습니다.

/**
 * jwt 에서 얻은 유저의 id 로 프로필 사진을 얻어옵니다.
 */
async function getProfileImagebyId(id) {
  url = MYPAGE_API_URL + `${id}/`;
  let myHeaders = new Headers();
  myHeaders.append("Authorization", `Bearer ${ACCESS_TOKEN}`);
  myHeaders.append("Content-Type", "application/json");

  let requestOptions = {
    method: "GET",
    headers: myHeaders,
  };

  const profileResponse = await (await fetch(url, requestOptions)).json();
  return profileResponse["image"];
}

/**
 * 이미지 경로를 받아 프로필 사진 이미지에 뿌려줍니다.
 */
async function loadProfileImage() {
  userId = await decodeJWT(ACCESS_TOKEN)["user_id"];
  profileElement = document.getElementsByClassName("user-profile");
  let src = STATIC_FILES_API_URL + (await getProfileImagebyId(userId));
  profileElement[0].src = src;
}

/**
 * Flask API 로부터 게시물 목록 데이터를 가져옵니다.
 * 만약, API 요청에 대한 응답 상태 코드가 401이라면,
 * 가지고 있는 리프레시 토큰으로 액세스 토큰을 재발급 요청한 후,
 * 게시물 목록 API 요청을 다시 보냅니다.
 * 그것에 대한 응답 상태 코드 또한 401이라면,
 * 로그인 페이지로 리다이렉트 처리합니다.
 */
async function getPostListDatafromAPI(page = 1) {
  try {
    let myHeaders = new Headers();
    myHeaders.append("Authorization", `Bearer ${ACCESS_TOKEN}`);
    myHeaders.append("Content-Type", "application/json");

    let requestOptions = {
      method: "GET",
      headers: myHeaders,
    };

    let rawResult = await fetch(
      POST_LIST_API_URL + "?page=" + page,
      requestOptions
    );
    // 만약 액세스 토큰이 만료되었다면, 새로운 액세스 토큰을 받아옵니다.
    if (rawResult.status == 401) {
      getNewJWT();
    }
    rawResult = await fetch(
      POST_LIST_API_URL + "?page=" + page,
      requestOptions
    );

    // 만약 리프레시 토큰도 만료되었다면, 로그인 페이지로 리다이렉트 처리합니다.
    if (rawResult.status == 401) {
      window.location.href = LOGIN_FRONTEND_URL;
    }
    const result = rawResult.json();
    return result;
  } catch (error) {
    console.log(error);
  }
}

/**
 * post Div 전체를 복사해 반환합니다.
 */
function getCopyDiv() {
  const postDiv = document.querySelector(".post");
  const newNode = postDiv.cloneNode(true);
  newNode.id = "copied-post";
  newNode.style = "display=inline";
  return newNode;
}

/**
 * id, 제목, 내용, 저자, 사진을 받아 해당 div를 하나의 게시물로 완성합니다.
 */
function getCompletedPost(
  idValue, // 게시물의 id
  titleValue, // 게시물의 제목
  feedImgValue, // 게시물의 피드 이미지
  contentValue, // 게시물의 내용
  authorNameValue, // 저자의 이름
  authorImageValue // 저자의 프로필 사진
) {
  div = getCopyDiv();
  let authorUpImg = div.children[0].children[0].children[0].children[0];
  let authorUpName = div.children[0].children[0].children[1];
  let feedImg = div.children[1];
  let authorDownName = div.children[2].children[3];
  let title = div.children[2].children[4];
  let content = div.children[2].children[5];
  let postTime = div.children[2].children[6];

  div.id = idValue;
  title.innerText = titleValue;
  feedImg.src = feedImgValue;
  content.innerText = contentValue;
  authorUpName.innerText = authorNameValue;
  authorUpImg.src = authorImageValue;
  authorDownName.innerText = authorNameValue;

  return div;
}

/**
 * 게시물 데이터를 받아온 다음,
 * 일정한 조건이 되면 호출되는 메서드입니다.
 * 페이지를 받아서, 적절한 데이터를 받아 화면에 그립니다.
 */
function loadMorePosts(page) {
  getPostListDatafromAPI(page).then((result) => {
    const postDiv = document.querySelector(".post-wrapper");
    for (let i = 0; i < result.length; i++) {
      // 게시물의 id
      const id = result[i]["id"];
      // 게시물의 제목
      const title = result[i]["title"];
      // 게시물의 피드 이미지
      const image = STATIC_FILES_API_URL + result[i]["image"];
      // 게시물의 내용
      const content = result[i]["content"];
      // 저자의 이름
      const authorName = result[i]["author"]["username"];
      // 저자의 프로필 사진
      const authorImage = STATIC_FILES_API_URL + result[i]["author"]["image"];

      postDiv.append(
        getCompletedPost(
          (idValue = id),
          (titleValue = title),
          (feedImgValue = image),
          (contentValue = content),
          (authorNameValue = authorName),
          (authorImageValue = authorImage)
        )
      );
    }
  });
}

/**
 * 게시물을 생성하기 위한 팝업창을 띄웁니다.
 */
function showPostCreateForm() {
  let width = 800;
  let height = 950;
  let left = window.screen.width / 2 - width / 2;
  let top = window.screen.height / 4;

  let windowStatus = `width=${width}, height=${height}, left=${left}, top=${top}, resizable=no, toolbars=no, menubar=no`;

  const url = POST_CREATE_FRONTEND_URL;

  window.open(url, "something", windowStatus);
}

/**
 * 프로필 정보를 수정하거나 조회하기 위한 팝업창을 띄웁니다.
 */
function showProfile() {
  let width = 800;
  let height = 950;
  let left = window.screen.width / 2 - width / 2;
  let top = window.screen.height / 4;

  let windowStatus = `width=${width}, height=${height}, left=${left}, top=${top}, resizable=no, toolbars=no, menubar=no`;

  const url = PROFILE_FORM_FRONTEND_URL;

  window.open(url, "something", windowStatus);
}

/**
 * 무한 스크롤을 수행합니다.
 */
function executeInfiniteScroll() {
  let pageCount = 1;
  let intersectionObserver = new IntersectionObserver(function (entries) {
    if (entries[0].intersectionRatio <= 0) {
      return;
    }
    // 게시물을 더 로드합니다.
    loadMorePosts(pageCount);
    pageCount++;
  });
  intersectionObserver.observe(document.querySelector(".bottom"));
}

function main() {
  executeInfiniteScroll(); // 스크롤을 내릴 때마다 게시물을 로드 (무한스크롤)
  loadProfileImage(); // 네비게이션 바에 프로필 사진을 뿌려줍니다.
}

main();

profile/profile.js 에서는 프로필 사진 수정을 처리하겠습니다.

/**
 * 사용자가 이미지 선택을 완료하면,
 * 1. 업로드한 이미지를 띄워주고
 * 2. 서버에 이미지를 업로드합니다.
 * 3. 이미지 업로드의 성공 여부에 따라 에러 메시지를 띄워줍니다.
 * 4. 이미지 업로드가 성공한다면 그것의 path 를 숨겨져 있는 input 태그의 value 로 넣어줍니다.
 */
async function getImageResponse(event) {
  loadPreviewImage(event);
  result = await submitImage();
  // 201로 성공적으로 이미지가 업로드되었다면,
  // 성공 메시지를 띄워주고 해당 이미지의 경로를 반환
  // 그렇지 않다면, 에러 메시지를 띄워줌
  let response = await result.json();
  if (result.status == 201) {
    alert(response["message"]);
    const path = response["path"];
    const imageInput = document.querySelector(".image");
    imageInput.setAttribute("value", path);
  } else {
    alert(JSON.stringify(response));
  }
}

/**
 * 업로드한 이미지를 미리 확인합니다.
 */
function loadPreviewImage(event) {
  var reader = new FileReader();
  reader.onload = function (event) {
    var img = document.createElement("img");
    img.setAttribute("src", event.target.result);
    document.querySelector("div#preview-image").appendChild(img);
  };
  reader.readAsDataURL(event.target.files[0]);
}

/**
 * input 태그에서 선택한 이미지를 서버에 전송합니다.
 * fetch() 의 결과를 반환합니다.
 */
async function submitImage() {
  // 이미지 파일을 서버에 전송하기 위해 form 생성
  const fileInput = document.querySelector(".imagefile");
  const formData = new FormData();

  // header 설정
  var myHeaders = new Headers();
  myHeaders.append("Authorization", `Bearer ${ACCESS_TOKEN}`);

  formData.append("image", fileInput.files[0]);

  var requestOptions = {
    method: "POST",
    headers: myHeaders,
    body: formData,
  };

  // 이미지 업로드 API 요청
  const result = await fetch(PROFILE_IMAGE_UPLOAD_API_URL, requestOptions);
  return result;
}

/**
 * form 태그 안에 있는 내용을 JSON 으로 변환합니다.
 */
function getFormJson() {
  let form = document.querySelector("#profile-form");
  let data = new FormData(form);
  let serializedFormData = serialize(data);
  return JSON.stringify(serializedFormData);
}

/**
 * form 태그 안에 있는 내용을 dictionary 형태로 반환합니다.
 */
function serialize(rawData) {
  let serializedData = {};
  for (let [key, value] of rawData) {
    console.log(value);
    if (key == "imagefile") {
      continue;
    }
    if (value == "") {
      serializedData[key] = null;
    }
    serializedData[key] = value;
  }
  console.log(serializedData);
  return serializedData;
}

/**
 * 정제된 데이터를 넣어 프로필 수정 요청을 보냅니다.
 */
async function submitProfileData() {
  // 인증을 위한 header 설정
  var myHeaders = new Headers();
  myHeaders.append("Authorization", `Bearer ${ACCESS_TOKEN}`);
  myHeaders.append("Content-Type", "application/json");

  // 보낼 데이터 설정
  var raw = getFormJson();

  // 최종 옵션 설정
  var requestOptions = {
    method: "PUT",
    headers: myHeaders,
    body: raw,
    redirect: "follow",
  };

  // 프로필 정보 수정 요청
  userId = await decodeJWT(ACCESS_TOKEN)["user_id"];
  const response = await fetch(MYPAGE_API_URL + userId + "/", requestOptions);
  console.log(response.status);
  if (response.status == 200) {
    alert("프로필 사진 수정이 완료되었습니다.");
    window.close();
  } else {
    alert(JSON.stringify(await response.json()));
  }
}

utils/decode_jwt 에서는, JWT 를 디코딩하는 함수를 정의해 두고, 필요할 때 가져다 쓰도록 하죠!

/**
 * jwt 를 받아 BASE64URL 디코딩합니다.
 */
function decodeJWT(token) {
  let base64Url = token.split(".")[1];
  let base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
  let jsonPayload = decodeURIComponent(
    window
      .atob(base64)
      .split("")
      .map(function (c) {
        return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2);
      })
      .join("")
  );
  return JSON.parse(jsonPayload);
}

utils/get_new_jwt 에서는, 현재 로컬스토리지에 존재하는 리프레시 토큰으로 액세스 토큰을 새로 발급받겠습니다.

/**
 * loacalStorage 에 존재하는 리프레시 토큰으로,
 * 새로운 액세스 토큰과 리프레시 토큰을 받아옵니다.
 * 받아온 새로운 토큰들을 localStorage에 저장합니다.
 */
async function getNewJWT() {
  let myHeaders = new Headers();
  myHeaders.append("Authorization", `Bearer ${REFRESH_TOKEN}`);
  myHeaders.append("Content-Type", "application/json");
  let requestOptions = {
    method: "POST",
    headers: myHeaders,
  };
  const refreshResponse = await (
    await fetch(REFRESH_TOKEN_API_URL, requestOptions)
  ).json();
  const access_token = refreshResponse["access_token"];
  const refresh_token = refreshResponse["refresh_token"];
  localStorage.setItem("access_token", access_token);
  localStorage.setItem("refresh_token", refresh_token);
}

마지막으로, utils/variables.js 에는 우리의 프론트엔드, 백엔드 서버의 URL들을 정의해 두도록 하겠습니다.

// ACCESS_TOKEN, REFRES_TOKEN 값
let ACCESS_TOKEN = localStorage.getItem("ACCESS_TOKEN");
let REFRESH_TOKEN = localStorage.getItem("REFRESH_TOKEN");

// API 서버 기본 URL
const API_SERVER_BASE_URL = "http://127.0.0.1:5000";

// API 서버 기능별 URL
const POST_LIST_API_URL = API_SERVER_BASE_URL + "/posts/";
const STATIC_FILES_API_URL = API_SERVER_BASE_URL + "/statics/";
const LOGIN_API_URL = API_SERVER_BASE_URL + "/login/";
const REFRESH_TOKEN_API_URL = API_SERVER_BASE_URL + "/refresh/";
const MYPAGE_API_URL = API_SERVER_BASE_URL + "/mypage/";
const POST_IMAGE_UPLOAD_API_URL = API_SERVER_BASE_URL + "/upload/post/image/";
const PROFILE_IMAGE_UPLOAD_API_URL =
  API_SERVER_BASE_URL + "/upload/profile/image/";

// Frontend 서버 기본 URL
const FRONTEND_SERVER_BASE_URL = "http://" + window.location.host;

// Frontend 서버 기능별 URL
const LOGIN_FRONTEND_URL = FRONTEND_SERVER_BASE_URL + "/flastagram/login";
const PROFILE_FORM_FRONTEND_URL =
  FRONTEND_SERVER_BASE_URL + "/flastagram/profile";
const POST_LIST_FRONTEND_URL = FRONTEND_SERVER_BASE_URL + "/flastagram/posts";
const POST_CREATE_FRONTEND_URL =
  FRONTEND_SERVER_BASE_URL + "/flastagram/post-create";

좋네요. 길었던 코드 위치 분배가 끝났습니다. 자바스크립트 파일들의 경로가 달라졌으므로 html 코드에서도 달라져야겠죠?

login.html

post_create.html

post_list.html

profile.html

이제 프로필 화면을 수정하겠습니다. 프로필 수정 팝업을 열면, 현재 로그인한 유저의 프로필 정보가 뜨도록 할 겁니다.

그럼 생각나는 건, 위의 것들을 그려주기 위해서 먼저 우리가 전에 작성해 두었던 “마이페이지 조회 api” 를 사용해야 한다는 겁니다. 아래와 같이 사용할 수 있었죠?

그러면, 위의 API를 사용해서 미리 폼에 사진을 채워봅시다!

먼저, profile.html 에서 우리는 새로 값을 그려줄 것이니 이전에 임의로 작성한 것들은 필요가 없겠죠? 아래와 같이 모든 값들을 과감히 지워줍니다. js 폴더 구조가 바뀌었으므로, 스크립트 경로도 달라져야 하겠네요.

당연히 결과는 이리 되겠죠? (http://localhost:3000/flastagram/profile)

그 구현의 시작은, fetch() 로 우리가 작성해두었던 회원정보 상세조회 API 를 호출하여 정보를 가져오는 것입니다.

/**
 * 회원정보 조회 API로부터 현재 로그인한 유저의 정보를 가져옵니다.
 */
async function getProfileDatafromAPI() {
  userId = await decodeJWT(ACCESS_TOKEN)["user_id"];
  try {
    let myHeaders = new Headers();
    myHeaders.append("Authorization", `Bearer ${ACCESS_TOKEN}`);
    myHeaders.append("Content-Type", "application/json");
    let requestOptions = {
      method: "GET",
      headers: myHeaders,
    };
    let rawResult = await fetch(MYPAGE_API_URL + userId + "/", requestOptions);
    // 만약 액세스 토큰이 만료되었다면, 새로운 액세스 토큰을 받아옵니다.
    if (rawResult.status == 401) {
      getNewJWT();
    }
    rawResult = await fetch(MYPAGE_API_URL + userId + "/", requestOptions);

    // 만약 리프레시 토큰도 만료되었다면, 로그인 페이지로 리다이렉트 처리합니다.
    if (rawResult.status == 401) {
      window.location.href = LOGIN_FRONTEND_URL;
    }
    const result = rawResult.json();
    console.log(result);
    return result;
  } catch (error) {
    console.log(error);
  }
}

getProfileDatafromAPI();

profile.js 에 위의 코드를 작성하겠습니다.

<!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">
    <link href="https://fonts.googleapis.com/css?family=Indie+Flower|Overpass+Mono" rel="stylesheet">
    <link rel="stylesheet" href="/assets/css/login.css" type="text/css" />
    <title>Login - Flastagram</title>
</head>

<body>
    <div id="wrapper">
        <div class="main-content">
            <div class="header">
                <img src="/assets/img/logo.png" class="brand-img" alt="brand image">
            </div>
            <form class="login-form">
                <input type="Email" placeholder="Email" name="email" class="email-input" />
                <input type="password" placeholder="Password" name="password" class="password-input" />
                <input type="button" value="Log in" class="btn" onclick="submitLoginData()" />
            </form>
        </div>
        <div class="sub-content">
            <div class="s-part">
                Don't have an account? <a href="#">Sign up</a>
            </div>
        </div>
    </div>
    <script src="/assets/js/utils/variables.js"></script>
    <script src="/assets/js/auth/login.js"></script>
</body>

</html>

login.html 을 위와 같이 작성하고 ( 맨 아래의 script 가져오는 코드가 변경되었죠?),

flastagram/login 에 접속 후 로그인을 하면,

게시물 목록 페이지로 이동될 겁니다.

그 상태에서 네비게이션 바에 있는 프로필 사진을 클릭해서 팝업창을 띄워 보세요. 그리고 f12로 개발자 도구를 열어 보면,

로그인한 사람의 정보를 잘 받아온 것을 확인할 수 있죠?

/**
 * API 로부터 가져온 정보로 팝업창의 프로필 정보를 채웁니다.
 */
async function loadProfileInformation() {
  const userInformationFromAPI = await getProfileDatafromAPI();
  let imageDiv = document.querySelector("#preview-image");
  imageDiv.style.backgroundImage = `url(${
    STATIC_FILES_API_URL + userInformationFromAPI["image"]
  })`;
  imageDiv.style.backgroundSize = "100% 100%";

  email = document.querySelector("#email-input");
  email.value = userInformationFromAPI["email"];
  username = document.querySelector("#username-input");
  username.value = userInformationFromAPI["username"];
  createdAt = document.querySelector("#created-at");
  createdAt.innerText = userInformationFromAPI["created_at"];
}

loadProfileInformation();

profile.js 의 getProfileDatafromAPI() 함수 아래에는 위의 코드를 작성하겠습니다. 위의 코드가 우리가 받아온 정보로 프로필 정보들을 화면에 그려줄 겁니다.

wow! 생각했던 대로 잘 동작하네요.

그리고 프로필 수정도 잘 동작하네요 :)

좋아요. 이번에는 게시물 목록에 터무니없는 1,999,231 개의 좋아요 개수 대신 실제 게시물의 좋아요 개수가 보여질 수 있도록 해 보겠습니다.

좋아요. 그러면 post_list.html 에서 위의 좋아요 개수를 나타내는 부분을 지워 버립시다.

좋네요! 위의 부분을 JS로 그려주기만 하면 됩니다.

아래의 가이드를 따라해보기 전, 먼저 본인의 아이디어대로 구현을 시도해 보세요.

/**
 * 게시물 데이터를 받아온 다음,
 * 일정한 조건이 되면 호출되는 메서드입니다.
 * 페이지를 받아서, 적절한 데이터를 받아 화면에 그립니다.
 */
function loadMorePosts(page) {
  getPostListDatafromAPI(page).then((result) => {
    const postDiv = document.querySelector(".post-wrapper");
    for (let i = 0; i < result.length; i++) {
      // 게시물의 id
      const id = result[i]["id"];
      // 게시물의 제목
      const title = result[i]["title"];
      // 게시물의 피드 이미지
      const image = STATIC_FILES_API_URL + result[i]["image"];
      // 게시물의 내용
      const content = result[i]["content"];
      // 저자의 이름
      const authorName = result[i]["author"]["username"];
      // 저자의 프로필 사진
      const authorImage = STATIC_FILES_API_URL + result[i]["author"]["image"];
      // 게시물 좋아요 개수
      const likerCount = result[i]["liker_count"];

      postDiv.append(
        getCompletedPost(
          (idValue = id),
          (titleValue = title),
          (feedImgValue = image),
          (contentValue = content),
          (authorNameValue = authorName),
          (authorImageValue = authorImage),
          (likerCountValue = likerCount)
        )
      );
    }
  });
}

우리가 작성해 두었던 loadMorePosts 메서드를 위와 같이 수정 하겠습니다. 수정된 부분은 아래와 같은데,

새로 “게시물 좋아요 개수” 를 API로부터 받아오고, getCompletedPost 에 likerCountValue 를 추가로 전달해 줄 겁니다.

새로 전달된 인자를 사용할 수 있도록, getCompletedPost 메서드를 아래와 같이 수정합니다.

/**
 * id, 제목, 내용, 저자, 사진을 받아 해당 div를 하나의 게시물로 완성합니다.
 */
function getCompletedPost(
  idValue, // 게시물의 id
  titleValue, // 게시물의 제목
  feedImgValue, // 게시물의 피드 이미지
  contentValue, // 게시물의 내용
  authorNameValue, // 저자의 이름
  authorImageValue, // 저자의 프로필 사진
  likerCountValue // 게시물 좋아요 갯수
) {
  div = getCopyDiv();
  let authorUpImg = div.children[0].children[0].children[0].children[0];
  let authorUpName = div.children[0].children[0].children[1];
  let feedImg = div.children[1];
  let authorDownName = div.children[2].children[3];
  let title = div.children[2].children[4];
  let content = div.children[2].children[5];
  let postTime = div.children[2].children[6];
  let likerCount = div.children[2].children[1];

  div.id = idValue;
  title.innerText = titleValue;
  feedImg.src = feedImgValue;
  content.innerText = contentValue;
  authorUpName.innerText = authorNameValue;
  authorUpImg.src = authorImageValue;
  authorDownName.innerText = authorNameValue;
  likerCount.innerText = `${likerCountValue} Likes`;

  return div;
}

수정된 부분은 아래와 같습니다.

추가로 전달된 인자를 받아와, likerCount 엘리먼트를 선택하고, 텍스트로 게시물의 좋아요 개수를 그려주도록 하였습니다.

그러면, 실제로 한번 볼까요? 지금은 0개의 좋아요가 있는 게시물이네요.

그것의 id는 70이므로, 저는 70번 게시물에 좋아요를 누르겠습니다.

결과를 확인해 보면,

좋아요 개수가 잘 표시되는 것을 확인할 수 있네요!!

그러면 또 불편한 점이 생깁니다. 이제 프론트엔드 단에서 좋아요, 그리고 좋아요 취소를 처리할 수 있다면 참 좋겠네요.

지피지기면 백전백승, 적을 알고 나를 알아봅시다. 먼저 적(?)을 살펴보자면,

게시물 목록이 아니라 상세이지만, 이전에 좋아요를 눌렀던 경우 오른쪽 아래의 “하트” 가 빨간색으로 채워져 로딩됩니다. 그렇지 않은, 이전에 좋아요를 누른 적이 없었던 경우에는 하트가 눌러져 있지 않겠죠?

그러한 처리들을 조금 쉽게 하기 위해서, 기존의 이미지로 되어 있던 여러 아이콘들을 fontawsome 의 것들로 교체하겠습니다. 먼저, post_list.html 의 맨 윗부분에 아래의 코드를 입력하여 CSS 를 가져옵니다.

서윗하게.. 코드 넣을 거임.

<link href=”https://use.fontawesome.com/releases/v6.2.1/css/all.css” rel=”stylesheet” />

그리고 네비게이션 바 코드를 약간 수정합니다.

    <!-- navbar start -->
    <nav class="navbar">
        <div class="nav-wrapper">
            <img src="/assets/img/logo.png" class="brand-img" alt="brand image">
            <input type="text" class="search-box" placeholder="search">
            <div class="nav-items">
                <i class="fa-solid fa-house icon"></i>
                <i class="fa-solid fa-paper-plane icon"></i>
                <a href="javascript:showPostCreateForm()" class="icon">
                    <i class="fa-regular fa-square-plus"></i>
                </a>
                <i class="fa-regular fa-compass icon"></i>
                <i class="fa-regular fa-heart icon"></i>
                <a href="javascript:showProfile()">
                    <img src="" class="user-profile icon" alt="">
                </a>
            </div>
        </div>
    </nav>
    <!-- navbar end -->

post_list.css 의 .icon 스타일도 약간 수정합니다.

.icon{
  font-size:25px;
  height: 100%;
  cursor: pointer;
  margin: 0 10px;
  display: inline-block;
  text-decoration : none;
  color: #000;
}

그러면 네비게이션 바가 아래와 같이 수정되어있는 것을 확인할 수 있을 겁니다.

그리고 post_list.html 의 스크립트에 몇 가지가 정확히 추가되어 있는지 확인한 후,

    <script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
    <script src="/assets/js/utils/decode_jwt.js"></script>
    <script src="/assets/js/utils/variables.js"></script>
    <script src="/assets/js/utils/get_new_jwt.js"></script>
    <script src="/assets/js/auth/handler.js"></script>
    <script src="/assets/js/post/post_list.js"></script>

1번을 클릭하면 게시물 작성 폼이,

2번을 클릭하면 프로필 사진 폼이 뜰 겁니다!

닉 워커..

좋아요. 네비게이션 바에 그것을 시도했듯이 게시물에도 똑같은 아이콘을 작성합시다. 이번에는 실제 인스타그램처럼, 좋아요 버튼을 누르면 하트가 채워지고, 다시 그것을 누르면 하트가 비워지는 것도 구현해 보겠습니다.

<script src=”https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js”></script>

서윗한 컨셉을 지키겠다. 코드는 아래에 있음.
                            <div class="reaction-wrapper">
                                <a class="heart icon" onclick="toggleLikeButton(this);" id="like-button"><i
                                        class="fa-regular fa-heart"></i></a>
                                <a class="comment icon"><i class="fa-regular fa-comment"></i></a>
                                <a class="send icon"><i class="fa-solid fa-paper-plane"></i></a>
                                <a class="save icon"><i class="fa-regular fa-bookmark"></i></a>
                            </div>

post_list.html 의 single post 부분에 위의 코드를 작성합니다. 그러고 보니 하트 아이콘은 a 태그로 감싸져 있고, onclick 속성에 새로운 메서드가 연결되어 있네요. post_list.js 에 아래의 함수를 작성합니다.

function toggleLikeButton(likeButton) {
  if ($(likeButton).children().first().attr("class") == "fa-solid fa-heart") {
    $(likeButton).html($("<i/>", { class: "fa-regular fa-heart" }));
  } else {
    $(likeButton).html($("<i/>", { class: "fa-solid fa-heart" }));
  }
}

위의 함수는 “좋아요 버튼” 이 채워져 있다면(정확히는 a 태그 안의 i 태그의 클래스명), 클릭했을 때에 하트가 비어있도록, 그렇지 않다면 하트가 채워지도록 클래스를 바꿔줄 겁니다.

그리고 브라우저를 열어 변화된 모습을 확인합니다. 하트를 누르면 하트가 채워지고, 그렇지 않다면 안 채워져 있을 겁니다.

클릭 전

여기서 하트를 누르면,

토글이 정상적으로 동작하네요.

좋아요. 어째저째 위의 구현은 되었는데 실제로 저걸 클릭하여 하트의 모양이 바뀐다고 해서 백엔드 서버에 요청이 가지는 않습니다. 좋아요 버튼을 누르면 좋아요를, 다시 그것을 누르면 좋아요 취소를 하도록 처리해 보겠습니다.

그 전 – 이러한 상황도 가정할 수 있겠네요. 만약에, 70번 게시물에 좋아요를 이전에 내가 누른 상태였다면 그 게시물은 페이지를 처음 로딩했을 때에 “하트가 채워져있는 상태” 로 화면에 그려져야 합니다.

그래서 이전 시간에 작업했던 것이 “현재 로그인한 유저의 게시물 좋아요 여부” 를 “게시물 상세/목록 API” 의 응답에 추가하는 것이었습니다. 잠시 리마인드해 보자면, 아래와 같았죠?

현재 로그인한 유저가 위의 게시물에 좋아요를 눌렀기 때문에 “true” 를 반환했네요. 그러면 생각의 흐름이 점점 한 줄기로 흘러갈 겁니다. “true” 일 경우 “하트가 채워져 있게”,”false” 인 경우 “하트가 비워져 있게” 처리하면 되겠습니다.

먼저, post_list.html 에서 우리는 자바스크립트로 그 코드를 그릴 것이므로 i 태그 아래의 클래스를 지워줍니다.

드래그한 부분을 지워주세요. :)

구현의 두 번째는, 새로운 게시물을 그려주는 코드를 수정하는 것입니다.

하나의 함수를 2개의 스크린샷으로 찍게 되어 코드가 좀 복잡해 보이네요. getCompletedPost 함수의 최종 구현은 아래와 같습니다.

/**
 * id, 제목, 내용, 저자, 사진을 받아 해당 div를 하나의 게시물로 완성합니다.
 */
function getCompletedPost(
  idValue, // 게시물의 id
  titleValue, // 게시물의 제목
  feedImgValue, // 게시물의 피드 이미지
  contentValue, // 게시물의 내용
  authorNameValue, // 저자의 이름
  authorImageValue, // 저자의 프로필 사진
  likerCountValue, // 게시물 좋아요 갯수
  isLikeValue // 게시물 좋아요 여부
) {
  div = getCopyDiv();
  let authorUpImg = div.children[0].children[0].children[0].children[0];
  let authorUpName = div.children[0].children[0].children[1];
  let feedImg = div.children[1];
  let authorDownName = div.children[2].children[3];
  let title = div.children[2].children[4];
  let content = div.children[2].children[5];
  let postTime = div.children[2].children[6];
  let likerCount = div.children[2].children[1];
  let isLike = div.children[2].children[0].children[0].children[0];

  div.id = idValue;
  title.innerText = titleValue;
  feedImg.src = feedImgValue;
  content.innerText = contentValue;
  authorUpName.innerText = authorNameValue;
  authorUpImg.src = authorImageValue;
  authorDownName.innerText = authorNameValue;
  likerCount.innerText = `${likerCountValue} Likes`;
  if (isLikeValue == false) {
    isLike.classList.add("fa-regular");
    isLike.classList.add("fa-heart");
  } else {
    isLike.classList.add("fa-solid");
    isLike.classList.add("fa-heart");
  }

  return div;
}

새 게시물을 완성하는 것을 처리했으므로, 그것을 활용하여 새로운 게시물을 연속해서 그려주는 함수의 수정도 필요하겠네요. loadMorePosts() 메서드의 내용을 아래와 같이 수정합니다.

/**
 * 게시물 데이터를 받아온 다음,
 * 일정한 조건이 되면 호출되는 메서드입니다.
 * 페이지를 받아서, 적절한 데이터를 받아 화면에 그립니다.
 */
function loadMorePosts(page) {
  getPostListDatafromAPI(page).then((result) => {
    const postDiv = document.querySelector(".post-wrapper");
    for (let i = 0; i < result.length; i++) {
      // 게시물의 id
      const id = result[i]["id"];
      // 게시물의 제목
      const title = result[i]["title"];
      // 게시물의 피드 이미지
      const image = STATIC_FILES_API_URL + result[i]["image"];
      // 게시물의 내용
      const content = result[i]["content"];
      // 저자의 이름
      const authorName = result[i]["author"]["username"];
      // 저자의 프로필 사진
      const authorImage = STATIC_FILES_API_URL + result[i]["author"]["image"];
      // 게시물 좋아요 개수
      const likerCount = result[i]["liker_count"];
      // 게시물 좋아요 여부
      const isLike = result[i]["is_like"];

      postDiv.append(
        getCompletedPost(
          (idValue = id),
          (titleValue = title),
          (feedImgValue = image),
          (contentValue = content),
          (authorNameValue = authorName),
          (authorImageValue = authorImage),
          (likerCountValue = likerCount),
          (isLikeValue = isLike)
        )
      );
    }
  });
}

좋아요. 좋습니다.(누구 때문에 말 버릇 됨..)

게시물 목록에 가서 결과를 확인해보기 전, 아래와 같이 게시물 목록 API 를 요청해서 우리가 원하는 결과의 청사진을 그려 보겠습니다. 저의 경우, 아래와 같이 제목과 내용이 “asdf” 인 게시물의 is_like 값은 true네요. 그러면 프론트엔드 단에서 접속을 하였을 때에는 하트가 채워진 상태로 보여야 합니다.

다른 게시물을 살펴볼까요?

“자바스크립트싫어!” 게시물은 좋아요를 누르지 않았고, 그렇다면 게시물 목록에서는 하트가 채워지지 않은 상태로 남아있어야 하겠네요!

떨리는 마음으로 새로고침을 해 보았더니,

진짜 매우 좋네요. 원하던 대로 결과가 나왔습니다. 각각 하트가 채워진, 그리고 채워지지 않은 상태로 바뀌었네요! 이제 해야 할 것은 진짜 서버로 게시물 좋아요 / 좋아요 취소 요청을 보내 우리의 UI에 살을 붙이는 겁니다.

잠깐 우리가 작성했던 구조를 리마인드해 봅시다. 위의 자바스크립트 코드에서 각각 좋아요 취소, 그리고 좋아요 요청을 보내면 되겠네요.

function toggleLikeButton(likeButton) {
  let postId = likeButton.parentElement.parentElement.parentElement.id;
  let likeElement = likeButton.parentElement.parentElement.children[1];
  let likeValue = parseInt(likeElement.innerText.replace(/[^0-9]/g, ""));
  if ($(likeButton).children().first().attr("class") == "fa-solid fa-heart") {
    // 좋아요 취소 요청을 보냄
    let myHeaders = new Headers();
    myHeaders.append("Authorization", `Bearer ${ACCESS_TOKEN}`);
    myHeaders.append("Content-Type", "application/json");
    var requestOptions = {
      method: "DELETE",
      headers: myHeaders,
      redirect: "follow",
    };
    fetch(`${POST_LIST_API_URL}${postId}/likes/`, requestOptions)
      .then((response) => response.text())
      .catch((error) => console.log("error", error));
    likeValue--;
    likeElement.innerText = `${likeValue} Likes`;
    $(likeButton).html($("<i/>", { class: "fa-regular fa-heart" }));
  } else {
    // 좋아요 요청을 보냄
    let myHeaders = new Headers();
    myHeaders.append("Authorization", `Bearer ${ACCESS_TOKEN}`);
    myHeaders.append("Content-Type", "application/json");
    var requestOptions = {
      method: "PUT",
      headers: myHeaders,
      redirect: "follow",
    };
    fetch(`${POST_LIST_API_URL}${postId}/likes/`, requestOptions)
      .then((response) => response.text())
      .catch((error) => console.log("error", error));
    likeValue++;
    likeElement.innerText = `${likeValue} Likes`;
    $(likeButton).html($("<i/>", { class: "fa-solid fa-heart" }));
  }
}

toggleLikeButton 함수들 위와 같이 수정하겠습니다. 구현의 논리는 아래와 같습니다.

좋아요가 정상적으로 동작하는지, 확인해 보세요. :)

>_<

잘 동작합니다. :)

Leave A Comment

Avada Programmer

Hello! We are a group of skilled developers and programmers.

Hello! We are a group of skilled developers and programmers.

We have experience in working with different platforms, systems, and devices to create products that are compatible and accessible.