유튜버 '노마드 코더'님의 무료강의 중 모멘텀 앱을 따라 만들어 보았다.
니꼬쌤의 강의는 이 강의로 처음 접해보았다.
자막이 잘 달려있음에도 베이스가 영어로 된 강의라 초반에는 버벅거렸는데 금방 익숙해졌다.
기초에 대한 개념을 천천히, 이해하기 쉽게 잘 설명해줘서 좋았다.
나중에 유료강의도 들어봐야겠다.
 

완성본 - 입력 전
완성본 - 입력 후


일하면서 중간중간 시간날 때마다 작업을 했다. 
에디터는 repl.it을 이용
 
앱의 주요 기능

  • 새로고침시 배경화면 이미지 랜덤 변경
  • 인풋창에 사용자 이름 입력시, [ Hello, {입력값} ] 출력 -- 로컬저장소 이용, 새로고침시 값 유지
  • 우측 상단 시계 매초마다 자동갱신
  • 좌측 상단 사용자 위치 기반 날씨정보 30초마다 자동갱신
  • 새로고침시 중앙의 명언 랜덤 변경
  • 하단 투두리스트(Todo list) -- 로컬저장소 이용, 새로고침시 값 유지

<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width">
  <title>replit</title>
  <link href="CSS/style.css" rel="stylesheet" type="text/css" />
  <!-- Loads the Extensions API -->
  <script src="https://unpkg.com/@replit/extensions@0.24.0/dist/index.global.js"></script>
</head>

<body>
  <!--  메인박스  -->
  <div id="main_container">
    <!--  로그인  -->
    <form id="login_container">
      <input 
        type="text" 
        id="login_input"
        placeholder="What is your name?"
        required
        >
      <button id="login_button">Submit</button>
    </form>
    <!--   인사   -->
    <h3 id="greeting"></h3>
    <!--   시계   -->
    <div id="clock_container">
      11:30 AM
    </div>
    <!--   명언   -->
    <div id="quotes_container">
      <p class="quotes_quote">asdasd</p>
      <p class="quotes_author">123123</p>
    </div>
    <!--   투두리스트   -->
    <div id="todo_container">
      <form class="todo_form">
        <input 
          type="text" 
          id="todo_input"
          placeholder="What will you do today?"
          required
          >
        <button id="todo_button">Submit</button>
      </form>
      <ul id="list_container">
      </ul>
    </div>
    <!--   날씨   -->
      <div id="weather_container">
        <p class="weather_location"></p>
        <p class="weather_temp"></p>
        <p class="weather_weather"></p>
      </div>
  </div>
  <!--  JS 파일 불러오기  -->
  <script src="script.js"></script>
  <script src="JS/bg.js"></script>
  <script src="JS/login.js"></script>
  <script src="JS/clock.js"></script>
  <script src="JS/quotes.js"></script>
  <script src="JS/todo.js"></script>
  <script src="JS/weather.js"></script>
</body>

</html>

HTML 코드
 
딱히 복잡한 부분은 없다.
하나 기억나는게 있다면 input 태그는 속성값이 어마어마하게 많다는 거 정도.


/* 스타일 리셋 */
@import 'reset.css';
/* 커스텀 디자인 */
:root {
  --color-white : #FFF;
  --color-violet : #5b2386;
  --color-yellow : #F5DF4D;
  --color-grey : #BABCBE;

  --font-family-Noto : 'Noto Sans KR';
  --font-family-Enjoy : 'OTEnjoystoriesBA';
  --font-family-G-Medium : 'GmarketSansMedium';
  --font-family-G-Bold : 'GmarketSansBold';

  --animation-duration : 300ms;
}
/* 폰트 (4종류) */
@font-face {
    font-family: 'OTEnjoystoriesBA';
    src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/noonfonts_two@1.0/OTEnjoystoriesBA.woff') format('woff');
    font-weight: normal;
    font-style: normal;
}

