Repository: woohyeonjo/ilovecat-javascript
Branch: master
Commit: 8132295fbf1d
Files: 24
Total size: 29.6 KB
Directory structure:
gitextract_t3qi0vtg/
├── .babelrc
├── .eslintrc
├── .gitignore
├── LICENSE
├── README.md
├── index.html
├── package.json
├── src/
│ ├── App.js
│ ├── __test__/
│ │ └── test.js
│ ├── api/
│ │ └── theCatAPI.js
│ ├── components/
│ │ ├── Card.js
│ │ ├── DetailModal.js
│ │ ├── Error.js
│ │ ├── Loading.js
│ │ ├── ResultsSection.js
│ │ └── SearchingSection.js
│ ├── css/
│ │ └── style.css
│ ├── main.js
│ └── util/
│ ├── darkmode.js
│ ├── lazyLoad.js
│ ├── scrollFetch.js
│ ├── sessionStorage.js
│ └── throttle.js
└── test.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .babelrc
================================================
{
"presets": ["@babel/preset-env"]
}
================================================
FILE: .eslintrc
================================================
{
"env": {
"browser": true,
"es6": true,
"node": true,
"jest": true
},
"extends": "eslint:recommended",
"globals": {
"Atomics": "readonly",
"SharedArrayBuffer": "readonly"
},
"parserOptions": {
"ecmaVersion": 2018,
"sourceType": "module"
},
"rules": {
"indent": [
"error",
4
],
"semi": [
"error",
"always"
],
"no-trailing-spaces": 0,
"keyword-spacing": 0,
"no-unused-vars": 1,
"no-multiple-empty-lines": 0,
"space-before-function-paren": 0,
"eol-last": 0
}
}
================================================
FILE: .gitignore
================================================
# Created by https://www.gitignore.io/api/node,macos,visualstudiocode
# Edit at https://www.gitignore.io/?templates=node,macos,visualstudiocode
### macOS ###
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# rollup.js default build output
dist/
# Uncomment the public line if your project uses Gatsby
# https://nextjs.org/blog/next-9-1#public-directory-support
# https://create-react-app.dev/docs/using-the-public-folder/#docsNav
# public
# Storybook build outputs
.out
.storybook-out
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Temporary folders
tmp/
temp/
### VisualStudioCode ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
### VisualStudioCode Patch ###
# Ignore all local history of files
.history
# End of https://www.gitignore.io/api/node,macos,visualstudiocode
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2020 nnm
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# ilovecat
> 프로그래머스 2020 Dev-Matching : 웹 프론트엔드 과제 복기

