├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── README.md ├── craco.config.js ├── jsconfig.json ├── package-lock.json ├── package.json ├── public ├── analytics.js ├── background.html ├── favicon.ico ├── images │ └── logo │ │ ├── group-5.svg │ │ ├── logo128.png │ │ ├── logo16.png │ │ ├── logo24.png │ │ ├── logo32.png │ │ └── logo64.png ├── index.html ├── manifest.json ├── popup.html └── robots.txt └── src ├── assets ├── css │ ├── default.css │ └── popup.css ├── images │ ├── add.png │ ├── alarm.png │ ├── background1.jpg │ ├── background2.jpg │ ├── card-image.jpg │ ├── check-false.png │ ├── check-true.png │ ├── close.svg │ ├── group-10.png │ ├── group-11.svg │ ├── group-17.svg │ ├── group-19.png │ ├── group-19.svg │ ├── group-25.svg │ ├── group-26.svg │ ├── group-27.svg │ ├── group-5.svg │ ├── history.png │ ├── info-person.jpg │ ├── link-copy.png │ ├── link-copy.svg │ ├── link-icon.png │ ├── link-icon.svg │ ├── link.svg │ ├── linkListEmptyIcon.png │ ├── link_icon.svg │ ├── logo-google.png │ ├── logo-urlink-box.png │ ├── logo-urlink-full.png │ ├── logo.png │ ├── logo │ │ ├── group-2.svg │ │ ├── group-3.svg │ │ ├── logo128.png │ │ ├── logo16.png │ │ ├── logo24.png │ │ ├── logo32.png │ │ ├── logo64.png │ │ ├── profileImg.png │ │ ├── profileImg@2x.png │ │ └── profileImg@3x.png │ ├── main1.png │ ├── main1_back.png │ ├── main2.png │ ├── main2_back.png │ ├── main3.png │ ├── main3_back.png │ ├── main4.png │ ├── main5.png │ ├── mainBackground.png │ ├── mainBackground2.png │ ├── mainLogo.png │ ├── more.png │ ├── more_3dot.svg │ ├── move.png │ ├── new-tab.svg │ ├── open.png │ ├── person.png │ ├── plus.png │ ├── plus_noborder.svg │ ├── search.png │ ├── search.svg │ ├── star.svg │ ├── star_fill.svg │ ├── union.svg │ └── white.svg └── scss │ ├── LoginSignup.scss │ ├── font.scss │ └── swiper.scss ├── background └── index.js ├── hooks ├── useDebounce.js ├── useEventListener.js └── useOutsideAlerter.js ├── main ├── App.js ├── components │ ├── ExtendedFab │ │ ├── index.jsx │ │ └── style.js │ ├── ScrollUpButton │ │ ├── index.jsx │ │ └── style.js │ ├── SearchBar │ │ ├── index.jsx │ │ └── style.js │ ├── SearchButton │ │ ├── index.jsx │ │ └── style.js │ ├── Toast │ │ └── index.jsx │ ├── ValidationMessage │ │ ├── index.jsx │ │ └── style.js │ └── modals │ │ ├── AlertModal │ │ ├── index.jsx │ │ └── style.js │ │ ├── TermsModal │ │ ├── index.jsx │ │ └── textInfo.js │ │ ├── index.jsx │ │ └── style.js ├── index.js ├── pages │ ├── Home │ │ ├── AppBar │ │ │ ├── AlarmList │ │ │ │ ├── index.jsx │ │ │ │ └── style.js │ │ │ ├── DraggableHistoryList │ │ │ │ ├── History │ │ │ │ │ ├── index.jsx │ │ │ │ │ └── style.js │ │ │ │ ├── HistoryDateTitle │ │ │ │ │ ├── index.jsx │ │ │ │ │ └── style.js │ │ │ │ ├── HistoryDragBox │ │ │ │ │ ├── index.jsx │ │ │ │ │ └── style.js │ │ │ │ ├── index.jsx │ │ │ │ └── style.js │ │ │ ├── Profile │ │ │ │ ├── index.jsx │ │ │ │ └── style.js │ │ │ ├── index.jsx │ │ │ └── style.js │ │ ├── CategoryList │ │ │ ├── AddCategoryModal │ │ │ │ ├── index.jsx │ │ │ │ └── style.js │ │ │ ├── CategoryHeader │ │ │ │ ├── index.jsx │ │ │ │ └── style.js │ │ │ ├── CategoryItem │ │ │ │ ├── index.jsx │ │ │ │ └── style.js │ │ │ ├── CategoryItemWrapper │ │ │ │ ├── index.jsx │ │ │ │ └── style.js │ │ │ ├── FirstCategoryDropZone │ │ │ │ ├── index.jsx │ │ │ │ └── style.js │ │ │ ├── FirstFavoriteDropZone │ │ │ │ ├── index.jsx │ │ │ │ └── style.js │ │ │ ├── UpdateCategoryModal │ │ │ │ ├── index.jsx │ │ │ │ └── style.js │ │ │ ├── index.jsx │ │ │ └── style.js │ │ ├── LinkDropZone │ │ │ ├── index.jsx │ │ │ └── style.js │ │ ├── LinkList │ │ │ ├── Header │ │ │ │ ├── EditableCategoryTitle │ │ │ │ │ ├── index.jsx │ │ │ │ │ └── style.js │ │ │ │ ├── index.jsx │ │ │ │ └── style.js │ │ │ ├── Link │ │ │ │ ├── index.jsx │ │ │ │ └── style.js │ │ │ ├── LinkSkeleton │ │ │ │ ├── index.jsx │ │ │ │ └── style.js │ │ │ ├── index.jsx │ │ │ └── style.js │ │ ├── index.jsx │ │ └── style.js │ ├── Login │ │ ├── LoginForm │ │ │ ├── GloginButton.jsx │ │ │ ├── NloginForm.jsx │ │ │ └── index.jsx │ │ └── index.jsx │ ├── Register │ │ ├── RegisterForm │ │ │ ├── GregisterButton.jsx │ │ │ ├── NregisterForm.jsx │ │ │ └── index.jsx │ │ └── index.jsx │ └── Start │ │ ├── index.jsx │ │ └── style.js └── theme.js ├── modules ├── alarm │ ├── api.js │ ├── index.js │ ├── queryInfoData.js │ ├── reducer.js │ └── saga.js ├── alarmNotice │ ├── hooks │ │ └── useAlarmNoticeConnection.js │ ├── index.js │ ├── queryInfoData.js │ ├── reducer.js │ ├── saga.js │ └── ws.js ├── category │ ├── api.js │ ├── hooks │ │ └── useCategories.js │ ├── index.js │ ├── queryInfoData.js │ ├── reducer.js │ └── saga.js ├── error │ ├── index.js │ └── reducer.js ├── helpers.js ├── historyLink │ ├── hooks │ │ └── useHistoryLinks.js │ ├── index.js │ ├── reducer.js │ └── saga.js ├── index.js ├── link │ ├── api.js │ ├── hooks │ │ └── useLinks.js │ ├── index.js │ ├── queryInfoData.js │ ├── reducer.js │ └── saga.js ├── pending │ ├── constants.js │ ├── index.js │ └── reducer.js ├── token │ ├── api.js │ ├── index.js │ └── queryInfoData.js ├── ui │ ├── constants.js │ ├── hooks │ │ ├── useDialog.js │ │ ├── useDrag.js │ │ ├── useDropZone.js │ │ └── useToast.js │ ├── index.js │ └── reducer.js └── user │ ├── api.js │ ├── hooks │ └── useUser.js │ ├── index.js │ ├── queryInfoData.js │ ├── reducer.js │ └── saga.js ├── popup └── index.js ├── setting.js └── utils ├── chromeApis ├── OAuth.js ├── bookmarks.js ├── history.js ├── onMessage.js ├── sendMessage.js └── tab.js ├── copyLink.js ├── filter.js ├── ga.js └── http ├── auth.js ├── client.js ├── queryFilter.js └── ws.js /.eslintignore: -------------------------------------------------------------------------------- 1 | 2 | /.vscode 3 | node_modules 4 | dist 5 | build 6 | eslintrc 7 | src/assets/* -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | root: true, 5 | extends: ['eslint:recommended', 'plugin:react/recommended', 'eslint-config-prettier', 'plugin:prettier/recommended'], 6 | plugins: ['react', 'react-hooks', 'import', 'prettier'], 7 | env: { 8 | browser: true, 9 | node: true, 10 | es6: true, 11 | }, 12 | parserOptions: { 13 | ecmaVersion: 2020, 14 | sourceType: 'module', 15 | ecmaFeatures: { 16 | jsx: true, 17 | }, 18 | }, 19 | globals: { 20 | chrome: 'readonly', 21 | }, 22 | settings: { 23 | 'import/resolver': { 24 | alias: { 25 | map: [ 26 | ['@/*', path.resolve(__dirname, './src')], 27 | ['@background/*', path.resolve(__dirname, './src/background')], 28 | ['@main/*', path.resolve(__dirname, './src/main')], 29 | ['@popup/*', path.resolve(__dirname, './src/popup')], 30 | ['@assets/*', path.resolve(__dirname, './src/assets')], 31 | ['@hooks/*', path.resolve(__dirname, './src/hooks')], 32 | ['@modules/*', path.resolve(__dirname, './src/modules')], 33 | ['@utils/*', path.resolve(__dirname, './src/utils')], 34 | ], 35 | extensions: ['.js', '.jsx', '.json'], 36 | }, 37 | }, 38 | }, 39 | rules: { 40 | 'react/prop-types': [0], 41 | 'no-unused-vars': 'off', 42 | 'react-hooks/rules-of-hooks': 'error', 43 | 'react-hooks/exhaustive-deps': 'warn', // Checks effect dependencies 44 | 'import/order': [ 45 | 'error', 46 | { 47 | groups: ['builtin', 'external', 'internal'], 48 | pathGroups: [ 49 | { 50 | pattern: 'react', 51 | group: 'external', 52 | position: 'before', 53 | }, 54 | ], 55 | pathGroupsExcludedImportTypes: ['react'], 56 | 'newlines-between': 'always', 57 | alphabetize: { 58 | order: 'asc', 59 | caseInsensitive: true, 60 | }, 61 | }, 62 | ], 63 | 'prettier/prettier': [ 64 | 'error', 65 | { 66 | endOfLine: 'auto', 67 | }, 68 | ], 69 | }, 70 | } 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | .idea 4 | 5 | # dependencies 6 | /node_modules 7 | /.pnp 8 | .pnp.js 9 | .eslintcache 10 | 11 | # testing 12 | /coverage 13 | 14 | # production 15 | /build 16 | /public/popup 17 | 18 | # misc 19 | .DS_Store 20 | .env.local 21 | .env.development.local 22 | .env.test.local 23 | .env.production.local 24 | 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | rest.http 29 | 30 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "singleQuote": true, 4 | "semi": false, 5 | "useTabs": false, 6 | "tabWidth": 2, 7 | "trailingComma": "es5" 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "javascript.format.enable": false, 3 | "editor.formatOnSave": false, 4 | "[javascript]": { 5 | "editor.formatOnSave": true 6 | }, 7 | "[json]": { 8 | "editor.formatOnSave": true 9 | }, 10 | "[html]": { 11 | "editor.formatOnSave": true 12 | }, 13 | "eslint.options": { 14 | "extensions": [ 15 | ".js", 16 | ".jsx", 17 | ], 18 | "validate": [ 19 | "javascript", 20 | "javascriptreact", 21 | ] 22 | }, 23 | "eslint.workingDirectories": [ 24 | { 25 | "mode": "auto" 26 | } 27 | ], 28 | "eslint.alwaysShowStatus": true, 29 | "editor.codeActionsOnSave": { 30 | "source.organizeImports": false, 31 | "source.fixAll.eslint": true 32 | }, 33 | "cSpell.words": [ 34 | "favorited", 35 | "getcontentanchorel", 36 | "Glogin", 37 | "Gregister", 38 | "Nlogin", 39 | "Nregister", 40 | "pageview", 41 | "scrollmagic", 42 | "Spoqa" 43 | ], 44 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # urLink Chrome Extension 2 | 3 | ![image](https://user-images.githubusercontent.com/51507260/113473528-fb80c600-94a4-11eb-96da-b84e7d561bf9.png) 4 | 5 | 6 | urlink logo 7 | 8 | 9 | #   urLink 10 | 11 | 12 | ### 🤔 내가 보관한 링크, 어디다 뒀더라..? 13 | 14 | 북마크, 메모장, 카카오톡 내게 쓰기, 노션 등.. 언젠가 보려고 여기저기 저장한 링크들, 찾으려고 하면 어디에 있는지 도저히 기억이 안나시나요? 이제 한 곳에서 한 번에 정리하고 사용하세요. 15 | 16 | 유어링크를 사용하면 쉽고 빠르게 웹사이트를 정리할 수 있음은 물론이고, 깔끔하게 정리된 링크들을 보며 자기유능감까지 느낄 수 있습니다! 17 | 18 | urlink(유어링크)는 인터넷에서 리서치할 때, 나중에 다시 보고 싶은 웹사이트를 발견했을 때, 쉽게 보관하고 정리하고 사용할 수 있도록 돕는 리서치 생산성 향상 서비스입니다. 19 | 20 | (유어링크는 PC 환경, 크롬 브라우저에서만 사용할 수 있는 '크롬 확장 프로그램'입니다.) 21 | 22 | ### 🔍 리서치에만 집중하세요. 발견한 정보는 유어링크가 정리해드릴게요. 23 | 24 | 정보를 검색하는 것 만으로도 충분히 바쁜데, 정리하는데 시간을 많이 쓸 필요는 없죠! 유어링크를 이용하면 리서치하다 발견한 유용한 정보를 쉽고 빠르게 보관, 정리할 수 있고 나중에 언제든지 꺼내볼 수 있어요. 25 | 26 | 대학생, 직장인, 마케터, 개발자, 디자이너 등 인터넷에서 자료를 찾고, 보관하고 정리하는데 어려움을 느끼시는 분이라면 누구나 유용하게 사용할 수 있어요! 27 | 28 | # 🎨 Features 29 | 30 | ### 1. 담고 싶은 웹사이트를 클릭 한 번으로 정리! 31 | 32 | - #### **유어링크 버튼을 활용해 링크 저장하기** 33 | 34 | 주소 표시줄 우측에 있는 **유어링크 버튼을 클릭**하면, 그 자리에서 내가 원하는 카테고리에 웹사이트 를 담을 수 있어요. 저장한 링크는 **[유어링크 열기]** 버튼을 눌러 확인할 수 있습니다. 35 | 36 | ![feature01](https://user-images.githubusercontent.com/51507260/113473947-b0b47d80-94a7-11eb-9468-21a8f7ae82b1.gif) 37 | 38 |
39 | 40 | - #### **방문기록을 드래그하여 링크 저장하기** 41 | 42 | 이전에 방문한 웹사이트를 쉽게 보관할 수 있어요. 우측 타이머 아이콘을 눌러 방문기록 리스트를 불러온 다음 원하는 링크를 왼쪽으로 드래그앤드랍하세요. 끌어다 놓기만 하면 내가 원하는 카테고리에 쉽게 링크를 보관할 수 있어요! 43 | 44 | ![feature02](https://user-images.githubusercontent.com/51507260/116044256-ab63e080-a6ab-11eb-82c3-00a6632a786a.gif) 45 | 46 | ### 2. 자주 보고 싶은 정보는 카드에 있는 페이보릿 버튼을 클릭하여 보관하세요. 47 | 48 | 카테고리 페이지 최상단에 카드가 배치 되어 내가 원하는 정보에 쉽게 접근할 수 있어요. 49 | 50 | ![image](https://user-images.githubusercontent.com/51507260/113474270-00944400-94aa-11eb-8ed3-30fb34f4abd2.png) 51 | 52 | ### 3. 까먹어도 괜찮아요! 알람이 알려줄 거에요. 53 | 54 | 보고싶은 정보를 원하는 시간에 받아보세요. 알람 기능을 이용해 원하는 날짜와 시간을 입력하면, 그 시간에 내가 읽고싶은 정보를 보내드려요. 알람을 이용해 잊혀지던 정보를 효율적으로 사용하세요. 55 | 56 |

57 | 59 | 60 |

61 | 62 |

63 | 64 | # 🧑‍💻 Get start 65 | 66 | `development` 환경에서는 `chrome api` 기능을 사용 할 수 없어 `build`를 하고 크롬 익스텐션 개발 모드로 확인 가능합니다. 67 | 68 | ### development 69 | 70 | ``` 71 | npm i 72 | npm run start 73 | ``` 74 | 75 | ### production [build] 76 | 77 | ``` 78 | npm i 79 | npm run build 80 | ``` 81 | 82 | ### chrome development 83 | 84 | ``` 85 | 1. 크롬 실행 86 | 2. 확장 프로그램 87 | 3. 압축해지된 확장 프로그램을 로드합니다[클릭] 88 | 4. build 된 폴더 선택 -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "src", 4 | "paths": { 5 | "@/*": ["./*"], 6 | "@background/*": ["background/*"], 7 | "@main/*": ["main/*"], 8 | "@popup/*": ["popup/*"], 9 | "@assets/*": ["assets/*"], 10 | "@components/*": ["components/*"], 11 | "@hooks/*": ["hooks/*"], 12 | "@modules/*": ["modules/*"], 13 | "@utils/*": ["utils/*"] 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "urlink-chrome-extension", 3 | "version": "1.3.1", 4 | "private": true, 5 | "dependencies": { 6 | "@date-io/moment": "^1.3.13", 7 | "@emotion/react": "^11.8.2", 8 | "@emotion/styled": "^11.8.1", 9 | "@hookform/resolvers": "^1.2.0", 10 | "@mui/icons-material": "^5.5.1", 11 | "@mui/lab": "^5.0.0-alpha.73", 12 | "@mui/material": "^5.5.1", 13 | "@mui/styles": "^5.5.1", 14 | "@reduxjs/toolkit": "^1.5.0", 15 | "@testing-library/jest-dom": "^4.2.4", 16 | "@testing-library/react": "^9.3.2", 17 | "@testing-library/user-event": "^7.1.2", 18 | "axios": "^0.21.1", 19 | "clsx": "^1.1.0", 20 | "history": "^4.10.1", 21 | "immer": "^9.0.12", 22 | "moment": "^2.26.0", 23 | "node-sass": "^6.0.0", 24 | "re-resizable": "^6.9.9", 25 | "react": "^17.0.2", 26 | "react-chrome-extension-router": "^1.1.0", 27 | "react-dom": "^17.0.2", 28 | "react-ga": "^3.3.0", 29 | "react-hook-form": "^6.13.1", 30 | "react-id-swiper": "^4.0.0", 31 | "react-redux": "^7.2.2", 32 | "react-router-dom": "^5.2.0", 33 | "react-scripts": "^4.0.2", 34 | "redux": "^4.0.5", 35 | "redux-devtools-extension": "^2.13.8", 36 | "redux-saga": "^1.1.3", 37 | "redux-thunk": "^2.3.0", 38 | "swiper": "^6.5.1", 39 | "yup": "^0.32.8" 40 | }, 41 | "scripts": { 42 | "start": "craco start", 43 | "build": "craco build", 44 | "test": "craco test", 45 | "lint": "eslint --cache . --ext .js,.jsx --fix", 46 | "eject": "react-scripts eject" 47 | }, 48 | "browserslist": { 49 | "production": [ 50 | ">0.2%", 51 | "not dead", 52 | "not op_mini all" 53 | ], 54 | "development": [ 55 | "last 1 chrome version", 56 | "last 1 firefox version", 57 | "last 1 safari version" 58 | ] 59 | }, 60 | "devDependencies": { 61 | "@craco/craco": "^6.4.3", 62 | "craco-alias": "^2.1.1", 63 | "craco-esbuild": "^0.5.0", 64 | "eslint-config-prettier": "^7.2.0", 65 | "eslint-import-resolver-alias": "^1.1.2", 66 | "eslint-plugin-import": "^2.22.1", 67 | "eslint-plugin-prettier": "^3.3.1", 68 | "prettier": "^2.2.1", 69 | "progress-bar-webpack-plugin": "^2.1.0", 70 | "react-error-overlay": "^6.0.9", 71 | "sass": "^1.50.1", 72 | "webpack-bundle-analyzer": "^4.4.0" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /public/analytics.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | /* eslint-disable no-unused-expressions */ 3 | 4 | ;(function (i, s, o, g, r, a, m) { 5 | i['GoogleAnalyticsObject'] = r 6 | ;(i[r] = 7 | i[r] || 8 | function () { 9 | ;(i[r].q = i[r].q || []).push(arguments) 10 | }), 11 | (i[r].l = 1 * new Date()) 12 | ;(a = s.createElement(o)), (m = s.getElementsByTagName(o)[0]) 13 | a.async = 1 14 | a.src = g 15 | m.parentNode.insertBefore(a, m) 16 | })(window, document, 'script', 'https://www.google-analytics.com/analytics.js', 'ga') 17 | 18 | ga('create', 'UA-207149982-2', 'auto') 19 | ga('set', 'checkProtocolTask', null) 20 | ga('send', 'pageview') 21 | -------------------------------------------------------------------------------- /public/background.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | UrLink-background 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/public/favicon.ico -------------------------------------------------------------------------------- /public/images/logo/logo128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/public/images/logo/logo128.png -------------------------------------------------------------------------------- /public/images/logo/logo16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/public/images/logo/logo16.png -------------------------------------------------------------------------------- /public/images/logo/logo24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/public/images/logo/logo24.png -------------------------------------------------------------------------------- /public/images/logo/logo32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/public/images/logo/logo32.png -------------------------------------------------------------------------------- /public/images/logo/logo64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/public/images/logo/logo64.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | UrLink 8 | 9 | 10 | 11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "urLink", 3 | "version": "1.3.1", 4 | "manifest_version": 2, 5 | "description": "북마크보다 손쉽게 웹사이트를 보관하고 정리하세요.", 6 | "permissions": ["", "identity", "history", "notifications", "bookmarks"], 7 | "icons": { 8 | "16": "images/logo/logo16.png", 9 | "24": "images/logo/logo24.png", 10 | "32": "images/logo/logo32.png", 11 | "64": "images/logo/logo64.png", 12 | "128": "images/logo/logo128.png" 13 | }, 14 | "web_accessible_resources": ["*.ttf", "*.woff2"], 15 | "browser_action": { 16 | "default_icon": "images/logo/logo16.png", 17 | "default_popup": "popup.html" 18 | }, 19 | "background": { 20 | "page": "background.html", 21 | "persistent": true 22 | }, 23 | "oauth2": { 24 | "client_id": "683415540436-aimi7aksc043mre0pdj2nejtr08fkchs.apps.googleusercontent.com", 25 | "scopes": ["https://www.googleapis.com/auth/userinfo.profile", "https://www.googleapis.com/auth/userinfo.email"] 26 | }, 27 | "content_security_policy": "script-src 'self' https://www.google-analytics.com https://apis.google.com 'sha256-34M3HxxCd7web+UMuTvWUlRtVdx6QrTGItv3k4d183Y='; object-src 'self'" 28 | } 29 | -------------------------------------------------------------------------------- /public/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | UrLink-popup 7 | 8 | 9 | 10 | 11 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/assets/css/default.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --maincolor: #2083ff; 3 | --lightblue: #77caff; 4 | --powder-blue: #e3f0ff; 5 | --pale-sky-blue: #c2e8ff; 6 | --maintext: #212529; 7 | --battleship-grey: #737b84; 8 | --blue-grey: #868e96; 9 | --very-light-blue: #e9ecef; 10 | --pale-grey: #c4c4c4; 11 | --light-grey: #f4f4f4; 12 | --salmon: #ff6b6b; 13 | --green-blue: #00b381; 14 | } 15 | 16 | @import url(http://spoqa.github.io/spoqa-han-sans/css/SpoqaHanSans-kr.css); 17 | @import url(http://spoqa.github.io/spoqa-han-sans/css/SpoqaHanSans-jp.css); 18 | @import url(http://spoqa.github.io/spoqa-han-sans/css/SpoqaHanSansNeo.css); -------------------------------------------------------------------------------- /src/assets/images/add.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/add.png -------------------------------------------------------------------------------- /src/assets/images/alarm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/alarm.png -------------------------------------------------------------------------------- /src/assets/images/background1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/background1.jpg -------------------------------------------------------------------------------- /src/assets/images/background2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/background2.jpg -------------------------------------------------------------------------------- /src/assets/images/card-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/card-image.jpg -------------------------------------------------------------------------------- /src/assets/images/check-false.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/check-false.png -------------------------------------------------------------------------------- /src/assets/images/check-true.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/check-true.png -------------------------------------------------------------------------------- /src/assets/images/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/images/group-10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/group-10.png -------------------------------------------------------------------------------- /src/assets/images/group-19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/group-19.png -------------------------------------------------------------------------------- /src/assets/images/group-27.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/assets/images/history.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/history.png -------------------------------------------------------------------------------- /src/assets/images/info-person.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/info-person.jpg -------------------------------------------------------------------------------- /src/assets/images/link-copy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/link-copy.png -------------------------------------------------------------------------------- /src/assets/images/link-copy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/assets/images/link-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/link-icon.png -------------------------------------------------------------------------------- /src/assets/images/link-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/assets/images/link.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/assets/images/linkListEmptyIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/linkListEmptyIcon.png -------------------------------------------------------------------------------- /src/assets/images/link_icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/assets/images/logo-google.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/logo-google.png -------------------------------------------------------------------------------- /src/assets/images/logo-urlink-box.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/logo-urlink-box.png -------------------------------------------------------------------------------- /src/assets/images/logo-urlink-full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/logo-urlink-full.png -------------------------------------------------------------------------------- /src/assets/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/logo.png -------------------------------------------------------------------------------- /src/assets/images/logo/group-2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4CDB8F08-2A66-450E-B56D-C4345AAE6B4E 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/assets/images/logo/logo128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/logo/logo128.png -------------------------------------------------------------------------------- /src/assets/images/logo/logo16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/logo/logo16.png -------------------------------------------------------------------------------- /src/assets/images/logo/logo24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/logo/logo24.png -------------------------------------------------------------------------------- /src/assets/images/logo/logo32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/logo/logo32.png -------------------------------------------------------------------------------- /src/assets/images/logo/logo64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/logo/logo64.png -------------------------------------------------------------------------------- /src/assets/images/logo/profileImg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/logo/profileImg.png -------------------------------------------------------------------------------- /src/assets/images/logo/profileImg@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/logo/profileImg@2x.png -------------------------------------------------------------------------------- /src/assets/images/logo/profileImg@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/logo/profileImg@3x.png -------------------------------------------------------------------------------- /src/assets/images/main1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/main1.png -------------------------------------------------------------------------------- /src/assets/images/main1_back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/main1_back.png -------------------------------------------------------------------------------- /src/assets/images/main2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/main2.png -------------------------------------------------------------------------------- /src/assets/images/main2_back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/main2_back.png -------------------------------------------------------------------------------- /src/assets/images/main3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/main3.png -------------------------------------------------------------------------------- /src/assets/images/main3_back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/main3_back.png -------------------------------------------------------------------------------- /src/assets/images/main4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/main4.png -------------------------------------------------------------------------------- /src/assets/images/main5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/main5.png -------------------------------------------------------------------------------- /src/assets/images/mainBackground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/mainBackground.png -------------------------------------------------------------------------------- /src/assets/images/mainBackground2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/mainBackground2.png -------------------------------------------------------------------------------- /src/assets/images/mainLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/mainLogo.png -------------------------------------------------------------------------------- /src/assets/images/more.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/more.png -------------------------------------------------------------------------------- /src/assets/images/more_3dot.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/images/move.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/move.png -------------------------------------------------------------------------------- /src/assets/images/new-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/assets/images/open.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/open.png -------------------------------------------------------------------------------- /src/assets/images/person.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/person.png -------------------------------------------------------------------------------- /src/assets/images/plus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/plus.png -------------------------------------------------------------------------------- /src/assets/images/plus_noborder.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/images/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urLink-DEV/urLink-frontend/14b63146fd68ecb8d49f38e7ece88ed78b1f676c/src/assets/images/search.png -------------------------------------------------------------------------------- /src/assets/images/search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 24E531E5-BEE1-47B4-BC31-FB2216B211EE 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/assets/images/star.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/images/star_fill.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/images/union.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/images/white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/assets/scss/font.scss: -------------------------------------------------------------------------------- 1 | @import url(http://spoqa.github.io/spoqa-han-sans/css/SpoqaHanSans-kr.css); 2 | @import url(http://spoqa.github.io/spoqa-han-sans/css/SpoqaHanSans-jp.css); 3 | @import url(http://spoqa.github.io/spoqa-han-sans/css/SpoqaHanSansNeo.css); -------------------------------------------------------------------------------- /src/assets/scss/swiper.scss: -------------------------------------------------------------------------------- 1 | .swiper-container-c { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | width: 700px !important; 6 | } 7 | .prev-btn { 8 | background: transparent; 9 | border: none; 10 | color: white; 11 | font-weight: bold; 12 | right: 10px; 13 | left: auto; 14 | top: auto; 15 | } 16 | .next-btn { 17 | background: transparent; 18 | border: none; 19 | color: white; 20 | font-weight: bold; 21 | right: auto; 22 | left: 10px; 23 | top: auto; 24 | } 25 | .swiper-container-horizontal > .swiper-pagination-bullets { 26 | bottom: 5px; 27 | } 28 | .swiper-pagination-bullet-active { 29 | background-color: white; 30 | } -------------------------------------------------------------------------------- /src/background/index.js: -------------------------------------------------------------------------------- 1 | import { requestAlarmReadNotice } from '@/modules/alarmNotice' 2 | import { createTab } from '@/utils/chromeApis/tab' 3 | import { getAccessToken } from '@/utils/http/auth' 4 | import { onMessage, REMOVE_TOKEN, UPDATE_TOKEN } from '@utils/chromeApis/onMessage' 5 | import { alarmSocket } from '@utils/http/ws' 6 | 7 | function connectionWS() { 8 | try { 9 | if (alarmSocket.ws?.OPEN) alarmSocket.onClose() 10 | if (!getAccessToken()) return 11 | alarmSocket.onConnection().setOnmessage((event) => { 12 | const { message, status } = JSON.parse(event.data) 13 | if (status === 'alarm') { 14 | const notification = new Notification('urLink 알람이 도착했습니다.', { 15 | icon: message.url_favicon_path, 16 | body: message.url_title, 17 | }) 18 | notification.onclick = () => { 19 | requestAlarmReadNotice({ alarm_id: message.id }) 20 | createTab(message.url_path) 21 | } 22 | } 23 | }) 24 | } catch (error) { 25 | console.dir('error', error) 26 | } 27 | } 28 | 29 | function connectionClose() { 30 | if (alarmSocket.ws?.OPEN) alarmSocket.onClose() 31 | } 32 | 33 | try { 34 | connectionWS() 35 | onMessage((request) => { 36 | switch (request.message) { 37 | case UPDATE_TOKEN: 38 | connectionWS() 39 | break 40 | case REMOVE_TOKEN: 41 | connectionClose() 42 | break 43 | default: 44 | break 45 | } 46 | }) 47 | } catch (error) { 48 | console.dir('error', error) 49 | } 50 | -------------------------------------------------------------------------------- /src/hooks/useDebounce.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | 3 | function useDebounce(value, delay) { 4 | // 디바운스 할 값을 관리하기위한 상태값과 setter함수 5 | const [debouncedValue, setDebouncedValue] = useState(value) 6 | 7 | useEffect(() => { 8 | // 딜레이 이후 값을 업데이트한다. 9 | const timer = setTimeout(() => { 10 | setDebouncedValue(value) 11 | }, delay) 12 | 13 | // 딜레이 기간중에 value혹은 delay값이 업데이트 되었다면 이(cleanup)함수를 실행한다. 14 | return () => { 15 | clearTimeout(timer) 16 | } 17 | }, [value, delay]) // delay값이나 value값이 업데이트 되었다면 다시 호출한다. 18 | 19 | return debouncedValue 20 | } 21 | 22 | export default useDebounce 23 | -------------------------------------------------------------------------------- /src/hooks/useEventListener.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | 3 | export default function useEventListener(eventName, handler, element = window) { 4 | const savedHandler = useRef() 5 | 6 | useEffect(() => { 7 | savedHandler.current = handler 8 | }, [handler]) 9 | 10 | useEffect(() => { 11 | const isSupported = element && element.addEventListener 12 | if (!isSupported) return 13 | 14 | const eventListener = (event) => savedHandler.current(event) 15 | 16 | element.addEventListener(eventName, eventListener) 17 | 18 | return () => { 19 | element.removeEventListener(eventName, eventListener) 20 | } 21 | }, [eventName, element]) 22 | } 23 | -------------------------------------------------------------------------------- /src/hooks/useOutsideAlerter.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | 3 | function useOutsideAlerter(ref, bool, callback) { 4 | useEffect(() => { 5 | function handleClickOutside(event) { 6 | if (ref.current && !ref.current?.contains(event.target)) { 7 | return callback() 8 | } 9 | } 10 | 11 | if (bool) document.addEventListener('mousedown', handleClickOutside) 12 | return () => document.removeEventListener('mousedown', handleClickOutside) 13 | }, [ref, bool, callback]) 14 | } 15 | 16 | export default useOutsideAlerter 17 | -------------------------------------------------------------------------------- /src/main/App.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | 3 | import moment from 'moment' 4 | import { Router } from 'react-chrome-extension-router' 5 | import { useLocation } from 'react-router-dom' 6 | 7 | import Snackbar from '@main/components/Toast' 8 | import Home from '@main/pages/Home' 9 | import { useToast } from '@modules/ui' 10 | import { GAPageview, initGA } from '@utils/ga' 11 | import { getAccessToken } from '@utils/http/auth' 12 | 13 | import GetStartPage from './pages/Start' 14 | import 'moment/locale/ko' 15 | 16 | moment.locale('ko') 17 | 18 | function App() { 19 | const { pathname } = useLocation() 20 | 21 | useEffect(() => { 22 | initGA() 23 | }, []) 24 | 25 | useEffect(() => { 26 | GAPageview(pathname) 27 | }, [pathname]) 28 | 29 | return ( 30 | <> 31 | {getAccessToken() ? : } 32 | 33 | 34 | ) 35 | } 36 | 37 | function ToastContainer() { 38 | const { open, type, message, close } = useToast() 39 | return 40 | } 41 | 42 | export default App 43 | -------------------------------------------------------------------------------- /src/main/components/ExtendedFab/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { StyledFab } from './style' 4 | 5 | function ExtendedFab(props) { 6 | const isExtended = React.Children.toArray(props.children).find((child) => typeof child === 'string') 7 | 8 | return 9 | } 10 | 11 | export default ExtendedFab 12 | -------------------------------------------------------------------------------- /src/main/components/ExtendedFab/style.js: -------------------------------------------------------------------------------- 1 | import { Fab } from '@mui/material' 2 | import { withStyles } from '@mui/styles' 3 | 4 | export const StyledFab = withStyles((theme) => ({ 5 | root: { 6 | position: 'absolute', 7 | bottom: 7, 8 | right: 5, 9 | width: 40, 10 | height: 40, 11 | margin: 0, 12 | borderRadius: '50%', 13 | }, 14 | }))(Fab) 15 | -------------------------------------------------------------------------------- /src/main/components/ScrollUpButton/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { ArrowUpward as ArrowUpwardIcon } from '@mui/icons-material' 4 | import clsx from 'clsx' 5 | 6 | import ExtendedFab from '@main/components/ExtendedFab' 7 | import { GAEvent } from '@utils/ga' 8 | 9 | import useStyles from './style' 10 | 11 | function ScrollUpButton({ targetRef, className, open }) { 12 | const classes = useStyles() 13 | 14 | const handleScrollUp = () => { 15 | GAEvent('스크롤 업 버튼', '스크롤 업 버튼 클릭') 16 | scrollTo(targetRef.current, 0, 500) 17 | } 18 | 19 | return ( 20 | 27 | 28 | 29 | ) 30 | } 31 | 32 | function scrollTo(element, to = 0, duration = 500, scrollToDone = null) { 33 | const start = element.scrollTop 34 | const change = to - start 35 | const increment = 20 36 | let currentTime = 0 37 | 38 | const easeInOutQuad = (currentTime, start, change, duration) => { 39 | currentTime /= duration / 2 40 | if (currentTime < 1) return (change / 2) * currentTime * currentTime + start 41 | currentTime-- 42 | return (-change / 2) * (currentTime * (currentTime - 2) - 1) + start 43 | } 44 | 45 | const animateScroll = () => { 46 | currentTime += increment 47 | const val = easeInOutQuad(currentTime, start, change, duration) 48 | element.scrollTop = val 49 | if (currentTime < duration) setTimeout(animateScroll, increment) 50 | else if (scrollToDone) scrollToDone() 51 | } 52 | 53 | animateScroll() 54 | } 55 | 56 | export default ScrollUpButton 57 | -------------------------------------------------------------------------------- /src/main/components/ScrollUpButton/style.js: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@mui/styles' 2 | 3 | const useStyles = makeStyles((theme) => ({ 4 | root: { 5 | position: 'fixed', 6 | bottom: 14, 7 | right: 58, 8 | 9 | opacity: 0, 10 | 11 | transform: 'translateY(100px)', 12 | transition: 'all .5s ease', 13 | }, 14 | showBtn: { 15 | opacity: 1, 16 | 17 | transform: 'translateY(0)', 18 | }, 19 | })) 20 | 21 | export default useStyles 22 | -------------------------------------------------------------------------------- /src/main/components/SearchBar/style.js: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@mui/styles' 2 | 3 | export const useStyles = makeStyles((theme) => ({ 4 | searchBar: { 5 | display: 'flex', 6 | gap: 3, 7 | flexDirection: 'row', 8 | justifyContent: 'flex-start', 9 | alignItems: 'center', 10 | 11 | width: 380, 12 | height: 38, 13 | padding: '0px 40px 0px 16px', 14 | backgroundColor: '#F3F3F3', 15 | borderRadius: 8, 16 | }, 17 | searchBarDisabled: { 18 | opacity: 0.4, 19 | }, 20 | searchSelect: { 21 | paddingRight: '12px', 22 | fontSize: 14, 23 | }, 24 | searchSelectPaper: { 25 | padding: 0, 26 | minWidth: 199, 27 | borderRadius: 8, 28 | 29 | '& > .MuiMenu-list': { 30 | padding: 0, 31 | display: 'flex', 32 | flexDirection: 'column', 33 | gap: 10, 34 | }, 35 | }, 36 | searchSelectItem: { 37 | display: 'flex', 38 | flexDirection: 'column', 39 | alignItems: 'flex-start', 40 | rowGap: 4, 41 | }, 42 | searchInputBase: { 43 | width: '100%', 44 | }, 45 | searchInput: { 46 | display: 'flex', 47 | alignItems: 'center', 48 | 49 | fontWeight: 400, 50 | fontSize: 14, 51 | lineHeight: 14, 52 | 53 | color: '#777777', 54 | }, 55 | divider: { 56 | height: 31, 57 | }, 58 | pickerBtn: { 59 | color: theme.palette.primary.main, 60 | fontWeight: 400, 61 | fontSize: 14, 62 | 63 | width: '100%', 64 | borderBottom: '1px solid', 65 | borderRadius: 0, 66 | 67 | '&:hover': { 68 | color: theme.palette.primary.main, 69 | }, 70 | }, 71 | datePicker: { 72 | width: 320, 73 | }, 74 | searchIcon: { 75 | margin: '11px 10px', 76 | width: 16, 77 | height: 16, 78 | color: '#777777', 79 | }, 80 | searchInputCancel: { 81 | position: 'relative', 82 | top: 0, 83 | right: -31, 84 | 85 | width: 24, 86 | height: 24, 87 | color: '#6b6b6f', 88 | }, 89 | })) 90 | 91 | export default useStyles 92 | -------------------------------------------------------------------------------- /src/main/components/SearchButton/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState } from 'react' 2 | 3 | import Popover from '@mui/material/Popover' 4 | import ToggleButton from '@mui/material/ToggleButton' 5 | 6 | import SearchImg from '@assets/images/search.png' 7 | 8 | import useStyles, { StyledToggleButtonGroup } from './style' 9 | 10 | function SearchButton({ buttonProps, inputProps, searchFilterList, onSelectButton, selectedName }) { 11 | const classes = useStyles() 12 | 13 | const searchButtonRef = useRef(null) 14 | const [openSearchBox, setOpenSearchBox] = useState(false) 15 | 16 | return ( 17 | <> 18 | 28 | setOpenSearchBox((open) => !open)} 32 | anchorEl={searchButtonRef.current} 33 | anchorOrigin={{ vertical: 'top', horizontal: 'left' }} 34 | transformOrigin={{ vertical: 'top', horizontal: 'left' }} 35 | > 36 |
37 |
38 | search-icon 39 | Search 40 |
41 |
42 | 43 |
44 | {searchFilterList && ( 45 |
46 | 47 | {searchFilterList.map(({ search, name }) => ( 48 | 55 | {name} 56 | 57 | ))} 58 | 59 |
60 | )} 61 |
62 |
63 | 64 | ) 65 | } 66 | 67 | export default SearchButton 68 | -------------------------------------------------------------------------------- /src/main/components/SearchButton/style.js: -------------------------------------------------------------------------------- 1 | import ToggleButtonGroup from '@mui/lab/ToggleButtonGroup' 2 | import { makeStyles, withStyles } from '@mui/styles' 3 | 4 | export const StyledToggleButtonGroup = withStyles((theme) => ({ 5 | grouped: { 6 | margin: theme.spacing(0.5), 7 | border: 'none', 8 | '&:not(:first-child)': { 9 | borderRadius: theme.shape.borderRadius, 10 | }, 11 | '&:first-child': { 12 | borderRadius: theme.shape.borderRadius, 13 | }, 14 | }, 15 | }))(ToggleButtonGroup) 16 | 17 | const useStyles = makeStyles((theme) => ({ 18 | flex: { 19 | display: 'flex', 20 | flexDirection: 'column', 21 | }, 22 | searchBtn: { 23 | flexShrink: 0, 24 | height: 30, 25 | padding: '5px 10px', 26 | borderRadius: 4, 27 | backgroundColor: '#ffffff', 28 | boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.1), 0 1px 3px 0 rgba(0, 0, 0, 0.12)', 29 | '&:hover': { 30 | boxShadow: '0 2px 8px 0 rgba(0, 0, 0, 0.15), 0 5px 12px 0 rgba(0, 0, 0, 0.12)', 31 | }, 32 | }, 33 | searchIcon: { 34 | marginRight: 5, 35 | }, 36 | searchBtnText: { 37 | width: 34, 38 | height: 15, 39 | color: '#868e96', 40 | fontSize: '12pt', 41 | fontWeight: '300', 42 | textAlign: 'center', 43 | }, 44 | inputBox: { 45 | width: 220, 46 | padding: '5px 10px', 47 | borderRadius: 4, 48 | backgroundColor: '#ffffff', 49 | boxShadow: '0 2px 8px 0 rgba(0, 0, 0, 0.15), 0 5px 12px 0 rgba(0, 0, 0, 0.12)', 50 | }, 51 | textfield: { 52 | width: '100%', 53 | height: 28, 54 | padding: '3px 7px', 55 | borderRadius: '4px', 56 | border: 'solid 1px #e9ecef', 57 | backgroundColor: '#f1f3f5', 58 | outline: 'none', 59 | '&:focus': { border: '1px solid #2083ff' }, 60 | }, 61 | marginBottom10: { 62 | marginBottom: 10, 63 | }, 64 | popoverBtn: { 65 | height: 30, 66 | marginTop: 3, 67 | '&.Mui-selected': { 68 | color: 'white', 69 | backgroundColor: '#2083ff', 70 | fontWeight: 400, 71 | }, 72 | }, 73 | })) 74 | 75 | export default useStyles 76 | -------------------------------------------------------------------------------- /src/main/components/Toast/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import CloseIcon from '@mui/icons-material/Close' 4 | import { Snackbar, IconButton, Slide, Grow, Fade } from '@mui/material' 5 | import Alert from '@mui/material/Alert' 6 | 7 | function SnackbarTransition({ transition, direction, ...rest }) { 8 | return ( 9 | 14 | ) 15 | } 16 | 17 | function Toast({ open, type, message, close, ...props }) { 18 | return ( 19 | 32 | 33 | 34 | } 35 | {...props} 36 | > 37 | {type && ( 38 | 39 | {message} 40 | 41 | )} 42 | 43 | ) 44 | } 45 | 46 | export default Toast 47 | -------------------------------------------------------------------------------- /src/main/components/ValidationMessage/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import clsx from 'clsx' 4 | 5 | import checkFalseImg from '@assets/images/check-false.png' 6 | import checkTrueImg from '@assets/images/check-true.png' 7 | 8 | import useStyles from './style.js' 9 | 10 | function ValidationMessage({ msg, check }) { 11 | const classes = useStyles() 12 | 13 | return ( 14 |
20 | {check 21 | {msg} 22 |
23 | ) 24 | } 25 | 26 | export default ValidationMessage 27 | -------------------------------------------------------------------------------- /src/main/components/ValidationMessage/style.js: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@mui/styles' 2 | 3 | const useStyles = makeStyles((theme) => ({ 4 | checkValidation: { 5 | position: 'relative', 6 | width: '100%', 7 | margin: '2px 0 10px 0', 8 | fontSize: '12px', 9 | fontWeight: 'normal', 10 | fontStretch: 'normal', 11 | fontStyle: 'normal', 12 | lineHeight: 'normal', 13 | letterSpacing: '-0.55px', 14 | opacity: 0, 15 | }, 16 | checkTrue: { 17 | color: '#00b381', 18 | opacity: 1, 19 | }, 20 | checkFalse: { 21 | color: '#ff6b6b', 22 | opacity: 1, 23 | }, 24 | })) 25 | 26 | export default useStyles 27 | -------------------------------------------------------------------------------- /src/main/components/modals/AlertModal/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { Button } from '@mui/material' 4 | 5 | import alertIcon from '@assets/images/logo/group-2.svg' 6 | import { 7 | StyledDialog, 8 | StyledDialogContent, 9 | StyledDialogContentText, 10 | StyledDialogActions, 11 | } from '@main/components/modals/style' 12 | 13 | import useStyles from './style' 14 | 15 | function AlertModal({ 16 | openBool, 17 | handleClose, 18 | contentText, 19 | btnYesText = '확인', 20 | handleNoText = '취소', 21 | handleYesClick, 22 | handleNoClick, 23 | children, 24 | }) { 25 | const classes = useStyles() 26 | 27 | return ( 28 | 29 | 30 | 31 | alert-icon 32 | {contentText || children} 33 | 34 | 35 | 36 | {handleYesClick && ( 37 | 40 | )} 41 | {(handleNoClick || handleClose) && ( 42 | 45 | )} 46 | 47 | 48 | ) 49 | } 50 | 51 | export default AlertModal 52 | -------------------------------------------------------------------------------- /src/main/components/modals/AlertModal/style.js: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@mui/styles' 2 | 3 | const useStyles = makeStyles((theme) => ({ 4 | termsModal: { 5 | width: '600px', 6 | height: '500px', 7 | }, 8 | alertModal: { 9 | display: 'flex', 10 | justifyContent: 'center', 11 | alignItems: 'center', 12 | fontWeight: 'bold', 13 | }, 14 | alertIcon: { 15 | position: 'absolute', 16 | top: 0, 17 | left: 0, 18 | }, 19 | alertModalBtn: { 20 | width: '100%', 21 | }, 22 | })) 23 | 24 | export default useStyles 25 | -------------------------------------------------------------------------------- /src/main/components/modals/TermsModal/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { DialogTitle, Button } from '@mui/material' 4 | 5 | import { 6 | StyledDialog, 7 | StyledDialogContent, 8 | StyledDialogContentText, 9 | StyledDialogActions, 10 | } from '@main/components/modals/style' 11 | 12 | import textInfo from './textInfo' 13 | 14 | function TermsModal({ open, onClose, onYesClick, onYesText = '동의함', onNoClick, onNoText = '동의안함' }) { 15 | return ( 16 | 23 | 24 | 이용 약관 동의 25 | 26 | 27 | 28 | 29 | 30 | {onYesClick && ( 31 | 34 | )} 35 | {onNoClick && ( 36 | 39 | )} 40 | 41 | 42 | ) 43 | } 44 | 45 | export default TermsModal 46 | -------------------------------------------------------------------------------- /src/main/components/modals/index.jsx: -------------------------------------------------------------------------------- 1 | export { default as TermsModal } from './TermsModal' 2 | export { default as AlertModal } from './AlertModal' 3 | -------------------------------------------------------------------------------- /src/main/components/modals/style.js: -------------------------------------------------------------------------------- 1 | import { Dialog, DialogActions, DialogContent, DialogContentText } from '@mui/material' 2 | import { withStyles } from '@mui/styles' 3 | 4 | export const StyledDialog = withStyles((theme) => ({ 5 | paperWidthSm: { 6 | width: 340, 7 | height: 216, 8 | }, 9 | }))(Dialog) 10 | 11 | export const StyledDialogContent = withStyles((theme) => ({ 12 | root: { 13 | '&:first-child': { 14 | padding: '60px 30px', 15 | overflowY: 'hidden', 16 | }, 17 | }, 18 | }))(DialogContent) 19 | 20 | export const StyledDialogContentText = withStyles((theme) => ({ 21 | root: { 22 | marginBottom: 12, 23 | 24 | color: theme.palette.text.primary, 25 | 26 | fontSize: 14, 27 | }, 28 | }))(DialogContentText) 29 | 30 | export const StyledDialogActions = withStyles((theme) => ({ 31 | root: { 32 | display: 'flex', 33 | flex: '0 0 auto', 34 | padding: 8, 35 | 36 | borderTop: '1px solid rgba(0, 0, 0, 0.15)', 37 | 38 | alignItems: 'center', 39 | justifyContent: 'space-around', 40 | }, 41 | }))(DialogActions) 42 | -------------------------------------------------------------------------------- /src/main/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import AdapterMoment from '@mui/lab/AdapterMoment' 4 | import LocalizationProvider from '@mui/lab/LocalizationProvider' 5 | import CssBaseline from '@mui/material/CssBaseline' 6 | import { ThemeProvider, StyledEngineProvider } from '@mui/material/styles' 7 | import { createBrowserHistory } from 'history' 8 | import ReactDOM from 'react-dom' 9 | import { Provider } from 'react-redux' 10 | import { Router } from 'react-router-dom' 11 | import { createStore, applyMiddleware } from 'redux' 12 | import { composeWithDevTools } from 'redux-devtools-extension' 13 | import createSagaMiddleware from 'redux-saga' 14 | import thunkMiddleware from 'redux-thunk' 15 | 16 | import { rootReducer, rootSaga } from '@modules' 17 | import '@assets/scss/font.scss' 18 | 19 | import App from './App' 20 | import theme from './theme' 21 | 22 | const browserHistory = createBrowserHistory() 23 | const sagaMiddleware = createSagaMiddleware() 24 | 25 | const store = createStore( 26 | rootReducer, 27 | composeWithDevTools({ trace: true })(applyMiddleware(sagaMiddleware, thunkMiddleware)) 28 | ) 29 | 30 | sagaMiddleware.run(rootSaga) 31 | 32 | ReactDOM.render( 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | , 45 | document.getElementById('root') 46 | ) 47 | -------------------------------------------------------------------------------- /src/main/pages/Home/AppBar/AlarmList/style.js: -------------------------------------------------------------------------------- 1 | import { Avatar } from '@mui/material' 2 | import { grey } from '@mui/material/colors' 3 | import { makeStyles, withStyles } from '@mui/styles' 4 | 5 | const useStyles = makeStyles((theme) => ({ 6 | root: { 7 | width: 360, 8 | minHeight: 305, 9 | maxHeight: 524, 10 | overflow: 'scroll', 11 | backgroundColor: theme.palette.background.theme, 12 | }, 13 | title: { 14 | width: 100, 15 | height: 29, 16 | fontSize: 20, 17 | fontWeight: 'bold', 18 | fontStretch: 'normal', 19 | fontStyle: 'normal', 20 | lineHeight: 'normal', 21 | letterSpacing: 'normal', 22 | color: '#000000', 23 | }, 24 | listItem: { 25 | paddingLeft: 10, 26 | backgroundColor: theme.palette.background.paper, 27 | }, 28 | avatar: { 29 | marginRight: 10, 30 | }, 31 | icon: { 32 | width: 56, 33 | height: 56, 34 | }, 35 | cover: { 36 | height: 358, 37 | backgroundSize: 'auto', 38 | }, 39 | text: { 40 | '& .MuiListItemText-primary': { 41 | fontSize: 14, 42 | }, 43 | '& .MuiListItemText-secondary': { 44 | fontSize: 11, 45 | }, 46 | }, 47 | noticeText: { 48 | '& .MuiListItemText-secondary': { 49 | fontWeight: 'bold', 50 | color: '#2083ff', 51 | }, 52 | }, 53 | readText: { 54 | '& .MuiListItemText-primary': { 55 | color: '#b9b9b9 !important', 56 | }, 57 | '& .MuiListItemText-secondary': { 58 | color: '#b9b9b9 !important', 59 | }, 60 | }, 61 | bgGrey: { 62 | backgroundColor: `${grey[200]} !important`, 63 | }, 64 | })) 65 | 66 | export const SmallAvatar = withStyles((theme) => ({ 67 | root: { 68 | width: 28, 69 | height: 28, 70 | border: `2px solid ${theme.palette.background.paper}`, 71 | backgroundColor: '#2083ff', 72 | }, 73 | }))(Avatar) 74 | 75 | export default useStyles 76 | -------------------------------------------------------------------------------- /src/main/pages/Home/AppBar/DraggableHistoryList/History/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useCallback, useState } from 'react' 2 | 3 | import LinkIcon from '@mui/icons-material/Link' 4 | import OpenInNewIcon from '@mui/icons-material/OpenInNew' 5 | import { IconButton, ListItem, ListItemIcon, ListItemSecondaryAction, ListItemText } from '@mui/material' 6 | import clsx from 'clsx' 7 | 8 | import logoImg from '@assets/images/logo/logo16.png' 9 | import newTabImg from '@assets/images/new-tab.svg' 10 | import { useToast } from '@modules/ui' 11 | import { createTab } from '@utils/chromeApis/tab' 12 | import copyLink from '@utils/copyLink' 13 | import { GAEvent } from '@utils/ga' 14 | 15 | import useStyles from './style' 16 | 17 | function History({ isSelected = false, data = {}, ...props }) { 18 | const classes = useStyles() 19 | const { openToast } = useToast() 20 | 21 | const [faviconLink, setFaviconLink] = useState(`https://www.google.com/s2/favicons?domain=${data.hostName}`) 22 | 23 | const handleNewTab = useCallback( 24 | (e) => { 25 | e.stopPropagation() 26 | GAEvent('방문기록', '링크 새 탭 열기') 27 | createTab(data.url) 28 | }, 29 | [data.url] 30 | ) 31 | 32 | const handleLinkCopy = useCallback( 33 | (e) => { 34 | e.stopPropagation() 35 | GAEvent('방문기록', '링크 복사 하기') 36 | copyLink(data.url) 37 | openToast({ type: 'success', message: '링크가 복사 되었습니다.' }) 38 | }, 39 | [data.url, openToast] 40 | ) 41 | 42 | return ( 43 | 54 | 55 | setFaviconLink(logoImg)} src={faviconLink} alt={data.title} /> 56 | 57 | 60 | {data.title + ` (${data.visitCount})`} 61 | {data.hostName} 62 | 63 | } 64 | /> 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | ) 75 | } 76 | 77 | export default memo(History) 78 | -------------------------------------------------------------------------------- /src/main/pages/Home/AppBar/DraggableHistoryList/History/style.js: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@mui/styles' 2 | 3 | const useStyles = makeStyles((theme) => ({ 4 | listItem: { 5 | margin: '10px 0', 6 | padding: '2px 253px 2px 16px', 7 | 8 | width: 583, 9 | height: 36, 10 | 11 | borderRadius: 4, 12 | backgroundColor: theme.palette.background.paper, 13 | 14 | '& .Mui-focusVisible': { 15 | backgroundColor: 'transparent', 16 | }, 17 | '&:hover': { 18 | backgroundColor: '#F2F2F2', 19 | '& > $buttonGroup': { 20 | visibility: 'inherit', 21 | }, 22 | }, 23 | }, 24 | selected: { 25 | backgroundColor: '#E8F1FF', 26 | }, 27 | listButton: { 28 | padding: '1px 0 0 0', 29 | width: 555, 30 | 31 | '& .MuiListItemIcon-root': { 32 | minWidth: 14, 33 | marginRight: 17, 34 | }, 35 | '& .MuiListItemText-primary': { 36 | display: 'flex', 37 | }, 38 | '&:hover': { 39 | backgroundColor: 'transparent', 40 | }, 41 | }, 42 | favicon: { 43 | width: 14, 44 | height: 14, 45 | }, 46 | mainFont: { 47 | display: 'inline-block', 48 | verticalAlign: 'bottom', 49 | 50 | overflow: 'hidden', 51 | 52 | width: 266, 53 | marginRight: 16, 54 | 55 | color: theme.palette.text.secondary, 56 | 57 | fontSize: 14, 58 | fontWeight: 400, 59 | letterSpacing: -0.44, 60 | whiteSpace: 'nowrap', 61 | textOverflow: 'ellipsis', 62 | }, 63 | subFont: { 64 | display: 'inline-block', 65 | verticalAlign: 'bottom', 66 | 67 | overflow: 'hidden', 68 | 69 | width: 157, 70 | 71 | color: theme.palette.text.description, 72 | 73 | fontSize: 14, 74 | fontWeight: 400, 75 | letterSpacing: -0.6, 76 | whiteSpace: 'nowrap', 77 | textOverflow: 'ellipsis', 78 | }, 79 | buttonGroup: { 80 | visibility: 'hidden', 81 | 82 | display: 'flex', 83 | gap: 7, 84 | }, 85 | iconButton: { 86 | padding: 7, 87 | 88 | width: 25, 89 | height: 25, 90 | borderRadius: 4, 91 | backgroundColor: '#FFFFFF', 92 | color: '#666666', 93 | }, 94 | openInNewIcon: { 95 | width: 11.11, 96 | }, 97 | linkIcon: { 98 | width: 16.67, 99 | }, 100 | })) 101 | 102 | export default useStyles 103 | -------------------------------------------------------------------------------- /src/main/pages/Home/AppBar/DraggableHistoryList/HistoryDateTitle/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import useStyles from './style' 4 | 5 | function HistoryDateTitle({ data }) { 6 | const classes = useStyles() 7 | const curDate = new Date().toLocaleDateString() 8 | const linkDate = new Date(data.lastVisitTime).toLocaleDateString() 9 | 10 | if (!data.first) return null 11 | return ( 12 |
13 | {curDate === linkDate ? '오늘' : linkDate} 14 | 15 |
16 | ) 17 | } 18 | 19 | export default HistoryDateTitle 20 | -------------------------------------------------------------------------------- /src/main/pages/Home/AppBar/DraggableHistoryList/HistoryDateTitle/style.js: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@mui/styles' 2 | 3 | const useStyles = makeStyles((theme) => ({ 4 | root: { 5 | width: 547, 6 | }, 7 | title: { 8 | minWidth: 42, 9 | maxWidth: 94, 10 | fontWeight: 400, 11 | fontSize: 14, 12 | lineHeight: '36px', 13 | 14 | position: 'absolute', 15 | 16 | paddingRight: 8, 17 | 18 | backgroundColor: 19 | theme.palette.type !== 'dark' ? theme.palette.colorGroup.lightGrey : theme.palette.background.default, 20 | }, 21 | line: { 22 | width: 570, 23 | height: 0.5, 24 | 25 | display: 'inline-block', 26 | background: '#C3C3C3', 27 | }, 28 | })) 29 | 30 | export default useStyles 31 | -------------------------------------------------------------------------------- /src/main/pages/Home/AppBar/DraggableHistoryList/HistoryDragBox/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from 'react' 2 | 3 | import clsx from 'clsx' 4 | 5 | import moveLink from '@assets/images/move.png' 6 | 7 | import useStyles from './style' 8 | 9 | const HistoryDragBox = forwardRef(({ selectedCount }, ref) => { 10 | const classes = useStyles() 11 | 12 | return ( 13 |
14 |
15 | move links 16 |
17 | 링크 {selectedCount}개 이동 18 |
19 | ) 20 | }) 21 | 22 | HistoryDragBox.displayName = 'HistoryDragBox' 23 | 24 | export default HistoryDragBox 25 | -------------------------------------------------------------------------------- /src/main/pages/Home/AppBar/DraggableHistoryList/HistoryDragBox/style.js: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@mui/styles' 2 | 3 | const useStyles = makeStyles((theme) => ({ 4 | tabMove: { 5 | position: 'absolute', 6 | zIndex: -1, 7 | top: 10, 8 | right: 0, 9 | 10 | display: 'flex', 11 | justifyContent: 'center', 12 | alignItems: 'center', 13 | 14 | width: 110, 15 | height: 35, 16 | 17 | color: theme.palette.common.white, 18 | backgroundColor: theme.palette.primary.main, 19 | borderRadius: 3.2, 20 | boxShadow: '0 11px 22px 0 rgba(0, 0, 0, 0.15), 0 8px 8px 0 rgba(0, 0, 0, 0.12)', 21 | 22 | fontSize: 12, 23 | }, 24 | circle: { 25 | display: 'flex', 26 | justifyContent: 'center', 27 | alignItems: 'center', 28 | 29 | width: 23, 30 | height: 23, 31 | marginRight: 7, 32 | 33 | borderRadius: '50%', 34 | boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.12), 0 1px 2px 0 rgba(0, 0, 0, 0.1)', 35 | }, 36 | moveIcon: { 37 | position: 'relative', 38 | left: 1, 39 | 40 | width: 20, 41 | height: 20, 42 | }, 43 | })) 44 | 45 | export default useStyles 46 | -------------------------------------------------------------------------------- /src/main/pages/Home/AppBar/DraggableHistoryList/style.js: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@mui/styles' 2 | 3 | const useStyles = makeStyles((theme) => ({ 4 | root: { 5 | position: 'relative', 6 | 7 | width: 656, 8 | height: 'calc(100vh - 72px);', 9 | 10 | backgroundColor: 11 | theme.palette.type !== 'dark' ? theme.palette.colorGroup.lightGrey : theme.palette.background.default, 12 | boxShadow: '0 2px 4px 0 rgba(0, 0, 0, 0.1)', 13 | }, 14 | header: { 15 | display: 'flex', 16 | justifyContent: 'space-between', 17 | 18 | padding: '17px 36px', 19 | }, 20 | mainText: { 21 | marginRight: 10, 22 | marginTop: 2, 23 | height: 36, 24 | 25 | backgroundColor: 'transparent', 26 | 27 | fontWeight: 700, 28 | fontSize: 20, 29 | color: '#333333', 30 | }, 31 | reloadIcon: { 32 | width: 16, 33 | height: 16, 34 | padding: 0, 35 | color: '#666666', 36 | }, 37 | headerButtonGroup: { 38 | display: 'flex', 39 | alignItems: 'center', 40 | gap: 10, 41 | }, 42 | headerSelectedLinkText: { 43 | color: '#747778', 44 | 45 | fontWeight: 400, 46 | fontSize: 14, 47 | lineHeight: '30px', 48 | }, 49 | headerButton: { 50 | padding: '12px 24px', 51 | 52 | backgroundColor: '#EDF0FF', 53 | borderRadius: 8, 54 | }, 55 | headerButtonText: { 56 | color: '#0058CB', 57 | 58 | fontWeight: 400, 59 | fontSize: 14, 60 | lineHeight: '14px', 61 | }, 62 | content: { 63 | overflowY: 'scroll', 64 | overflowX: 'hidden', 65 | 66 | height: '100%', 67 | padding: '12px 36px 0px', 68 | }, 69 | linkListEmpty: { 70 | marginTop: 80, 71 | fontWeight: 400, 72 | fontSize: 20, 73 | lineHeight: '14px', 74 | color: '#77777B', 75 | }, 76 | 77 | /* common */ 78 | rowSpread: { 79 | display: 'flex', 80 | alignItems: 'center', 81 | }, 82 | center: { 83 | display: 'flex', 84 | alignItems: 'center', 85 | justifyContent: 'center', 86 | }, 87 | })) 88 | 89 | export default useStyles 90 | -------------------------------------------------------------------------------- /src/main/pages/Home/AppBar/Profile/style.js: -------------------------------------------------------------------------------- 1 | import makeStyles from '@mui/styles/makeStyles' 2 | 3 | const useStyles = makeStyles({ 4 | root: { 5 | width: 320, 6 | height: 228, 7 | }, 8 | title: { 9 | width: 100, 10 | height: 29, 11 | fontSize: 20, 12 | fontWeight: 'bold', 13 | fontStretch: 'normal', 14 | fontStyle: 'normal', 15 | lineHeight: 'normal', 16 | letterSpacing: 'normal', 17 | color: '#000000', 18 | }, 19 | content: { 20 | paddingTop: 24, 21 | }, 22 | profileImg: { 23 | width: 52, 24 | height: 52, 25 | marginRight: 16, 26 | marginBottom: 24, 27 | }, 28 | profileInfoGrid: { 29 | paddingTop: 5, 30 | }, 31 | profileName: { 32 | fontSize: 14, 33 | }, 34 | profileEmail: { 35 | fontSize: 12, 36 | }, 37 | profileBtn: { 38 | fontSize: 12, 39 | marginTop: 5, 40 | }, 41 | logoutBtn: { 42 | width: '100%', 43 | }, 44 | }) 45 | 46 | export default useStyles 47 | -------------------------------------------------------------------------------- /src/main/pages/Home/AppBar/style.js: -------------------------------------------------------------------------------- 1 | import { ListItem } from '@mui/material' 2 | import { makeStyles, withStyles } from '@mui/styles' 3 | 4 | export const useStyles = makeStyles((theme) => ({ 5 | appBar: { 6 | position: 'sticky', 7 | top: 0, 8 | zIndex: 100, 9 | 10 | paddingTop: 12, 11 | paddingBottom: 12, 12 | 13 | display: 'flex', 14 | justifyContent: 'flex-end', 15 | alignItems: 'center', 16 | 17 | backgroundColor: 18 | theme.palette.type !== 'dark' ? theme.palette.colorGroup.lightGrey : theme.palette.background.default, 19 | }, 20 | appBarInversion: { 21 | backgroundColor: '#FFFFFF', 22 | }, 23 | imgButton: { 24 | width: 17, 25 | height: 17, 26 | 27 | '& > img': { 28 | objectFit: 'contain', 29 | }, 30 | }, 31 | iconButtonGroup: { 32 | padding: 0, 33 | marginLeft: 11, 34 | marginRight: 37, 35 | display: 'flex', 36 | gap: 11, 37 | }, 38 | drawer: { 39 | '& > .MuiDrawer-paper': { 40 | zIndex: theme.zIndex.drawer - 1, 41 | }, 42 | '& > .MuiDrawer-paperAnchorRight': { 43 | top: 73, 44 | }, 45 | }, 46 | })) 47 | 48 | export const StyledListItem = withStyles((theme) => ({ 49 | root: { 50 | width: 48, 51 | height: 48, 52 | 53 | backgroundColor: 'white', 54 | borderRadius: 8, 55 | justifyContent: 'center', 56 | 57 | '&:hover': { 58 | backgroundColor: '#d6e4f5', 59 | '& img': { 60 | filter: 'brightness(10)', 61 | }, 62 | }, 63 | }, 64 | }))(ListItem) 65 | 66 | export default useStyles 67 | -------------------------------------------------------------------------------- /src/main/pages/Home/CategoryList/AddCategoryModal/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | 3 | import { Button } from '@mui/material' 4 | import clsx from 'clsx' 5 | import { useDispatch } from 'react-redux' 6 | 7 | import { categoriesRead, categoryCreateThunk } from '@modules/category' 8 | import { useToast } from '@modules/ui' 9 | import { GAEvent } from '@utils/ga' 10 | 11 | import { StyledDialog, StyledDialogTitle, StyledDialogContent, StyledDialogActions, useStyles } from './style' 12 | 13 | function AddCategoryModal({ open, onClose }) { 14 | const dispatch = useDispatch() 15 | const { openToast } = useToast() 16 | const classes = useStyles() 17 | const [categoryName, setCategoryName] = useState('') 18 | const [addLoading, setAddLoading] = useState(false) 19 | 20 | const handleClickConfirm = async () => { 21 | setAddLoading(true) 22 | if (addLoading || !categoryName) return 23 | try { 24 | await dispatch(categoryCreateThunk({ name: categoryName, is_favorited: false })) 25 | dispatch(categoriesRead.request(undefined, { selectFirstCategory: true })) 26 | setAddLoading(false) 27 | setCategoryName('') 28 | GAEvent('카테고리', '카테고리 생성 완료') 29 | onClose() 30 | } catch (error) { 31 | openToast({ type: 'error', message: error?.response?.data?.message || '네트워크 오류!!' }) 32 | } 33 | } 34 | const handleChangeInput = (e) => { 35 | if (e.target.value.length > 24) return 36 | setCategoryName((prev) => (prev = e.target.value)) 37 | } 38 | const handleKeyUpEnter = (e) => { 39 | e.stopPropagation() 40 | if (e.key === 'Enter') handleClickConfirm() 41 | } 42 | const handleClickCancel = () => { 43 | setCategoryName('') 44 | onClose() 45 | GAEvent('카테고리', '카테고리 생성 취소') 46 | } 47 | 48 | return ( 49 | 50 | 카테고리 생성 51 | 52 | 61 | 62 | 63 | 66 | 69 | 70 | 71 | ) 72 | } 73 | 74 | export default AddCategoryModal 75 | -------------------------------------------------------------------------------- /src/main/pages/Home/CategoryList/AddCategoryModal/style.js: -------------------------------------------------------------------------------- 1 | import { Dialog, DialogTitle, DialogActions, DialogContent } from '@mui/material' 2 | import { withStyles } from '@mui/styles' 3 | import { makeStyles } from '@mui/styles' 4 | 5 | export const StyledDialog = withStyles((theme) => ({ 6 | paperWidthSm: { 7 | padding: '36px 48px 40px', 8 | width: 508, 9 | height: 315, 10 | boxShadow: '8px 8px 24px rgba(0, 0, 0, 0.12)', 11 | borderRadius: 8, 12 | justifyContent: 'space-between', 13 | }, 14 | }))(Dialog) 15 | 16 | export const StyledDialogTitle = withStyles((theme) => ({ 17 | root: { fontWeight: 'bold', padding: 0, fontSize: 24 }, 18 | }))(DialogTitle) 19 | 20 | export const StyledDialogContent = withStyles((theme) => ({ 21 | root: { padding: 0, display: 'inline-block', flex: '0 1 auto' }, 22 | }))(DialogContent) 23 | 24 | export const StyledDialogActions = withStyles((theme) => ({ 25 | root: { 26 | display: 'flex', 27 | flex: '0 0 auto', 28 | padding: 0, 29 | alignItems: 'center', 30 | justifyContent: 'flex-end', 31 | }, 32 | }))(DialogActions) 33 | 34 | export const useStyles = makeStyles((theme) => ({ 35 | categoryNameInput: { 36 | width: '100%', 37 | height: 59, 38 | background: '#FCFCFC', 39 | border: '1px solid #AAAAAA', 40 | borderRadius: 8, 41 | padding: 16, 42 | fontSize: 18, 43 | color: '#333', 44 | '&::placeholder': { 45 | color: '#999', 46 | }, 47 | '&:focus': { 48 | border: `1px solid ${theme.palette.primary.main}`, 49 | outline: 'none', 50 | }, 51 | }, 52 | modalButton: { 53 | width: 86, 54 | height: 48, 55 | color: '#666666', 56 | background: '#fff', 57 | border: '1px solid #E6E6E6', 58 | borderRadius: 8, 59 | 60 | '&.confirm': { 61 | backgroundColor: theme.palette.primary.main, 62 | color: '#fff', 63 | fontWeight: 'bold', 64 | }, 65 | }, 66 | })) 67 | -------------------------------------------------------------------------------- /src/main/pages/Home/CategoryList/CategoryHeader/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import useStyles from './style' 4 | 5 | function CategoryHeader({ type }) { 6 | const classes = useStyles() 7 | 8 | return
{type === 'favorite' ? 'Favorite' : 'Category'}
9 | } 10 | 11 | export default React.memo(CategoryHeader) 12 | -------------------------------------------------------------------------------- /src/main/pages/Home/CategoryList/CategoryHeader/style.js: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@mui/styles' 2 | 3 | const useStyles = makeStyles((theme) => ({ 4 | categoryHeader: { 5 | display: 'flex', 6 | justifyContent: 'space-between', 7 | alignItems: 'center', 8 | fontSize: 14, 9 | color: '#333', 10 | lineHeight: '32px', 11 | }, 12 | })) 13 | 14 | export default useStyles 15 | -------------------------------------------------------------------------------- /src/main/pages/Home/CategoryList/CategoryItem/style.js: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@mui/styles' 2 | 3 | const useStyles = makeStyles((theme) => ({ 4 | tabContainer: { 5 | position: 'relative', 6 | height: 40, 7 | display: 'block', 8 | borderRadius: 4, 9 | margin: '4px auto', 10 | outline: 'none', 11 | cursor: 'pointer', 12 | backgroundColor: ({ hovered, selected }) => (hovered || selected ? '#E8F1FF' : 'transparent'), 13 | '&:hover': { 14 | backgroundColor: ({ selected }) => (selected ? '#E8F1FF' : '#F2F2F2'), 15 | }, 16 | }, 17 | tab: { 18 | borderRadius: 4, 19 | width: '100%', 20 | padding: '0 16px', 21 | height: '100%', 22 | display: 'flex', 23 | alignItems: 'center', 24 | justifyContent: 'space-between', 25 | backgroundColor: ({ moreOpen, selected }) => 26 | moreOpen && selected ? '#E8F1FF' : moreOpen && !selected ? '#F2F2F2' : 'transparent', 27 | '& > .show-btn-group': { 28 | visibility: ({ moreOpen }) => (moreOpen ? 'visible' : 'hidden'), 29 | }, 30 | '&:hover': { 31 | '& > .show-btn-group': { 32 | visibility: 'visible', 33 | }, 34 | '& > .link-container': { 35 | visibility: 'hidden', 36 | }, 37 | }, 38 | }, 39 | dragFinished: { 40 | opacity: '80%', 41 | fontWeight: 'bold', 42 | }, 43 | title: { 44 | fontSize: 14, 45 | width: 'calc(100% - 60px)', 46 | fontWeight: ({ selected }) => (selected ? 700 : 400), 47 | lineHeight: '32px', 48 | color: ({ selected }) => (selected ? '#333' : '#666'), 49 | textOverflow: 'ellipsis', 50 | overflow: 'hidden', 51 | whiteSpace: 'nowrap', 52 | }, 53 | urlCount: { 54 | display: 'inline-block', 55 | fontSize: 12, 56 | lineHeight: '32px', 57 | color: '#999', 58 | opacity: 0.6, 59 | textAlign: 'center', 60 | }, 61 | link: { 62 | display: 'flex', 63 | alignItems: 'center', 64 | justifyContent: 'flex-end', 65 | visibility: ({ moreOpen }) => (moreOpen ? 'hidden' : 'visible'), 66 | }, 67 | btnGroup: { 68 | position: 'absolute', 69 | padding: '8px 16px 8px 0', 70 | top: 0, 71 | right: 0, 72 | display: 'flex', 73 | borderRadius: 4, 74 | 75 | '& > button': { 76 | display: 'flex', 77 | flexDirection: 'column', 78 | justifyContent: 'center', 79 | alignItems: 'center', 80 | width: 24, 81 | height: 24, 82 | backgroundColor: '#fff', 83 | borderRadius: 4, 84 | padding: 0, 85 | border: 'unset', 86 | marginLeft: 8, 87 | cursor: 'pointer', 88 | }, 89 | }, 90 | moreBtnGroup: { 91 | display: 'flex', 92 | flexDirection: 'column', 93 | alignItems: 'flex-start', 94 | justifyContent: 'space-between', 95 | padding: 12, 96 | position: 'fixed', 97 | backgroundColor: '#fff', 98 | boxShadow: '8px 8px 10px rgba(0, 0, 0, 0.08)', 99 | borderRadius: 8, 100 | zIndex: 10, 101 | width: 107, 102 | height: 77, 103 | 104 | '& > button': { 105 | fontSize: 14, 106 | lineHeight: '20px', 107 | color: '#666', 108 | backgroundColor: 'transparent', 109 | border: 'unset', 110 | width: '100%', 111 | textAlign: 'left', 112 | cursor: 'pointer', 113 | 114 | '&:hover': { 115 | fontWeight: '900', 116 | }, 117 | }, 118 | }, 119 | })) 120 | 121 | export default useStyles 122 | -------------------------------------------------------------------------------- /src/main/pages/Home/CategoryList/CategoryItemWrapper/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react' 2 | 3 | import { DRAG } from '@modules/ui' 4 | 5 | import useStyles from './style' 6 | const { CATEGORY } = DRAG 7 | 8 | function CategoryItemWrapper({ 9 | data, 10 | handleDragStart, 11 | handleDragOver, 12 | handleDragLeave, 13 | handleDragDrop, 14 | handleDragEnd, 15 | children, 16 | }) { 17 | const classes = useStyles() 18 | 19 | const dragLineRef = useRef(null) 20 | const categoryRef = useRef(null) 21 | 22 | return ( 23 | 24 |
25 |
35 | {children} 36 |
37 | 38 | ) 39 | } 40 | 41 | export default React.memo(CategoryItemWrapper) 42 | -------------------------------------------------------------------------------- /src/main/pages/Home/CategoryList/CategoryItemWrapper/style.js: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@mui/styles' 2 | 3 | const useStyles = makeStyles((theme) => ({ 4 | dragline: { 5 | width: 208, 6 | height: 2, 7 | borderRadius: 2, 8 | backgroundImage: 'linear-gradient(271deg, #e0f6ff, #2083ff)', 9 | opacity: 0, 10 | margin: 'auto', 11 | }, 12 | })) 13 | 14 | export default useStyles 15 | -------------------------------------------------------------------------------- /src/main/pages/Home/CategoryList/FirstCategoryDropZone/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import useStyles from './style' 4 | 5 | function FirstCategoryDropZone({ openDropZone, handleDragDrop, handleDragOverFirstCategory }) { 6 | const classes = useStyles() 7 | 8 | return ( 9 |
15 |

카테고리를 생성해주세요.

16 |
17 | ) 18 | } 19 | 20 | export default FirstCategoryDropZone 21 | -------------------------------------------------------------------------------- /src/main/pages/Home/CategoryList/FirstCategoryDropZone/style.js: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@mui/styles' 2 | 3 | const useStyles = makeStyles((theme) => ({ 4 | hidden: { 5 | display: 'none', 6 | }, 7 | categoryDropZone: { 8 | width: '100%', 9 | height: '300px', 10 | }, 11 | title: { 12 | borderRadius: '4px', 13 | border: 'dashed 1px #CCCCCC', 14 | backgroundColor: '#FCFCFC', 15 | fontSize: 14, 16 | fontWeight: 400, 17 | fontStretch: 'normal', 18 | fontStyle: 'normal', 19 | lineHeight: '38px', 20 | letterSpacing: 'normal', 21 | textAlign: 'center', 22 | color: '#CCCCCC', 23 | margin: '10px auto', 24 | }, 25 | })) 26 | 27 | export default useStyles 28 | -------------------------------------------------------------------------------- /src/main/pages/Home/CategoryList/FirstFavoriteDropZone/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import useStyles from './style' 4 | 5 | function FirstFavoriteDropZone({ handleDragDrop, handleDragOverFirstFavorite }) { 6 | const classes = useStyles() 7 | 8 | return ( 9 |
15 |