@font-face {
    font-family: 'GmarketSansBold';
    src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/noonfonts_2001@1.1/GmarketSansBold.woff') format('woff');
    font-weight: normal;
    font-style: normal;
}

@font-face {
    font-family: 'GmarketSansMedium';
    src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/noonfonts_2001@1.1/GmarketSansMedium.woff') format('woff');
    font-weight: normal;
    font-style: normal;
}

@font-face {
    font-family: 'Noto Sans KR';
    font-style: normal;
    font-weight: 400;
    src: url("https://fonts.gstatic.com/ea/notosanskr/v2/NotoSansKR-Regular.woff2") format('woff2'),
    url("https://fonts.gstatic.com/ea/notosanskr/v2/NotoSansKR-Regular.woff") format('woff'),
    url("https://fonts.gstatic.com/ea/notosanskr/v2/NotoSansKR-Regular.otf") format('opentype');
}

html {
  font-size : 16px;
}

button, input {
  border : none;
  border-radius: 8px;
  outline : none;
  padding: 8px 12px;
  cursor : pointer;
  margin-left: .5rem;
}

button {
  background-color: var(--color-yellow);
  color: var(--color-violet);
  transition: var(--animation-duration) opacity;
}

button:hover {
  opacity: .77;
}

.hidden {
  display: none;
}
/* 메인 박스 + 배경화면 */
#main_container {
  position: relative;
  font-family: 'GmarketSansMedium';
  color : #fff;
  width: 100%;
  height: 100vh;
  background-size: cover;
  background-position: center;
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 10rem 0;
}
/* 로그인 */
#login_container {
  margin: 2rem 0;
}

#login_input, #login_button {
  font-size : 1.25rem;
}

#greeting {
  font-size : 3.5rem;
  color : var(--color-yellow);
  font-weight: 700;
  margin: 2rem 0;
}
/* 시계 */
#clock_container {
  position: absolute;
  top : 2rem;
  right : 2rem;
  font-size : 1.75rem;
  color : var(--color-yellow);
  font-weight: 700;
  opacity: .77;
}
/* 명언 */
#quotes_container {
  display: flex;
  flex-direction: column;
  justify-content: center;
  margin: 2rem 10rem;
}

.quotes_quote {
  margin-bottom: 1rem;
  font-size: 1.25rem;
  color: var(--color-white);
}

.quotes_author {
  color: var(--color-yellow);
}

/* 투두리스트 */
#todo_container {
  margin: 2rem 0;
}

.todo_form {
  margin-bottom: 2rem;
}

#todo_input, #todo_button {
  font-size: 1.25rem;
}

#list_container {
  max-height : 170px;
  overflow-y: scroll;
}

#list_container li {
  background-color: var(--color-white);
  color: var(--color-violet);
  padding: 8px 12px;
  margin: 8px 12px;
  border-radius: 8px;
  line-height: 1.5;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

/* 날씨 */
#weather_container {
  position: absolute;
  top : 2rem;
  left : 2rem;
  font-size : 1.75rem;
  color : var(--color-yellow);
  font-weight: 700;
  opacity: .77;
}

CSS 코드
 
특별히 어려운 점은 없었다.
지금 업로드 하면서 눈치챈 거지만 font를 4개나 받아와 놓고 그중 하나만 사용했다는 거.
복사 붙여 넣기를 해서 눈치채지 못했다. 나중에 나머지 세 개는 지워야겠다.
 


const container = document.getElementById('main_container');

// 이미지 파일 배열
const imgArr = [
  '1.jpg',
  '2.jpg',
  '3.jpg',
  '4.jpg',
  '5.jpg',
  '6.jpg',
  '7.jpg',
  '8.jpg',
  '9.jpg',
  '10.jpg',
];

// 랜덤 이미지 파일 받기
function getRandomElement(arr) {
  return arr[Math.floor(Math.random() * arr.length)];
}

