├── .github
├── ISSUE_TEMPLATE
│ └── new-issue.md
├── auto_assign.yml
├── pull_request_template.md
└── workflows
│ ├── assign_action.yml
│ ├── auto-test.be.yml
│ ├── auto-test.fe.yml
│ ├── deploy_dev.yml
│ ├── deploy_prod.be.yml
│ └── deploy_prod.fe.yml
├── .gitignore
├── README.md
├── backend
├── .dockerignore
├── .env.sample
├── .eslintrc.js
├── .gitignore
├── .prettierrc.json
├── Dockerfile
├── README.md
├── nest-cli.json
├── package-lock.json
├── package.json
├── src
│ ├── app.module.ts
│ ├── batch
│ │ ├── batch.config.ts
│ │ ├── batch.queue.ts
│ │ ├── batch.service.ts
│ │ ├── batcher.abstract.ts
│ │ ├── batcher.doi.ts
│ │ ├── batcher.search.ts
│ │ └── tests
│ │ │ ├── batch.service.spec.ts
│ │ │ ├── batcher.mock.ts
│ │ │ └── batcher.spec.ts
│ ├── envLayer.ts
│ ├── main.ts
│ ├── ranking
│ │ ├── entities
│ │ │ ├── ranking.dto.ts
│ │ │ └── ranking.entity.ts
│ │ ├── ranking.controller.ts
│ │ ├── ranking.module.ts
│ │ ├── ranking.service.ts
│ │ └── tests
│ │ │ ├── ranking.controller.spec.ts
│ │ │ ├── ranking.service.mock.ts
│ │ │ └── rankingData.mock.ts
│ ├── search
│ │ ├── entities
│ │ │ ├── crossRef.entity.ts
│ │ │ └── search.dto.ts
│ │ ├── search.controller.ts
│ │ ├── search.module.ts
│ │ ├── search.service.ts
│ │ └── tests
│ │ │ ├── crossref.mock.ts
│ │ │ ├── search.controller.spec.ts
│ │ │ ├── search.service.mock.ts
│ │ │ └── searchdata.mock.ts
│ └── util.ts
├── tsconfig.build.json
└── tsconfig.json
├── docker-compose
├── docker-compose.be.prod.yml
├── docker-compose.dev.yml
├── docker-compose.elasticsearch.yml
└── docker-compose.fe.prod.yml
└── frontend
├── .dockerignore
├── .env.sample
├── .eslintrc.json
├── .gitignore
├── .prettierrc.json
├── Dockerfile
├── README.md
├── config-overrides.js
├── nginx.conf
├── package-lock.json
├── package.json
├── public
├── assets
│ ├── moon.png
│ └── prv-image.png
├── favicon.ico
├── index.html
└── robots.txt
├── src
├── App.test.tsx
├── App.tsx
├── api
│ └── api.ts
├── components
│ ├── Footer.tsx
│ ├── IconButton.tsx
│ ├── Pagination.tsx
│ ├── index.ts
│ ├── loader
│ │ ├── LoaderWrapper.tsx
│ │ └── MoonLoader.tsx
│ └── search
│ │ ├── AutoCompletedList.tsx
│ │ ├── RecentKeywordsList.tsx
│ │ └── Search.tsx
├── constants
│ └── path.ts
├── custom.d.ts
├── error
│ ├── ErrorBoundary.tsx
│ ├── GlobalErrorFallback.tsx
│ ├── RankingErrorFallback.tsx
│ └── index.ts
├── hooks
│ ├── graph
│ │ ├── useGraph.ts
│ │ ├── useGraphData.ts
│ │ ├── useGraphEmphasize.ts
│ │ └── useGraphZoom.ts
│ ├── index.ts
│ ├── useDebouncedValue.ts
│ └── useInterval.ts
├── icons
│ ├── ClockIcon.tsx
│ ├── DropdownIcon.tsx
│ ├── DropdownReverseIcon.tsx
│ ├── GithubLogoIcon.tsx
│ ├── InfoIcon.tsx
│ ├── LogoIcon.tsx
│ ├── MagnifyingGlassIcon.tsx
│ ├── PreviousButtonIcon.tsx
│ ├── XIcon.tsx
│ └── index.ts
├── index.tsx
├── pages
│ ├── Main
│ │ ├── Main.tsx
│ │ └── components
│ │ │ ├── KeywordRanking.tsx
│ │ │ ├── RankingSlide.tsx
│ │ │ └── StarLayer.tsx
│ ├── PaperDetail
│ │ ├── PaperDetail.tsx
│ │ ├── components
│ │ │ ├── ColorRangeBar.tsx
│ │ │ ├── InfoTooltip.tsx
│ │ │ ├── PaperInfo.tsx
│ │ │ └── ReferenceGraph.tsx
│ │ └── workers
│ │ │ └── forceSimulation.worker.ts
│ └── SearchList
│ │ ├── SearchList.tsx
│ │ └── components
│ │ ├── Paper.tsx
│ │ ├── SearchBarHeader.tsx
│ │ └── SearchResults.tsx
├── queries
│ ├── queries.ts
│ └── query-key.ts
├── react-app-env.d.ts
├── style
│ ├── GlobalStyle.tsx
│ ├── styleUtils.ts
│ └── theme.ts
└── utils
│ ├── createQueryString.ts
│ ├── format.tsx
│ └── storage.ts
├── tsconfig.json
└── tsconfig.paths.json
/.github/ISSUE_TEMPLATE/new-issue.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: New Issue
3 | about: 이슈 템플릿 입니다. 해결과정 / 질문 / 의견은 댓글로 남깁니다.
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | ## 이슈 내용
11 | 무슨 이슈인지에 대한 설명을 적어주세요.
12 |
13 | ## 기대 결과
14 | - 원하는 결과물에 대한 내용을 적어주세요.
15 |
--------------------------------------------------------------------------------
/.github/auto_assign.yml:
--------------------------------------------------------------------------------
1 | # Set to true to add reviewers to pull requests
2 | addReviewers: true
3 |
4 | # Set to true to add assignees to pull requests
5 | addAssignees: false
6 |
7 | reviewers:
8 | - leesungbin
9 | - yeynii
10 | - Palwol
11 | - JunYupK
12 |
13 | numberOfReviewers: 2
14 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | ## 개요
2 | 여기에 기능에 대한 간략한 설명을 적어주세요.
3 |
4 | ## 작업사항
5 | - 여기에 기능에 대한 작업 사항을 적어주세요
6 |
7 | ## 리뷰 요청사항
8 | - N/A
9 |
--------------------------------------------------------------------------------
/.github/workflows/assign_action.yml:
--------------------------------------------------------------------------------
1 | name: 'Auto Assign Reviewers'
2 | on:
3 | pull_request:
4 | types: [opened, ready_for_review]
5 |
6 | jobs:
7 | add-reviews:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - uses: kentaro-m/auto-assign-action@v1.2.4
11 |
--------------------------------------------------------------------------------
/.github/workflows/auto-test.be.yml:
--------------------------------------------------------------------------------
1 | name: "Backend Tester"
2 | on:
3 | pull_request:
4 | types: [opened, ready_for_review, synchronize]
5 | paths:
6 | - "backend/**"
7 |
8 | jobs:
9 | run-test:
10 | runs-on: ubuntu-latest
11 | strategy:
12 | matrix:
13 | node-version: [18.x]
14 | steps:
15 | - uses: actions/checkout@v3
16 | - name: BE Test - ${{ matrix.node-version }}
17 | uses: actions/setup-node@v3
18 | with:
19 | node-version: ${{ matrix.node-version }}
20 | - run: |
21 | cd backend
22 | npm ci
23 | npm test
24 |
--------------------------------------------------------------------------------
/.github/workflows/auto-test.fe.yml:
--------------------------------------------------------------------------------
1 | name: "Frontend Tester"
2 | on:
3 | pull_request:
4 | types: [opened, ready_for_review, synchronize]
5 | paths:
6 | - "frontend/**"
7 |
8 | jobs:
9 | run-test:
10 | runs-on: ubuntu-latest
11 | strategy:
12 | matrix:
13 | node-version: [18.x]
14 | steps:
15 | - uses: actions/checkout@v3
16 | - name: FE Test - ${{ matrix.node-version }}
17 | uses: actions/setup-node@v3
18 | with:
19 | node-version: ${{ matrix.node-version }}
20 | - run: |
21 | cd frontend
22 | npm ci
23 | npm test
24 |
--------------------------------------------------------------------------------
/.github/workflows/deploy_dev.yml:
--------------------------------------------------------------------------------
1 | name: Auto deploy to NCP
2 |
3 | on:
4 | push:
5 | branches:
6 | - dev
7 |
8 | jobs:
9 | changes:
10 | name: Diff check
11 | runs-on: ubuntu-latest
12 | permissions:
13 | pull-requests: read
14 | outputs:
15 | backend: ${{ steps.filter.outputs.backend }}
16 | frontend: ${{ steps.filter.outputs.frontend }}
17 | steps:
18 | - uses: actions/checkout@v3
19 | - uses: dorny/paths-filter@v2
20 | id: filter
21 | with:
22 | filters: |
23 | backend:
24 | - 'backend/**'
25 | frontend:
26 | - 'frontend/**'
27 | push_to_registry_be:
28 | name: (BE) Build & Push
29 | needs: changes
30 | if: ${{ needs.changes.outputs.backend == 'true' }}
31 | runs-on: ubuntu-latest
32 | steps:
33 | - name: Checkout
34 | uses: actions/checkout@v3
35 | - name: Set up Docker Buildx 1
36 | uses: docker/setup-buildx-action@v2
37 | - name: Login to NCP Container Registry
38 | uses: docker/login-action@v2
39 | with:
40 | registry: ${{ secrets.NCP_CONTAINER_REGISTRY }}
41 | username: ${{ secrets.NCP_ACCESS_KEY }}
42 | password: ${{ secrets.NCP_SECRET_KEY }}
43 | - name: build and push
44 | uses: docker/build-push-action@v3
45 | with:
46 | context: ./backend
47 | file: ./backend/Dockerfile
48 | push: true
49 | tags: |
50 | ${{ secrets.NCP_CONTAINER_REGISTRY }}/prv-backend:latest
51 | ${{ secrets.NCP_CONTAINER_REGISTRY }}/prv-backend:${{ github.run_number }}
52 | cache-from: type=registry,ref=${{ secrets.NCP_CONTAINER_REGISTRY }}/prv-backend:latest
53 | cache-to: type=inline
54 |
55 | push_to_registry_fe:
56 | name: (FE) Build & Push
57 | needs: changes
58 | if: ${{ needs.changes.outputs.frontend == 'true' }}
59 | runs-on: ubuntu-latest
60 | steps:
61 | - name: Checkout
62 | uses: actions/checkout@v3
63 | - name: Set up Docker Buildx 1
64 | uses: docker/setup-buildx-action@v2
65 | - name: Login to NCP Container Registry
66 | uses: docker/login-action@v2
67 | with:
68 | registry: ${{ secrets.NCP_CONTAINER_REGISTRY }}
69 | username: ${{ secrets.NCP_ACCESS_KEY }}
70 | password: ${{ secrets.NCP_SECRET_KEY }}
71 | - name: build and push
72 | uses: docker/build-push-action@v3
73 | with:
74 | context: ./frontend
75 | file: ./frontend/Dockerfile
76 | push: true
77 | tags: |
78 | ${{ secrets.NCP_CONTAINER_REGISTRY }}/prv-frontend:latest
79 | ${{ secrets.NCP_CONTAINER_REGISTRY }}/prv-frontend:${{ github.run_number }}
80 | cache-from: type=registry,ref=${{ secrets.NCP_CONTAINER_REGISTRY }}/prv-frontend:latest
81 | cache-to: type=inline
82 | secrets: |
83 | "REACT_APP_BASE_URL=${{ secrets.REACT_APP_BASE_URL_DEV }}"
84 |
85 | pull_from_registry:
86 | name: Connect server ssh and pull from container registry
87 | needs: [push_to_registry_be, push_to_registry_fe]
88 | if: |
89 | always() &&
90 | (needs.push_to_registry_be.result == 'success' || needs.push_to_registry_fe.result == 'success')
91 | runs-on: ubuntu-latest
92 | steps:
93 | - name: connect ssh
94 | uses: appleboy/ssh-action@master
95 | with:
96 | host: ${{ secrets.DEV_HOST }}
97 | username: ${{ secrets.DEV_USERNAME }}
98 | password: ${{ secrets.DEV_PASSWORD }}
99 | port: ${{ secrets.DEV_PORT }}
100 | script: |
101 | docker compose --env-file ${{ secrets.ENV_FILENAME_DOCKER_COMPOSE_DEV }} pull
102 | docker compose --env-file ${{ secrets.ENV_FILENAME_DOCKER_COMPOSE_DEV }} up -d
103 | docker image prune -f
104 |
--------------------------------------------------------------------------------
/.github/workflows/deploy_prod.be.yml:
--------------------------------------------------------------------------------
1 | name: Production Deploy(BE)
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | paths:
8 | - 'backend/**'
9 |
10 | jobs:
11 | push_to_registry_be:
12 | name: (BE) Build & Push
13 | runs-on: ubuntu-latest
14 | steps:
15 | - name: Checkout
16 | uses: actions/checkout@v3
17 | - name: Set up Docker Buildx 1
18 | uses: docker/setup-buildx-action@v2
19 | - name: Login to NCP Container Registry
20 | uses: docker/login-action@v2
21 | with:
22 | registry: ${{ secrets.PRODUCTION_CONTAINER_REGISTRY }}
23 | username: ${{ secrets.PRODUCTION_ACCESS_KEY }}
24 | password: ${{ secrets.PRODUCTION_SECRET_KEY }}
25 | - name: build and push
26 | uses: docker/build-push-action@v3
27 | with:
28 | context: ./backend
29 | file: ./backend/Dockerfile
30 | push: true
31 | tags: |
32 | ${{ secrets.PRODUCTION_CONTAINER_REGISTRY }}/prv-backend:latest
33 | ${{ secrets.PRODUCTION_CONTAINER_REGISTRY }}/prv-backend:${{ github.run_number }}
34 | cache-from: type=registry,ref=${{ secrets.PRODUCTION_CONTAINER_REGISTRY }}/prv-backend:latest
35 | cache-to: type=inline
36 |
37 | pull_from_registry:
38 | name: Connect server ssh and pull from container registry
39 | needs: push_to_registry_be
40 | runs-on: ubuntu-latest
41 | steps:
42 | - name: connect ssh
43 | uses: appleboy/ssh-action@master
44 | with:
45 | host: ${{ secrets.PRODUCTION_BE_HOST }}
46 | username: ${{ secrets.PRODUCTION_BE_USERNAME }}
47 | password: ${{ secrets.PRODUCTION_BE_PASSWORD }}
48 | port: ${{ secrets.PRODUCTION_BE_PORT }}
49 | script: |
50 | docker-compose --env-file ${{ secrets.ENV_FILENAME_BACKEND }} -f docker-compose.be.prod.yml pull
51 | docker-compose --env-file ${{ secrets.ENV_FILENAME_BACKEND }} -f docker-compose.be.prod.yml up -d
52 | docker image prune -f
53 |
--------------------------------------------------------------------------------
/.github/workflows/deploy_prod.fe.yml:
--------------------------------------------------------------------------------
1 | name: Production Deploy(FE)
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | paths:
8 | - 'frontend/**'
9 |
10 | jobs:
11 | push_to_registry_fe:
12 | name: (FE) Build & Push
13 | runs-on: ubuntu-latest
14 | steps:
15 | - name: Checkout
16 | uses: actions/checkout@v3
17 | - name: Set up Docker Buildx 1
18 | uses: docker/setup-buildx-action@v2
19 | - name: Login to NCP Container Registry
20 | uses: docker/login-action@v2
21 | with:
22 | registry: ${{ secrets.PRODUCTION_CONTAINER_REGISTRY }}
23 | username: ${{ secrets.PRODUCTION_ACCESS_KEY }}
24 | password: ${{ secrets.PRODUCTION_SECRET_KEY }}
25 | - name: build and push
26 | uses: docker/build-push-action@v3
27 | with:
28 | context: ./frontend
29 | file: ./frontend/Dockerfile
30 | push: true
31 | tags: |
32 | ${{ secrets.PRODUCTION_CONTAINER_REGISTRY }}/prv-frontend:latest
33 | ${{ secrets.PRODUCTION_CONTAINER_REGISTRY }}/prv-frontend:${{ github.run_number }}
34 | cache-from: type=registry,ref=${{ secrets.PRODUCTION_CONTAINER_REGISTRY }}/prv-frontend:latest
35 | cache-to: type=inline
36 | secrets: |
37 | REACT_APP_BASE_URL=${{ secrets.REACT_APP_BASE_URL_PROD }}
38 |
39 | pull_from_registry:
40 | name: Connect server ssh and pull from container registry
41 | needs: push_to_registry_fe
42 | runs-on: ubuntu-latest
43 | steps:
44 | - name: connect ssh
45 | uses: appleboy/ssh-action@master
46 | with:
47 | host: ${{ secrets.PRODUCTION_FE_HOST }}
48 | username: ${{ secrets.PRODUCTION_FE_USERNAME }}
49 | password: ${{ secrets.PRODUCTION_FE_PASSWORD }}
50 | port: ${{ secrets.PRODUCTION_FE_PORT }}
51 | script: |
52 | docker-compose --env-file ${{ secrets.ENV_FILENAME_FRONTEND }} -f docker-compose.fe.prod.yml pull
53 | docker-compose --env-file ${{ secrets.ENV_FILENAME_FRONTEND }} -f docker-compose.fe.prod.yml up -d
54 | docker image prune -f
55 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | .secrets
3 | .czrc
4 | .coverage
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # 🌟 PRV(Paper Reference Visualization)
4 |
5 | **Paper Reference Visualization**
6 |
7 | 논문 검색 & 레퍼런스 시각화 사이트
8 |
9 | 
10 | 
11 | 
12 |
13 |
14 |
15 | ### 기능 목록
16 |
17 | #### 인기 검색어
18 |
19 | 
20 |
21 | - 검색량이 많은 검색어를 1~10위까지 보여줍니다.
22 | - 검색어를 클릭하면 해당 키워드로 검색된 리스트 페이지로 이동합니다.
23 |
24 | #### 논문 검색
25 |
26 | 
27 |
28 | - 검색창에 포커스하면 최근 검색어 목록을 5개까지 보여줍니다.
29 | - 키워드를 2자이상 입력하면 자동완성 검색어 목록을 보여줍니다.
30 | - 저자, 제목, 키워드를 입력하여 검색버튼을 누르면 검색 리스트로 이동합니다.
31 | - DOI로 검색하면 바로 해당 논문의 시각화 페이지로 이동합니다.
32 | - 최근 검색어 목록이나 자동완성 검색어는 mouse-over, 방향키 이벤트로 커서를 이동시킬 수 있습니다.
33 |
34 | #### 논문 리스트
35 |
36 | 
37 |
38 | - 키워드와 유사성이 높은 논문 목록을 보여줍니다.
39 | - 리스트는 20개 단위로 페이지네이션 됩니다.
40 |
41 | #### 논문 시각화 페이지
42 |
43 | 
44 |
45 | - 좌측에서는 선택한 논문의 정보(제목, 저자, DOI, 인용논문 목록)을 보여줍니다.
46 | - 인용 논문 목록에 포함된 논문제목을 hovering하면 오른쪽 그래프에서 해당하는 논문node를 강조합니다.
47 | - 우측에서는 선택한 논문의 데이터로 시각화된 네트워크 차트를 보여줍니다.
48 | - 논문은 node, 논문간 인용관계는 line으로 표현됩니다.
49 | - 주위 node를 클릭하면 해당 논문node의 인용관계가 추가로 시각화 됩니다.
50 | - node에 호버링하면 해당 논문과 해당 논문이 인용한 논문들의 nodes, lines가 강조됩니다.
51 | - 마우스 드래그로 그래프 위치를 옮길 수 있습니다.
52 | - 스크롤로 그래프를 zoom-in, zoom-out 할 수 있습니다.
53 |
54 | ### 팀원
55 |
56 |
57 | J053
58 | J073
59 | J143
60 | J205
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 | 김준엽
69 |
70 | 박미림
71 |
72 | 이성빈
73 |
74 | 최예윤
75 |
76 |
77 |
78 | ### 개발 환경 세팅
79 |
80 | > 환경변수는 `/frontend`, `/backend` 폴더에 있는 `.env.sample` 파일을 참고해주시기 바랍니다.
81 |
82 | #### Front-end
83 |
84 | ```bash
85 | cd frontend
86 | npm install
87 | npm start
88 | ```
89 |
90 | #### Back-end
91 |
92 | ```bash
93 | cd backend
94 | npm install
95 | npm start
96 | ```
97 |
98 | ## 기술스택
99 | 
100 |
101 | ## 데이터 수집 정책
102 |
103 | - PRV 서비스에서 사용되는 모든 논문 정보는 Crossref API를 통해 수집됩니다.
104 | - 사용자로부터 수집되는 정보는 다음과 같습니다.
105 | - 검색 키워드
106 | - 수집되는 정보는 다음과 같은 목적으로 이용합니다.
107 | - 인기 검색어 서비스 제공
108 | - 키워드 자동완성 검색 서비스 제공
109 | - 키워드 검색 서비스 제공
110 | - 논문 DOI를 통한 인용관계 시각화 서비스 제공
111 | - 사용자는 키워드 검색시 PRV 데이터베이스에 있는 정보 혹은 Crossref API를 통해 요청한 정보를 조회할 수 있으며, 데이터베이스에 없는 논문에 대한 데이터 수집은 Request batch에 의해 처리되므로 검색 결과를 즉시 받아보지 못할 수 있습니다.
112 | - Request batch에 의해 수집된 결과는 데이터베이스에 저장됩니다.
113 | - 추가 문의사항은 viewpoint.prv@gmail.com 로 연락바랍니다.
114 |
115 | ### [Crossref](https://www.crossref.org/) API
116 |
117 | - Crossref : Official digital object identifier Registration Agency of the International DOI Foundation.
118 | - 22.12.08. 기준 140,229,346개의 논문 메타데이터를 보유 중
119 | - License - Creative Commons Attribution 4.0 International (CC BY 4.0)
120 |
121 |
123 |
--------------------------------------------------------------------------------
/backend/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
--------------------------------------------------------------------------------
/backend/.env.sample:
--------------------------------------------------------------------------------
1 | PORT=
2 | REDIS_POPULAR_KEY=
3 | REDIS_PREVRANKING=
4 | REDIS_HOST=
5 | REDIS_PORT=
6 | REDIS_PASSWORD=
7 | ELASTIC_INDEX=
8 | ELASTIC_HOST=
9 | ELASTIC_USER=
10 | ELASTIC_PASSWORD=
11 | MAIL_TO=
12 | ALLOW_UPDATE=
13 | SHOULD_RUN_BATCH=
--------------------------------------------------------------------------------
/backend/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | parser: '@typescript-eslint/parser',
3 | parserOptions: {
4 | project: 'tsconfig.json',
5 | tsconfigRootDir: __dirname,
6 | sourceType: 'module',
7 | },
8 | plugins: ['@typescript-eslint/eslint-plugin'],
9 | extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'],
10 | root: true,
11 | env: {
12 | node: true,
13 | jest: true,
14 | },
15 | ignorePatterns: ['.eslintrc.js'],
16 | rules: {
17 | '@typescript-eslint/interface-name-prefix': 'off',
18 | '@typescript-eslint/explicit-function-return-type': 'off',
19 | '@typescript-eslint/explicit-module-boundary-types': 'off',
20 | '@typescript-eslint/no-explicit-any': 'off',
21 | },
22 | };
23 |
--------------------------------------------------------------------------------
/backend/.gitignore:
--------------------------------------------------------------------------------
1 | # compiled output
2 | /dist
3 | /node_modules
4 |
5 | # Logs
6 | logs
7 | *.log
8 | npm-debug.log*
9 | pnpm-debug.log*
10 | yarn-debug.log*
11 | yarn-error.log*
12 | lerna-debug.log*
13 |
14 | # OS
15 | .DS_Store
16 |
17 | # Tests
18 | /coverage
19 | /.nyc_output
20 |
21 | # IDEs and editors
22 | /.idea
23 | .project
24 | .classpath
25 | .c9/
26 | *.launch
27 | .settings/
28 | *.sublime-workspace
29 |
30 | # IDE - VSCode
31 | .vscode/*
32 | !.vscode/settings.json
33 | !.vscode/tasks.json
34 | !.vscode/launch.json
35 | !.vscode/extensions.json
36 |
37 | # env
38 | *.env
--------------------------------------------------------------------------------
/backend/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "endOfLine": "auto",
3 | "tabWidth": 2,
4 | "semi": true,
5 | "singleQuote": true,
6 | "trailingComma": "all",
7 | "printWidth": 120,
8 | "bracketSpacing": true,
9 | "useTabs": false
10 | }
--------------------------------------------------------------------------------
/backend/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:18
2 | WORKDIR /app
3 | COPY package*.json .
4 | RUN npm ci
5 | COPY . .
6 | RUN npm run build
7 | CMD npm run start:prod
--------------------------------------------------------------------------------
/backend/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
6 | [circleci-url]: https://circleci.com/gh/nestjs/nest
7 |
8 | A progressive Node.js framework for building efficient and scalable server-side applications.
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
24 |
25 | ## Description
26 |
27 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
28 |
29 | ## Installation
30 |
31 | ```bash
32 | $ npm install
33 | ```
34 |
35 | ## Running the app
36 |
37 | ```bash
38 | # development
39 | $ npm run start
40 |
41 | # watch mode
42 | $ npm run start:dev
43 |
44 | # production mode
45 | $ npm run start:prod
46 | ```
47 |
48 | ## Test
49 |
50 | ```bash
51 | # unit tests
52 | $ npm run test
53 |
54 | # e2e tests
55 | $ npm run test:e2e
56 |
57 | # test coverage
58 | $ npm run test:cov
59 | ```
60 |
61 | ## Support
62 |
63 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
64 |
65 | ## Stay in touch
66 |
67 | - Author - [Kamil Myśliwiec](https://kamilmysliwiec.com)
68 | - Website - [https://nestjs.com](https://nestjs.com/)
69 | - Twitter - [@nestframework](https://twitter.com/nestframework)
70 |
71 | ## License
72 |
73 | Nest is [MIT licensed](LICENSE).
74 |
--------------------------------------------------------------------------------
/backend/nest-cli.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/nest-cli",
3 | "collection": "@nestjs/schematics",
4 | "sourceRoot": "src"
5 | }
--------------------------------------------------------------------------------
/backend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "backend",
3 | "version": "0.0.1",
4 | "description": "",
5 | "author": "",
6 | "private": true,
7 | "license": "UNLICENSED",
8 | "scripts": {
9 | "prebuild": "rimraf dist",
10 | "build": "nest build",
11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
12 | "start": "nest start",
13 | "start:mac": "env $(cat be.env | grep -v '#' | xargs) npm start",
14 | "start:dev": "nest start --watch",
15 | "start:debug": "nest start --debug --watch",
16 | "start:prod": "node dist/main",
17 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
18 | "test": "jest",
19 | "test:watch": "jest --watch",
20 | "test:cov": "jest --coverage",
21 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
22 | "test:e2e": "jest --config ./test/jest-e2e.json"
23 | },
24 | "dependencies": {
25 | "@elastic/elasticsearch": "^8.5.0",
26 | "@liaoliaots/nestjs-redis": "^9.0.4",
27 | "@nestjs/axios": "^1.0.0",
28 | "@nestjs/common": "^9.0.0",
29 | "@nestjs/config": "^2.2.0",
30 | "@nestjs/core": "^9.0.0",
31 | "@nestjs/platform-express": "^9.0.0",
32 | "@nestjs/schedule": "^2.1.0",
33 | "cache-manager": "^5.1.3",
34 | "class-transformer": "^0.5.1",
35 | "class-validator": "^0.13.2",
36 | "ioredis": "^5.2.4",
37 | "ioredis-mock": "^8.2.2",
38 | "reflect-metadata": "^0.1.13",
39 | "rimraf": "^3.0.2",
40 | "rxjs": "^7.2.0"
41 | },
42 | "devDependencies": {
43 | "@nestjs/cli": "^9.0.0",
44 | "@nestjs/elasticsearch": "^9.0.0",
45 | "@nestjs/schematics": "^9.0.0",
46 | "@nestjs/swagger": "^6.1.3",
47 | "@nestjs/testing": "^9.0.0",
48 | "@types/cron": "^2.0.0",
49 | "@types/express": "^4.17.13",
50 | "@types/ioredis-mock": "^8.2.0",
51 | "@types/jest": "28.1.8",
52 | "@types/node": "^16.0.0",
53 | "@types/supertest": "^2.0.11",
54 | "@typescript-eslint/eslint-plugin": "^5.0.0",
55 | "@typescript-eslint/parser": "^5.0.0",
56 | "axios": "^1.1.3",
57 | "eslint": "^8.0.1",
58 | "eslint-config-prettier": "^8.3.0",
59 | "eslint-plugin-prettier": "^4.0.0",
60 | "jest": "28.1.3",
61 | "prettier": "^2.3.2",
62 | "redis-mock": "^0.56.3",
63 | "source-map-support": "^0.5.20",
64 | "supertest": "^6.1.3",
65 | "ts-jest": "28.0.8",
66 | "ts-loader": "^9.2.3",
67 | "ts-node": "^10.0.0",
68 | "tsconfig-paths": "4.1.0",
69 | "typescript": "^4.7.4"
70 | },
71 | "jest": {
72 | "moduleFileExtensions": [
73 | "js",
74 | "json",
75 | "ts"
76 | ],
77 | "roots": [
78 | ""
79 | ],
80 | "modulePaths": [
81 | ""
82 | ],
83 | "moduleDirectories": [
84 | "node_modules"
85 | ],
86 | "testRegex": ".*\\.spec\\.ts$",
87 | "transform": {
88 | "^.+\\.(t|j)s$": "ts-jest"
89 | },
90 | "collectCoverageFrom": [
91 | "**/*.(t|j)s"
92 | ],
93 | "coverageDirectory": "../coverage",
94 | "testEnvironment": "node",
95 | "testTimeout": 50000
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/backend/src/app.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { RankingModule } from './ranking/ranking.module';
3 | import { SearchModule } from './search/search.module';
4 |
5 | @Module({
6 | imports: [RankingModule, SearchModule],
7 | })
8 | export class AppModule {}
9 |
--------------------------------------------------------------------------------
/backend/src/batch/batch.config.ts:
--------------------------------------------------------------------------------
1 | export const TIME_INTERVAL = 1 * 1000;
2 | export const RESTART_INTERVAL = 2 * 1000 * 60;
3 | export const MAX_RETRY = 3;
4 | export const MAX_DEPTH = 2;
5 | export const SEARCH_BATCH_SIZE = 5;
6 | export const DOI_BATCH_SIZE = 20;
7 | export const DOI_REGEXP = new RegExp(/^[\d]{2}\.[\d]{1,}\/.*/);
8 |
--------------------------------------------------------------------------------
/backend/src/batch/batch.queue.ts:
--------------------------------------------------------------------------------
1 | import Redis from 'ioredis';
2 |
3 | export class RedisQueue {
4 | constructor(private redis: Redis, public name: string) {}
5 | async push(value: string, pushLeft = false) {
6 | if (pushLeft) {
7 | await this.redis.lpush(this.name, value);
8 | } else {
9 | await this.redis.rpush(this.name, value);
10 | }
11 |
12 | return value;
13 | }
14 | async pop(count = 1) {
15 | return await this.redis.lpop(this.name, count);
16 | }
17 | async size() {
18 | return await this.redis.llen(this.name);
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/backend/src/batch/batch.service.ts:
--------------------------------------------------------------------------------
1 | import { InjectRedis } from '@liaoliaots/nestjs-redis';
2 | import { HttpService } from '@nestjs/axios';
3 | import { Interval } from '@nestjs/schedule';
4 | import Redis from 'ioredis';
5 | import { SHOULD_RUN_BATCH } from 'src/envLayer';
6 | import { SearchService } from 'src/search/search.service';
7 | import { TIME_INTERVAL, SEARCH_BATCH_SIZE, DOI_BATCH_SIZE } from './batch.config';
8 | import { DoiBatcher } from './batcher.doi';
9 | import { SearchBatcher } from './batcher.search';
10 |
11 | export class BatchService {
12 | searchBatcher: SearchBatcher;
13 | doiBatcher: DoiBatcher;
14 | constructor(
15 | @InjectRedis() private readonly redis: Redis,
16 | private readonly httpService: HttpService,
17 | private readonly searchService: SearchService,
18 | ) {
19 | this.searchBatcher = new SearchBatcher(
20 | this.redis,
21 | this.httpService.axiosRef,
22 | this.searchService,
23 | 'url batch queue',
24 | );
25 | this.doiBatcher = new DoiBatcher(this.redis, this.httpService.axiosRef, this.searchService, 'paper batch queue');
26 | }
27 | keywordToRedisKey(keyword: string) {
28 | return `s:${keyword}`;
29 | }
30 | async keywordExist(keyword: string) {
31 | const lowercased = keyword.toLowerCase();
32 | const key = this.keywordToRedisKey(lowercased);
33 | return (await this.redis.ttl(key)) >= 0;
34 | }
35 | async setKeyword(keyword: string) {
36 | if (!SHOULD_RUN_BATCH) return false;
37 | const lowercased = keyword.toLowerCase();
38 | if (await this.keywordExist(lowercased)) return false;
39 | const key = this.keywordToRedisKey(lowercased);
40 | this.redis.set(key, 1);
41 | this.redis.expire(key, 60 * 60 * 24);
42 | return true;
43 | }
44 |
45 | @Interval(TIME_INTERVAL)
46 | batchSearchQueue(batchSize = SEARCH_BATCH_SIZE) {
47 | this.searchBatcher.runBatch(batchSize);
48 | }
49 |
50 | @Interval(TIME_INTERVAL)
51 | async batchDoiQueue(batchSize = DOI_BATCH_SIZE) {
52 | const referencesDoiWithDepth = await this.doiBatcher.runBatch(batchSize);
53 | referencesDoiWithDepth?.forEach((v) => {
54 | this.doiBatcher.pushToQueue(0, v.depth + 1, -1, false, v.doi);
55 | });
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/backend/src/batch/batcher.abstract.ts:
--------------------------------------------------------------------------------
1 | import { GetGetResult } from '@elastic/elasticsearch/lib/api/types';
2 | import { Injectable } from '@nestjs/common';
3 | import { AxiosError, AxiosInstance, AxiosResponse } from 'axios';
4 | import Redis from 'ioredis';
5 | import {
6 | CrossRefItem,
7 | CrossRefPaperResponse,
8 | CrossRefResponse,
9 | PaperInfoDetail,
10 | ReferenceInfo,
11 | } from 'src/search/entities/crossRef.entity';
12 | import { SearchService } from 'src/search/search.service';
13 | import { ALLOW_UPDATE, SHOULD_RUN_BATCH } from 'src/envLayer';
14 | import { MAX_DEPTH, RESTART_INTERVAL } from './batch.config';
15 | import { RedisQueue } from './batch.queue';
16 |
17 | export interface QueueItemParsed {
18 | retries: number;
19 | depth: number;
20 | pagesLeft: number;
21 | url: string;
22 | }
23 | export interface UrlParams {
24 | doi?: string;
25 | keyword?: string;
26 | cursor?: string;
27 | }
28 |
29 | type CrossRefAble = CrossRefResponse | CrossRefPaperResponse;
30 |
31 | @Injectable()
32 | export abstract class Batcher {
33 | static blocked = false;
34 | running = false;
35 |
36 | queue: RedisQueue;
37 | // failedQueue: RedisQueue;
38 | constructor(
39 | private readonly redis: Redis,
40 | private readonly axios: AxiosInstance,
41 | readonly searchService: SearchService,
42 | readonly name: string,
43 | ) {
44 | this.queue = new RedisQueue(this.redis, name);
45 | // this.failedQueue = new RedisQueue(this.redis, name);
46 | }
47 | abstract makeUrl(...params: string[]): string;
48 | abstract getParamsFromUrl(url: string): UrlParams;
49 | abstract onFulfilled(
50 | item: QueueItemParsed,
51 | params: UrlParams,
52 | res: AxiosResponse,
53 | i?: number,
54 | ): { papers: PaperInfoDetail[]; referenceDOIs: string[] }; // paper들의 reference들에 대한 doi 목록
55 | abstract onRejected(
56 | item: QueueItemParsed,
57 | params: UrlParams,
58 | shouldPushLeft?: boolean,
59 | res?: PromiseRejectedResult,
60 | i?: number,
61 | ): any;
62 | abstract validateBatchItem(item: QueueItemParsed): boolean;
63 |
64 | parseQueueItem(value: string) {
65 | const splits = value.split(':');
66 | return {
67 | retries: parseInt(splits[0]),
68 | depth: parseInt(splits[1]),
69 | pagesLeft: parseInt(splits[2]),
70 | url: splits.slice(3).join(':'),
71 | } as QueueItemParsed;
72 | }
73 |
74 | pushToQueue(retries = 0, depth = 0, page = -1, shouldPushLeft = false, ...params: string[]) {
75 | if (!SHOULD_RUN_BATCH) return;
76 | const url = this.makeUrl(...params);
77 | this.queue.push(`${retries}:${depth}:${page}:${url}`, shouldPushLeft);
78 | }
79 | async batchLog(queue: RedisQueue, batched: string[]) {
80 | const queueSize = await queue.size();
81 | const batchedSize = batched?.length || 0;
82 | (queueSize || batchedSize) && console.log(`${queue.name} size`, queueSize, ', batch size ', batchedSize);
83 | }
84 | fetchCrossRef(url: string) {
85 | return this.axios.get(url);
86 | }
87 |
88 | async runBatch(batchSize: number) {
89 | if (Batcher.blocked || this.running) return;
90 | const queue = this.queue;
91 | // const failedQueue = this.failedQueue;
92 | const batched = await queue.pop(batchSize);
93 | // await this.batchLog(queue, batched);
94 | if (!batched) return;
95 | this.running = true;
96 |
97 | const items = batched.map((item) => this.parseQueueItem(item)).filter((item) => this.validateBatchItem(item));
98 | const responses = await this.batchRequest(items);
99 | const { papers, doiWithDepth } = this.responsesParser(items, responses);
100 | const bulkPapers = await this.makeBulkIndex(papers);
101 | this.doBulkInsert(bulkPapers);
102 |
103 | this.running = false;
104 | return doiWithDepth;
105 | }
106 | batchRequest(items: QueueItemParsed[]) {
107 | return Promise.allSettled(items.map((item) => this.fetchCrossRef(item.url)));
108 | }
109 |
110 | responsesParser(items: QueueItemParsed[], responses: PromiseSettledResult>[]) {
111 | return responses
112 | .map((res, i) => {
113 | const params = this.getParamsFromUrl(items[i].url);
114 | if (res.status === 'fulfilled') {
115 | return this.onFulfilled(items[i], params, res.value, i);
116 | } else {
117 | if (Batcher.blocked) {
118 | this.onRejected(items[i], params, true, res, i);
119 | return;
120 | }
121 |
122 | const error = res.reason as AxiosError;
123 | // Resource not found.
124 | if (error.response?.status === 404) {
125 | return;
126 | }
127 |
128 | // Too many request
129 | if (error.response?.status === 429) {
130 | this.stopBatch();
131 | }
132 |
133 | // Timeout exceeded
134 | this.onRejected(items[i], params, false, res, i);
135 | }
136 | })
137 | .reduce(
138 | (acc, cur, i) => {
139 | if (cur?.papers) Array.prototype.push.apply(acc.papers, cur.papers);
140 | if (cur?.referenceDOIs) {
141 | const doiWithDepth = cur.referenceDOIs.map((doi) => {
142 | return { doi, depth: items[i].depth };
143 | });
144 | Array.prototype.push.apply(acc.doiWithDepth, doiWithDepth);
145 | }
146 | return acc;
147 | },
148 | { papers: [], doiWithDepth: [] as { doi: string; depth: number }[] },
149 | );
150 | }
151 |
152 | async makeBulkIndex(papers: PaperInfoDetail[]): Promise {
153 | if (ALLOW_UPDATE) return papers;
154 | const dois = papers.map((paper) => {
155 | return paper.doi;
156 | });
157 | const { docs } = await this.searchService.multiGet(dois);
158 | const indexes = docs
159 | .map((doc, i) => {
160 | if ((doc as GetGetResult).found) return;
161 | return papers[i];
162 | })
163 | .filter(Boolean);
164 |
165 | // console.log(`${this.queue.name} skipped papers:`, papers.length - indexes.length);
166 | return indexes;
167 | }
168 | doBulkInsert(papers: PaperInfoDetail[]) {
169 | return this.searchService.bulkInsert(papers);
170 | }
171 |
172 | getPapersReferences(item: CrossRefItem, depth: number) {
173 | const hasHope = this.paperHasInformation(item);
174 | const dois: string[] = [];
175 | if (hasHope && depth + 1 < MAX_DEPTH) {
176 | item.reference?.forEach((ref) => {
177 | // doi가 있는 reference는 다음 paperQueue에 집어넣는다.
178 | if (this.referenceHasInformation(ref)) {
179 | dois.push(ref.DOI);
180 | }
181 | });
182 | }
183 | return dois;
184 | }
185 |
186 | paperHasInformation(paper: CrossRefItem) {
187 | // DOI와 제목이 있는 논문만 db에 저장한다.
188 | return paper.DOI && paper.title;
189 | }
190 |
191 | referenceHasInformation(reference: ReferenceInfo) {
192 | return reference['DOI'];
193 | }
194 |
195 | stopBatch() {
196 | Batcher.blocked = true;
197 | console.log(`${new Date()} Too many request.`);
198 | setTimeout(() => {
199 | Batcher.blocked = false;
200 | console.log(`${new Date()} Batch Restarted`);
201 | }, RESTART_INTERVAL);
202 | }
203 | }
204 |
--------------------------------------------------------------------------------
/backend/src/batch/batcher.doi.ts:
--------------------------------------------------------------------------------
1 | import { AxiosInstance, AxiosResponse } from 'axios';
2 | import Redis from 'ioredis';
3 | import { CrossRefPaperResponse, PaperInfoDetail } from 'src/search/entities/crossRef.entity';
4 | import { SearchService } from 'src/search/search.service';
5 | import { CROSSREF_API_PAPER_URL } from 'src/util';
6 | import { DOI_REGEXP, MAX_RETRY } from './batch.config';
7 | import { Batcher, UrlParams, QueueItemParsed } from './batcher.abstract';
8 |
9 | export class DoiBatcher extends Batcher {
10 | constructor(redis: Redis, axios: AxiosInstance, searchService: SearchService, name: string) {
11 | super(redis, axios, searchService, name);
12 | }
13 | makeUrl(doi: string) {
14 | return CROSSREF_API_PAPER_URL(doi);
15 | }
16 | getParamsFromUrl(url: string): UrlParams {
17 | const u = new URL(url);
18 | const doi = u.pathname.replace(/\/works\//, '');
19 | return { doi };
20 | }
21 | validateBatchItem(item: QueueItemParsed): boolean {
22 | const { doi } = this.getParamsFromUrl(item.url);
23 | // DOI 대문자일 경우 검색 안 되는 경우 발생
24 | item.url = item.url.toLowerCase();
25 | return DOI_REGEXP.test(doi);
26 | }
27 | onFulfilled(
28 | item: QueueItemParsed,
29 | params: UrlParams,
30 | res: AxiosResponse,
31 | ): { papers: PaperInfoDetail[]; referenceDOIs: string[] } {
32 | const paper = res.data.message;
33 | const { depth } = item;
34 | const referenceDOIs = this.getPapersReferences(paper, depth);
35 | const p = this.searchService.parsePaperInfoDetail(paper);
36 | return { papers: [p], referenceDOIs };
37 | }
38 |
39 | onRejected(item: QueueItemParsed, params: UrlParams, shouldPushLeft: boolean) {
40 | const { doi } = params;
41 | if (item.retries + 1 > MAX_RETRY) {
42 | // this.failedQueue.push(item.url);
43 | return;
44 | }
45 | console.log('error', item.url);
46 | item.retries++;
47 | this.pushToQueue(item.retries + 1, item.depth, item.pagesLeft - 1, shouldPushLeft, doi);
48 | return;
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/backend/src/batch/batcher.search.ts:
--------------------------------------------------------------------------------
1 | import { AxiosInstance, AxiosResponse } from 'axios';
2 | import Redis from 'ioredis';
3 | import { CrossRefResponse } from 'src/search/entities/crossRef.entity';
4 | import { SearchService } from 'src/search/search.service';
5 | import { CROSSREF_API_URL_CURSOR, MAX_ROWS } from 'src/util';
6 | import { MAX_RETRY } from './batch.config';
7 | import { Batcher, QueueItemParsed, UrlParams } from './batcher.abstract';
8 |
9 | export class SearchBatcher extends Batcher {
10 | constructor(redis: Redis, axios: AxiosInstance, searchService: SearchService, name: string) {
11 | super(redis, axios, searchService, name);
12 | }
13 |
14 | makeUrl(keyword: string, cursor: string) {
15 | return CROSSREF_API_URL_CURSOR(keyword, cursor);
16 | }
17 |
18 | getParamsFromUrl(url: string): UrlParams {
19 | const u = new URL(url);
20 | const params = new URLSearchParams(u.search);
21 | const keyword = params.get('query');
22 | const cursor = params.get('cursor') || '*';
23 | return { keyword, cursor };
24 | }
25 | validateBatchItem(item: QueueItemParsed): boolean {
26 | return true;
27 | }
28 | onFulfilled(item: QueueItemParsed, params: UrlParams, res: AxiosResponse) {
29 | const { cursor: presentCursor, keyword } = params;
30 | if (presentCursor === '*') {
31 | const cursor = res.data.message['next-cursor'];
32 | const maxPage = Math.floor(res.data.message['total-results'] / MAX_ROWS);
33 | this.pushToQueue(0, item.depth + 1, maxPage, true, keyword, cursor);
34 | } else if (item.pagesLeft > 0) {
35 | this.pushToQueue(0, item.depth, item.pagesLeft - 1, false, keyword, presentCursor);
36 | }
37 | const referenceDOIs = res.data.message.items.flatMap((paper) => {
38 | return this.getPapersReferences(paper, item.depth);
39 | });
40 | const papers = res.data.message.items.map((paper) => {
41 | return this.searchService.parsePaperInfoDetail(paper);
42 | });
43 | return { papers, referenceDOIs };
44 | }
45 |
46 | onRejected(item: QueueItemParsed, params: UrlParams, shouldPushLeft: boolean) {
47 | const { keyword, cursor } = params;
48 | if (item.retries + 1 > MAX_RETRY) {
49 | // this.failedQueue.push(item.url);
50 | return;
51 | }
52 | console.log('error', item.url);
53 | item.retries++;
54 | this.pushToQueue(item.retries + 1, item.depth, item.pagesLeft - 1, shouldPushLeft, keyword, cursor);
55 | return;
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/backend/src/batch/tests/batch.service.spec.ts:
--------------------------------------------------------------------------------
1 | import { SearchService } from 'src/search/search.service';
2 | import { mockHttpService, mockElasticService } from 'src/search/tests/search.service.mock';
3 | import { BatchService } from '../batch.service';
4 | import { mockRedisQueue, popDoiItem } from './batcher.mock';
5 |
6 | describe('doiBatcher', () => {
7 | let service: BatchService;
8 |
9 | beforeEach(() => {
10 | const httpService = mockHttpService();
11 | const elasticService = mockElasticService();
12 | service = new BatchService(mockRedisQueue(popDoiItem), httpService, new SearchService(elasticService, httpService));
13 | });
14 | it('run doi batch', async () => {
15 | const doiBatcher__runBatch = jest.spyOn(service.doiBatcher, 'runBatch');
16 | const doiBatcher__pushToQueue = jest.spyOn(service.doiBatcher, 'pushToQueue');
17 |
18 | await service.batchDoiQueue(10);
19 | expect(doiBatcher__runBatch).toBeCalledTimes(1);
20 | expect(doiBatcher__pushToQueue).toBeCalledTimes(20);
21 | });
22 | });
23 |
--------------------------------------------------------------------------------
/backend/src/batch/tests/batcher.mock.ts:
--------------------------------------------------------------------------------
1 | import Redis from 'ioredis';
2 |
3 | export function mockRedisQueue(popItem: () => string) {
4 | const lpush = jest.fn().mockResolvedValue(true);
5 | const rpush = jest.fn().mockResolvedValue(true);
6 | const lpop = jest
7 | .fn()
8 | .mockImplementation((key: string, size: number) => Array.from({ length: size }, () => popItem()));
9 | const llen = jest.fn().mockResolvedValue(10);
10 | return {
11 | lpush,
12 | rpush,
13 | lpop,
14 | llen,
15 | } as unknown as Redis;
16 | }
17 |
18 | export const popDoiItem = () => `0:0:-1:https://api.crossref.org/works/10.1234/asdf`;
19 | export const popSearchItem = () => `0:0:-1:https://api.crossref.org/works?query=keyword&rows=1000&cursor=examplecursor`;
20 |
--------------------------------------------------------------------------------
/backend/src/batch/tests/batcher.spec.ts:
--------------------------------------------------------------------------------
1 | import { SearchService } from 'src/search/search.service';
2 | import { mockHttpService, mockElasticService } from 'src/search/tests/search.service.mock';
3 | import { DOI_REGEXP } from '../batch.config';
4 | import { DoiBatcher } from '../batcher.doi';
5 | import { SearchBatcher } from '../batcher.search';
6 | import { mockRedisQueue, popDoiItem, popSearchItem } from './batcher.mock';
7 |
8 | describe('doiBatcher', () => {
9 | let batcher: DoiBatcher;
10 |
11 | beforeEach(async () => {
12 | const redisService = mockRedisQueue(popDoiItem);
13 | const httpService = mockHttpService();
14 | const elasticService = mockElasticService();
15 | batcher = new DoiBatcher(
16 | redisService,
17 | httpService.axiosRef,
18 | new SearchService(elasticService, httpService),
19 | 'doi batcher',
20 | );
21 | });
22 | it('getParamsFromUrl', async () => {
23 | const items = await batcher.queue.pop(1);
24 | const { url } = batcher.parseQueueItem(items[0]);
25 | const { doi } = batcher.getParamsFromUrl(url);
26 | expect(DOI_REGEXP.test(doi)).toBe(true);
27 | });
28 | });
29 |
30 | describe('searchBatcher', () => {
31 | let batcher: SearchBatcher;
32 |
33 | beforeEach(async () => {
34 | const redisService = mockRedisQueue(popSearchItem);
35 | const httpService = mockHttpService();
36 | const elasticService = mockElasticService();
37 | batcher = new SearchBatcher(
38 | redisService,
39 | httpService.axiosRef,
40 | new SearchService(elasticService, httpService),
41 | 'doi batcher',
42 | );
43 | });
44 | it('getParamsFromUrl', async () => {
45 | const items = await batcher.queue.pop(1);
46 | const { url } = batcher.parseQueueItem(items[0]);
47 | const { keyword, cursor } = batcher.getParamsFromUrl(url);
48 | expect(typeof keyword).toBe('string');
49 | expect(typeof cursor).toBe('string');
50 | });
51 | });
52 |
--------------------------------------------------------------------------------
/backend/src/envLayer.ts:
--------------------------------------------------------------------------------
1 | export const PORT = process.env.PORT || 4000;
2 | export const REDIS_POPULAR_KEY = process.env.REDIS_POPULAR_KEY;
3 | export const REDIS_PREVRANKING = process.env.REDIS_PREVRANKING;
4 | export const REDIS_HOST = process.env.REDIS_HOST || 'localhost';
5 | export const REDIS_PORT = process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT, 10) : 6379;
6 | export const REDIS_PASSWORD = process.env.REDIS_PASSWORD;
7 | export const ELASTIC_INDEX = process.env.ELASTIC_INDEX;
8 | export const ELASTIC_HOST = process.env.ELASTIC_HOST;
9 | export const ELASTIC_USER = process.env.ELASTIC_USER;
10 | export const ELASTIC_PASSWORD = process.env.ELASTIC_PASSWORD;
11 | export const MAIL_TO = process.env.MAIL_TO;
12 | export const ALLOW_UPDATE = process.env.ALLOW_UPDATE ? (eval(process.env.ALLOW_UPDATE) as boolean) : false;
13 | export const SHOULD_RUN_BATCH = process.env.SHOULD_RUN_BATCH ? (eval(process.env.SHOULD_RUN_BATCH) as boolean) : true;
14 |
--------------------------------------------------------------------------------
/backend/src/main.ts:
--------------------------------------------------------------------------------
1 | import { ValidationPipe } from '@nestjs/common';
2 | import { NestFactory } from '@nestjs/core';
3 | import { AppModule } from './app.module';
4 | import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
5 | import { PORT } from './envLayer';
6 |
7 | async function bootstrap() {
8 | const app = await NestFactory.create(AppModule);
9 | app.useGlobalPipes(new ValidationPipe());
10 | app.enableCors();
11 | const config = new DocumentBuilder()
12 | .setTitle('PRV API Documentation')
13 | .setDescription('논문 간 인용관계 시각화 서비스 API 문서')
14 | .setVersion('1.0.0')
15 | .addTag('prv')
16 | .build();
17 | const document = SwaggerModule.createDocument(app, config);
18 | SwaggerModule.setup('api', app, document);
19 | await app.listen(PORT);
20 | }
21 | bootstrap();
22 |
--------------------------------------------------------------------------------
/backend/src/ranking/entities/ranking.dto.ts:
--------------------------------------------------------------------------------
1 | import { IsNotEmpty } from 'class-validator';
2 |
3 | export class RankingDto {}
4 |
--------------------------------------------------------------------------------
/backend/src/ranking/entities/ranking.entity.ts:
--------------------------------------------------------------------------------
1 | import { ApiProperty } from '@nestjs/swagger';
2 | export class Ranking {
3 | @ApiProperty()
4 | keyword: string;
5 |
6 | @ApiProperty()
7 | changeRanking: number;
8 | }
9 | export class redisRanking {
10 | keyword: string;
11 | count: number;
12 | }
13 |
--------------------------------------------------------------------------------
/backend/src/ranking/ranking.controller.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Get, Query } from '@nestjs/common';
2 | import { RankingService } from './ranking.service';
3 | import { ApiResponse, ApiRequestTimeoutResponse } from '@nestjs/swagger';
4 | import { Ranking } from './entities/ranking.entity';
5 |
6 | @Controller('keyword-ranking')
7 | export class RankingController {
8 | constructor(private readonly rankingService: RankingService) {}
9 | @ApiResponse({ status: 200, description: '검색 결과', type: Ranking, isArray: true })
10 | @ApiRequestTimeoutResponse({ description: '검색 timeout' })
11 | @Get()
12 | async getTen() {
13 | return await this.rankingService.getTen();
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/backend/src/ranking/ranking.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { RedisModule } from '@liaoliaots/nestjs-redis';
3 | import { RankingController } from './ranking.controller';
4 | import { RankingService } from './ranking.service';
5 | import { ConfigModule } from '@nestjs/config';
6 | import { ScheduleModule } from '@nestjs/schedule';
7 | import { REDIS_HOST, REDIS_PASSWORD, REDIS_PORT } from 'src/envLayer';
8 |
9 | @Module({
10 | imports: [
11 | ConfigModule.forRoot({ isGlobal: true, envFilePath: `.dev.env` }),
12 | RedisModule.forRoot({
13 | config: {
14 | host: REDIS_HOST,
15 | port: REDIS_PORT,
16 | password: REDIS_PASSWORD,
17 | },
18 | }),
19 | ScheduleModule.forRoot(),
20 | ],
21 | controllers: [RankingController],
22 | providers: [RankingService],
23 | })
24 | export class RankingModule {}
25 |
--------------------------------------------------------------------------------
/backend/src/ranking/ranking.service.ts:
--------------------------------------------------------------------------------
1 | import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
2 | import { InjectRedis } from '@liaoliaots/nestjs-redis';
3 | import Redis from 'ioredis';
4 | import { Ranking } from './entities/ranking.entity';
5 | import { Interval } from '@nestjs/schedule';
6 | import { urlRegex } from 'src/util';
7 | import { REDIS_POPULAR_KEY, REDIS_PREVRANKING } from 'src/envLayer';
8 |
9 | @Injectable()
10 | export class RankingService {
11 | constructor(@InjectRedis() private readonly redis: Redis) {}
12 | async getTen() {
13 | const redisSearchData = await this.redis.zrevrangebyscore(REDIS_POPULAR_KEY, '+inf', 1);
14 | const topTen = redisSearchData.slice(0, 10);
15 | const result: Ranking[] = [];
16 | await Promise.all(
17 | topTen.map(async (v, i) => {
18 | const tmp: Ranking = { keyword: '', changeRanking: 0 };
19 | tmp.keyword = v;
20 | const prevrank = await this.redis.zscore(REDIS_PREVRANKING, v);
21 | prevrank ? (tmp.changeRanking = Number(prevrank) - i) : (tmp.changeRanking = null);
22 | result.push(tmp);
23 | }),
24 | );
25 | return result;
26 | }
27 | async insertRedis(data: string) {
28 | if (data === '' || data.length < 2) return;
29 | if (data.match(urlRegex)) return;
30 | const encodeData = decodeURI(data);
31 | try {
32 | const isRanking: string = await this.redis.zscore(REDIS_POPULAR_KEY, encodeData);
33 | isRanking
34 | ? await this.redis.zadd(REDIS_POPULAR_KEY, Number(isRanking) + 1, encodeData)
35 | : await this.redis.zadd(REDIS_POPULAR_KEY, 1, encodeData);
36 | } catch (error) {
37 | throw new HttpException('Internal Server Error', HttpStatus.INTERNAL_SERVER_ERROR);
38 | }
39 | }
40 | @Interval('update-ranking', 600000)
41 | async updateRanking() {
42 | const redisSearchData = await this.redis.zrevrangebyscore(REDIS_POPULAR_KEY, '+inf', 1);
43 | const topTen = redisSearchData.slice(0, 100);
44 | await this.redis.del(REDIS_PREVRANKING);
45 | await Promise.all(
46 | topTen.map(async (v, i) => {
47 | await this.redis.zadd(REDIS_PREVRANKING, i, v);
48 | }),
49 | );
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/backend/src/ranking/tests/ranking.controller.spec.ts:
--------------------------------------------------------------------------------
1 | import { RedisModule } from '@liaoliaots/nestjs-redis';
2 | import { BadRequestException, INestApplication } from '@nestjs/common';
3 | import { ConfigModule } from '@nestjs/config';
4 | import { Test, TestingModule } from '@nestjs/testing';
5 | import e from 'express';
6 | import { RankingController } from '../ranking.controller';
7 | import { RankingService } from '../ranking.service';
8 | import { mockRedisService } from './ranking.service.mock';
9 | describe('RankingServiceTest', () => {
10 | let controller: RankingController;
11 | let service: RankingService;
12 | beforeEach(async () => {
13 | const rankingService = mockRedisService();
14 | const module: TestingModule = await Test.createTestingModule({
15 | controllers: [RankingController],
16 | providers: [RankingService, { provide: RankingService, useValue: rankingService }],
17 | }).compile();
18 | controller = module.get(RankingController);
19 | service = module.get(RankingService);
20 | });
21 | describe('/keyword-ranking', () => {
22 | it('redis date가 10개 이하인 경우', async () => {
23 | //Case 1. redis date가 10개 이하인 경우
24 | const topTen = await service.getTen();
25 | expect(topTen.length).toBeLessThanOrEqual(10);
26 | });
27 | it('데이터 삽입 후 topTen 체크', async () => {
28 | //Case 2. 데이터 삽입 후 topTen 체크
29 | const flag = await service.insertRedis('9번째 데이터');
30 | expect(flag).toBe('new');
31 | const topTen = await controller.getTen();
32 | expect(topTen.length).toBe(9);
33 | const flag2 = await service.insertRedis('10번째 데이터');
34 | expect(flag2).toBe('new');
35 | const topTen2 = await controller.getTen();
36 | expect(topTen2.length).toBe(10);
37 | });
38 | it('2위인 "사랑해요" 데이터가 한번 더 검색시 1위로 업데이트', async () => {
39 | //Case 3. 2위인 "사랑해요" 데이터가 한번 더 검색시 1위로 업데이트
40 | const flag = await service.insertRedis('사랑해요');
41 | expect(flag).toBe('update');
42 | const topTen = await controller.getTen();
43 | expect(topTen[0].keyword).toBe('부스트캠프');
44 | });
45 | });
46 | describe('/keyword-ranking/insert', () => {
47 | // Case1. 기존 redis에 없던 데이터 삽입
48 | it('기존 redis에 없던 데이터 삽입', async () => {
49 | const result = await service.insertRedis('newData');
50 | expect(result).toBe('new');
51 | });
52 | // Case2. 기존 redis에 있던 데이터 삽입
53 | it('기존 redis에 있던 데이터 삽입', async () => {
54 | const result = await service.insertRedis('부스트캠프');
55 | expect(result).toBe('update');
56 | });
57 | //Case3. redis에 빈 검색어 입력
58 | it('빈 검색어 redis에 삽입', async () => {
59 | await expect(service.insertRedis('')).rejects.toEqual(
60 | new BadRequestException({ status: 400, error: 'bad request' }),
61 | );
62 | });
63 | //Case4. insert 실패시 타임 아웃 TimeOut
64 | it('insert 실패시 타임 아웃 TimeOut', async () => {
65 | await expect(service.insertRedis('')).rejects.toEqual(
66 | new BadRequestException({ status: 400, error: 'bad request' }),
67 | );
68 | });
69 | });
70 | });
71 |
--------------------------------------------------------------------------------
/backend/src/ranking/tests/ranking.service.mock.ts:
--------------------------------------------------------------------------------
1 | import { BadRequestException } from '@nestjs/common';
2 | import { redisRanking } from '../entities/ranking.entity';
3 | import redisMockData from './rankingData.mock';
4 | export function mockRedisService() {
5 | const insertRedis = jest.fn(async (keyword: string) => {
6 | if (keyword === '') throw new BadRequestException({ status: 400, error: 'bad request' });
7 | for (const data of redisMockData) {
8 | if (data.keyword === keyword) {
9 | data[keyword]++;
10 | redisMockData.sort(compare);
11 | return 'update';
12 | }
13 | }
14 | redisMockData.push({ keyword: keyword, count: 1 });
15 | redisMockData.sort(compare);
16 | return 'new';
17 | });
18 | const getTen = jest.fn().mockResolvedValue(redisMockData);
19 | const redisService = { insertRedis, getTen };
20 | return redisService;
21 | }
22 |
23 | function compare(a: redisRanking, b: redisRanking) {
24 | if (a.count < b.count) return 1;
25 | else if (a.count > b.count) return -1;
26 | return 0;
27 | }
28 |
--------------------------------------------------------------------------------
/backend/src/ranking/tests/rankingData.mock.ts:
--------------------------------------------------------------------------------
1 | import { redisRanking } from '../entities/ranking.entity';
2 |
3 | export default [
4 | {
5 | keyword: '부스트캠프',
6 | count: 150,
7 | },
8 | {
9 | keyword: '사랑해요',
10 | count: 150,
11 | },
12 | {
13 | keyword: 'boostcamp',
14 | count: 130,
15 | },
16 | {
17 | keyword: 'web, mobile',
18 | count: 129,
19 | },
20 | {
21 | keyword: 'ios',
22 | count: 40,
23 | },
24 | {
25 | keyword: 'android',
26 | count: 37,
27 | },
28 | {
29 | keyword: 'black hole',
30 | count: 23,
31 | },
32 | {
33 | keyword: 'stone',
34 | count: 12,
35 | },
36 | ] as redisRanking[];
37 |
--------------------------------------------------------------------------------
/backend/src/search/entities/crossRef.entity.ts:
--------------------------------------------------------------------------------
1 | import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
2 | export class PaperInfo {
3 | @ApiProperty()
4 | title: string;
5 |
6 | @ApiPropertyOptional()
7 | authors?: string[];
8 |
9 | @ApiProperty()
10 | doi?: string;
11 |
12 | @ApiProperty()
13 | key?: string;
14 |
15 | constructor(body: PaperInfo) {
16 | this.title = body.title;
17 | this.authors = body.authors;
18 | this.doi = this.key = body.doi.toLowerCase();
19 | }
20 | }
21 | export class PaperInfoExtended extends PaperInfo {
22 | @ApiPropertyOptional()
23 | publishedAt?: string;
24 |
25 | @ApiPropertyOptional()
26 | citations?: number;
27 |
28 | @ApiPropertyOptional()
29 | references?: number;
30 |
31 | constructor(body: PaperInfoExtended) {
32 | super(body);
33 | this.publishedAt = body.publishedAt;
34 | this.citations = body.citations;
35 | this.references = body.references;
36 | }
37 | }
38 | export class PaperInfoDetail extends PaperInfoExtended {
39 | @ApiPropertyOptional({ type: PaperInfoExtended, isArray: true })
40 | referenceList?: PaperInfoExtended[];
41 |
42 | constructor(body: PaperInfoDetail) {
43 | super(body);
44 | this.referenceList = body.referenceList;
45 | }
46 | }
47 | export class ReferenceInfo {
48 | issn?: string;
49 | 'standards-body'?: string;
50 | issue?: string;
51 | key?: string;
52 | 'series-title'?: string;
53 | 'isbn-type'?: string;
54 | 'doi-asserted-by'?: string;
55 | 'first-page'?: string;
56 | isbn?: string;
57 | DOI?: string;
58 | component?: string;
59 | 'article-title'?: string;
60 | 'volume-title'?: string;
61 | volume?: string;
62 | author?: string;
63 | 'standard-designator'?: string;
64 | year?: string;
65 | unstructured?: string;
66 | edition?: string;
67 | 'journal-title'?: string;
68 | 'issn-type'?: string;
69 | }
70 | export interface CrossRefResponse {
71 | message: {
72 | 'next-cursor'?: string;
73 | 'total-results': number;
74 | items: CrossRefItem[];
75 | };
76 | }
77 |
78 | export interface CrossRefItem {
79 | title?: string[];
80 | author?: {
81 | given?: string;
82 | family?: string;
83 | name?: string;
84 | }[];
85 | DOI?: string;
86 | created?: {
87 | 'date-time': string;
88 | };
89 | 'is-referenced-by-count'?: number;
90 | 'reference-count'?: number;
91 | reference?: ReferenceInfo[];
92 | }
93 | export interface CrossRefPaperResponse {
94 | message: CrossRefItem & { 'next-cursor'?: string; 'total-results'?: number };
95 | }
96 |
--------------------------------------------------------------------------------
/backend/src/search/entities/search.dto.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-inferrable-types */
2 | import { Transform } from 'class-transformer';
3 | import { IsOptional, IsPositive, IsString, Matches, MinLength } from 'class-validator';
4 | import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
5 | import { DOI_REGEXP } from 'src/batch/batch.config';
6 |
7 | export class SearchDto {
8 | @ApiProperty({
9 | example: 'einstein',
10 | description: '검색 키워드 (2글자 이상)',
11 | required: true,
12 | })
13 | @Transform((params) => encodeURI(params.value).trim())
14 | @IsString()
15 | @MinLength(2)
16 | keyword: string;
17 |
18 | @ApiPropertyOptional({
19 | example: 20,
20 | description: '페이지별 검색결과 갯수',
21 | default: 20,
22 | })
23 | @IsOptional()
24 | @Transform((params) => parseInt(params.value))
25 | @IsPositive()
26 | rows: number = 20;
27 |
28 | @ApiPropertyOptional({
29 | example: 1,
30 | description: '페이지 번호',
31 | default: 1,
32 | })
33 | @IsOptional()
34 | @Transform((params) => parseInt(params.value))
35 | @IsPositive()
36 | page: number = 1;
37 | }
38 | export class AutoCompleteDto {
39 | @ApiProperty({
40 | example: 'einstein',
41 | description: '검색 키워드 (2글자 이상)',
42 | })
43 | @IsString()
44 | @MinLength(2)
45 | keyword: string;
46 | }
47 | export class GetPaperDto {
48 | @ApiProperty({
49 | example: '10.1234/qwer.asdf',
50 | description: '논문의 DOI',
51 | })
52 | @Matches(DOI_REGEXP, { message: 'DOI 형식이 올바르지 않습니다.' })
53 | @Transform((params) => params.value.toLowerCase())
54 | doi: string;
55 | }
56 |
--------------------------------------------------------------------------------
/backend/src/search/search.controller.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Get, NotFoundException, Query, UsePipes, ValidationPipe } from '@nestjs/common';
2 | import { SearchService } from './search.service';
3 | import { AutoCompleteDto, GetPaperDto, SearchDto } from './entities/search.dto';
4 | import { GetGetResult, SearchTotalHits } from '@elastic/elasticsearch/lib/api/types';
5 | import { RankingService } from 'src/ranking/ranking.service';
6 | import { BatchService } from 'src/batch/batch.service';
7 | import { ApiResponse, ApiRequestTimeoutResponse, ApiBadRequestResponse, ApiNotFoundResponse } from '@nestjs/swagger';
8 | import { PaperInfo, PaperInfoDetail, PaperInfoExtended } from './entities/crossRef.entity';
9 |
10 | @Controller('search')
11 | export class SearchController {
12 | constructor(
13 | private readonly searchService: SearchService,
14 | private readonly rankingService: RankingService,
15 | private readonly batchService: BatchService,
16 | ) {}
17 |
18 | @ApiResponse({ status: 200, description: '자동검색 성공', type: PaperInfo, isArray: true })
19 | @ApiRequestTimeoutResponse({ description: '검색 timeout' })
20 | @ApiBadRequestResponse({ description: '유효하지 않은 키워드' })
21 | @ApiNotFoundResponse({ description: '검색 결과가 존재하지 않습니다. 정보를 수집중입니다.' })
22 | @Get('auto-complete')
23 | @UsePipes(new ValidationPipe({ transform: true }))
24 | async getAutoCompletePapers(@Query() query: AutoCompleteDto) {
25 | const { keyword } = query;
26 | const data = await this.searchService.getElasticSearch(keyword);
27 | const papers = data.hits.hits.map((paper) => new PaperInfo(paper._source));
28 | return papers;
29 | }
30 |
31 | @ApiResponse({ status: 200, description: '검색 결과', type: PaperInfoExtended, isArray: true })
32 | @ApiRequestTimeoutResponse({ description: '검색 timeout' })
33 | @ApiBadRequestResponse({ description: '유효하지 않은 keyword | rows | page' })
34 | @ApiNotFoundResponse({ description: '검색 결과가 존재하지 않습니다. 정보를 수집중입니다.' })
35 | @Get()
36 | @UsePipes(new ValidationPipe({ transform: true }))
37 | async getPapers(@Query() query: SearchDto) {
38 | const { keyword, rows, page } = query;
39 | const data = await this.searchService.getElasticSearch(keyword, rows, rows * (page - 1));
40 | const totalItems = (data.hits.total as SearchTotalHits).value;
41 | const totalPages = Math.ceil(totalItems / rows);
42 | if (page > totalPages && totalPages !== 0) {
43 | throw new NotFoundException(`page(${page})는 ${totalPages} 보다 클 수 없습니다.`);
44 | }
45 | this.rankingService.insertRedis(keyword);
46 | const keywordHasSet = await this.batchService.setKeyword(keyword);
47 | if (keywordHasSet) this.batchService.searchBatcher.pushToQueue(0, 0, -1, true, keyword);
48 |
49 | // Elasticsearch 검색 결과가 없을 경우, Crossref 검색
50 | if (totalItems === 0) {
51 | const { items: papers, totalItems } = await this.searchService.getPapersFromCrossref(keyword, rows, page);
52 | return {
53 | papers,
54 | pageInfo: {
55 | totalItems,
56 | totalPages: Math.ceil(totalItems / rows),
57 | },
58 | };
59 | }
60 |
61 | const papers = data.hits.hits.map((paper) => new PaperInfoExtended(paper._source));
62 | return {
63 | papers,
64 | pageInfo: {
65 | totalItems,
66 | totalPages,
67 | },
68 | };
69 | }
70 |
71 | @ApiResponse({ status: 200, description: '논문 상세정보 검색 결과', type: PaperInfoDetail })
72 | @ApiRequestTimeoutResponse({ description: '검색 timeout' })
73 | @ApiBadRequestResponse({ description: '유효하지 않은 doi' })
74 | @ApiNotFoundResponse({ description: '해당 doi는 존재하지 않습니다. 정보를 수집중입니다.' })
75 | @Get('paper')
76 | @UsePipes(new ValidationPipe({ transform: true }))
77 | async getPaper(@Query() query: GetPaperDto) {
78 | const { doi } = query;
79 |
80 | const keywordHasSet = await this.batchService.setKeyword(doi);
81 | if (keywordHasSet) this.batchService.doiBatcher.pushToQueue(0, 0, -1, false, doi);
82 |
83 | const paper = await this.searchService.getPaper(doi);
84 | try {
85 | if (paper) {
86 | const origin = new PaperInfoDetail(paper._source);
87 | // 기존에 넣어놨던 데이터에 referenceList라는 key가 없을 수 있다..
88 | if (!origin.referenceList?.length) {
89 | this.batchService.doiBatcher.pushToQueue(0, 1, -1, false, origin.doi || origin.key);
90 | throw new Error('SHOULD_CALL_CROSSREF');
91 | // return { ...origin, referenceList: [] };
92 | }
93 |
94 | const references = await this.searchService.multiGet(
95 | origin.referenceList.map((ref) => ref.key).filter(Boolean),
96 | );
97 |
98 | const referenceList = references.docs.map((doc) => {
99 | const _source = (doc as GetGetResult)._source;
100 | if (!!_source.title || !!_source.authors?.length) throw new Error('SHOULD_CALL_CROSSREF');
101 | return { key: doc._id, ..._source };
102 | });
103 | if (referenceList.length !== origin.references) throw new Error('SHOULD_CALL_CROSSREF');
104 | return { ...origin, referenceList };
105 | }
106 | throw new Error('SHOULD_CALL_CROSSREF');
107 | } catch (err) {
108 | const res = await this.searchService.getPaperFromCrossref(doi);
109 | return res;
110 | // throw new NotFoundException('해당 doi는 존재하지 않습니다. 정보를 수집중입니다.');
111 | }
112 | }
113 |
114 | @Get('stat')
115 | async getStats() {
116 | const es = await this.searchService.esStat();
117 | const searchBatch = await this.batchService.searchBatcher.queue.size();
118 | const doiBatch = await this.batchService.doiBatcher.queue.size();
119 | return {
120 | es,
121 | searchBatch,
122 | doiBatch,
123 | };
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/backend/src/search/search.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { SearchController } from './search.controller';
3 | import { SearchService } from './search.service';
4 | import { HttpModule } from '@nestjs/axios';
5 | import { ElasticsearchModule } from '@nestjs/elasticsearch';
6 | import { HttpConnection } from '@elastic/elasticsearch';
7 | import { ScheduleModule } from '@nestjs/schedule';
8 | import { RankingService } from 'src/ranking/ranking.service';
9 | import { BatchService } from 'src/batch/batch.service';
10 | import { MAIL_TO, ELASTIC_HOST, ELASTIC_USER, ELASTIC_PASSWORD } from 'src/envLayer';
11 | @Module({
12 | imports: [
13 | HttpModule.register({
14 | timeout: 1000 * 60,
15 | headers: {
16 | 'User-Agent': `Axios/1.1.3(mailto:${MAIL_TO})`,
17 | },
18 | }),
19 | ElasticsearchModule.registerAsync({
20 | useFactory: () => ({
21 | node: ELASTIC_HOST,
22 | headers: {
23 | Accept: 'application/json',
24 | 'Content-Type': 'application/json',
25 | },
26 | auth: {
27 | username: ELASTIC_USER,
28 | password: ELASTIC_PASSWORD,
29 | },
30 | Connection: HttpConnection,
31 | }),
32 | }),
33 | ScheduleModule.forRoot(),
34 | ],
35 | controllers: [SearchController],
36 | providers: [SearchService, RankingService, BatchService],
37 | })
38 | export class SearchModule {}
39 |
--------------------------------------------------------------------------------
/backend/src/search/search.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, NotFoundException, RequestTimeoutException } from '@nestjs/common';
2 | import {
3 | CrossRefItem,
4 | PaperInfoExtended,
5 | PaperInfo,
6 | PaperInfoDetail,
7 | CrossRefPaperResponse,
8 | CrossRefResponse,
9 | } from './entities/crossRef.entity';
10 | import { ElasticsearchService } from '@nestjs/elasticsearch';
11 | import { MgetOperation, SearchHit } from '@elastic/elasticsearch/lib/api/types';
12 | import { HttpService } from '@nestjs/axios';
13 | import { CROSSREF_API_PAPER_URL, CROSSREF_API_URL } from '../util';
14 | import { ELASTIC_INDEX } from 'src/envLayer';
15 |
16 | @Injectable()
17 | export class SearchService {
18 | constructor(private readonly esService: ElasticsearchService, private readonly httpService: HttpService) {}
19 |
20 | parsePaperInfo = (item: CrossRefItem) => {
21 | const data = {
22 | title: item.title?.[0],
23 | authors: item.author?.reduce((acc, cur) => {
24 | const authorName = `${cur.name ? cur.name : cur.given ? cur.given + ' ' : ''}${cur.family || ''}`;
25 | authorName && acc.push(authorName);
26 | return acc;
27 | }, []),
28 | doi: item.DOI,
29 | key: item.DOI,
30 | };
31 |
32 | return new PaperInfo(data);
33 | };
34 | parsePaperInfoExtended = (item: CrossRefItem) => {
35 | const data = {
36 | ...this.parsePaperInfo(item),
37 | publishedAt: item.created?.['date-time'],
38 | citations: item['is-referenced-by-count'],
39 | references: item['references-count'],
40 | };
41 |
42 | return new PaperInfoExtended(data);
43 | };
44 | parsePaperInfoDetail = (item: CrossRefItem) => {
45 | const keysTable: { [key: string]: boolean } = {};
46 | const referenceList =
47 | item['reference']
48 | ?.map((reference) => {
49 | const key = reference['DOI'] || reference.key || reference.unstructured;
50 | const title =
51 | reference['article-title'] ||
52 | reference['journal-title'] ||
53 | reference['series-title'] ||
54 | reference['volume-title'] ||
55 | reference.unstructured;
56 | const doi = reference['DOI'];
57 | const authors = reference['author'] ? [reference['author']] : undefined;
58 | return {
59 | key,
60 | title,
61 | doi,
62 | authors,
63 | };
64 | })
65 | .filter((reference) => {
66 | if (!keysTable[reference.key]) {
67 | keysTable[reference.key] = true;
68 | return true;
69 | }
70 | return false;
71 | }) || [];
72 | const data = {
73 | ...this.parsePaperInfoExtended(item),
74 | referenceList,
75 | };
76 |
77 | return new PaperInfoDetail(data);
78 | };
79 | async getPapersFromCrossref(keyword: string, rows: number, page: number, selects?: string[]) {
80 | const crossRefdata = await this.httpService.axiosRef
81 | .get(CROSSREF_API_URL(keyword, rows, page, selects))
82 | .catch((err) => {
83 | throw new RequestTimeoutException(err.message);
84 | });
85 | const items = crossRefdata.data.message.items.map((item) => this.parsePaperInfoExtended(item));
86 | const totalItems = crossRefdata.data.message['total-results'];
87 | return { items, totalItems };
88 | }
89 |
90 | async getPaperFromCrossref(doi: string) {
91 | try {
92 | const item = await this.httpService.axiosRef.get(CROSSREF_API_PAPER_URL(doi));
93 | return this.parsePaperInfoDetail(item.data.message);
94 | } catch (error) {
95 | throw new NotFoundException('해당 doi는 존재하지 않습니다. 정보를 수집중입니다.');
96 | }
97 | }
98 | async getPaper(doi: string) {
99 | try {
100 | const paper = await this.esService.get({ index: ELASTIC_INDEX, id: doi });
101 | return paper;
102 | } catch (_) {
103 | return false;
104 | }
105 | }
106 | async putElasticSearch(paper: PaperInfoExtended) {
107 | return await this.esService.index({
108 | index: ELASTIC_INDEX,
109 | id: paper.doi,
110 | document: {
111 | ...paper,
112 | },
113 | });
114 | }
115 | async getElasticSearch(keyword: string, size = 5, from = 0) {
116 | const key = decodeURIComponent(keyword);
117 | const query = {
118 | bool: {
119 | should: [
120 | {
121 | match_phrase_prefix: {
122 | title: key,
123 | },
124 | },
125 | {
126 | match_phrase_prefix: {
127 | authors: key,
128 | },
129 | },
130 | ],
131 | },
132 | };
133 | return await this.esService
134 | .search({
135 | index: ELASTIC_INDEX,
136 | from,
137 | size,
138 | sort: ['_score', { citations: 'desc' }],
139 | query,
140 | })
141 | .catch(() => {
142 | return { hits: { hits: [] as SearchHit[], total: 0 } };
143 | });
144 | }
145 |
146 | async bulkInsert(papers: PaperInfoDetail[]) {
147 | const dataset = papers.map((paper) => {
148 | return { id: paper.doi, ...paper };
149 | });
150 | if (dataset.length <= 0) return;
151 | const operations = dataset.flatMap((doc) => [{ index: { _index: ELASTIC_INDEX, _id: doc.id } }, doc]);
152 | const bulkResponse = await this.esService.bulk({ refresh: true, operations });
153 | // console.log(`bulk insert response : ${bulkResponse.items.length}`);
154 | }
155 | async multiGet(ids: string[]) {
156 | if (ids.length === 0) return { docs: [] };
157 | const docs: MgetOperation[] = ids.map((id) => {
158 | return {
159 | _index: ELASTIC_INDEX,
160 | _id: id,
161 | _source: { include: ['key', 'title', 'authors', 'doi', 'publishedAt', 'citations', 'references'] },
162 | };
163 | });
164 | return await this.esService.mget({ docs });
165 | }
166 | esStat() {
167 | return this.esService.cat.indices();
168 | }
169 | }
170 |
--------------------------------------------------------------------------------
/backend/src/search/tests/search.controller.spec.ts:
--------------------------------------------------------------------------------
1 | import { SearchController } from '../search.controller';
2 | import { SearchService } from '../search.service';
3 | import { HttpService } from '@nestjs/axios';
4 | import { PaperInfo, PaperInfoDetail, PaperInfoExtended } from '../entities/crossRef.entity';
5 | import { Test, TestingModule } from '@nestjs/testing';
6 | import { ElasticsearchService } from '@nestjs/elasticsearch';
7 | import { mockBatchService, mockElasticService, mockHttpService, mockRankingService } from './search.service.mock';
8 | import { RankingService } from '../../ranking/ranking.service';
9 | import { INestApplication, ValidationPipe } from '@nestjs/common';
10 | import * as request from 'supertest';
11 | import { BatchService } from 'src/batch/batch.service';
12 |
13 | describe('SearchController', () => {
14 | let controller: SearchController;
15 | let service: SearchService;
16 | let app: INestApplication;
17 |
18 | let spyGetElasticSearch: jest.SpyInstance;
19 | const keyword = 'coffee';
20 |
21 | beforeEach(async () => {
22 | const httpService = mockHttpService();
23 | const rankingService = mockRankingService();
24 | const elasticService = mockElasticService();
25 | const batchService = mockBatchService();
26 |
27 | const module: TestingModule = await Test.createTestingModule({
28 | controllers: [SearchController],
29 | providers: [
30 | SearchService,
31 | {
32 | provide: RankingService,
33 | useValue: rankingService,
34 | },
35 | {
36 | provide: HttpService,
37 | useValue: httpService,
38 | },
39 | {
40 | provide: ElasticsearchService,
41 | useValue: elasticService,
42 | },
43 | {
44 | provide: BatchService,
45 | useValue: batchService,
46 | },
47 | ],
48 | }).compile();
49 | controller = module.get(SearchController);
50 | service = module.get(SearchService);
51 | spyGetElasticSearch = jest.spyOn(service, 'getElasticSearch');
52 | // spyGetCrossRefData = jest.spyOn(service, 'getCrossRefData');
53 |
54 | app = module.createNestApplication();
55 | app.useGlobalPipes(new ValidationPipe());
56 | await app.init();
57 | });
58 | afterEach(() => app.close());
59 |
60 | describe('/search/auto-complete', () => {
61 | it('getAutoCompletePapers - keyword=coffee 일 때 PaperInfo[]를 return', async () => {
62 | // Case 1. elasticsearch에 data가 없을 경우
63 | const itemsByCrossRef = await controller.getAutoCompletePapers({ keyword });
64 | expect(itemsByCrossRef.length).toBe(5);
65 | itemsByCrossRef.forEach((item) => {
66 | expect(item instanceof PaperInfo).toBe(true);
67 | });
68 |
69 | // Case 2. elasticsearch에 data가 있는 경우
70 | const itemsByElasticsearch = await controller.getAutoCompletePapers({ keyword });
71 | expect(itemsByElasticsearch.length).toBe(5);
72 | itemsByElasticsearch.forEach((item) => {
73 | expect(item instanceof PaperInfo).toBe(true);
74 | });
75 |
76 | expect(spyGetElasticSearch).toBeCalledTimes(2);
77 | });
78 | it('keyword 미포함시 error - GET /search/auto-complete?keyword=', () => {
79 | const url = (keyword: string) => `/search/auto-complete?keyword=${keyword}`;
80 | request(app.getHttpServer()).get(url('')).expect(400);
81 | });
82 | });
83 | describe('/search', () => {
84 | const DEFAULT_ROWS = 20;
85 | const TOTAL_ITEMS = 28810;
86 | it(`getPapers - keyword='coffee' 일 때 PaperInfoExtended[]를 return`, async () => {
87 | const keyword = 'coffee';
88 | const { papers: items, pageInfo } = await controller.getPapers({ keyword, rows: 20, page: 1 });
89 | expect(items.length).toBe(DEFAULT_ROWS);
90 | expect(pageInfo.totalItems).toBe(TOTAL_ITEMS);
91 | items.forEach((item) => {
92 | expect(item).toBeInstanceOf(PaperInfoExtended);
93 | });
94 | expect(spyGetElasticSearch).toBeCalledTimes(1);
95 | });
96 | it('keyword 미포함시 error - GET /search?keyword=', () => {
97 | const url = (keyword: string) => `/search?keyword=${keyword}`;
98 | request(app.getHttpServer()).get(url('')).expect(400);
99 | });
100 | it('rows<=0 이거나, rows 값이 integer가 아닐 경우 error - GET /search?keyword=coffee&rows=', () => {
101 | const url = (rows: string | number) => `/search?keyword=${keyword}&rows=${rows}`;
102 | const rowsNotAvailables = [-1, -0.1, '0', 'value'];
103 | rowsNotAvailables.forEach((value) => {
104 | request(app.getHttpServer()).get(url(value)).expect(400);
105 | });
106 | });
107 | it('page<=0 이거나, page 값이 integer가 아닐 경우 error - GET /search?keyword=coffee&page=', () => {
108 | const url = (page: string | number) => `/search?keyword=${keyword}&page=${page}`;
109 | const pageNotAvailables = [-1, -0.1, '0', 'value'];
110 | pageNotAvailables.forEach((value) => {
111 | request(app.getHttpServer()).get(url(value)).expect(400);
112 | });
113 | });
114 | it('page>max 이면 error - GET /search?keyword=coffee&page=', () => {
115 | const url = (page: string | number) => `/search?keyword=${keyword}&page=${page}`;
116 | const maxPage = Math.ceil(TOTAL_ITEMS / DEFAULT_ROWS);
117 | request(app.getHttpServer())
118 | .get(url(maxPage + 1))
119 | .expect(404);
120 | });
121 | });
122 |
123 | describe('/search/paper', () => {
124 | it(`getPaper - doi=10.1234/some_doi 일 때 PaperInfoDetail을 return`, async () => {
125 | const doi = '10.1234/some_doi';
126 | const paper = await controller.getPaper({ doi });
127 | expect(paper.references).toBe(5);
128 | expect(paper.referenceList.length).toBe(5);
129 | expect(() => new PaperInfoDetail(paper)).not.toThrow();
130 | });
131 | it('doi가 입력되지 않을 경우 error - GET /search/paper?doi=', () => {
132 | const url = (keyword: string) => `/search/paper?doi=${keyword}`;
133 | request(app.getHttpServer()).get(url('')).expect(400);
134 | });
135 | });
136 | });
137 |
--------------------------------------------------------------------------------
/backend/src/search/tests/search.service.mock.ts:
--------------------------------------------------------------------------------
1 | import mockCrossRefData from './crossref.mock';
2 | import mockSearchData from './searchdata.mock';
3 | import { HttpService } from '@nestjs/axios';
4 | import { CrossRefPaperResponse, CrossRefResponse, PaperInfo, PaperInfoDetail } from '../entities/crossRef.entity';
5 | import { ElasticsearchService } from '@nestjs/elasticsearch';
6 |
7 | export function mockHttpService() {
8 | const httpService = new HttpService();
9 | jest.spyOn(httpService['axiosRef'], 'get').mockImplementation((url: string) => {
10 | const params = new URLSearchParams(new URL(url).search);
11 | const keyword = params.get('query');
12 | if (keyword === null) {
13 | const item = mockCrossRefData.message.items[0];
14 | return Promise.resolve<{ data: CrossRefPaperResponse }>({
15 | data: { message: item },
16 | });
17 | }
18 | const rows = parseInt(params.get('rows'));
19 | const selects = params.get('select').split(',');
20 | const items = mockCrossRefData.message.items.slice(0, rows).map((item, i) => {
21 | return selects.reduce((acc, select) => {
22 | if (select === 'title') item[select] = [`${keyword}-${i}`];
23 | acc[select] = item[select];
24 | return acc;
25 | }, {});
26 | });
27 |
28 | return Promise.resolve<{ data: CrossRefResponse }>({
29 | data: { message: { 'total-results': mockCrossRefData.message['total-results'], items } },
30 | });
31 | });
32 | return httpService;
33 | }
34 |
35 | export function mockElasticService() {
36 | // TODO: should mockup index?
37 | const index = jest.fn().mockResolvedValue(() => {
38 | return true;
39 | });
40 | const search = jest.fn().mockImplementation(({ size }) => {
41 | return Promise.resolve({
42 | hits: {
43 | total: {
44 | value: 28810,
45 | },
46 | hits: mockSearchData
47 | .map((data, key) => {
48 | return {
49 | _source: { ...new PaperInfo(data), key },
50 | };
51 | })
52 | .slice(0, size),
53 | },
54 | });
55 | });
56 | const get = jest.fn().mockResolvedValue({
57 | _source: {
58 | ...mockSearchData[0],
59 | referenceList: Array.from({ length: mockSearchData[0].references || 0 }, (_, i) => {
60 | return { key: mockSearchData[i].doi };
61 | }),
62 | },
63 | });
64 | const mget = jest.fn().mockResolvedValue({
65 | docs: Array.from({ length: mockSearchData[0].references || 0 }, (_, i) => {
66 | const data = mockSearchData[i];
67 | return { _id: data.doi, found: Math.random() > 0.5, _source: data };
68 | }),
69 | });
70 | const bulk = jest.fn().mockResolvedValue([]);
71 | const elasticService = { index, search, get, mget, bulk };
72 | return elasticService as unknown as ElasticsearchService;
73 | }
74 |
75 | export function mockRankingService() {
76 | const insertRedis = jest.fn().mockResolvedValue(() => {
77 | console.log('insertRedis 사용했다하하ㅏㅎ하ㅏ');
78 | return true;
79 | });
80 | const rankingService = { insertRedis };
81 | return rankingService;
82 | }
83 |
84 | export function mockBatchService() {
85 | const setKeyword = jest.fn().mockResolvedValue(() => {
86 | return true;
87 | });
88 | const doiBatcher = {
89 | pushToQueue: jest.fn(),
90 | };
91 | const searchBatcher = {
92 | pushToQueue: jest.fn(),
93 | };
94 | const batchService = { setKeyword, doiBatcher, searchBatcher };
95 | return batchService;
96 | }
97 |
--------------------------------------------------------------------------------
/backend/src/search/tests/searchdata.mock.ts:
--------------------------------------------------------------------------------
1 | import { PaperInfoExtended } from '../entities/crossRef.entity';
2 |
3 | export default [
4 | {
5 | title:
6 | 'Thermodynamic Properties of Systems Comprising Esters: Experimental Data and Modeling with PC-SAFT and SAFT Mie',
7 | doi: '10.1021/acs.iecr.9b00714.s001',
8 | publishedAt: '2020-04-09T15:47:35Z',
9 | citations: 0,
10 | references: 10,
11 | },
12 | {
13 | title:
14 | 'Comparison of CP-PC-SAFT and PC-SAFT with k12 = 0 and PPR78 in Predicting Binary Systems of Hydrocarbons with Squalane, ndodecylbenzene, cis-decalin, Tetralin, and Naphthalene at High Pressures',
15 | doi: '10.1021/acs.iecr.1c03486.s001',
16 | publishedAt: '2021-10-25T13:26:30Z',
17 | citations: 0,
18 | references: 0,
19 | },
20 | {
21 | title: 'Boyle temperature from SAFT, PC-SAFT and SAFT-VR equations of state',
22 | authors: ['Samane Zarei', 'Farzaneh Feyzi'],
23 | doi: '10.1016/j.molliq.2013.06.010',
24 | publishedAt: '2013-07-04T20:46:55Z',
25 | citations: 15,
26 | references: 44,
27 | },
28 | {
29 | title: 'Comparison of SAFT-VR-Mie and CP-PC-SAFT in Estimating the Phase Behavior of Acetone + nAlkane Systems',
30 | doi: '10.1021/acs.iecr.0c04435.s001',
31 | publishedAt: '2020-11-25T06:50:17Z',
32 | citations: 0,
33 | references: 0,
34 | },
35 | {
36 | title: 'Modeling of carbon dioxide and water sorption in glassy polymers through PC-SAFT and NET PC-SAFT',
37 | authors: ['Liang Liu', 'Sandra E. Kentish'],
38 | doi: '10.1016/j.polymer.2016.10.002',
39 | publishedAt: '2016-10-09T04:06:52Z',
40 | citations: 10,
41 | references: 60,
42 | },
43 | {
44 | title: 'Thermodynamic Modeling of Triglycerides using PC-SAFT',
45 | doi: '10.1021/acs.jced.8b01046.s001',
46 | publishedAt: '2020-04-09T21:21:54Z',
47 | citations: 0,
48 | references: 0,
49 | },
50 | {
51 | title: 'Vle Property Measurements and Pc-Saft/ Cp- Pc-Saft/ E-Ppr78 Modeling of the Co2 + N-Tetradecane Mixture',
52 | authors: [
53 | 'Vener Khairutdinov',
54 | 'Farid Gumerov',
55 | 'Ilnar Khabriev',
56 | 'Talgat Akhmetzyanov',
57 | 'Ilfat Salikhov',
58 | 'Ilya Polishuk',
59 | 'ilmutdin abdulagatov',
60 | ],
61 | doi: '10.2139/ssrn.4188488',
62 | publishedAt: '2022-08-13T13:32:29Z',
63 | citations: 0,
64 | references: 0,
65 | },
66 | {
67 | title: 'Application of PC-SAFT to glycol containing systems – PC-SAFT towards a predictive approach',
68 | authors: ['Andreas Grenner', 'Georgios M. Kontogeorgis', 'Nicolas von Solms', 'Michael L. Michelsen'],
69 | doi: '10.1016/j.fluid.2007.04.025',
70 | publishedAt: '2007-04-30T14:49:55Z',
71 | citations: 37,
72 | references: 37,
73 | },
74 | {
75 | title: 'PC-SAFT Modeling of CO2 Solubilities in Deep Eutectic Solvents',
76 | doi: '10.1021/acs.jpcb.5b07888.s001',
77 | publishedAt: '2020-04-08T16:19:35Z',
78 | citations: 0,
79 | references: 0,
80 | },
81 | {
82 | title:
83 | 'IPC-SAFT: An Industrialized Version of the Volume-Translated PC-SAFT Equation of State for Pure Components, Resulting from Experience Acquired All through the Years on the Parameterization of SAFT-Type and Cubic Models',
84 | doi: '10.1021/acs.iecr.9b04660.s001',
85 | publishedAt: '2020-04-07T21:03:41Z',
86 | citations: 0,
87 | references: 0,
88 | },
89 | {
90 | title:
91 | 'Parameterization of SAFT Models: Analysis of Different Parameter Estimation Strategies and Application to the Development of a Comprehensive Database of PC-SAFT Molecular Parameters',
92 | doi: '10.1021/acs.jced.0c00792.s001',
93 | publishedAt: '2020-11-30T23:27:16Z',
94 | citations: 0,
95 | references: 0,
96 | },
97 | {
98 | title:
99 | 'Implementation of CP-PC-SAFT and CS-SAFT-VR-Mie for Predicting Thermodynamic Properties of C1C3 Halocarbon Systems. I. Pure Compounds and Mixtures with Nonassociating Compounds',
100 | doi: '10.1021/acs.iecr.1c01700.s001',
101 | publishedAt: '2021-06-28T13:45:30Z',
102 | citations: 0,
103 | references: 0,
104 | },
105 | {
106 | title: 'Predicting the Solubility of CO2 in Toluene + Ionic Liquid Mixtures with PC-SAFT',
107 | doi: '10.1021/acs.iecr.7b01497.s001',
108 | publishedAt: '2020-04-06T18:59:00Z',
109 | citations: 0,
110 | references: 0,
111 | },
112 | {
113 | title:
114 | 'Evaluation of SAFT and PC-SAFT models for the description of homo- and co-polymer solution phase equilibria',
115 | authors: ['Theodora Spyriouni', 'Ioannis G. Economou'],
116 | doi: '10.1016/j.polymer.2005.09.001',
117 | publishedAt: '2005-09-23T13:48:14Z',
118 | citations: 19,
119 | references: 27,
120 | },
121 | {
122 | title: 'VLE property measurements and PC-SAFT/ CP- PC-SAFT/ E-PPR78 modeling of the CO2 + n-tetradecane mixture',
123 | authors: [
124 | 'Vener F. Khairutdinov',
125 | 'Farid M. Gumerov',
126 | 'Ilnar Sh. Khabriev',
127 | 'Talgat R. Akhmetzyanov',
128 | 'Ilfat Z. Salikhov',
129 | 'Ilya Polishuk',
130 | 'Ilmutdin M. Abdulagatov',
131 | ],
132 | doi: '10.1016/j.fluid.2022.113615',
133 | publishedAt: '2022-09-20T01:54:20Z',
134 | citations: 0,
135 | references: 79,
136 | },
137 | {
138 | title: 'Thermodynamic Properties of 1Hexyl-3-methylimidazolium Nitrate and 1Alkanols Mixtures: PC-SAFT Model',
139 | doi: '10.1021/acs.jced.9b00507.s001',
140 | publishedAt: '2020-04-08T11:44:12Z',
141 | citations: 0,
142 | references: 0,
143 | },
144 | {
145 | title: 'Integrated Working Fluids and Process Optimization for Refrigeration Systems Using Polar PC-SAFT',
146 | doi: '10.1021/acs.iecr.1c03624.s001',
147 | publishedAt: '2021-11-29T13:45:54Z',
148 | citations: 0,
149 | references: 0,
150 | },
151 | {
152 | title: 'Thermodynamic and Transport Properties of Formic Acid and 2Alkanol Mixtures: PC-SAFT Model',
153 | doi: '10.1021/acs.jced.2c00496.s001',
154 | publishedAt: '2022-11-09T21:00:10Z',
155 | citations: 0,
156 | references: 0,
157 | },
158 | {
159 | title: '5Hydroxymethylfurfural Synthesis in Nonaqueous Two-Phase Systems (NTPS)PC-SAFT Predictions and Validation',
160 | doi: '10.1021/acs.oprd.0c00072.s001',
161 | publishedAt: '2020-05-26T16:46:36Z',
162 | citations: 0,
163 | references: 0,
164 | },
165 | {
166 | title: 'Investigating Various Parametrization Strategies for Pharmaceuticals within the PC-SAFT Equation of State',
167 | doi: '10.1021/acs.jced.0c00707.s001',
168 | publishedAt: '2020-09-30T13:48:49Z',
169 | citations: 0,
170 | references: 0,
171 | },
172 | ] as PaperInfoExtended[];
173 |
--------------------------------------------------------------------------------
/backend/src/util.ts:
--------------------------------------------------------------------------------
1 | import { MAIL_TO } from './envLayer';
2 |
3 | const BASE_URL = 'https://api.crossref.org/works';
4 | export const CROSSREF_API_URL = (
5 | keyword: string,
6 | rows = 5,
7 | page = 1,
8 | selects: string[] = ['title', 'author', 'created', 'is-referenced-by-count', 'references-count', 'DOI'],
9 | ) =>
10 | `${BASE_URL}?query=${keyword}&rows=${rows}&select=${selects.join(',')}&offset=${rows * (page - 1)}&mailto=${MAIL_TO}`;
11 |
12 | export const MAX_ROWS = 1000;
13 | export const CROSSREF_API_URL_CURSOR = (
14 | keyword: string,
15 | cursor = '*',
16 | rows = MAX_ROWS,
17 | selects: string[] = ['title', 'author', 'created', 'is-referenced-by-count', 'references-count', 'DOI', 'reference'],
18 | ) =>
19 | `${BASE_URL}?query=${keyword}&rows=${rows}&select=${selects.join(
20 | ',',
21 | )}&mailto=${MAIL_TO}&sort=is-referenced-by-count&cursor=${cursor}`;
22 | export const CROSSREF_API_PAPER_URL = (doi: string) => `${BASE_URL}/${doi}`;
23 | export class Queue {
24 | data: Set;
25 | constructor() {
26 | this.data = new Set();
27 | }
28 | pop() {
29 | const firstValue = this.data[Symbol.iterator]().next().value as T;
30 | this.data.delete(firstValue);
31 | return firstValue;
32 | }
33 | push(value: T) {
34 | if (!this.data.has(value)) this.data.add(value);
35 | }
36 | isEmpty() {
37 | if (this.data.size == 0) return true;
38 | else return false;
39 | }
40 | }
41 | export const CROSSREF_CACHE_QUEUE = new Queue();
42 | export const urlRegex = /(https?:\/\/[^\s]+)/gm;
43 |
--------------------------------------------------------------------------------
/backend/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
4 | }
5 |
--------------------------------------------------------------------------------
/backend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "commonjs",
4 | "declaration": true,
5 | "removeComments": true,
6 | "emitDecoratorMetadata": true,
7 | "experimentalDecorators": true,
8 | "allowSyntheticDefaultImports": true,
9 | "target": "es2017",
10 | "sourceMap": true,
11 | "outDir": "./dist",
12 | "baseUrl": "./",
13 | "incremental": true,
14 | "skipLibCheck": true,
15 | "strictNullChecks": false,
16 | "noImplicitAny": false,
17 | "strictBindCallApply": false,
18 | "forceConsistentCasingInFileNames": false,
19 | "noFallthroughCasesInSwitch": false
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/docker-compose/docker-compose.be.prod.yml:
--------------------------------------------------------------------------------
1 | version: "3"
2 |
3 | services:
4 | prv:
5 | restart: always
6 | env_file:
7 | - ${ENV_FILE_BE}
8 | image: ${NCP_CONTAINER_REGISTRY}/prv-backend
9 | links:
10 | - redis
11 | ports:
12 | - 4000:4000
13 |
14 | redis:
15 | image: redis:7.0.5
16 | expose:
17 | - 6379
18 | command: redis-server --save 60 1000 --loglevel notice --requirepass ${REDIS_PASSWORD}
--------------------------------------------------------------------------------
/docker-compose/docker-compose.dev.yml:
--------------------------------------------------------------------------------
1 | version: "3"
2 |
3 | services:
4 | prv:
5 | restart: always
6 | env_file:
7 | - ${ENV_FILE_BE}
8 | image: ${NCP_CONTAINER_REGISTRY}/prv-backend
9 | ports:
10 | - 4000:4000
11 |
12 |
13 | redis:
14 | image: redis:7.0.5
15 | expose:
16 | - 6379
17 | command: redis-server --save 60 1000 --loglevel notice --requirepass ${REDIS_PASSWORD}
18 |
19 | client:
20 | env_file:
21 | - ${ENV_FILE_FE}
22 | image: ${NCP_CONTAINER_REGISTRY}/prv-frontend
23 | ports:
24 | - 3000:80
--------------------------------------------------------------------------------
/docker-compose/docker-compose.elasticsearch.yml:
--------------------------------------------------------------------------------
1 | version: "3.8"
2 |
3 | services:
4 | setup:
5 | container_name: setup
6 | image: docker.elastic.co/elasticsearch/elasticsearch:${STACK_VERSION}
7 | volumes:
8 | - certs:/usr/share/elasticsearch/config/certs
9 | user: "0"
10 | command: >
11 | bash -c '
12 | if [ ! -f config/certs/ca.zip ]; then
13 | echo "Creating CA";
14 | bin/elasticsearch-certutil ca --silent --pem -out config/certs/ca.zip;
15 | unzip config/certs/ca.zip -d config/certs;
16 | fi;
17 | if [ ! -f config/certs/certs.zip ]; then
18 | echo "Creating certs";
19 | echo -ne \
20 | "instances:\n"\
21 | " - name: esmaster\n"\
22 | " dns:\n"\
23 | " - esmaster\n"\
24 | " - localhost\n"\
25 | " ip:\n"\
26 | " - 127.0.0.1\n"\
27 | > config/certs/instances.yml;
28 | bin/elasticsearch-certutil cert --silent --pem -out config/certs/certs.zip --in config/certs/instances.yml --ca-cert config/certs/ca/ca.crt --ca-key config/certs/ca/ca.key;
29 | unzip -o config/certs/certs.zip -d config/certs;
30 | fi;
31 | echo "Setting file permissions"
32 | chown -R root:root config/certs;
33 | find . -type d -exec chmod 750 \{\} \;;
34 | find . -type f -exec chmod 640 \{\} \;;
35 | echo "Waiting for Elasticsearch availability";
36 | until curl -s --cacert config/certs/ca/ca.crt https://esmaster:9200 | grep -q "missing authentication credentials"; do sleep 30; done;
37 | echo "Setting kibana_system password";
38 | until curl -s -X POST --cacert config/certs/ca/ca.crt -u "elastic:${ELASTIC_PASSWORD}" -H "Content-Type: application/json" https://esmaster:9200/_security/user/kibana_system/_password -d "{\"password\":\"${KIBANA_PASSWORD}\"}" | grep -q "^{}"; do sleep 10; done;
39 | echo "Setup Finished";
40 | '
41 | esmaster:
42 | container_name: esmaster
43 | depends_on:
44 | - setup
45 | image: docker.elastic.co/elasticsearch/elasticsearch:${STACK_VERSION}
46 | volumes:
47 | - certs:/usr/share/elasticsearch/config/certs
48 | - esmasterdata:/usr/share/elasticsearch/data
49 | ports:
50 | - ${ES_PORT}:9200
51 | environment:
52 | - node.name=esmaster
53 | - ELASTIC_PASSWORD=${ELASTIC_PASSWORD}
54 | - bootstrap.memory_lock=true
55 | - discovery.type=single-node
56 | - xpack.security.enabled=true
57 | - xpack.security.http.ssl.enabled=true
58 | - xpack.security.http.ssl.key=certs/esmaster/esmaster.key
59 | - xpack.security.http.ssl.certificate=certs/esmaster/esmaster.crt
60 | - xpack.security.http.ssl.certificate_authorities=certs/ca/ca.crt
61 | - xpack.security.http.ssl.verification_mode=certificate
62 | - xpack.security.transport.ssl.enabled=true
63 | - xpack.security.transport.ssl.key=certs/esmaster/esmaster.key
64 | - xpack.security.transport.ssl.certificate=certs/esmaster/esmaster.crt
65 | - xpack.security.transport.ssl.certificate_authorities=certs/ca/ca.crt
66 | - xpack.security.transport.ssl.verification_mode=certificate
67 | - xpack.license.self_generated.type=${LICENSE}
68 | ulimits:
69 | memlock:
70 | soft: -1
71 | hard: -1
72 |
73 | kibana:
74 | container_name: kibana
75 | depends_on:
76 | - esmaster
77 | image: docker.elastic.co/kibana/kibana:${STACK_VERSION}
78 | volumes:
79 | - certs:/usr/share/kibana/config/certs
80 | - kibana-data:/usr/share/kibana/data
81 | ports:
82 | - ${KIBANA_PORT}:5601
83 | environment:
84 | - SERVERNAME=kibana
85 | - ELASTICSEARCH_HOSTS=https://esmaster:${ES_PORT}
86 | - ELASTICSEARCH_USERNAME=kibana_system
87 | - ELASTICSEARCH_PASSWORD=${KIBANA_PASSWORD}
88 | - ELASTICSEARCH_SSL_CERTIFICATEAUTHORITIES=config/certs/ca/ca.crt
89 | ulimits:
90 | memlock:
91 | soft: -1
92 | hard: -1
93 |
94 | volumes:
95 | certs:
96 | driver: local
97 | esmasterdata:
98 | driver: local
99 | kibana-data:
100 | driver: local
--------------------------------------------------------------------------------
/docker-compose/docker-compose.fe.prod.yml:
--------------------------------------------------------------------------------
1 | version: "3"
2 |
3 | services:
4 | client:
5 | image: ${NCP_CONTAINER_REGISTRY}/prv-frontend
6 | ports:
7 | - 80:80
8 | - 443:443
9 |
--------------------------------------------------------------------------------
/frontend/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | build
--------------------------------------------------------------------------------
/frontend/.env.sample:
--------------------------------------------------------------------------------
1 | REACT_APP_BASE_URL=
--------------------------------------------------------------------------------
/frontend/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "es2021": true
5 | },
6 | "extends": ["eslint:recommended", "plugin:react/recommended", "plugin:@typescript-eslint/recommended", "prettier"],
7 | "overrides": [],
8 | "parser": "@typescript-eslint/parser",
9 | "parserOptions": {
10 | "ecmaVersion": "latest",
11 | "sourceType": "module"
12 | },
13 | "plugins": ["react", "@typescript-eslint", "prettier", "react-hooks"],
14 | "rules": {
15 | "react/react-in-jsx-scope": "off",
16 | "react-hooks/rules-of-hooks": "error",
17 | "react-hooks/exhaustive-deps": "warn"
18 | },
19 | "settings": {
20 | "import/resolver": {
21 | "typescript": {}
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 | .DS_Store
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/frontend/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "endOfLine": "lf",
3 | "tabWidth": 2,
4 | "semi": true,
5 | "singleQuote": true,
6 | "trailingComma": "all",
7 | "printWidth": 120,
8 | "bracketSpacing": true,
9 | "useTabs": false
10 | }
--------------------------------------------------------------------------------
/frontend/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:18 as builder
2 | WORKDIR /app
3 | COPY package*.json .
4 | RUN npm ci
5 | COPY . .
6 | RUN --mount=type=secret,id=REACT_APP_BASE_URL \
7 | export REACT_APP_BASE_URL=$(cat /run/secrets/REACT_APP_BASE_URL) && \
8 | echo "REACT_APP_BASE_URL=$REACT_APP_BASE_URL" >> .env.production
9 | RUN ls -al
10 | RUN npm run build
11 |
12 | FROM nginx:alpine
13 | WORKDIR /usr/share/nginx/statics
14 | RUN rm /etc/nginx/conf.d/default.conf
15 | COPY ./nginx.conf /etc/nginx/conf.d
16 | COPY --from=builder /app/build .
17 | EXPOSE 80
18 | CMD ["nginx", "-g", "daemon off;"]
--------------------------------------------------------------------------------
/frontend/README.md:
--------------------------------------------------------------------------------
1 | # Getting Started with Create React App
2 |
3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
4 |
5 | ## Available Scripts
6 |
7 | In the project directory, you can run:
8 |
9 | ### `npm start`
10 |
11 | Runs the app in the development mode.\
12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
13 |
14 | The page will reload if you make edits.\
15 | You will also see any lint errors in the console.
16 |
17 | ### `npm test`
18 |
19 | Launches the test runner in the interactive watch mode.\
20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
21 |
22 | ### `npm run build`
23 |
24 | Builds the app for production to the `build` folder.\
25 | It correctly bundles React in production mode and optimizes the build for the best performance.
26 |
27 | The build is minified and the filenames include the hashes.\
28 | Your app is ready to be deployed!
29 |
30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
31 |
32 | ### `npm run eject`
33 |
34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!**
35 |
36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
37 |
38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
39 |
40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
41 |
42 | ## Learn More
43 |
44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
45 |
46 | To learn React, check out the [React documentation](https://reactjs.org/).
47 |
--------------------------------------------------------------------------------
/frontend/config-overrides.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-var-requires */
2 | /* eslint-disable no-undef */
3 | const { override, addWebpackAlias } = require('customize-cra');
4 | const path = require('path');
5 |
6 | module.exports = override(
7 | addWebpackAlias({
8 | '@': path.resolve(__dirname, 'src'),
9 | }),
10 | );
11 |
--------------------------------------------------------------------------------
/frontend/nginx.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 80;
3 | location / {
4 | root /usr/share/nginx/statics;
5 | index index.html;
6 | try_files $uri $uri/ /index.html;
7 | }
8 | }
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@testing-library/jest-dom": "^5.16.5",
7 | "@testing-library/react": "^13.4.0",
8 | "@testing-library/user-event": "^13.5.0",
9 | "@types/jest": "^27.5.2",
10 | "@types/node": "^16.18.3",
11 | "@types/react": "^18.0.25",
12 | "@types/react-dom": "^18.0.8",
13 | "axios": "^1.1.3",
14 | "d3": "^7.6.1",
15 | "lodash-es": "^4.17.21",
16 | "react": "^18.2.0",
17 | "react-dom": "^18.2.0",
18 | "react-query": "^3.39.2",
19 | "react-router-dom": "^6.4.3",
20 | "react-scripts": "5.0.1",
21 | "styled-components": "^5.3.6",
22 | "styled-reset": "^4.4.2",
23 | "typescript": "^4.8.4",
24 | "web-vitals": "^2.1.4"
25 | },
26 | "scripts": {
27 | "start": "react-app-rewired start",
28 | "build": "react-app-rewired build",
29 | "test": "react-app-rewired test",
30 | "eject": "react-scripts eject"
31 | },
32 | "eslintConfig": {
33 | "extends": [
34 | "react-app",
35 | "react-app/jest"
36 | ]
37 | },
38 | "browserslist": {
39 | "production": [
40 | ">0.2%",
41 | "not dead",
42 | "not op_mini all"
43 | ],
44 | "development": [
45 | "last 1 chrome version",
46 | "last 1 firefox version",
47 | "last 1 safari version"
48 | ]
49 | },
50 | "devDependencies": {
51 | "@types/d3": "^7.4.0",
52 | "@types/lodash-es": "^4.17.6",
53 | "@types/styled-components": "^5.1.26",
54 | "@typescript-eslint/eslint-plugin": "^5.42.1",
55 | "@typescript-eslint/parser": "^5.42.1",
56 | "customize-cra": "^1.0.0",
57 | "eslint": "^8.27.0",
58 | "eslint-config-prettier": "^8.5.0",
59 | "eslint-plugin-prettier": "^4.2.1",
60 | "eslint-plugin-react": "^7.31.10",
61 | "eslint-plugin-react-hooks": "^4.6.0",
62 | "react-app-rewired": "^2.2.1",
63 | "prettier": "^2.7.1"
64 | },
65 | "jest": {
66 | "moduleNameMapper": {
67 | "@/(.*)": "/src/$1"
68 | }
69 | }
70 | }
--------------------------------------------------------------------------------
/frontend/public/assets/moon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/web18-PRV/431c9f47b1ff229d44f37c3dba7e717e0f9e07fb/frontend/public/assets/moon.png
--------------------------------------------------------------------------------
/frontend/public/assets/prv-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/web18-PRV/431c9f47b1ff229d44f37c3dba7e717e0f9e07fb/frontend/public/assets/prv-image.png
--------------------------------------------------------------------------------
/frontend/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/web18-PRV/431c9f47b1ff229d44f37c3dba7e717e0f9e07fb/frontend/public/favicon.ico
--------------------------------------------------------------------------------
/frontend/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | PRV
33 |
34 |
35 |
36 | You need to enable JavaScript to run this app.
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/frontend/public/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Allow: /
--------------------------------------------------------------------------------
/frontend/src/App.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import { act } from 'react-dom/test-utils';
4 | import { ThemeProvider } from 'styled-components';
5 | import theme from './style/theme';
6 | import Footer from './components/Footer';
7 |
8 | let container: HTMLDivElement;
9 | beforeEach(() => {
10 | container = document.createElement('div');
11 | document.body.appendChild(container);
12 | });
13 | afterEach(() => {
14 | document.body.removeChild(container);
15 | });
16 |
17 | it('Footer 렌더링 테스트', () => {
18 | act(() => {
19 | ReactDOM.createRoot(container).render(
20 |
21 |
22 | ,
23 | );
24 | });
25 | const span = container?.querySelector('span');
26 | expect(span?.textContent).toBe('문의사항, 버그제보: viewpoint.prv@gmail.com');
27 | });
28 |
--------------------------------------------------------------------------------
/frontend/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { LoaderWrapper } from '@/components';
2 | import { PATH_DETAIL, PATH_MAIN, PATH_SEARCH_LIST } from '@/constants/path';
3 | import { ErrorBoundary } from '@/error';
4 | import GlobalStyle from '@/style/GlobalStyle';
5 | import theme from '@/style/theme';
6 | import { AxiosError } from 'axios';
7 | import React, { Suspense } from 'react';
8 | import { QueryClient, QueryClientProvider } from 'react-query';
9 | import { ReactQueryDevtools } from 'react-query/devtools';
10 | import { Route, Routes, useLocation } from 'react-router-dom';
11 | import { ThemeProvider } from 'styled-components';
12 | import { Reset } from 'styled-reset';
13 |
14 | const Main = React.lazy(() => import('./pages/Main/Main'));
15 | const SearchList = React.lazy(() => import('./pages/SearchList/SearchList'));
16 | const GlobalErrorFallback = React.lazy(() => import('./error/GlobalErrorFallback'));
17 | const PaperDatail = React.lazy(() => import('./pages/PaperDetail/PaperDetail'));
18 |
19 | const queryClient = new QueryClient({
20 | defaultOptions: {
21 | queries: {
22 | refetchOnWindowFocus: false,
23 | refetchOnMount: false,
24 | refetchOnReconnect: false,
25 | staleTime: 10 * 60 * 1000,
26 | cacheTime: 10 * 60 * 1000,
27 | retry: (failureCount, error) => {
28 | if (error instanceof AxiosError) {
29 | return error.response?.status === 408 && failureCount <= 1 ? true : false;
30 | }
31 | return false;
32 | },
33 | suspense: true,
34 | },
35 | },
36 | });
37 |
38 | function App() {
39 | const location = useLocation();
40 |
41 | return (
42 |
43 |
44 |
45 |
46 |
47 | }>
48 |
49 | } />
50 | } />
51 | } />
52 | } />
53 |
54 |
55 |
56 |
57 |
58 |
59 | );
60 | }
61 |
62 | export default App;
63 |
--------------------------------------------------------------------------------
/frontend/src/api/api.ts:
--------------------------------------------------------------------------------
1 | import axios, { AxiosInstance } from 'axios';
2 |
3 | export interface IRankingData {
4 | keyword: string;
5 | count: number;
6 | }
7 |
8 | export interface IPapersData {
9 | papers: IPaper[];
10 | pageInfo: IPageInfo;
11 | }
12 |
13 | export interface IGetSearch {
14 | keyword: string;
15 | page: string;
16 | rows?: string;
17 | }
18 | export interface IGetAutoComplete {
19 | keyword: string;
20 | }
21 | export interface IAutoCompletedItem {
22 | authors?: string[];
23 | doi: string;
24 | title: string;
25 | }
26 | export interface IGetPaperDetail {
27 | doi: string;
28 | }
29 | export interface IPaperDetail extends IPaper {
30 | referenceList: IReference[];
31 | }
32 |
33 | export interface IReference {
34 | key: string;
35 | title?: string;
36 | authors?: string[];
37 | doi?: string;
38 | publishedAt?: string;
39 | citations?: number;
40 | references?: number;
41 | }
42 | export interface IPaper {
43 | title: string;
44 | authors: string[];
45 | doi: string;
46 | key: string;
47 | publishedAt: string;
48 | citations: number;
49 | references: number;
50 | }
51 |
52 | export interface IPageInfo {
53 | totalItems: number;
54 | totalPages: number;
55 | }
56 |
57 | export default class Api {
58 | private readonly baseURL = process.env.REACT_APP_BASE_URL;
59 | private readonly instance: AxiosInstance;
60 |
61 | constructor() {
62 | this.instance = axios.create({ baseURL: this.baseURL });
63 | }
64 |
65 | async getKeywordRanking(): Promise {
66 | const res = await this.instance.get('/keyword-ranking');
67 | return res.data;
68 | }
69 |
70 | async getSearch(params: IGetSearch): Promise {
71 | params.keyword = decodeURI(params.keyword);
72 | const res = await this.instance.get('/search', {
73 | params,
74 | });
75 | return res.data;
76 | }
77 |
78 | async getAutoComplete(params: IGetAutoComplete): Promise {
79 | const res = await this.instance.get('/search/auto-complete', { params });
80 | return res.data;
81 | }
82 |
83 | async getPaperDetail(params: IGetPaperDetail): Promise {
84 | const res = await this.instance.get(`/search/paper`, { params });
85 | return res.data;
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/frontend/src/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import { GithubLogoIcon } from '@/icons';
2 | import styled from 'styled-components';
3 |
4 | interface FooterProps {
5 | bgColor?: string;
6 | contentColor?: string;
7 | }
8 |
9 | const Footer = ({ bgColor, contentColor }: FooterProps) => {
10 | return (
11 |
12 | 문의사항, 버그제보: viewpoint.prv@gmail.com
13 |
14 |
19 | 데이터 수집 정책
20 |
21 | Copyright Ⓒ 2022. View Point All rights reserved.
22 |
28 |
29 |
30 |
31 |
32 | );
33 | };
34 |
35 | const DataLink = styled.a`
36 | :hover {
37 | text-decoration: underline;
38 | }
39 | `;
40 |
41 | const Container = styled.footer<{ bgColor?: string; contentColor?: string }>`
42 | display: flex;
43 | justify-content: space-between;
44 | align-items: center;
45 | width: 100%;
46 | height: 45px;
47 | padding: 0 25px;
48 | background-color: ${({ bgColor, theme }) => bgColor || `${theme.COLOR.offWhite}10`};
49 | color: ${({ contentColor, theme }) => contentColor || theme.COLOR.gray1};
50 | ${({ theme }) => theme.TYPO.caption}
51 | `;
52 |
53 | const FooterRight = styled.div`
54 | display: flex;
55 | align-items: center;
56 | gap: 15px;
57 | `;
58 |
59 | export default Footer;
60 |
--------------------------------------------------------------------------------
/frontend/src/components/IconButton.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | interface IProps {
4 | icon: JSX.Element;
5 | onClick?: React.MouseEventHandler;
6 | onMouseDown?: React.MouseEventHandler;
7 | type?: 'button' | 'submit' | 'reset' | undefined;
8 | 'aria-label': string;
9 | }
10 |
11 | const IconButton = ({ icon, type = 'button', ...rest }: IProps) => {
12 | return (
13 |
14 | {icon}
15 |
16 | );
17 | };
18 |
19 | const Button = styled.button`
20 | display: flex;
21 | align-items: center;
22 | background-color: transparent;
23 | cursor: pointer;
24 | `;
25 |
26 | export default IconButton;
27 |
--------------------------------------------------------------------------------
/frontend/src/components/Pagination.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react';
2 | import styled from 'styled-components';
3 |
4 | interface PaginationProps {
5 | activePage: number;
6 | totalPages: number;
7 | range: number;
8 | onChange: (page: number) => void;
9 | }
10 |
11 | const Pagination = ({ activePage, totalPages, range, onChange }: PaginationProps) => {
12 | const pageItems = useMemo(
13 | () =>
14 | Array.from(
15 | { length: Math.min(range, totalPages - (Math.ceil(activePage / range) - 1) * range) },
16 | (_, i) => (Math.ceil(activePage / range) - 1) * range + i + 1,
17 | ),
18 | [activePage, range, totalPages],
19 | );
20 |
21 | const isPrevButtonExist = activePage > range;
22 | const isNextButtonExist = totalPages > Math.ceil(activePage / range) * range;
23 |
24 | // 이전 범위의 마지막 페이지로 이동
25 | const goToPrevRangeLastPage = () => {
26 | onChange((Math.ceil(activePage / range) - 1) * range);
27 | };
28 |
29 | // 이후 범위의 첫 페이지로 이동
30 | const goToNextRangeFirstPage = () => {
31 | onChange(Math.ceil(activePage / range) * range + 1);
32 | };
33 |
34 | return (
35 |
36 |
37 | 이전
38 |
39 | {pageItems.map((page) => {
40 | const currentPage = activePage === page;
41 | return (
42 | onChange(page)}>
43 | {page}
44 |
45 | );
46 | })}
47 |
48 | 다음
49 |
50 |
51 | );
52 | };
53 |
54 | const Container = styled.div`
55 | ${({ theme }) => theme.TYPO.body1};
56 | margin: 20px auto 0 auto;
57 | `;
58 |
59 | const Button = styled.button<{ disabled: boolean }>`
60 | display: ${({ disabled }) => (disabled ? 'none' : 'initial')};
61 | background-color: transparent;
62 | margin: 0 10px;
63 | color: ${({ theme }) => theme.COLOR.gray3};
64 | cursor: pointer;
65 | :hover {
66 | color: ${({ theme }) => theme.COLOR.black};
67 | }
68 | `;
69 |
70 | const PaginationItem = styled.span<{ isSelected: boolean }>`
71 | cursor: pointer;
72 | margin: 0 5px;
73 | font-weight: ${({ isSelected }) => (isSelected ? 700 : 'auto')};
74 | :hover {
75 | text-decoration: underline;
76 | }
77 | `;
78 |
79 | export default Pagination;
80 |
--------------------------------------------------------------------------------
/frontend/src/components/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Footer } from './Footer';
2 | export { default as IconButton } from './IconButton';
3 | export { default as LoaderWrapper } from './loader/LoaderWrapper';
4 | export { default as MoonLoader } from './loader/MoonLoader';
5 | export { default as Pagination } from './Pagination';
6 | export { default as Search } from './search/Search';
7 |
--------------------------------------------------------------------------------
/frontend/src/components/loader/LoaderWrapper.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import MoonLoader from './MoonLoader';
3 |
4 | const LoaderWrapper = () => {
5 | return (
6 |
7 |
8 |
9 | );
10 | };
11 |
12 | const MoonWrapper = styled.div`
13 | display: flex;
14 | align-items: center;
15 | justify-content: center;
16 | width: 100%;
17 | height: 100%;
18 | flex: 1;
19 | `;
20 |
21 | export default LoaderWrapper;
22 |
--------------------------------------------------------------------------------
/frontend/src/components/loader/MoonLoader.tsx:
--------------------------------------------------------------------------------
1 | import theme from '@/style/theme';
2 | import * as d3 from 'd3';
3 | import { memo, useEffect, useRef } from 'react';
4 | import styled from 'styled-components';
5 |
6 | const MoonLoader = () => {
7 | const pathRef = useRef(null);
8 | const moonSize = 50;
9 |
10 | const calculateMoonLightPath = (radian: number) => {
11 | return `M 50,0
12 | A 50,50 0 0 ${Math.floor(radian / Math.PI) % 2} 50,100
13 | A ${50 * Math.cos(radian)},50 0 0 ${Math.floor((radian / Math.PI) * 2) % 2} 50,0`;
14 | };
15 |
16 | useEffect(() => {
17 | let radian = Math.PI;
18 | const timer = d3.interval(() => {
19 | d3.select(pathRef.current).attr('d', calculateMoonLightPath(radian));
20 | radian += Math.PI / 10;
21 | }, 100);
22 | return () => timer.stop();
23 | }, []);
24 |
25 | return (
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | );
55 | };
56 |
57 | const MoonContainer = styled.div`
58 | text-align: center;
59 | margin-top: 25px;
60 | `;
61 |
62 | export default memo(MoonLoader);
63 |
--------------------------------------------------------------------------------
/frontend/src/components/search/AutoCompletedList.tsx:
--------------------------------------------------------------------------------
1 | import { IAutoCompletedItem } from '@/api/api';
2 | import { highlightKeyword, removeTag, sliceTitle } from '@/utils/format';
3 | import { Dispatch, SetStateAction, useEffect } from 'react';
4 | import styled from 'styled-components';
5 | interface AutoCompletedListProps {
6 | autoCompletedItems?: IAutoCompletedItem[];
7 | keyword: string;
8 | hoverdIndex: number;
9 | setHoveredIndex: Dispatch>;
10 | handleMouseDown: (doi: string) => void;
11 | }
12 |
13 | const AutoCompletedList = ({
14 | autoCompletedItems = [],
15 | keyword,
16 | hoverdIndex,
17 | setHoveredIndex,
18 | handleMouseDown,
19 | }: AutoCompletedListProps) => {
20 | const handleAutoCompletedDown = (index: number) => {
21 | const doi = autoCompletedItems[index].doi;
22 | handleMouseDown(doi);
23 | };
24 |
25 | // 대표 author찾기
26 | const getRepresentativeAuthor = (authors: string[]) => {
27 | return (
28 | authors
29 | .concat()
30 | .filter((v, i, arr: string[]) => v.toLowerCase().includes(keyword.toLowerCase()) && arr.splice(i))?.[0] ||
31 | authors[0]
32 | );
33 | };
34 |
35 | useEffect(() => {
36 | setHoveredIndex(-1);
37 | }, [setHoveredIndex]);
38 |
39 | return (
40 |
41 | {autoCompletedItems.length > 0 ? (
42 | autoCompletedItems.map((item, i) => (
43 | setHoveredIndex(i)}
47 | onMouseDown={() => handleAutoCompletedDown(i)}
48 | >
49 | {highlightKeyword(sliceTitle(removeTag(item.title)), keyword)}
50 | {item.authors && (
51 |
52 | authors : {highlightKeyword(getRepresentativeAuthor(item.authors), keyword)}
53 | {item.authors.length > 1 && 외 {item.authors.length - 1}명 }
54 |
55 | )}
56 |
57 | ))
58 | ) : (
59 | 자동완성 검색어가 없습니다.
60 | )}
61 |
62 | );
63 | };
64 |
65 | const Container = styled.div`
66 | overflow-y: auto;
67 | `;
68 |
69 | const AutoCompleted = styled.li<{ hovered: boolean }>`
70 | display: flex;
71 | flex-direction: column;
72 | width: 100%;
73 | padding: 8px 30px;
74 | gap: 4px;
75 | color: ${({ theme }) => theme.COLOR.black};
76 | cursor: pointer;
77 | background-color: ${({ theme, hovered }) => (hovered ? theme.COLOR.gray1 : 'auto')};
78 | `;
79 |
80 | const Title = styled.div`
81 | ${({ theme }) => theme.TYPO.body1}
82 | line-height: 1.1em;
83 | `;
84 |
85 | const Author = styled.div`
86 | ${({ theme }) => theme.TYPO.caption}
87 | color: ${({ theme }) => theme.COLOR.gray3};
88 | `;
89 |
90 | const NoResult = styled.div`
91 | padding-top: 25px;
92 | text-align: center;
93 | overflow: hidden;
94 | `;
95 |
96 | export default AutoCompletedList;
97 |
--------------------------------------------------------------------------------
/frontend/src/components/search/RecentKeywordsList.tsx:
--------------------------------------------------------------------------------
1 | import { IconButton } from '@/components';
2 | import { ClockIcon, XIcon } from '@/icons';
3 | import { Ellipsis } from '@/style/styleUtils';
4 | import { setLocalStorage } from '@/utils/storage';
5 | import { isEmpty } from 'lodash-es';
6 | import { Dispatch, SetStateAction, useEffect } from 'react';
7 | import styled from 'styled-components';
8 |
9 | interface RecentKeywordsListProps {
10 | recentKeywords: string[];
11 | hoverdIndex: number;
12 | handleMouseDown: (prop: string) => void;
13 | setHoveredIndex: Dispatch>;
14 | initializeRecentKeywords: () => void;
15 | }
16 |
17 | const RecentKeywordsList = ({
18 | recentKeywords,
19 | hoverdIndex,
20 | handleMouseDown,
21 | setHoveredIndex,
22 | initializeRecentKeywords,
23 | }: RecentKeywordsListProps) => {
24 | const handleRecentKeywordRemove = (e: React.MouseEvent, keyword: string) => {
25 | e.preventDefault();
26 | e.stopPropagation();
27 | setLocalStorage('recentKeywords', Array.from([...recentKeywords.filter((v) => v !== keyword)]));
28 | initializeRecentKeywords();
29 | };
30 |
31 | useEffect(() => {
32 | setHoveredIndex(-1);
33 | }, [setHoveredIndex]);
34 |
35 | return (
36 |
37 | {!isEmpty(recentKeywords) ? (
38 | recentKeywords.map((keyword, i) => (
39 | setHoveredIndex(i)}
43 | onMouseDown={() => handleMouseDown(keyword)}
44 | >
45 |
46 | {keyword}
47 | }
49 | onMouseDown={(e) => handleRecentKeywordRemove(e, keyword)}
50 | aria-label="삭제"
51 | />
52 |
53 | ))
54 | ) : (
55 | 최근 검색어가 없습니다.
56 | )}
57 |
58 | );
59 | };
60 |
61 | const Container = styled.div`
62 | overflow-y: auto;
63 | `;
64 |
65 | const Keyword = styled.li<{ hovered: boolean }>`
66 | display: flex;
67 | gap: 20px;
68 | width: 100%;
69 | padding: 8px 16px;
70 | color: ${({ theme }) => theme.COLOR.black};
71 | cursor: pointer;
72 | background-color: ${({ theme, hovered }) => (hovered ? theme.COLOR.gray1 : 'auto')};
73 | `;
74 |
75 | const KeywordText = styled(Ellipsis)`
76 | width: 100%;
77 | display: block;
78 | `;
79 |
80 | const NoResult = styled.div`
81 | padding-top: 25px;
82 | text-align: center;
83 | overflow: hidden;
84 | `;
85 |
86 | const DeleteButton = styled(IconButton)`
87 | margin-left: auto;
88 | width: 20px;
89 | height: 20px;
90 | display: flex;
91 | justify-content: center;
92 | align-items: center;
93 | :hover {
94 | background-color: ${({ theme }) => theme.COLOR.offWhite};
95 | border-radius: 50%;
96 | }
97 | `;
98 |
99 | export default RecentKeywordsList;
100 |
--------------------------------------------------------------------------------
/frontend/src/components/search/Search.tsx:
--------------------------------------------------------------------------------
1 | import { IPaperDetail } from '@/api/api';
2 | import { IconButton, MoonLoader } from '@/components';
3 | import { PATH_SEARCH_LIST } from '@/constants/path';
4 | import { useDebouncedValue } from '@/hooks';
5 | import { MagnifyingGlassIcon } from '@/icons';
6 | import { useAutoCompleteQuery } from '@/queries/queries';
7 | import { createDetailQuery } from '@/utils/createQueryString';
8 | import { getDoiKey, isDoiFormat } from '@/utils/format';
9 | import { getLocalStorage, setLocalStorage } from '@/utils/storage';
10 | import { ChangeEvent, FormEvent, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
11 | import { createSearchParams, useNavigate } from 'react-router-dom';
12 | import styled from 'styled-components';
13 | import AutoCompletedList from './AutoCompletedList';
14 | import RecentKeywordsList from './RecentKeywordsList';
15 |
16 | enum DROPDOWN_TYPE {
17 | AUTO_COMPLETE = 'AUTO_COMPLETE',
18 | RECENT_KEYWORDS = 'RECENT_KEYWORDS',
19 | HIDDEN = 'HIDDEN',
20 | LOADING = 'LOADING',
21 | }
22 |
23 | interface SearchProps {
24 | initialKeyword?: string;
25 | }
26 |
27 | const Search = ({ initialKeyword = '' }: SearchProps) => {
28 | const [keyword, setKeyword] = useState(initialKeyword);
29 | const [recentKeywords, setRecentKeywords] = useState([]);
30 | const [isFocused, setIsFocused] = useState(false);
31 | const [hoverdIndex, setHoveredIndex] = useState(-1);
32 | const inputRef = useRef(null);
33 |
34 | const debouncedValue = useDebouncedValue(keyword, 150);
35 |
36 | const { isLoading, data: autoCompletedItems } = useAutoCompleteQuery(debouncedValue, {
37 | enabled: !!(debouncedValue && debouncedValue.length >= 2 && isFocused),
38 | });
39 |
40 | const navigate = useNavigate();
41 |
42 | const dropdownType = useMemo(() => {
43 | // 포커스 되지 않거나 doi포맷으로 입력하는 경우에는 드랍다운을 숨긴다
44 | if (!isFocused || isDoiFormat(debouncedValue)) {
45 | return DROPDOWN_TYPE.HIDDEN;
46 | }
47 | if (isLoading) {
48 | return DROPDOWN_TYPE.LOADING;
49 | }
50 | if (debouncedValue.length >= 2) {
51 | return DROPDOWN_TYPE.AUTO_COMPLETE;
52 | }
53 | return DROPDOWN_TYPE.RECENT_KEYWORDS;
54 | }, [isFocused, isLoading, debouncedValue]);
55 |
56 | // keyword 검색
57 | const goToSearchList = useCallback(
58 | (keyword: string) => {
59 | const params = { keyword, page: '1', rows: '20' };
60 | navigate({
61 | pathname: PATH_SEARCH_LIST,
62 | search: createSearchParams(params).toString(),
63 | });
64 | },
65 | [navigate],
66 | );
67 |
68 | const getRecentKeywords = () => {
69 | const result = getLocalStorage('recentKeywords');
70 | if (!Array.isArray(result)) {
71 | return [];
72 | }
73 | return result;
74 | };
75 |
76 | // 논문 상세정보 페이지로 이동
77 | const goToDetailPage = (doi: string, state?: { initialData: IPaperDetail }) => {
78 | navigate(createDetailQuery(doi), { state });
79 | };
80 |
81 | const handleInputChange = (e: ChangeEvent) => {
82 | const target = e.target as HTMLInputElement;
83 | setKeyword(target.value);
84 | };
85 |
86 | const handleInputFocus = () => {
87 | setIsFocused(true);
88 | };
89 |
90 | const handleInputBlur = () => {
91 | setIsFocused(false);
92 | };
93 |
94 | const initializeRecentKeywords = useCallback(() => {
95 | const recentKeywords = getRecentKeywords();
96 | setRecentKeywords(recentKeywords);
97 | }, []);
98 |
99 | // localStorage에 최근 검색어를 중복없이 최대 5개까지 저장 후 search-list로 이동
100 | const onKeywordSearch = async (newKeyword: string) => {
101 | if (!newKeyword) return;
102 | setKeyword(newKeyword);
103 | setLocalStorage(
104 | 'recentKeywords',
105 | Array.from([newKeyword, ...recentKeywords.filter((keyword) => keyword !== newKeyword)]).slice(0, 5),
106 | );
107 |
108 | // DOI 형식의 input이 들어온 경우
109 | if (isDoiFormat(newKeyword)) {
110 | goToDetailPage(getDoiKey(newKeyword));
111 | return;
112 | }
113 | goToSearchList(newKeyword);
114 | };
115 |
116 | const handleSubmit = (e: FormEvent) => {
117 | e.preventDefault();
118 | if (!inputRef.current) return;
119 | inputRef.current.blur();
120 |
121 | // hover된 항목이 없는경우
122 | if (hoverdIndex < 0) {
123 | onKeywordSearch(keyword);
124 | return;
125 | }
126 | // hover된 항목으로 검색
127 | switch (dropdownType) {
128 | case DROPDOWN_TYPE.AUTO_COMPLETE:
129 | autoCompletedItems && goToDetailPage(autoCompletedItems?.[hoverdIndex].doi);
130 | break;
131 | case DROPDOWN_TYPE.RECENT_KEYWORDS:
132 | onKeywordSearch(recentKeywords[hoverdIndex]);
133 | break;
134 | }
135 | };
136 |
137 | // 방향키 입력 이벤트 핸들러
138 | const handleInputKeyDown = (e: KeyboardEvent) => {
139 | const length = dropdownType === DROPDOWN_TYPE.AUTO_COMPLETE ? autoCompletedItems?.length : recentKeywords.length;
140 |
141 | if (length === undefined) return;
142 |
143 | switch (e.code) {
144 | case 'ArrowDown':
145 | setHoveredIndex((prev) => (prev + 1 > length - 1 ? -1 : prev + 1));
146 | break;
147 | case 'ArrowUp':
148 | setHoveredIndex((prev) => (prev - 1 < -1 ? length - 1 : prev - 1));
149 | break;
150 | }
151 | };
152 |
153 | const renderDropdownContent = (type: DROPDOWN_TYPE) => {
154 | return {
155 | [DROPDOWN_TYPE.AUTO_COMPLETE]: (
156 |
163 | ),
164 | [DROPDOWN_TYPE.RECENT_KEYWORDS]: (
165 |
172 | ),
173 | [DROPDOWN_TYPE.LOADING]: ,
174 | [DROPDOWN_TYPE.HIDDEN]: <>>,
175 | }[type];
176 | };
177 |
178 | useEffect(() => {
179 | setKeyword(initialKeyword);
180 | }, [initialKeyword]);
181 |
182 | useEffect(() => {
183 | initializeRecentKeywords();
184 | }, [initializeRecentKeywords]);
185 |
186 | return (
187 |
188 |
189 |
190 |
199 | } aria-label="검색" type="submit" />
200 |
201 | {renderDropdownContent(dropdownType)}
202 |
203 |
204 | );
205 | };
206 |
207 | const Container = styled.div`
208 | position: relative;
209 | flex: 1;
210 | overflow-y: auto;
211 | z-index: 3;
212 | margin-bottom: 45px;
213 | padding-bottom: 15px;
214 | `;
215 |
216 | const SearchBox = styled.div`
217 | display: flex;
218 | flex-direction: column;
219 | align-items: center;
220 | width: 500px;
221 | max-height: 100%;
222 | background-color: ${({ theme }) => theme.COLOR.offWhite};
223 | border-radius: 25px;
224 | overflow-y: auto;
225 | margin: auto;
226 | -webkit-box-shadow: 0px 4px 11px -1px rgba(0, 0, 0, 0.15);
227 | box-shadow: 0px 4px 11px -1px rgba(0, 0, 0, 0.15);
228 | `;
229 |
230 | const SearchBar = styled.form`
231 | width: 100%;
232 | height: 50px;
233 | min-height: 50px;
234 | padding: 0 16px;
235 | gap: 16px;
236 | display: flex;
237 | align-items: center;
238 | `;
239 |
240 | const SearchInput = styled.input`
241 | width: 100%;
242 | height: 100%;
243 | background-color: transparent;
244 | text-align: center;
245 | ${({ theme }) => theme.TYPO.body1}
246 | ::placeholder {
247 | color: ${({ theme }) => theme.COLOR.gray2};
248 | }
249 | `;
250 |
251 | const DropdownContainer = styled.div`
252 | width: 100%;
253 | overflow-y: auto;
254 | overflow-x: hidden;
255 | display: flex;
256 | flex-direction: column;
257 | gap: 8px;
258 | ${({ theme }) => theme.TYPO.body1}
259 | color: ${({ theme }) => theme.COLOR.gray2};
260 | padding-bottom: 25px;
261 | ::before {
262 | content: '';
263 | width: 90%;
264 | margin: 0 auto;
265 | border-top: 1px solid ${({ theme }) => theme.COLOR.gray1};
266 | }
267 | :empty {
268 | padding-bottom: 0;
269 | ::before {
270 | content: none;
271 | }
272 | }
273 | `;
274 |
275 | export default Search;
276 |
--------------------------------------------------------------------------------
/frontend/src/constants/path.ts:
--------------------------------------------------------------------------------
1 | export const PATH_MAIN = '/';
2 | export const PATH_SEARCH_LIST = '/search-list';
3 | export const PATH_DETAIL = '/detail';
4 |
--------------------------------------------------------------------------------
/frontend/src/custom.d.ts:
--------------------------------------------------------------------------------
1 | import 'styled-components';
2 | import { ColorConfig, TypoConfig } from './style/theme';
3 |
4 | declare module 'styled-components' {
5 | export interface DefaultTheme {
6 | COLOR: ColorConfig;
7 | TYPO: TypoConfig;
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/frontend/src/error/ErrorBoundary.tsx:
--------------------------------------------------------------------------------
1 | import { Component, ErrorInfo, ReactNode } from 'react';
2 |
3 | interface IProps {
4 | children?: ReactNode;
5 | fallback: React.ElementType;
6 | }
7 |
8 | interface IState {
9 | error: Error | null;
10 | }
11 |
12 | export default class ErrorBoundary extends Component {
13 | constructor(props: IProps) {
14 | super(props);
15 | this.state = { error: null };
16 | }
17 |
18 | static getDerivedStateFromError(error: Error) {
19 | return { error };
20 | }
21 |
22 | componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
23 | console.log('error: ', error);
24 | console.log('errorInfo: ', errorInfo);
25 | }
26 |
27 | render() {
28 | const { error } = this.state;
29 | const { children } = this.props;
30 |
31 | if (error !== null) {
32 | return ;
33 | }
34 | return children;
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/frontend/src/error/GlobalErrorFallback.tsx:
--------------------------------------------------------------------------------
1 | import { AxiosError } from 'axios';
2 | import { Link } from 'react-router-dom';
3 | import styled from 'styled-components';
4 |
5 | interface IProps {
6 | error?: AxiosError;
7 | }
8 |
9 | const GlobalErrorFallback = ({ error }: IProps) => {
10 | const code = error?.response?.status;
11 |
12 | return (
13 |
14 | Oops!
15 | {code || 404} Error occurred
16 |
17 | Go Home
18 |
19 |
20 | );
21 | };
22 |
23 | const Container = styled.div`
24 | display: flex;
25 | flex-direction: column;
26 | width: 100%;
27 | height: 100%;
28 | justify-content: center;
29 | align-items: center;
30 | gap: 30px;
31 | background-color: ${({ theme }) => theme.COLOR.primary3};
32 | color: ${({ theme }) => theme.COLOR.offWhite};
33 | `;
34 |
35 | const H1 = styled.h1`
36 | font-size: 70px;
37 | font-weight: 700;
38 | margin-bottom: 35px;
39 | `;
40 |
41 | const H2 = styled.h2`
42 | ${({ theme }) => theme.TYPO.title}
43 | `;
44 |
45 | const Button = styled.button`
46 | padding: 10px 20px;
47 | color: ${({ theme }) => theme.COLOR.offWhite};
48 | background-color: ${({ theme }) => theme.COLOR.primary2};
49 | border-radius: 20px;
50 | cursor: pointer;
51 | `;
52 |
53 | export default GlobalErrorFallback;
54 |
--------------------------------------------------------------------------------
/frontend/src/error/RankingErrorFallback.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const RankingErrorFallback = () => {
4 | return 오류 발생으로 인기검색어 사용이 불가합니다. ;
5 | };
6 |
7 | const ErrorMessage = styled.div`
8 | display: flex;
9 | justify-content: center;
10 | align-items: center;
11 | margin: 30px 0px;
12 | padding: 5px 20px;
13 | `;
14 |
15 | export default RankingErrorFallback;
16 |
--------------------------------------------------------------------------------
/frontend/src/error/index.ts:
--------------------------------------------------------------------------------
1 | export { default as ErrorBoundary } from './ErrorBoundary';
2 | export { default as GlobalErrorFallback } from './GlobalErrorFallback';
3 | export { default as RankingErrorFallback } from './RankingErrorFallback';
4 |
--------------------------------------------------------------------------------
/frontend/src/hooks/graph/useGraph.ts:
--------------------------------------------------------------------------------
1 | import { Link, Node } from '@/pages/PaperDetail/components/ReferenceGraph';
2 | import theme from '@/style/theme';
3 | import * as d3 from 'd3';
4 | import { useCallback } from 'react';
5 |
6 | const useGraph = (
7 | nodeSelector: SVGGElement | null,
8 | linkSelector: SVGGElement | null,
9 | addChildrensNodes: (doi: string) => void,
10 | changeHoveredNode: (doi: string) => void,
11 | ) => {
12 | const drawLink = useCallback(
13 | (links: Link[]) => {
14 | d3.select(linkSelector)
15 | .selectAll('line')
16 | .data(links)
17 | .join('line')
18 | .attr('x1', (d) => (d.source as Node).x || null)
19 | .attr('y1', (d) => (d.source as Node).y || null)
20 | .attr('x2', (d) => (d.target as Node).x || null)
21 | .attr('y2', (d) => (d.target as Node).y || null);
22 | },
23 | [linkSelector],
24 | );
25 |
26 | const drawNode = useCallback(
27 | (nodes: Node[]) => {
28 | const NORMAL_SYMBOL_SIZE = 20;
29 | const STAR_SYMBOL_SIZE = 100;
30 |
31 | const normalSymbol = d3.symbol().type(d3.symbolSquare).size(NORMAL_SYMBOL_SIZE)();
32 | const starSymbol = d3.symbol().type(d3.symbolStar).size(STAR_SYMBOL_SIZE)();
33 |
34 | const converToColor = (value: number) => {
35 | const loged = Math.trunc(Math.log10(value));
36 | return d3.scaleLinear([0, 4], ['white', theme.COLOR.secondary2]).interpolate(d3.interpolateRgb)(loged);
37 | };
38 |
39 | d3.select(nodeSelector)
40 | .selectAll('path')
41 | .data(nodes)
42 | .join('path')
43 | .attr('transform', (d) => `translate(${[d.x, d.y]})`)
44 | .attr('d', (d) => (d.isSelected ? starSymbol : normalSymbol))
45 | .attr('fill', (d) => converToColor(d.citations || 0))
46 | .attr('fill-opacity', (d) => (d.doi ? 1 : 0.5))
47 | .on('mouseover', (_, d) => d.doi && changeHoveredNode(d.key))
48 | .on('mouseout', () => changeHoveredNode(''))
49 | .on('click', (_, d) => d.doi && addChildrensNodes(d.doi));
50 |
51 | d3.select(nodeSelector)
52 | .selectAll('text')
53 | .data(nodes)
54 | .join('text')
55 | .text((d) => `${d.author} ${d.publishedYear ? `(${d.publishedYear})` : ''}`)
56 | .attr('x', (d) => d.x || null)
57 | .attr('y', (d) => (d.y ? d.y + 10 : null))
58 | .attr('dy', 5)
59 | .style('font-weight', 700)
60 | .on('mouseover', (_, d) => d.doi && changeHoveredNode(d.key))
61 | .on('mouseout', () => changeHoveredNode(''))
62 | .on('click', (_, d) => d.doi && addChildrensNodes(d.doi));
63 | },
64 | [nodeSelector, addChildrensNodes, changeHoveredNode],
65 | );
66 |
67 | return { drawLink, drawNode };
68 | };
69 |
70 | export default useGraph;
71 |
--------------------------------------------------------------------------------
/frontend/src/hooks/graph/useGraphData.ts:
--------------------------------------------------------------------------------
1 | import { IPaperDetail } from '@/api/api';
2 | import { Link, Node } from '@/pages/PaperDetail/components/ReferenceGraph';
3 | import { useEffect, useRef, useState } from 'react';
4 |
5 | export default function useGraphData(data: IPaperDetail) {
6 | const [links, setLinks] = useState ([]);
7 | const nodes = useRef([]);
8 | const doiMap = useRef>(new Map());
9 |
10 | useEffect(() => {
11 | const newIndex = doiMap.current.get(data.key);
12 | if (newIndex !== undefined && nodes.current[newIndex].isSelected) return;
13 |
14 | const newNodes = [
15 | {
16 | author: data.authors?.[0] || 'unknown',
17 | isSelected: true,
18 | key: data.key.toLowerCase(),
19 | doi: data.doi,
20 | citations: data.citations,
21 | publishedYear: new Date(data.publishedAt).getFullYear(),
22 | },
23 | ...data.referenceList.map((v) => ({
24 | author: v.authors?.[0] || 'unknown',
25 | isSelected: false,
26 | key: v.key.toLowerCase(),
27 | doi: v.doi,
28 | citations: v.citations,
29 | publishedYear: v.publishedAt && new Date(v.publishedAt).getFullYear(),
30 | })),
31 | ] as Node[];
32 |
33 | newNodes.forEach((node) => {
34 | const foundIndex = doiMap.current.get(node.key);
35 | if (foundIndex === undefined) {
36 | nodes.current.push(node);
37 | return;
38 | }
39 | if (foundIndex === newIndex) {
40 | Object.entries(node).forEach(([k, v]) => {
41 | nodes.current[foundIndex][k] = v;
42 | });
43 | }
44 | });
45 |
46 | nodes.current.forEach((node, i) => doiMap.current.set(node.key, i));
47 |
48 | const newLinks = data.referenceList.map((reference) => ({
49 | source: data.key.toLowerCase(),
50 | target: reference.key.toLowerCase(),
51 | }));
52 | setLinks((prev) => [...prev, ...newLinks]);
53 | }, [data, links]);
54 |
55 | return { nodes: nodes.current, links };
56 | }
57 |
--------------------------------------------------------------------------------
/frontend/src/hooks/graph/useGraphEmphasize.ts:
--------------------------------------------------------------------------------
1 | import { Link, Node } from '@/pages/PaperDetail/components/ReferenceGraph';
2 | import * as d3 from 'd3';
3 | import { useCallback, useEffect } from 'react';
4 | import { useTheme } from 'styled-components';
5 |
6 | const styles = {
7 | EMPHASIZE_OPACITY: '1',
8 | BASIC_OPACITY: '0.5',
9 | EMPHASIZE_STROKE_WIDTH: '1.5px',
10 | BASIC_STROKE_WIDTH: '0.5px',
11 | EMPHASIZE_STROKE_DASH: 'none',
12 | BASIC_STROKE_DASH: '1',
13 | };
14 |
15 | export default function useGraphEmphasize(
16 | nodeSelector: SVGGElement | null,
17 | linkSelector: SVGGElement | null,
18 | nodes: Node[],
19 | links: Link[],
20 | hoveredNode: string,
21 | selectedKey: string,
22 | ) {
23 | const theme = useTheme();
24 | const getStyles = useCallback(
25 | (key: string, emphasizeStyle: string, basicStyle: string) =>
26 | key === selectedKey || key === hoveredNode ? emphasizeStyle : basicStyle,
27 | [hoveredNode, selectedKey],
28 | );
29 |
30 | useEffect(() => {
31 | if (nodeSelector === null) return;
32 |
33 | // hover된 노드 강조
34 | d3.select(nodeSelector)
35 | .selectAll('text')
36 | .data(nodes)
37 | .filter((d) => d.key === hoveredNode)
38 | .style('fill-opacity', styles.EMPHASIZE_OPACITY);
39 |
40 | // hover된 노드의 자식 노드들 강조
41 | d3.select(nodeSelector)
42 | .selectAll('text')
43 | .data(nodes)
44 | .filter((d) => {
45 | return links
46 | .filter((l) => l.source === hoveredNode)
47 | .map((l) => l.target)
48 | .includes(d.key);
49 | })
50 | .style('fill-opacity', styles.EMPHASIZE_OPACITY);
51 | }, [hoveredNode, links, nodeSelector, nodes, theme]);
52 |
53 | useEffect(() => {
54 | if (nodeSelector === null) return;
55 |
56 | // click된 노드 강조
57 | d3.select(nodeSelector)
58 | .selectAll('text')
59 | .data(nodes)
60 | .filter((d) => d.key === selectedKey)
61 | .style('fill', theme.COLOR.secondary2);
62 |
63 | // click된 노드의 자식 노드들 강조
64 | d3.select(nodeSelector)
65 | .selectAll('text')
66 | .data(nodes)
67 | .filter((d) => {
68 | const result = links
69 | .filter((l) => l.source === selectedKey)
70 | .map((l) => l.target)
71 | .includes(d.key);
72 | return result;
73 | })
74 | .style('fill', theme.COLOR.secondary2);
75 |
76 | // click/hover된 노드의 링크 강조
77 | d3.select(linkSelector)
78 | .selectAll('line')
79 | .data(links)
80 | .style('stroke', (d) => getStyles(d.source as string, theme.COLOR.secondary1, theme.COLOR.gray1))
81 | .style('stroke-width', (d) =>
82 | getStyles(d.source as string, styles.EMPHASIZE_STROKE_WIDTH, styles.BASIC_STROKE_WIDTH),
83 | )
84 | .style('stroke-dasharray', (d) =>
85 | getStyles(d.source as string, styles.EMPHASIZE_STROKE_DASH, styles.BASIC_STROKE_DASH),
86 | );
87 |
88 | return () => {
89 | d3.select(nodeSelector).selectAll('text').style('fill-opacity', styles.BASIC_OPACITY);
90 | d3.select(nodeSelector).selectAll('text').style('fill', theme.COLOR.offWhite);
91 | };
92 | }, [nodeSelector, nodes, links, selectedKey, linkSelector, getStyles, theme]);
93 | }
94 |
--------------------------------------------------------------------------------
/frontend/src/hooks/graph/useGraphZoom.ts:
--------------------------------------------------------------------------------
1 | import * as d3 from 'd3';
2 | import { useEffect } from 'react';
3 |
4 | export default function useGraphZoom(svgSelector: SVGSVGElement | null) {
5 | useEffect(() => {
6 | if (svgSelector === null) return;
7 | const handleZoom = (e: any) => {
8 | d3.select(svgSelector).selectChildren().attr('transform', e.transform);
9 | };
10 |
11 | const zoom = d3.zoom().scaleExtent([0.1, 5]).on('zoom', handleZoom);
12 | d3.select(svgSelector).call(zoom);
13 | }, [svgSelector]);
14 | }
15 |
--------------------------------------------------------------------------------
/frontend/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export { default as useGraph } from './graph/useGraph';
2 | export { default as useGraphData } from './graph/useGraphData';
3 | export { default as useGraphEmphasize } from './graph/useGraphEmphasize';
4 | export { default as useGraphZoom } from './graph/useGraphZoom';
5 | export { default as useDebouncedValue } from './useDebouncedValue';
6 | export { default as useInterval } from './useInterval';
7 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useDebouncedValue.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 |
3 | export default function useDebounceValue(value: T, delay: number) {
4 | const [debouncedValue, setDebouncedValue] = useState(value);
5 | useEffect(() => {
6 | const handler = setTimeout(() => {
7 | setDebouncedValue(value);
8 | }, delay);
9 |
10 | return () => {
11 | clearTimeout(handler);
12 | };
13 | }, [value, delay]);
14 |
15 | return debouncedValue;
16 | }
17 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useInterval.ts:
--------------------------------------------------------------------------------
1 | import { useRef, useEffect } from 'react';
2 |
3 | type intervalCallbackType = () => void;
4 |
5 | export default function useInterval(callback: intervalCallbackType, delay: number) {
6 | const savedCallback = useRef();
7 | useEffect(() => {
8 | savedCallback.current = callback;
9 | }, [callback]);
10 |
11 | useEffect(() => {
12 | const tick = () => {
13 | if (savedCallback.current === undefined) return;
14 | savedCallback.current();
15 | };
16 |
17 | const id = setInterval(tick, delay);
18 | return () => clearInterval(id);
19 | }, [delay]);
20 | }
21 |
--------------------------------------------------------------------------------
/frontend/src/icons/ClockIcon.tsx:
--------------------------------------------------------------------------------
1 | import theme from '@/style/theme';
2 |
3 | const Clockicon = () => {
4 | return (
5 |
6 |
7 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | );
19 | };
20 |
21 | export default Clockicon;
22 |
--------------------------------------------------------------------------------
/frontend/src/icons/DropdownIcon.tsx:
--------------------------------------------------------------------------------
1 | const DropdownIcon = () => {
2 | return (
3 |
4 |
5 |
6 | );
7 | };
8 |
9 | export default DropdownIcon;
10 |
--------------------------------------------------------------------------------
/frontend/src/icons/DropdownReverseIcon.tsx:
--------------------------------------------------------------------------------
1 | const DropDownReverseIcon = () => {
2 | return (
3 |
4 |
5 |
6 | );
7 | };
8 |
9 | export default DropDownReverseIcon;
10 |
--------------------------------------------------------------------------------
/frontend/src/icons/GithubLogoIcon.tsx:
--------------------------------------------------------------------------------
1 | const GithubLogoIcon = () => {
2 | return (
3 |
4 |
5 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | );
17 | };
18 |
19 | export default GithubLogoIcon;
20 |
--------------------------------------------------------------------------------
/frontend/src/icons/InfoIcon.tsx:
--------------------------------------------------------------------------------
1 | interface IProps {
2 | color: string;
3 | }
4 |
5 | const InfoIcon = ({ color }: IProps) => {
6 | return (
7 |
8 |
13 |
14 | );
15 | };
16 |
17 | export default InfoIcon;
18 |
--------------------------------------------------------------------------------
/frontend/src/icons/LogoIcon.tsx:
--------------------------------------------------------------------------------
1 | interface IProps {
2 | height?: string;
3 | width?: string;
4 | }
5 |
6 | const LogoIcon = ({ height, width }: IProps) => {
7 | return (
8 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 | );
69 | };
70 |
71 | LogoIcon.defaultProps = {
72 | width: '40',
73 | height: '40',
74 | };
75 |
76 | export default LogoIcon;
77 |
--------------------------------------------------------------------------------
/frontend/src/icons/MagnifyingGlassIcon.tsx:
--------------------------------------------------------------------------------
1 | const MaginifyingGlassIcon = () => {
2 | return (
3 |
4 |
5 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | );
17 | };
18 |
19 | export default MaginifyingGlassIcon;
20 |
--------------------------------------------------------------------------------
/frontend/src/icons/PreviousButtonIcon.tsx:
--------------------------------------------------------------------------------
1 | const PreviousButtonIcon = () => {
2 | return (
3 |
4 |
8 |
9 | );
10 | };
11 |
12 | export default PreviousButtonIcon;
13 |
--------------------------------------------------------------------------------
/frontend/src/icons/XIcon.tsx:
--------------------------------------------------------------------------------
1 | import theme from '@/style/theme';
2 |
3 | const XIcon = () => {
4 | return (
5 |
6 |
7 |
8 | );
9 | };
10 |
11 | export default XIcon;
12 |
--------------------------------------------------------------------------------
/frontend/src/icons/index.ts:
--------------------------------------------------------------------------------
1 | export { default as ClockIcon } from './ClockIcon';
2 | export { default as DropdownIcon } from './DropdownIcon';
3 | export { default as DropdownReverseIcon } from './DropdownReverseIcon';
4 | export { default as GithubLogoIcon } from './GithubLogoIcon';
5 | export { default as LogoIcon } from './LogoIcon';
6 | export { default as MagnifyingGlassIcon } from './MagnifyingGlassIcon';
7 | export { default as PreviousButtonIcon } from './PreviousButtonIcon';
8 | export { default as XIcon } from './XIcon';
9 |
--------------------------------------------------------------------------------
/frontend/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import { BrowserRouter } from 'react-router-dom';
4 | import App from './App';
5 |
6 | const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
7 |
8 | root.render(
9 |
10 |
11 |
12 |
13 | ,
14 | );
15 |
--------------------------------------------------------------------------------
/frontend/src/pages/Main/Main.tsx:
--------------------------------------------------------------------------------
1 | import { Footer, Search } from '@/components';
2 | import { ErrorBoundary, RankingErrorFallback } from '@/error';
3 | import { LogoIcon } from '@/icons';
4 | import styled from 'styled-components';
5 | import KeywordRanking from './components/KeywordRanking';
6 | import StarLayer from './components/StarLayer';
7 |
8 | const Main = () => {
9 | return (
10 |
11 |
12 |
13 |
14 |
15 | PRV
16 |
17 |
18 | Paper Reference Visualization
19 | 논문 간 인용관계 시각화 솔루션
20 | This website renders reference relation of paper
21 |
22 |
23 |
24 |
25 |
26 | * DOI로 직접 검색하시면 원하는 논문을 바로 찾을 수 있습니다.{'\n'}(DOI 형식 : https://doi.org/xxxxx)
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | );
35 | };
36 |
37 | const Container = styled.div`
38 | height: 100%;
39 | background-color: ${({ theme }) => theme.COLOR.primary3};
40 | color: ${({ theme }) => theme.COLOR.offWhite};
41 | `;
42 |
43 | const MainContainer = styled.main`
44 | display: flex;
45 | flex-direction: column;
46 | justify-content: center;
47 | align-items: center;
48 | gap: 10px;
49 | height: 100%;
50 | `;
51 |
52 | const TitleContainer = styled.div`
53 | margin-top: 150px;
54 | display: flex;
55 | align-items: center;
56 | gap: 10px;
57 | `;
58 |
59 | const Title = styled.div`
60 | ${({ theme }) => theme.TYPO.H4};
61 | `;
62 |
63 | const ContentContainer = styled.div`
64 | display: flex;
65 | flex-direction: column;
66 | align-items: center;
67 | gap: 10px;
68 | ${({ theme }) => theme.TYPO.body2};
69 | `;
70 |
71 | const Positioner = styled.div`
72 | position: absolute;
73 | bottom: 0;
74 | width: 100%;
75 | `;
76 |
77 | const Text = styled.div`
78 | white-space: pre-line;
79 | text-align: center;
80 | ${({ theme }) => theme.TYPO.body2}
81 | line-height : 1.5
82 | `;
83 |
84 | export default Main;
85 |
--------------------------------------------------------------------------------
/frontend/src/pages/Main/components/KeywordRanking.tsx:
--------------------------------------------------------------------------------
1 | import { IconButton } from '@/components';
2 | import { DropdownIcon, DropdownReverseIcon } from '@/icons';
3 | import { useKeywordRankingQuery } from '@/queries/queries';
4 | import { Ellipsis } from '@/style/styleUtils';
5 | import { createSearchQuery } from '@/utils/createQueryString';
6 | import { useState } from 'react';
7 | import { Link } from 'react-router-dom';
8 | import styled from 'styled-components';
9 | import RankingSlide from './RankingSlide';
10 |
11 | const KeywordRanking = () => {
12 | const [isRankingListOpen, setIsRankingListOpen] = useState(false);
13 | const { isLoading, data: rankingData } = useKeywordRankingQuery();
14 |
15 | const handleRankingClick = () => {
16 | setIsRankingListOpen((prev) => !prev);
17 | };
18 |
19 | return (
20 |
21 |
22 |
23 | 인기 검색어
24 |
25 |
26 | {!isLoading && rankingData?.length ? : '데이터가 없습니다.'}
27 |
28 | : }
30 | onClick={handleRankingClick}
31 | aria-label={isRankingListOpen ? '인기검색어 목록 닫기' : '인기검색어 목록 펼치기'}
32 | />
33 |
34 | {isRankingListOpen && (
35 | <>
36 |
37 |
38 | {rankingData?.slice(0, 10).map((data, index) => (
39 |
40 |
41 | {index + 1}
42 | {data.keyword}
43 |
44 |
45 | ))}
46 |
47 | >
48 | )}
49 |
50 | {isRankingListOpen && }
51 |
52 | );
53 | };
54 |
55 | const RankingContainer = styled.div`
56 | position: relative;
57 | width: 500px;
58 | height: 70px;
59 | `;
60 |
61 | const RankingBar = styled.div`
62 | display: flex;
63 | flex-direction: column;
64 | justify-content: center;
65 | position: absolute;
66 | width: 100%;
67 | margin-top: 30px;
68 | padding: 5px 20px;
69 | background-color: ${({ theme }) => theme.COLOR.primary3};
70 | border: 1px solid ${({ theme }) => theme.COLOR.offWhite};
71 | border-radius: 20px;
72 | z-index: 10;
73 | `;
74 |
75 | const HeaderContainer = styled.div`
76 | display: flex;
77 | align-items: center;
78 | width: 100%;
79 | height: 23px;
80 | ${({ theme }) => theme.TYPO.body_h}
81 | `;
82 |
83 | const Title = styled.span`
84 | width: 100px;
85 | `;
86 |
87 | const HeaderDivideLine = styled.hr`
88 | width: 1px;
89 | height: 16px;
90 | `;
91 |
92 | const RankingContent = styled.div`
93 | display: flex;
94 | align-items: center;
95 | margin: 0 10px;
96 | width: 320px;
97 | height: 25px;
98 | cursor: pointer;
99 | `;
100 |
101 | const DivideLine = styled.hr`
102 | width: 100%;
103 | border: 1px solid ${({ theme }) => theme.COLOR.offWhite};
104 | fill: ${({ theme }) => theme.COLOR.offWhite};
105 | margin-bottom: 10px;
106 | `;
107 |
108 | const RankingKeywordContainer = styled.ul`
109 | display: flex;
110 | flex-direction: column;
111 | gap: 8px;
112 | margin-bottom: 10px;
113 | `;
114 |
115 | const KeywordIndex = styled.span`
116 | width: 20px;
117 | `;
118 |
119 | const Keyword = styled(Ellipsis)`
120 | ${({ theme }) => theme.TYPO.body1};
121 | display: block;
122 | width: 100%;
123 | `;
124 |
125 | const KeywordContainer = styled.li`
126 | display: flex;
127 | width: 100%;
128 | gap: 15px;
129 | cursor: pointer;
130 | :hover {
131 | ${Keyword} {
132 | ${({ theme }) => theme.TYPO.body_h};
133 | text-decoration: underline;
134 | }
135 | }
136 | `;
137 |
138 | const Dimmer = styled.div`
139 | position: fixed;
140 | top: 0;
141 | left: 0;
142 | width: 100%;
143 | height: 100%;
144 | z-index: 5;
145 | `;
146 |
147 | export default KeywordRanking;
148 |
--------------------------------------------------------------------------------
/frontend/src/pages/Main/components/RankingSlide.tsx:
--------------------------------------------------------------------------------
1 | import { useInterval } from '@/hooks';
2 | import { Ellipsis } from '@/style/styleUtils';
3 | import { useState } from 'react';
4 | import styled from 'styled-components';
5 |
6 | interface IRankingData {
7 | keyword: string;
8 | count: number;
9 | }
10 |
11 | interface IRankingSlideProps {
12 | rankingData: IRankingData[];
13 | }
14 |
15 | interface ISlideProps {
16 | transition: string;
17 | keywordIndex: number;
18 | dataSize: number;
19 | }
20 |
21 | const SLIDE_DELAY = 2500;
22 | const TRANSITION_TIME = 1500;
23 | const TRANSITION_SETTING = `transform linear ${TRANSITION_TIME}ms`;
24 |
25 | const RankingSlide = ({ rankingData }: IRankingSlideProps) => {
26 | const [keywordIndex, setKeywordIndex] = useState(0);
27 | const [transition, setTransition] = useState('');
28 | const newRankingData = [...rankingData.slice(0, 10), rankingData[0]];
29 | const dataSize = newRankingData.length;
30 |
31 | // 마지막 인덱스 도착시 처음 인덱스로 되돌리는 함수
32 | const replaceSlide = () => {
33 | setTimeout(() => {
34 | setTransition('');
35 | setKeywordIndex(0);
36 | }, TRANSITION_TIME);
37 | };
38 |
39 | const moveSlide = () => {
40 | setTransition(TRANSITION_SETTING);
41 | setKeywordIndex((prev) => prev + 1);
42 | if (keywordIndex === dataSize - 2) {
43 | replaceSlide();
44 | }
45 | };
46 |
47 | useInterval(() => {
48 | moveSlide();
49 | }, SLIDE_DELAY);
50 |
51 | return (
52 |
53 |
54 | {newRankingData.map((data, index) => (
55 |
56 | {index === dataSize - 1 ? 1 : index + 1}
57 | {data.keyword}
58 |
59 | ))}
60 |
61 |
62 | );
63 | };
64 |
65 | const Container = styled.div`
66 | display: flex;
67 | flex-direction: column;
68 | justify-content: flex-start;
69 | width: 100%;
70 | height: 25px;
71 | overflow-y: hidden;
72 | `;
73 |
74 | const Slide = styled.ul`
75 | display: flex;
76 | flex-direction: column;
77 | width: 100%;
78 | transition: ${(props) => props.transition};
79 | transform: ${(props) => `translateY(${(-100 / props.dataSize) * props.keywordIndex}%)`};
80 | `;
81 |
82 | const SlideItem = styled.li`
83 | display: flex;
84 | justify-content: flex-start;
85 | align-items: center;
86 | gap: 8px;
87 | padding: 5px 0;
88 |
89 | span:last-of-type {
90 | ${({ theme }) => theme.TYPO.body1}
91 | }
92 | `;
93 |
94 | const KeywordIndex = styled.span`
95 | width: 20px;
96 | `;
97 |
98 | const Keyword = styled(Ellipsis)`
99 | ${({ theme }) => theme.TYPO.body1};
100 | display: block;
101 | width: 100%;
102 | `;
103 |
104 | export default RankingSlide;
105 |
--------------------------------------------------------------------------------
/frontend/src/pages/Main/components/StarLayer.tsx:
--------------------------------------------------------------------------------
1 | import theme from '@/style/theme';
2 | import { debounce } from 'lodash-es';
3 | import { useEffect, useMemo, useState } from 'react';
4 | import styled, { keyframes } from 'styled-components';
5 |
6 | const COLORS = [
7 | theme.COLOR.secondary1,
8 | theme.COLOR.secondary2,
9 | theme.COLOR.primary1,
10 | theme.COLOR.primary2,
11 | theme.COLOR.offWhite,
12 | ];
13 |
14 | const StarLayer = () => {
15 | const [height, setHeight] = useState(0);
16 | const [width, setWidth] = useState(0);
17 |
18 | const layerCount = 15;
19 | const starDensity = Math.trunc(((height * width) / 600000) * layerCount);
20 |
21 | const resizeCallback = useMemo(
22 | () =>
23 | debounce(() => {
24 | setHeight(window.innerHeight);
25 | setWidth(window.innerWidth);
26 | }, 150),
27 | [],
28 | );
29 |
30 | useEffect(() => {
31 | resizeCallback();
32 | window.addEventListener('resize', resizeCallback);
33 | return () => window.removeEventListener('resize', resizeCallback);
34 | }, [resizeCallback]);
35 |
36 | return (
37 |
38 | {Array.from({ length: layerCount }, (_, i) => {
39 | const randomConstant = Math.random();
40 | return (
41 |
48 | {Array.from({ length: starDensity }, (_, j) => (
49 |
59 |
60 |
61 | ))}
62 |
63 | );
64 | })}
65 |
66 | );
67 | };
68 |
69 | const Container = styled.div`
70 | position: absolute;
71 | width: 100%;
72 | height: 100%;
73 | overflow: hidden;
74 | `;
75 |
76 | const twinkle = keyframes`
77 | 0% {
78 | opacity: 0;
79 | animation-timing-function: ease-in;
80 | }
81 |
82 | 60% {
83 | opacity: 1;
84 | animation-timing-function: ease-out;
85 | }
86 |
87 | 80% {
88 | opacity: 0;
89 | }
90 |
91 | 100% {
92 | opacity: 0;
93 | }
94 | `;
95 |
96 | const Stars = styled.div`
97 | opacity: 0;
98 | animation-name: ${twinkle};
99 | animation-timing-function: linear;
100 | animation-iteration-count: infinite;
101 | `;
102 |
103 | const Star = styled.div`
104 | position: absolute;
105 | background: ${theme.COLOR.offWhite};
106 | border-radius: 5px;
107 | `;
108 |
109 | const Blur = styled.div`
110 | background: inherit;
111 | width: inherit;
112 | height: inherit;
113 | border-radius: inherit;
114 | filter: blur(5px);
115 | `;
116 |
117 | export default StarLayer;
118 |
--------------------------------------------------------------------------------
/frontend/src/pages/PaperDetail/PaperDetail.tsx:
--------------------------------------------------------------------------------
1 | import { IPaperDetail } from '@/api/api';
2 | import { IconButton, MoonLoader } from '@/components';
3 | import { PATH_MAIN } from '@/constants/path';
4 | import { LogoIcon, PreviousButtonIcon } from '@/icons';
5 | import { usePaperQuery } from '@/queries/queries';
6 | import { useCallback, useEffect, useState } from 'react';
7 | import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
8 | import styled from 'styled-components';
9 | import ColorRangeBar from './components/ColorRangeBar';
10 | import PaperInfo from './components/PaperInfo';
11 | import ReferenceGraph from './components/ReferenceGraph';
12 |
13 | const PaperDatail = () => {
14 | const navigate = useNavigate();
15 | const location = useLocation();
16 | const [data, setData] = useState(location.state?.initialData);
17 | const [searchParams] = useSearchParams();
18 | const [doi, setDoi] = useState(searchParams.get('doi') || '');
19 | const [hoveredNode, setHoveredNode] = useState('');
20 |
21 | const { isLoading, data: _data } = usePaperQuery(doi.toLowerCase());
22 |
23 | const handlePreviousButtonClick = () => {
24 | navigate(-1);
25 | };
26 |
27 | const handleLogoClick = () => {
28 | navigate(PATH_MAIN);
29 | };
30 |
31 | const changeHoveredNode = useCallback((key: string) => {
32 | setHoveredNode(key.toLowerCase());
33 | }, []);
34 |
35 | const addChildrensNodes = useCallback(async (doi: string) => {
36 | setDoi(doi);
37 | }, []);
38 |
39 | useEffect(() => {
40 | if (!_data) return;
41 | setData(_data);
42 | }, [_data]);
43 |
44 | return (
45 |
46 |
47 | {location.state?.hasPrevPage && (
48 | } onClick={handlePreviousButtonClick} aria-label="뒤로가기" />
49 | )}
50 | } onClick={handleLogoClick} aria-label="메인으로" />
51 |
52 |
53 | {data && (
54 | <>
55 |
61 |
67 | >
68 | )}
69 |
70 |
71 | {isLoading && (
72 |
73 |
74 |
75 | )}
76 |
77 | );
78 | };
79 |
80 | const Container = styled.div`
81 | display: flex;
82 | height: 100%;
83 | background-color: ${({ theme }) => theme.COLOR.primary4};
84 | `;
85 |
86 | const Header = styled.header`
87 | display: flex;
88 | justify-content: space-between;
89 | align-items: center;
90 | position: absolute;
91 | top: 0;
92 | left: 0;
93 | width: 100%;
94 | height: 50px;
95 | padding: 10px 10px 0;
96 | `;
97 |
98 | const Main = styled.main`
99 | display: flex;
100 | width: 100%;
101 | height: 100%;
102 | `;
103 |
104 | const LoaderWrapper = styled.div`
105 | position: absolute;
106 | z-index: 10;
107 | width: 100%;
108 | height: 100%;
109 | display: flex;
110 | justify-content: center;
111 | align-items: center;
112 | background-color: ${({ theme }) => theme.COLOR.primary4}50;
113 | `;
114 |
115 | export default PaperDatail;
116 |
--------------------------------------------------------------------------------
/frontend/src/pages/PaperDetail/components/ColorRangeBar.tsx:
--------------------------------------------------------------------------------
1 | import * as d3 from 'd3';
2 | import styled from 'styled-components';
3 | import theme from '../../../style/theme';
4 |
5 | const ColorRangeBar = () => {
6 | const converToColor = d3.scaleLog([1, 10000], ['white', theme.COLOR.secondary2]).interpolate(d3.interpolateRgb);
7 | const axisNums = [1, 10, 100, 1000, 10000];
8 | const colors = axisNums.map(converToColor);
9 |
10 | return (
11 |
12 | citations
13 |
14 | {colors.map((color) => (
15 |
16 | ))}
17 |
18 |
19 |
20 | {axisNums.map((num, i) => (
21 |
22 | {num}
23 | {i === axisNums.length - 1}
24 |
25 | ))}
26 |
27 | {axisNums[axisNums.length - 1]}
28 | {'+'}
29 |
30 |
31 |
32 | );
33 | };
34 |
35 | const Container = styled.div`
36 | ${({ theme }) => theme.TYPO.caption};
37 | position: absolute;
38 | bottom: 30px;
39 | right: 30px;
40 | > label {
41 | color: ${(props) => props.theme.COLOR.offWhite};
42 | margin: 0 0 0 auto;
43 | }
44 | `;
45 |
46 | const ColorRange = styled.div`
47 | margin: 0 auto;
48 | width: 200px;
49 | height: 20px;
50 | margin: 10px 0;
51 | display: flex;
52 | > div {
53 | flex: 1;
54 | }
55 | `;
56 |
57 | const Axis = styled.div`
58 | position: relative;
59 | display: flex;
60 | justify-content: space-between;
61 | ${({ theme }) => theme.TYPO.caption};
62 | color: ${(props) => props.theme.COLOR.offWhite};
63 | > span {
64 | position: absolute;
65 | transform: translateX(-50%);
66 | }
67 | `;
68 |
69 | export default ColorRangeBar;
70 |
--------------------------------------------------------------------------------
/frontend/src/pages/PaperDetail/components/InfoTooltip.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import styled from 'styled-components';
3 | import IconButton from '@/components/IconButton';
4 | import InfoIcon from '@/icons/InfoIcon';
5 | import { getSessionStorage, setSessionStorage } from '@/utils/storage';
6 |
7 | interface InfoContainerProps {
8 | isOpened: boolean;
9 | }
10 |
11 | const InfoTooltip = () => {
12 | const [isOpened, setisOpened] = useState(getSessionStorage('isTooltipClosed') ? false : true);
13 |
14 | const handleInfoButtonClick = () => {
15 | setisOpened(true);
16 | };
17 |
18 | const handleCloseButtonClick = () => {
19 | const isTooltipClosed = getSessionStorage('isTooltipClosed');
20 | if (!isTooltipClosed) {
21 | setSessionStorage('isTooltipClosed', true);
22 | }
23 | setisOpened(false);
24 | };
25 |
26 | return (
27 |
28 |
29 | } onClick={handleInfoButtonClick} aria-label="정보" />
30 |
31 |
32 | 그래프 사용법
33 |
34 | • 그래프의 노드나 좌측의 레퍼런스 논문을 클릭하여 선택한 논문의 인용관계를 추가로 그릴 수 있습니다.
35 | • 마우스 휠을 이용해 그래프를 확대, 축소할 수 있습니다.
36 | • 마우스 드래그로 그래프의 위치를 옮길 수 있습니다.
37 | • 노드색이 불투명한 논문은 정보가 완전하지 않은 논문으로, 선택 및 추가 인터랙션이 불가능합니다.
38 | • 노드의 색상은 피인용수(citations)를 의미합니다. (오른쪽 아래 범례 참고)
39 |
40 |
41 | 닫기
42 |
43 |
44 |
45 | );
46 | };
47 |
48 | const Container = styled.div`
49 | display: flex;
50 | flex-direction: column;
51 | gap: 5px;
52 | position: relative;
53 | align-items: flex-start;
54 | position: absolute;
55 | top: 20px;
56 | left: 320px;
57 | `;
58 |
59 | const IconButtonWrapper = styled.div`
60 | opacity: 0.3;
61 | :hover {
62 | opacity: 1;
63 | }
64 | `;
65 |
66 | const InfoContainer = styled.div`
67 | display: flex;
68 | flex-direction: column;
69 | gap: 5px;
70 | width: 400px;
71 | padding: 15px 15px 10px 20px;
72 | background-color: ${({ theme }) => theme.COLOR.gray1};
73 | border-radius: 10px;
74 | position: absolute;
75 | top: 0;
76 | left: 0;
77 | visibility: ${(props) => (props.isOpened ? 'visible' : 'hidden')};
78 | `;
79 |
80 | const Title = styled.h3`
81 | ${({ theme }) => theme.TYPO.body_h};
82 | `;
83 |
84 | const InfoList = styled.ul`
85 | display: flex;
86 | flex-direction: column;
87 | gap: 5px;
88 | margin-top: 5px;
89 | ${({ theme }) => theme.TYPO.body2};
90 | color: ${({ theme }) => theme.COLOR.primary4};
91 | line-height: 1rem;
92 | li {
93 | word-break: keep-all;
94 | text-align: justify;
95 | text-indent: -8px;
96 | }
97 | `;
98 |
99 | const ButtonWrapper = styled.div`
100 | display: flex;
101 | justify-content: flex-end;
102 | `;
103 |
104 | const Button = styled.button`
105 | width: 80px;
106 | height: 30px;
107 | margin-top: 10px;
108 | ${({ theme }) => theme.TYPO.body2};
109 | color: ${({ theme }) => theme.COLOR.offWhite};
110 | background-color: ${({ theme }) => theme.COLOR.primary3};
111 | opacity: 0.9;
112 | border-radius: 3px;
113 | cursor: pointer;
114 | :hover {
115 | opacity: 1;
116 | }
117 | `;
118 |
119 | export default InfoTooltip;
120 |
--------------------------------------------------------------------------------
/frontend/src/pages/PaperDetail/components/PaperInfo.tsx:
--------------------------------------------------------------------------------
1 | import { IPaperDetail } from '@/api/api';
2 | import { Ellipsis } from '@/style/styleUtils';
3 | import { removeTag, sliceTitle } from '@/utils/format';
4 | import styled from 'styled-components';
5 |
6 | interface IProps {
7 | data: IPaperDetail;
8 | hoveredNode: string;
9 | changeHoveredNode: (key: string) => void;
10 | addChildrensNodes: (doi: string) => void;
11 | }
12 |
13 | const DOI_BASE_URL = 'https://doi.org/';
14 |
15 | const PaperInfo = ({ data, hoveredNode, changeHoveredNode, addChildrensNodes }: IProps) => {
16 | const handleMouseOver = (key: string) => {
17 | changeHoveredNode(key);
18 | };
19 |
20 | const handleMouseOut = () => {
21 | changeHoveredNode('');
22 | };
23 |
24 | return (
25 |
26 |
27 | {sliceTitle(removeTag(data?.title))}
28 |
29 |
30 | {data?.authors?.length > 1 ? 'Authors ' : 'Author '}
31 | {data?.authors?.join(', ')}
32 |
33 |
34 | DOI
35 |
36 | {DOI_BASE_URL}
37 | {data?.doi}
38 |
39 |
40 |
41 |
42 |
43 |
44 | References ({data.referenceList.length})
45 |
46 | {data.referenceList.map((reference, i) => (
47 | handleMouseOver(reference.key)}
50 | onMouseOut={() => handleMouseOut()}
51 | className={`info ${reference.key.toLowerCase() === hoveredNode ? 'hovered' : ''}`}
52 | onClick={() => reference.doi && addChildrensNodes(reference.doi)}
53 | disabled={!reference.doi}
54 | >
55 | {reference.title && {sliceTitle(removeTag(reference.title))} }
56 | {reference.authors?.join(', ') || 'unknown'}
57 |
58 | ))}
59 |
60 |
61 |
62 | );
63 | };
64 |
65 | const Container = styled.div`
66 | display: flex;
67 | flex-direction: column;
68 | gap: 15px;
69 | width: 300px;
70 | padding: 10px;
71 | color: ${({ theme }) => theme.COLOR.offWhite};
72 | background-color: ${({ theme }) => theme.COLOR.primary3};
73 | `;
74 |
75 | const BasicInfo = styled.div`
76 | display: flex;
77 | flex-direction: column;
78 | gap: 20px;
79 | margin-top: 50px;
80 | padding: 0 15px;
81 | `;
82 |
83 | const InfoContainer = styled.div`
84 | display: flex;
85 | flex-direction: column;
86 | gap: 15px;
87 | `;
88 |
89 | const Title = styled.h2`
90 | ${({ theme }) => theme.TYPO.title};
91 | font-weight: 700;
92 | line-height: 1.3rem;
93 | `;
94 |
95 | const InfoItem = styled.div`
96 | display: flex;
97 | flex-direction: column;
98 | gap: 5px;
99 | h3 {
100 | ${({ theme }) => theme.TYPO.body_h};
101 | }
102 | a {
103 | ${({ theme }) => theme.TYPO.body2};
104 | word-wrap: break-word;
105 | line-height: 1.1em;
106 | :hover {
107 | text-decoration: underline;
108 | }
109 | }
110 | `;
111 |
112 | const InfoAuthor = styled(Ellipsis)`
113 | ${({ theme }) => theme.TYPO.body2};
114 | -webkit-line-clamp: 3;
115 | `;
116 |
117 | const DivideLine = styled.hr`
118 | width: 100%;
119 | border: 0.5px solid ${({ theme }) => theme.COLOR.gray2};
120 | `;
121 |
122 | const References = styled.div`
123 | display: flex;
124 | flex-direction: column;
125 | gap: 20px;
126 | overflow-y: auto;
127 | overflow-x: hidden;
128 | flex: 1;
129 | ::-webkit-scrollbar {
130 | width: 8px;
131 | }
132 | ::-webkit-scrollbar-track {
133 | background-color: transparent;
134 | }
135 | ::-webkit-scrollbar-thumb {
136 | background-color: ${({ theme }) => theme.COLOR.black};
137 | border-radius: 4px;
138 | }
139 |
140 | h3 {
141 | ${({ theme }) => theme.TYPO.body_h};
142 | padding: 0 15px;
143 | }
144 | `;
145 |
146 | const ReferenceContainer = styled.ul`
147 | display: flex;
148 | flex-direction: column;
149 | gap: 20px;
150 | padding: 0 15px;
151 | `;
152 |
153 | const ReferenceItem = styled.li<{ disabled: boolean }>`
154 | display: flex;
155 | flex-direction: column;
156 | gap: 10px;
157 | cursor: ${({ disabled }) => (disabled ? 'auto' : 'pointer')};
158 | opacity: ${({ disabled }) => (disabled ? 0.5 : 1)};
159 |
160 | span {
161 | word-break: break-all;
162 | :first-child {
163 | ${({ theme }) => theme.TYPO.body2_h};
164 | line-height: 1.1rem;
165 | }
166 | }
167 |
168 | &.hovered {
169 | color: ${({ theme, disabled }) => (!disabled ? theme.COLOR.secondary2 : undefined)};
170 | }
171 | `;
172 |
173 | const ReferenceAuthor = styled(Ellipsis)`
174 | ${({ theme }) => theme.TYPO.caption};
175 | `;
176 |
177 | export default PaperInfo;
178 |
--------------------------------------------------------------------------------
/frontend/src/pages/PaperDetail/components/ReferenceGraph.tsx:
--------------------------------------------------------------------------------
1 | import { IPaperDetail } from '@/api/api';
2 | import { useGraph, useGraphData, useGraphEmphasize, useGraphZoom } from '@/hooks';
3 | import { SimulationNodeDatum } from 'd3';
4 | import { useEffect, useRef } from 'react';
5 | import styled from 'styled-components';
6 | import InfoTooltip from './InfoTooltip';
7 |
8 | interface ReferenceGraphProps {
9 | data: IPaperDetail;
10 | addChildrensNodes: (doi: string) => void;
11 | hoveredNode: string;
12 | changeHoveredNode: (key: string) => void;
13 | }
14 |
15 | export interface Node extends SimulationNodeDatum {
16 | [key: string]: string | boolean | number | null | undefined;
17 | title?: string;
18 | author?: string;
19 | isSelected: boolean;
20 | key: string;
21 | doi?: string;
22 | citations?: number;
23 | publishedYear?: number;
24 | }
25 |
26 | export interface Link {
27 | source: Node | string;
28 | target: Node | string;
29 | }
30 |
31 | const ReferenceGraph = ({ data, addChildrensNodes, hoveredNode, changeHoveredNode }: ReferenceGraphProps) => {
32 | const svgRef = useRef(null);
33 | const linkRef = useRef(null);
34 | const nodeRef = useRef(null);
35 | const workerRef = useRef(null);
36 |
37 | const { nodes, links } = useGraphData(data);
38 | const { drawLink, drawNode } = useGraph(nodeRef.current, linkRef.current, addChildrensNodes, changeHoveredNode);
39 |
40 | useGraphZoom(svgRef.current);
41 | useGraphEmphasize(nodeRef.current, linkRef.current, nodes, links, hoveredNode, data.key);
42 |
43 | useEffect(() => {
44 | if (!svgRef.current || (nodes.length === 0 && links.length === 0)) return;
45 |
46 | if (workerRef.current !== null) {
47 | workerRef.current.terminate();
48 | }
49 |
50 | workerRef.current = new Worker(new URL('../workers/forceSimulation.worker.ts', import.meta.url));
51 |
52 | // 서브스레드로 nodes, links, 중앙좌표 전송
53 | workerRef.current.postMessage({
54 | nodes,
55 | links,
56 | centerX: svgRef.current?.clientWidth / 2,
57 | centerY: svgRef.current?.clientHeight / 2,
58 | });
59 |
60 | workerRef.current.onmessage = (event) => {
61 | const { newNodes, newLinks } = event.data as { newNodes: Node[]; newLinks: Link[] };
62 | if (!newLinks) return;
63 | drawLink(newLinks);
64 | drawNode(newNodes);
65 | };
66 | }, [nodes, links, drawLink, drawNode]);
67 |
68 | return (
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 | );
77 | };
78 |
79 | const Container = styled.div`
80 | display: flex;
81 | justify-content: center;
82 | align-items: center;
83 | flex-grow: 1;
84 | background-color: ${({ theme }) => theme.COLOR.primary4};
85 | `;
86 |
87 | const Graph = styled.svg`
88 | width: 100%;
89 | height: 100%;
90 | `;
91 |
92 | const Links = styled.g`
93 | line {
94 | stroke: ${({ theme }) => theme.COLOR.gray1};
95 | stroke-width: 0.5px;
96 | stroke-dasharray: 1;
97 | }
98 |
99 | path {
100 | fill: ${({ theme }) => theme.COLOR.secondary1};
101 | }
102 | `;
103 |
104 | const Nodes = styled.g`
105 | text {
106 | text-anchor: middle;
107 | font-family: 'Helvetica Neue', Helvetica, sans-serif;
108 | fill: ${({ theme }) => theme.COLOR.gray2};
109 | fill-opacity: 50%;
110 | font-size: 8px;
111 | cursor: default;
112 | :hover {
113 | fill-opacity: 100%;
114 | }
115 | }
116 | `;
117 |
118 | export default ReferenceGraph;
119 |
--------------------------------------------------------------------------------
/frontend/src/pages/PaperDetail/workers/forceSimulation.worker.ts:
--------------------------------------------------------------------------------
1 | import * as d3 from 'd3';
2 | import { Link, Node } from '../components/ReferenceGraph';
3 |
4 | interface DataProps {
5 | data: {
6 | nodes: Node[];
7 | links: Link[];
8 | centerX: number;
9 | centerY: number;
10 | };
11 | }
12 |
13 | self.onmessage = ({ data }: DataProps) => {
14 | const { nodes, links, centerX, centerY } = data;
15 | const simulation = d3
16 | .forceSimulation(nodes)
17 | .force('charge', d3.forceManyBody().strength(-200).distanceMax(200))
18 | .force('center', d3.forceCenter(centerX, centerY))
19 | .force(
20 | 'link',
21 | d3.forceLink(links).id((d) => (d as Node).key),
22 | )
23 | .on('tick', () => {
24 | self.postMessage({ type: 'tick', newNodes: nodes, newLinks: links });
25 | if (simulation.alpha() < simulation.alphaMin()) {
26 | simulation.stop();
27 | self.postMessage({ type: 'stop' });
28 | }
29 | });
30 | };
31 |
--------------------------------------------------------------------------------
/frontend/src/pages/SearchList/SearchList.tsx:
--------------------------------------------------------------------------------
1 | import { IGetSearch } from '@/api/api';
2 | import { Footer, LoaderWrapper } from '@/components';
3 | import theme from '@/style/theme';
4 | import { Suspense, useMemo } from 'react';
5 | import { useSearchParams } from 'react-router-dom';
6 | import styled from 'styled-components';
7 | import SearchBarHeader from './components/SearchBarHeader';
8 | import SearchResults from './components/SearchResults';
9 |
10 | const SearchList = () => {
11 | const [searchParams, setSearchParams] = useSearchParams();
12 | const params = useMemo(() => {
13 | const paramNames = ['page', 'keyword', 'rows'];
14 | return paramNames.reduce(
15 | (prev, curr) => (searchParams.get(curr) ? { ...prev, [curr]: searchParams.get(curr) } : prev),
16 | {
17 | page: '1',
18 | keyword: '',
19 | },
20 | );
21 | }, [searchParams]);
22 |
23 | const changePage = (page: number) => {
24 | const updated = { ...params, page: page.toString() };
25 | setSearchParams(updated);
26 | };
27 |
28 | return (
29 |
30 |
31 | }>
32 |
33 |
34 |
35 |
36 | );
37 | };
38 |
39 | const Container = styled.div`
40 | display: flex;
41 | flex-direction: column;
42 | height: 100%;
43 | background-color: ${({ theme }) => theme.COLOR.offWhite};
44 | `;
45 |
46 | export default SearchList;
47 |
--------------------------------------------------------------------------------
/frontend/src/pages/SearchList/components/Paper.tsx:
--------------------------------------------------------------------------------
1 | import { IPaper } from '@/api/api';
2 | import { Ellipsis } from '@/style/styleUtils';
3 | import { highlightKeyword, removeTag, sliceTitle } from '@/utils/format';
4 | import styled from 'styled-components';
5 |
6 | interface PaperProps {
7 | data: IPaper;
8 | keyword: string;
9 | }
10 |
11 | const Paper = ({ data, keyword }: PaperProps) => {
12 | const { publishedAt, title, authors, citations, references } = data;
13 |
14 | const year = new Date(publishedAt).getFullYear();
15 |
16 | return (
17 |
18 | {title && {highlightKeyword(sliceTitle(removeTag(title)), keyword)} }
19 | {authors && (
20 |
21 |
22 | {authors.length > 1 ? 'Authors' : 'Author'}
23 | {highlightKeyword(authors?.join(', '), keyword)}
24 |
25 |
26 | )}
27 |
28 |
29 | Published
30 | {year}
31 |
32 |
33 | cited by
34 | {citations}
35 | references
36 | {references}
37 |
38 |
39 |
40 | );
41 | };
42 |
43 | const Container = styled.div`
44 | display: flex;
45 | flex-direction: column;
46 | gap: 5px;
47 | `;
48 |
49 | const Title = styled.div`
50 | color: ${({ theme }) => theme.COLOR.black};
51 | ${({ theme }) => theme.TYPO.title}
52 | line-height: 1.1em;
53 | cursor: pointer;
54 | :hover {
55 | text-decoration: underline;
56 | }
57 | `;
58 |
59 | const Content = styled.div`
60 | ${({ theme }) => theme.TYPO.body2};
61 | color: ${({ theme }) => theme.COLOR.gray4};
62 | display: flex;
63 | justify-content: space-between;
64 | `;
65 |
66 | const ContentItem = styled.div`
67 | display: flex;
68 | gap: 5px;
69 | `;
70 |
71 | const Bold = styled.b`
72 | font-weight: 700;
73 | `;
74 |
75 | export default Paper;
76 |
--------------------------------------------------------------------------------
/frontend/src/pages/SearchList/components/SearchBarHeader.tsx:
--------------------------------------------------------------------------------
1 | import { Search } from '@/components';
2 | import { PATH_MAIN } from '@/constants/path';
3 | import { LogoIcon } from '@/icons';
4 | import { useNavigate } from 'react-router-dom';
5 | import styled from 'styled-components';
6 |
7 | interface SearchBarHeaderProps {
8 | keyword: string;
9 | }
10 |
11 | const SearchBarHeader = ({ keyword }: SearchBarHeaderProps) => {
12 | const navigate = useNavigate();
13 |
14 | const handleIconClick = () => {
15 | navigate(PATH_MAIN);
16 | };
17 |
18 | return (
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | );
28 | };
29 |
30 | const Container = styled.header`
31 | padding: 0;
32 | position: relative;
33 | text-align: center;
34 | width: 100%;
35 | height: 70px;
36 | background-color: ${({ theme }) => theme.COLOR.primary3};
37 | `;
38 |
39 | const Logo = styled.div`
40 | position: absolute;
41 | top: 50%;
42 | transform: translateY(-50%);
43 | left: 16px;
44 | z-index: 4;
45 | cursor: pointer;
46 | `;
47 |
48 | const Positioner = styled.div`
49 | position: absolute;
50 | width: 100%;
51 | margin: 10px auto;
52 | flex: 1;
53 | `;
54 |
55 | export default SearchBarHeader;
56 |
--------------------------------------------------------------------------------
/frontend/src/pages/SearchList/components/SearchResults.tsx:
--------------------------------------------------------------------------------
1 | import { IGetSearch } from '@/api/api';
2 | import { IconButton, Pagination } from '@/components';
3 | import InfoIcon from '@/icons/InfoIcon';
4 | import { useSearchQuery } from '@/queries/queries';
5 | import theme from '@/style/theme';
6 | import { createDetailQuery } from '@/utils/createQueryString';
7 | import { useState } from 'react';
8 | import { Link } from 'react-router-dom';
9 | import styled from 'styled-components';
10 | import Paper from './Paper';
11 |
12 | interface SearchResultsProps {
13 | params: IGetSearch;
14 | changePage: (page: number) => void;
15 | }
16 |
17 | const SearchResults = ({ params, changePage }: SearchResultsProps) => {
18 | const [isTooltipOpened, setIsTooltipOpened] = useState(false);
19 | const keyword = params.keyword || '';
20 | const page = Number(params.page);
21 | const { data } = useSearchQuery(params);
22 |
23 | const handleMouseOver = () => {
24 | setIsTooltipOpened(true);
25 | };
26 |
27 | const handleMouseOut = () => {
28 | setIsTooltipOpened(false);
29 | };
30 |
31 | return data && data.papers.length > 0 ? (
32 | <>
33 |
34 | Articles ({data.pageInfo.totalItems.toLocaleString() || 0})
35 |
36 | } aria-label="정보" />
37 |
38 | {isTooltipOpened && 논문의 정보가 정확하지 않거나 누락되어 있을 수 있습니다. }
39 |
40 |
41 |
42 |
43 | {data.papers.map((paper) => (
44 |
45 |
46 |
47 | ))}
48 |
49 |
50 |
51 | >
52 | ) : (
53 | '{keyword}'에 대한 검색 결과가 없습니다.
54 | );
55 | };
56 |
57 | const SectionHeader = styled.div`
58 | display: flex;
59 | align-items: center;
60 | `;
61 |
62 | const H1 = styled.h1`
63 | color: ${({ theme }) => theme.COLOR.gray4};
64 | margin: 16px 15px 16px 30px;
65 | ${({ theme }) => theme.TYPO.H5}
66 | `;
67 |
68 | const IconButtonWrapper = styled.div`
69 | opacity: 0.5;
70 | cursor: pointer;
71 | z-index: 2;
72 | :hover {
73 | opacity: 1;
74 | }
75 | `;
76 |
77 | const InfoTooltip = styled.span`
78 | ${({ theme }) => theme.TYPO.body1};
79 | font-weight: 700;
80 | padding: 5px 8px;
81 | margin-left: 10px;
82 | color: ${({ theme }) => theme.COLOR.gray4};
83 | `;
84 |
85 | const Hr = styled.hr`
86 | border-top: 1px solid ${({ theme }) => theme.COLOR.gray2};
87 | margin: 0;
88 | `;
89 |
90 | const Section = styled.section`
91 | display: flex;
92 | flex: 1;
93 | padding: 20px 30px;
94 | overflow: auto;
95 | flex-direction: column;
96 | `;
97 |
98 | const Papers = styled.div`
99 | display: flex;
100 | flex-direction: column;
101 | gap: 30px;
102 | `;
103 |
104 | const NoResult = styled.div`
105 | flex: 1;
106 | text-align: center;
107 | padding-top: 100px;
108 | `;
109 |
110 | export default SearchResults;
111 |
--------------------------------------------------------------------------------
/frontend/src/queries/queries.ts:
--------------------------------------------------------------------------------
1 | import { isEmpty } from 'lodash-es';
2 | import Api, { IAutoCompletedItem, IGetSearch, IPaperDetail, IPapersData, IRankingData } from './../api/api';
3 | import { queryKey } from './query-key';
4 | import { useQuery } from 'react-query';
5 |
6 | const api = new Api();
7 |
8 | export const useKeywordRankingQuery = (options?: object) => {
9 | return useQuery([queryKey.KEYWORD_RANKING], () => api.getKeywordRanking(), {
10 | suspense: false,
11 | ...options,
12 | });
13 | };
14 |
15 | export const useAutoCompleteQuery = (params: string, options?: object) => {
16 | return useQuery(
17 | [queryKey.AUTO_COMPLETE, params],
18 | () => api.getAutoComplete({ keyword: params }),
19 | {
20 | suspense: false,
21 | ...options,
22 | },
23 | );
24 | };
25 |
26 | export const useSearchQuery = (params: IGetSearch, options?: object) => {
27 | return useQuery([queryKey.SEARCH, params], () => api.getSearch(params), {
28 | enabled: !isEmpty(params),
29 | ...options,
30 | });
31 | };
32 |
33 | export const usePaperQuery = (params: string, options?: object) => {
34 | return useQuery([queryKey.PAPER, params], () => api.getPaperDetail({ doi: params }), {
35 | select: (data) => {
36 | const referenceList = data.referenceList.filter((reference) => reference.title);
37 | return { ...data, referenceList };
38 | },
39 | suspense: false,
40 | useErrorBoundary: true,
41 | ...options,
42 | });
43 | };
44 |
--------------------------------------------------------------------------------
/frontend/src/queries/query-key.ts:
--------------------------------------------------------------------------------
1 | export const queryKey = {
2 | AUTO_COMPLETE: 'autoComplete',
3 | KEYWORD_RANKING: 'keywordRanking',
4 | SEARCH: 'search',
5 | PAPER: 'paper',
6 | };
7 |
--------------------------------------------------------------------------------
/frontend/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/frontend/src/style/GlobalStyle.tsx:
--------------------------------------------------------------------------------
1 | import { createGlobalStyle } from 'styled-components';
2 |
3 | const GlobalStyle = createGlobalStyle`
4 | *, *::before, *::after {
5 | box-sizing: border-box;
6 | font-family: 'Merriweather', serif;
7 | }
8 |
9 | #root {
10 | height: 100vh;
11 | font-family: 'Merriweather', serif;
12 | }
13 |
14 | button, input{
15 | border: none;
16 | outline: none;
17 | padding: 0;
18 | }
19 |
20 | li{
21 | list-style: none;
22 | }
23 |
24 | a {
25 | color: inherit;
26 | text-decoration: none;
27 | }
28 | `;
29 |
30 | export default GlobalStyle;
31 |
--------------------------------------------------------------------------------
/frontend/src/style/styleUtils.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | export const Ellipsis = styled.span`
4 | white-space: normal;
5 | overflow: hidden;
6 | text-overflow: ellipsis;
7 | display: -webkit-box;
8 | -webkit-line-clamp: 1;
9 | -webkit-box-orient: vertical;
10 | word-break: keep-all;
11 | line-height: 1.1em;
12 | `;
13 |
14 | export const Emphasize = styled.span`
15 | color: #3244ff;
16 | font-weight: 700;
17 | `;
18 |
--------------------------------------------------------------------------------
/frontend/src/style/theme.ts:
--------------------------------------------------------------------------------
1 | import { DefaultTheme } from 'styled-components';
2 |
3 | type ColorName =
4 | | 'primary1'
5 | | 'primary2'
6 | | 'primary3'
7 | | 'primary4'
8 | | 'secondary1'
9 | | 'secondary2'
10 | | 'offWhite'
11 | | 'gray1'
12 | | 'gray2'
13 | | 'gray3'
14 | | 'gray4'
15 | | 'black'
16 | | 'error';
17 |
18 | type TypoName = 'H4' | 'H5' | 'title' | 'subtitle' | 'body_h' | 'body1' | 'body2_h' | 'body2' | 'caption';
19 |
20 | export type ColorConfig = {
21 | [key in ColorName]: string;
22 | };
23 |
24 | export type TypoConfig = {
25 | [key in TypoName]: string;
26 | };
27 |
28 | const COLOR: ColorConfig = {
29 | primary1: '#DFA9A7',
30 | primary2: '#7E4B77',
31 | primary3: '#3D334F',
32 | primary4: '#1F1D34',
33 | secondary1: '#FFF5BF',
34 | secondary2: '#FFD600',
35 | offWhite: '#F8F8F8',
36 | gray1: '#DCDCDC',
37 | gray2: '#B5B5B5',
38 | gray3: '#727272',
39 | gray4: '#474747',
40 | black: '#151515',
41 | error: '#F45452',
42 | };
43 |
44 | const TYPO: TypoConfig = {
45 | H4: `
46 | font-weight: 300;
47 | font-size: 36px;
48 | `,
49 | H5: `
50 | font-weight: 700;
51 | font-size: 18px;
52 | `,
53 | title: `
54 | font-weight: 400;
55 | font-size: 16px;
56 | `,
57 | subtitle: `
58 | font-weight: 300;
59 | font-size: 16px;
60 | `,
61 | body_h: `
62 | font-weight: 700;
63 | font-size: 14px;`,
64 | body1: `
65 | font-weight: 300;
66 | font-size: 14px;`,
67 | body2_h: `
68 | font-weight: 400;
69 | font-size: 12px;`,
70 | body2: `
71 | font-weight: 300;
72 | font-size: 12px;`,
73 | caption: `
74 | font-weight: 300;
75 | font-size: 10px;`,
76 | };
77 |
78 | const theme: DefaultTheme = {
79 | COLOR,
80 | TYPO,
81 | };
82 |
83 | export default theme;
84 |
--------------------------------------------------------------------------------
/frontend/src/utils/createQueryString.ts:
--------------------------------------------------------------------------------
1 | import { PATH_DETAIL, PATH_SEARCH_LIST } from '@/constants/path';
2 |
3 | const DEFAULT_PAGE = 1;
4 | const DEFAULT_ROWS = 20;
5 |
6 | export const createSearchQuery = (keyword: string, page?: number, rows?: number) => {
7 | return `${PATH_SEARCH_LIST}?keyword=${keyword}&page=${page || DEFAULT_PAGE}&rows=${rows || DEFAULT_ROWS}`;
8 | };
9 |
10 | export const createDetailQuery = (doi: string) => {
11 | return `${PATH_DETAIL}?doi=${doi}`;
12 | };
13 |
--------------------------------------------------------------------------------
/frontend/src/utils/format.tsx:
--------------------------------------------------------------------------------
1 | import { Emphasize } from '@/style/styleUtils';
2 |
3 | const MAX_TITLE_LENGTH = 150;
4 |
5 | export const removeTag = (text: string) => {
6 | return text?.replace(/<[^>]*>?/g, ' ') || '';
7 | };
8 |
9 | export const highlightKeyword = (text: string, keyword: string) => {
10 | const rawKeywordList = keyword.trim().toLowerCase().split(/\s/gi);
11 |
12 | return rawKeywordList.length > 0 && rawKeywordList.some((rawKeyword) => text.toLowerCase().includes(rawKeyword))
13 | ? text
14 | .split(new RegExp(`(${rawKeywordList.join('|')})`, 'gi'))
15 | .map((part, i) =>
16 | rawKeywordList.some((keywordPart) => part.trim().toLowerCase() === keywordPart) ? (
17 | {part}
18 | ) : (
19 | part
20 | ),
21 | )
22 | : text;
23 | };
24 |
25 | export const sliceTitle = (title: string) => {
26 | return title.length > MAX_TITLE_LENGTH ? `${title.slice(0, MAX_TITLE_LENGTH)}...` : title;
27 | };
28 |
29 | export const isDoiFormat = (doi: string) => {
30 | return RegExp(/^(https:\/\/doi.org\/)*([\d]{2}\.[\d]{1,}\/.*)$/i).test(doi);
31 | };
32 |
33 | export const getDoiKey = (doi: string) => {
34 | return doi.match(RegExp(/^(https:\/\/doi.org\/)*([\d]{2}\.[\d]{1,}\/.*)$/i))?.[2] || '';
35 | };
36 |
--------------------------------------------------------------------------------
/frontend/src/utils/storage.ts:
--------------------------------------------------------------------------------
1 | export const setLocalStorage = (key: string, value: unknown) => {
2 | window.localStorage.setItem(key, JSON.stringify(value));
3 | };
4 |
5 | export const getLocalStorage = (key: string) => {
6 | const item = window.localStorage.getItem(key);
7 | if (item) return JSON.parse(item);
8 | return null;
9 | };
10 |
11 | export const setSessionStorage = (key: string, value: unknown) => {
12 | window.sessionStorage.setItem(key, JSON.stringify(value));
13 | };
14 |
15 | export const getSessionStorage = (key: string) => {
16 | const item = window.sessionStorage.getItem(key);
17 | if (item) return JSON.parse(item);
18 | return null;
19 | };
20 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "esModuleInterop": true,
8 | "allowSyntheticDefaultImports": true,
9 | "strict": true,
10 | "forceConsistentCasingInFileNames": true,
11 | "noFallthroughCasesInSwitch": true,
12 | "module": "esnext",
13 | "moduleResolution": "node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx"
18 | },
19 | "include": ["src"],
20 | "extends": "./tsconfig.paths.json"
21 | }
22 |
--------------------------------------------------------------------------------
/frontend/tsconfig.paths.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "paths": {
5 | "@/*": ["src/*"]
6 | }
7 | }
8 | }
9 |
--------------------------------------------------------------------------------