자주 사용하는 카테고리 등록

16 |
17 | ) 18 | } 19 | 20 | export default FirstFavoriteDropZone 21 | -------------------------------------------------------------------------------- /src/main/pages/Home/CategoryList/FirstFavoriteDropZone/style.js: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@mui/styles' 2 | 3 | const useStyles = makeStyles((theme) => ({ 4 | firstFavoriteDropZone: { 5 | width: '100%', 6 | height: '90px', 7 | }, 8 | title: { 9 | borderRadius: '4px', 10 | border: 'dashed 1px #CCCCCC', 11 | backgroundColor: '#FCFCFC', 12 | fontSize: 14, 13 | fontWeight: 400, 14 | fontStretch: 'normal', 15 | fontStyle: 'normal', 16 | lineHeight: '38px', 17 | letterSpacing: 'normal', 18 | textAlign: 'center', 19 | color: '#CCCCCC', 20 | margin: '10px auto', 21 | }, 22 | })) 23 | 24 | export default useStyles 25 | -------------------------------------------------------------------------------- /src/main/pages/Home/CategoryList/UpdateCategoryModal/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react' 2 | 3 | import { Button } from '@mui/material' 4 | import clsx from 'clsx' 5 | import { useSelector, useDispatch } from 'react-redux' 6 | 7 | import { 8 | categoryEdit, 9 | categoryClearEdit, 10 | categorySelect, 11 | categorySelector, 12 | categoryModifyThunk, 13 | categoriesReadThunk, 14 | } from '@modules/category' 15 | import { useToast } from '@modules/ui' 16 | import { GAEvent } from '@utils/ga' 17 | 18 | import { StyledDialog, StyledDialogTitle, StyledDialogContent, StyledDialogActions, useStyles } from './style' 19 | 20 | function UpdateCategoryModal({ open, onClose }) { 21 | const dispatch = useDispatch() 22 | const editedCategory = useSelector(categorySelector.editedCategory) 23 | const { openToast } = useToast() 24 | const inputRef = useRef() 25 | const classes = useStyles() 26 | const [categoryName, setCategoryName] = useState(editedCategory.name) 27 | const [updateLoading, setUpdateLoading] = useState(false) 28 | 29 | const handleConfirm = async () => { 30 | setUpdateLoading(true) 31 | if (updateLoading || !categoryName) return 32 | try { 33 | const response = await dispatch( 34 | categoryModifyThunk({ 35 | id: editedCategory.id, 36 | name: inputRef.current.value, 37 | }) 38 | ) 39 | await dispatch(categoriesReadThunk()) 40 | dispatch(categorySelect({ ...response })) 41 | dispatch(categoryClearEdit()) 42 | setUpdateLoading(false) 43 | GAEvent('메인', '카테고리 제목 수정 완료') 44 | onClose() 45 | } catch (error) { 46 | openToast({ type: 'error', message: error?.response?.data?.message || '네트워크 오류!!' }) 47 | } 48 | } 49 | const handleChangeInput = (e) => { 50 | if (e.target.value.length > 24) return 51 | setCategoryName((prev) => (prev = e.target.value)) 52 | dispatch(categoryEdit({ name: e.target.value })) 53 | } 54 | const handleKeyUpEnter = (e) => { 55 | e.stopPropagation() 56 | if (e.key === 'Enter') handleConfirm() 57 | } 58 | const handleCancel = () => { 59 | dispatch(categoryClearEdit()) 60 | onClose() 61 | GAEvent('메인', '카테고리 제목 수정 취소') 62 | } 63 | 64 | return ( 65 | 66 | 카테고리 수정 67 | 68 | 78 | 79 | 80 | 83 | 86 | 87 | 88 | ) 89 | } 90 | 91 | export default UpdateCategoryModal 92 | -------------------------------------------------------------------------------- /src/main/pages/Home/CategoryList/UpdateCategoryModal/style.js: -------------------------------------------------------------------------------- 1 | import { Dialog, DialogTitle, DialogActions, DialogContent } from '@mui/material' 2 | import { withStyles } from '@mui/styles' 3 | import { makeStyles } from '@mui/styles' 4 | 5 | export const StyledDialog = withStyles((theme) => ({ 6 | paperWidthSm: { 7 | padding: '36px 48px 40px', 8 | width: 508, 9 | height: 315, 10 | boxShadow: '8px 8px 24px rgba(0, 0, 0, 0.12)', 11 | borderRadius: 8, 12 | justifyContent: 'space-between', 13 | }, 14 | }))(Dialog) 15 | 16 | export const StyledDialogTitle = withStyles((theme) => ({ 17 | root: { fontWeight: 'bold', padding: 0, fontSize: 24 }, 18 | }))(DialogTitle) 19 | 20 | export const StyledDialogContent = withStyles((theme) => ({ 21 | root: { padding: 0, display: 'inline-block', flex: '0 1 auto' }, 22 | }))(DialogContent) 23 | 24 | export const StyledDialogActions = withStyles((theme) => ({ 25 | root: { 26 | display: 'flex', 27 | flex: '0 0 auto', 28 | padding: 0, 29 | alignItems: 'center', 30 | justifyContent: 'flex-end', 31 | }, 32 | }))(DialogActions) 33 | 34 | export const useStyles = makeStyles((theme) => ({ 35 | categoryNameInput: { 36 | width: '100%', 37 | height: 59, 38 | background: '#FCFCFC', 39 | border: '1px solid #AAAAAA', 40 | borderRadius: 8, 41 | padding: 16, 42 | fontSize: 18, 43 | color: '#333', 44 | '&::placeholder': { 45 | color: '#999', 46 | }, 47 | '&:focus': { 48 | border: `1px solid ${theme.palette.primary.main}`, 49 | outline: 'none', 50 | }, 51 | }, 52 | modalButton: { 53 | width: 86, 54 | height: 48, 55 | color: '#666666', 56 | background: '#fff', 57 | border: '1px solid #E6E6E6', 58 | borderRadius: 8, 59 | 60 | '&.confirm': { 61 | backgroundColor: theme.palette.primary.main, 62 | color: '#fff', 63 | fontWeight: 'bold', 64 | }, 65 | }, 66 | })) 67 | -------------------------------------------------------------------------------- /src/main/pages/Home/CategoryList/style.js: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@mui/styles' 2 | 3 | const useStyles = makeStyles(() => ({ 4 | logo: { 5 | width: 95, 6 | margin: '24px 0 28px', 7 | }, 8 | drawerPaper: { 9 | width: '100%', 10 | height: '100%', 11 | padding: '0 28px', 12 | }, 13 | categoryContainer: { 14 | width: '100%', 15 | height: 'calc(100% - 200px)', 16 | scrollbarWidth: 'none', 17 | '&::-webkit-scrollbar': { 18 | display: 'none', 19 | }, 20 | overflowY: 'scroll', 21 | borderTop: ({ observerVisible }) => (observerVisible ? 'unset' : '1px solid #C0C0C0'), 22 | }, 23 | ioBox: { 24 | width: '100%', 25 | height: 52, 26 | }, 27 | flexCenterBackground: { 28 | display: 'flex', 29 | flexDirection: 'column', 30 | justifyContent: 'center', 31 | alignItems: 'center', 32 | height: '100vh', 33 | }, 34 | favoriteList: { 35 | padding: 0, 36 | marginBottom: 52, 37 | }, 38 | notFavoriteList: {}, 39 | addCategoryBtn: { 40 | height: '120px', 41 | width: '100%', 42 | borderTop: '1px solid #C0C0C0', 43 | display: 'flex', 44 | flexDirection: 'column', 45 | justifyContent: 'center', 46 | alignItems: 'center', 47 | backgroundColor: 'transparent', 48 | border: 'unset', 49 | borderRadius: 'unset', 50 | 51 | '& > h3': { 52 | fontSize: 16, 53 | fontWeight: 400, 54 | lineHeight: '32px', 55 | color: '#666', 56 | padding: '4px 16px', 57 | borderRadius: 8, 58 | justifyContent: 'space-between', 59 | display: 'flex', 60 | width: '100%', 61 | }, 62 | 63 | '&:hover': { 64 | cursor: 'pointer', 65 | backgroundColor: 'transparent', 66 | '& > h3': { 67 | backgroundColor: '#F2F2F2', 68 | }, 69 | }, 70 | '&:focus-visible': { 71 | outline: 'unset', 72 | }, 73 | }, 74 | })) 75 | 76 | export default useStyles 77 | -------------------------------------------------------------------------------- /src/main/pages/Home/LinkDropZone/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react' 2 | 3 | import { AddToPhotos as AddToPhotosIcon } from '@mui/icons-material' 4 | import clsx from 'clsx' 5 | import { useDispatch, useSelector } from 'react-redux' 6 | 7 | import { categoriesRead, categorySelector } from '@modules/category' 8 | import { linkCreateThunk, linksRead } from '@modules/link' 9 | import { useToast } from '@modules/ui' 10 | import { DROP_ZONE, DRAG, useDrag, useDropZone } from '@modules/ui' 11 | 12 | import useStyles from './style' 13 | 14 | const { LINK } = DRAG 15 | const { LINK_DROP_ZONE } = DROP_ZONE 16 | 17 | function LinkDropZone() { 18 | const classes = useStyles() 19 | const dispatch = useDispatch() 20 | const { openToast } = useToast() 21 | const categoryList = useSelector(categorySelector.listData) 22 | const selectedCategory = useSelector(categorySelector.selectedCategory) 23 | const { listData, clearDragData } = useDrag(LINK) 24 | const { open } = useDropZone(LINK_DROP_ZONE) 25 | 26 | const handleDropOnCardArea = useCallback( 27 | async (e) => { 28 | try { 29 | e.stopPropagation() 30 | if (!categoryList.length) { 31 | openToast({ type: 'error', message: '카테고리를 생성해주세요.' }) 32 | return 33 | } 34 | if (!selectedCategory.id) { 35 | openToast({ type: 'error', message: '링크를 저장할 카테고리를 만들거나 선택해주세요.' }) 36 | return 37 | } 38 | const path = listData.reduce((prev, data) => prev.concat(data.path), []) 39 | await dispatch(linkCreateThunk({ categoryId: selectedCategory.id, path })) 40 | clearDragData() 41 | dispatch(linksRead.request({ categoryId: selectedCategory.id }, { key: selectedCategory.id })) 42 | dispatch(categoriesRead.request()) 43 | openToast({ type: 'success', message: '링크가 저장 되었습니다.' }) 44 | } catch (error) { 45 | openToast({ type: 'error', message: error?.response?.data?.message || '네트워크 오류!!' }) 46 | } 47 | }, 48 | [dispatch, listData, openToast, selectedCategory.id, clearDragData, categoryList] 49 | ) 50 | 51 | const handleDragOverOnCardArea = useCallback((e) => { 52 | e.preventDefault() 53 | }, []) 54 | 55 | return ( 56 |
63 | 64 |
65 | ) 66 | } 67 | 68 | export default LinkDropZone 69 | -------------------------------------------------------------------------------- /src/main/pages/Home/LinkDropZone/style.js: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@mui/styles' 2 | 3 | const useStyles = makeStyles((theme) => ({ 4 | coverBackground: { 5 | position: 'absolute', 6 | zIndex: 1, 7 | 8 | display: 'flex', 9 | justifyContent: 'center', 10 | alignItems: 'center', 11 | 12 | width: 'calc(100% - 917px)', 13 | height: 'calc(100vh - 72px);', 14 | 15 | backgroundColor: 'rgba(53, 142, 255, 0.15)', 16 | opacity: 1, 17 | }, 18 | displayNone: { 19 | display: 'none', 20 | }, 21 | addLinkIcon: { 22 | width: 50, 23 | height: 50, 24 | 25 | color: theme.palette.primary.main, 26 | }, 27 | })) 28 | 29 | export default useStyles 30 | -------------------------------------------------------------------------------- /src/main/pages/Home/LinkList/Header/EditableCategoryTitle/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import CreateIcon from '@mui/icons-material/Create' 4 | import IconButton from '@mui/material/IconButton' 5 | import Typography from '@mui/material/Typography' 6 | import clsx from 'clsx' 7 | import { useSelector, useDispatch } from 'react-redux' 8 | 9 | import { MODAL_NAME, uiSelector, useDialog } from '@/modules/ui' 10 | import { categoryEdit, categorySelector } from '@modules/category' 11 | import { GAEvent } from '@utils/ga' 12 | 13 | import useStyles from './style' 14 | 15 | function EditableCategoryTitle() { 16 | const classes = useStyles() 17 | 18 | const dispatch = useDispatch() 19 | const category = useSelector(categorySelector.selectedCategory) 20 | const isAppBarInversion = useSelector(uiSelector.isAppBarInversion) 21 | 22 | const { toggle: updateCategoryToggle } = useDialog(MODAL_NAME.UPDATE_CATEGORY_MODAL) 23 | 24 | const handleClickChangeName = (e) => { 25 | e.stopPropagation() 26 | updateCategoryToggle() 27 | dispatch(categoryEdit({ id: category?.id, name: category?.name })) 28 | GAEvent('메인', '카테고리 제목 수정 버튼 클릭') 29 | } 30 | 31 | if (!category.id) return null 32 | 33 | return ( 34 |
35 | 40 | {category?.name} 41 | 42 | 47 | 48 | 49 |
50 | ) 51 | } 52 | 53 | export default EditableCategoryTitle 54 | -------------------------------------------------------------------------------- /src/main/pages/Home/LinkList/Header/EditableCategoryTitle/style.js: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@mui/styles' 2 | 3 | export const useStyles = makeStyles((theme) => ({ 4 | root: { 5 | display: 'flex', 6 | alignItems: 'center', 7 | justifyContent: 'space-between', 8 | flexShrink: 0, 9 | maxWidth: 499, 10 | borderRadius: 8, 11 | }, 12 | title: { 13 | fontWeight: 700, 14 | fontSize: 28, 15 | color: '#333', 16 | }, 17 | titleInversion: { 18 | fontSize: 16, 19 | }, 20 | updateBtn: { 21 | marginLeft: 10, 22 | }, 23 | updateBtnInversion: { 24 | opacity: 0, 25 | }, 26 | confirmBtn: { 27 | width: 74, 28 | height: 38, 29 | backgroundColor: theme.palette.primary.main, 30 | borderRadius: 8, 31 | color: '#fff', 32 | fontWeight: 400, 33 | fontSize: 14, 34 | border: 'unset', 35 | cursor: 'pointer', 36 | }, 37 | })) 38 | 39 | export default useStyles 40 | -------------------------------------------------------------------------------- /src/main/pages/Home/LinkList/Header/style.js: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@mui/styles' 2 | 3 | export const useStyles = makeStyles((theme) => ({ 4 | toolbar: { 5 | paddingLeft: 0, 6 | paddingRight: 0, 7 | 8 | display: 'flex', 9 | justifyContent: 'space-between', 10 | 11 | [theme.breakpoints.up('sm')]: { 12 | height: 40, 13 | }, 14 | }, 15 | selectLinksBtn: { 16 | padding: '8px 20px', 17 | color: '#666666', 18 | backgroundColor: '#ffffff', 19 | border: '1px solid #E6E6E6', 20 | borderRadius: 8, 21 | }, 22 | selectLinksBtnGroup: { 23 | display: 'flex', 24 | flexDirection: 'row', 25 | alignItems: 'center', 26 | fontSize: 14, 27 | marginLeft: 'auto', 28 | }, 29 | selectBoxInversion: { 30 | opacity: 0, 31 | display: 'none', 32 | }, 33 | chosenLinks: { 34 | marginRight: 8, 35 | }, 36 | btnInBtnGroup: { 37 | padding: '8px 20px', 38 | marginLeft: 8, 39 | backgroundColor: '#EDF0FF', 40 | borderRadius: 8, 41 | }, 42 | deleteLinksBtn: { 43 | color: 'red', 44 | padding: '8px 20px', 45 | marginLeft: 8, 46 | backgroundColor: '#FFEDE9', 47 | borderRadius: 8, 48 | }, 49 | refreshBtn: { 50 | padding: 6, 51 | }, 52 | })) 53 | 54 | export default useStyles 55 | -------------------------------------------------------------------------------- /src/main/pages/Home/LinkList/Link/style.js: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@mui/styles' 2 | 3 | const useStyles = makeStyles((theme) => ({ 4 | backdrop: { 5 | position: 'absolute', 6 | backgroundColor: 'rgba(0, 0, 0, 0.2)', 7 | }, 8 | root: { 9 | position: 'relative', 10 | width: 300, 11 | height: 348, 12 | marginTop: 20, 13 | boxShadow: 'none', 14 | borderRadius: 8, 15 | 16 | '&:hover': { 17 | transform: 'translateY(-12px)', 18 | transition: 'transform 0.5s', 19 | cursor: 'pointer', 20 | }, 21 | }, 22 | selectedBackdrop: { 23 | position: 'absolute', 24 | backgroundColor: 'rgba(29, 120, 255, 0.1)', 25 | }, 26 | checkbox: { 27 | position: 'absolute', 28 | top: 0, 29 | right: 0, 30 | color: 'white', 31 | '&.Mui-checked': { 32 | color: 'white', 33 | 34 | '& > .MuiSvgIcon-root': { 35 | backgroundColor: '#1D78FF', 36 | borderRadius: 4, 37 | }, 38 | }, 39 | '& > .MuiSvgIcon-root': { 40 | backgroundColor: 'rgba(129, 147, 174, 0.4)', 41 | borderRadius: 4, 42 | }, 43 | }, 44 | urlBox: { 45 | display: 'flex', 46 | alignItems: 'center', 47 | }, 48 | urlFavicon: { 49 | paddingBottom: 8, 50 | }, 51 | urlSubFont: { 52 | color: theme.palette.text.secondary, 53 | fontSize: 12, 54 | fontWeight: 400, 55 | letterSpacing: -0.6, 56 | paddingLeft: 8, 57 | paddingBottom: 8, 58 | }, 59 | newTabIcon: { 60 | position: 'absolute', 61 | top: 5, 62 | right: 5, 63 | padding: 0, 64 | }, 65 | cardContent: { 66 | height: 182, 67 | padding: '12px 16px 0 16px', 68 | }, 69 | contentTitleEditable: { 70 | width: '100%', 71 | overflow: 'hidden', 72 | fontSize: 16, 73 | padding: '4px 8px', 74 | marginBottom: 4, 75 | backgroundColor: '#F6F6F6', 76 | borderRadius: 4, 77 | }, 78 | contentDescEditable: { 79 | display: 'box', 80 | boxOrient: 'vertical', 81 | overflow: 'hidden', 82 | width: '100%', 83 | maxHeight: 65, 84 | fontSize: 14, 85 | whiteSpace: 'pre-line', 86 | lineClamp: 3, 87 | padding: '4px 8px', 88 | backgroundColor: '#F6F6F6', 89 | borderRadius: 4, 90 | }, 91 | contentTitle: { 92 | width: '100%', 93 | overflow: 'hidden', 94 | display: 'box', 95 | boxOrient: 'vertical', 96 | lineClamp: 2, 97 | maxHeight: 50, 98 | fontWeight: 400, 99 | fontSize: 16, 100 | marginBottom: 8, 101 | }, 102 | contentDesc: { 103 | display: 'box', 104 | boxOrient: 'vertical', 105 | overflow: 'hidden', 106 | width: '100%', 107 | maxHeight: 65, 108 | fontWeight: 300, 109 | fontSize: 13, 110 | whiteSpace: 'pre-line', 111 | lineClamp: 3, 112 | }, 113 | cardActions: { 114 | position: 'absolute', 115 | bottom: 0, 116 | width: '100%', 117 | padding: '0 16px 16px 16px', 118 | }, 119 | copyIcon: { 120 | width: 18, 121 | height: 18, 122 | }, 123 | doneIcon: { 124 | marginLeft: 'auto', 125 | }, 126 | unionIcon: { 127 | width: 30, 128 | height: 30, 129 | marginLeft: 'auto', 130 | }, 131 | menuIconImg: { 132 | width: 30, 133 | height: 20, 134 | paddingRight: 10, 135 | }, 136 | alarmIconActive: { 137 | color: '#fdd835', 138 | }, 139 | dateTimePicker: { 140 | display: 'none', 141 | }, 142 | })) 143 | 144 | export default useStyles 145 | -------------------------------------------------------------------------------- /src/main/pages/Home/LinkList/LinkSkeleton/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import Card from '@mui/material/Card' 4 | import CardContent from '@mui/material/CardContent' 5 | import Skeleton from '@mui/material/Skeleton' 6 | 7 | import useStyles from './style' 8 | 9 | function LinkSkeleton() { 10 | const classes = useStyles() 11 | 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | ) 23 | } 24 | 25 | export default LinkSkeleton 26 | -------------------------------------------------------------------------------- /src/main/pages/Home/LinkList/LinkSkeleton/style.js: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@mui/styles' 2 | 3 | const useStyles = makeStyles((theme) => ({ 4 | card: { 5 | width: 300, 6 | height: 348, 7 | marginTop: 20, 8 | boxShadow: 'none', 9 | }, 10 | cardContent: { 11 | padding: '12px 16px', 12 | '& span': { 13 | transform: 'scale(1.0)', 14 | }, 15 | }, 16 | })) 17 | 18 | export default useStyles 19 | -------------------------------------------------------------------------------- /src/main/pages/Home/LinkList/style.js: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@mui/styles' 2 | 3 | export const useStyles = makeStyles((theme) => ({ 4 | root: { 5 | display: 'flex', 6 | flexDirection: 'column', 7 | justifyContent: 'center', 8 | alignItems: 'center', 9 | height: 'max-content', 10 | [theme.breakpoints.up('sm')]: { 11 | width: '100%', 12 | }, 13 | [theme.breakpoints.up('md')]: { 14 | minWidth: 748, 15 | }, 16 | [theme.breakpoints.up('lg')]: { 17 | minWidth: 1084, 18 | }, 19 | [theme.breakpoints.up('xl')]: { 20 | minWidth: 1420, 21 | }, 22 | backgroundColor: 23 | theme.palette.type !== 'dark' ? theme.palette.colorGroup.lightGrey : theme.palette.background.default, 24 | padding: '80px 56px 172px 56px', 25 | }, 26 | header: { 27 | position: 'sticky', 28 | top: 8, 29 | zIndex: 101, 30 | 31 | marginBottom: 32, 32 | [theme.breakpoints.up('sm')]: { 33 | width: '100%', 34 | }, 35 | [theme.breakpoints.up('md')]: { 36 | width: 636, 37 | }, 38 | [theme.breakpoints.up('lg')]: { 39 | width: 972, 40 | }, 41 | [theme.breakpoints.up('xl')]: { 42 | width: 1308, 43 | }, 44 | }, 45 | content: { 46 | display: 'flex', 47 | justifyContent: 'flex-start', 48 | flexFlow: 'row wrap', 49 | gap: 36, 50 | margin: '0', 51 | [theme.breakpoints.up('sm')]: { 52 | width: '100%', 53 | }, 54 | [theme.breakpoints.up('md')]: { 55 | width: 636, 56 | }, 57 | [theme.breakpoints.up('lg')]: { 58 | width: 972, 59 | }, 60 | [theme.breakpoints.up('xl')]: { 61 | width: 1308, 62 | }, 63 | }, 64 | center: { 65 | display: 'flex', 66 | flexDirection: 'column', 67 | alignItems: 'center', 68 | justifyContent: 'center', 69 | width: '100%', 70 | height: 482, 71 | backgroundColor: '#ffffff', 72 | borderRadius: 8, 73 | [theme.breakpoints.up('sm')]: { 74 | width: '100%', 75 | }, 76 | [theme.breakpoints.up('md')]: { 77 | width: 636, 78 | }, 79 | [theme.breakpoints.up('lg')]: { 80 | width: 972, 81 | }, 82 | [theme.breakpoints.up('xl')]: { 83 | width: 1308, 84 | }, 85 | }, 86 | centerFont: { 87 | height: 40, 88 | fontWeight: 400, 89 | fontSize: 20, 90 | color: '#77777B', 91 | }, 92 | centerSubFont: { 93 | height: 25, 94 | fontWeight: 300, 95 | fontSize: 16, 96 | color: '#77777B', 97 | }, 98 | })) 99 | 100 | export default useStyles 101 | -------------------------------------------------------------------------------- /src/main/pages/Home/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useRef, useState } from 'react' 2 | 3 | import { Resizable } from 're-resizable' 4 | import { useDispatch, useSelector } from 'react-redux' 5 | 6 | import ScrollUpButton from '@/main/components/ScrollUpButton' 7 | import { useAlarmNoticeConnection } from '@/modules/alarmNotice' 8 | import { categorySelector } from '@/modules/category' 9 | import { appBarInversionChangeState } from '@/modules/ui' 10 | 11 | import AppBar from './AppBar' 12 | import CategoryList from './CategoryList' 13 | import LinkDropZone from './LinkDropZone' 14 | import LinkList from './LinkList' 15 | import useStyles from './style' 16 | 17 | export default function Home() { 18 | useAlarmNoticeConnection() 19 | 20 | const [resizing, setResizing] = useState(false) 21 | const onResizeStart = () => setResizing(true) 22 | const onResizeStop = () => setResizing(false) 23 | const classes = useStyles({ resizing }) 24 | 25 | const dispatch = useDispatch() 26 | const selectedCategory = useSelector(categorySelector.selectedCategory) 27 | 28 | const mainRef = useRef(null) 29 | 30 | const [isShowScrollUpButton, setIshShowScrollUpButton] = useState(null) 31 | 32 | const handleScrollMain = useCallback( 33 | (e) => { 34 | const scrollTop = e.target.scrollTop 35 | const clientHeight = e.target.clientHeight 36 | dispatch(appBarInversionChangeState(scrollTop > 150)) 37 | setIshShowScrollUpButton(scrollTop + clientHeight / 2 > clientHeight) 38 | }, 39 | [dispatch] 40 | ) 41 | 42 | useEffect(() => { 43 | mainRef.current.scrollTop = 0 44 | }, [selectedCategory]) 45 | 46 | return ( 47 |
48 | 71 | 72 | 73 |
74 | 75 | 76 | 77 | 78 |
79 |
80 | ) 81 | } 82 | -------------------------------------------------------------------------------- /src/main/pages/Home/style.js: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@mui/styles' 2 | 3 | export const useStyles = makeStyles((theme) => ({ 4 | root: { 5 | display: 'flex', 6 | height: '100vh', 7 | backgroundColor: '#fafafa', 8 | 9 | '& > .resizable-category .resize-handler': { 10 | right: '0 !important', 11 | borderRight: ({ resizing }) => (resizing ? '2px solid #E9E9E9' : 'transparent'), 12 | '&:hover': { 13 | borderRight: '2px solid #E9E9E9', 14 | }, 15 | }, 16 | }, 17 | main: { 18 | width: '100%', 19 | height: '100vh', 20 | 21 | overflow: 'scroll', 22 | }, 23 | })) 24 | 25 | export default useStyles 26 | -------------------------------------------------------------------------------- /src/main/pages/Login/LoginForm/GloginButton.jsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react' 2 | 3 | import Button from '@mui/material/Button' 4 | import { useDispatch } from 'react-redux' 5 | 6 | import LogoGoogle from '@assets/images/logo-google.png' 7 | import { useToast } from '@modules/ui' 8 | import { userGloginThunk } from '@modules/user' 9 | import { GAEvent } from '@utils/ga' 10 | 11 | function GloginButton() { 12 | const dispatch = useDispatch() 13 | const { openToast } = useToast() 14 | 15 | const handleGoogleLogin = useCallback( 16 | async (e) => { 17 | e.preventDefault() 18 | try { 19 | GAEvent('로그인', '구글 로그인') 20 | await dispatch(userGloginThunk()) 21 | window.location.href = '/index.html' 22 | } catch (error) { 23 | openToast({ type: 'error', message: error?.response?.data?.message || '네트워크 오류!!' }) 24 | } 25 | }, 26 | [dispatch, openToast] 27 | ) 28 | 29 | return ( 30 | 37 | ) 38 | } 39 | 40 | export default GloginButton 41 | -------------------------------------------------------------------------------- /src/main/pages/Login/LoginForm/NloginForm.jsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react' 2 | 3 | import { yupResolver } from '@hookform/resolvers/yup' 4 | import { Button } from '@mui/material' 5 | import { Link } from 'react-chrome-extension-router' 6 | import { useForm } from 'react-hook-form' 7 | import { useDispatch } from 'react-redux' 8 | import * as yup from 'yup' 9 | 10 | import ValidationMessage from '@main/components/ValidationMessage' 11 | import Register from '@main/pages/Register' 12 | import { useToast } from '@modules/ui' 13 | import { userLoginThunk } from '@modules/user' 14 | import { GAEvent } from '@utils/ga' 15 | 16 | const SCHEMA = yup.object().shape({ 17 | email: yup.string().required('이메일은 필수 입력입니다.'), 18 | password: yup.string().required('비밀번호는 필수 입력입니다.'), 19 | }) 20 | 21 | function NloginForm() { 22 | const dispatch = useDispatch() 23 | const { openToast } = useToast() 24 | const { register, handleSubmit, errors, formState } = useForm({ 25 | defaultValues: { 26 | email: '', 27 | password: '', 28 | }, 29 | mode: 'onChange', 30 | resolver: yupResolver(SCHEMA), 31 | }) 32 | 33 | const handleLogin = useCallback( 34 | async (formData) => { 35 | try { 36 | GAEvent('로그인', '일반 로그인') 37 | await dispatch(userLoginThunk(formData)) 38 | window.location.href = '/index.html' 39 | } catch (error) { 40 | openToast({ type: 'error', message: error.response?.data?.message || '네트워크 오류!!' }) 41 | } 42 | }, 43 | [dispatch, openToast] 44 | ) 45 | 46 | return ( 47 |
48 | {/* 이메일 */} 49 |
50 | 53 | 54 | {!!errors.email && } 55 |
56 | 57 | {/* 비밀번호 */} 58 |
59 | 62 | 70 | {!!errors.password && } 71 |
72 | 73 | {/* 회원가입 | 로그인 */} 74 |
75 | 78 | 79 | 82 | 83 |
84 |
85 | ) 86 | } 87 | 88 | export default NloginForm 89 | -------------------------------------------------------------------------------- /src/main/pages/Login/LoginForm/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import URLinkLogo from '@assets/images/logo-urlink-full.png' 4 | 5 | import GloginButton from './GloginButton' 6 | import NloginForm from './NloginForm' 7 | 8 | function LoginForm() { 9 | return ( 10 |
11 | urLink logo 12 |
로그인
13 | 14 |
15 | OR 16 |
17 | 18 |
19 | ) 20 | } 21 | 22 | export default LoginForm 23 | -------------------------------------------------------------------------------- /src/main/pages/Login/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import '@assets/scss/LoginSignup.scss' 4 | 5 | import LoginForm from './LoginForm' 6 | 7 | function Login() { 8 | return ( 9 |
10 |
11 | 12 |
13 |
14 |
15 | ) 16 | } 17 | 18 | export default Login 19 | -------------------------------------------------------------------------------- /src/main/pages/Register/RegisterForm/GregisterButton.jsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react' 2 | 3 | import Button from '@mui/material/Button' 4 | import { useDispatch } from 'react-redux' 5 | 6 | import LogoGoogle from '@assets/images/logo-google.png' 7 | import { useToast } from '@modules/ui' 8 | import { userGregisterThunk } from '@modules/user' 9 | import { GAEvent } from '@utils/ga' 10 | 11 | function GregisterButton() { 12 | const dispatch = useDispatch() 13 | const { openToast } = useToast() 14 | 15 | const handleGoogleSignup = useCallback( 16 | async (e) => { 17 | e.preventDefault() 18 | try { 19 | GAEvent('회원가입', '구글 회원가입') 20 | await dispatch(userGregisterThunk()) 21 | window.location.href = '/index.html' 22 | } catch (error) { 23 | openToast({ type: 'error', message: error?.response?.data?.message || '네트워크 오류!!' }) 24 | } 25 | }, 26 | [dispatch, openToast] 27 | ) 28 | 29 | return ( 30 | 37 | ) 38 | } 39 | 40 | export default GregisterButton 41 | -------------------------------------------------------------------------------- /src/main/pages/Register/RegisterForm/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import URLinkLogo from '@assets/images/logo-urlink-full.png' 4 | 5 | import GregisterButton from './GregisterButton' 6 | import NregisterForm from './NregisterForm' 7 | 8 | function RegisterForm() { 9 | return ( 10 |
11 | urLink logo 12 |
회원가입
13 | 14 |
15 | OR 16 |
17 | 18 |
19 | ) 20 | } 21 | 22 | export default RegisterForm 23 | -------------------------------------------------------------------------------- /src/main/pages/Register/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import '@assets/scss/LoginSignup.scss' 4 | 5 | import RegisterForm from './RegisterForm' 6 | 7 | function Register() { 8 | return ( 9 |
10 |
11 | 12 |
13 |
14 |
15 | ) 16 | } 17 | 18 | export default Register 19 | -------------------------------------------------------------------------------- /src/main/pages/Start/style.js: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@mui/styles' 2 | 3 | import mainBackground from '@assets/images/mainBackground2.png' 4 | 5 | const useStyles = makeStyles((theme) => ({ 6 | root: { 7 | overflow: 'hidden', 8 | padding: 40, 9 | display: 'flex', 10 | height: '100vh', 11 | flexDirection: 'column', 12 | alignItems: 'center', 13 | justifyContent: 'center', 14 | backgroundImage: `url(${mainBackground}), linear-gradient(to top, #0260d8, #157cff 68%)`, 15 | backgroundSize: '100%', 16 | '&::-webkit-scrollbar': { 17 | display: 'none !important', 18 | }, 19 | }, 20 | titleCenter: { 21 | display: 'flex', 22 | flexDirection: 'column', 23 | alignItems: 'center', 24 | justifyContent: 'center', 25 | width: '100%', 26 | }, 27 | getStartBtn: { 28 | marginTop: '10px', 29 | display: 'flex', 30 | alignItems: 'center', 31 | justifyContent: 'center', 32 | width: '200px', 33 | height: '48px', 34 | borderRadius: '40px', 35 | boxShadow: '0 2px 16px 0 rgba(0, 0, 0, 0.24)', 36 | backgroundColor: '#ffffff', 37 | textDecoration: 'none', 38 | }, 39 | getStartText: { 40 | fontSize: '24px', 41 | fontWeight: 'bold', 42 | }, 43 | textBlack: { 44 | color: '#212529', 45 | }, 46 | textBlue: { 47 | color: '#358eff', 48 | }, 49 | textGrp: { 50 | flexDirection: 'column', 51 | alignItems: 'center', 52 | justifyContent: 'center', 53 | marginTop: 20, 54 | }, 55 | textCenter: { 56 | textAlign: 'center', 57 | height: 29, 58 | 59 | fontSize: 15, 60 | letterSpacing: -0.47, 61 | color: '#ffffff', 62 | }, 63 | textBold: { 64 | fontWeight: 'bold', 65 | }, 66 | imgCenter: { 67 | paddingTop: 20, 68 | display: 'flex', 69 | flexDirection: 'column', 70 | alignItems: 'center', 71 | justifyContent: 'center', 72 | }, 73 | imgAutoSize: { 74 | width: '100%', 75 | }, 76 | })) 77 | 78 | export default useStyles 79 | -------------------------------------------------------------------------------- /src/main/theme.js: -------------------------------------------------------------------------------- 1 | import { createTheme } from '@mui/material/styles' 2 | 3 | const theme = createTheme({ 4 | breakpoints: { 5 | values: { 6 | xs: 0, 7 | sm: 600, 8 | md: 1098, 9 | lg: 1435, 10 | xl: 1770, 11 | }, 12 | }, 13 | palette: { 14 | colorGroup: { 15 | battleshipGrey: '#737b84', 16 | lightGrey: '#fafafa', 17 | blueGrey: '#868e96', 18 | paleGrey: '#f6f7f9', 19 | salmon: '#ff6b6b', 20 | greenBlue: '#00b381', 21 | }, 22 | common: { 23 | black: '#212529', 24 | }, 25 | primary: { 26 | main: '#1D78FF', 27 | }, 28 | text: { 29 | primary: '#333333', 30 | secondary: '#666666', 31 | description: '#999999', 32 | }, 33 | }, 34 | typography: { 35 | fontFamily: ['"Spoqa Han Sans"', '"Spoqa Han Sans JP"', 'sans-serif'].join(','), 36 | }, 37 | components: { 38 | MuiCssBaseline: { 39 | styleOverrides: ` 40 | ::-webkit-scrollbar { 41 | display: none; 42 | } 43 | 44 | *: { 45 | // box-sizing: border-box; 46 | font-family: "Spoqa Han Sans", "Spoqa Han Sans JP", "sans-serif"; 47 | } 48 | 49 | button: { 50 | cursor: pointer; 51 | outline: 0; 52 | border: unset; 53 | background-color: transparent; 54 | } 55 | `, 56 | }, 57 | }, 58 | }) 59 | 60 | export default theme 61 | -------------------------------------------------------------------------------- /src/modules/alarm/api.js: -------------------------------------------------------------------------------- 1 | import { axios } from '@utils/http/client' 2 | import queryFilter from '@utils/http/queryFilter' 3 | 4 | import queryInfoData from './queryInfoData' 5 | 6 | export const requestAlarmsRead = (data = {}) => { 7 | const queryData = queryInfoData['alarmsRead'] 8 | const info = queryFilter({ queryData, originDataInfo: data }) 9 | const urlData = queryFilter({ queryData, queryType: 'urlQuery', originDataInfo: data }) 10 | return axios[queryData.method](queryData.replaceAPI({ ...urlData }), info) 11 | } 12 | 13 | export const requestAlarmRemove = (data = {}) => { 14 | const queryData = queryInfoData['alarmRemove'] 15 | const info = queryFilter({ queryData, originDataInfo: data }) 16 | const urlData = queryFilter({ queryData, queryType: 'urlQuery', originDataInfo: data }) 17 | return axios[queryData.method](queryData.replaceAPI({ ...urlData }), info) 18 | } 19 | 20 | export const requestAlarmCreate = (data = {}) => { 21 | const queryData = queryInfoData['alarmCreate'] 22 | const info = queryFilter({ queryData, originDataInfo: data }) 23 | const urlData = queryFilter({ queryData, queryType: 'urlQuery', originDataInfo: data }) 24 | return axios[queryData.method](queryData.replaceAPI({ ...urlData }), info) 25 | } 26 | -------------------------------------------------------------------------------- /src/modules/alarm/index.js: -------------------------------------------------------------------------------- 1 | export * from './api' 2 | export { default as queryInfoData } from './queryInfoData' 3 | export * from './reducer' 4 | export * from './saga' 5 | -------------------------------------------------------------------------------- /src/modules/alarm/queryInfoData.js: -------------------------------------------------------------------------------- 1 | const queryInfoData = { 2 | /** 3 | * * alarm 리스트 모두 조회 GET 4 | * * alarm/ 5 | * * JWT 필요 6 | */ 7 | alarmsRead: { 8 | API: 'alarm/', 9 | method: 'get', 10 | bodyQuery: {}, 11 | urlQuery: {}, 12 | replaceAPI() { 13 | return this.API 14 | }, 15 | }, 16 | 17 | /** 18 | * * alarm 등록 POST 19 | * * alarm/?category={categoryId}&url={urlId} (all Required) 20 | * * JWT 필요 21 | */ 22 | alarmCreate: { 23 | API: 'alarm/?category={categoryId}&url={urlId}', 24 | method: 'post', 25 | bodyQuery: { 26 | name: '', 27 | reserved_time: { 28 | year: '', 29 | month: '', 30 | day: '', 31 | hour: '', 32 | minute: '', 33 | }, 34 | }, 35 | urlQuery: { 36 | categoryId: '', 37 | urlId: '', 38 | }, 39 | replaceAPI({ categoryId, urlId }) { 40 | if (!categoryId || !urlId) return 41 | return this.API.replace('{categoryId}', categoryId).replace('{urlId}', urlId) 42 | }, 43 | }, 44 | 45 | /** 46 | * * alarm 등록 DELETE 47 | * * alarm/{alarmId}/ (Required) 48 | * * JWT 필요 49 | */ 50 | alarmRemove: { 51 | API: 'alarm/{alarmId}/', 52 | method: 'delete', 53 | bodyQuery: {}, 54 | urlQuery: { 55 | alarmId: '', 56 | }, 57 | replaceAPI({ alarmId }) { 58 | if (!alarmId) return 59 | return this.API.replace('{alarmId}', alarmId) 60 | }, 61 | }, 62 | } 63 | 64 | export default queryInfoData 65 | -------------------------------------------------------------------------------- /src/modules/alarm/reducer.js: -------------------------------------------------------------------------------- 1 | import { createReducer } from '@reduxjs/toolkit' 2 | 3 | import { createRequestAction, createRequestThunk } from '../helpers' 4 | 5 | export const ALARM = 'ALARM' 6 | 7 | export const alarmsRead = createRequestAction(`${ALARM}/READ`) 8 | 9 | export const alarmCreate = createRequestAction(`${ALARM}/CREATE`) 10 | export const alarmCreateThunk = createRequestThunk(alarmCreate) 11 | 12 | export const alarmRemove = createRequestAction(`${ALARM}/REMOVE`) 13 | 14 | // Reducer 15 | const initialState = {} 16 | export const alarmReducer = createReducer(initialState, {}) 17 | -------------------------------------------------------------------------------- /src/modules/alarm/saga.js: -------------------------------------------------------------------------------- 1 | import { call, takeLatest } from 'redux-saga/effects' 2 | 3 | import { createRequestSaga } from '../helpers' 4 | import * as api from './api' 5 | import { alarmsRead, alarmCreate, alarmRemove } from './reducer' 6 | 7 | const watchAlarmsRead = createRequestSaga(alarmsRead, function* () { 8 | const { data } = yield call(api.requestAlarmsRead) 9 | return data 10 | }) 11 | 12 | const watchalarmCreate = createRequestSaga(alarmCreate, function* (action) { 13 | const { data } = yield call(api.requestAlarmCreate, action.payload) 14 | return data 15 | }) 16 | 17 | const watchAlarmRemove = createRequestSaga(alarmRemove, function* (action) { 18 | const { data } = yield call(api.requestAlarmRemove, action.payload) 19 | return data 20 | }) 21 | 22 | export function* alarmSaga() { 23 | yield takeLatest(alarmsRead.REQUEST, watchAlarmsRead) 24 | yield takeLatest(alarmCreate.REQUEST, watchalarmCreate) 25 | yield takeLatest(alarmRemove.REQUEST, watchAlarmRemove) 26 | } 27 | -------------------------------------------------------------------------------- /src/modules/alarmNotice/hooks/useAlarmNoticeConnection.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | 3 | import { useDispatch, useSelector } from 'react-redux' 4 | 5 | import { alarmNoticeConnection } from '@modules/alarmNotice' 6 | import { ERROR } from '@modules/error' 7 | import { PENDING } from '@modules/pending' 8 | import { alarmSocket, SOCKET_READY_STATE } from '@utils/http/ws' 9 | 10 | const useAlarmNoticeConnection = () => { 11 | const dispatch = useDispatch() 12 | const pending = useSelector((state) => state[PENDING][alarmNoticeConnection.TYPE]) 13 | const error = useSelector((state) => state[ERROR][alarmNoticeConnection.TYPE]) 14 | 15 | useEffect(() => { 16 | if (!alarmSocket.ws || alarmSocket.ws?.readyState !== SOCKET_READY_STATE.OPEN) { 17 | alarmSocket 18 | .onConnection() 19 | .setOnmessage((event) => { 20 | dispatch(alarmNoticeConnection.request({ event })) 21 | }) 22 | .setOnerror((event) => { 23 | dispatch(alarmNoticeConnection.failure({ event })) 24 | }) 25 | } 26 | return () => { 27 | alarmSocket.onClose() 28 | } 29 | }, [dispatch]) 30 | 31 | return { pending, error } 32 | } 33 | 34 | export default useAlarmNoticeConnection 35 | -------------------------------------------------------------------------------- /src/modules/alarmNotice/index.js: -------------------------------------------------------------------------------- 1 | export * from './ws' 2 | export { default as useAlarmNoticeConnection } from './hooks/useAlarmNoticeConnection' 3 | export { default as queryInfoData } from './queryInfoData' 4 | export * from './reducer' 5 | export * from './saga' 6 | -------------------------------------------------------------------------------- /src/modules/alarmNotice/queryInfoData.js: -------------------------------------------------------------------------------- 1 | const queryInfoData = { 2 | /** 3 | * * alarm Read SOCKET 4 | * * 알람 봤다고 요청 5 | */ 6 | alarmReadNotice: { 7 | method: 'socket', 8 | bodyQuery: { 9 | alarm_id: '', 10 | action: 'read', 11 | }, 12 | }, 13 | 14 | /** 15 | * * alarm No message return SOCKET 16 | * * 알람에 노출하지 않기로 요청 17 | */ 18 | alarmNoReturn: { 19 | method: 'socket', 20 | bodyQuery: { 21 | alarm_id: '', 22 | action: 'done', 23 | }, 24 | }, 25 | } 26 | 27 | export default queryInfoData 28 | -------------------------------------------------------------------------------- /src/modules/alarmNotice/reducer.js: -------------------------------------------------------------------------------- 1 | import { createReducer } from '@reduxjs/toolkit' 2 | 3 | import { createRequestAction, createRequestThunk } from '../helpers' 4 | 5 | export const ALARM_NOTICE = 'ALARM_NOTICE' 6 | 7 | export const alarmNoticeConnection = createRequestAction(`${ALARM_NOTICE}/CONNECTION`) 8 | export const alarmNoticeListLoad = createRequestAction(`${ALARM_NOTICE}/LIST_LOAD`) 9 | export const alarmNoticeAdd = createRequestAction(`${ALARM_NOTICE}/ADD`) 10 | export const alarmNoticeModify = createRequestAction(`${ALARM_NOTICE}/MODIFY`) 11 | export const alarmNoticeReadNotice = createRequestAction(`${ALARM_NOTICE}/READ_NOTICE`) 12 | export const alarmNoticeReadNoticeThunk = createRequestThunk(alarmNoticeReadNotice) 13 | export const alarmNoticeNoReturnNotice = createRequestAction(`${ALARM_NOTICE}/NO_RETURN_NOTICE`) 14 | export const alarmNoticeNoReturnNoticeThunk = createRequestThunk(alarmNoticeNoReturnNotice) 15 | 16 | // Reducer 17 | const initialState = { 18 | listData: [], 19 | } 20 | export const alarmNoticeReducer = createReducer(initialState, { 21 | [alarmNoticeListLoad.SUCCESS]: (state, { payload: listData }) => { 22 | state.listData = listData 23 | }, 24 | [alarmNoticeAdd.SUCCESS]: (state, { payload: data }) => { 25 | state.listData.push(data) 26 | }, 27 | [alarmNoticeModify.SUCCESS]: (state, { payload: listData }) => { 28 | state.listData = listData 29 | }, 30 | }) 31 | 32 | // Select 33 | export const alarmNoticeSelector = { 34 | listData: (state) => state[ALARM_NOTICE].listData, 35 | } 36 | -------------------------------------------------------------------------------- /src/modules/alarmNotice/saga.js: -------------------------------------------------------------------------------- 1 | import { call, put, takeLatest, takeEvery } from 'redux-saga/effects' 2 | 3 | import { createRequestSaga } from '@modules/helpers' 4 | 5 | import { 6 | alarmNoticeConnection, 7 | alarmNoticeListLoad, 8 | alarmNoticeAdd, 9 | alarmNoticeModify, 10 | alarmNoticeReadNotice, 11 | alarmNoticeNoReturnNotice, 12 | } from './reducer' 13 | import * as ws from './ws' 14 | 15 | const watchAlarmNoticeConnection = createRequestSaga(alarmNoticeConnection, function* ({ payload: { event } }) { 16 | const { message, status } = JSON.parse(event.data) 17 | switch (status) { 18 | case 'initial': 19 | yield put(alarmNoticeListLoad.success(message)) 20 | break 21 | case 'alarm': 22 | yield put(alarmNoticeAdd.success(message)) 23 | break 24 | case 'update': 25 | yield put(alarmNoticeModify.success(message)) 26 | break 27 | default: 28 | break 29 | } 30 | }) 31 | 32 | const watchAlarmNoticeReadNotice = createRequestSaga(alarmNoticeReadNotice, function* (action) { 33 | yield call(ws.requestAlarmReadNotice, action.payload) 34 | }) 35 | 36 | const watchAlarmNoticeNoReturnNotice = createRequestSaga(alarmNoticeNoReturnNotice, function* (action) { 37 | yield call(ws.requestAlarmNoReturn, action.payload) 38 | }) 39 | 40 | export function* alarmNoticeSaga() { 41 | yield takeLatest(alarmNoticeConnection.REQUEST, watchAlarmNoticeConnection) 42 | yield takeEvery(alarmNoticeReadNotice.REQUEST, watchAlarmNoticeReadNotice) 43 | yield takeEvery(alarmNoticeNoReturnNotice.REQUEST, watchAlarmNoticeNoReturnNotice) 44 | } 45 | -------------------------------------------------------------------------------- /src/modules/alarmNotice/ws.js: -------------------------------------------------------------------------------- 1 | import queryFilter from '@utils/http/queryFilter' 2 | import { alarmSocket } from '@utils/http/ws' 3 | 4 | import queryInfoData from './queryInfoData' 5 | 6 | export const requestAlarmReadNotice = (data = {}) => { 7 | const queryData = queryInfoData['alarmReadNotice'] 8 | const info = queryFilter({ queryData, originDataInfo: { ...data, action: 'read' } }) 9 | return alarmSocket.ws.send(JSON.stringify(info)) 10 | } 11 | 12 | export const requestAlarmNoReturn = (data = {}) => { 13 | const queryData = queryInfoData['alarmNoReturn'] 14 | const info = queryFilter({ queryData, originDataInfo: { ...data, action: 'done' } }) 15 | return alarmSocket.ws.send(JSON.stringify(info)) 16 | } 17 | -------------------------------------------------------------------------------- /src/modules/category/api.js: -------------------------------------------------------------------------------- 1 | import { axios } from '@utils/http/client' 2 | import queryFilter from '@utils/http/queryFilter' 3 | 4 | import queryInfoData from './queryInfoData' 5 | 6 | export const requestCategoriesRead = (data = {}) => { 7 | const queryData = queryInfoData['categoriesRead'] 8 | const info = queryFilter({ queryData, originDataInfo: data }) 9 | const urlData = queryFilter({ queryData, queryType: 'urlQuery', originDataInfo: data }) 10 | return axios[queryData.method](queryData.replaceAPI({ ...urlData }), info) 11 | } 12 | 13 | export const requestCategoryCreate = (data = {}) => { 14 | const queryData = queryInfoData['categoryCreate'] 15 | const info = queryFilter({ queryData, originDataInfo: data }) 16 | const urlData = queryFilter({ queryData, queryType: 'urlQuery', originDataInfo: data }) 17 | return axios[queryData.method](queryData.replaceAPI({ ...urlData }), info) 18 | } 19 | 20 | export const requestCategoryModify = (data = {}) => { 21 | const queryData = queryInfoData['categoryModify'] 22 | const info = queryFilter({ queryData, originDataInfo: data }) 23 | const urlData = queryFilter({ queryData, queryType: 'urlQuery', originDataInfo: data }) 24 | return axios[queryData.method](queryData.replaceAPI({ ...urlData }), info) 25 | } 26 | 27 | export const requestCategoryRemove = (data = {}) => { 28 | const queryData = queryInfoData['categoryRemove'] 29 | const info = queryFilter({ queryData, originDataInfo: data }) 30 | const urlData = queryFilter({ queryData, queryType: 'urlQuery', originDataInfo: data }) 31 | return axios[queryData.method](queryData.replaceAPI({ ...urlData }), info) 32 | } 33 | -------------------------------------------------------------------------------- /src/modules/category/hooks/useCategories.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { useDispatch, useSelector } from 'react-redux' 4 | 5 | import { categoriesRead, categorySelector } from '@modules/category' 6 | import { ERROR } from '@modules/error' 7 | import { PENDING } from '@modules/pending' 8 | 9 | const useCategories = () => { 10 | const dispatch = useDispatch() 11 | const pending = useSelector((state) => state[PENDING][categoriesRead.TYPE]) 12 | const error = useSelector((state) => state[ERROR][categoriesRead.TYPE]) 13 | const categories = useSelector(categorySelector.listData) 14 | const favoritedArr = useSelector(categorySelector.favoriteCategories) 15 | const notFavoritedArr = useSelector(categorySelector.normalCategories) 16 | 17 | const reload = () => { 18 | dispatch(categoriesRead.request()) 19 | } 20 | 21 | React.useEffect(() => { 22 | dispatch(categoriesRead.request(undefined, { selectFirstCategory: true })) 23 | }, [dispatch]) 24 | 25 | return { pending, error, categories, favoritedArr, notFavoritedArr, reload } 26 | } 27 | 28 | export default useCategories 29 | -------------------------------------------------------------------------------- /src/modules/category/index.js: -------------------------------------------------------------------------------- 1 | export * from './api' 2 | export { default as useCategories } from './hooks/useCategories' 3 | export { default as queryInfoData } from './queryInfoData' 4 | export * from './reducer' 5 | export * from './saga' 6 | -------------------------------------------------------------------------------- /src/modules/category/queryInfoData.js: -------------------------------------------------------------------------------- 1 | const queryInfoData = { 2 | /** 3 | * * 카테고리 리스트 조회 GET 4 | * * /category/ 5 | * * Authorization: JWT 필요 6 | */ 7 | categoriesRead: { 8 | API: 'category/', 9 | method: 'get', 10 | bodyQuery: {}, 11 | urlQuery: {}, 12 | replaceAPI() { 13 | return this.API 14 | }, 15 | }, 16 | 17 | /** 18 | * * 카테고리 생성 POST 19 | * * /category/ 20 | * * Authorization: JWT 필요 21 | */ 22 | categoryCreate: { 23 | API: 'category/', 24 | method: 'post', 25 | bodyQuery: { 26 | name: '', 27 | is_favorited: '', 28 | }, 29 | urlQuery: {}, 30 | replaceAPI() { 31 | return this.API 32 | }, 33 | }, 34 | 35 | /** 36 | * * 카테고리 수정 PATCH 37 | * * /category/{id}/ 38 | * * Authorization: JWT 필요 39 | */ 40 | categoryModify: { 41 | API: 'category/{id}/', 42 | method: 'patch', 43 | bodyQuery: { 44 | id: '', 45 | name: '', 46 | order: '', 47 | is_favorited: '', 48 | }, 49 | urlQuery: { 50 | id: '', 51 | }, 52 | replaceAPI({ id }) { 53 | if (!id) return 54 | return this.API.replace('{id}', id) 55 | }, 56 | }, 57 | 58 | /** 59 | * * 카테고리 삭제 DELETE 60 | * * /category/{id}/ 61 | * * Authorization: JWT 필요 62 | */ 63 | categoryRemove: { 64 | API: 'category/{id}/', 65 | method: 'delete', 66 | bodyQuery: {}, 67 | urlQuery: { 68 | id: '', 69 | }, 70 | replaceAPI({ id }) { 71 | if (!id) return 72 | return this.API.replace('{id}', id) 73 | }, 74 | }, 75 | } 76 | 77 | export default queryInfoData 78 | -------------------------------------------------------------------------------- /src/modules/category/reducer.js: -------------------------------------------------------------------------------- 1 | import { createReducer, createAction, createSelector } from '@reduxjs/toolkit' 2 | 3 | import { createRequestAction, createRequestThunk } from '../helpers' 4 | 5 | export const CATEGORY = 'CATEGORY' 6 | 7 | export const categoriesRead = createRequestAction(`${CATEGORY}/READ`) 8 | export const categoriesReadThunk = createRequestThunk(categoriesRead) 9 | 10 | export const categoryCreate = createRequestAction(`${CATEGORY}/CREATE`) 11 | export const categoryCreateThunk = createRequestThunk(categoryCreate) 12 | 13 | export const categoryModify = createRequestAction(`${CATEGORY}/MODIFY`) 14 | export const categoryModifyThunk = createRequestThunk(categoryModify) 15 | 16 | export const categoryRemove = createRequestAction(`${CATEGORY}/REMOVE`) 17 | export const categoryRemoveThunk = createRequestThunk(categoryRemove) 18 | 19 | export const categorySelect = createAction(`${CATEGORY}/SELECT`) 20 | export const categoryEdit = createAction(`${CATEGORY}/EDIT`) 21 | export const categoryClearEdit = createAction(`${CATEGORY}/CLEAR_EDIT`) 22 | 23 | // Reducer 24 | const initialState = { 25 | listData: [], 26 | selectedCategory: {}, 27 | editedCategory: {}, 28 | } 29 | export const categoryReducer = createReducer(initialState, { 30 | [categoriesRead.SUCCESS]: (state, { payload, meta }) => { 31 | state.listData = payload 32 | if (meta?.selectFirstCategory) { 33 | state.selectedCategory = { 34 | ...state.selectedCategory, 35 | ...payload[0], 36 | } 37 | } 38 | }, 39 | [categoryRemove.SUCCESS]: (state, { payload, meta }) => { 40 | if (meta?.key === state.selectedCategory.id) { 41 | state.selectedCategory = initialState.selectedCategory 42 | } 43 | }, 44 | [categorySelect]: (state, { payload }) => { 45 | state.selectedCategory = { ...state.selectedCategory, ...payload } 46 | }, 47 | [categoryEdit]: (state, { payload }) => { 48 | state.editedCategory = { ...state.editedCategory, ...payload } 49 | }, 50 | [categoryClearEdit]: (state) => { 51 | state.editedCategory = initialState.editedCategory 52 | }, 53 | }) 54 | 55 | // Select 56 | const selectFavoriteCategoriesState = createSelector( 57 | (state) => state.listData, 58 | (listData) => { 59 | return listData.filter((item) => Boolean(item.is_favorited)) 60 | } 61 | ) 62 | const selectNormalCategoriesState = createSelector( 63 | (state) => state.listData, 64 | (listData) => { 65 | return listData.filter((item) => !item.is_favorited) 66 | } 67 | ) 68 | export const categorySelector = { 69 | listData: (state) => state[CATEGORY].listData, 70 | favoriteCategories: (state) => selectFavoriteCategoriesState(state[CATEGORY]), 71 | normalCategories: (state) => selectNormalCategoriesState(state[CATEGORY]), 72 | selectedCategory: (state) => state[CATEGORY].selectedCategory, 73 | editedCategory: (state) => state[CATEGORY].editedCategory, 74 | } 75 | -------------------------------------------------------------------------------- /src/modules/category/saga.js: -------------------------------------------------------------------------------- 1 | import { call, debounce, takeLatest } from 'redux-saga/effects' 2 | 3 | import { createRequestSaga } from '../helpers' 4 | import * as api from './api' 5 | import { categoriesRead, categoryCreate, categoryModify, categoryRemove } from './reducer' 6 | 7 | const watchCategoriesRead = createRequestSaga(categoriesRead, function* () { 8 | const { data } = yield call(api.requestCategoriesRead) 9 | return data 10 | }) 11 | 12 | const watchCategoryCreate = createRequestSaga(categoryCreate, function* (action) { 13 | const { data } = yield call(api.requestCategoryCreate, action.payload) 14 | return data 15 | }) 16 | 17 | const watchCategoryModify = createRequestSaga(categoryModify, function* (action) { 18 | const { data } = yield call(api.requestCategoryModify, action.payload) 19 | return data 20 | }) 21 | 22 | const watchCategoryRemove = createRequestSaga(categoryRemove, function* (action) { 23 | const { data } = yield call(api.requestCategoryRemove, action.payload) 24 | return data 25 | }) 26 | 27 | export function* categorySaga() { 28 | yield debounce(100, categoriesRead.REQUEST, watchCategoriesRead) 29 | yield takeLatest(categoryCreate.REQUEST, watchCategoryCreate) 30 | yield takeLatest(categoryModify.REQUEST, watchCategoryModify) 31 | yield takeLatest(categoryRemove.REQUEST, watchCategoryRemove) 32 | } 33 | -------------------------------------------------------------------------------- /src/modules/error/index.js: -------------------------------------------------------------------------------- 1 | export * from './reducer' 2 | -------------------------------------------------------------------------------- /src/modules/error/reducer.js: -------------------------------------------------------------------------------- 1 | import { createReducer, createAction } from '@reduxjs/toolkit' 2 | 3 | import { getActionName } from '../helpers' 4 | 5 | export const ERROR = 'ERROR' 6 | const NO_KEY = undefined 7 | 8 | export const removeError = createAction('error/REMOVE_ERROR') 9 | 10 | // Reducer 11 | const initialState = {} 12 | export const errorReducer = createReducer( 13 | initialState, 14 | { 15 | [removeError]: (state, { payload: actionName }) => { 16 | delete state[actionName] 17 | }, 18 | }, 19 | [ 20 | { 21 | matcher: ({ type }) => { 22 | return ( 23 | getActionName(type) && (type.endsWith('/REQUEST') || type.endsWith('/SUCCESS') || type.endsWith('/FAILURE')) 24 | ) 25 | }, 26 | reducer(state, { meta, payload, type }) { 27 | const actionName = getActionName(type) 28 | const key = meta?.key 29 | 30 | if (type.endsWith('/FAILURE')) { 31 | if (key !== NO_KEY) { 32 | if (typeof state[actionName] !== 'object') state[actionName] = {} 33 | state[actionName][key] = payload 34 | } else { 35 | state[actionName] = payload 36 | } 37 | } else if (type.endsWith('/SUCCESS') || type.endsWith('/REQUEST')) { 38 | if (key !== NO_KEY) { 39 | if (typeof state[actionName] !== 'object') state[actionName] = {} 40 | delete state[actionName][key] 41 | } else { 42 | delete state[actionName] 43 | } 44 | } 45 | }, 46 | }, 47 | ] 48 | ) 49 | -------------------------------------------------------------------------------- /src/modules/helpers.js: -------------------------------------------------------------------------------- 1 | import { createAction } from '@reduxjs/toolkit' 2 | import { call, put } from 'redux-saga/effects' 3 | 4 | export function createRequestAction(type) { 5 | const REQUEST = `${type}/REQUEST` 6 | const SUCCESS = `${type}/SUCCESS` 7 | const FAILURE = `${type}/FAILURE` 8 | 9 | return { 10 | TYPE: type, 11 | REQUEST, 12 | SUCCESS, 13 | FAILURE, 14 | request: createAction(REQUEST, (payload, meta) => ({ payload, meta })), 15 | success: createAction(SUCCESS, (payload, meta) => ({ payload, meta })), 16 | failure: createAction(FAILURE, (payload, meta) => ({ payload, meta })), 17 | } 18 | } 19 | 20 | export function createRequestSaga(actions, saga) { 21 | return function* (action) { 22 | try { 23 | const result = yield call(saga, action) 24 | yield put(actions.success(result, action.meta)) 25 | if (action.resolve) { 26 | action.resolve(result) 27 | } 28 | } catch (e) { 29 | yield put(actions.failure(e, action.meta)) 30 | if (action.reject) { 31 | action.reject(e) 32 | } 33 | } 34 | } 35 | } 36 | 37 | export function createRequestThunk(actions) { 38 | return (payload, meta) => (dispatch) => { 39 | return new Promise((resolve, reject) => { 40 | dispatch({ ...actions.request(payload, meta), resolve, reject }) 41 | }) 42 | } 43 | } 44 | 45 | export function getActionName(actionType) { 46 | if (typeof actionType !== 'string') { 47 | return null 48 | } 49 | 50 | return actionType.split('/').slice(0, -1).join('/') 51 | } 52 | -------------------------------------------------------------------------------- /src/modules/historyLink/hooks/useHistoryLinks.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useCallback } from 'react' 2 | 3 | import moment from 'moment' 4 | import { useDispatch, useSelector } from 'react-redux' 5 | 6 | import { ERROR } from '@modules/error' 7 | import { historyLinkListRead, historyLinkSelector, setHistoryLinkChangeFilter } from '@modules/historyLink' 8 | import { INIT, PENDING } from '@modules/pending' 9 | 10 | const MIN_TIME = new Date(moment().add(-1, 'year')).getTime() 11 | 12 | const useHistoryLinks = () => { 13 | const dispatch = useDispatch() 14 | const pending = useSelector((state) => state[PENDING][historyLinkListRead.TYPE]) 15 | const error = useSelector((state) => state[ERROR][historyLinkListRead.TYPE]) 16 | const filter = useSelector(historyLinkSelector.filter) 17 | const listData = useSelector(historyLinkSelector.listData) 18 | 19 | const reload = useCallback(() => { 20 | dispatch( 21 | setHistoryLinkChangeFilter({ 22 | text: '', 23 | startTime: new Date(moment().add(-1, 'day')).getTime(), 24 | endTime: new Date().getTime(), 25 | isNext: false, 26 | }) 27 | ) 28 | }, [dispatch]) 29 | 30 | const keywordSearch = useCallback( 31 | (value) => { 32 | dispatch( 33 | setHistoryLinkChangeFilter({ 34 | text: value, 35 | startTime: value ? 0 : new Date(moment().add(-1, 'day')).getTime(), 36 | endTime: new Date().getTime(), 37 | isNext: false, 38 | }) 39 | ) 40 | }, 41 | [dispatch] 42 | ) 43 | 44 | const dateSearch = useCallback( 45 | (value) => { 46 | if (!value) value = new Date() 47 | dispatch( 48 | setHistoryLinkChangeFilter({ 49 | text: '', 50 | startTime: new Date(moment(value).startOf('day').add(-1, 'day')).getTime(), 51 | endTime: new Date(moment(value).startOf('day').add(1, 'day')).getTime(), 52 | isNext: false, 53 | }) 54 | ) 55 | }, 56 | [dispatch] 57 | ) 58 | 59 | const next = useCallback(() => { 60 | dispatch( 61 | setHistoryLinkChangeFilter({ 62 | startTime: new Date(moment(filter.startTime).add(-1, 'day')).getTime(), 63 | endTime: filter.startTime, 64 | isNext: true, 65 | }) 66 | ) 67 | }, [dispatch, filter.startTime]) 68 | 69 | useEffect(() => { 70 | if (pending === INIT) reload() 71 | }, [pending, reload]) 72 | 73 | useEffect(() => { 74 | if (MIN_TIME < filter.startTime || !filter.startTime) { 75 | dispatch(historyLinkListRead.request(filter)) 76 | } 77 | }, [filter, dispatch]) 78 | 79 | return { pending, error, filter, listData, reload, keywordSearch, dateSearch, next } 80 | } 81 | 82 | export default useHistoryLinks 83 | -------------------------------------------------------------------------------- /src/modules/historyLink/index.js: -------------------------------------------------------------------------------- 1 | export { default as useHistoryLinks } from './hooks/useHistoryLinks' 2 | export * from './reducer' 3 | export * from './saga' 4 | -------------------------------------------------------------------------------- /src/modules/historyLink/reducer.js: -------------------------------------------------------------------------------- 1 | import { createAction, createReducer } from '@reduxjs/toolkit' 2 | 3 | import { createRequestAction } from '../helpers' 4 | 5 | export const HISTORY_LINK = 'HISTORY_LINK' 6 | 7 | export const setInitHistoryLinkFilter = createAction(`${HISTORY_LINK}/INIT_FILTER`) 8 | export const setHistoryLinkChangeFilter = createAction(`${HISTORY_LINK}/CHANGE_FILTER`) 9 | export const historyLinkListRead = createRequestAction(`${HISTORY_LINK}/LIST_READ`) 10 | 11 | // Reducer 12 | const initialState = { 13 | filter: { 14 | text: '', 15 | startTime: 0, 16 | endTime: 0, 17 | maxResults: 0, 18 | isNext: false, 19 | }, 20 | listData: [], 21 | } 22 | export const historyLinkReducer = createReducer(initialState, { 23 | [setInitHistoryLinkFilter]: (state) => { 24 | state.filter = initialState.filter 25 | }, 26 | [setHistoryLinkChangeFilter]: (state, { payload: data }) => { 27 | state.filter = { ...state.filter, ...data } 28 | }, 29 | [historyLinkListRead.SUCCESS]: (state, { payload: listData }) => { 30 | if (state.filter.isNext) state.listData.push(...listData) 31 | else state.listData = listData 32 | }, 33 | }) 34 | 35 | // Select 36 | export const historyLinkSelector = { 37 | listData: (state) => state[HISTORY_LINK].listData, 38 | filter: (state) => state[HISTORY_LINK].filter, 39 | } 40 | -------------------------------------------------------------------------------- /src/modules/historyLink/saga.js: -------------------------------------------------------------------------------- 1 | import { call, takeLatest } from 'redux-saga/effects' 2 | 3 | import { getHistoryList } from '@utils/chromeApis/history' 4 | 5 | import { createRequestSaga } from '../helpers' 6 | import { historyLinkListRead } from './reducer' 7 | 8 | const watchHistoryLinkListRead = createRequestSaga(historyLinkListRead, function* (action) { 9 | const resultList = yield call(getHistoryList, action.payload) 10 | return resultList 11 | }) 12 | 13 | export function* historyLinkSaga() { 14 | yield takeLatest(historyLinkListRead.REQUEST, watchHistoryLinkListRead) 15 | } 16 | -------------------------------------------------------------------------------- /src/modules/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux' 2 | import { fork, all } from 'redux-saga/effects' 3 | 4 | import { ALARM, alarmReducer, alarmSaga } from './alarm' 5 | import { ALARM_NOTICE, alarmNoticeReducer, alarmNoticeSaga } from './alarmNotice' 6 | import { CATEGORY, categoryReducer, categorySaga } from './category' 7 | import { ERROR, errorReducer } from './error' 8 | import { HISTORY_LINK, historyLinkReducer, historyLinkSaga } from './historyLink' 9 | import { LINK, linkReducer, linkSaga } from './link' 10 | import { PENDING, pendingReducer } from './pending' 11 | import { UI, uiReducer } from './ui' 12 | import { USER, userReducer, userSaga } from './user' 13 | 14 | // root reducer 15 | export const rootReducer = combineReducers({ 16 | [PENDING]: pendingReducer, 17 | [ERROR]: errorReducer, 18 | [CATEGORY]: categoryReducer, 19 | [LINK]: linkReducer, 20 | [ALARM]: alarmReducer, 21 | [USER]: userReducer, 22 | [HISTORY_LINK]: historyLinkReducer, 23 | [ALARM_NOTICE]: alarmNoticeReducer, 24 | [UI]: uiReducer, 25 | }) 26 | 27 | // root saga 28 | export function* rootSaga() { 29 | yield all([ 30 | fork(categorySaga), 31 | fork(linkSaga), 32 | fork(alarmSaga), 33 | fork(userSaga), 34 | fork(historyLinkSaga), 35 | fork(alarmNoticeSaga), 36 | ]) 37 | } 38 | -------------------------------------------------------------------------------- /src/modules/link/api.js: -------------------------------------------------------------------------------- 1 | import { axios } from '@utils/http/client' 2 | import queryFilter from '@utils/http/queryFilter' 3 | 4 | import queryInfoData from './queryInfoData' 5 | 6 | export const requestLinksRead = (data = {}) => { 7 | const queryData = queryInfoData['linksRead'] 8 | const info = queryFilter({ queryData, originDataInfo: data }) 9 | const urlData = queryFilter({ queryData, queryType: 'urlQuery', originDataInfo: data }) 10 | return axios[queryData.method](queryData.replaceAPI({ ...urlData }), info) 11 | } 12 | 13 | export const requestLinkModify = (data = {}) => { 14 | const queryData = queryInfoData['linkModify'] 15 | const info = queryFilter({ queryData, originDataInfo: data }) 16 | const urlData = queryFilter({ queryData, queryType: 'urlQuery', originDataInfo: data }) 17 | return axios[queryData.method](queryData.replaceAPI({ ...urlData }), info) 18 | } 19 | 20 | export const requestLinkRemove = (data = {}) => { 21 | const queryData = queryInfoData['linkRemove'] 22 | const info = queryFilter({ queryData, originDataInfo: data }) 23 | const urlData = queryFilter({ queryData, queryType: 'urlQuery', originDataInfo: data }) 24 | return axios[queryData.method](queryData.replaceAPI({ ...urlData }), info) 25 | } 26 | 27 | export const requestLinkCreate = (data = {}) => { 28 | const queryData = queryInfoData['linkCreate'] 29 | const info = queryFilter({ queryData, originDataInfo: data }) 30 | const urlData = queryFilter({ queryData, queryType: 'urlQuery', originDataInfo: data }) 31 | return axios[queryData.method](queryData.replaceAPI({ ...urlData }), info) 32 | } 33 | -------------------------------------------------------------------------------- /src/modules/link/hooks/useLinks.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useCallback } from 'react' 2 | 3 | import { useDispatch, useSelector } from 'react-redux' 4 | 5 | import { ERROR } from '@modules/error' 6 | import { linksRead, linkSelector } from '@modules/link' 7 | import { INIT, PENDING } from '@modules/pending' 8 | 9 | const useLinks = ({ detact = false, categoryId, selectedName, keyword }) => { 10 | const dispatch = useDispatch() 11 | const pending = useSelector((state) => state[PENDING][linksRead.TYPE]) 12 | const error = useSelector((state) => state[ERROR][linksRead.TYPE]) 13 | const links = useSelector((state) => linkSelector.linksData(state)[categoryId]) || [] 14 | 15 | const reload = useCallback(() => { 16 | if (categoryId) dispatch(linksRead.request({ categoryId }, { key: categoryId })) 17 | }, [dispatch, categoryId]) 18 | 19 | const filterChangeLoad = useCallback(() => { 20 | if (categoryId) { 21 | dispatch( 22 | linksRead.request( 23 | { 24 | categoryId, 25 | [selectedName]: keyword, 26 | }, 27 | { key: categoryId } 28 | ) 29 | ) 30 | } 31 | }, [dispatch, selectedName, keyword, categoryId]) 32 | 33 | useEffect(() => { 34 | if (pending === INIT) { 35 | reload() 36 | } 37 | }, [pending, reload]) 38 | 39 | useEffect(() => { 40 | if (detact) { 41 | filterChangeLoad() 42 | } 43 | }, [detact, filterChangeLoad]) 44 | 45 | return { pending, error, links, reload } 46 | } 47 | 48 | export default useLinks 49 | -------------------------------------------------------------------------------- /src/modules/link/index.js: -------------------------------------------------------------------------------- 1 | export * from './api' 2 | export { default as useLinks } from './hooks/useLinks' 3 | export { default as queryInfoData } from './queryInfoData' 4 | export * from './reducer' 5 | export * from './saga' 6 | -------------------------------------------------------------------------------- /src/modules/link/queryInfoData.js: -------------------------------------------------------------------------------- 1 | const queryInfoData = { 2 | /** 3 | * * Link 리스트 조회 GET 4 | * * url/?category={categoryId}&path={path}&title={title} 5 | * * JWT 필요 6 | */ 7 | linksRead: { 8 | API: 'url/?category={categoryId}&path={path}&title={title}', 9 | method: 'get', 10 | bodyQuery: {}, 11 | urlQuery: { 12 | categoryId: '', 13 | path: '', 14 | title: '', 15 | }, 16 | replaceAPI({ categoryId, path, title }) { 17 | if (!categoryId) return 18 | return this.API.replace('{categoryId}', categoryId) 19 | .replace('&path={path}', path ? `&path=${path}` : '') 20 | .replace('&title={title}', title ? `&title=${title}` : '') 21 | }, 22 | }, 23 | 24 | /** 25 | * * URL 등록 POST 26 | * * url/?category={categoryId} 27 | * * JWT 필요 28 | */ 29 | linkCreate: { 30 | API: 'url/?category={categoryId}', 31 | method: 'post', 32 | bodyQuery: { 33 | path: [], 34 | }, 35 | urlQuery: { 36 | categoryId: '', 37 | }, 38 | replaceAPI({ categoryId }) { 39 | if (!categoryId) return 40 | return this.API.replace('{categoryId}', categoryId) 41 | }, 42 | }, 43 | 44 | /** 45 | * * URL 등록 PATCH 46 | * * /url/{urlId}/ 47 | * * JWT 필요 48 | */ 49 | linkModify: { 50 | API: 'url/{urlId}/', 51 | method: 'patch', 52 | bodyQuery: { 53 | title: '', 54 | description: '', 55 | is_favorited: '', 56 | }, 57 | urlQuery: { 58 | urlId: '', 59 | }, 60 | replaceAPI({ urlId }) { 61 | if (!urlId) return 62 | return this.API.replace('{urlId}', urlId) 63 | }, 64 | }, 65 | 66 | /** 67 | * * URL 삭제 DELETE 68 | * * url/{urlId}/ 69 | * * JWT 필요 70 | */ 71 | linkRemove: { 72 | API: 'url/{urlId}/', 73 | method: 'delete', 74 | bodyQuery: {}, 75 | urlQuery: { 76 | urlId: '', 77 | }, 78 | replaceAPI({ urlId }) { 79 | if (!urlId) return 80 | return this.API.replace('{urlId}', urlId) 81 | }, 82 | }, 83 | } 84 | 85 | export default queryInfoData 86 | -------------------------------------------------------------------------------- /src/modules/link/reducer.js: -------------------------------------------------------------------------------- 1 | import { createAction, createReducer } from '@reduxjs/toolkit' 2 | 3 | import { createRequestAction, createRequestThunk } from '../helpers' 4 | 5 | export const LINK = 'LINK' 6 | 7 | export const linksRead = createRequestAction(`${LINK}/LIST/READ`) 8 | export const linksReadThunk = createRequestThunk(linksRead) 9 | 10 | export const linkCreate = createRequestAction(`${LINK}/CREATE`) 11 | export const linkCreateThunk = createRequestThunk(linkCreate) 12 | 13 | export const linkModify = createRequestAction(`${LINK}/MODIFY`) 14 | export const linkModifyThunk = createRequestThunk(linkModify) 15 | 16 | export const linkRemove = createRequestAction(`${LINK}/REMOVE`) 17 | export const linkRemoveThunk = createRequestThunk(linkRemove) 18 | 19 | export const linksRemove = createRequestAction(`${LINK}/LIST/REMOVE`) 20 | export const linksRemoveThunk = createRequestThunk(linksRemove) 21 | 22 | export const linkSelectBoxChangeState = createAction(`${LINK}/CHANGE_SELECT_BOX_STATE`) 23 | export const linkSelect = createAction(`${LINK}/SELECT`) 24 | export const linkCancleSelect = createAction(`${LINK}/CANCLE_SELECT`) 25 | export const linkClearSelect = createAction(`${LINK}/CLEAR_SELECT`) 26 | 27 | export const linkSearchFilterInit = createAction(`${LINK}/INIT_SEARCH_FILTER`) 28 | export const linkSearchFilterChangeState = createAction(`${LINK}/CHANGE_SEARCH_FILTER`) 29 | 30 | // Reducer 31 | const initialState = { 32 | searchFilter: { 33 | selectedName: '', 34 | keyword: '', 35 | }, 36 | linksData: {}, 37 | isOpenLinkSelectBox: false, 38 | selectedLink: [], 39 | createLinksCategoryId: undefined, 40 | } 41 | export const linkReducer = createReducer(initialState, { 42 | [linkSearchFilterInit]: (state) => { 43 | state.searchFilter = initialState.searchFilter 44 | }, 45 | [linkSearchFilterChangeState]: (state, { payload: data }) => { 46 | state.searchFilter = { ...state.searchFilter, ...data } 47 | }, 48 | [linksRead.SUCCESS]: (state, { payload, meta }) => { 49 | state.linksData[meta.key] = payload 50 | }, 51 | [linkCreate.REQUEST]: (state, { payload }) => { 52 | state.createLinksCategoryId = payload.categoryId 53 | }, 54 | [linkSelectBoxChangeState]: (state, { payload }) => { 55 | state.isOpenLinkSelectBox = payload 56 | }, 57 | [linkSelect]: (state, { payload: linkData }) => { 58 | if (!state.selectedLink.find((data) => data.id === linkData.id)) { 59 | state.selectedLink.push(linkData) 60 | } 61 | }, 62 | [linkCancleSelect]: (state, { payload: linkData }) => { 63 | state.selectedLink = state.selectedLink.filter((data) => data.id !== linkData.id) 64 | }, 65 | [linkClearSelect]: (state) => { 66 | state.selectedLink = initialState.selectedLink 67 | }, 68 | }) 69 | 70 | // Select 71 | export const linkSelector = { 72 | linksData: (state) => state[LINK].linksData, 73 | isOpenLinkSelectBox: (state) => state[LINK].isOpenLinkSelectBox, 74 | selectSelectedLink: (state) => state[LINK].selectedLink, 75 | searchFilter: (state) => state[LINK].searchFilter, 76 | createLinksCategoryId: (state) => state[LINK].createLinksCategoryId, 77 | } 78 | -------------------------------------------------------------------------------- /src/modules/link/saga.js: -------------------------------------------------------------------------------- 1 | import { all, call, debounce, takeLatest } from 'redux-saga/effects' 2 | 3 | import { createRequestSaga } from '../helpers' 4 | import * as api from './api' 5 | import { linksRead, linkCreate, linkModify, linkRemove, linksRemove } from './reducer' 6 | 7 | const watchLinksRead = createRequestSaga(linksRead, function* (action) { 8 | const { data } = yield call(api.requestLinksRead, action.payload) 9 | return data 10 | }) 11 | 12 | const watchLinkCreate = createRequestSaga(linkCreate, function* (action) { 13 | const { data } = yield call(api.requestLinkCreate, action.payload) 14 | return data 15 | }) 16 | 17 | const watchLinkModify = createRequestSaga(linkModify, function* (action) { 18 | const { data } = yield call(api.requestLinkModify, action.payload) 19 | return data 20 | }) 21 | 22 | const watchLinkRemove = createRequestSaga(linkRemove, function* (action) { 23 | const { data } = yield call(api.requestLinkRemove, action.payload) 24 | return data 25 | }) 26 | 27 | const watchLinksRemove = createRequestSaga(linksRemove, function* ({ payload: { urlIdList } }) { 28 | const listData = yield all(urlIdList.map((data) => call(api.requestLinkRemove, data))) 29 | return listData.map((res) => res.data) 30 | }) 31 | 32 | export function* linkSaga() { 33 | yield debounce(100, linksRead.REQUEST, watchLinksRead) 34 | yield takeLatest(linkCreate.REQUEST, watchLinkCreate) 35 | yield takeLatest(linkModify.REQUEST, watchLinkModify) 36 | yield takeLatest(linkRemove.REQUEST, watchLinkRemove) 37 | yield takeLatest(linksRemove.REQUEST, watchLinksRemove) 38 | } 39 | -------------------------------------------------------------------------------- /src/modules/pending/constants.js: -------------------------------------------------------------------------------- 1 | export const INIT = undefined 2 | -------------------------------------------------------------------------------- /src/modules/pending/index.js: -------------------------------------------------------------------------------- 1 | export * from './constants' 2 | export * from './reducer' 3 | -------------------------------------------------------------------------------- /src/modules/pending/reducer.js: -------------------------------------------------------------------------------- 1 | import { createAction, createReducer } from '@reduxjs/toolkit' 2 | 3 | import { getActionName } from '../helpers' 4 | 5 | export const PENDING = 'PENDING' 6 | export const NO_KEY = undefined 7 | 8 | export const setPending = createAction(`${PENDING}/SET_PENDING`) 9 | export const clearPendingHistory = createAction(`${PENDING}/CLEAR_PENDING_HISTORY`) 10 | 11 | // Reducer 12 | const initialState = {} 13 | export const pendingReducer = createReducer( 14 | initialState, 15 | { 16 | [setPending]: (state, { payload: actionName }) => { 17 | state[actionName] = true 18 | }, 19 | [clearPendingHistory]: (state, { payload: { actionName, key } }) => { 20 | if (key !== NO_KEY) { 21 | if (state[actionName]?.[key]) { 22 | delete state[actionName][key] 23 | } 24 | } else { 25 | delete state[actionName] 26 | } 27 | }, 28 | }, 29 | [ 30 | { 31 | matcher: ({ type }) => { 32 | return ( 33 | getActionName(type) && (type.endsWith('/REQUEST') || type.endsWith('/SUCCESS') || type.endsWith('/FAILURE')) 34 | ) 35 | }, 36 | reducer(state, { meta, type }) { 37 | const actionName = getActionName(type) 38 | const isPending = type.endsWith('/REQUEST') 39 | const key = meta?.key 40 | 41 | if (key !== NO_KEY) { 42 | if (typeof state[actionName] !== 'object') state[actionName] = {} 43 | state[actionName][key] = isPending 44 | } else { 45 | state[actionName] = isPending 46 | } 47 | }, 48 | }, 49 | ] 50 | ) 51 | -------------------------------------------------------------------------------- /src/modules/token/api.js: -------------------------------------------------------------------------------- 1 | import { axios } from '@utils/http/client' 2 | import queryFilter from '@utils/http/queryFilter' 3 | 4 | import queryInfoData from './queryInfoData' 5 | 6 | export const requestUpdateToken = (data = {}) => { 7 | const queryData = queryInfoData['updateToken'] 8 | const info = queryFilter({ queryData, originDataInfo: data }) 9 | return axios[queryData.method](queryData.repaceAPI(), info) 10 | } 11 | -------------------------------------------------------------------------------- /src/modules/token/index.js: -------------------------------------------------------------------------------- 1 | export * from './api' 2 | export { default as queryInfoData } from './queryInfoData' 3 | -------------------------------------------------------------------------------- /src/modules/token/queryInfoData.js: -------------------------------------------------------------------------------- 1 | const queryInfoData = { 2 | /** 3 | * * JWT 갱신 POST 4 | * * /user/token/refresh/ 5 | * * JWT 불필요 6 | */ 7 | updateToken: { 8 | API: '/user/token/refresh/', 9 | method: 'post', 10 | bodyQuery: { 11 | refresh: '', 12 | }, 13 | repaceAPI() { 14 | return this.API 15 | }, 16 | }, 17 | 18 | /** 19 | * * JWT 검사 POST 20 | * * /user/token/verify/ 21 | * * JWT 불필요 22 | */ 23 | checkToken: { 24 | API: '/user/token/verify/', 25 | method: 'post', 26 | bodyQuery: { 27 | token: '', 28 | }, 29 | repaceAPI() { 30 | return this.API 31 | }, 32 | }, 33 | } 34 | 35 | export default queryInfoData 36 | -------------------------------------------------------------------------------- /src/modules/ui/constants.js: -------------------------------------------------------------------------------- 1 | export const MODAL_NAME = { 2 | TERMS_MODAL: 'TERMS_MODAL', 3 | REMOVE_USER_ALERT_MODAL: 'REMOVE_USER_ALERT_MODAL', 4 | DELETE_CATEGORY_ALERT_MODAL: 'DELETE_CATEGORY_ALERT_MODAL', 5 | UPDATE_CATEGORY_MODAL: 'UPDATE_CATEGORY_MODAL', 6 | ADD_CATEGORY_MODAL: 'ADD_CATEGORY_MODAL', 7 | BOOKMARKS_MIGRATION_MODAL: 'BOOKMARKS_MIGRATION_MODAL', 8 | } 9 | 10 | export const DRAG = { 11 | DRAG_STATUS: 'drag', 12 | DROP_STATUS: 'drop', 13 | LINK: 'link', 14 | CATEGORY: 'category', 15 | } 16 | 17 | export const DROP_ZONE = { 18 | LINK_DROP_ZONE: 'LINK_DROP_ZONE', 19 | } 20 | -------------------------------------------------------------------------------- /src/modules/ui/hooks/useDialog.js: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react' 2 | 3 | import { useDispatch, useSelector } from 'react-redux' 4 | 5 | import { openDialog, closeDialog, closeAllDialogs, uiSelector } from '@modules/ui' 6 | 7 | const useDialog = (type) => { 8 | const dispatch = useDispatch() 9 | const dialogs = useSelector(uiSelector.dialogs) 10 | const open = !!dialogs.find((dialog) => dialog.type === type) 11 | 12 | const toggle = useCallback(() => { 13 | if (open) { 14 | dispatch(closeDialog({ type })) 15 | } else { 16 | dispatch(openDialog({ type })) 17 | } 18 | }, [open, type, dispatch]) 19 | 20 | const close = useCallback(() => { 21 | dispatch(closeDialog({ type })) 22 | }, [dispatch, type]) 23 | 24 | const closeAll = useCallback(() => { 25 | if (dialogs.length) { 26 | dispatch(closeAllDialogs()) 27 | } 28 | }, [dispatch, dialogs]) 29 | 30 | return { open, toggle, close, closeAll } 31 | } 32 | 33 | export default useDialog 34 | -------------------------------------------------------------------------------- /src/modules/ui/hooks/useDrag.js: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react' 2 | 3 | import { useDispatch, useSelector } from 'react-redux' 4 | 5 | import { UI, DRAG, setDrag, clearDrag } from '@modules/ui' 6 | 7 | const { DRAG_STATUS } = DRAG 8 | 9 | const useDrag = (type) => { 10 | const dispatch = useDispatch() 11 | const { 12 | type: dragType, 13 | status: dragStatus, 14 | listData, 15 | data, 16 | } = useSelector((state) => { 17 | return { 18 | type: state[UI].drag.type, 19 | status: state[UI].drag.status, 20 | ...state[UI].drag?.[type], 21 | } 22 | }) 23 | 24 | const setDragData = useCallback( 25 | (dragData) => { 26 | if (Array.isArray(dragData)) { 27 | dispatch( 28 | setDrag({ 29 | type, 30 | status: DRAG_STATUS, 31 | listData: dragData, 32 | }) 33 | ) 34 | } else { 35 | dispatch( 36 | setDrag({ 37 | type, 38 | status: DRAG_STATUS, 39 | data: dragData, 40 | }) 41 | ) 42 | } 43 | }, 44 | [type, dispatch] 45 | ) 46 | 47 | const clearDragData = useCallback(() => { 48 | dispatch(clearDrag()) 49 | }, [dispatch]) 50 | 51 | return { dragType, dragStatus, listData, data, setDragData, clearDragData } 52 | } 53 | 54 | export default useDrag 55 | -------------------------------------------------------------------------------- /src/modules/ui/hooks/useDropZone.js: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react' 2 | 3 | import { useDispatch, useSelector } from 'react-redux' 4 | 5 | import { openDropZone, closeDropZone, closeAllDropZones, uiSelector } from '@modules/ui' 6 | 7 | const useDropZone = (type) => { 8 | const dispatch = useDispatch() 9 | const dropZones = useSelector(uiSelector.dropZones) 10 | const open = !!dropZones.find((dropZone) => dropZone.type === type) 11 | 12 | const toggle = useCallback(() => { 13 | if (open) dispatch(closeDropZone({ type })) 14 | else dispatch(openDropZone({ type })) 15 | }, [open, type, dispatch]) 16 | 17 | const close = useCallback(() => { 18 | dispatch(closeDropZone({ type })) 19 | }, [dispatch, type]) 20 | 21 | const closeAll = useCallback(() => { 22 | if (dropZones.length) { 23 | dispatch(closeAllDropZones()) 24 | } 25 | }, [dispatch, dropZones]) 26 | 27 | return { open, toggle, close, closeAll } 28 | } 29 | 30 | export default useDropZone 31 | -------------------------------------------------------------------------------- /src/modules/ui/hooks/useToast.js: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react' 2 | 3 | import { useDispatch, useSelector } from 'react-redux' 4 | 5 | import { setToast, uiSelector } from '@modules/ui' 6 | 7 | const useToast = () => { 8 | const dispatch = useDispatch() 9 | const { open, type, message } = useSelector(uiSelector.toast) 10 | 11 | const openToast = useCallback( 12 | ({ type, message }) => { 13 | dispatch(setToast({ open: true, type, message })) 14 | }, 15 | [dispatch] 16 | ) 17 | 18 | const close = useCallback(() => { 19 | dispatch(setToast({ open: false })) 20 | }, [dispatch]) 21 | 22 | return { open, type, message, openToast, close } 23 | } 24 | 25 | export default useToast 26 | -------------------------------------------------------------------------------- /src/modules/ui/index.js: -------------------------------------------------------------------------------- 1 | export * from './constants' 2 | export { default as useDialog } from './hooks/useDialog' 3 | export { default as useDrag } from './hooks/useDrag' 4 | export { default as useDropZone } from './hooks/useDropZone' 5 | export { default as useToast } from './hooks/useToast' 6 | export * from './reducer' 7 | -------------------------------------------------------------------------------- /src/modules/ui/reducer.js: -------------------------------------------------------------------------------- 1 | import { createAction, createReducer } from '@reduxjs/toolkit' 2 | 3 | export const UI = 'UI' 4 | 5 | export const setIsMobile = createAction(`${UI}/SET_IS_MOBILE`) 6 | export const openDialog = createAction(`${UI}/OPEN_DIALOG`) 7 | export const closeDialog = createAction(`${UI}/CLOSE_DIALOG`) 8 | export const closeAllDialogs = createAction(`${UI}/CLOSE_ALL_DIALOGS`) 9 | export const setToast = createAction(`${UI}/SET_TOAST`) 10 | export const openDropZone = createAction(`${UI}/OPEN_DROP_ZONE`) 11 | export const closeDropZone = createAction(`${UI}/CLOSE_DROP_ZONE`) 12 | export const closeAllDropZones = createAction(`${UI}/CLOSE_ALL_DROP_ZONES`) 13 | export const setDrag = createAction(`${UI}/SET_DRAG`) 14 | export const clearDrag = createAction(`${UI}/CLEAR_DRAG`) 15 | export const appBarInversionChangeState = createAction(`${UI}/APP_BAR_INVERSION_CHANGE_STATE`) 16 | 17 | // Reducer 18 | const initialState = { 19 | dialogs: [], 20 | toast: { 21 | open: false, 22 | type: 'success', 23 | message: '', 24 | }, 25 | dropZones: [], 26 | drag: { 27 | type: '', 28 | status: '', 29 | link: { 30 | listData: [], 31 | data: { 32 | id: undefined, // number 33 | path: '', 34 | }, 35 | }, 36 | category: { 37 | listData: [], 38 | data: { 39 | id: undefined, // number 40 | name: '', 41 | order: undefined, // number 42 | is_favorited: undefined, // true | false 43 | dragFinished: undefined, // true | false 44 | }, 45 | }, 46 | }, 47 | isAppBarInversion: false, 48 | } 49 | export const uiReducer = createReducer(initialState, { 50 | [openDialog]: (state, { payload }) => { 51 | if (!state.dialogs.find((dialog) => dialog.type === payload.type)) { 52 | state.dialogs.push(payload) 53 | } 54 | }, 55 | [closeDialog]: (state, { payload }) => { 56 | state.dialogs = state.dialogs.filter((dialog) => { 57 | if (payload.meta?.key) { 58 | return dialog.meta?.key !== payload.meta?.key 59 | } 60 | return dialog.type !== payload.type 61 | }) 62 | }, 63 | [closeAllDialogs]: (state) => { 64 | state.dialogs = initialState.dialogs 65 | }, 66 | [setToast]: (state, { payload }) => { 67 | state.toast = { ...state.toast, ...payload } 68 | }, 69 | [openDropZone]: (state, { payload }) => { 70 | if (!state.dropZones.find((dropZone) => dropZone.type === payload.type)) { 71 | state.dropZones.push(payload) 72 | } 73 | }, 74 | [closeDropZone]: (state, { payload }) => { 75 | state.dropZones = state.dropZones.filter((dropZone) => { 76 | if (payload.meta?.key) { 77 | return dropZone.meta?.key !== payload.meta?.key 78 | } 79 | return dropZone.type !== payload.type 80 | }) 81 | }, 82 | [closeAllDropZones]: (state) => { 83 | state.dropZones = initialState.dropZones 84 | }, 85 | [setDrag]: (state, { payload: { type, status, ...props } }) => { 86 | state.drag.type = type 87 | state.drag.status = status 88 | state.drag[type] = { ...state.drag[type], ...props } 89 | }, 90 | [clearDrag]: (state) => { 91 | state.drag = initialState.drag 92 | }, 93 | [appBarInversionChangeState]: (state, { payload }) => { 94 | state.isAppBarInversion = payload 95 | }, 96 | }) 97 | 98 | // Select 99 | export const uiSelector = { 100 | dialogs: (state) => state[UI].dialogs, 101 | toast: (state) => state[UI].toast, 102 | dropZones: (state) => state[UI].dropZones, 103 | drag: (state) => state[UI].drag, 104 | isAppBarInversion: (state) => state[UI].isAppBarInversion, 105 | } 106 | -------------------------------------------------------------------------------- /src/modules/user/api.js: -------------------------------------------------------------------------------- 1 | import { axios } from '@utils/http/client' 2 | import queryFilter from '@utils/http/queryFilter' 3 | 4 | import queryInfoData from './queryInfoData' 5 | 6 | export const requestNregister = (data = {}) => { 7 | const queryData = queryInfoData['nRegister'] 8 | const info = queryFilter({ queryData, originDataInfo: data }) 9 | const urlData = queryFilter({ queryData, queryType: 'urlQuery', originDataInfo: data }) 10 | return axios[queryData.method](queryData.replaceAPI({ ...urlData }), info) 11 | } 12 | 13 | export const requestNlogin = (data = {}) => { 14 | const queryData = queryInfoData['nLogin'] 15 | const info = queryFilter({ queryData, originDataInfo: data }) 16 | const urlData = queryFilter({ queryData, queryType: 'urlQuery', originDataInfo: data }) 17 | return axios[queryData.method](queryData.replaceAPI({ ...urlData }), info) 18 | } 19 | 20 | export const requestGregister = (data = {}) => { 21 | const queryData = queryInfoData['gRegister'] 22 | const info = queryFilter({ queryData, originDataInfo: data }) 23 | const urlData = queryFilter({ queryData, queryType: 'urlQuery', originDataInfo: data }) 24 | return axios[queryData.method](queryData.replaceAPI({ ...urlData }), info) 25 | } 26 | 27 | export const requestGLogin = (data = {}) => { 28 | const queryData = queryInfoData['gLogin'] 29 | const info = queryFilter({ queryData, originDataInfo: data }) 30 | const urlData = queryFilter({ queryData, queryType: 'urlQuery', originDataInfo: data }) 31 | return axios[queryData.method](queryData.replaceAPI({ ...urlData }), info) 32 | } 33 | 34 | export const requestUserRead = (data = {}) => { 35 | const queryData = queryInfoData['userRead'] 36 | const info = queryFilter({ queryData, originDataInfo: data }) 37 | const urlData = queryFilter({ queryData, queryType: 'urlQuery', originDataInfo: data }) 38 | return axios[queryData.method](queryData.replaceAPI({ ...urlData }), info) 39 | } 40 | 41 | export const requestUserModiify = (data = {}) => { 42 | const queryData = queryInfoData['userModiify'] 43 | const info = queryFilter({ queryData, originDataInfo: data }) 44 | const urlData = queryFilter({ queryData, queryType: 'urlQuery', originDataInfo: data }) 45 | return axios[queryData.method](queryData.replaceAPI({ ...urlData }), info) 46 | } 47 | 48 | export const requestUserRemove = (data = {}) => { 49 | const queryData = queryInfoData['userRemove'] 50 | const info = queryFilter({ queryData, originDataInfo: data }) 51 | const urlData = queryFilter({ queryData, queryType: 'urlQuery', originDataInfo: data }) 52 | return axios[queryData.method](queryData.replaceAPI({ ...urlData }), info) 53 | } 54 | -------------------------------------------------------------------------------- /src/modules/user/hooks/useUser.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | 3 | import { useDispatch, useSelector } from 'react-redux' 4 | 5 | import { ERROR } from '@modules/error' 6 | import { INIT, PENDING } from '@modules/pending' 7 | import { userRead, userSelector } from '@modules/user' 8 | 9 | const useUserData = () => { 10 | const dispatch = useDispatch() 11 | const data = useSelector(userSelector.data) 12 | const pending = useSelector((state) => state[PENDING][userRead.TYPE]) 13 | const error = useSelector((state) => state[ERROR][userRead.TYPE]) 14 | 15 | const reload = () => { 16 | dispatch(userRead.request()) 17 | } 18 | 19 | useEffect(() => { 20 | if (pending === INIT) { 21 | dispatch(userRead.request()) 22 | } 23 | }, [dispatch, pending]) 24 | 25 | return { pending, error, data, reload } 26 | } 27 | 28 | export default useUserData 29 | -------------------------------------------------------------------------------- /src/modules/user/index.js: -------------------------------------------------------------------------------- 1 | export * from './api' 2 | export { default as useUserData } from './hooks/useUser' 3 | export * from './reducer' 4 | export * from './saga' 5 | -------------------------------------------------------------------------------- /src/modules/user/queryInfoData.js: -------------------------------------------------------------------------------- 1 | const queryInfoData = { 2 | /** 3 | * * 일반 회원가입 POST 4 | * * /user/sign-up/ 5 | * * Authorization: JWT 불필요 6 | */ 7 | nRegister: { 8 | API: '/user/sign-up/', 9 | method: 'post', 10 | bodyQuery: { 11 | sign_up_type: '', // * (normal default [server]) 12 | email: '', 13 | username: '', 14 | password: '', 15 | }, 16 | urlQuery: {}, 17 | replaceAPI() { 18 | return this.API 19 | }, 20 | }, 21 | 22 | /** 23 | * * 일반 로그인 POST 24 | * * /user/sign-in/ 25 | * * Authorization: JWT 불필요 26 | */ 27 | nLogin: { 28 | API: '/user/sign-in/', 29 | method: 'post', 30 | bodyQuery: { 31 | email: '', 32 | password: '', 33 | }, 34 | urlQuery: {}, 35 | replaceAPI() { 36 | return this.API 37 | }, 38 | }, 39 | 40 | /** 41 | * * 구글 회원가입 POST 42 | * * /user/google/sign-up/ 43 | * * Authorization: JWT 불필요 44 | */ 45 | gRegister: { 46 | API: 'user/google/sign-up/', 47 | method: 'post', 48 | bodyQuery: { 49 | token: '', 50 | }, 51 | urlQuery: {}, 52 | replaceAPI() { 53 | return this.API 54 | }, 55 | }, 56 | 57 | /** 58 | * * 구글 로그인 POST 59 | * * /user/google/sign-in/ 60 | * * Authorization: JWT 불필요 61 | * * email, password 불필요 62 | */ 63 | gLogin: { 64 | API: '/user/google/sign-in/', 65 | method: 'post', 66 | bodyQuery: { 67 | token: '', 68 | }, 69 | urlQuery: {}, 70 | replaceAPI() { 71 | return this.API 72 | }, 73 | }, 74 | 75 | /** 76 | * * 로그아웃 POST 77 | * * /user/sign-out/ 78 | * * Authorization: JWT 불필요 79 | * * email, password 불필요 80 | */ 81 | logout: { 82 | API: '/user/sign-out/', 83 | method: 'post', 84 | bodyQuery: {}, 85 | urlQuery: {}, 86 | replaceAPI() { 87 | return this.API 88 | }, 89 | }, 90 | 91 | /** 92 | * * 회원정보 조회 GET 93 | * * /user/{userId}/ (option) 94 | * * Authorization: JWT 필요 95 | */ 96 | userRead: { 97 | API: 'user/{userId}/', 98 | method: 'get', 99 | bodyQuery: {}, 100 | urlQuery: { 101 | userId: '', 102 | }, 103 | replaceAPI({ userId }) { 104 | return this.API.replace('{userId}/', userId ? `${userId}/` : '') 105 | }, 106 | }, 107 | 108 | /** 109 | * * 회원정보 부분 수정 PATCH 110 | * * /user/{userId}/ (option) 111 | * * Authorization: JWT 필요 112 | */ 113 | userModiify: { 114 | API: 'user/{userId}/', 115 | method: 'patch', 116 | bodyQuery: { 117 | // "sign_up_type": "", 118 | // "email": "", 119 | username: '', 120 | password: '', 121 | }, 122 | urlQuery: { 123 | userId: '', 124 | }, 125 | replaceAPI({ userId }) { 126 | return this.API.replace('{userId}/', userId ? `${userId}/` : '') 127 | }, 128 | }, 129 | 130 | /** 131 | * * 회원정보 삭제 DELETE 132 | * * /user/{userId}/ (option) 133 | * * Authorization: JWT 필요 134 | */ 135 | userRemove: { 136 | API: 'user/{userId}/', 137 | method: 'delete', 138 | bodyQuery: {}, 139 | urlQuery: { 140 | userId: '', 141 | }, 142 | replaceAPI({ userId }) { 143 | return this.API.replace('{userId}/', userId ? `${userId}/` : '') 144 | }, 145 | }, 146 | } 147 | 148 | export default queryInfoData 149 | -------------------------------------------------------------------------------- /src/modules/user/reducer.js: -------------------------------------------------------------------------------- 1 | import { createReducer } from '@reduxjs/toolkit' 2 | 3 | import { createRequestAction, createRequestThunk } from '../helpers' 4 | 5 | export const USER = 'USER' 6 | 7 | export const userRegister = createRequestAction(`${USER}/REGISTER`) 8 | export const userRegisterThunk = createRequestThunk(userRegister) 9 | 10 | export const userLogin = createRequestAction(`${USER}/LOGIN`) 11 | export const userLoginThunk = createRequestThunk(userLogin) 12 | 13 | export const userLogout = createRequestAction(`${USER}/LOGOUT`) 14 | export const userLogoutThunk = createRequestThunk(userLogout) 15 | 16 | export const userGregister = createRequestAction(`${USER}/G_REGISTER`) 17 | export const userGregisterThunk = createRequestThunk(userGregister) 18 | 19 | export const userGlogin = createRequestAction(`${USER}/G_LOGIN`) 20 | export const userGloginThunk = createRequestThunk(userGlogin) 21 | 22 | export const userRead = createRequestAction(`${USER}/READ`) 23 | export const userReadThunk = createRequestThunk(userRead) 24 | 25 | export const userModify = createRequestAction(`${USER}/MODIFY`) 26 | export const userModifyThunk = createRequestThunk(userModify) 27 | 28 | export const userRemove = createRequestAction(`${USER}/REMOVE`) 29 | export const userRemoveThunk = createRequestThunk(userRemove) 30 | 31 | // Reducer 32 | const initialState = { 33 | data: {}, 34 | } 35 | export const userReducer = createReducer(initialState, { 36 | [userLogin.SUCCESS]: (state, { payload }) => { 37 | state.data = payload 38 | }, 39 | [userGlogin.SUCCESS]: (state, { payload }) => { 40 | state.data = payload 41 | }, 42 | [userRead.SUCCESS]: (state, { payload }) => { 43 | state.data = payload 44 | }, 45 | }) 46 | 47 | // Select 48 | export const userSelector = { 49 | data: (state) => state[USER].data, 50 | } 51 | -------------------------------------------------------------------------------- /src/modules/user/saga.js: -------------------------------------------------------------------------------- 1 | import { call, takeLatest } from 'redux-saga/effects' 2 | 3 | import { getAuthToken, removeCachedAuthToken } from '@utils/chromeApis/OAuth' 4 | import { REMOVE_TOKEN, UPDATE_TOKEN } from '@utils/chromeApis/onMessage' 5 | import { sendMessage } from '@utils/chromeApis/sendMessage' 6 | import { setAccessToken, removeAccessToken } from '@utils/http/auth' 7 | 8 | import { createRequestSaga } from '../helpers' 9 | import * as api from './api' 10 | import { 11 | userRegister, 12 | userLogin, 13 | userLogout, 14 | userGregister, 15 | userGlogin, 16 | userRead, 17 | userModify, 18 | userRemove, 19 | } from './reducer' 20 | 21 | const watchUserRegister = createRequestSaga(userRegister, function* (action) { 22 | const { data } = yield call(api.requestNregister, action.payload) 23 | yield call(setAccessToken, data.token) 24 | yield call(sendMessage, { message: UPDATE_TOKEN }) 25 | return data 26 | }) 27 | 28 | const watchUserLogin = createRequestSaga(userLogin, function* (action) { 29 | const { data } = yield call(api.requestNlogin, action.payload) 30 | yield call(setAccessToken, data.token) 31 | yield call(sendMessage, { message: UPDATE_TOKEN }) 32 | return data 33 | }) 34 | 35 | const watchUserLogout = createRequestSaga(userLogout, function* (_action) { 36 | yield call(removeAccessToken) 37 | yield call(sendMessage, { message: REMOVE_TOKEN }) 38 | }) 39 | 40 | const watchUserGregister = createRequestSaga(userGregister, function* (_action) { 41 | let token 42 | try { 43 | token = yield call(getAuthToken) 44 | const { data } = yield call(api.requestGregister, { token }) 45 | yield call(setAccessToken, data.token) 46 | yield call(sendMessage, { message: UPDATE_TOKEN }) 47 | return data 48 | } catch (error) { 49 | if (token && error.response?.status >= 400) { 50 | yield call(removeCachedAuthToken, token) 51 | } 52 | throw error 53 | } 54 | }) 55 | 56 | const watchUserGlogin = createRequestSaga(userGlogin, function* (_action) { 57 | let token 58 | try { 59 | token = yield call(getAuthToken) 60 | const { data } = yield call(api.requestGLogin, { token }) 61 | yield call(setAccessToken, data.token) 62 | yield call(sendMessage, { message: UPDATE_TOKEN }) 63 | return data 64 | } catch (error) { 65 | if (token && error.response?.status >= 400) { 66 | yield call(removeCachedAuthToken, token) 67 | } 68 | throw error 69 | } 70 | }) 71 | 72 | const watchUserRead = createRequestSaga(userRead, function* (action) { 73 | const { data } = yield call(api.requestUserRead, action.payload, null) 74 | return data 75 | }) 76 | 77 | const watchUserModify = createRequestSaga(userModify, function* (action) { 78 | const { data } = yield call(api.requestUserModiify, action.payload, null) 79 | return data 80 | }) 81 | 82 | const watchUserRemove = createRequestSaga(userRemove, function* (action) { 83 | const { data } = yield call(api.requestUserRemove, action.payload) 84 | yield call(removeAccessToken) 85 | return data 86 | }) 87 | 88 | export function* userSaga() { 89 | yield takeLatest(userRegister.REQUEST, watchUserRegister) 90 | yield takeLatest(userLogin.REQUEST, watchUserLogin) 91 | yield takeLatest(userLogout.REQUEST, watchUserLogout) 92 | yield takeLatest(userGregister.REQUEST, watchUserGregister) 93 | yield takeLatest(userGlogin.REQUEST, watchUserGlogin) 94 | 95 | yield takeLatest(userRead.REQUEST, watchUserRead) 96 | yield takeLatest(userModify.REQUEST, watchUserModify) 97 | yield takeLatest(userRemove.REQUEST, watchUserRemove) 98 | } 99 | -------------------------------------------------------------------------------- /src/setting.js: -------------------------------------------------------------------------------- 1 | export const STORAGE = localStorage 2 | export const SERVER_TOKEN = 'JWT' 3 | export const SERVER_TOKEN_NOT_VALID = 'token_not_valid' 4 | export const LOGIN_REQUIRED_VALID = 'authentication credentials' 5 | export const TOKEN_NAME = 'accessToken' 6 | export const UPDATE_TOKEN_NAME = 'refreshToken' 7 | -------------------------------------------------------------------------------- /src/utils/chromeApis/OAuth.js: -------------------------------------------------------------------------------- 1 | export function getAuthToken() { 2 | return new Promise((resolve, reject) => { 3 | const idCheck = chrome.runtime?.id 4 | if (!idCheck) reject({ message: 'is not defined getAuthToken' }) 5 | else { 6 | chrome.identity.getAuthToken({ interactive: true }, (token) => { 7 | if (token) resolve(token) 8 | }) 9 | } 10 | }) 11 | } 12 | 13 | export function removeCachedAuthToken(token) { 14 | return new Promise((resolve, reject) => { 15 | const idCheck = chrome.runtime?.id 16 | if (!idCheck) reject({ message: 'is not defined removeCachedAuthToken' }) 17 | else chrome.identity.removeCachedAuthToken({ token }, () => resolve()) 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /src/utils/chromeApis/bookmarks.js: -------------------------------------------------------------------------------- 1 | export function getBookmarks() { 2 | chrome.bookmarks.getTree((data) => console.log(data)) 3 | } 4 | 5 | const onBookmarkFolderAdded = (newFolder, fn) => { 6 | fn(newFolder.id) 7 | } 8 | 9 | export function createBookmark(data) { 10 | const { id, title, url } = data 11 | chrome.bookmarks.create({ 12 | parentId: id, 13 | title, 14 | url, 15 | }) 16 | } 17 | 18 | export function createBookmarkFolder(data, fn) { 19 | const { id, title } = data 20 | const idCheck = chrome.runtime?.id 21 | if (idCheck) { 22 | chrome.bookmarks.create( 23 | { 24 | parentId: id, 25 | title: title, 26 | }, 27 | (newFolder) => onBookmarkFolderAdded(newFolder, fn) 28 | ) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/chromeApis/history.js: -------------------------------------------------------------------------------- 1 | /** 2 | * * Get Chrome History List information -> promise Pattern 3 | * @param {String} text String 4 | * @param {Date} startTime Date 5 | * @param {Date} endTime Date 6 | * @param {Number} maxResults Number 7 | * @returns {Object} search 8 | * * first : (true|false) 해당 날짜의 첫번째 게시글 9 | * * id: "11081" (history Id) 10 | * * favicon: "https://www.google.com/s2/favicons?domain=${url}" 11 | * * lastVisitTime: 1588933029447.23 12 | * * title: "React App" 13 | * * typedCount: 0 (사용자가 주소(typing in the address)를 입력하여 이 페이지를 탐색 한 횟수) 14 | * * url: "chrome-extension://fljjldbbgojlhkhamhcgamibbjlincej/index.html?index=1" 15 | * * hostName: "chrome-extension://fljjldbbgojlhkhamhcgamibbjlincej/index.html" 16 | * * visitCount: 24 (사용자가이 페이지를 탐색(navigated to this page) 한 횟수) 17 | */ 18 | export function getHistoryList({ text, startTime = 0, endTime = Date.now(), maxResults }) { 19 | return new Promise((resolve, _reject) => { 20 | const idCheck = chrome.runtime?.id 21 | if (!idCheck) resolve(urlTempList) 22 | else { 23 | chrome.history.search({ text, startTime, endTime, maxResults }, (historyList) => { 24 | historyList = historyList.filter( 25 | (history) => startTime <= history.lastVisitTime && history.lastVisitTime <= endTime 26 | ) 27 | historyList.sort((preHistory, curHistory) => curHistory.lastVisitTime - preHistory.lastVisitTime) 28 | let prevDate = '' 29 | const resultList = historyList.map(function (history) { 30 | let curDate = new Date(history.lastVisitTime).toLocaleDateString() 31 | let first = false 32 | if (curDate !== prevDate) { 33 | first = true 34 | prevDate = curDate 35 | } 36 | const url = document.createElement('a') 37 | url.href = history.url 38 | return { 39 | ...history, 40 | first, 41 | hostName: url.hostname, 42 | favicon: `https://www.google.com/s2/favicons?domain=${url.hostname}`, 43 | } 44 | }) 45 | resolve(resultList) 46 | }) 47 | } 48 | }) 49 | } 50 | 51 | const urlTempList = [ 52 | { 53 | id: '1', 54 | lastVisitTime: 1588933029447.23, 55 | title: 'React App', 56 | typedCount: 0, 57 | hostName: 'https://www.naver.com', 58 | visitCount: 24, 59 | }, 60 | { 61 | id: '2', 62 | lastVisitTime: 1588933029447.23, 63 | title: 'React App', 64 | typedCount: 0, 65 | hostName: 'https://www.naver.com', 66 | visitCount: 24, 67 | }, 68 | { 69 | id: '3', 70 | lastVisitTime: 1588933029447.23, 71 | title: 'React App', 72 | typedCount: 0, 73 | hostName: 'https://www.naver.com', 74 | visitCount: 24, 75 | }, 76 | { 77 | id: '4', 78 | lastVisitTime: 1588933029447.23, 79 | title: 'React App', 80 | typedCount: 0, 81 | hostName: 'https://www.naver.com', 82 | visitCount: 24, 83 | }, 84 | { 85 | id: '5', 86 | lastVisitTime: 1588933029447.23, 87 | title: 'React App', 88 | typedCount: 0, 89 | hostName: 'https://www.naver.com', 90 | visitCount: 24, 91 | }, 92 | ] 93 | -------------------------------------------------------------------------------- /src/utils/chromeApis/onMessage.js: -------------------------------------------------------------------------------- 1 | export function onMessage(callback) { 2 | const idCheck = chrome.runtime?.id 3 | if (!idCheck) return 4 | else chrome.runtime?.onMessage.addListener(callback) 5 | } 6 | 7 | export const UPDATE_TOKEN = 'UPDATE_TOKEN' 8 | export const REMOVE_TOKEN = 'REMOVE_TOKEN' 9 | -------------------------------------------------------------------------------- /src/utils/chromeApis/sendMessage.js: -------------------------------------------------------------------------------- 1 | export function sendMessage(data) { 2 | const idCheck = chrome.runtime?.id 3 | if (!idCheck) return 4 | else chrome.runtime.sendMessage(data) 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/chromeApis/tab.js: -------------------------------------------------------------------------------- 1 | export function createTab(url) { 2 | const idCheck = chrome.runtime?.id 3 | if (!idCheck) window.open(url) 4 | else chrome.tabs.create({ selected: true, url }) 5 | } 6 | 7 | export function createTabList(urlList) { 8 | const idCheck = chrome.runtime?.id 9 | if (!idCheck) urlList.forEach((url) => window.open(url)) 10 | else urlList.forEach((url) => chrome.tabs.create({ selected: true, url })) 11 | } 12 | 13 | export function getTabsQuery() { 14 | return new Promise((resolve, reject) => { 15 | const idCheck = chrome.runtime?.id 16 | if (!idCheck) reject({ message: 'is not defined query of tabs' }) 17 | else chrome.tabs.query({ active: true, windowId: chrome.windows?.WINDOW_ID_CURRENT }, (tabs) => resolve(tabs)) 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /src/utils/copyLink.js: -------------------------------------------------------------------------------- 1 | async function copyLink(url) { 2 | try { 3 | await navigator.clipboard.writeText(url) 4 | return true 5 | } catch (e) { 6 | return false 7 | } 8 | } 9 | 10 | export default copyLink 11 | -------------------------------------------------------------------------------- /src/utils/filter.js: -------------------------------------------------------------------------------- 1 | export function isObjValueEmpty(obj) { 2 | return Object.entries(obj).filter(([_, value]) => !value).length === Object.entries(obj).length 3 | } 4 | 5 | export function isObjKeysEmpty(obj) { 6 | return !!Object.keys(obj).length 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/ga.js: -------------------------------------------------------------------------------- 1 | import ReactGA from 'react-ga' 2 | 3 | const TRACKING_ID = 'UA-207149982-2' 4 | 5 | export const initGA = () => { 6 | const isDev = !process.env.NODE_ENV || process.env.NODE_ENV === 'development' 7 | ReactGA.initialize(TRACKING_ID, { debug: isDev }) 8 | } 9 | 10 | export const GAPageview = (path) => { 11 | ReactGA.set({ page: path }) 12 | ReactGA.pageview(path) 13 | } 14 | 15 | export const GAEvent = (category, action, label) => { 16 | ReactGA.event({ 17 | category, 18 | action, 19 | label, 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/http/auth.js: -------------------------------------------------------------------------------- 1 | import { STORAGE, TOKEN_NAME, UPDATE_TOKEN_NAME } from 'setting' 2 | 3 | export function getAccessToken() { 4 | return STORAGE.getItem(TOKEN_NAME) || '' 5 | } 6 | 7 | export function getRefreshToken() { 8 | return STORAGE.getItem(UPDATE_TOKEN_NAME) || '' 9 | } 10 | 11 | export function setAccessToken(token = {}) { 12 | const accessToken = token.access || '' 13 | const refreshToken = token.refresh || '' 14 | STORAGE.setItem(TOKEN_NAME, accessToken || STORAGE.getItem(TOKEN_NAME)) 15 | STORAGE.setItem(UPDATE_TOKEN_NAME, refreshToken || STORAGE.getItem(UPDATE_TOKEN_NAME)) 16 | } 17 | 18 | export function removeAccessToken() { 19 | STORAGE.setItem(TOKEN_NAME, '') 20 | STORAGE.setItem(UPDATE_TOKEN_NAME, '') 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/http/client.js: -------------------------------------------------------------------------------- 1 | import Axios from 'axios' 2 | import { SERVER_TOKEN, SERVER_TOKEN_NOT_VALID, LOGIN_REQUIRED_VALID } from 'setting' 3 | 4 | import { requestUpdateToken, queryInfoData } from '@modules/token' 5 | 6 | import { UPDATE_TOKEN } from '../chromeApis/onMessage' 7 | import { sendMessage } from '../chromeApis/sendMessage' 8 | import { getAccessToken, getRefreshToken, setAccessToken, removeAccessToken } from './auth' 9 | import queryFilter from './queryFilter' 10 | 11 | /** 12 | * * Basic 13 | * * Security scheme type: HTTP 14 | * * HTTP Authorization Scheme basic 15 | 16 | * * JWT 17 | * * Security scheme type: API Key 18 | * * Header parameter name: Authorization 19 | */ 20 | 21 | export const axiosSetting = { 22 | scheme: 'https', 23 | host: 'urlink-official.com', 24 | api: '/api/v1', 25 | port: '', 26 | server: function () { 27 | return (this.scheme ? this.scheme + ':' : '') + '//' + this.host + this.api + (this.port ? ':' + this.port : '') 28 | }, 29 | redirectPage: () => { 30 | window.location.reload() 31 | }, 32 | } 33 | 34 | export const axios = Axios.create({ 35 | baseURL: axiosSetting.server(), 36 | }) 37 | 38 | // Add a request interceptor 39 | axios.interceptors.request.use( 40 | (config) => { 41 | const accessToken = getAccessToken() 42 | if (accessToken) config.headers.Authorization = `${SERVER_TOKEN} ${accessToken}` 43 | return config 44 | }, 45 | (error) => { 46 | return Promise.reject(error) 47 | } 48 | ) 49 | 50 | // Add a response interceptor 51 | axios.interceptors.response.use( 52 | (response) => response, 53 | async (error) => { 54 | if (!error.response) { 55 | error.response = { data: { message: '네트워크 연결이 끊어져 있습니다.' } } 56 | } 57 | const UPDATE_TOKEN_API = queryInfoData['updateToken'].API 58 | const CHECK_TOKEN_API = queryInfoData['checkToken'].API 59 | 60 | const status = error.response.status || '' 61 | const response = error.response.data || {} 62 | const originalRequest = error.config || {} 63 | const url = originalRequest.url || '' 64 | 65 | // API 호출 시, accessToken 만료일 때 66 | if ( 67 | status === 401 && 68 | response.code === SERVER_TOKEN_NOT_VALID && 69 | url.indexOf(UPDATE_TOKEN_API) === -1 // UPDATE TOKEN 요청이 아닐 때 70 | ) { 71 | try { 72 | const refresh = getRefreshToken() 73 | if (!refresh) throw new Error() 74 | 75 | const response = await requestUpdateToken({ refresh }) 76 | if (!response.data) throw new Error() 77 | else { 78 | setAccessToken(response.data) 79 | sendMessage({ message: UPDATE_TOKEN }) 80 | // 만약 이전에 보냈던 url이 TOKEN 검사 요청이었을 때 81 | if (url.indexOf(CHECK_TOKEN_API) > -1) { 82 | const token = getAccessToken() 83 | const queryData = queryInfoData['updateToken'] 84 | const checkToken = queryFilter({ queryData, originDataInfo: { token } }) 85 | originalRequest.data = checkToken 86 | } 87 | return axios.request(originalRequest) 88 | } 89 | } catch (error) { 90 | console.dir(error) 91 | removeAccessToken() 92 | axiosSetting.redirectPage() // token_not_valid login => go login!! 93 | } 94 | } 95 | // login 필수 96 | else if (status === 401 && response.detail?.indexOf(LOGIN_REQUIRED_VALID) > -1) { 97 | removeAccessToken() 98 | axiosSetting.redirectPage() // no authentication => go login!! 99 | } 100 | // 그 외는 서버에서 내리는 에러 101 | else return Promise.reject(error) 102 | } 103 | ) 104 | -------------------------------------------------------------------------------- /src/utils/http/queryFilter.js: -------------------------------------------------------------------------------- 1 | function queryFilter({ queryData, queryType = 'bodyQuery', originDataInfo }) { 2 | let dataInfo = {} 3 | let isFormData = false 4 | const dataKeys = Object.keys(queryData[queryType]) 5 | if (originDataInfo.toString().match('FormData')) { 6 | dataInfo = new FormData() 7 | isFormData = true 8 | } 9 | 10 | if (isFormData) { 11 | for (const [key, value] of originDataInfo.entries()) { 12 | if (dataKeys.includes(key)) dataInfo.append(key, value) 13 | } 14 | } else { 15 | Object.entries(originDataInfo).forEach(([key, value]) => { 16 | if (dataKeys.includes(key)) dataInfo[key] = value 17 | }) 18 | } 19 | 20 | if (isFormData && queryType === 'urlQuery') return Object.fromEntries(dataInfo) 21 | else return dataInfo 22 | } 23 | 24 | export default queryFilter 25 | -------------------------------------------------------------------------------- /src/utils/http/ws.js: -------------------------------------------------------------------------------- 1 | import { getAccessToken } from './auth' 2 | import { axiosSetting } from './client' 3 | 4 | export const SOCKET_READY_STATE = { 5 | OPEN: 1, 6 | CLOSED: 3, 7 | } 8 | 9 | export const socketInfoData = { 10 | host: `wss://${axiosSetting.host}/ws/connection/?token={token}`, 11 | replaceWS(token) { 12 | return this.host.replace('{token}', token) 13 | }, 14 | } 15 | 16 | class AlarmWS { 17 | constructor() { 18 | this.connectionRetry = 1 19 | this.ws = null 20 | this.connectionRetry = 1 21 | } 22 | 23 | onConnection(settingWs = {}) { 24 | this.ws = new WebSocket(socketInfoData.replaceWS(getAccessToken())) 25 | const { onmessage, onerror, onclose } = settingWs 26 | this.ws.onmessage = onmessage 27 | this.ws.onerror = onerror 28 | this.ws.onclose = onclose 29 | return this 30 | } 31 | 32 | onClose() { 33 | if (!this.ws) return 34 | this.ws.close() 35 | return this 36 | } 37 | 38 | setOnmessage(callback) { 39 | try { 40 | if (!this.ws) this.onConnection() 41 | if (callback && typeof callback === 'function') this.ws.onmessage = callback 42 | else { 43 | this.ws.onmessage = (e) => { 44 | const { data } = JSON.parse(e.data) 45 | console.log(data) 46 | } 47 | } 48 | return this 49 | } catch (error) { 50 | console.error(error) 51 | } 52 | } 53 | 54 | setOnerror(callback, noRequestConnection = false) { 55 | try { 56 | if (!this.ws) return 57 | if (noRequestConnection && callback && typeof callback === 'function') this.ws.onerror = callback 58 | else { 59 | this.ws.onerror = async (event) => { 60 | if (callback && typeof callback === 'function') callback(event) 61 | if (this.ws.readyState === SOCKET_READY_STATE.CLOSED && this.connectionRetry <= 10) { 62 | setTimeout(() => { 63 | this.onConnection(this.ws) 64 | this.connectionRetry++ 65 | }, 2000) 66 | } 67 | } 68 | } 69 | return this 70 | } catch (error) { 71 | console.error(error) 72 | } 73 | } 74 | } 75 | 76 | export const alarmSocket = new AlarmWS() 77 | --------------------------------------------------------------------------------