// 배경화면에 랜덤 이미지 삽입
const randomImage = getRandomElement(imgArr);
container.style.backgroundImage = `url('Images/${randomImage}')`;

JS 파일 1/6 배경화면 변경 관련 코드
 
10개의 이미지 파일을 Images 폴더 안에 넣어둔 후 각각의 파일명을 배열에 하드코딩해서 넣어놨다.
그 후에 랜덤수를 배열의 배수만큼 곱한 후 소숫점 자리를 모두 버리면 0~9까지의 랜덤수를 뽑아낼 수 있다.
 
그 후엔 style.backgroundImage 속성으로 직접 이미지를 삽입한다.
 
크게 어려운 부분은 없었다.
 


const clock = document.getElementById('clock_container');

function getCurrentTime() {
  const currentDate = new Date();
  const currentTimeString = currentDate.toLocaleTimeString("ko-KR");
  clock.innerText = currentTimeString;
}

getCurrentTime();
const currentTime = setInterval(getCurrentTime, 1000);

JS 파일 2/6 디지털 시계 관련 코드
 
new Date() 를 이용해 현재 날짜와 관련된 값을 받아온 후, toLocaleTimeString("ko-KR") 메서드를 이용.
한국 기준 시간을 불러왔다.
강의에서는. getHours , getMinutes 등의 메서드를 이용해서 각각의 시, 분, 초 값을 받는 방식을 택했다.
난 귀찮아서 toLocaleTimeString 메서드를 이용했다.
 
값을 잘 받았으면 매 초(1000ms)마다 값을 갱신할 수 있도록 setInterval() 함수를 이용해 준다.
setTimeout과는 달리 지정한 시간마다 원하는 값을 갱신할 수 있도록 도와주는 똑똑한 녀석이다.
 
여기도 크게 어려운 부분은 없었다.
 


const loginForm = document.getElementById('login_container');
const loginInput = document.querySelector('#login_input');
const greeting = document.getElementById('greeting');

// 로컬 저장소 키값
const USER_KEY = 'userName';

const HIDDEN_CLASS = 'hidden';

// 인풋값을 받아 로컬 저장소에 저장 + 디스플레이 효과
function getLoginValue() {
  const username = loginInput.value;
  loginForm.classList.add(HIDDEN_CLASS);
  greeting.classList.remove(HIDDEN_CLASS);
  greeting.innerText = 'Hello, ' + username;
  localStorage.setItem(USER_KEY, username);
}

// 서브밋 핸들러
function submitLogin(e) {
  e.preventDefault();
  getLoginValue();
}

loginForm.addEventListener('submit', submitLogin);

// 로컬 저장소의 유저명
const localUserName = localStorage.getItem(USER_KEY);

// 새로고침 시 오류방지
if(localUserName) {
  loginForm.classList.add(HIDDEN_CLASS);
  greeting.innerText = 'Hello, ' + localUserName;
  greeting.classList.remove(HIDDEN_CLASS);
} else {
  loginForm.classList.remove(HIDDEN_CLASS);
  greeting.classList.add(HIDDEN_CLASS);
}

JS 파일 3/6 로그인 관련 코드
 
원하는 값을 브라우저에 저장해 주는 기능인 localStorage (그 외 sessionStorage도 있음).
이 녀석은 몇 번을 해도 어렵다.. 이 코드같이 value가 하나인 경우는 그나마 해보겠는데 배열이나 오브젝트가 되면 너무너무 복잡해지는 것 같다.
 
코드 작동 순서

  1. 사용자가 인풋값을 제출하면
  2. e.preventDefault()를 이용해 새로고침을 방지
  3. 사용자가 제출한 값을 받아서 greeting에 삽입
  4. loginForm은 안 보이게, greeting은 보이게 설정
  5. 사용자가 제출한 값 localStorage에 저장
  6. 마지막으로 if, else 문으로 새로고침 방지

 