### 개발환경
- `babel`, `eslint`, [`Web Server for Chrome`](https://chrome.google.com/webstore/detail/web-server-for-chrome/ofhbbkphhbklhfoeikjpcbhemlocgigb)
- FrontEnd 학습에 더 집중하기 위해 Server는 간편하게 Web Server for Chrome을 사용했다.
- Webpack을 사용하지 않았다.
- **Vanila JavaScript만을 사용하여 구현**
### 학습 포인트
- [**요구사항에 따른 퍼블리싱 : 시맨틱 웹, 반응형 페이지**](https://velog.io/@hyeon930/요구사항에-따른-퍼블리싱-시맨틱-웹-반응형-페이지)
- [**유저의 입력에 반응하기 로딩 화면과 결과 없음 : Event loop, Class**](https://velog.io/@hyeon930/유저의-입력에-반응하기-로딩-화면과-결과-없음-Event-loop-Class)
- [**Modal 만들기 : Event propagation**](https://velog.io/@hyeon930/Modal-만들기-Event-propagation)
- [**이벤트 리스너 줄이기 : Event delegation**](https://velog.io/@hyeon930/이벤트-리스너-줄이기-Event-delegation)
- [**필요한 시점에 필요한 리소스 가져오기 : Lazy load**](https://velog.io/@hyeon930/필요한-시점에-필요한-리소스-가져오기-Lazy-loading)
- [**새로고침 후에도 결과 화면 유지하기 : Web storage API**](https://velog.io/@hyeon930/새로고침-후에도-결과-화면-유지하기-Web-Storage-API)
- [**무한 스크롤 만들기 : Throttling**](https://velog.io/@hyeon930/무한-스크롤-만들기-Throttling)
- [**비동기 요청 에러 핸들링하기 : Root Return**](https://velog.io/@hyeon930/비동기-요청-에러-핸들링하기)
================================================
FILE: index.html
================================================
<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8'>
<meta http-equiv='X-UA-Compatible' content='IE=edge'>
<meta name='viewport' content='width=device-width, initial-scale=1'>
<title>I LOVE CAT</title>
<link rel="stylesheet" href="./src/css/style.css">
<script type='module' src='./src/main.js'></script>
<script type='module' src='./src/util/darkmode.js'></script>
</head>
<body>
<div class='app'></div>
</body>
</html>
================================================
FILE: package.json
================================================
{
"name": "ilovecat",
"version": "1.0.0",
"description": "Programmers 2020 Dev-Matching",
"main": "index.js",
"scripts": {
"build": "babel src -d dist -w",
"lint": "eslint \"**/*.js\" --ignore-pattern node_modules/",
"test": "jest"
},
"repository": {
"type": "git",
"url": "git+https://github.com/woohyeonjo/ilovecat.git"
},
"author": "nnm",
"license": "MIT",
"bugs": {
"url": "https://github.com/woohyeonjo/ilovecat/issues"
},
"homepage": "https://github.com/woohyeonjo/ilovecat#readme",
"devDependencies": {
"@babel/cli": "^7.8.4",
"@babel/core": "^7.8.7",
"@babel/polyfill": "^7.8.7",
"@babel/preset-env": "^7.8.7",
"eslint": "^6.8.0",
"eslint-plugin-jest": "^23.11.0",
"jest": "^26.0.1"
}
}
================================================
FILE: src/App.js
================================================
import SearchingSection from './components/SearchingSection.js';
import ResultsSection from './components/ResultsSection.js';
import DetailModal from './components/DetailModal.js';
import Loading from './components/Loading.js';
import Error from './components/Error.js';
import { api } from './api/theCatAPI.js';
import { getItem, setItem } from './util/sessionStorage.js';
export default class App {
constructor($target) {
const keywords = getItem('keywords');
const data = getItem('data');
const searchingSection = new SearchingSection({
$target,
keywords,
onSearch: async keyword => {
loading.toggleSpinner();
const response = await api.fetchCats(keyword);
if(!response.isError){
setItem('data', response.data);
resultsSection.setState(response.data);
loading.toggleSpinner();
} else {
error.setState(response.data);
}
},
onRandom: async () => {
loading.toggleSpinner();
const response = await api.fetchRandomCats();
if(!response.isError){
setItem('data', response.data);
resultsSection.setState(response.data);
loading.toggleSpinner();
} else {
error.setState(response.data);
}
}
});
const resultsSection = new ResultsSection({
$target,
data,
onClick: data => {
detailModal.setState(data);
},
onScroll: async () => {
loading.toggleSpinner();
const response = await api.fetchRandomCats();
if(!response.isError){
const beforeData = getItem('data');
const nextData = beforeData.concat(response.data);
setItem('data', nextData);
resultsSection.setState(nextData);
loading.toggleSpinner();
} else {
error.setState(response.data);
}
}
});
const detailModal = new DetailModal({
$target
});
const loading = new Loading({
$target
});
const error = new Error({
$target
});
const darkmodeBtn = document.createElement('span');
darkmodeBtn.className = 'darkmode-btn';
darkmodeBtn.innerText = '🌕';
$target.appendChild(darkmodeBtn);
}
}
================================================
FILE: src/__test__/test.js
================================================
import '@babel/polyfill';
import ResultsSection from '../components/ResultsSection.js';
const div = document.createElement('div');
const data = {};
const onClick = () => {};
const onScroll = () => {};
it("그냥 해봄", () => {
const resultsSection = new ResultsSection({div, data, onClick, onScroll});
expect(resultsSection.data).toEqual({});
});
================================================
FILE: src/api/theCatAPI.js
================================================
/* eslint-disable no-useless-catch */
const API_ENDPOINT = 'https://api.thecatapi.com/v1';
const request = async url => {
try {
const response = await fetch(url);
if(response.ok) {
const data = await response.json();
return data;
} else {
const errorData = await response.json();
throw errorData;
}
} catch(e) {
throw {
message: e.message,
status: e.status
};
}
};
const api = {
fetchCats: async keyword => {
/*
keyword로 breed를 찾고 각 breed의 id로 이미지를 찾는다.
*/
try {
const breeds = await request(`${API_ENDPOINT}/breeds/search?q=${keyword}`);
const requests = breeds.map(async breed => {
return await request(`${API_ENDPOINT}/images/search?limit=20&breed_ids=${breed.id}`);
});
const responses = await Promise.all(requests);
const result = Array.prototype.concat.apply([], responses);
return {
isError: false,
data: result
};
} catch(e) {
return {
isError: true,
data: e
};
}
},
fetchRandomCats: async () => {
/*
랜덤으로 20개의 고양이 사진을 리턴한다.
*/
try {
const result = await request(`${API_ENDPOINT}/images/search?limit=20`);
return {
isError: false,
data: result
};
} catch(e) {
return {
isError: true,
data: e
};
}
}
};
export { api };
================================================
FILE: src/components/Card.js
================================================
export default class Card {
constructor({$target, data}) {
this.data = data;
this.card = document.createElement('article');
this.card.className = 'cat-card';
this.card.dataset.id = data.id;
$target.appendChild(this.card);
this.render();
}
render() {
const url = this.data.url;
const {name, origin} = this.data.breeds.length > 0 ? this.data.breeds[0] : { name: '정보없음', origin: '정보없음'};
const cardImage = document.createElement('img');
cardImage.className = 'card-image';
cardImage.classList.add('lazy');
cardImage.dataset.src = url;
const cardInfo = document.createElement('article');
cardInfo.className = 'card-info';
const catName = document.createElement('p');
catName.className = 'cat-name';
catName.innerText = name;
const catOrigin = document.createElement('p');
catOrigin.className = 'cat-origin';
catOrigin.innerText = origin;
cardInfo.appendChild(catName);
cardInfo.appendChild(catOrigin);
this.card.appendChild(cardImage);
this.card.appendChild(cardInfo);
}
}
================================================
FILE: src/components/DetailModal.js
================================================
export default class DetailModal {
constructor({$target}) {
this.isVisible = false;
this.data = null;
this.modalWrapper = document.createElement('div');
this.modalWrapper.className = 'modal-wrapper';
this.modalWrapper.classList.add('hidden');
$target.appendChild(this.modalWrapper);
this.render();
}
toggleModal(){
this.isVisible = !this.isVisible;
const modal = document.querySelector('.modal-wrapper');
modal.classList.toggle('hidden');
}
setState(data) {
this.toggleModal();
this.data = data;
this.render();
}
onClose() {
this.toggleModal();
this.data = null;
this.modalWrapper.innerHTML = '';
}
render() {
if(!this.isVisible) return;
const { url } = this.data;
const { name, origin, temperament } = this.data.breeds[0] ?
this.data.breeds[0] : {name: '정보없음', origin: '정보없음', temperament: '정보없음'};
const { imperial, metric } = this.data.breeds[0] ?
this.data.breeds[0].weight : {imperial: '정보없음', metric: '정보없음'};
const overlay = document.createElement('div');
overlay.className = 'overlay';
const modalContents = document.createElement('section');
modalContents.className = 'modal-contents';
const modalHeader = document.createElement('header');
modalHeader.className = 'modal-header';
const modalTitle = document.createElement('p');
modalTitle.className = 'modal-title';
modalTitle.innerText = name;
const closeBtn = document.createElement('span');
closeBtn.className = 'close-btn';
closeBtn.innerText = 'X';
const modalImage = document.createElement('img');
modalImage.className = 'modal-image';
modalImage.src = url;
const modalInfo = document.createElement('article');
modalInfo.className = 'modal-info';
const catOrigin = document.createElement('p');
catOrigin.className = 'cat-origin';
catOrigin.innerText = origin;
const catTemperament = document.createElement('p');
catTemperament.className= 'cat-temperament';
catTemperament.innerText = temperament;
const catWeight = document.createElement('p');
catWeight.className = 'cat-width';
catWeight.innerText = `${imperial} (imperial) / ${metric} (metric)`;
closeBtn.addEventListener('click', () => { this.onClose(); });
overlay.addEventListener('click', () => { this.onClose(); });
modalHeader.appendChild(modalTitle);
modalHeader.appendChild(closeBtn);
modalInfo.appendChild(catOrigin);
modalInfo.appendChild(catTemperament);
modalInfo.appendChild(catWeight);
modalContents.appendChild(modalHeader);
modalContents.appendChild(modalImage);
modalContents.appendChild(modalInfo);
this.modalWrapper.appendChild(overlay);
this.modalWrapper.appendChild(modalContents);
}
}
================================================
FILE: src/components/Error.js
================================================
export default class Error {
constructor({ $target }) {
this.$target = $target;
this.errorData = null;
this.render();
}
setState(nextData){
this.errorData = nextData;
this.render();
}
render() {
if(!this.errorData) return;
this.$target.innerHTML = '';
const errorSection = document.createElement('section');
errorSection.className = 'error-section';
const errorImage = document.createElement('img');
errorImage.className = 'error-image';
errorImage.src = '/src/img/squarecat.jpg';
const statusCode = document.createElement('p');
statusCode.className = 'status-code';
statusCode.innerText = this.errorData.status;
const errorMessage = document.createElement('p');
errorMessage.className = 'error-message';
errorMessage.innerText = this.errorData.message;
const returnBtn = document.createElement('p');
returnBtn.className = 'return-btn';
returnBtn.innerText = '돌아가기';
returnBtn.addEventListener('click', () => {
location.reload();
});
errorSection.appendChild(errorImage);
errorSection.appendChild(statusCode);
errorSection.appendChild(errorMessage);
errorSection.appendChild(returnBtn);
this.$target.appendChild(errorSection);
}
}
================================================
FILE: src/components/Loading.js
================================================
export default class Loading {
constructor({$target}) {
this.spinnerWrapper = document.createElement('div');
this.spinnerWrapper.className = 'spinner-wrapper';
this.spinnerWrapper.classList.add('hidden');
$target.appendChild(this.spinnerWrapper);
this.render();
}
toggleSpinner() {
const spinner = document.querySelector('.spinner-wrapper');
spinner.classList.toggle('hidden');
}
render() {
const spinnerImage = document.createElement('img');
spinnerImage.className = 'spinner-image';
spinnerImage.src = 'src/img/loading.gif';
this.spinnerWrapper.appendChild(spinnerImage);
}
}
================================================
FILE: src/components/ResultsSection.js
================================================
import Card from './Card.js';
import { lazyLoad } from '../util/lazyLoad.js';
import { scrollFetch } from '../util/scrollFetch.js';
export default class ResultsSection {
constructor({$target, data, onClick, onScroll}) {
this.data = data;
this.onClick = onClick;
this.onScroll = onScroll;
this.section = document.createElement('section');
this.section.className = 'results-section';
$target.appendChild(this.section);
this.render();
lazyLoad();
scrollFetch(this.onScroll);
}
setState(data) {
this.data = data;
this.render();
lazyLoad();
}
findCatById(id) {
const result = this.data.find(cat => cat.id == id);
return result;
}
render() {
if(!this.data) return;
this.section.innerHTML = '';
if(this.data.length > 0){
const cardContainer = document.createElement('div');
cardContainer.className = 'card-container';
this.data.map(cat => {
new Card({
$target: cardContainer,
data: cat
});
});
// Event Deligation을 위해서 cardContainer에 이벤트를 추가한다.
cardContainer.addEventListener('click', e => {
const path = e.path;
const card = path.find(comp => comp.className == 'cat-card');
if(card){
const id = card.dataset.id;
const catInfo = this.findCatById(id);
this.onClick(catInfo);
}
});
this.section.appendChild(cardContainer);
} else {
const noticeSection = document.createElement('section');
noticeSection.className = 'notice-section';
const notice = document.createElement('h2');
notice.className = 'notice';
notice.innerText = '검색 결과가 없습니다.';
const noticeImage = document.createElement('img');
noticeImage.className = 'notice-image';
noticeImage.src = 'src/img/emptybox.png';
noticeSection.appendChild(notice);
noticeSection.appendChild(noticeImage);
this.section.appendChild(noticeSection);
}
}
}
================================================
FILE: src/components/SearchingSection.js
================================================
import { setItem } from '../util/sessionStorage.js';
export default class SearchBar {
constructor({$target, keywords, onSearch, onRandom}) {
this.recent = keywords;
this.onSearch = onSearch;
this.onRandom = onRandom;
this.section = document.createElement('section');
this.section.className = 'searching-section';
$target.appendChild(this.section);
this.render();
this.focusOnSearchBox();
}
focusOnSearchBox() {
const searchBox = document.querySelector('.search-box');
searchBox.focus();
}
addRecentKeyword(keyword) {
if(this.recent.includes(keyword)) return;
if(this.recent.length == 5) this.recent.shift();
this.recent.push(keyword);
setItem('keywords', this.recent);
this.render();
}
searchByKeyword(keyword) {
if(keyword.length == 0) return;
this.addRecentKeyword(keyword);
this.onSearch(keyword);
}
deleteKeyword(){
const searchBox = document.querySelector('.search-box');
searchBox.value = '';
}
render() {
this.section.innerHTML = '';
const randomBtn = document.createElement('span');
randomBtn.className = 'random-btn';
randomBtn.innerText = '🐱';
const wrapper = document.createElement('div');
wrapper.className = 'search-box-wrapper';
const searchBox = document.createElement('input');
searchBox.className = 'search-box';
searchBox.placeholder = '고양이를 검색하세요.';
const recentKeywords = document.createElement('div');
recentKeywords.className = 'recent-keywords';
this.recent.map(keyword => {
const link = document.createElement('span');
link.className = 'keyword';
link.innerText = keyword;
link.addEventListener('click', () => { this.searchByKeyword(keyword); });
recentKeywords.appendChild(link);
});
randomBtn.addEventListener('click', this.onRandom);
searchBox.addEventListener('focus', this.deleteKeyword);
searchBox.addEventListener('keyup', event => {
if(event.keyCode == 13){
this.searchByKeyword(searchBox.value);
}
});
wrapper.appendChild(searchBox);
wrapper.appendChild(recentKeywords);
this.section.appendChild(randomBtn);
this.section.appendChild(wrapper);
}
}
================================================
FILE: src/css/style.css
================================================
:root {
--color-mode: 'light';
--color-dark: black;
--color-light: white;
--background: white;
--text-color: black;
}
body {
background: var(--background);
color: var(--text-color);
transition: background 500ms ease-in-out, color 200ms ease;
}
@media (prefers-color-scheme: dark) {
:root {
--color-mode: 'dark';
}
:root:not([data-user-color-scheme]) {
--background: var(--color-dark);
--text-color: var(--color-light);
}
}
[data-user-color-scheme="dark"] {
--background: var(--color-dark);
--text-color: var(--color-light);
}
.searching-section {
display: flex;
justify-content: center;
align-items: center;
}
.search-box-wrapper {
display: flex;
flex-direction: column;
width: 50%;
}
.search-box {
font-size: 20px;
}
.recent-keywords {
margin-top: 10px;
}
.keyword {
background-color: rgb(255, 127, 0);
color: white;
border-radius: 11px;
padding: 5px;
margin-right: 8px;
}
.random-btn {
font-size: 50px;
margin-right: 10px;
}
.results-section {
margin-top: 3%;
}
.notice-section {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.notice {
margin-top: 8%;
text-align: center;
}
.notice-image {
height: 300px;
width: 350px;
}
.card-container {
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
}
.cat-card {
display: flex;
flex-direction: column;
/* TODO: 카드 정렬하기 */
margin-left: calc( (100% - (20% * 4)) / 4);
width: 250px;
height: 350px;
}
.card-image {
height: 70%;
}
.hidden {
visibility: hidden;
}
.modal-wrapper {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
z-index: 1;
display: flex;
justify-content: center;
align-items: center;
}
.overlay {
position: absolute;
width: 100%;
height: 100%;
background-color: rgb(0, 0, 0, 0.5);
}
.modal-contents {
position: relative;
display: flex;
flex-direction: column;
height: 70%;
width: 30%;
padding: 10px;
background-color: var(--background);
color: var(--text-color);
/* TODO: box-shadow */
}
.modal-image {
height: 60%;
}
.modal-header {
display: flex;
justify-content: space-between;
font-size: 30px;
}
.modal-title {
margin: 0;
}
.spinner-wrapper {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
z-index: 1;
display: flex;
justify-content: center;
align-items: center;
background-color: rgb(255, 255, 255, 0.7);
}
.spinner-image {
width: 300px;
height: 300px;
border-radius: 49%;
}
.error-section {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.error-image {
margin-top: 8%;
border-radius: 10%;
}
.status-code {
margin: 0;
font-size: 5rem;
font-weight: bold;
}
.error-message {
margin-top: -15px;
font-size: 20px;
}
.return-btn {
margin-top: 15px;
}
.darkmode-btn {
font-size: 3rem;
position: fixed;
top: 1rem;
right: 5rem;
z-index: 3;
}
================================================
FILE: src/main.js
================================================
import App from './App.js';
const app = new App(document.querySelector('.app'));
================================================
FILE: src/util/darkmode.js
================================================
const STORAGE_KEY = 'user-color-scheme';
const COLOR_MODE_KEY = '--color-mode';
const darkmodeBtn = document.querySelector('.darkmode-btn');
const getCSSCustomProp = (propKey) => {
let response = getComputedStyle(document.documentElement).getPropertyValue(propKey);
// Tidy up the string if there’s something to work with
if (response.length) {
response = response.replace(/\'|"/g, '').trim();
}
// Return the string response by default
return response;
};
const applySetting = passedSetting => {
let currentSetting = passedSetting || localStorage.getItem(STORAGE_KEY);
if(currentSetting) {
document.documentElement.setAttribute('data-user-color-scheme', currentSetting);
setButtonLabel(currentSetting);
} else {
setButtonLabel(getCSSCustomProp(COLOR_MODE_KEY));
}
};
const toggleSetting = () => {
let currentSetting = localStorage.getItem(STORAGE_KEY);
switch(currentSetting) {
case null:
currentSetting = getCSSCustomProp(COLOR_MODE_KEY) === 'dark' ? 'light' : 'dark';
break;
case 'light':
currentSetting = 'dark';
break;
case 'dark':
currentSetting = 'light';
break;
}
localStorage.setItem(STORAGE_KEY, currentSetting);
return currentSetting;
};
const setButtonLabel = currentSetting => {
darkmodeBtn.innerText = currentSetting === 'dark' ? '🌕' : '🌑';
};
darkmodeBtn.addEventListener('click', evt => {
evt.preventDefault();
applySetting(toggleSetting());
});
applySetting();
================================================
FILE: src/util/lazyLoad.js
================================================
export function lazyLoad() {
const lazyImages = [].slice.call(document.querySelectorAll("img.lazy"));
if ("IntersectionObserver" in window) {
let lazyImageObserver = new IntersectionObserver(function(entries, observer) {
entries.forEach(function(entry) {
if (entry.isIntersecting) {
let lazyImage = entry.target;
lazyImage.src = lazyImage.dataset.src;
lazyImage.classList.remove("lazy");
lazyImageObserver.unobserve(lazyImage);
}
});
});
lazyImages.forEach(function(lazyImage) {
lazyImageObserver.observe(lazyImage);
});
}
}
================================================
FILE: src/util/scrollFetch.js
================================================
import { throttling } from './throttle.js';
const throttler = throttling();
function scrollFetch(fetchData) {
window.addEventListener('scroll', () => {
throttler.throttle(() => {
console.log("Activate Scroll Event");
if (getScrollTop() < getDocumentHeight() - window.innerHeight) return;
fetchData();
}, 700);
});
}
function getScrollTop() {
return (window.pageYOffset !== undefined) ? window.pageYOffset : (document.documentElement || document.body.parentNode || document.body).scrollTop;
}
function getDocumentHeight() {
const body = document.body;
const html = document.documentElement;
return Math.max(
body.scrollHeight, body.offsetHeight,
html.clientHeight, html.scrollHeight, html.offsetHeight
);
}
export { scrollFetch };
================================================
FILE: src/util/sessionStorage.js
================================================
function getItem(key) {
const value = sessionStorage.getItem(key);
if(key === 'data') return value === null ? null : JSON.parse(value);
else return value === null ? [] : JSON.parse(value);
}
function setItem(key, value) {
if(value === null || value === undefined) return;
const toJson = JSON.stringify(value);
sessionStorage.setItem(key, toJson);
}
export { getItem, setItem };
================================================
FILE: src/util/throttle.js
================================================
export const throttling = () => {
let throttleCheck;
return {
throttle(callback, milliseconds){
if(!throttleCheck){
// setTimeout은 timer id를 반환한다.
throttleCheck = setTimeout(() => {
callback(...arguments);
throttleCheck = false;
}, milliseconds);
}
}
};
};
================================================
FILE: test.js
================================================
const str = 'My first testing';
test('FirstTesting', () => {
expect(str).toBe('My first testing');
});
const arrlike = {
"length": 3,
"wow": 3
};
test('ArrayLike Object Test', () => {
expect(arrlike).toHaveLength(3);
});
test('ArrayLike Object Test 2', () => {
expect(arrlike).not.toContain("wow");
});
const arr = [1, 2, 3, 4];
test('contain test', () => {
expect(arr).toContain(4);
});
gitextract_t3qi0vtg/ ├── .babelrc ├── .eslintrc ├── .gitignore ├── LICENSE ├── README.md ├── index.html ├── package.json ├── src/ │ ├── App.js │ ├── __test__/ │ │ └── test.js │ ├── api/ │ │ └── theCatAPI.js │ ├── components/ │ │ ├── Card.js │ │ ├── DetailModal.js │ │ ├── Error.js │ │ ├── Loading.js │ │ ├── ResultsSection.js │ │ └── SearchingSection.js │ ├── css/ │ │ └── style.css │ ├── main.js │ └── util/ │ ├── darkmode.js │ ├── lazyLoad.js │ ├── scrollFetch.js │ ├── sessionStorage.js │ └── throttle.js └── test.js
SYMBOL INDEX (41 symbols across 13 files)
FILE: src/App.js
class App (line 10) | class App {
method constructor (line 11) | constructor($target) {
FILE: src/api/theCatAPI.js
constant API_ENDPOINT (line 2) | const API_ENDPOINT = 'https://api.thecatapi.com/v1';
FILE: src/components/Card.js
class Card (line 1) | class Card {
method constructor (line 2) | constructor({$target, data}) {
method render (line 13) | render() {
FILE: src/components/DetailModal.js
class DetailModal (line 1) | class DetailModal {
method constructor (line 2) | constructor({$target}) {
method toggleModal (line 14) | toggleModal(){
method setState (line 21) | setState(data) {
method onClose (line 27) | onClose() {
method render (line 33) | render() {
FILE: src/components/Error.js
class Error (line 1) | class Error {
method constructor (line 2) | constructor({ $target }) {
method setState (line 9) | setState(nextData){
method render (line 14) | render() {
FILE: src/components/Loading.js
class Loading (line 1) | class Loading {
method constructor (line 2) | constructor({$target}) {
method toggleSpinner (line 12) | toggleSpinner() {
method render (line 17) | render() {
FILE: src/components/ResultsSection.js
class ResultsSection (line 5) | class ResultsSection {
method constructor (line 6) | constructor({$target, data, onClick, onScroll}) {
method setState (line 20) | setState(data) {
method findCatById (line 26) | findCatById(id) {
method render (line 31) | render() {
FILE: src/components/SearchingSection.js
class SearchBar (line 3) | class SearchBar {
method constructor (line 4) | constructor({$target, keywords, onSearch, onRandom}) {
method focusOnSearchBox (line 18) | focusOnSearchBox() {
method addRecentKeyword (line 23) | addRecentKeyword(keyword) {
method searchByKeyword (line 33) | searchByKeyword(keyword) {
method deleteKeyword (line 40) | deleteKeyword(){
method render (line 45) | render() {
FILE: src/util/darkmode.js
constant STORAGE_KEY (line 1) | const STORAGE_KEY = 'user-color-scheme';
constant COLOR_MODE_KEY (line 2) | const COLOR_MODE_KEY = '--color-mode';
FILE: src/util/lazyLoad.js
function lazyLoad (line 1) | function lazyLoad() {
FILE: src/util/scrollFetch.js
function scrollFetch (line 5) | function scrollFetch(fetchData) {
function getScrollTop (line 15) | function getScrollTop() {
function getDocumentHeight (line 19) | function getDocumentHeight() {
FILE: src/util/sessionStorage.js
function getItem (line 1) | function getItem(key) {
function setItem (line 8) | function setItem(key, value) {
FILE: src/util/throttle.js
method throttle (line 5) | throttle(callback, milliseconds){
Condensed preview — 24 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (34K chars).
[
{
"path": ".babelrc",
"chars": 40,
"preview": "{\n \"presets\": [\"@babel/preset-env\"]\n}"
},
{
"path": ".eslintrc",
"chars": 686,
"preview": "{\n \"env\": {\n \"browser\": true,\n \"es6\": true,\n \"node\": true,\n \"jest\": true\n },\n \"exte"
},
{
"path": ".gitignore",
"chars": 2464,
"preview": "\n# Created by https://www.gitignore.io/api/node,macos,visualstudiocode\n# Edit at https://www.gitignore.io/?templates=nod"
},
{
"path": "LICENSE",
"chars": 1060,
"preview": "MIT License\n\nCopyright (c) 2020 nnm\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof thi"
},
{
"path": "README.md",
"chars": 1164,
"preview": "# ilovecat\n> 프로그래머스 2020 Dev-Matching : 웹 프론트엔드 과제 복기\n\n\n\n\n\n### 개발환경\n\n- `babel`, `eslint`, [`Web Serv"
},
{
"path": "index.html",
"chars": 452,
"preview": "<!DOCTYPE html>\n<html>\n<head>\n <meta charset='utf-8'>\n <meta http-equiv='X-UA-Compatible' content='IE=edge'>\n <"
},
{
"path": "package.json",
"chars": 777,
"preview": "{\n \"name\": \"ilovecat\",\n \"version\": \"1.0.0\",\n \"description\": \"Programmers 2020 Dev-Matching\",\n \"main\": \"index.js\",\n "
},
{
"path": "src/App.js",
"chars": 2715,
"preview": "import SearchingSection from './components/SearchingSection.js';\nimport ResultsSection from './components/ResultsSection"
},
{
"path": "src/__test__/test.js",
"chars": 352,
"preview": "import '@babel/polyfill';\nimport ResultsSection from '../components/ResultsSection.js';\n\nconst div = document.createElem"
},
{
"path": "src/api/theCatAPI.js",
"chars": 1705,
"preview": "/* eslint-disable no-useless-catch */\nconst API_ENDPOINT = 'https://api.thecatapi.com/v1';\n\nconst request = async url =>"
},
{
"path": "src/components/Card.js",
"chars": 1185,
"preview": "export default class Card {\n constructor({$target, data}) {\n this.data = data;\n this.card = document.cr"
},
{
"path": "src/components/DetailModal.js",
"chars": 3105,
"preview": "export default class DetailModal {\n constructor({$target}) {\n this.isVisible = false;\n this.data = null"
},
{
"path": "src/components/Error.js",
"chars": 1411,
"preview": "export default class Error {\n constructor({ $target }) {\n this.$target = $target;\n this.errorData = nul"
},
{
"path": "src/components/Loading.js",
"chars": 694,
"preview": "export default class Loading {\n constructor({$target}) {\n this.spinnerWrapper = document.createElement('div');"
},
{
"path": "src/components/ResultsSection.js",
"chars": 2383,
"preview": "import Card from './Card.js';\nimport { lazyLoad } from '../util/lazyLoad.js';\nimport { scrollFetch } from '../util/scrol"
},
{
"path": "src/components/SearchingSection.js",
"chars": 2510,
"preview": "import { setItem } from '../util/sessionStorage.js';\n\nexport default class SearchBar { \n constructor({$target, key"
},
{
"path": "src/css/style.css",
"chars": 3228,
"preview": ":root {\n --color-mode: 'light';\n --color-dark: black;\n --color-light: white;\n --background: white;\n --tex"
},
{
"path": "src/main.js",
"chars": 82,
"preview": "import App from './App.js';\n\nconst app = new App(document.querySelector('.app'));\n"
},
{
"path": "src/util/darkmode.js",
"chars": 1556,
"preview": "const STORAGE_KEY = 'user-color-scheme';\nconst COLOR_MODE_KEY = '--color-mode';\n\nconst darkmodeBtn = document.querySelec"
},
{
"path": "src/util/lazyLoad.js",
"chars": 726,
"preview": "export function lazyLoad() {\n const lazyImages = [].slice.call(document.querySelectorAll(\"img.lazy\"));\n \n if (\""
},
{
"path": "src/util/scrollFetch.js",
"chars": 835,
"preview": "import { throttling } from './throttle.js';\n\nconst throttler = throttling();\n\nfunction scrollFetch(fetchData) {\n wind"
},
{
"path": "src/util/sessionStorage.js",
"chars": 406,
"preview": "function getItem(key) {\n const value = sessionStorage.getItem(key);\n\n if(key === 'data') return value === null ? n"
},
{
"path": "src/util/throttle.js",
"chars": 397,
"preview": "export const throttling = () => {\n let throttleCheck;\n\n return {\n throttle(callback, milliseconds){\n "
},
{
"path": "test.js",
"chars": 423,
"preview": "const str = 'My first testing';\n\ntest('FirstTesting', () => {\n expect(str).toBe('My first testing');\n});\n\n\nconst arrl"
}
]
About this extraction
This page contains the full source code of the woohyeonjo/ilovecat-javascript GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 24 files (29.6 KB), approximately 7.8k tokens, and a symbol index with 41 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.