diff --git a/packages/console/assets/index.css b/packages/console/assets/index.css
new file mode 100644
index 0000000..39f5997
--- /dev/null
+++ b/packages/console/assets/index.css
@@ -0,0 +1,289 @@
+@import url('./module/nav.css');
+@import url('./module/loading.css');
+
+*,
+*:before,
+*:after {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: "Montserrat", Sans-Serif;
+ color: var(--text-color);
+ display: grid;
+ justify-content: center;
+}
+
+svg {
+ pointer-events: none;
+}
+
+.app {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+main {
+ display: flex;
+ width: auto;
+ margin-top: 2em;
+}
+
+.saved {
+ width: 1000px;
+ justify-content: space-between;
+}
+
+.pin {
+ display: flex;
+ flex-direction: column;
+ margin-top: 1em;
+ border: 2px solid #e6e6e6;
+ border-radius: 15px;
+ padding: 20px;
+}
+
+.button-wrapper {
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-end;
+}
+
+.heart label {
+ box-shadow: 0px 0px 0px 0px rgba(226, 32, 44, 0.5);
+}
+
+.heart label:after {
+ content: "\f004";
+}
+
+.heart input:checked+label {
+ background-color: #e2202c;
+ border-color: #e2202c;
+ box-shadow: 0px 0px 0px 0.5em rgba(226, 32, 44, 0);
+}
+
+.heart input:checked+label:after {
+ color: #e2202c;
+}
+
+.anim-icon {
+ width: 1.9em;
+ height: 1.9em;
+ margin: 10px;
+ font-size: 13px;
+ display: inline-block;
+ position: relative;
+ vertical-align: middle;
+}
+
+.anim-icon input {
+ display: none;
+}
+
+.anim-icon label {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ border: 0.1em solid #ccc;
+ border-radius: 100%;
+ display: block;
+ font: normal normal normal 13px/1 FontAwesome;
+ color: #ccc;
+ font-size: inherit;
+ text-rendering: auto;
+ -webkit-font-smoothing: antialiased;
+}
+
+.anim-icon label:after {
+ left: 0;
+ top: 50%;
+ margin-top: -0.5em;
+ display: block;
+ position: relative;
+ text-align: center;
+}
+
+.anim-icon input:checked+label {
+ -webkit-animation: check-in 0.3s forwards;
+ animation: check-in 0.3s forwards;
+ transition: background-color 0.1s 0.2s, box-shadow 1s;
+ border-width: 0.1em;
+ border-style: solid;
+}
+
+.anim-icon input:checked+label:after {
+ -webkit-animation: icon 0.3s forwards;
+ animation: icon 0.3s forwards;
+}
+
+.anim-icon-md {
+ font-size: 20px;
+}
+
+.anim-icon-lg {
+ font-size: 30px;
+}
+
+@-webkit-keyframes icon {
+ 0% {
+ margin-top: -0.5em;
+ font-size: 1.5em;
+ }
+
+ 100% {
+ font-size: 1em;
+ opacity: 1;
+ color: white;
+ }
+}
+
+@keyframes icon {
+ 0% {
+ margin-top: -0.5em;
+ font-size: 1.5em;
+ }
+
+ 100% {
+ font-size: 1em;
+ opacity: 1;
+ color: white;
+ }
+}
+
+@-webkit-keyframes check-in {
+ 0% {
+ left: 20%;
+ top: 20%;
+ width: 60%;
+ height: 60%;
+ }
+
+ 80% {
+ left: -5%;
+ top: -5%;
+ width: 110%;
+ height: 110%;
+ }
+
+ 100% {
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ }
+}
+
+@keyframes check-in {
+ 0% {
+ left: 20%;
+ top: 20%;
+ width: 60%;
+ height: 60%;
+ }
+
+ 80% {
+ left: -5%;
+ top: -5%;
+ width: 110%;
+ height: 110%;
+ }
+
+ 100% {
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ }
+}
+
+@-webkit-keyframes check {
+ 0% {
+ left: 5%;
+ top: 5%;
+ width: 90%;
+ height: 90%;
+ }
+
+ 10% {
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ }
+
+ 80% {
+ left: -5%;
+ top: -5%;
+ width: 110%;
+ height: 110%;
+ }
+
+ 90% {
+ left: 5%;
+ top: 5%;
+ width: 90%;
+ height: 90%;
+ }
+
+ 100% {
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ }
+}
+
+@keyframes check {
+ 0% {
+ left: 5%;
+ top: 5%;
+ width: 90%;
+ height: 90%;
+ }
+
+ 10% {
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ }
+
+ 80% {
+ left: -5%;
+ top: -5%;
+ width: 110%;
+ height: 110%;
+ }
+
+ 90% {
+ left: 5%;
+ top: 5%;
+ width: 90%;
+ height: 90%;
+ }
+
+ 100% {
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ }
+}
+
+img {
+ width: 460px;
+ height: 460px;
+ object-fit: cover;
+}
+
+.saved .container {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 40px;
+}
\ No newline at end of file
diff --git a/packages/console/assets/module/loading.css b/packages/console/assets/module/loading.css
new file mode 100644
index 0000000..7c32341
--- /dev/null
+++ b/packages/console/assets/module/loading.css
@@ -0,0 +1,23 @@
+.loading {
+ transition: all ease-in-out .2s;
+ opacity: 1;
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ background: rgb(255, 255, 255, 0.8);
+ z-index: 10;
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ align-items: center;
+ margin: auto;
+ overflow: hidden !important;
+ touch-action: none;
+}
+
+.hidden {
+ opacity: 0;
+ visibility: hidden;
+}
\ No newline at end of file
diff --git a/packages/console/assets/module/nav.css b/packages/console/assets/module/nav.css
new file mode 100644
index 0000000..4c4d8fd
--- /dev/null
+++ b/packages/console/assets/module/nav.css
@@ -0,0 +1,265 @@
+@import url("https://fonts.googleapis.com/css?family=Montserrat:400,400i,700");
+
+:root {
+ --bg-blue: #362FE3;
+ --bg-pink: #BF6684;
+ --bg-purple: #9439E4;
+ --text-color: #333;
+ --highlight: #8F7BF6;
+ --radius: 40px;
+ --icon-size: 37px;
+}
+
+nav {
+ padding: 0 20px;
+ width: 500px;
+ height: 100px;
+ background: white;
+ border-radius: var(--radius);
+ display: flex;
+ align-items: center;
+ justify-content: space-around;
+ position: relative;
+}
+
+nav::before {
+ content: "";
+ width: calc(100% + 40px);
+ height: calc(50vh + 160px);
+ background: rgba(255, 255, 255, 0.1);
+ border-radius: calc(var(--radius) + 10px);
+ position: absolute;
+ bottom: -10px;
+ z-index: -1;
+ box-shadow: 0 0 40px #0001;
+}
+
+nav div.item label[for] {
+ width: calc(var(--icon-size) + 15px);
+ height: auto;
+ aspect-ratio: 1/1;
+ color: var(--text-color);
+ cursor: pointer;
+ display: grid;
+ place-items: center;
+ position: relative;
+ transition: transform 0.3s ease-in-out, color 0.1s linear;
+}
+
+nav div.item label[for]::after {
+ content: attr(data-label);
+ position: absolute;
+ bottom: -20px;
+ text-transform: capitalize;
+ font-weight: 800;
+ pointer-events: none;
+ opacity: 0;
+ transform: translateY(50px);
+}
+
+nav div.item label[for] svg {
+ width: var(--icon-size);
+ height: auto;
+ aspect-ratio: 1/1;
+}
+
+nav div.item input[type=radio] {
+ display: none;
+ pointer-events: none;
+}
+
+nav div.item input[type=radio]:checked+label {
+ color: var(--highlight);
+ transform: translateY(-10px);
+}
+
+nav div.item input[type=radio]:checked+label::after {
+ opacity: 1;
+ transform: translateY(0px);
+ transition: transform 0.3s ease-in-out, opacity 0.2s ease-in-out 0.1s;
+}
+
+@-webkit-keyframes explore {
+
+ 0%,
+ 100% {
+ transform-origin: center;
+ transform: scale(1);
+ }
+
+ 50% {
+ transform: scale(1.15);
+ }
+}
+
+@keyframes explore {
+
+ 0%,
+ 100% {
+ transform-origin: center;
+ transform: scale(1);
+ }
+
+ 50% {
+ transform: scale(1.15);
+ }
+}
+
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(3),
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(6),
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(9) {
+ -webkit-animation: explore 0.3s linear;
+ animation: explore 0.3s linear;
+}
+
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(2),
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(5),
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(8) {
+ -webkit-animation: explore 0.3s linear 0.1s;
+ animation: explore 0.3s linear 0.1s;
+}
+
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(1),
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(4),
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(7) {
+ -webkit-animation: explore 0.3s linear 0.2s;
+ animation: explore 0.3s linear 0.2s;
+}
+
+@-webkit-keyframes cartbtn {
+
+ 0%,
+ 100% {
+ transform-origin: center;
+ transform: translateX(0);
+ }
+
+ 25% {
+ transform: translateX(20px);
+ }
+
+ 26% {
+ opacity: 0;
+ }
+
+ 74% {
+ opacity: 0;
+ }
+
+ 75% {
+ opacity: 1;
+ transform: translateX(-20px);
+ }
+}
+
+@keyframes cartbtn {
+
+ 0%,
+ 100% {
+ transform-origin: center;
+ transform: translateX(0);
+ }
+
+ 25% {
+ transform: translateX(20px);
+ }
+
+ 26% {
+ opacity: 0;
+ }
+
+ 74% {
+ opacity: 0;
+ }
+
+ 75% {
+ opacity: 1;
+ transform: translateX(-20px);
+ }
+}
+
+input[type=radio]#cartbtn:checked+label[for] svg path:nth-child(2),
+input[type=radio]#cartbtn:checked+label[for] svg path:nth-child(3) {
+ -webkit-animation: cartbtn 0.3s ease-out;
+ animation: cartbtn 0.3s ease-out;
+}
+
+@-webkit-keyframes saved {
+
+ 0%,
+ 100% {
+ transform-style: preserve-3d;
+ transform-origin: top;
+ transform: skewX(0deg);
+ }
+
+ 30% {
+ transform: skewX(-6deg);
+ }
+
+ 70% {
+ transform: skewX(2deg);
+ }
+}
+
+@keyframes saved {
+
+ 0%,
+ 100% {
+ transform-style: preserve-3d;
+ transform-origin: top;
+ transform: skewX(0deg);
+ }
+
+ 30% {
+ transform: skewX(-6deg);
+ }
+
+ 70% {
+ transform: skewX(2deg);
+ }
+}
+
+input[type=radio]#saved:checked+label[for] svg path {
+ -webkit-animation: saved 0.3s linear;
+ animation: saved 0.3s linear;
+}
+
+@-webkit-keyframes profile {
+
+ 0%,
+ 100% {
+ transform-origin: bottom;
+ transform: rotate(0deg);
+ }
+
+ 40% {
+ transform: rotate(-5deg);
+ }
+
+ 60% {
+ transform: rotate(5deg);
+ }
+}
+
+@keyframes profile {
+
+ 0%,
+ 100% {
+ transform-origin: bottom;
+ transform: rotate(0deg);
+ }
+
+ 40% {
+ transform: rotate(-5deg);
+ }
+
+ 60% {
+ transform: rotate(5deg);
+ }
+}
+
+input[type=radio]#profile:checked+label[for] svg path:first-of-type {
+ -webkit-animation: profile 0.3s linear;
+ animation: profile 0.3s linear;
+}
\ No newline at end of file
diff --git a/packages/console/assets/page/login.css b/packages/console/assets/page/login.css
new file mode 100644
index 0000000..265d56e
--- /dev/null
+++ b/packages/console/assets/page/login.css
@@ -0,0 +1,81 @@
+.login-page {
+ width: 360px;
+ margin: auto;
+}
+
+.login-page .container {
+ position: relative;
+ z-index: 1;
+ background: #FFFFFF;
+ max-width: 360px;
+ margin: 0 auto 100px;
+ padding: 45px;
+ text-align: center;
+ box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.2), 0 5px 5px 0 rgba(0, 0, 0, 0.24);
+ border-radius: 30px;
+}
+
+.login-page .container input {
+ outline: 0;
+ background: #f2f2f2;
+ width: 100%;
+ border: 0;
+ margin: 0 0 15px;
+ padding: 15px;
+ box-sizing: border-box;
+ font-size: 14px;
+ border-radius: 30px;
+}
+
+.login-page .container button {
+ text-transform: uppercase;
+ outline: 0;
+ background: #3165b1;
+ width: 100%;
+ border: 0;
+ padding: 15px;
+ color: #FFFFFF;
+ font-size: 14px;
+ transition: all 0.3 ease;
+ cursor: pointer;
+ margin-top: 10px;
+ border-radius: 30px;
+}
+
+.login-page .container button:hover,
+.login-page .container button:active,
+.login-page .container button:focus {
+ background: #0081ff;
+}
+
+.login-page .container .message {
+ margin: 15px 0 0;
+ color: #b3b3b3;
+ font-size: 12px;
+}
+
+.login-page .container .message a {
+ color: #3165b1;
+ font-weight: bold;
+ text-decoration: none;
+}
+
+.hidden {
+ /* opacity: 0;
+ visibility: hidden;
+ height: 0; */
+ display: none;
+}
+
+.login-wrapper {
+ background: rgb(2,0,36);
+ background: linear-gradient(90deg, rgba(2,0,36,1) 0%, rgba(0,212,255,1) 0%, rgba(49,101,177,1) 100%);
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ position: absolute;
+ overflow: hidden;
+ z-index: 100;
+ display: flex;
+}
\ No newline at end of file
diff --git a/packages/console/favicon.ico b/packages/console/favicon.ico
new file mode 100644
index 0000000..34002ef
Binary files /dev/null and b/packages/console/favicon.ico differ
diff --git a/packages/console/index.html b/packages/console/index.html
new file mode 100644
index 0000000..3464fe7
--- /dev/null
+++ b/packages/console/index.html
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+ Document
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/console/login.html b/packages/console/login.html
new file mode 100644
index 0000000..e9e2398
--- /dev/null
+++ b/packages/console/login.html
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+ Parang Cafe | Login
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/console/package.json b/packages/console/package.json
new file mode 100644
index 0000000..6d79fe5
--- /dev/null
+++ b/packages/console/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "parang",
+ "version": "1.0.0",
+ "description": "",
+ "main": "webpack.config.js",
+ "scripts": {
+ "watch:tsc": "tsc -w",
+ "watch:webpack": "webpack -w",
+ "build": "tsc && webpack",
+ "start": "webpack-dev-server",
+ "test": "tsc && webpack && cypress run --browser chrome"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "devDependencies": {
+ "css-loader": "^6.5.1",
+ "html-webpack-plugin": "^5.5.0",
+ "resolve-url-loader": "^5.0.0",
+ "sass": "^1.49.0",
+ "sass-loader": "^12.4.0",
+ "style-loader": "^3.3.1",
+ "ts-loader": "^9.2.6",
+ "tsconfig-paths-webpack-plugin": "^3.5.2",
+ "webpack": "^5.66.0",
+ "webpack-cli": "^4.9.1",
+ "webpack-dev-server": "^4.7.3"
+ }
+}
diff --git a/packages/console/src/api/index.js b/packages/console/src/api/index.js
new file mode 100644
index 0000000..b76cdfc
--- /dev/null
+++ b/packages/console/src/api/index.js
@@ -0,0 +1,54 @@
+export async function login(url, data) {
+ const config = {
+ method: 'POST',
+ headers: new Headers({ 'content-type': 'application/json' }),
+ };
+ if (data) config.body = JSON.stringify(data);
+ const response = await fetch(url, config);
+ const parse = await response.json();
+ return parse;
+}
+
+export async function signup(url, data) {
+ const config = {
+ method: 'POST',
+ headers: new Headers({ 'content-type': 'application/json' }),
+ };
+ if (data) config.body = JSON.stringify(data);
+ const response = await fetch(url, config);
+ const parse = await response.json();
+ return parse;
+}
+
+export async function getBookmarkList(url, data) {
+ const config = {
+ method: 'POST',
+ headers: new Headers({ 'content-type': 'application/json' }),
+ };
+ if (data) config.body = JSON.stringify(data);
+ const response = await fetch(url, config);
+ const parse = await response.json();
+ return parse;
+}
+
+export async function addBookmark(url, data) {
+ const config = {
+ method: 'POST',
+ headers: new Headers({ 'content-type': 'application/json' }),
+ };
+ if (data) config.body = JSON.stringify(data);
+ const response = await fetch(url, config);
+ const parse = await response.json();
+ return parse;
+}
+
+export async function removeBookmark(url, data) {
+ const config = {
+ method: 'DELETE',
+ headers: new Headers({ 'content-type': 'application/json' }),
+ };
+ if (data) config.body = JSON.stringify(data);
+ const response = await fetch(url, config);
+ const parse = await response.json();
+ return parse;
+}
diff --git a/packages/console/src/helper/debounce.js b/packages/console/src/helper/debounce.js
new file mode 100644
index 0000000..3b765e0
--- /dev/null
+++ b/packages/console/src/helper/debounce.js
@@ -0,0 +1,56 @@
+function throttle(delay, noTrailing, callback, debounceMode) {
+ let timeoutID;
+ let cancelled = false;
+ let lastExec = 0;
+
+ function clearExistingTimeout() {
+ if (timeoutID === undefined) return;
+ clearTimeout(timeoutID);
+ }
+
+ function cancel() {
+ clearExistingTimeout();
+ cancelled = true;
+ }
+
+ if (typeof noTrailing !== 'boolean') {
+ debounceMode = callback;
+ callback = noTrailing;
+ noTrailing = undefined;
+ }
+
+ function wrapper(...args) {
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
+ const self = this;
+ const elapsed = Date.now() - lastExec;
+
+ if (cancelled) return;
+
+ function exec() {
+ lastExec = Date.now();
+ callback.apply(self, args);
+ }
+
+ function clear() {
+ timeoutID = undefined;
+ }
+
+ if (debounceMode && !timeoutID) exec();
+
+ clearExistingTimeout();
+
+ if (debounceMode === undefined && elapsed > delay) {
+ exec();
+ } else if (noTrailing !== true) {
+ timeoutID = setTimeout(
+ debounceMode ? clear : exec,
+ debounceMode === undefined ? delay - elapsed : delay,
+ );
+ }
+ }
+
+ wrapper.cancel = cancel;
+ return wrapper;
+}
+
+export const debounce = (callback, delay) => throttle(delay, callback, false);
diff --git a/packages/console/src/helper/dom.js b/packages/console/src/helper/dom.js
new file mode 100644
index 0000000..f0c07cc
--- /dev/null
+++ b/packages/console/src/helper/dom.js
@@ -0,0 +1,5 @@
+export const $ = selector => document.querySelector(selector);
+
+export const $all = selector => document.querySelectorAll(selector);
+
+export const toggleLoading = () => $('.loading').classList.toggle('hidden');
diff --git a/packages/console/src/helper/index.js b/packages/console/src/helper/index.js
new file mode 100644
index 0000000..b7b8951
--- /dev/null
+++ b/packages/console/src/helper/index.js
@@ -0,0 +1,2 @@
+export * from './debounce.js';
+export * from './dom.js';
diff --git a/packages/console/src/login.js b/packages/console/src/login.js
new file mode 100644
index 0000000..ca706b2
--- /dev/null
+++ b/packages/console/src/login.js
@@ -0,0 +1,48 @@
+import '../assets/page/login.css';
+import { login, signup } from './api/index.js';
+import { $, $all } from './helper/index.js';
+
+$all('.message a').forEach(tag => {
+ tag.addEventListener('click', () => {
+ $all('.forms').forEach(form => {
+ form.classList.toggle('hidden');
+ });
+ });
+});
+
+$('button[data-submit="signup"]').addEventListener('click', async event => {
+ event.preventDefault();
+
+ const email = $('#signup-email').value;
+ const password = $('#signup-password').value;
+ const passwordConfirm = $('#signup-password-confirm').value;
+
+ if (password !== passwordConfirm) return alert('패스워드를 확인해주세요.');
+ const regEmail =
+ /^[0-9a-zA-Z]([-_\\.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_\\.]?[0-9a-zA-Z])*\.[a-zA-Z]{2,3}$/i;
+ if (!regEmail.test(email)) return alert('옳지 않은 이메일 형식입니다.');
+
+ await signup('http://localhost:3000/api/user', {
+ email,
+ password,
+ status: 0,
+ });
+
+ alert('회원가입이 완료되었습니다.\n로그인해주세요.');
+});
+
+$('button[data-submit="login"]').addEventListener('click', async event => {
+ event.preventDefault();
+
+ const email = $('#login-email').value;
+ const password = $('#login-password').value;
+
+ const data = await login('http://localhost:3000/api/user/login', {
+ email,
+ password,
+ });
+ const { _id, email: userEmail } = data[0];
+ alert(`환영합니다, ${userEmail}님!`);
+ localStorage.setItem('user_token', _id);
+ location.replace('http://localhost:5510/');
+});
diff --git a/packages/console/src/main.js b/packages/console/src/main.js
new file mode 100644
index 0000000..994d518
--- /dev/null
+++ b/packages/console/src/main.js
@@ -0,0 +1,110 @@
+import '../assets/index.css';
+import { addBookmark, getBookmarkList } from './api';
+import { $, toggleLoading, debounce } from './helper/index.js';
+
+(() => {
+ const isLogin = localStorage.getItem('user_token');
+ if (isLogin !== null) return;
+
+ location.replace('./login.html');
+})();
+
+let globalIndex = 0;
+
+const createPin = () => {
+ toggleLoading();
+ const pin = document.createElement('div');
+ const buttonWrapper = document.createElement('div');
+ const image = document.createElement('img');
+ const random = Math.floor(Math.random() * 123) + 1;
+ image.src = `https://randomfox.ca/images/${random}.jpg`;
+ buttonWrapper.setAttribute('class', 'button-wrapper');
+ buttonWrapper.innerHTML = `
+
+
+
+
+ `;
+ pin.classList.add('pin');
+ pin.appendChild(buttonWrapper);
+ pin.appendChild(image);
+ toggleLoading();
+ return pin;
+};
+
+const loadMore = debounce(() => {
+ const container = $('.container');
+ const pinList = [];
+ for (let i = 10; i > 0; i--) {
+ pinList.push(createPin(++globalIndex));
+ }
+ container.append(...pinList);
+}, 500);
+
+loadMore();
+
+window.addEventListener('scroll', () => {
+ const loader = $('.loader');
+ if (loader === null) return;
+ if (loader.getBoundingClientRect().top > window.innerHeight) return;
+ loadMore();
+});
+
+$('nav').addEventListener('click', async event => {
+ event.stopPropagation();
+ if (!event.target.matches('input')) return;
+
+ const $main = $('main');
+ $main.innerHTML = '';
+
+ if (event.target.matches('#explore')) {
+ $main.classList.remove('saved');
+ $main.innerHTML = `
+
+
+ `;
+
+ globalIndex = 0;
+ loadMore();
+ }
+
+ if (event.target.matches('#saved')) {
+ $main.classList.add('saved');
+ const _id = localStorage.getItem('user_token');
+ const result = await getBookmarkList(
+ 'http://localhost:3000/api/user/bookmark',
+ { _id },
+ );
+ const $content = `
+
+ ${result
+ .map(
+ ({ _id, url }, index) => `
+
+
![](https://randomfox.ca/images/${url}.jpg)
+
`,
+ )
+ .join('')}
+
+ `;
+
+ $main.innerHTML = $content;
+ }
+});
+
+$('main').addEventListener('click', async event => {
+ if (!event.target.matches('label[for^="heart"]')) return;
+ const _id = localStorage.getItem('user_token');
+ await addBookmark(
+ `http://localhost:3000/api/user/bookmark/${event.target.getAttribute(
+ 'key',
+ )}`,
+ { _id },
+ );
+ console.log('북마크에 저장되었습니다.');
+});
diff --git a/packages/console/tsconfig.json b/packages/console/tsconfig.json
new file mode 100644
index 0000000..9338810
--- /dev/null
+++ b/packages/console/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "baseUrl": ".",
+ "module": "esnext",
+ "noEmit": false,
+ "outDir": "dist",
+ "target": "es2017",
+ "isolatedModules": true,
+ "paths": {
+ "~/*": [
+ "*"
+ ],
+ "@/*": [
+ "src/*"
+ ],
+ },
+ "typeRoots": [
+ "src/types"
+ ]
+ },
+ "include": ["**/*.ts"],
+ "exclude": ["node_modules"]
+}
\ No newline at end of file
diff --git a/packages/console/webpack.config.js b/packages/console/webpack.config.js
new file mode 100644
index 0000000..71bd288
--- /dev/null
+++ b/packages/console/webpack.config.js
@@ -0,0 +1,85 @@
+/* eslint-disable @typescript-eslint/no-var-requires */
+const path = require('path');
+const webpack = require('webpack');
+const HTMLWebpackPlugin = require('html-webpack-plugin');
+const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
+
+module.exports = {
+ mode: 'development',
+ devServer: {
+ port: 5510,
+ hot: true,
+ open: true,
+ historyApiFallback: true,
+ watchFiles: ['src/**/*.ts', 'public/**/*'],
+ },
+ // devtool: 'inline-source-map',
+ target: ['es5', 'web'],
+ entry: {
+ // 각 html에 필요한 entry 파일
+ index: './src/main.js',
+ login: './src/login.js',
+ },
+ output: {
+ path: path.resolve(__dirname, 'public'),
+ filename: '[chunkhash].js',
+ clean: true,
+ },
+ resolve: {
+ extensions: ['.ts', '.tsx', '.js', '.jsx'],
+ plugins: [new TsconfigPathsPlugin({})],
+ },
+ plugins: [
+ new HTMLWebpackPlugin({
+ hash: true,
+ filename: 'index.html',
+ excludeChunks: ['login'], // entry에서 해당 리스트를 제외한 나머지
+ template: path.resolve(__dirname, 'index.html'),
+ favicon: './favicon.ico',
+ }),
+ new HTMLWebpackPlugin({
+ hash: true,
+ filename: 'login.html',
+ chunks: ['login'], // entry에서 해당 리스트만 포함
+ template: path.resolve(__dirname, 'login.html'),
+ favicon: './favicon.ico',
+ }),
+ new webpack.HotModuleReplacementPlugin(),
+ ],
+ module: {
+ rules: [
+ // {
+ // test: /\.s[ac]ss$/i,
+ // use: [
+ // 'style-loader',
+ // 'css-loader',
+ // 'resolve-url-loader',
+ // {
+ // loader: 'sass-loader',
+ // options: {
+ // sourceMap: true,
+ // },
+ // },
+ // ],
+ // exclude: /node_modules/,
+ // },
+ {
+ test: /\.css$/,
+ use: ['style-loader', 'css-loader'],
+ },
+ {
+ test: /\.(ts|tsx)$/,
+ include: path.join(__dirname, 'src'),
+ exclude: /(node_modules)|(dist)/,
+ use: {
+ loader: 'ts-loader',
+ options: {
+ configFile: path.resolve(__dirname, 'tsconfig.json'),
+ // skip typechecking for speed
+ transpileOnly: true,
+ },
+ },
+ },
+ ],
+ },
+};
diff --git a/packages/joy/assets/index.css b/packages/joy/assets/index.css
new file mode 100644
index 0000000..39f5997
--- /dev/null
+++ b/packages/joy/assets/index.css
@@ -0,0 +1,289 @@
+@import url('./module/nav.css');
+@import url('./module/loading.css');
+
+*,
+*:before,
+*:after {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: "Montserrat", Sans-Serif;
+ color: var(--text-color);
+ display: grid;
+ justify-content: center;
+}
+
+svg {
+ pointer-events: none;
+}
+
+.app {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+main {
+ display: flex;
+ width: auto;
+ margin-top: 2em;
+}
+
+.saved {
+ width: 1000px;
+ justify-content: space-between;
+}
+
+.pin {
+ display: flex;
+ flex-direction: column;
+ margin-top: 1em;
+ border: 2px solid #e6e6e6;
+ border-radius: 15px;
+ padding: 20px;
+}
+
+.button-wrapper {
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-end;
+}
+
+.heart label {
+ box-shadow: 0px 0px 0px 0px rgba(226, 32, 44, 0.5);
+}
+
+.heart label:after {
+ content: "\f004";
+}
+
+.heart input:checked+label {
+ background-color: #e2202c;
+ border-color: #e2202c;
+ box-shadow: 0px 0px 0px 0.5em rgba(226, 32, 44, 0);
+}
+
+.heart input:checked+label:after {
+ color: #e2202c;
+}
+
+.anim-icon {
+ width: 1.9em;
+ height: 1.9em;
+ margin: 10px;
+ font-size: 13px;
+ display: inline-block;
+ position: relative;
+ vertical-align: middle;
+}
+
+.anim-icon input {
+ display: none;
+}
+
+.anim-icon label {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ border: 0.1em solid #ccc;
+ border-radius: 100%;
+ display: block;
+ font: normal normal normal 13px/1 FontAwesome;
+ color: #ccc;
+ font-size: inherit;
+ text-rendering: auto;
+ -webkit-font-smoothing: antialiased;
+}
+
+.anim-icon label:after {
+ left: 0;
+ top: 50%;
+ margin-top: -0.5em;
+ display: block;
+ position: relative;
+ text-align: center;
+}
+
+.anim-icon input:checked+label {
+ -webkit-animation: check-in 0.3s forwards;
+ animation: check-in 0.3s forwards;
+ transition: background-color 0.1s 0.2s, box-shadow 1s;
+ border-width: 0.1em;
+ border-style: solid;
+}
+
+.anim-icon input:checked+label:after {
+ -webkit-animation: icon 0.3s forwards;
+ animation: icon 0.3s forwards;
+}
+
+.anim-icon-md {
+ font-size: 20px;
+}
+
+.anim-icon-lg {
+ font-size: 30px;
+}
+
+@-webkit-keyframes icon {
+ 0% {
+ margin-top: -0.5em;
+ font-size: 1.5em;
+ }
+
+ 100% {
+ font-size: 1em;
+ opacity: 1;
+ color: white;
+ }
+}
+
+@keyframes icon {
+ 0% {
+ margin-top: -0.5em;
+ font-size: 1.5em;
+ }
+
+ 100% {
+ font-size: 1em;
+ opacity: 1;
+ color: white;
+ }
+}
+
+@-webkit-keyframes check-in {
+ 0% {
+ left: 20%;
+ top: 20%;
+ width: 60%;
+ height: 60%;
+ }
+
+ 80% {
+ left: -5%;
+ top: -5%;
+ width: 110%;
+ height: 110%;
+ }
+
+ 100% {
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ }
+}
+
+@keyframes check-in {
+ 0% {
+ left: 20%;
+ top: 20%;
+ width: 60%;
+ height: 60%;
+ }
+
+ 80% {
+ left: -5%;
+ top: -5%;
+ width: 110%;
+ height: 110%;
+ }
+
+ 100% {
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ }
+}
+
+@-webkit-keyframes check {
+ 0% {
+ left: 5%;
+ top: 5%;
+ width: 90%;
+ height: 90%;
+ }
+
+ 10% {
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ }
+
+ 80% {
+ left: -5%;
+ top: -5%;
+ width: 110%;
+ height: 110%;
+ }
+
+ 90% {
+ left: 5%;
+ top: 5%;
+ width: 90%;
+ height: 90%;
+ }
+
+ 100% {
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ }
+}
+
+@keyframes check {
+ 0% {
+ left: 5%;
+ top: 5%;
+ width: 90%;
+ height: 90%;
+ }
+
+ 10% {
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ }
+
+ 80% {
+ left: -5%;
+ top: -5%;
+ width: 110%;
+ height: 110%;
+ }
+
+ 90% {
+ left: 5%;
+ top: 5%;
+ width: 90%;
+ height: 90%;
+ }
+
+ 100% {
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ }
+}
+
+img {
+ width: 460px;
+ height: 460px;
+ object-fit: cover;
+}
+
+.saved .container {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 40px;
+}
\ No newline at end of file
diff --git a/packages/joy/assets/module/loading.css b/packages/joy/assets/module/loading.css
new file mode 100644
index 0000000..7c32341
--- /dev/null
+++ b/packages/joy/assets/module/loading.css
@@ -0,0 +1,23 @@
+.loading {
+ transition: all ease-in-out .2s;
+ opacity: 1;
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ background: rgb(255, 255, 255, 0.8);
+ z-index: 10;
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ align-items: center;
+ margin: auto;
+ overflow: hidden !important;
+ touch-action: none;
+}
+
+.hidden {
+ opacity: 0;
+ visibility: hidden;
+}
\ No newline at end of file
diff --git a/packages/joy/assets/module/nav.css b/packages/joy/assets/module/nav.css
new file mode 100644
index 0000000..4c4d8fd
--- /dev/null
+++ b/packages/joy/assets/module/nav.css
@@ -0,0 +1,265 @@
+@import url("https://fonts.googleapis.com/css?family=Montserrat:400,400i,700");
+
+:root {
+ --bg-blue: #362FE3;
+ --bg-pink: #BF6684;
+ --bg-purple: #9439E4;
+ --text-color: #333;
+ --highlight: #8F7BF6;
+ --radius: 40px;
+ --icon-size: 37px;
+}
+
+nav {
+ padding: 0 20px;
+ width: 500px;
+ height: 100px;
+ background: white;
+ border-radius: var(--radius);
+ display: flex;
+ align-items: center;
+ justify-content: space-around;
+ position: relative;
+}
+
+nav::before {
+ content: "";
+ width: calc(100% + 40px);
+ height: calc(50vh + 160px);
+ background: rgba(255, 255, 255, 0.1);
+ border-radius: calc(var(--radius) + 10px);
+ position: absolute;
+ bottom: -10px;
+ z-index: -1;
+ box-shadow: 0 0 40px #0001;
+}
+
+nav div.item label[for] {
+ width: calc(var(--icon-size) + 15px);
+ height: auto;
+ aspect-ratio: 1/1;
+ color: var(--text-color);
+ cursor: pointer;
+ display: grid;
+ place-items: center;
+ position: relative;
+ transition: transform 0.3s ease-in-out, color 0.1s linear;
+}
+
+nav div.item label[for]::after {
+ content: attr(data-label);
+ position: absolute;
+ bottom: -20px;
+ text-transform: capitalize;
+ font-weight: 800;
+ pointer-events: none;
+ opacity: 0;
+ transform: translateY(50px);
+}
+
+nav div.item label[for] svg {
+ width: var(--icon-size);
+ height: auto;
+ aspect-ratio: 1/1;
+}
+
+nav div.item input[type=radio] {
+ display: none;
+ pointer-events: none;
+}
+
+nav div.item input[type=radio]:checked+label {
+ color: var(--highlight);
+ transform: translateY(-10px);
+}
+
+nav div.item input[type=radio]:checked+label::after {
+ opacity: 1;
+ transform: translateY(0px);
+ transition: transform 0.3s ease-in-out, opacity 0.2s ease-in-out 0.1s;
+}
+
+@-webkit-keyframes explore {
+
+ 0%,
+ 100% {
+ transform-origin: center;
+ transform: scale(1);
+ }
+
+ 50% {
+ transform: scale(1.15);
+ }
+}
+
+@keyframes explore {
+
+ 0%,
+ 100% {
+ transform-origin: center;
+ transform: scale(1);
+ }
+
+ 50% {
+ transform: scale(1.15);
+ }
+}
+
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(3),
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(6),
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(9) {
+ -webkit-animation: explore 0.3s linear;
+ animation: explore 0.3s linear;
+}
+
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(2),
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(5),
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(8) {
+ -webkit-animation: explore 0.3s linear 0.1s;
+ animation: explore 0.3s linear 0.1s;
+}
+
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(1),
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(4),
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(7) {
+ -webkit-animation: explore 0.3s linear 0.2s;
+ animation: explore 0.3s linear 0.2s;
+}
+
+@-webkit-keyframes cartbtn {
+
+ 0%,
+ 100% {
+ transform-origin: center;
+ transform: translateX(0);
+ }
+
+ 25% {
+ transform: translateX(20px);
+ }
+
+ 26% {
+ opacity: 0;
+ }
+
+ 74% {
+ opacity: 0;
+ }
+
+ 75% {
+ opacity: 1;
+ transform: translateX(-20px);
+ }
+}
+
+@keyframes cartbtn {
+
+ 0%,
+ 100% {
+ transform-origin: center;
+ transform: translateX(0);
+ }
+
+ 25% {
+ transform: translateX(20px);
+ }
+
+ 26% {
+ opacity: 0;
+ }
+
+ 74% {
+ opacity: 0;
+ }
+
+ 75% {
+ opacity: 1;
+ transform: translateX(-20px);
+ }
+}
+
+input[type=radio]#cartbtn:checked+label[for] svg path:nth-child(2),
+input[type=radio]#cartbtn:checked+label[for] svg path:nth-child(3) {
+ -webkit-animation: cartbtn 0.3s ease-out;
+ animation: cartbtn 0.3s ease-out;
+}
+
+@-webkit-keyframes saved {
+
+ 0%,
+ 100% {
+ transform-style: preserve-3d;
+ transform-origin: top;
+ transform: skewX(0deg);
+ }
+
+ 30% {
+ transform: skewX(-6deg);
+ }
+
+ 70% {
+ transform: skewX(2deg);
+ }
+}
+
+@keyframes saved {
+
+ 0%,
+ 100% {
+ transform-style: preserve-3d;
+ transform-origin: top;
+ transform: skewX(0deg);
+ }
+
+ 30% {
+ transform: skewX(-6deg);
+ }
+
+ 70% {
+ transform: skewX(2deg);
+ }
+}
+
+input[type=radio]#saved:checked+label[for] svg path {
+ -webkit-animation: saved 0.3s linear;
+ animation: saved 0.3s linear;
+}
+
+@-webkit-keyframes profile {
+
+ 0%,
+ 100% {
+ transform-origin: bottom;
+ transform: rotate(0deg);
+ }
+
+ 40% {
+ transform: rotate(-5deg);
+ }
+
+ 60% {
+ transform: rotate(5deg);
+ }
+}
+
+@keyframes profile {
+
+ 0%,
+ 100% {
+ transform-origin: bottom;
+ transform: rotate(0deg);
+ }
+
+ 40% {
+ transform: rotate(-5deg);
+ }
+
+ 60% {
+ transform: rotate(5deg);
+ }
+}
+
+input[type=radio]#profile:checked+label[for] svg path:first-of-type {
+ -webkit-animation: profile 0.3s linear;
+ animation: profile 0.3s linear;
+}
\ No newline at end of file
diff --git a/packages/joy/assets/page/login.css b/packages/joy/assets/page/login.css
new file mode 100644
index 0000000..265d56e
--- /dev/null
+++ b/packages/joy/assets/page/login.css
@@ -0,0 +1,81 @@
+.login-page {
+ width: 360px;
+ margin: auto;
+}
+
+.login-page .container {
+ position: relative;
+ z-index: 1;
+ background: #FFFFFF;
+ max-width: 360px;
+ margin: 0 auto 100px;
+ padding: 45px;
+ text-align: center;
+ box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.2), 0 5px 5px 0 rgba(0, 0, 0, 0.24);
+ border-radius: 30px;
+}
+
+.login-page .container input {
+ outline: 0;
+ background: #f2f2f2;
+ width: 100%;
+ border: 0;
+ margin: 0 0 15px;
+ padding: 15px;
+ box-sizing: border-box;
+ font-size: 14px;
+ border-radius: 30px;
+}
+
+.login-page .container button {
+ text-transform: uppercase;
+ outline: 0;
+ background: #3165b1;
+ width: 100%;
+ border: 0;
+ padding: 15px;
+ color: #FFFFFF;
+ font-size: 14px;
+ transition: all 0.3 ease;
+ cursor: pointer;
+ margin-top: 10px;
+ border-radius: 30px;
+}
+
+.login-page .container button:hover,
+.login-page .container button:active,
+.login-page .container button:focus {
+ background: #0081ff;
+}
+
+.login-page .container .message {
+ margin: 15px 0 0;
+ color: #b3b3b3;
+ font-size: 12px;
+}
+
+.login-page .container .message a {
+ color: #3165b1;
+ font-weight: bold;
+ text-decoration: none;
+}
+
+.hidden {
+ /* opacity: 0;
+ visibility: hidden;
+ height: 0; */
+ display: none;
+}
+
+.login-wrapper {
+ background: rgb(2,0,36);
+ background: linear-gradient(90deg, rgba(2,0,36,1) 0%, rgba(0,212,255,1) 0%, rgba(49,101,177,1) 100%);
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ position: absolute;
+ overflow: hidden;
+ z-index: 100;
+ display: flex;
+}
\ No newline at end of file
diff --git a/packages/joy/favicon.ico b/packages/joy/favicon.ico
new file mode 100644
index 0000000..34002ef
Binary files /dev/null and b/packages/joy/favicon.ico differ
diff --git a/packages/joy/index.html b/packages/joy/index.html
new file mode 100644
index 0000000..3464fe7
--- /dev/null
+++ b/packages/joy/index.html
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+ Document
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/joy/login.html b/packages/joy/login.html
new file mode 100644
index 0000000..e9e2398
--- /dev/null
+++ b/packages/joy/login.html
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+ Parang Cafe | Login
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/joy/package.json b/packages/joy/package.json
new file mode 100644
index 0000000..6d79fe5
--- /dev/null
+++ b/packages/joy/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "parang",
+ "version": "1.0.0",
+ "description": "",
+ "main": "webpack.config.js",
+ "scripts": {
+ "watch:tsc": "tsc -w",
+ "watch:webpack": "webpack -w",
+ "build": "tsc && webpack",
+ "start": "webpack-dev-server",
+ "test": "tsc && webpack && cypress run --browser chrome"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "devDependencies": {
+ "css-loader": "^6.5.1",
+ "html-webpack-plugin": "^5.5.0",
+ "resolve-url-loader": "^5.0.0",
+ "sass": "^1.49.0",
+ "sass-loader": "^12.4.0",
+ "style-loader": "^3.3.1",
+ "ts-loader": "^9.2.6",
+ "tsconfig-paths-webpack-plugin": "^3.5.2",
+ "webpack": "^5.66.0",
+ "webpack-cli": "^4.9.1",
+ "webpack-dev-server": "^4.7.3"
+ }
+}
diff --git a/packages/joy/src/api/index.js b/packages/joy/src/api/index.js
new file mode 100644
index 0000000..b76cdfc
--- /dev/null
+++ b/packages/joy/src/api/index.js
@@ -0,0 +1,54 @@
+export async function login(url, data) {
+ const config = {
+ method: 'POST',
+ headers: new Headers({ 'content-type': 'application/json' }),
+ };
+ if (data) config.body = JSON.stringify(data);
+ const response = await fetch(url, config);
+ const parse = await response.json();
+ return parse;
+}
+
+export async function signup(url, data) {
+ const config = {
+ method: 'POST',
+ headers: new Headers({ 'content-type': 'application/json' }),
+ };
+ if (data) config.body = JSON.stringify(data);
+ const response = await fetch(url, config);
+ const parse = await response.json();
+ return parse;
+}
+
+export async function getBookmarkList(url, data) {
+ const config = {
+ method: 'POST',
+ headers: new Headers({ 'content-type': 'application/json' }),
+ };
+ if (data) config.body = JSON.stringify(data);
+ const response = await fetch(url, config);
+ const parse = await response.json();
+ return parse;
+}
+
+export async function addBookmark(url, data) {
+ const config = {
+ method: 'POST',
+ headers: new Headers({ 'content-type': 'application/json' }),
+ };
+ if (data) config.body = JSON.stringify(data);
+ const response = await fetch(url, config);
+ const parse = await response.json();
+ return parse;
+}
+
+export async function removeBookmark(url, data) {
+ const config = {
+ method: 'DELETE',
+ headers: new Headers({ 'content-type': 'application/json' }),
+ };
+ if (data) config.body = JSON.stringify(data);
+ const response = await fetch(url, config);
+ const parse = await response.json();
+ return parse;
+}
diff --git a/packages/joy/src/helper/debounce.js b/packages/joy/src/helper/debounce.js
new file mode 100644
index 0000000..3b765e0
--- /dev/null
+++ b/packages/joy/src/helper/debounce.js
@@ -0,0 +1,56 @@
+function throttle(delay, noTrailing, callback, debounceMode) {
+ let timeoutID;
+ let cancelled = false;
+ let lastExec = 0;
+
+ function clearExistingTimeout() {
+ if (timeoutID === undefined) return;
+ clearTimeout(timeoutID);
+ }
+
+ function cancel() {
+ clearExistingTimeout();
+ cancelled = true;
+ }
+
+ if (typeof noTrailing !== 'boolean') {
+ debounceMode = callback;
+ callback = noTrailing;
+ noTrailing = undefined;
+ }
+
+ function wrapper(...args) {
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
+ const self = this;
+ const elapsed = Date.now() - lastExec;
+
+ if (cancelled) return;
+
+ function exec() {
+ lastExec = Date.now();
+ callback.apply(self, args);
+ }
+
+ function clear() {
+ timeoutID = undefined;
+ }
+
+ if (debounceMode && !timeoutID) exec();
+
+ clearExistingTimeout();
+
+ if (debounceMode === undefined && elapsed > delay) {
+ exec();
+ } else if (noTrailing !== true) {
+ timeoutID = setTimeout(
+ debounceMode ? clear : exec,
+ debounceMode === undefined ? delay - elapsed : delay,
+ );
+ }
+ }
+
+ wrapper.cancel = cancel;
+ return wrapper;
+}
+
+export const debounce = (callback, delay) => throttle(delay, callback, false);
diff --git a/packages/joy/src/helper/dom.js b/packages/joy/src/helper/dom.js
new file mode 100644
index 0000000..f0c07cc
--- /dev/null
+++ b/packages/joy/src/helper/dom.js
@@ -0,0 +1,5 @@
+export const $ = selector => document.querySelector(selector);
+
+export const $all = selector => document.querySelectorAll(selector);
+
+export const toggleLoading = () => $('.loading').classList.toggle('hidden');
diff --git a/packages/joy/src/helper/index.js b/packages/joy/src/helper/index.js
new file mode 100644
index 0000000..b7b8951
--- /dev/null
+++ b/packages/joy/src/helper/index.js
@@ -0,0 +1,2 @@
+export * from './debounce.js';
+export * from './dom.js';
diff --git a/packages/joy/src/login.js b/packages/joy/src/login.js
new file mode 100644
index 0000000..ca706b2
--- /dev/null
+++ b/packages/joy/src/login.js
@@ -0,0 +1,48 @@
+import '../assets/page/login.css';
+import { login, signup } from './api/index.js';
+import { $, $all } from './helper/index.js';
+
+$all('.message a').forEach(tag => {
+ tag.addEventListener('click', () => {
+ $all('.forms').forEach(form => {
+ form.classList.toggle('hidden');
+ });
+ });
+});
+
+$('button[data-submit="signup"]').addEventListener('click', async event => {
+ event.preventDefault();
+
+ const email = $('#signup-email').value;
+ const password = $('#signup-password').value;
+ const passwordConfirm = $('#signup-password-confirm').value;
+
+ if (password !== passwordConfirm) return alert('패스워드를 확인해주세요.');
+ const regEmail =
+ /^[0-9a-zA-Z]([-_\\.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_\\.]?[0-9a-zA-Z])*\.[a-zA-Z]{2,3}$/i;
+ if (!regEmail.test(email)) return alert('옳지 않은 이메일 형식입니다.');
+
+ await signup('http://localhost:3000/api/user', {
+ email,
+ password,
+ status: 0,
+ });
+
+ alert('회원가입이 완료되었습니다.\n로그인해주세요.');
+});
+
+$('button[data-submit="login"]').addEventListener('click', async event => {
+ event.preventDefault();
+
+ const email = $('#login-email').value;
+ const password = $('#login-password').value;
+
+ const data = await login('http://localhost:3000/api/user/login', {
+ email,
+ password,
+ });
+ const { _id, email: userEmail } = data[0];
+ alert(`환영합니다, ${userEmail}님!`);
+ localStorage.setItem('user_token', _id);
+ location.replace('http://localhost:5510/');
+});
diff --git a/packages/joy/src/main.js b/packages/joy/src/main.js
new file mode 100644
index 0000000..994d518
--- /dev/null
+++ b/packages/joy/src/main.js
@@ -0,0 +1,110 @@
+import '../assets/index.css';
+import { addBookmark, getBookmarkList } from './api';
+import { $, toggleLoading, debounce } from './helper/index.js';
+
+(() => {
+ const isLogin = localStorage.getItem('user_token');
+ if (isLogin !== null) return;
+
+ location.replace('./login.html');
+})();
+
+let globalIndex = 0;
+
+const createPin = () => {
+ toggleLoading();
+ const pin = document.createElement('div');
+ const buttonWrapper = document.createElement('div');
+ const image = document.createElement('img');
+ const random = Math.floor(Math.random() * 123) + 1;
+ image.src = `https://randomfox.ca/images/${random}.jpg`;
+ buttonWrapper.setAttribute('class', 'button-wrapper');
+ buttonWrapper.innerHTML = `
+
+
+
+
+ `;
+ pin.classList.add('pin');
+ pin.appendChild(buttonWrapper);
+ pin.appendChild(image);
+ toggleLoading();
+ return pin;
+};
+
+const loadMore = debounce(() => {
+ const container = $('.container');
+ const pinList = [];
+ for (let i = 10; i > 0; i--) {
+ pinList.push(createPin(++globalIndex));
+ }
+ container.append(...pinList);
+}, 500);
+
+loadMore();
+
+window.addEventListener('scroll', () => {
+ const loader = $('.loader');
+ if (loader === null) return;
+ if (loader.getBoundingClientRect().top > window.innerHeight) return;
+ loadMore();
+});
+
+$('nav').addEventListener('click', async event => {
+ event.stopPropagation();
+ if (!event.target.matches('input')) return;
+
+ const $main = $('main');
+ $main.innerHTML = '';
+
+ if (event.target.matches('#explore')) {
+ $main.classList.remove('saved');
+ $main.innerHTML = `
+
+
+ `;
+
+ globalIndex = 0;
+ loadMore();
+ }
+
+ if (event.target.matches('#saved')) {
+ $main.classList.add('saved');
+ const _id = localStorage.getItem('user_token');
+ const result = await getBookmarkList(
+ 'http://localhost:3000/api/user/bookmark',
+ { _id },
+ );
+ const $content = `
+
+ ${result
+ .map(
+ ({ _id, url }, index) => `
+
+
![](https://randomfox.ca/images/${url}.jpg)
+
`,
+ )
+ .join('')}
+
+ `;
+
+ $main.innerHTML = $content;
+ }
+});
+
+$('main').addEventListener('click', async event => {
+ if (!event.target.matches('label[for^="heart"]')) return;
+ const _id = localStorage.getItem('user_token');
+ await addBookmark(
+ `http://localhost:3000/api/user/bookmark/${event.target.getAttribute(
+ 'key',
+ )}`,
+ { _id },
+ );
+ console.log('북마크에 저장되었습니다.');
+});
diff --git a/packages/joy/tsconfig.json b/packages/joy/tsconfig.json
new file mode 100644
index 0000000..9338810
--- /dev/null
+++ b/packages/joy/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "baseUrl": ".",
+ "module": "esnext",
+ "noEmit": false,
+ "outDir": "dist",
+ "target": "es2017",
+ "isolatedModules": true,
+ "paths": {
+ "~/*": [
+ "*"
+ ],
+ "@/*": [
+ "src/*"
+ ],
+ },
+ "typeRoots": [
+ "src/types"
+ ]
+ },
+ "include": ["**/*.ts"],
+ "exclude": ["node_modules"]
+}
\ No newline at end of file
diff --git a/packages/joy/webpack.config.js b/packages/joy/webpack.config.js
new file mode 100644
index 0000000..71bd288
--- /dev/null
+++ b/packages/joy/webpack.config.js
@@ -0,0 +1,85 @@
+/* eslint-disable @typescript-eslint/no-var-requires */
+const path = require('path');
+const webpack = require('webpack');
+const HTMLWebpackPlugin = require('html-webpack-plugin');
+const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
+
+module.exports = {
+ mode: 'development',
+ devServer: {
+ port: 5510,
+ hot: true,
+ open: true,
+ historyApiFallback: true,
+ watchFiles: ['src/**/*.ts', 'public/**/*'],
+ },
+ // devtool: 'inline-source-map',
+ target: ['es5', 'web'],
+ entry: {
+ // 각 html에 필요한 entry 파일
+ index: './src/main.js',
+ login: './src/login.js',
+ },
+ output: {
+ path: path.resolve(__dirname, 'public'),
+ filename: '[chunkhash].js',
+ clean: true,
+ },
+ resolve: {
+ extensions: ['.ts', '.tsx', '.js', '.jsx'],
+ plugins: [new TsconfigPathsPlugin({})],
+ },
+ plugins: [
+ new HTMLWebpackPlugin({
+ hash: true,
+ filename: 'index.html',
+ excludeChunks: ['login'], // entry에서 해당 리스트를 제외한 나머지
+ template: path.resolve(__dirname, 'index.html'),
+ favicon: './favicon.ico',
+ }),
+ new HTMLWebpackPlugin({
+ hash: true,
+ filename: 'login.html',
+ chunks: ['login'], // entry에서 해당 리스트만 포함
+ template: path.resolve(__dirname, 'login.html'),
+ favicon: './favicon.ico',
+ }),
+ new webpack.HotModuleReplacementPlugin(),
+ ],
+ module: {
+ rules: [
+ // {
+ // test: /\.s[ac]ss$/i,
+ // use: [
+ // 'style-loader',
+ // 'css-loader',
+ // 'resolve-url-loader',
+ // {
+ // loader: 'sass-loader',
+ // options: {
+ // sourceMap: true,
+ // },
+ // },
+ // ],
+ // exclude: /node_modules/,
+ // },
+ {
+ test: /\.css$/,
+ use: ['style-loader', 'css-loader'],
+ },
+ {
+ test: /\.(ts|tsx)$/,
+ include: path.join(__dirname, 'src'),
+ exclude: /(node_modules)|(dist)/,
+ use: {
+ loader: 'ts-loader',
+ options: {
+ configFile: path.resolve(__dirname, 'tsconfig.json'),
+ // skip typechecking for speed
+ transpileOnly: true,
+ },
+ },
+ },
+ ],
+ },
+};
diff --git a/packages/namu/assets/index.css b/packages/namu/assets/index.css
new file mode 100644
index 0000000..39f5997
--- /dev/null
+++ b/packages/namu/assets/index.css
@@ -0,0 +1,289 @@
+@import url('./module/nav.css');
+@import url('./module/loading.css');
+
+*,
+*:before,
+*:after {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: "Montserrat", Sans-Serif;
+ color: var(--text-color);
+ display: grid;
+ justify-content: center;
+}
+
+svg {
+ pointer-events: none;
+}
+
+.app {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+main {
+ display: flex;
+ width: auto;
+ margin-top: 2em;
+}
+
+.saved {
+ width: 1000px;
+ justify-content: space-between;
+}
+
+.pin {
+ display: flex;
+ flex-direction: column;
+ margin-top: 1em;
+ border: 2px solid #e6e6e6;
+ border-radius: 15px;
+ padding: 20px;
+}
+
+.button-wrapper {
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-end;
+}
+
+.heart label {
+ box-shadow: 0px 0px 0px 0px rgba(226, 32, 44, 0.5);
+}
+
+.heart label:after {
+ content: "\f004";
+}
+
+.heart input:checked+label {
+ background-color: #e2202c;
+ border-color: #e2202c;
+ box-shadow: 0px 0px 0px 0.5em rgba(226, 32, 44, 0);
+}
+
+.heart input:checked+label:after {
+ color: #e2202c;
+}
+
+.anim-icon {
+ width: 1.9em;
+ height: 1.9em;
+ margin: 10px;
+ font-size: 13px;
+ display: inline-block;
+ position: relative;
+ vertical-align: middle;
+}
+
+.anim-icon input {
+ display: none;
+}
+
+.anim-icon label {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ border: 0.1em solid #ccc;
+ border-radius: 100%;
+ display: block;
+ font: normal normal normal 13px/1 FontAwesome;
+ color: #ccc;
+ font-size: inherit;
+ text-rendering: auto;
+ -webkit-font-smoothing: antialiased;
+}
+
+.anim-icon label:after {
+ left: 0;
+ top: 50%;
+ margin-top: -0.5em;
+ display: block;
+ position: relative;
+ text-align: center;
+}
+
+.anim-icon input:checked+label {
+ -webkit-animation: check-in 0.3s forwards;
+ animation: check-in 0.3s forwards;
+ transition: background-color 0.1s 0.2s, box-shadow 1s;
+ border-width: 0.1em;
+ border-style: solid;
+}
+
+.anim-icon input:checked+label:after {
+ -webkit-animation: icon 0.3s forwards;
+ animation: icon 0.3s forwards;
+}
+
+.anim-icon-md {
+ font-size: 20px;
+}
+
+.anim-icon-lg {
+ font-size: 30px;
+}
+
+@-webkit-keyframes icon {
+ 0% {
+ margin-top: -0.5em;
+ font-size: 1.5em;
+ }
+
+ 100% {
+ font-size: 1em;
+ opacity: 1;
+ color: white;
+ }
+}
+
+@keyframes icon {
+ 0% {
+ margin-top: -0.5em;
+ font-size: 1.5em;
+ }
+
+ 100% {
+ font-size: 1em;
+ opacity: 1;
+ color: white;
+ }
+}
+
+@-webkit-keyframes check-in {
+ 0% {
+ left: 20%;
+ top: 20%;
+ width: 60%;
+ height: 60%;
+ }
+
+ 80% {
+ left: -5%;
+ top: -5%;
+ width: 110%;
+ height: 110%;
+ }
+
+ 100% {
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ }
+}
+
+@keyframes check-in {
+ 0% {
+ left: 20%;
+ top: 20%;
+ width: 60%;
+ height: 60%;
+ }
+
+ 80% {
+ left: -5%;
+ top: -5%;
+ width: 110%;
+ height: 110%;
+ }
+
+ 100% {
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ }
+}
+
+@-webkit-keyframes check {
+ 0% {
+ left: 5%;
+ top: 5%;
+ width: 90%;
+ height: 90%;
+ }
+
+ 10% {
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ }
+
+ 80% {
+ left: -5%;
+ top: -5%;
+ width: 110%;
+ height: 110%;
+ }
+
+ 90% {
+ left: 5%;
+ top: 5%;
+ width: 90%;
+ height: 90%;
+ }
+
+ 100% {
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ }
+}
+
+@keyframes check {
+ 0% {
+ left: 5%;
+ top: 5%;
+ width: 90%;
+ height: 90%;
+ }
+
+ 10% {
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ }
+
+ 80% {
+ left: -5%;
+ top: -5%;
+ width: 110%;
+ height: 110%;
+ }
+
+ 90% {
+ left: 5%;
+ top: 5%;
+ width: 90%;
+ height: 90%;
+ }
+
+ 100% {
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ }
+}
+
+img {
+ width: 460px;
+ height: 460px;
+ object-fit: cover;
+}
+
+.saved .container {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 40px;
+}
\ No newline at end of file
diff --git a/packages/namu/assets/module/loading.css b/packages/namu/assets/module/loading.css
new file mode 100644
index 0000000..7c32341
--- /dev/null
+++ b/packages/namu/assets/module/loading.css
@@ -0,0 +1,23 @@
+.loading {
+ transition: all ease-in-out .2s;
+ opacity: 1;
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ background: rgb(255, 255, 255, 0.8);
+ z-index: 10;
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ align-items: center;
+ margin: auto;
+ overflow: hidden !important;
+ touch-action: none;
+}
+
+.hidden {
+ opacity: 0;
+ visibility: hidden;
+}
\ No newline at end of file
diff --git a/packages/namu/assets/module/nav.css b/packages/namu/assets/module/nav.css
new file mode 100644
index 0000000..4c4d8fd
--- /dev/null
+++ b/packages/namu/assets/module/nav.css
@@ -0,0 +1,265 @@
+@import url("https://fonts.googleapis.com/css?family=Montserrat:400,400i,700");
+
+:root {
+ --bg-blue: #362FE3;
+ --bg-pink: #BF6684;
+ --bg-purple: #9439E4;
+ --text-color: #333;
+ --highlight: #8F7BF6;
+ --radius: 40px;
+ --icon-size: 37px;
+}
+
+nav {
+ padding: 0 20px;
+ width: 500px;
+ height: 100px;
+ background: white;
+ border-radius: var(--radius);
+ display: flex;
+ align-items: center;
+ justify-content: space-around;
+ position: relative;
+}
+
+nav::before {
+ content: "";
+ width: calc(100% + 40px);
+ height: calc(50vh + 160px);
+ background: rgba(255, 255, 255, 0.1);
+ border-radius: calc(var(--radius) + 10px);
+ position: absolute;
+ bottom: -10px;
+ z-index: -1;
+ box-shadow: 0 0 40px #0001;
+}
+
+nav div.item label[for] {
+ width: calc(var(--icon-size) + 15px);
+ height: auto;
+ aspect-ratio: 1/1;
+ color: var(--text-color);
+ cursor: pointer;
+ display: grid;
+ place-items: center;
+ position: relative;
+ transition: transform 0.3s ease-in-out, color 0.1s linear;
+}
+
+nav div.item label[for]::after {
+ content: attr(data-label);
+ position: absolute;
+ bottom: -20px;
+ text-transform: capitalize;
+ font-weight: 800;
+ pointer-events: none;
+ opacity: 0;
+ transform: translateY(50px);
+}
+
+nav div.item label[for] svg {
+ width: var(--icon-size);
+ height: auto;
+ aspect-ratio: 1/1;
+}
+
+nav div.item input[type=radio] {
+ display: none;
+ pointer-events: none;
+}
+
+nav div.item input[type=radio]:checked+label {
+ color: var(--highlight);
+ transform: translateY(-10px);
+}
+
+nav div.item input[type=radio]:checked+label::after {
+ opacity: 1;
+ transform: translateY(0px);
+ transition: transform 0.3s ease-in-out, opacity 0.2s ease-in-out 0.1s;
+}
+
+@-webkit-keyframes explore {
+
+ 0%,
+ 100% {
+ transform-origin: center;
+ transform: scale(1);
+ }
+
+ 50% {
+ transform: scale(1.15);
+ }
+}
+
+@keyframes explore {
+
+ 0%,
+ 100% {
+ transform-origin: center;
+ transform: scale(1);
+ }
+
+ 50% {
+ transform: scale(1.15);
+ }
+}
+
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(3),
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(6),
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(9) {
+ -webkit-animation: explore 0.3s linear;
+ animation: explore 0.3s linear;
+}
+
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(2),
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(5),
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(8) {
+ -webkit-animation: explore 0.3s linear 0.1s;
+ animation: explore 0.3s linear 0.1s;
+}
+
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(1),
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(4),
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(7) {
+ -webkit-animation: explore 0.3s linear 0.2s;
+ animation: explore 0.3s linear 0.2s;
+}
+
+@-webkit-keyframes cartbtn {
+
+ 0%,
+ 100% {
+ transform-origin: center;
+ transform: translateX(0);
+ }
+
+ 25% {
+ transform: translateX(20px);
+ }
+
+ 26% {
+ opacity: 0;
+ }
+
+ 74% {
+ opacity: 0;
+ }
+
+ 75% {
+ opacity: 1;
+ transform: translateX(-20px);
+ }
+}
+
+@keyframes cartbtn {
+
+ 0%,
+ 100% {
+ transform-origin: center;
+ transform: translateX(0);
+ }
+
+ 25% {
+ transform: translateX(20px);
+ }
+
+ 26% {
+ opacity: 0;
+ }
+
+ 74% {
+ opacity: 0;
+ }
+
+ 75% {
+ opacity: 1;
+ transform: translateX(-20px);
+ }
+}
+
+input[type=radio]#cartbtn:checked+label[for] svg path:nth-child(2),
+input[type=radio]#cartbtn:checked+label[for] svg path:nth-child(3) {
+ -webkit-animation: cartbtn 0.3s ease-out;
+ animation: cartbtn 0.3s ease-out;
+}
+
+@-webkit-keyframes saved {
+
+ 0%,
+ 100% {
+ transform-style: preserve-3d;
+ transform-origin: top;
+ transform: skewX(0deg);
+ }
+
+ 30% {
+ transform: skewX(-6deg);
+ }
+
+ 70% {
+ transform: skewX(2deg);
+ }
+}
+
+@keyframes saved {
+
+ 0%,
+ 100% {
+ transform-style: preserve-3d;
+ transform-origin: top;
+ transform: skewX(0deg);
+ }
+
+ 30% {
+ transform: skewX(-6deg);
+ }
+
+ 70% {
+ transform: skewX(2deg);
+ }
+}
+
+input[type=radio]#saved:checked+label[for] svg path {
+ -webkit-animation: saved 0.3s linear;
+ animation: saved 0.3s linear;
+}
+
+@-webkit-keyframes profile {
+
+ 0%,
+ 100% {
+ transform-origin: bottom;
+ transform: rotate(0deg);
+ }
+
+ 40% {
+ transform: rotate(-5deg);
+ }
+
+ 60% {
+ transform: rotate(5deg);
+ }
+}
+
+@keyframes profile {
+
+ 0%,
+ 100% {
+ transform-origin: bottom;
+ transform: rotate(0deg);
+ }
+
+ 40% {
+ transform: rotate(-5deg);
+ }
+
+ 60% {
+ transform: rotate(5deg);
+ }
+}
+
+input[type=radio]#profile:checked+label[for] svg path:first-of-type {
+ -webkit-animation: profile 0.3s linear;
+ animation: profile 0.3s linear;
+}
\ No newline at end of file
diff --git a/packages/namu/assets/page/login.css b/packages/namu/assets/page/login.css
new file mode 100644
index 0000000..265d56e
--- /dev/null
+++ b/packages/namu/assets/page/login.css
@@ -0,0 +1,81 @@
+.login-page {
+ width: 360px;
+ margin: auto;
+}
+
+.login-page .container {
+ position: relative;
+ z-index: 1;
+ background: #FFFFFF;
+ max-width: 360px;
+ margin: 0 auto 100px;
+ padding: 45px;
+ text-align: center;
+ box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.2), 0 5px 5px 0 rgba(0, 0, 0, 0.24);
+ border-radius: 30px;
+}
+
+.login-page .container input {
+ outline: 0;
+ background: #f2f2f2;
+ width: 100%;
+ border: 0;
+ margin: 0 0 15px;
+ padding: 15px;
+ box-sizing: border-box;
+ font-size: 14px;
+ border-radius: 30px;
+}
+
+.login-page .container button {
+ text-transform: uppercase;
+ outline: 0;
+ background: #3165b1;
+ width: 100%;
+ border: 0;
+ padding: 15px;
+ color: #FFFFFF;
+ font-size: 14px;
+ transition: all 0.3 ease;
+ cursor: pointer;
+ margin-top: 10px;
+ border-radius: 30px;
+}
+
+.login-page .container button:hover,
+.login-page .container button:active,
+.login-page .container button:focus {
+ background: #0081ff;
+}
+
+.login-page .container .message {
+ margin: 15px 0 0;
+ color: #b3b3b3;
+ font-size: 12px;
+}
+
+.login-page .container .message a {
+ color: #3165b1;
+ font-weight: bold;
+ text-decoration: none;
+}
+
+.hidden {
+ /* opacity: 0;
+ visibility: hidden;
+ height: 0; */
+ display: none;
+}
+
+.login-wrapper {
+ background: rgb(2,0,36);
+ background: linear-gradient(90deg, rgba(2,0,36,1) 0%, rgba(0,212,255,1) 0%, rgba(49,101,177,1) 100%);
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ position: absolute;
+ overflow: hidden;
+ z-index: 100;
+ display: flex;
+}
\ No newline at end of file
diff --git a/packages/namu/favicon.ico b/packages/namu/favicon.ico
new file mode 100644
index 0000000..34002ef
Binary files /dev/null and b/packages/namu/favicon.ico differ
diff --git a/packages/namu/index.html b/packages/namu/index.html
new file mode 100644
index 0000000..3464fe7
--- /dev/null
+++ b/packages/namu/index.html
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+ Document
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/namu/login.html b/packages/namu/login.html
new file mode 100644
index 0000000..e9e2398
--- /dev/null
+++ b/packages/namu/login.html
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+ Parang Cafe | Login
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/namu/package.json b/packages/namu/package.json
new file mode 100644
index 0000000..6d79fe5
--- /dev/null
+++ b/packages/namu/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "parang",
+ "version": "1.0.0",
+ "description": "",
+ "main": "webpack.config.js",
+ "scripts": {
+ "watch:tsc": "tsc -w",
+ "watch:webpack": "webpack -w",
+ "build": "tsc && webpack",
+ "start": "webpack-dev-server",
+ "test": "tsc && webpack && cypress run --browser chrome"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "devDependencies": {
+ "css-loader": "^6.5.1",
+ "html-webpack-plugin": "^5.5.0",
+ "resolve-url-loader": "^5.0.0",
+ "sass": "^1.49.0",
+ "sass-loader": "^12.4.0",
+ "style-loader": "^3.3.1",
+ "ts-loader": "^9.2.6",
+ "tsconfig-paths-webpack-plugin": "^3.5.2",
+ "webpack": "^5.66.0",
+ "webpack-cli": "^4.9.1",
+ "webpack-dev-server": "^4.7.3"
+ }
+}
diff --git a/packages/namu/src/api/index.js b/packages/namu/src/api/index.js
new file mode 100644
index 0000000..b76cdfc
--- /dev/null
+++ b/packages/namu/src/api/index.js
@@ -0,0 +1,54 @@
+export async function login(url, data) {
+ const config = {
+ method: 'POST',
+ headers: new Headers({ 'content-type': 'application/json' }),
+ };
+ if (data) config.body = JSON.stringify(data);
+ const response = await fetch(url, config);
+ const parse = await response.json();
+ return parse;
+}
+
+export async function signup(url, data) {
+ const config = {
+ method: 'POST',
+ headers: new Headers({ 'content-type': 'application/json' }),
+ };
+ if (data) config.body = JSON.stringify(data);
+ const response = await fetch(url, config);
+ const parse = await response.json();
+ return parse;
+}
+
+export async function getBookmarkList(url, data) {
+ const config = {
+ method: 'POST',
+ headers: new Headers({ 'content-type': 'application/json' }),
+ };
+ if (data) config.body = JSON.stringify(data);
+ const response = await fetch(url, config);
+ const parse = await response.json();
+ return parse;
+}
+
+export async function addBookmark(url, data) {
+ const config = {
+ method: 'POST',
+ headers: new Headers({ 'content-type': 'application/json' }),
+ };
+ if (data) config.body = JSON.stringify(data);
+ const response = await fetch(url, config);
+ const parse = await response.json();
+ return parse;
+}
+
+export async function removeBookmark(url, data) {
+ const config = {
+ method: 'DELETE',
+ headers: new Headers({ 'content-type': 'application/json' }),
+ };
+ if (data) config.body = JSON.stringify(data);
+ const response = await fetch(url, config);
+ const parse = await response.json();
+ return parse;
+}
diff --git a/packages/namu/src/helper/debounce.js b/packages/namu/src/helper/debounce.js
new file mode 100644
index 0000000..3b765e0
--- /dev/null
+++ b/packages/namu/src/helper/debounce.js
@@ -0,0 +1,56 @@
+function throttle(delay, noTrailing, callback, debounceMode) {
+ let timeoutID;
+ let cancelled = false;
+ let lastExec = 0;
+
+ function clearExistingTimeout() {
+ if (timeoutID === undefined) return;
+ clearTimeout(timeoutID);
+ }
+
+ function cancel() {
+ clearExistingTimeout();
+ cancelled = true;
+ }
+
+ if (typeof noTrailing !== 'boolean') {
+ debounceMode = callback;
+ callback = noTrailing;
+ noTrailing = undefined;
+ }
+
+ function wrapper(...args) {
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
+ const self = this;
+ const elapsed = Date.now() - lastExec;
+
+ if (cancelled) return;
+
+ function exec() {
+ lastExec = Date.now();
+ callback.apply(self, args);
+ }
+
+ function clear() {
+ timeoutID = undefined;
+ }
+
+ if (debounceMode && !timeoutID) exec();
+
+ clearExistingTimeout();
+
+ if (debounceMode === undefined && elapsed > delay) {
+ exec();
+ } else if (noTrailing !== true) {
+ timeoutID = setTimeout(
+ debounceMode ? clear : exec,
+ debounceMode === undefined ? delay - elapsed : delay,
+ );
+ }
+ }
+
+ wrapper.cancel = cancel;
+ return wrapper;
+}
+
+export const debounce = (callback, delay) => throttle(delay, callback, false);
diff --git a/packages/namu/src/helper/dom.js b/packages/namu/src/helper/dom.js
new file mode 100644
index 0000000..f0c07cc
--- /dev/null
+++ b/packages/namu/src/helper/dom.js
@@ -0,0 +1,5 @@
+export const $ = selector => document.querySelector(selector);
+
+export const $all = selector => document.querySelectorAll(selector);
+
+export const toggleLoading = () => $('.loading').classList.toggle('hidden');
diff --git a/packages/namu/src/helper/index.js b/packages/namu/src/helper/index.js
new file mode 100644
index 0000000..b7b8951
--- /dev/null
+++ b/packages/namu/src/helper/index.js
@@ -0,0 +1,2 @@
+export * from './debounce.js';
+export * from './dom.js';
diff --git a/packages/namu/src/login.js b/packages/namu/src/login.js
new file mode 100644
index 0000000..ca706b2
--- /dev/null
+++ b/packages/namu/src/login.js
@@ -0,0 +1,48 @@
+import '../assets/page/login.css';
+import { login, signup } from './api/index.js';
+import { $, $all } from './helper/index.js';
+
+$all('.message a').forEach(tag => {
+ tag.addEventListener('click', () => {
+ $all('.forms').forEach(form => {
+ form.classList.toggle('hidden');
+ });
+ });
+});
+
+$('button[data-submit="signup"]').addEventListener('click', async event => {
+ event.preventDefault();
+
+ const email = $('#signup-email').value;
+ const password = $('#signup-password').value;
+ const passwordConfirm = $('#signup-password-confirm').value;
+
+ if (password !== passwordConfirm) return alert('패스워드를 확인해주세요.');
+ const regEmail =
+ /^[0-9a-zA-Z]([-_\\.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_\\.]?[0-9a-zA-Z])*\.[a-zA-Z]{2,3}$/i;
+ if (!regEmail.test(email)) return alert('옳지 않은 이메일 형식입니다.');
+
+ await signup('http://localhost:3000/api/user', {
+ email,
+ password,
+ status: 0,
+ });
+
+ alert('회원가입이 완료되었습니다.\n로그인해주세요.');
+});
+
+$('button[data-submit="login"]').addEventListener('click', async event => {
+ event.preventDefault();
+
+ const email = $('#login-email').value;
+ const password = $('#login-password').value;
+
+ const data = await login('http://localhost:3000/api/user/login', {
+ email,
+ password,
+ });
+ const { _id, email: userEmail } = data[0];
+ alert(`환영합니다, ${userEmail}님!`);
+ localStorage.setItem('user_token', _id);
+ location.replace('http://localhost:5510/');
+});
diff --git a/packages/namu/src/main.js b/packages/namu/src/main.js
new file mode 100644
index 0000000..994d518
--- /dev/null
+++ b/packages/namu/src/main.js
@@ -0,0 +1,110 @@
+import '../assets/index.css';
+import { addBookmark, getBookmarkList } from './api';
+import { $, toggleLoading, debounce } from './helper/index.js';
+
+(() => {
+ const isLogin = localStorage.getItem('user_token');
+ if (isLogin !== null) return;
+
+ location.replace('./login.html');
+})();
+
+let globalIndex = 0;
+
+const createPin = () => {
+ toggleLoading();
+ const pin = document.createElement('div');
+ const buttonWrapper = document.createElement('div');
+ const image = document.createElement('img');
+ const random = Math.floor(Math.random() * 123) + 1;
+ image.src = `https://randomfox.ca/images/${random}.jpg`;
+ buttonWrapper.setAttribute('class', 'button-wrapper');
+ buttonWrapper.innerHTML = `
+
+
+
+
+ `;
+ pin.classList.add('pin');
+ pin.appendChild(buttonWrapper);
+ pin.appendChild(image);
+ toggleLoading();
+ return pin;
+};
+
+const loadMore = debounce(() => {
+ const container = $('.container');
+ const pinList = [];
+ for (let i = 10; i > 0; i--) {
+ pinList.push(createPin(++globalIndex));
+ }
+ container.append(...pinList);
+}, 500);
+
+loadMore();
+
+window.addEventListener('scroll', () => {
+ const loader = $('.loader');
+ if (loader === null) return;
+ if (loader.getBoundingClientRect().top > window.innerHeight) return;
+ loadMore();
+});
+
+$('nav').addEventListener('click', async event => {
+ event.stopPropagation();
+ if (!event.target.matches('input')) return;
+
+ const $main = $('main');
+ $main.innerHTML = '';
+
+ if (event.target.matches('#explore')) {
+ $main.classList.remove('saved');
+ $main.innerHTML = `
+
+
+ `;
+
+ globalIndex = 0;
+ loadMore();
+ }
+
+ if (event.target.matches('#saved')) {
+ $main.classList.add('saved');
+ const _id = localStorage.getItem('user_token');
+ const result = await getBookmarkList(
+ 'http://localhost:3000/api/user/bookmark',
+ { _id },
+ );
+ const $content = `
+
+ ${result
+ .map(
+ ({ _id, url }, index) => `
+
+
![](https://randomfox.ca/images/${url}.jpg)
+
`,
+ )
+ .join('')}
+
+ `;
+
+ $main.innerHTML = $content;
+ }
+});
+
+$('main').addEventListener('click', async event => {
+ if (!event.target.matches('label[for^="heart"]')) return;
+ const _id = localStorage.getItem('user_token');
+ await addBookmark(
+ `http://localhost:3000/api/user/bookmark/${event.target.getAttribute(
+ 'key',
+ )}`,
+ { _id },
+ );
+ console.log('북마크에 저장되었습니다.');
+});
diff --git a/packages/namu/tsconfig.json b/packages/namu/tsconfig.json
new file mode 100644
index 0000000..9338810
--- /dev/null
+++ b/packages/namu/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "baseUrl": ".",
+ "module": "esnext",
+ "noEmit": false,
+ "outDir": "dist",
+ "target": "es2017",
+ "isolatedModules": true,
+ "paths": {
+ "~/*": [
+ "*"
+ ],
+ "@/*": [
+ "src/*"
+ ],
+ },
+ "typeRoots": [
+ "src/types"
+ ]
+ },
+ "include": ["**/*.ts"],
+ "exclude": ["node_modules"]
+}
\ No newline at end of file
diff --git a/packages/namu/webpack.config.js b/packages/namu/webpack.config.js
new file mode 100644
index 0000000..71bd288
--- /dev/null
+++ b/packages/namu/webpack.config.js
@@ -0,0 +1,85 @@
+/* eslint-disable @typescript-eslint/no-var-requires */
+const path = require('path');
+const webpack = require('webpack');
+const HTMLWebpackPlugin = require('html-webpack-plugin');
+const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
+
+module.exports = {
+ mode: 'development',
+ devServer: {
+ port: 5510,
+ hot: true,
+ open: true,
+ historyApiFallback: true,
+ watchFiles: ['src/**/*.ts', 'public/**/*'],
+ },
+ // devtool: 'inline-source-map',
+ target: ['es5', 'web'],
+ entry: {
+ // 각 html에 필요한 entry 파일
+ index: './src/main.js',
+ login: './src/login.js',
+ },
+ output: {
+ path: path.resolve(__dirname, 'public'),
+ filename: '[chunkhash].js',
+ clean: true,
+ },
+ resolve: {
+ extensions: ['.ts', '.tsx', '.js', '.jsx'],
+ plugins: [new TsconfigPathsPlugin({})],
+ },
+ plugins: [
+ new HTMLWebpackPlugin({
+ hash: true,
+ filename: 'index.html',
+ excludeChunks: ['login'], // entry에서 해당 리스트를 제외한 나머지
+ template: path.resolve(__dirname, 'index.html'),
+ favicon: './favicon.ico',
+ }),
+ new HTMLWebpackPlugin({
+ hash: true,
+ filename: 'login.html',
+ chunks: ['login'], // entry에서 해당 리스트만 포함
+ template: path.resolve(__dirname, 'login.html'),
+ favicon: './favicon.ico',
+ }),
+ new webpack.HotModuleReplacementPlugin(),
+ ],
+ module: {
+ rules: [
+ // {
+ // test: /\.s[ac]ss$/i,
+ // use: [
+ // 'style-loader',
+ // 'css-loader',
+ // 'resolve-url-loader',
+ // {
+ // loader: 'sass-loader',
+ // options: {
+ // sourceMap: true,
+ // },
+ // },
+ // ],
+ // exclude: /node_modules/,
+ // },
+ {
+ test: /\.css$/,
+ use: ['style-loader', 'css-loader'],
+ },
+ {
+ test: /\.(ts|tsx)$/,
+ include: path.join(__dirname, 'src'),
+ exclude: /(node_modules)|(dist)/,
+ use: {
+ loader: 'ts-loader',
+ options: {
+ configFile: path.resolve(__dirname, 'tsconfig.json'),
+ // skip typechecking for speed
+ transpileOnly: true,
+ },
+ },
+ },
+ ],
+ },
+};
diff --git a/packages/seal/assets/index.css b/packages/seal/assets/index.css
new file mode 100644
index 0000000..39f5997
--- /dev/null
+++ b/packages/seal/assets/index.css
@@ -0,0 +1,289 @@
+@import url('./module/nav.css');
+@import url('./module/loading.css');
+
+*,
+*:before,
+*:after {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: "Montserrat", Sans-Serif;
+ color: var(--text-color);
+ display: grid;
+ justify-content: center;
+}
+
+svg {
+ pointer-events: none;
+}
+
+.app {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+main {
+ display: flex;
+ width: auto;
+ margin-top: 2em;
+}
+
+.saved {
+ width: 1000px;
+ justify-content: space-between;
+}
+
+.pin {
+ display: flex;
+ flex-direction: column;
+ margin-top: 1em;
+ border: 2px solid #e6e6e6;
+ border-radius: 15px;
+ padding: 20px;
+}
+
+.button-wrapper {
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-end;
+}
+
+.heart label {
+ box-shadow: 0px 0px 0px 0px rgba(226, 32, 44, 0.5);
+}
+
+.heart label:after {
+ content: "\f004";
+}
+
+.heart input:checked+label {
+ background-color: #e2202c;
+ border-color: #e2202c;
+ box-shadow: 0px 0px 0px 0.5em rgba(226, 32, 44, 0);
+}
+
+.heart input:checked+label:after {
+ color: #e2202c;
+}
+
+.anim-icon {
+ width: 1.9em;
+ height: 1.9em;
+ margin: 10px;
+ font-size: 13px;
+ display: inline-block;
+ position: relative;
+ vertical-align: middle;
+}
+
+.anim-icon input {
+ display: none;
+}
+
+.anim-icon label {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ border: 0.1em solid #ccc;
+ border-radius: 100%;
+ display: block;
+ font: normal normal normal 13px/1 FontAwesome;
+ color: #ccc;
+ font-size: inherit;
+ text-rendering: auto;
+ -webkit-font-smoothing: antialiased;
+}
+
+.anim-icon label:after {
+ left: 0;
+ top: 50%;
+ margin-top: -0.5em;
+ display: block;
+ position: relative;
+ text-align: center;
+}
+
+.anim-icon input:checked+label {
+ -webkit-animation: check-in 0.3s forwards;
+ animation: check-in 0.3s forwards;
+ transition: background-color 0.1s 0.2s, box-shadow 1s;
+ border-width: 0.1em;
+ border-style: solid;
+}
+
+.anim-icon input:checked+label:after {
+ -webkit-animation: icon 0.3s forwards;
+ animation: icon 0.3s forwards;
+}
+
+.anim-icon-md {
+ font-size: 20px;
+}
+
+.anim-icon-lg {
+ font-size: 30px;
+}
+
+@-webkit-keyframes icon {
+ 0% {
+ margin-top: -0.5em;
+ font-size: 1.5em;
+ }
+
+ 100% {
+ font-size: 1em;
+ opacity: 1;
+ color: white;
+ }
+}
+
+@keyframes icon {
+ 0% {
+ margin-top: -0.5em;
+ font-size: 1.5em;
+ }
+
+ 100% {
+ font-size: 1em;
+ opacity: 1;
+ color: white;
+ }
+}
+
+@-webkit-keyframes check-in {
+ 0% {
+ left: 20%;
+ top: 20%;
+ width: 60%;
+ height: 60%;
+ }
+
+ 80% {
+ left: -5%;
+ top: -5%;
+ width: 110%;
+ height: 110%;
+ }
+
+ 100% {
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ }
+}
+
+@keyframes check-in {
+ 0% {
+ left: 20%;
+ top: 20%;
+ width: 60%;
+ height: 60%;
+ }
+
+ 80% {
+ left: -5%;
+ top: -5%;
+ width: 110%;
+ height: 110%;
+ }
+
+ 100% {
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ }
+}
+
+@-webkit-keyframes check {
+ 0% {
+ left: 5%;
+ top: 5%;
+ width: 90%;
+ height: 90%;
+ }
+
+ 10% {
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ }
+
+ 80% {
+ left: -5%;
+ top: -5%;
+ width: 110%;
+ height: 110%;
+ }
+
+ 90% {
+ left: 5%;
+ top: 5%;
+ width: 90%;
+ height: 90%;
+ }
+
+ 100% {
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ }
+}
+
+@keyframes check {
+ 0% {
+ left: 5%;
+ top: 5%;
+ width: 90%;
+ height: 90%;
+ }
+
+ 10% {
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ }
+
+ 80% {
+ left: -5%;
+ top: -5%;
+ width: 110%;
+ height: 110%;
+ }
+
+ 90% {
+ left: 5%;
+ top: 5%;
+ width: 90%;
+ height: 90%;
+ }
+
+ 100% {
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ }
+}
+
+img {
+ width: 460px;
+ height: 460px;
+ object-fit: cover;
+}
+
+.saved .container {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 40px;
+}
\ No newline at end of file
diff --git a/packages/seal/assets/module/loading.css b/packages/seal/assets/module/loading.css
new file mode 100644
index 0000000..7c32341
--- /dev/null
+++ b/packages/seal/assets/module/loading.css
@@ -0,0 +1,23 @@
+.loading {
+ transition: all ease-in-out .2s;
+ opacity: 1;
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ background: rgb(255, 255, 255, 0.8);
+ z-index: 10;
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ align-items: center;
+ margin: auto;
+ overflow: hidden !important;
+ touch-action: none;
+}
+
+.hidden {
+ opacity: 0;
+ visibility: hidden;
+}
\ No newline at end of file
diff --git a/packages/seal/assets/module/nav.css b/packages/seal/assets/module/nav.css
new file mode 100644
index 0000000..4c4d8fd
--- /dev/null
+++ b/packages/seal/assets/module/nav.css
@@ -0,0 +1,265 @@
+@import url("https://fonts.googleapis.com/css?family=Montserrat:400,400i,700");
+
+:root {
+ --bg-blue: #362FE3;
+ --bg-pink: #BF6684;
+ --bg-purple: #9439E4;
+ --text-color: #333;
+ --highlight: #8F7BF6;
+ --radius: 40px;
+ --icon-size: 37px;
+}
+
+nav {
+ padding: 0 20px;
+ width: 500px;
+ height: 100px;
+ background: white;
+ border-radius: var(--radius);
+ display: flex;
+ align-items: center;
+ justify-content: space-around;
+ position: relative;
+}
+
+nav::before {
+ content: "";
+ width: calc(100% + 40px);
+ height: calc(50vh + 160px);
+ background: rgba(255, 255, 255, 0.1);
+ border-radius: calc(var(--radius) + 10px);
+ position: absolute;
+ bottom: -10px;
+ z-index: -1;
+ box-shadow: 0 0 40px #0001;
+}
+
+nav div.item label[for] {
+ width: calc(var(--icon-size) + 15px);
+ height: auto;
+ aspect-ratio: 1/1;
+ color: var(--text-color);
+ cursor: pointer;
+ display: grid;
+ place-items: center;
+ position: relative;
+ transition: transform 0.3s ease-in-out, color 0.1s linear;
+}
+
+nav div.item label[for]::after {
+ content: attr(data-label);
+ position: absolute;
+ bottom: -20px;
+ text-transform: capitalize;
+ font-weight: 800;
+ pointer-events: none;
+ opacity: 0;
+ transform: translateY(50px);
+}
+
+nav div.item label[for] svg {
+ width: var(--icon-size);
+ height: auto;
+ aspect-ratio: 1/1;
+}
+
+nav div.item input[type=radio] {
+ display: none;
+ pointer-events: none;
+}
+
+nav div.item input[type=radio]:checked+label {
+ color: var(--highlight);
+ transform: translateY(-10px);
+}
+
+nav div.item input[type=radio]:checked+label::after {
+ opacity: 1;
+ transform: translateY(0px);
+ transition: transform 0.3s ease-in-out, opacity 0.2s ease-in-out 0.1s;
+}
+
+@-webkit-keyframes explore {
+
+ 0%,
+ 100% {
+ transform-origin: center;
+ transform: scale(1);
+ }
+
+ 50% {
+ transform: scale(1.15);
+ }
+}
+
+@keyframes explore {
+
+ 0%,
+ 100% {
+ transform-origin: center;
+ transform: scale(1);
+ }
+
+ 50% {
+ transform: scale(1.15);
+ }
+}
+
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(3),
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(6),
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(9) {
+ -webkit-animation: explore 0.3s linear;
+ animation: explore 0.3s linear;
+}
+
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(2),
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(5),
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(8) {
+ -webkit-animation: explore 0.3s linear 0.1s;
+ animation: explore 0.3s linear 0.1s;
+}
+
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(1),
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(4),
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(7) {
+ -webkit-animation: explore 0.3s linear 0.2s;
+ animation: explore 0.3s linear 0.2s;
+}
+
+@-webkit-keyframes cartbtn {
+
+ 0%,
+ 100% {
+ transform-origin: center;
+ transform: translateX(0);
+ }
+
+ 25% {
+ transform: translateX(20px);
+ }
+
+ 26% {
+ opacity: 0;
+ }
+
+ 74% {
+ opacity: 0;
+ }
+
+ 75% {
+ opacity: 1;
+ transform: translateX(-20px);
+ }
+}
+
+@keyframes cartbtn {
+
+ 0%,
+ 100% {
+ transform-origin: center;
+ transform: translateX(0);
+ }
+
+ 25% {
+ transform: translateX(20px);
+ }
+
+ 26% {
+ opacity: 0;
+ }
+
+ 74% {
+ opacity: 0;
+ }
+
+ 75% {
+ opacity: 1;
+ transform: translateX(-20px);
+ }
+}
+
+input[type=radio]#cartbtn:checked+label[for] svg path:nth-child(2),
+input[type=radio]#cartbtn:checked+label[for] svg path:nth-child(3) {
+ -webkit-animation: cartbtn 0.3s ease-out;
+ animation: cartbtn 0.3s ease-out;
+}
+
+@-webkit-keyframes saved {
+
+ 0%,
+ 100% {
+ transform-style: preserve-3d;
+ transform-origin: top;
+ transform: skewX(0deg);
+ }
+
+ 30% {
+ transform: skewX(-6deg);
+ }
+
+ 70% {
+ transform: skewX(2deg);
+ }
+}
+
+@keyframes saved {
+
+ 0%,
+ 100% {
+ transform-style: preserve-3d;
+ transform-origin: top;
+ transform: skewX(0deg);
+ }
+
+ 30% {
+ transform: skewX(-6deg);
+ }
+
+ 70% {
+ transform: skewX(2deg);
+ }
+}
+
+input[type=radio]#saved:checked+label[for] svg path {
+ -webkit-animation: saved 0.3s linear;
+ animation: saved 0.3s linear;
+}
+
+@-webkit-keyframes profile {
+
+ 0%,
+ 100% {
+ transform-origin: bottom;
+ transform: rotate(0deg);
+ }
+
+ 40% {
+ transform: rotate(-5deg);
+ }
+
+ 60% {
+ transform: rotate(5deg);
+ }
+}
+
+@keyframes profile {
+
+ 0%,
+ 100% {
+ transform-origin: bottom;
+ transform: rotate(0deg);
+ }
+
+ 40% {
+ transform: rotate(-5deg);
+ }
+
+ 60% {
+ transform: rotate(5deg);
+ }
+}
+
+input[type=radio]#profile:checked+label[for] svg path:first-of-type {
+ -webkit-animation: profile 0.3s linear;
+ animation: profile 0.3s linear;
+}
\ No newline at end of file
diff --git a/packages/seal/assets/page/login.css b/packages/seal/assets/page/login.css
new file mode 100644
index 0000000..265d56e
--- /dev/null
+++ b/packages/seal/assets/page/login.css
@@ -0,0 +1,81 @@
+.login-page {
+ width: 360px;
+ margin: auto;
+}
+
+.login-page .container {
+ position: relative;
+ z-index: 1;
+ background: #FFFFFF;
+ max-width: 360px;
+ margin: 0 auto 100px;
+ padding: 45px;
+ text-align: center;
+ box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.2), 0 5px 5px 0 rgba(0, 0, 0, 0.24);
+ border-radius: 30px;
+}
+
+.login-page .container input {
+ outline: 0;
+ background: #f2f2f2;
+ width: 100%;
+ border: 0;
+ margin: 0 0 15px;
+ padding: 15px;
+ box-sizing: border-box;
+ font-size: 14px;
+ border-radius: 30px;
+}
+
+.login-page .container button {
+ text-transform: uppercase;
+ outline: 0;
+ background: #3165b1;
+ width: 100%;
+ border: 0;
+ padding: 15px;
+ color: #FFFFFF;
+ font-size: 14px;
+ transition: all 0.3 ease;
+ cursor: pointer;
+ margin-top: 10px;
+ border-radius: 30px;
+}
+
+.login-page .container button:hover,
+.login-page .container button:active,
+.login-page .container button:focus {
+ background: #0081ff;
+}
+
+.login-page .container .message {
+ margin: 15px 0 0;
+ color: #b3b3b3;
+ font-size: 12px;
+}
+
+.login-page .container .message a {
+ color: #3165b1;
+ font-weight: bold;
+ text-decoration: none;
+}
+
+.hidden {
+ /* opacity: 0;
+ visibility: hidden;
+ height: 0; */
+ display: none;
+}
+
+.login-wrapper {
+ background: rgb(2,0,36);
+ background: linear-gradient(90deg, rgba(2,0,36,1) 0%, rgba(0,212,255,1) 0%, rgba(49,101,177,1) 100%);
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ position: absolute;
+ overflow: hidden;
+ z-index: 100;
+ display: flex;
+}
\ No newline at end of file
diff --git a/packages/seal/favicon.ico b/packages/seal/favicon.ico
new file mode 100644
index 0000000..34002ef
Binary files /dev/null and b/packages/seal/favicon.ico differ
diff --git a/packages/seal/index.html b/packages/seal/index.html
new file mode 100644
index 0000000..3464fe7
--- /dev/null
+++ b/packages/seal/index.html
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+ Document
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/seal/login.html b/packages/seal/login.html
new file mode 100644
index 0000000..e9e2398
--- /dev/null
+++ b/packages/seal/login.html
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+ Parang Cafe | Login
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/seal/package.json b/packages/seal/package.json
new file mode 100644
index 0000000..6d79fe5
--- /dev/null
+++ b/packages/seal/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "parang",
+ "version": "1.0.0",
+ "description": "",
+ "main": "webpack.config.js",
+ "scripts": {
+ "watch:tsc": "tsc -w",
+ "watch:webpack": "webpack -w",
+ "build": "tsc && webpack",
+ "start": "webpack-dev-server",
+ "test": "tsc && webpack && cypress run --browser chrome"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "devDependencies": {
+ "css-loader": "^6.5.1",
+ "html-webpack-plugin": "^5.5.0",
+ "resolve-url-loader": "^5.0.0",
+ "sass": "^1.49.0",
+ "sass-loader": "^12.4.0",
+ "style-loader": "^3.3.1",
+ "ts-loader": "^9.2.6",
+ "tsconfig-paths-webpack-plugin": "^3.5.2",
+ "webpack": "^5.66.0",
+ "webpack-cli": "^4.9.1",
+ "webpack-dev-server": "^4.7.3"
+ }
+}
diff --git a/packages/seal/src/api/index.js b/packages/seal/src/api/index.js
new file mode 100644
index 0000000..b76cdfc
--- /dev/null
+++ b/packages/seal/src/api/index.js
@@ -0,0 +1,54 @@
+export async function login(url, data) {
+ const config = {
+ method: 'POST',
+ headers: new Headers({ 'content-type': 'application/json' }),
+ };
+ if (data) config.body = JSON.stringify(data);
+ const response = await fetch(url, config);
+ const parse = await response.json();
+ return parse;
+}
+
+export async function signup(url, data) {
+ const config = {
+ method: 'POST',
+ headers: new Headers({ 'content-type': 'application/json' }),
+ };
+ if (data) config.body = JSON.stringify(data);
+ const response = await fetch(url, config);
+ const parse = await response.json();
+ return parse;
+}
+
+export async function getBookmarkList(url, data) {
+ const config = {
+ method: 'POST',
+ headers: new Headers({ 'content-type': 'application/json' }),
+ };
+ if (data) config.body = JSON.stringify(data);
+ const response = await fetch(url, config);
+ const parse = await response.json();
+ return parse;
+}
+
+export async function addBookmark(url, data) {
+ const config = {
+ method: 'POST',
+ headers: new Headers({ 'content-type': 'application/json' }),
+ };
+ if (data) config.body = JSON.stringify(data);
+ const response = await fetch(url, config);
+ const parse = await response.json();
+ return parse;
+}
+
+export async function removeBookmark(url, data) {
+ const config = {
+ method: 'DELETE',
+ headers: new Headers({ 'content-type': 'application/json' }),
+ };
+ if (data) config.body = JSON.stringify(data);
+ const response = await fetch(url, config);
+ const parse = await response.json();
+ return parse;
+}
diff --git a/packages/seal/src/helper/debounce.js b/packages/seal/src/helper/debounce.js
new file mode 100644
index 0000000..3b765e0
--- /dev/null
+++ b/packages/seal/src/helper/debounce.js
@@ -0,0 +1,56 @@
+function throttle(delay, noTrailing, callback, debounceMode) {
+ let timeoutID;
+ let cancelled = false;
+ let lastExec = 0;
+
+ function clearExistingTimeout() {
+ if (timeoutID === undefined) return;
+ clearTimeout(timeoutID);
+ }
+
+ function cancel() {
+ clearExistingTimeout();
+ cancelled = true;
+ }
+
+ if (typeof noTrailing !== 'boolean') {
+ debounceMode = callback;
+ callback = noTrailing;
+ noTrailing = undefined;
+ }
+
+ function wrapper(...args) {
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
+ const self = this;
+ const elapsed = Date.now() - lastExec;
+
+ if (cancelled) return;
+
+ function exec() {
+ lastExec = Date.now();
+ callback.apply(self, args);
+ }
+
+ function clear() {
+ timeoutID = undefined;
+ }
+
+ if (debounceMode && !timeoutID) exec();
+
+ clearExistingTimeout();
+
+ if (debounceMode === undefined && elapsed > delay) {
+ exec();
+ } else if (noTrailing !== true) {
+ timeoutID = setTimeout(
+ debounceMode ? clear : exec,
+ debounceMode === undefined ? delay - elapsed : delay,
+ );
+ }
+ }
+
+ wrapper.cancel = cancel;
+ return wrapper;
+}
+
+export const debounce = (callback, delay) => throttle(delay, callback, false);
diff --git a/packages/seal/src/helper/dom.js b/packages/seal/src/helper/dom.js
new file mode 100644
index 0000000..f0c07cc
--- /dev/null
+++ b/packages/seal/src/helper/dom.js
@@ -0,0 +1,5 @@
+export const $ = selector => document.querySelector(selector);
+
+export const $all = selector => document.querySelectorAll(selector);
+
+export const toggleLoading = () => $('.loading').classList.toggle('hidden');
diff --git a/packages/seal/src/helper/index.js b/packages/seal/src/helper/index.js
new file mode 100644
index 0000000..b7b8951
--- /dev/null
+++ b/packages/seal/src/helper/index.js
@@ -0,0 +1,2 @@
+export * from './debounce.js';
+export * from './dom.js';
diff --git a/packages/seal/src/login.js b/packages/seal/src/login.js
new file mode 100644
index 0000000..ca706b2
--- /dev/null
+++ b/packages/seal/src/login.js
@@ -0,0 +1,48 @@
+import '../assets/page/login.css';
+import { login, signup } from './api/index.js';
+import { $, $all } from './helper/index.js';
+
+$all('.message a').forEach(tag => {
+ tag.addEventListener('click', () => {
+ $all('.forms').forEach(form => {
+ form.classList.toggle('hidden');
+ });
+ });
+});
+
+$('button[data-submit="signup"]').addEventListener('click', async event => {
+ event.preventDefault();
+
+ const email = $('#signup-email').value;
+ const password = $('#signup-password').value;
+ const passwordConfirm = $('#signup-password-confirm').value;
+
+ if (password !== passwordConfirm) return alert('패스워드를 확인해주세요.');
+ const regEmail =
+ /^[0-9a-zA-Z]([-_\\.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_\\.]?[0-9a-zA-Z])*\.[a-zA-Z]{2,3}$/i;
+ if (!regEmail.test(email)) return alert('옳지 않은 이메일 형식입니다.');
+
+ await signup('http://localhost:3000/api/user', {
+ email,
+ password,
+ status: 0,
+ });
+
+ alert('회원가입이 완료되었습니다.\n로그인해주세요.');
+});
+
+$('button[data-submit="login"]').addEventListener('click', async event => {
+ event.preventDefault();
+
+ const email = $('#login-email').value;
+ const password = $('#login-password').value;
+
+ const data = await login('http://localhost:3000/api/user/login', {
+ email,
+ password,
+ });
+ const { _id, email: userEmail } = data[0];
+ alert(`환영합니다, ${userEmail}님!`);
+ localStorage.setItem('user_token', _id);
+ location.replace('http://localhost:5510/');
+});
diff --git a/packages/seal/src/main.js b/packages/seal/src/main.js
new file mode 100644
index 0000000..994d518
--- /dev/null
+++ b/packages/seal/src/main.js
@@ -0,0 +1,110 @@
+import '../assets/index.css';
+import { addBookmark, getBookmarkList } from './api';
+import { $, toggleLoading, debounce } from './helper/index.js';
+
+(() => {
+ const isLogin = localStorage.getItem('user_token');
+ if (isLogin !== null) return;
+
+ location.replace('./login.html');
+})();
+
+let globalIndex = 0;
+
+const createPin = () => {
+ toggleLoading();
+ const pin = document.createElement('div');
+ const buttonWrapper = document.createElement('div');
+ const image = document.createElement('img');
+ const random = Math.floor(Math.random() * 123) + 1;
+ image.src = `https://randomfox.ca/images/${random}.jpg`;
+ buttonWrapper.setAttribute('class', 'button-wrapper');
+ buttonWrapper.innerHTML = `
+
+
+
+
+ `;
+ pin.classList.add('pin');
+ pin.appendChild(buttonWrapper);
+ pin.appendChild(image);
+ toggleLoading();
+ return pin;
+};
+
+const loadMore = debounce(() => {
+ const container = $('.container');
+ const pinList = [];
+ for (let i = 10; i > 0; i--) {
+ pinList.push(createPin(++globalIndex));
+ }
+ container.append(...pinList);
+}, 500);
+
+loadMore();
+
+window.addEventListener('scroll', () => {
+ const loader = $('.loader');
+ if (loader === null) return;
+ if (loader.getBoundingClientRect().top > window.innerHeight) return;
+ loadMore();
+});
+
+$('nav').addEventListener('click', async event => {
+ event.stopPropagation();
+ if (!event.target.matches('input')) return;
+
+ const $main = $('main');
+ $main.innerHTML = '';
+
+ if (event.target.matches('#explore')) {
+ $main.classList.remove('saved');
+ $main.innerHTML = `
+
+
+ `;
+
+ globalIndex = 0;
+ loadMore();
+ }
+
+ if (event.target.matches('#saved')) {
+ $main.classList.add('saved');
+ const _id = localStorage.getItem('user_token');
+ const result = await getBookmarkList(
+ 'http://localhost:3000/api/user/bookmark',
+ { _id },
+ );
+ const $content = `
+
+ ${result
+ .map(
+ ({ _id, url }, index) => `
+
+
![](https://randomfox.ca/images/${url}.jpg)
+
`,
+ )
+ .join('')}
+
+ `;
+
+ $main.innerHTML = $content;
+ }
+});
+
+$('main').addEventListener('click', async event => {
+ if (!event.target.matches('label[for^="heart"]')) return;
+ const _id = localStorage.getItem('user_token');
+ await addBookmark(
+ `http://localhost:3000/api/user/bookmark/${event.target.getAttribute(
+ 'key',
+ )}`,
+ { _id },
+ );
+ console.log('북마크에 저장되었습니다.');
+});
diff --git a/packages/seal/tsconfig.json b/packages/seal/tsconfig.json
new file mode 100644
index 0000000..9338810
--- /dev/null
+++ b/packages/seal/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "baseUrl": ".",
+ "module": "esnext",
+ "noEmit": false,
+ "outDir": "dist",
+ "target": "es2017",
+ "isolatedModules": true,
+ "paths": {
+ "~/*": [
+ "*"
+ ],
+ "@/*": [
+ "src/*"
+ ],
+ },
+ "typeRoots": [
+ "src/types"
+ ]
+ },
+ "include": ["**/*.ts"],
+ "exclude": ["node_modules"]
+}
\ No newline at end of file
diff --git a/packages/seal/webpack.config.js b/packages/seal/webpack.config.js
new file mode 100644
index 0000000..71bd288
--- /dev/null
+++ b/packages/seal/webpack.config.js
@@ -0,0 +1,85 @@
+/* eslint-disable @typescript-eslint/no-var-requires */
+const path = require('path');
+const webpack = require('webpack');
+const HTMLWebpackPlugin = require('html-webpack-plugin');
+const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
+
+module.exports = {
+ mode: 'development',
+ devServer: {
+ port: 5510,
+ hot: true,
+ open: true,
+ historyApiFallback: true,
+ watchFiles: ['src/**/*.ts', 'public/**/*'],
+ },
+ // devtool: 'inline-source-map',
+ target: ['es5', 'web'],
+ entry: {
+ // 각 html에 필요한 entry 파일
+ index: './src/main.js',
+ login: './src/login.js',
+ },
+ output: {
+ path: path.resolve(__dirname, 'public'),
+ filename: '[chunkhash].js',
+ clean: true,
+ },
+ resolve: {
+ extensions: ['.ts', '.tsx', '.js', '.jsx'],
+ plugins: [new TsconfigPathsPlugin({})],
+ },
+ plugins: [
+ new HTMLWebpackPlugin({
+ hash: true,
+ filename: 'index.html',
+ excludeChunks: ['login'], // entry에서 해당 리스트를 제외한 나머지
+ template: path.resolve(__dirname, 'index.html'),
+ favicon: './favicon.ico',
+ }),
+ new HTMLWebpackPlugin({
+ hash: true,
+ filename: 'login.html',
+ chunks: ['login'], // entry에서 해당 리스트만 포함
+ template: path.resolve(__dirname, 'login.html'),
+ favicon: './favicon.ico',
+ }),
+ new webpack.HotModuleReplacementPlugin(),
+ ],
+ module: {
+ rules: [
+ // {
+ // test: /\.s[ac]ss$/i,
+ // use: [
+ // 'style-loader',
+ // 'css-loader',
+ // 'resolve-url-loader',
+ // {
+ // loader: 'sass-loader',
+ // options: {
+ // sourceMap: true,
+ // },
+ // },
+ // ],
+ // exclude: /node_modules/,
+ // },
+ {
+ test: /\.css$/,
+ use: ['style-loader', 'css-loader'],
+ },
+ {
+ test: /\.(ts|tsx)$/,
+ include: path.join(__dirname, 'src'),
+ exclude: /(node_modules)|(dist)/,
+ use: {
+ loader: 'ts-loader',
+ options: {
+ configFile: path.resolve(__dirname, 'tsconfig.json'),
+ // skip typechecking for speed
+ transpileOnly: true,
+ },
+ },
+ },
+ ],
+ },
+};
diff --git a/packages/yechu/assets/index.css b/packages/yechu/assets/index.css
new file mode 100644
index 0000000..39f5997
--- /dev/null
+++ b/packages/yechu/assets/index.css
@@ -0,0 +1,289 @@
+@import url('./module/nav.css');
+@import url('./module/loading.css');
+
+*,
+*:before,
+*:after {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: "Montserrat", Sans-Serif;
+ color: var(--text-color);
+ display: grid;
+ justify-content: center;
+}
+
+svg {
+ pointer-events: none;
+}
+
+.app {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+main {
+ display: flex;
+ width: auto;
+ margin-top: 2em;
+}
+
+.saved {
+ width: 1000px;
+ justify-content: space-between;
+}
+
+.pin {
+ display: flex;
+ flex-direction: column;
+ margin-top: 1em;
+ border: 2px solid #e6e6e6;
+ border-radius: 15px;
+ padding: 20px;
+}
+
+.button-wrapper {
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-end;
+}
+
+.heart label {
+ box-shadow: 0px 0px 0px 0px rgba(226, 32, 44, 0.5);
+}
+
+.heart label:after {
+ content: "\f004";
+}
+
+.heart input:checked+label {
+ background-color: #e2202c;
+ border-color: #e2202c;
+ box-shadow: 0px 0px 0px 0.5em rgba(226, 32, 44, 0);
+}
+
+.heart input:checked+label:after {
+ color: #e2202c;
+}
+
+.anim-icon {
+ width: 1.9em;
+ height: 1.9em;
+ margin: 10px;
+ font-size: 13px;
+ display: inline-block;
+ position: relative;
+ vertical-align: middle;
+}
+
+.anim-icon input {
+ display: none;
+}
+
+.anim-icon label {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ border: 0.1em solid #ccc;
+ border-radius: 100%;
+ display: block;
+ font: normal normal normal 13px/1 FontAwesome;
+ color: #ccc;
+ font-size: inherit;
+ text-rendering: auto;
+ -webkit-font-smoothing: antialiased;
+}
+
+.anim-icon label:after {
+ left: 0;
+ top: 50%;
+ margin-top: -0.5em;
+ display: block;
+ position: relative;
+ text-align: center;
+}
+
+.anim-icon input:checked+label {
+ -webkit-animation: check-in 0.3s forwards;
+ animation: check-in 0.3s forwards;
+ transition: background-color 0.1s 0.2s, box-shadow 1s;
+ border-width: 0.1em;
+ border-style: solid;
+}
+
+.anim-icon input:checked+label:after {
+ -webkit-animation: icon 0.3s forwards;
+ animation: icon 0.3s forwards;
+}
+
+.anim-icon-md {
+ font-size: 20px;
+}
+
+.anim-icon-lg {
+ font-size: 30px;
+}
+
+@-webkit-keyframes icon {
+ 0% {
+ margin-top: -0.5em;
+ font-size: 1.5em;
+ }
+
+ 100% {
+ font-size: 1em;
+ opacity: 1;
+ color: white;
+ }
+}
+
+@keyframes icon {
+ 0% {
+ margin-top: -0.5em;
+ font-size: 1.5em;
+ }
+
+ 100% {
+ font-size: 1em;
+ opacity: 1;
+ color: white;
+ }
+}
+
+@-webkit-keyframes check-in {
+ 0% {
+ left: 20%;
+ top: 20%;
+ width: 60%;
+ height: 60%;
+ }
+
+ 80% {
+ left: -5%;
+ top: -5%;
+ width: 110%;
+ height: 110%;
+ }
+
+ 100% {
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ }
+}
+
+@keyframes check-in {
+ 0% {
+ left: 20%;
+ top: 20%;
+ width: 60%;
+ height: 60%;
+ }
+
+ 80% {
+ left: -5%;
+ top: -5%;
+ width: 110%;
+ height: 110%;
+ }
+
+ 100% {
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ }
+}
+
+@-webkit-keyframes check {
+ 0% {
+ left: 5%;
+ top: 5%;
+ width: 90%;
+ height: 90%;
+ }
+
+ 10% {
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ }
+
+ 80% {
+ left: -5%;
+ top: -5%;
+ width: 110%;
+ height: 110%;
+ }
+
+ 90% {
+ left: 5%;
+ top: 5%;
+ width: 90%;
+ height: 90%;
+ }
+
+ 100% {
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ }
+}
+
+@keyframes check {
+ 0% {
+ left: 5%;
+ top: 5%;
+ width: 90%;
+ height: 90%;
+ }
+
+ 10% {
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ }
+
+ 80% {
+ left: -5%;
+ top: -5%;
+ width: 110%;
+ height: 110%;
+ }
+
+ 90% {
+ left: 5%;
+ top: 5%;
+ width: 90%;
+ height: 90%;
+ }
+
+ 100% {
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ }
+}
+
+img {
+ width: 460px;
+ height: 460px;
+ object-fit: cover;
+}
+
+.saved .container {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 40px;
+}
\ No newline at end of file
diff --git a/packages/yechu/assets/module/loading.css b/packages/yechu/assets/module/loading.css
new file mode 100644
index 0000000..7c32341
--- /dev/null
+++ b/packages/yechu/assets/module/loading.css
@@ -0,0 +1,23 @@
+.loading {
+ transition: all ease-in-out .2s;
+ opacity: 1;
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ background: rgb(255, 255, 255, 0.8);
+ z-index: 10;
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ align-items: center;
+ margin: auto;
+ overflow: hidden !important;
+ touch-action: none;
+}
+
+.hidden {
+ opacity: 0;
+ visibility: hidden;
+}
\ No newline at end of file
diff --git a/packages/yechu/assets/module/nav.css b/packages/yechu/assets/module/nav.css
new file mode 100644
index 0000000..4c4d8fd
--- /dev/null
+++ b/packages/yechu/assets/module/nav.css
@@ -0,0 +1,265 @@
+@import url("https://fonts.googleapis.com/css?family=Montserrat:400,400i,700");
+
+:root {
+ --bg-blue: #362FE3;
+ --bg-pink: #BF6684;
+ --bg-purple: #9439E4;
+ --text-color: #333;
+ --highlight: #8F7BF6;
+ --radius: 40px;
+ --icon-size: 37px;
+}
+
+nav {
+ padding: 0 20px;
+ width: 500px;
+ height: 100px;
+ background: white;
+ border-radius: var(--radius);
+ display: flex;
+ align-items: center;
+ justify-content: space-around;
+ position: relative;
+}
+
+nav::before {
+ content: "";
+ width: calc(100% + 40px);
+ height: calc(50vh + 160px);
+ background: rgba(255, 255, 255, 0.1);
+ border-radius: calc(var(--radius) + 10px);
+ position: absolute;
+ bottom: -10px;
+ z-index: -1;
+ box-shadow: 0 0 40px #0001;
+}
+
+nav div.item label[for] {
+ width: calc(var(--icon-size) + 15px);
+ height: auto;
+ aspect-ratio: 1/1;
+ color: var(--text-color);
+ cursor: pointer;
+ display: grid;
+ place-items: center;
+ position: relative;
+ transition: transform 0.3s ease-in-out, color 0.1s linear;
+}
+
+nav div.item label[for]::after {
+ content: attr(data-label);
+ position: absolute;
+ bottom: -20px;
+ text-transform: capitalize;
+ font-weight: 800;
+ pointer-events: none;
+ opacity: 0;
+ transform: translateY(50px);
+}
+
+nav div.item label[for] svg {
+ width: var(--icon-size);
+ height: auto;
+ aspect-ratio: 1/1;
+}
+
+nav div.item input[type=radio] {
+ display: none;
+ pointer-events: none;
+}
+
+nav div.item input[type=radio]:checked+label {
+ color: var(--highlight);
+ transform: translateY(-10px);
+}
+
+nav div.item input[type=radio]:checked+label::after {
+ opacity: 1;
+ transform: translateY(0px);
+ transition: transform 0.3s ease-in-out, opacity 0.2s ease-in-out 0.1s;
+}
+
+@-webkit-keyframes explore {
+
+ 0%,
+ 100% {
+ transform-origin: center;
+ transform: scale(1);
+ }
+
+ 50% {
+ transform: scale(1.15);
+ }
+}
+
+@keyframes explore {
+
+ 0%,
+ 100% {
+ transform-origin: center;
+ transform: scale(1);
+ }
+
+ 50% {
+ transform: scale(1.15);
+ }
+}
+
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(3),
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(6),
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(9) {
+ -webkit-animation: explore 0.3s linear;
+ animation: explore 0.3s linear;
+}
+
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(2),
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(5),
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(8) {
+ -webkit-animation: explore 0.3s linear 0.1s;
+ animation: explore 0.3s linear 0.1s;
+}
+
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(1),
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(4),
+input[type=radio]#explore:checked+label[for] svg path:nth-of-type(7) {
+ -webkit-animation: explore 0.3s linear 0.2s;
+ animation: explore 0.3s linear 0.2s;
+}
+
+@-webkit-keyframes cartbtn {
+
+ 0%,
+ 100% {
+ transform-origin: center;
+ transform: translateX(0);
+ }
+
+ 25% {
+ transform: translateX(20px);
+ }
+
+ 26% {
+ opacity: 0;
+ }
+
+ 74% {
+ opacity: 0;
+ }
+
+ 75% {
+ opacity: 1;
+ transform: translateX(-20px);
+ }
+}
+
+@keyframes cartbtn {
+
+ 0%,
+ 100% {
+ transform-origin: center;
+ transform: translateX(0);
+ }
+
+ 25% {
+ transform: translateX(20px);
+ }
+
+ 26% {
+ opacity: 0;
+ }
+
+ 74% {
+ opacity: 0;
+ }
+
+ 75% {
+ opacity: 1;
+ transform: translateX(-20px);
+ }
+}
+
+input[type=radio]#cartbtn:checked+label[for] svg path:nth-child(2),
+input[type=radio]#cartbtn:checked+label[for] svg path:nth-child(3) {
+ -webkit-animation: cartbtn 0.3s ease-out;
+ animation: cartbtn 0.3s ease-out;
+}
+
+@-webkit-keyframes saved {
+
+ 0%,
+ 100% {
+ transform-style: preserve-3d;
+ transform-origin: top;
+ transform: skewX(0deg);
+ }
+
+ 30% {
+ transform: skewX(-6deg);
+ }
+
+ 70% {
+ transform: skewX(2deg);
+ }
+}
+
+@keyframes saved {
+
+ 0%,
+ 100% {
+ transform-style: preserve-3d;
+ transform-origin: top;
+ transform: skewX(0deg);
+ }
+
+ 30% {
+ transform: skewX(-6deg);
+ }
+
+ 70% {
+ transform: skewX(2deg);
+ }
+}
+
+input[type=radio]#saved:checked+label[for] svg path {
+ -webkit-animation: saved 0.3s linear;
+ animation: saved 0.3s linear;
+}
+
+@-webkit-keyframes profile {
+
+ 0%,
+ 100% {
+ transform-origin: bottom;
+ transform: rotate(0deg);
+ }
+
+ 40% {
+ transform: rotate(-5deg);
+ }
+
+ 60% {
+ transform: rotate(5deg);
+ }
+}
+
+@keyframes profile {
+
+ 0%,
+ 100% {
+ transform-origin: bottom;
+ transform: rotate(0deg);
+ }
+
+ 40% {
+ transform: rotate(-5deg);
+ }
+
+ 60% {
+ transform: rotate(5deg);
+ }
+}
+
+input[type=radio]#profile:checked+label[for] svg path:first-of-type {
+ -webkit-animation: profile 0.3s linear;
+ animation: profile 0.3s linear;
+}
\ No newline at end of file
diff --git a/packages/yechu/assets/page/login.css b/packages/yechu/assets/page/login.css
new file mode 100644
index 0000000..265d56e
--- /dev/null
+++ b/packages/yechu/assets/page/login.css
@@ -0,0 +1,81 @@
+.login-page {
+ width: 360px;
+ margin: auto;
+}
+
+.login-page .container {
+ position: relative;
+ z-index: 1;
+ background: #FFFFFF;
+ max-width: 360px;
+ margin: 0 auto 100px;
+ padding: 45px;
+ text-align: center;
+ box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.2), 0 5px 5px 0 rgba(0, 0, 0, 0.24);
+ border-radius: 30px;
+}
+
+.login-page .container input {
+ outline: 0;
+ background: #f2f2f2;
+ width: 100%;
+ border: 0;
+ margin: 0 0 15px;
+ padding: 15px;
+ box-sizing: border-box;
+ font-size: 14px;
+ border-radius: 30px;
+}
+
+.login-page .container button {
+ text-transform: uppercase;
+ outline: 0;
+ background: #3165b1;
+ width: 100%;
+ border: 0;
+ padding: 15px;
+ color: #FFFFFF;
+ font-size: 14px;
+ transition: all 0.3 ease;
+ cursor: pointer;
+ margin-top: 10px;
+ border-radius: 30px;
+}
+
+.login-page .container button:hover,
+.login-page .container button:active,
+.login-page .container button:focus {
+ background: #0081ff;
+}
+
+.login-page .container .message {
+ margin: 15px 0 0;
+ color: #b3b3b3;
+ font-size: 12px;
+}
+
+.login-page .container .message a {
+ color: #3165b1;
+ font-weight: bold;
+ text-decoration: none;
+}
+
+.hidden {
+ /* opacity: 0;
+ visibility: hidden;
+ height: 0; */
+ display: none;
+}
+
+.login-wrapper {
+ background: rgb(2,0,36);
+ background: linear-gradient(90deg, rgba(2,0,36,1) 0%, rgba(0,212,255,1) 0%, rgba(49,101,177,1) 100%);
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ position: absolute;
+ overflow: hidden;
+ z-index: 100;
+ display: flex;
+}
\ No newline at end of file
diff --git a/packages/yechu/favicon.ico b/packages/yechu/favicon.ico
new file mode 100644
index 0000000..34002ef
Binary files /dev/null and b/packages/yechu/favicon.ico differ
diff --git a/packages/yechu/index.html b/packages/yechu/index.html
new file mode 100644
index 0000000..3464fe7
--- /dev/null
+++ b/packages/yechu/index.html
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+ Document
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/yechu/login.html b/packages/yechu/login.html
new file mode 100644
index 0000000..e9e2398
--- /dev/null
+++ b/packages/yechu/login.html
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+ Parang Cafe | Login
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/yechu/package.json b/packages/yechu/package.json
new file mode 100644
index 0000000..6d79fe5
--- /dev/null
+++ b/packages/yechu/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "parang",
+ "version": "1.0.0",
+ "description": "",
+ "main": "webpack.config.js",
+ "scripts": {
+ "watch:tsc": "tsc -w",
+ "watch:webpack": "webpack -w",
+ "build": "tsc && webpack",
+ "start": "webpack-dev-server",
+ "test": "tsc && webpack && cypress run --browser chrome"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "devDependencies": {
+ "css-loader": "^6.5.1",
+ "html-webpack-plugin": "^5.5.0",
+ "resolve-url-loader": "^5.0.0",
+ "sass": "^1.49.0",
+ "sass-loader": "^12.4.0",
+ "style-loader": "^3.3.1",
+ "ts-loader": "^9.2.6",
+ "tsconfig-paths-webpack-plugin": "^3.5.2",
+ "webpack": "^5.66.0",
+ "webpack-cli": "^4.9.1",
+ "webpack-dev-server": "^4.7.3"
+ }
+}
diff --git a/packages/yechu/src/api/index.js b/packages/yechu/src/api/index.js
new file mode 100644
index 0000000..b76cdfc
--- /dev/null
+++ b/packages/yechu/src/api/index.js
@@ -0,0 +1,54 @@
+export async function login(url, data) {
+ const config = {
+ method: 'POST',
+ headers: new Headers({ 'content-type': 'application/json' }),
+ };
+ if (data) config.body = JSON.stringify(data);
+ const response = await fetch(url, config);
+ const parse = await response.json();
+ return parse;
+}
+
+export async function signup(url, data) {
+ const config = {
+ method: 'POST',
+ headers: new Headers({ 'content-type': 'application/json' }),
+ };
+ if (data) config.body = JSON.stringify(data);
+ const response = await fetch(url, config);
+ const parse = await response.json();
+ return parse;
+}
+
+export async function getBookmarkList(url, data) {
+ const config = {
+ method: 'POST',
+ headers: new Headers({ 'content-type': 'application/json' }),
+ };
+ if (data) config.body = JSON.stringify(data);
+ const response = await fetch(url, config);
+ const parse = await response.json();
+ return parse;
+}
+
+export async function addBookmark(url, data) {
+ const config = {
+ method: 'POST',
+ headers: new Headers({ 'content-type': 'application/json' }),
+ };
+ if (data) config.body = JSON.stringify(data);
+ const response = await fetch(url, config);
+ const parse = await response.json();
+ return parse;
+}
+
+export async function removeBookmark(url, data) {
+ const config = {
+ method: 'DELETE',
+ headers: new Headers({ 'content-type': 'application/json' }),
+ };
+ if (data) config.body = JSON.stringify(data);
+ const response = await fetch(url, config);
+ const parse = await response.json();
+ return parse;
+}
diff --git a/packages/yechu/src/helper/debounce.js b/packages/yechu/src/helper/debounce.js
new file mode 100644
index 0000000..3b765e0
--- /dev/null
+++ b/packages/yechu/src/helper/debounce.js
@@ -0,0 +1,56 @@
+function throttle(delay, noTrailing, callback, debounceMode) {
+ let timeoutID;
+ let cancelled = false;
+ let lastExec = 0;
+
+ function clearExistingTimeout() {
+ if (timeoutID === undefined) return;
+ clearTimeout(timeoutID);
+ }
+
+ function cancel() {
+ clearExistingTimeout();
+ cancelled = true;
+ }
+
+ if (typeof noTrailing !== 'boolean') {
+ debounceMode = callback;
+ callback = noTrailing;
+ noTrailing = undefined;
+ }
+
+ function wrapper(...args) {
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
+ const self = this;
+ const elapsed = Date.now() - lastExec;
+
+ if (cancelled) return;
+
+ function exec() {
+ lastExec = Date.now();
+ callback.apply(self, args);
+ }
+
+ function clear() {
+ timeoutID = undefined;
+ }
+
+ if (debounceMode && !timeoutID) exec();
+
+ clearExistingTimeout();
+
+ if (debounceMode === undefined && elapsed > delay) {
+ exec();
+ } else if (noTrailing !== true) {
+ timeoutID = setTimeout(
+ debounceMode ? clear : exec,
+ debounceMode === undefined ? delay - elapsed : delay,
+ );
+ }
+ }
+
+ wrapper.cancel = cancel;
+ return wrapper;
+}
+
+export const debounce = (callback, delay) => throttle(delay, callback, false);
diff --git a/packages/yechu/src/helper/dom.js b/packages/yechu/src/helper/dom.js
new file mode 100644
index 0000000..f0c07cc
--- /dev/null
+++ b/packages/yechu/src/helper/dom.js
@@ -0,0 +1,5 @@
+export const $ = selector => document.querySelector(selector);
+
+export const $all = selector => document.querySelectorAll(selector);
+
+export const toggleLoading = () => $('.loading').classList.toggle('hidden');
diff --git a/packages/yechu/src/helper/index.js b/packages/yechu/src/helper/index.js
new file mode 100644
index 0000000..b7b8951
--- /dev/null
+++ b/packages/yechu/src/helper/index.js
@@ -0,0 +1,2 @@
+export * from './debounce.js';
+export * from './dom.js';
diff --git a/packages/yechu/src/login.js b/packages/yechu/src/login.js
new file mode 100644
index 0000000..ca706b2
--- /dev/null
+++ b/packages/yechu/src/login.js
@@ -0,0 +1,48 @@
+import '../assets/page/login.css';
+import { login, signup } from './api/index.js';
+import { $, $all } from './helper/index.js';
+
+$all('.message a').forEach(tag => {
+ tag.addEventListener('click', () => {
+ $all('.forms').forEach(form => {
+ form.classList.toggle('hidden');
+ });
+ });
+});
+
+$('button[data-submit="signup"]').addEventListener('click', async event => {
+ event.preventDefault();
+
+ const email = $('#signup-email').value;
+ const password = $('#signup-password').value;
+ const passwordConfirm = $('#signup-password-confirm').value;
+
+ if (password !== passwordConfirm) return alert('패스워드를 확인해주세요.');
+ const regEmail =
+ /^[0-9a-zA-Z]([-_\\.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_\\.]?[0-9a-zA-Z])*\.[a-zA-Z]{2,3}$/i;
+ if (!regEmail.test(email)) return alert('옳지 않은 이메일 형식입니다.');
+
+ await signup('http://localhost:3000/api/user', {
+ email,
+ password,
+ status: 0,
+ });
+
+ alert('회원가입이 완료되었습니다.\n로그인해주세요.');
+});
+
+$('button[data-submit="login"]').addEventListener('click', async event => {
+ event.preventDefault();
+
+ const email = $('#login-email').value;
+ const password = $('#login-password').value;
+
+ const data = await login('http://localhost:3000/api/user/login', {
+ email,
+ password,
+ });
+ const { _id, email: userEmail } = data[0];
+ alert(`환영합니다, ${userEmail}님!`);
+ localStorage.setItem('user_token', _id);
+ location.replace('http://localhost:5510/');
+});
diff --git a/packages/yechu/src/main.js b/packages/yechu/src/main.js
new file mode 100644
index 0000000..994d518
--- /dev/null
+++ b/packages/yechu/src/main.js
@@ -0,0 +1,110 @@
+import '../assets/index.css';
+import { addBookmark, getBookmarkList } from './api';
+import { $, toggleLoading, debounce } from './helper/index.js';
+
+(() => {
+ const isLogin = localStorage.getItem('user_token');
+ if (isLogin !== null) return;
+
+ location.replace('./login.html');
+})();
+
+let globalIndex = 0;
+
+const createPin = () => {
+ toggleLoading();
+ const pin = document.createElement('div');
+ const buttonWrapper = document.createElement('div');
+ const image = document.createElement('img');
+ const random = Math.floor(Math.random() * 123) + 1;
+ image.src = `https://randomfox.ca/images/${random}.jpg`;
+ buttonWrapper.setAttribute('class', 'button-wrapper');
+ buttonWrapper.innerHTML = `
+
+
+
+
+ `;
+ pin.classList.add('pin');
+ pin.appendChild(buttonWrapper);
+ pin.appendChild(image);
+ toggleLoading();
+ return pin;
+};
+
+const loadMore = debounce(() => {
+ const container = $('.container');
+ const pinList = [];
+ for (let i = 10; i > 0; i--) {
+ pinList.push(createPin(++globalIndex));
+ }
+ container.append(...pinList);
+}, 500);
+
+loadMore();
+
+window.addEventListener('scroll', () => {
+ const loader = $('.loader');
+ if (loader === null) return;
+ if (loader.getBoundingClientRect().top > window.innerHeight) return;
+ loadMore();
+});
+
+$('nav').addEventListener('click', async event => {
+ event.stopPropagation();
+ if (!event.target.matches('input')) return;
+
+ const $main = $('main');
+ $main.innerHTML = '';
+
+ if (event.target.matches('#explore')) {
+ $main.classList.remove('saved');
+ $main.innerHTML = `
+
+
+ `;
+
+ globalIndex = 0;
+ loadMore();
+ }
+
+ if (event.target.matches('#saved')) {
+ $main.classList.add('saved');
+ const _id = localStorage.getItem('user_token');
+ const result = await getBookmarkList(
+ 'http://localhost:3000/api/user/bookmark',
+ { _id },
+ );
+ const $content = `
+
+ ${result
+ .map(
+ ({ _id, url }, index) => `
+
+
![](https://randomfox.ca/images/${url}.jpg)
+
`,
+ )
+ .join('')}
+
+ `;
+
+ $main.innerHTML = $content;
+ }
+});
+
+$('main').addEventListener('click', async event => {
+ if (!event.target.matches('label[for^="heart"]')) return;
+ const _id = localStorage.getItem('user_token');
+ await addBookmark(
+ `http://localhost:3000/api/user/bookmark/${event.target.getAttribute(
+ 'key',
+ )}`,
+ { _id },
+ );
+ console.log('북마크에 저장되었습니다.');
+});
diff --git a/packages/yechu/tsconfig.json b/packages/yechu/tsconfig.json
new file mode 100644
index 0000000..9338810
--- /dev/null
+++ b/packages/yechu/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "baseUrl": ".",
+ "module": "esnext",
+ "noEmit": false,
+ "outDir": "dist",
+ "target": "es2017",
+ "isolatedModules": true,
+ "paths": {
+ "~/*": [
+ "*"
+ ],
+ "@/*": [
+ "src/*"
+ ],
+ },
+ "typeRoots": [
+ "src/types"
+ ]
+ },
+ "include": ["**/*.ts"],
+ "exclude": ["node_modules"]
+}
\ No newline at end of file
diff --git a/packages/yechu/webpack.config.js b/packages/yechu/webpack.config.js
new file mode 100644
index 0000000..71bd288
--- /dev/null
+++ b/packages/yechu/webpack.config.js
@@ -0,0 +1,85 @@
+/* eslint-disable @typescript-eslint/no-var-requires */
+const path = require('path');
+const webpack = require('webpack');
+const HTMLWebpackPlugin = require('html-webpack-plugin');
+const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
+
+module.exports = {
+ mode: 'development',
+ devServer: {
+ port: 5510,
+ hot: true,
+ open: true,
+ historyApiFallback: true,
+ watchFiles: ['src/**/*.ts', 'public/**/*'],
+ },
+ // devtool: 'inline-source-map',
+ target: ['es5', 'web'],
+ entry: {
+ // 각 html에 필요한 entry 파일
+ index: './src/main.js',
+ login: './src/login.js',
+ },
+ output: {
+ path: path.resolve(__dirname, 'public'),
+ filename: '[chunkhash].js',
+ clean: true,
+ },
+ resolve: {
+ extensions: ['.ts', '.tsx', '.js', '.jsx'],
+ plugins: [new TsconfigPathsPlugin({})],
+ },
+ plugins: [
+ new HTMLWebpackPlugin({
+ hash: true,
+ filename: 'index.html',
+ excludeChunks: ['login'], // entry에서 해당 리스트를 제외한 나머지
+ template: path.resolve(__dirname, 'index.html'),
+ favicon: './favicon.ico',
+ }),
+ new HTMLWebpackPlugin({
+ hash: true,
+ filename: 'login.html',
+ chunks: ['login'], // entry에서 해당 리스트만 포함
+ template: path.resolve(__dirname, 'login.html'),
+ favicon: './favicon.ico',
+ }),
+ new webpack.HotModuleReplacementPlugin(),
+ ],
+ module: {
+ rules: [
+ // {
+ // test: /\.s[ac]ss$/i,
+ // use: [
+ // 'style-loader',
+ // 'css-loader',
+ // 'resolve-url-loader',
+ // {
+ // loader: 'sass-loader',
+ // options: {
+ // sourceMap: true,
+ // },
+ // },
+ // ],
+ // exclude: /node_modules/,
+ // },
+ {
+ test: /\.css$/,
+ use: ['style-loader', 'css-loader'],
+ },
+ {
+ test: /\.(ts|tsx)$/,
+ include: path.join(__dirname, 'src'),
+ exclude: /(node_modules)|(dist)/,
+ use: {
+ loader: 'ts-loader',
+ options: {
+ configFile: path.resolve(__dirname, 'tsconfig.json'),
+ // skip typechecking for speed
+ transpileOnly: true,
+ },
+ },
+ },
+ ],
+ },
+};