코드가 복잡해지면 오타도 덩달아 문제인 것 같다. 오타 때문에 날린 시간도 꽤 됐기에 조심 또 조심해야겠다.
 


const quote = document.querySelector('.quotes_quote');
const author = document.querySelector('.quotes_author');

// 명언 배열
const quotes = [
    {
    quote: 'I never dreamed about success, I worked for it',
    author: 'Estee Lauder'
    },
    {
    quote: 'Do not try to be original, just try to be good.',
    author: 'Paul Rand'
    },
    {
    quote: 'Do not be afraid to give up the good to go for the great',
    author: 'John D. Rockefeller'
    },
    {
    quote: 'If you cannot fly then run. If you cannot run, then walk. And if you cannot walk, then crawl, but whatever you do, you have to keep moving forward.',
    author: 'Martin Luther King Jr.'
    },
    {
    quote: 'Our greatest weakness lies in giving up. The most certain way to succeed is always to try just one more time.',
    author: 'Thomas Edison'
    },
    {
    quote: 'The fastest way to change yourself is to hang out with people who are already the way you want to be',
    author: 'REid Hoffman'
    },
    {
    quote: 'Money is like gasoline during a road trip. You do not want to run out of gas on your trip, but you are not doing a tour of gas stations',
    author: 'Tim O Reilly'
    },
    {
    quote: 'Some people dream of success, while other people get up every morning and make it happen',
    author: 'Wayne Huizenga'
    },
    {
    quote: 'The only thing worse than starting something and falling.. is not starting something',
    author: 'SEth Godin'
    },
    {
    quote: 'If you really want to do something, you will find a way. If you do not, you will find an excuse.',
    author: 'Jim Rohn'
    },
    ];

// 랜덤 수 불러오기 (최대값 : 배열의 길이)
let randomNum = Math.floor(Math.random() * quotes.length);

// 배열의 랜덤 수 번째 요소를 삽입
quote.innerText = quotes[randomNum].quote;
author.innerText = quotes[randomNum].author;

JS 파일 4/6 명언 관련 코드
 
감사하게도 강의 댓글에 어떤 수강자분이 명언 배열을 공유해 주셨다.
 
랜덤 수 로직은 배경화면 변경하는 로직과 다를 바 없었기에 쉬웠다.
 


'use strict';

const todoForm = document.querySelector('.todo_form');
const todoInput = document.querySelector('#todo_input');
const listBox = document.querySelector('#list_container');

const TODO_KEY = 'todos';

// 로컬스토리지에서 할 일 리스트 불러오기
let todos = JSON.parse(localStorage.getItem(TODO_KEY)) || [];

// 할 일 삭제 후 로컬스토리지에 저장하기
function saveTodos() {
localStorage.setItem(TODO_KEY, JSON.stringify(todos));
}

// 할 일 삭제하기
function deleteTodo (index) {
todos.splice(index, 1);
saveTodos();
}

// 할 일 삭제 버튼 핸들러
function handleDeleteBtn (e) {
const list = e.target.parentNode;
const index = todos.findIndex(todo => todo.id === list.id);
deleteTodo (index);
list.remove();
}

// 할 일 생성하기
function createTodo (todoValue) {
const listItem = document.createElement('li');
const listValue = document.createElement('span');
const deleteBtn = document.createElement('button');
const id = Date.now().toString();

listValue.textContent = todoValue;
deleteBtn.textContent = '🗑️';
deleteBtn.addEventListener('click', handleDeleteBtn);

listItem.appendChild(listValue);
listItem.appendChild(deleteBtn);
listItem.id = id;
listBox.appendChild(listItem);

const newTodo = { id, value: todoValue };
todos.push(newTodo);
saveTodos();
}

// 서브밋 핸들러
function handleTodoSubmit (e) {
e.preventDefault();
const todoValue = todoInput.value.trim();
if (todoValue === '') return;
createTodo (todoValue);
todoInput.value = '';
todoInput.focus();
}

