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) => ` +
+
+
+ + +
+
+
`, + ) + .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) => ` +
+
+
+ + +
+
+
`, + ) + .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) => ` +
+
+
+ + +
+
+
`, + ) + .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) => ` +
+
+
+ + +
+
+
`, + ) + .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) => ` +
+
+
+ + +
+
+
`, + ) + .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, + }, + }, + }, + ], + }, +};