// 초기 할 일 목록 불러오기
function loadTodos () {
todos.forEach(todo => createTodo (todo.value));
}

// 이벤트 리스너
todoForm.addEventListener('submit', handleTodoSubmit);

// 새로고침 방지
if (todos.length !== 0) {
loadTodos();
}

JS 파일 5/6 투두리스트(Todo list) 관련 코드
 
이번 프로젝트에서 가장 어려웠던 부분이다. (앞서 말했던 localStorage 관련해서 시간을 엄청 잡아먹었다.)
특히 삭제버튼 클릭 시 로컬스토리지 아이템도 함께 제거하는 작업이 어려웠다.

  • 각 요소에 id값을 할당하고
  • findIndex 함수로 해당 요소의 인덱스 값을 받은 후
  • splice 함수로 index 번째 아이템을 1개 삭제

아직도 눈이 어질어질하다... ㅋㅋ
 
코드 작동 순서는 글로 표현하자니 머리가 지끈지끈해서 생략하겠다.
 


const weatherLocation = document.querySelector('.weather_location');
const temp = document.querySelector('.weather_temp');
const weather = document.querySelector('.weather_weather');

// API 데이터 불러오기
function callDatas(lat, lon) {
  const API_KEY = 'your key'
  const url = `https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lon}&appid=${API_KEY}&units=metric`;

  fetch(url)
    .then(response => {
      if (response.ok) {
        console.log(response);
        return response.json();
      }
      throw new Error('Network response was not ok.');
    })
    .then(data => {
      // 불러온 데이터를 처리하는 코드 작성
      weatherLocation.innerText = data.name;
      temp.innerText = `${Math.floor(data.main.temp)} °C`;
      weather.innerText = data.weather[0].main;
    })
    .catch(error => {
      console.error('There has been a problem with your fetch operation:', error);
    });
}

// 사용자의 위도, 경도값 받아오기 
function success(pos) {
  const crd = pos.coords;

  const lat = crd.latitude;
  const lon = crd.longitude;

  // 데이터가 있을 때 한 번 호출
  // callDatas(lat, lon);

  // 이후 30초마다 호출
  setInterval(() => {
    callDatas(lat,lon);
  }, 30000);
}

function error(err) {
  console.warn(`ERROR(${err.code}): ${err.message}`);
}

navigator.geolocation.getCurrentPosition(success, error);

JS 파일 6/6 날씨 관련 코드
 
사용자의 위도와 경도를 알아오는 것부터, fetch() 함수를 이용해서 openweathermap의 API 정보를 받아오는 것까지 쉽지만은 않았다.
 
참고로 사용자의 위도, 경도값은 openweathermap API를 이용하기 위해 필요하다.
 
navigator.geolocation API는 .getCurrentPosition() 메서드를 사용할 수 있다.
.getCurrentPosition() 은 success, error, option 세 개의 인자를 가질 수 있다.
각각 [ 위치정보 요청이 성공했을 때 실행할 함수, 위치정보 요청이 실패했을 때 실행할 함수, 그 외 옵션 ]을 뜻한다.
요청이 성공했을 경우에는 position이라는 값을 받을 수 있는데 여기엔 사용자의 위도, 경도를 포함한 유용한 정보들이 들어있다.
예 ) 위도 : position.coords.latitude  //  경도 : position.coords.longitude
 
사용자의 위도, 경도값을 잘 받아왔으면 나머지는 간단하다.
fetch() 함수를 통해 데이터를 받아와 원하는 값을 뿌려주면 끝이다.
 


이번 프로젝트는 localStorage와 사용자의 위치값 받아오기가 가장 큰 난제였다.
그래도 완성된 작품을 이렇게 리뷰할 수 있어서 기쁘다.
 
이번에는 모르는 부분만 중간중간 강의를 참고했지만, 다음에는 강의를 보지 않고서 나만의 스타일로 다시 만들어봐야겠다~