├── .babelrc ├── .editorconfig ├── .eslintrc.js ├── .github ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE │ ├── Bug_report.md │ └── Feature_request.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── app ├── assets │ ├── README.md │ ├── style.scss │ └── top.jpg ├── components │ ├── AlertModal.vue │ ├── AppFooter.vue │ ├── AppHeader.vue │ ├── CategorizeButton.vue │ ├── CategorizedStock.vue │ ├── CategorizedStockEdit.vue │ ├── CategorizedStockList.vue │ ├── Category.vue │ ├── CategoryList.vue │ ├── ConfirmModal.vue │ ├── CreateCategory.vue │ ├── DefaultMenuList.vue │ ├── Loading.vue │ ├── Logo.vue │ ├── Pagination.vue │ ├── README.md │ ├── SideMenu.vue │ ├── Stock.vue │ ├── StockEdit.vue │ ├── StockList.vue │ └── pages │ │ ├── Error.vue │ │ ├── Index.vue │ │ ├── Login.vue │ │ ├── Maintenance.vue │ │ ├── Privacy.vue │ │ ├── Signup.vue │ │ ├── Terms.vue │ │ ├── cancel │ │ ├── Complete.vue │ │ └── Index.vue │ │ └── stocks │ │ ├── All.vue │ │ └── categories │ │ └── StockCategories.vue ├── constants │ └── envConstant.ts ├── domain │ └── domain.ts ├── factory │ └── qiitaStockApi.ts ├── layouts │ ├── README.md │ ├── default.vue │ ├── error.vue │ └── stocks.vue ├── middleware │ ├── README.md │ ├── authCookieMiddleware.ts │ └── redirectMiddleware.ts ├── pages │ ├── README.md │ ├── cancel │ │ ├── complete.vue │ │ └── index.vue │ ├── error.vue │ ├── index.vue │ ├── login.vue │ ├── maintenance.vue │ ├── privacy.vue │ ├── signup.vue │ ├── stocks │ │ ├── all.vue │ │ └── categories │ │ │ └── _id.vue │ └── terms.vue ├── plugins │ ├── README.md │ └── ga.js ├── repositories │ ├── api.ts │ └── httpResponse.ts ├── static │ └── assets │ │ ├── favicon.ico │ │ └── ogp.png ├── store │ ├── README.md │ ├── index.ts │ └── qiita.ts └── types │ └── shims-vue.d.ts ├── buildspec.yml ├── createEnv.ts ├── deployToS3.js ├── deployUtils.js ├── envUtils.ts ├── jest.config.js ├── nodemon.json ├── nuxt.config.ts ├── package.json ├── server ├── api │ └── qiita.ts ├── app.ts ├── auth │ └── oauth.ts ├── constants │ └── envConstant.ts ├── core │ └── nuxt.ts ├── domain │ ├── auth.ts │ ├── qiita.ts │ ├── qiitaApiInterface.ts │ └── qiitaStockerApiInterface.ts ├── factroy │ └── api │ │ ├── qiitaApiFactory.ts │ │ └── qiitaStockerApiFactory.ts ├── lambda.ts ├── repositories │ ├── qiitaApi.ts │ └── qiitaStockerApi.ts └── server.ts ├── serverless.yml ├── test └── Logo.spec.ts ├── tsconfig.json ├── tsconfig.server.json └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "test": { 4 | "presets": [ 5 | [ 6 | "@babel/preset-env", 7 | { 8 | "targets": { 9 | "node": "current" 10 | } 11 | } 12 | ] 13 | ] 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | node: true 6 | }, 7 | extends: [ 8 | '@nuxtjs/eslint-config-typescript', 9 | 'plugin:nuxt/recommended', 10 | 'plugin:prettier/recommended', 11 | 'prettier', 12 | 'prettier/vue', 13 | 'prettier/@typescript-eslint' 14 | ], 15 | plugins: [ 16 | 'prettier' 17 | ], 18 | rules: { 19 | 'nuxt/no-cjs-in-config': 'off', 20 | 'camelcase': 0, 21 | 'vue/no-v-html': 0, 22 | 'no-console': 0 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # PRを出す前に確認する事 2 | 3 | 以下の条件を満たしているか再度チェックしよう🐱! 4 | 5 | - PRのタイトルは分かりやすい事(理想は非エンジニアが見ても何となく分かるように、無理な場合も多いけど・・・) 6 | - READMEに書いてるセルフチェックが全て完了している事 7 | - e.g. Lintでのコード整形 8 | - e.g テストコードの実装 9 | - PRテンプレートに従って必要な情報が記載されている事 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: バグ報告 3 | about: バグの報告用テンプレート 4 | 5 | --- 6 | 7 | # 概要 8 | [required] 9 | 10 | # 再現手順 11 | [required] 12 | 13 | # 再現環境 14 | [required] 15 | 16 | # このバグによって引き起こされる問題 17 | [optional] 18 | 19 | # スクリーンショット 20 | [optional] 21 | 22 | # 補足情報 23 | [optional] 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: ストーリー 3 | about: スクラム開発でいうところのストーリーのテンプレート 4 | 5 | --- 6 | 7 | # Doneの定義 8 | [required] 9 | 10 | # 補足情報 11 | [optional] 12 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # issueURL 2 | [required] 3 | 4 | # 関連URL 5 | [optional] 6 | 7 | # Doneの定義 8 | [required] 9 | 10 | # スクリーンショット 11 | [optional] ただしUI変更の時は [required] 12 | 13 | # 変更点概要 14 | 15 | ## 仕様的変更点概要 16 | [optional] 17 | 18 | ## 技術的変更点概要 19 | [required] 20 | 21 | # レビュアーに重点的にチェックして欲しい点 22 | [optional] 23 | 24 | # 補足情報 25 | [optional] 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # TypeScript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | .envrc 62 | 63 | # parcel-bundler cache (https://parceljs.org/) 64 | .cache 65 | 66 | # next.js build output 67 | .next 68 | 69 | # nuxt.js build output 70 | .nuxt 71 | 72 | # Nuxt generate 73 | dist 74 | 75 | # vuepress build output 76 | .vuepress/dist 77 | 78 | # Serverless directories 79 | .serverless 80 | 81 | # IDE 82 | .idea 83 | qiita-stocker-frontend.iml 84 | 85 | # Service worker 86 | sw.* 87 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2020 nekochans 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # qiita-stocker-nuxt 2 | 3 | ## デプロイ 4 | CodeBuildプロジェクトからデプロイを行います。 5 | -------------------------------------------------------------------------------- /app/assets/README.md: -------------------------------------------------------------------------------- 1 | # ASSETS 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your un-compiled assets such as LESS, SASS, or JavaScript. 6 | 7 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/assets#webpacked). 8 | -------------------------------------------------------------------------------- /app/assets/style.scss: -------------------------------------------------------------------------------- 1 | @import '~bulma/sass/utilities/_all'; 2 | 3 | $modal-background-background-color: rgba(10, 10, 10, 0.3); 4 | 5 | @import '~bulma'; 6 | 7 | .columns { 8 | margin: 0; 9 | } 10 | 11 | @media screen and (min-width: 768px) { 12 | .columns { 13 | margin-left: -0.75rem; 14 | margin-right: -0.75rem; 15 | margin-top: -0.75rem; 16 | } 17 | } 18 | 19 | -------------------------------------------------------------------------------- /app/assets/top.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nekochans/qiita-stocker-frontend/830de7227e6ca33e0105091456d67d409ed4494a/app/assets/top.jpg -------------------------------------------------------------------------------- /app/components/AlertModal.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 36 | -------------------------------------------------------------------------------- /app/components/AppFooter.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 37 | -------------------------------------------------------------------------------- /app/components/AppHeader.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 87 | 88 | 94 | -------------------------------------------------------------------------------- /app/components/CategorizeButton.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 93 | 94 | 103 | -------------------------------------------------------------------------------- /app/components/CategorizedStock.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 71 | 72 | 111 | -------------------------------------------------------------------------------- /app/components/CategorizedStockEdit.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 78 | 79 | 103 | -------------------------------------------------------------------------------- /app/components/CategorizedStockList.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 47 | -------------------------------------------------------------------------------- /app/components/Category.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | 161 | 162 | 197 | -------------------------------------------------------------------------------- /app/components/CategoryList.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 45 | 46 | 52 | -------------------------------------------------------------------------------- /app/components/ConfirmModal.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 49 | -------------------------------------------------------------------------------- /app/components/CreateCategory.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 75 | 76 | 82 | -------------------------------------------------------------------------------- /app/components/DefaultMenuList.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 32 | -------------------------------------------------------------------------------- /app/components/Loading.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 11 | -------------------------------------------------------------------------------- /app/components/Logo.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 14 | 15 | 85 | -------------------------------------------------------------------------------- /app/components/Pagination.vue: -------------------------------------------------------------------------------- 1 | 76 | 77 | 161 | 162 | 168 | -------------------------------------------------------------------------------- /app/components/README.md: -------------------------------------------------------------------------------- 1 | # COMPONENTS 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | The components directory contains your Vue.js Components. 6 | 7 | _Nuxt.js doesn't supercharge these components._ 8 | -------------------------------------------------------------------------------- /app/components/SideMenu.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 60 | -------------------------------------------------------------------------------- /app/components/Stock.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 64 | 65 | 106 | -------------------------------------------------------------------------------- /app/components/StockEdit.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 59 | 60 | 76 | -------------------------------------------------------------------------------- /app/components/StockList.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 38 | -------------------------------------------------------------------------------- /app/components/pages/Error.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 20 | 21 | 26 | -------------------------------------------------------------------------------- /app/components/pages/Index.vue: -------------------------------------------------------------------------------- 1 | 60 | 61 | 69 | 70 | 105 | -------------------------------------------------------------------------------- /app/components/pages/Login.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 23 | 24 | 29 | -------------------------------------------------------------------------------- /app/components/pages/Maintenance.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 19 | 20 | 25 | -------------------------------------------------------------------------------- /app/components/pages/Privacy.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 83 | 84 | 89 | -------------------------------------------------------------------------------- /app/components/pages/Signup.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 27 | 28 | 34 | -------------------------------------------------------------------------------- /app/components/pages/Terms.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 117 | 118 | 123 | -------------------------------------------------------------------------------- /app/components/pages/cancel/Complete.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 20 | -------------------------------------------------------------------------------- /app/components/pages/cancel/Index.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 38 | 39 | 44 | -------------------------------------------------------------------------------- /app/components/pages/stocks/All.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 216 | 217 | 222 | -------------------------------------------------------------------------------- /app/components/pages/stocks/categories/StockCategories.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 257 | 258 | 263 | -------------------------------------------------------------------------------- /app/constants/envConstant.ts: -------------------------------------------------------------------------------- 1 | export const apiUrlBase = (): string => { 2 | return typeof process.env.apiUrlBase === 'string' 3 | ? process.env.apiUrlBase 4 | : '' 5 | } 6 | -------------------------------------------------------------------------------- /app/domain/domain.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError, AxiosResponse } from 'axios' 2 | import QiitaStockApiFactory from '@/factory/qiitaStockApi' 3 | 4 | const api = QiitaStockApiFactory.create() 5 | 6 | type QiitaStockerErrorData = { 7 | code: number 8 | message: string 9 | } 10 | 11 | export type QiitaStockerError = AxiosError & { 12 | response: AxiosResponse 13 | } 14 | 15 | export type Page = { 16 | page: number 17 | perPage: number 18 | relation: string 19 | } 20 | 21 | export type Category = { 22 | categoryId: number 23 | name: string 24 | } 25 | 26 | export type Stock = { 27 | articleId: string 28 | title: string 29 | userId: string 30 | profileImageUrl: string 31 | articleCreatedAt: string 32 | tags: string[] 33 | } 34 | 35 | export type FetchedCategorizedStock = Stock & { 36 | id: number 37 | } 38 | 39 | export type CategorizedStock = Stock & { 40 | id: number 41 | isChecked: boolean 42 | } 43 | 44 | export type UncategorizedStock = Stock & { 45 | category?: Category 46 | isChecked: boolean 47 | } 48 | 49 | export type FetchCategoriesRequest = { 50 | apiUrlBase: string 51 | sessionId: string 52 | } 53 | export type FetchCategoriesResponse = Category & {} 54 | 55 | export type CancelCategorizationRequest = { 56 | apiUrlBase: string 57 | sessionId: string 58 | id: number 59 | } 60 | 61 | export type UpdateCategoryRequest = { 62 | apiUrlBase: string 63 | sessionId: string 64 | categoryId: number 65 | name: string 66 | } 67 | export type UpdateCategoryResponse = Category & {} 68 | 69 | export type DestroyCategoryRequest = { 70 | apiUrlBase: string 71 | sessionId: string 72 | categoryId: number 73 | } 74 | 75 | export type FetchUncategorizedStockRequest = { 76 | apiUrlBase: string 77 | sessionId: string 78 | page: number 79 | parPage: number 80 | } 81 | 82 | export type FetchUncategorizedStockResponse = { 83 | paging: Page[] 84 | stocks: { stock: Stock; category?: Category }[] 85 | } 86 | 87 | export type FetchCategorizedStockRequest = { 88 | apiUrlBase: string 89 | sessionId: string 90 | categoryId: number 91 | page: number 92 | parPage: number 93 | } 94 | 95 | export type FetchCategorizedStockResponse = { 96 | paging: Page[] 97 | stocks: FetchedCategorizedStock[] 98 | } 99 | 100 | export type SaveCategoryRequest = { 101 | apiUrlBase: string 102 | name: string 103 | sessionId: string 104 | } 105 | 106 | export type SaveCategoryResponse = Category & {} 107 | 108 | export type CategorizeRequest = { 109 | apiUrlBase: string 110 | sessionId: string 111 | categoryId: number 112 | articleIds: string[] 113 | } 114 | 115 | export type QiitaStockApi = { 116 | cancelAccount(): Promise 117 | logout(): Promise 118 | fetchCategories( 119 | request: FetchCategoriesRequest 120 | ): Promise 121 | updateCategory( 122 | request: UpdateCategoryRequest 123 | ): Promise 124 | fetchUncategorizedStocks( 125 | request: FetchUncategorizedStockRequest 126 | ): Promise 127 | fetchCategorizedStocks( 128 | request: FetchCategorizedStockRequest 129 | ): Promise 130 | saveCategory(request: SaveCategoryRequest): Promise 131 | destroyCategory(request: DestroyCategoryRequest): Promise 132 | categorize(request: CategorizeRequest): Promise 133 | cancelCategorization(request: CancelCategorizationRequest): Promise 134 | } 135 | 136 | export const cancelAccount = async () => { 137 | await api.cancelAccount() 138 | } 139 | 140 | export const logout = async () => { 141 | await api.logout() 142 | } 143 | 144 | export const fetchCategories = ( 145 | request: FetchCategoriesRequest 146 | ): Promise => { 147 | return api.fetchCategories(request) 148 | } 149 | 150 | export const updateCategory = ( 151 | request: UpdateCategoryRequest 152 | ): Promise => { 153 | return api.updateCategory(request) 154 | } 155 | 156 | export const fetchUncategorizedStocks = ( 157 | request: FetchUncategorizedStockRequest 158 | ): Promise => { 159 | return api.fetchUncategorizedStocks(request) 160 | } 161 | 162 | export const fetchCategorizedStocks = ( 163 | request: FetchCategorizedStockRequest 164 | ): Promise => { 165 | return api.fetchCategorizedStocks(request) 166 | } 167 | 168 | export const saveCategory = ( 169 | request: SaveCategoryRequest 170 | ): Promise => { 171 | return api.saveCategory(request) 172 | } 173 | 174 | export const destroyCategory = ( 175 | request: DestroyCategoryRequest 176 | ): Promise => { 177 | return api.destroyCategory(request) 178 | } 179 | 180 | export const categorize = (request: CategorizeRequest): Promise => { 181 | return api.categorize(request) 182 | } 183 | 184 | export const cancelCategorization = ( 185 | request: CancelCategorizationRequest 186 | ): Promise => { 187 | return api.cancelCategorization(request) 188 | } 189 | -------------------------------------------------------------------------------- /app/factory/qiitaStockApi.ts: -------------------------------------------------------------------------------- 1 | import Api from '@/repositories/api' 2 | import { QiitaStockApi } from '@/domain/domain' 3 | 4 | export default class QiitaStockApiFactory { 5 | static create(): QiitaStockApi { 6 | return new Api() 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /app/layouts/README.md: -------------------------------------------------------------------------------- 1 | # LAYOUTS 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your Application Layouts. 6 | 7 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/views#layouts). 8 | -------------------------------------------------------------------------------- /app/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 22 | -------------------------------------------------------------------------------- /app/layouts/error.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 25 | -------------------------------------------------------------------------------- /app/layouts/stocks.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 19 | -------------------------------------------------------------------------------- /app/middleware/README.md: -------------------------------------------------------------------------------- 1 | # MIDDLEWARE 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your application middleware. 6 | Middleware let you define custom functions that can be run before rendering either a page or a group of pages. 7 | 8 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/routing#middleware). 9 | -------------------------------------------------------------------------------- /app/middleware/authCookieMiddleware.ts: -------------------------------------------------------------------------------- 1 | import Cookies from 'universal-cookie' 2 | import { Context, Middleware } from '@nuxt/types' 3 | 4 | const authCookieMiddleware: Middleware = ({ req, store }: Context) => { 5 | if (process.browser) return 6 | 7 | const cookies = new Cookies(req.headers.cookie) 8 | const sessionId = cookies.get('sessionId') 9 | if (sessionId) store.dispatch('qiita/saveSessionIdAction', { sessionId }) 10 | } 11 | 12 | export default authCookieMiddleware 13 | -------------------------------------------------------------------------------- /app/middleware/redirectMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { Context, Middleware } from '@nuxt/types' 2 | 3 | const redirectMiddleware: Middleware = ({ redirect }: Context) => { 4 | return redirect('/maintenance') 5 | } 6 | 7 | export default redirectMiddleware 8 | -------------------------------------------------------------------------------- /app/pages/README.md: -------------------------------------------------------------------------------- 1 | # PAGES 2 | 3 | This directory contains your Application Views and Routes. 4 | The framework reads all the `*.vue` files inside this directory and creates the router of your application. 5 | 6 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/routing). 7 | -------------------------------------------------------------------------------- /app/pages/cancel/complete.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 16 | -------------------------------------------------------------------------------- /app/pages/cancel/index.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 16 | -------------------------------------------------------------------------------- /app/pages/error.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 30 | -------------------------------------------------------------------------------- /app/pages/index.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 16 | -------------------------------------------------------------------------------- /app/pages/login.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 16 | -------------------------------------------------------------------------------- /app/pages/maintenance.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 30 | -------------------------------------------------------------------------------- /app/pages/privacy.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 16 | -------------------------------------------------------------------------------- /app/pages/signup.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 16 | -------------------------------------------------------------------------------- /app/pages/stocks/all.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 43 | -------------------------------------------------------------------------------- /app/pages/stocks/categories/_id.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 53 | -------------------------------------------------------------------------------- /app/pages/terms.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 16 | -------------------------------------------------------------------------------- /app/plugins/README.md: -------------------------------------------------------------------------------- 1 | # PLUGINS 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains Javascript plugins that you want to run before mounting the root Vue.js application. 6 | 7 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/plugins). 8 | -------------------------------------------------------------------------------- /app/plugins/ga.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | export default ({ app }) => { 4 | /* 5 | ** Google アナリティクスのスクリプトをインクルード 6 | */ 7 | ;(function(i, s, o, g, r, a, m) { 8 | i.GoogleAnalyticsObject = r 9 | ;(i[r] = 10 | i[r] || 11 | function() { 12 | ;(i[r].q = i[r].q || []).push(arguments) 13 | }), 14 | (i[r].l = 1 * new Date()) 15 | ;(a = s.createElement(o)), (m = s.getElementsByTagName(o)[0]) 16 | a.async = 1 17 | a.src = g 18 | m.parentNode.insertBefore(a, m) 19 | })( 20 | window, 21 | document, 22 | 'script', 23 | 'https://www.google-analytics.com/analytics.js', 24 | 'ga' 25 | ) 26 | /* 27 | ** 現在のページをセット 28 | */ 29 | ga('create', process.env.trackingId, 'auto') 30 | /* 31 | ** ルートが変更されるたびに毎回実行(初期化も実行される) 32 | */ 33 | app.router.afterEach((to, from) => { 34 | /* 35 | ** Google アナリティクスにページビューが追加されたことを伝える 36 | */ 37 | ga('set', 'page', to.fullPath) 38 | ga('send', 'pageview') 39 | }) 40 | } 41 | -------------------------------------------------------------------------------- /app/repositories/api.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosResponse } from 'axios' 2 | import { 3 | CancelCategorizationRequest, 4 | CategorizeRequest, 5 | DestroyCategoryRequest, 6 | FetchedCategorizedStock, 7 | FetchCategoriesRequest, 8 | FetchCategoriesResponse, 9 | FetchCategorizedStockRequest, 10 | FetchCategorizedStockResponse, 11 | FetchUncategorizedStockRequest, 12 | FetchUncategorizedStockResponse, 13 | Page, 14 | QiitaStockApi, 15 | QiitaStockerError, 16 | SaveCategoryRequest, 17 | SaveCategoryResponse, 18 | Stock, 19 | UpdateCategoryRequest, 20 | UpdateCategoryResponse, 21 | Category 22 | } from '@/domain/domain' 23 | 24 | import { 25 | HttpUncategorizedStockResponse, 26 | HttpCategorizedStockResponse 27 | } from '@/repositories/httpResponse' 28 | 29 | export default class Api implements QiitaStockApi { 30 | /** 31 | * @return {Promise} 32 | */ 33 | cancelAccount(): Promise { 34 | return axios 35 | .get('/api/cancel') 36 | .then(() => { 37 | return Promise.resolve() 38 | }) 39 | .catch((axiosError: QiitaStockerError) => { 40 | return Promise.reject(axiosError.response.data) 41 | }) 42 | } 43 | 44 | /** 45 | * @return {Promise} 46 | */ 47 | logout(): Promise { 48 | return axios 49 | .get('/api/logout') 50 | .then(() => { 51 | return Promise.resolve() 52 | }) 53 | .catch((axiosError: QiitaStockerError) => { 54 | return Promise.reject(axiosError.response.data) 55 | }) 56 | } 57 | 58 | /** 59 | * @param request 60 | * @return {Promise} 61 | */ 62 | fetchCategories( 63 | request: FetchCategoriesRequest 64 | ): Promise { 65 | return axios 66 | .get(`${request.apiUrlBase}/api/categories`, { 67 | headers: { 68 | Authorization: `Bearer ${request.sessionId}` 69 | } 70 | }) 71 | .then((axiosResponse: AxiosResponse) => { 72 | return Promise.resolve(axiosResponse.data) 73 | }) 74 | .catch((axiosError: QiitaStockerError) => { 75 | return Promise.reject(axiosError.response.data) 76 | }) 77 | } 78 | 79 | /** 80 | * @param request 81 | * @return {Promise} 82 | */ 83 | updateCategory( 84 | request: UpdateCategoryRequest 85 | ): Promise { 86 | return axios 87 | .patch( 88 | `${request.apiUrlBase}/api/categories/${request.categoryId}`, 89 | { name: request.name }, 90 | { 91 | headers: { 92 | Authorization: `Bearer ${request.sessionId}`, 93 | 'Content-Type': 'application/json' 94 | } 95 | } 96 | ) 97 | .then((axiosResponse: AxiosResponse) => { 98 | return Promise.resolve(axiosResponse.data) 99 | }) 100 | .catch((axiosError: QiitaStockerError) => { 101 | return Promise.reject(axiosError.response.data) 102 | }) 103 | } 104 | 105 | /** 106 | * @param request 107 | * @return {Promise} 108 | */ 109 | fetchUncategorizedStocks( 110 | request: FetchUncategorizedStockRequest 111 | ): Promise { 112 | return axios 113 | .get( 114 | `${request.apiUrlBase}/api/stocks?page=${request.page}&per_page=${request.parPage}`, 115 | { 116 | headers: { 117 | Authorization: `Bearer ${request.sessionId}` 118 | } 119 | } 120 | ) 121 | .then((axiosResponse: AxiosResponse) => { 122 | const linkHeader: string = axiosResponse.headers.link 123 | const paging: Page[] = this.parseLinkHeader(linkHeader) 124 | 125 | const response: FetchUncategorizedStockResponse = { 126 | stocks: this.convertStocks(axiosResponse.data), 127 | paging 128 | } 129 | 130 | return Promise.resolve(response) 131 | }) 132 | .catch((axiosError: QiitaStockerError) => { 133 | return Promise.reject(axiosError.response.data) 134 | }) 135 | } 136 | 137 | fetchCategorizedStocks( 138 | request: FetchCategorizedStockRequest 139 | ): Promise { 140 | return axios 141 | .get( 142 | `${request.apiUrlBase}/api/stocks/categories/${request.categoryId}?page=${request.page}&per_page=${request.parPage}`, 143 | { 144 | headers: { 145 | Authorization: `Bearer ${request.sessionId}` 146 | } 147 | } 148 | ) 149 | .then((axiosResponse: AxiosResponse) => { 150 | const linkHeader: string = axiosResponse.headers.link 151 | const paging: Page[] = this.parseLinkHeader(linkHeader) 152 | 153 | const response: FetchCategorizedStockResponse = { 154 | stocks: this.convertCategorizedStocks(axiosResponse.data), 155 | paging 156 | } 157 | 158 | return Promise.resolve(response) 159 | }) 160 | .catch((axiosError: QiitaStockerError) => { 161 | return Promise.reject(axiosError.response.data) 162 | }) 163 | } 164 | 165 | /** 166 | * @param request 167 | * @return {Promise} 168 | */ 169 | saveCategory(request: SaveCategoryRequest): Promise { 170 | return axios 171 | .post( 172 | `${request.apiUrlBase}/api/categories`, 173 | { name: request.name }, 174 | { 175 | headers: { 176 | Authorization: `Bearer ${request.sessionId}`, 177 | 'Content-Type': 'application/json' 178 | } 179 | } 180 | ) 181 | .then((axiosResponse: AxiosResponse) => { 182 | return Promise.resolve(axiosResponse.data) 183 | }) 184 | .catch((axiosError: QiitaStockerError) => { 185 | return Promise.reject(axiosError.response.data) 186 | }) 187 | } 188 | 189 | /** 190 | * @param request 191 | * @return {Promise} 192 | */ 193 | destroyCategory(request: DestroyCategoryRequest): Promise { 194 | return axios 195 | .delete(`${request.apiUrlBase}/api/categories/${request.categoryId}`, { 196 | headers: { 197 | Authorization: `Bearer ${request.sessionId}`, 198 | 'Content-Type': 'application/json' 199 | } 200 | }) 201 | .then(() => { 202 | return Promise.resolve() 203 | }) 204 | .catch((axiosError: QiitaStockerError) => { 205 | return Promise.reject(axiosError.response.data) 206 | }) 207 | } 208 | 209 | /** 210 | * @param request 211 | * @return {Promise} 212 | */ 213 | categorize(request: CategorizeRequest): Promise { 214 | return axios 215 | .post( 216 | `${request.apiUrlBase}/api/categories/stocks`, 217 | { 218 | id: request.categoryId, 219 | articleIds: request.articleIds 220 | }, 221 | { 222 | headers: { 223 | Authorization: `Bearer ${request.sessionId}`, 224 | 'Content-Type': 'application/json' 225 | } 226 | } 227 | ) 228 | .then(() => { 229 | return Promise.resolve() 230 | }) 231 | .catch((axiosError: QiitaStockerError) => { 232 | return Promise.reject(axiosError.response.data) 233 | }) 234 | } 235 | 236 | /** 237 | * @param request 238 | * @return {Promise} 239 | */ 240 | cancelCategorization(request: CancelCategorizationRequest): Promise { 241 | return axios 242 | .delete(`${request.apiUrlBase}/api/categories/stocks/${request.id}`, { 243 | headers: { 244 | Authorization: `Bearer ${request.sessionId}` 245 | } 246 | }) 247 | .then(() => { 248 | return Promise.resolve() 249 | }) 250 | .catch((axiosError: QiitaStockerError) => { 251 | return Promise.reject(axiosError.response.data) 252 | }) 253 | } 254 | 255 | private parseLinkHeader(linkHeader: string): Page[] { 256 | let paging: Page[] = [] 257 | 258 | if (linkHeader) { 259 | paging = linkHeader.split(',').map(info => { 260 | const matchesArray: any = info.match( 261 | /page=(.*?)&per_page=(.*?)>; rel="(\w+)"/ 262 | ) 263 | const castPage: number = parseInt(matchesArray[1]) 264 | const castPerPage: number = parseInt(matchesArray[2]) 265 | 266 | return { 267 | page: castPage, 268 | perPage: castPerPage, 269 | relation: matchesArray[3] 270 | } 271 | }) 272 | } 273 | 274 | return paging 275 | } 276 | 277 | private convertStocks( 278 | response: HttpUncategorizedStockResponse[] 279 | ): { stock: Stock; category?: Category }[] { 280 | return response.map(response => { 281 | const uncategorizedStock: { stock: Stock; category?: Category } = { 282 | stock: { 283 | articleId: response.stock.article_id, 284 | title: response.stock.title, 285 | userId: response.stock.user_id, 286 | profileImageUrl: response.stock.profile_image_url, 287 | articleCreatedAt: response.stock.article_created_at, 288 | tags: response.stock.tags 289 | } 290 | } 291 | 292 | if (response.category) { 293 | uncategorizedStock.category = { 294 | categoryId: response.category.categoryId, 295 | name: response.category.name 296 | } 297 | } 298 | 299 | return uncategorizedStock 300 | }) 301 | } 302 | 303 | private convertCategorizedStocks( 304 | response: HttpCategorizedStockResponse[] 305 | ): FetchedCategorizedStock[] { 306 | return response.map(response => { 307 | const categorizedStock: FetchedCategorizedStock = { 308 | id: response.id, 309 | articleId: response.article_id, 310 | title: response.title, 311 | userId: response.user_id, 312 | profileImageUrl: response.profile_image_url, 313 | articleCreatedAt: response.article_created_at, 314 | tags: response.tags 315 | } 316 | 317 | return categorizedStock 318 | }) 319 | } 320 | } 321 | -------------------------------------------------------------------------------- /app/repositories/httpResponse.ts: -------------------------------------------------------------------------------- 1 | import { Category } from '@/domain/domain' 2 | 3 | type HttpStockResponse = { 4 | article_id: string 5 | title: string 6 | user_id: string 7 | profile_image_url: string 8 | article_created_at: string 9 | tags: string[] 10 | } 11 | 12 | export type HttpCategorizedStockResponse = HttpStockResponse & { 13 | id: number 14 | } 15 | 16 | export type HttpUncategorizedStockResponse = { 17 | stock: HttpStockResponse 18 | category?: Category 19 | } 20 | -------------------------------------------------------------------------------- /app/static/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nekochans/qiita-stocker-frontend/830de7227e6ca33e0105091456d67d409ed4494a/app/static/assets/favicon.ico -------------------------------------------------------------------------------- /app/static/assets/ogp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nekochans/qiita-stocker-frontend/830de7227e6ca33e0105091456d67d409ed4494a/app/static/assets/ogp.png -------------------------------------------------------------------------------- /app/store/README.md: -------------------------------------------------------------------------------- 1 | # STORE 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your Vuex Store files. 6 | Vuex Store option is implemented in the Nuxt.js framework. 7 | 8 | Creating a file in this directory automatically activates the option in the framework. 9 | 10 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/vuex-store). 11 | -------------------------------------------------------------------------------- /app/store/index.ts: -------------------------------------------------------------------------------- 1 | export interface RootState {} 2 | -------------------------------------------------------------------------------- /app/store/qiita.ts: -------------------------------------------------------------------------------- 1 | import { createNamespacedHelpers } from 'vuex' 2 | import { DefineGetters, DefineMutations, DefineActions } from 'vuex-type-helper' 3 | import * as EnvConstant from '../constants/envConstant' 4 | import { 5 | cancelAccount, 6 | logout, 7 | fetchCategories, 8 | updateCategory, 9 | fetchUncategorizedStocks, 10 | fetchCategorizedStocks, 11 | saveCategory, 12 | destroyCategory, 13 | categorize, 14 | cancelCategorization, 15 | UncategorizedStock, 16 | CategorizedStock, 17 | FetchUncategorizedStockRequest, 18 | Page, 19 | FetchCategoriesRequest, 20 | FetchCategoriesResponse, 21 | CancelCategorizationRequest, 22 | FetchCategorizedStockRequest, 23 | FetchCategorizedStockResponse, 24 | UpdateCategoryRequest, 25 | UpdateCategoryResponse, 26 | FetchUncategorizedStockResponse, 27 | Category, 28 | SaveCategoryRequest, 29 | SaveCategoryResponse, 30 | DestroyCategoryRequest, 31 | CategorizeRequest 32 | } from '@/domain/domain' 33 | 34 | export type QiitaState = { 35 | sessionId: string 36 | displayCategoryId: number 37 | categories: Category[] 38 | uncategorizedStocks: UncategorizedStock[] 39 | categorizedStocks: CategorizedStock[] 40 | isCategorizing: boolean 41 | isCancelingCategorization: boolean 42 | isLoading: boolean 43 | currentPage: number 44 | paging: Page[] 45 | } 46 | 47 | export interface QiitaGetters { 48 | isLoggedIn: boolean 49 | currentPage: number 50 | firstPage: Page 51 | prevPage: Page 52 | nextPage: Page 53 | lastPage: Page 54 | checkedStockArticleIds: string[] 55 | checkedCategorizedStockArticleIds: string[] 56 | displayCategoryId: number 57 | categories: Category[] 58 | displayCategories: Category[] 59 | uncategorizedStocks: UncategorizedStock[] 60 | categorizedStocks: CategorizedStock[] 61 | isCategorizing: boolean 62 | isCancelingCategorization: boolean 63 | isLoading: boolean 64 | } 65 | 66 | export interface QiitaMutations { 67 | saveSessionId: { 68 | sessionId: string 69 | } 70 | saveUncategorizedStocks: { 71 | uncategorizedStocks: UncategorizedStock[] 72 | } 73 | saveCategorizedStocks: { 74 | categorizedStocks: CategorizedStock[] 75 | } 76 | removeCategorizedStocks: { 77 | stockArticleIds: string[] 78 | } 79 | removeCategorizedStocksById: { 80 | categorizedStockId: number 81 | } 82 | setIsLoading: { 83 | isLoading: boolean 84 | } 85 | saveCurrentPage: { 86 | currentPage: number 87 | } 88 | saveDisplayCategoryId: { 89 | categoryId: number 90 | } 91 | savePaging: { 92 | paging: Page[] 93 | } 94 | addCategory: Category 95 | saveCategories: { 96 | categories: Category[] 97 | } 98 | removeCategory: number 99 | updateCategory: { 100 | stateCategory: Category 101 | categoryName: string 102 | } 103 | updateStockCategoryName: Category 104 | removeCategoryFromStock: number 105 | setIsCategorizing: {} 106 | setIsCancelingCategorization: {} 107 | checkStock: { 108 | stock: UncategorizedStock 109 | isChecked: boolean 110 | } 111 | uncheckStock: {} 112 | updateStockCategory: { 113 | stockArticleIds: string[] 114 | category: Category 115 | } 116 | resetData: {} 117 | } 118 | 119 | export interface QiitaActions { 120 | saveSessionIdAction: { 121 | sessionId: string 122 | } 123 | cancelAction: {} 124 | fetchUncategorizedStocks: Page 125 | fetchCategorizedStock: FetchCategorizedStockPayload 126 | logoutAction: {} 127 | fetchCategory: {} 128 | updateCategory: UpdateCategoryPayload 129 | saveCategory: string 130 | destroyCategory: number 131 | setIsCategorizing: {} 132 | setIsCancelingCategorization: {} 133 | categorize: CategorizePayload 134 | cancelCategorization: number 135 | checkStock: UncategorizedStock 136 | saveDisplayCategoryId: number 137 | resetData: {} 138 | setIsLoadingAction: boolean 139 | } 140 | 141 | export const state = (): QiitaState => ({ 142 | sessionId: '', 143 | displayCategoryId: 0, 144 | categories: [], 145 | uncategorizedStocks: [], 146 | categorizedStocks: [], 147 | isCategorizing: false, 148 | isCancelingCategorization: false, 149 | isLoading: false, 150 | currentPage: 1, 151 | paging: [] 152 | }) 153 | 154 | export type UpdateCategoryPayload = { 155 | stateCategory: Category 156 | categoryName: string 157 | } 158 | 159 | export type CategorizePayload = { 160 | category: Category 161 | stockArticleIds: string[] 162 | } 163 | 164 | export type FetchCategorizedStockPayload = { 165 | page: Page 166 | categoryId: number 167 | } 168 | 169 | export const getters: DefineGetters = { 170 | isLoggedIn: (state): boolean => { 171 | return !!state.sessionId 172 | }, 173 | currentPage: (state): number => { 174 | return state.currentPage 175 | }, 176 | firstPage: (state): Page => { 177 | const page: Page | undefined = state.paging.find(page => { 178 | return page.relation === 'first' 179 | }) 180 | 181 | if (page !== undefined) { 182 | return page 183 | } 184 | return { page: 0, perPage: 0, relation: '' } 185 | }, 186 | prevPage: (state): Page => { 187 | const page: Page | undefined = state.paging.find(page => { 188 | return page.relation === 'prev' 189 | }) 190 | 191 | if (page !== undefined) { 192 | return page 193 | } 194 | return { page: 0, perPage: 0, relation: '' } 195 | }, 196 | nextPage: (state): Page => { 197 | const page: Page | undefined = state.paging.find(page => { 198 | return page.relation === 'next' 199 | }) 200 | 201 | if (page !== undefined) { 202 | return page 203 | } 204 | return { page: 0, perPage: 0, relation: '' } 205 | }, 206 | lastPage: (state): Page => { 207 | const page: Page | undefined = state.paging.find(page => { 208 | return page.relation === 'last' 209 | }) 210 | 211 | if (page !== undefined) { 212 | return page 213 | } 214 | return { page: 0, perPage: 0, relation: '' } 215 | }, 216 | checkedStockArticleIds: (state): string[] => { 217 | return state.uncategorizedStocks 218 | .filter(stock => stock.isChecked) 219 | .map(stock => stock.articleId) 220 | }, 221 | checkedCategorizedStockArticleIds: (state): string[] => { 222 | return state.categorizedStocks 223 | .filter(categorizedStock => categorizedStock.isChecked) 224 | .map(categorizedStock => categorizedStock.articleId) 225 | }, 226 | displayCategoryId: (state): number => { 227 | return state.displayCategoryId 228 | }, 229 | categories: (state): Category[] => { 230 | return state.categories 231 | }, 232 | displayCategories: (state): Category[] => { 233 | return state.categories.filter( 234 | category => category.categoryId !== state.displayCategoryId 235 | ) 236 | }, 237 | uncategorizedStocks: (state): UncategorizedStock[] => { 238 | return state.uncategorizedStocks 239 | }, 240 | categorizedStocks: (state): CategorizedStock[] => { 241 | return state.categorizedStocks 242 | }, 243 | isCategorizing: (state): boolean => { 244 | return state.isCategorizing 245 | }, 246 | isCancelingCategorization: (state): boolean => { 247 | return state.isCancelingCategorization 248 | }, 249 | isLoading: (state): boolean => { 250 | return state.isLoading 251 | } 252 | } 253 | 254 | export const mutations: DefineMutations = { 255 | saveSessionId: (state, { sessionId }) => { 256 | state.sessionId = sessionId 257 | }, 258 | saveUncategorizedStocks: (state, { uncategorizedStocks }) => { 259 | state.uncategorizedStocks = uncategorizedStocks 260 | }, 261 | saveCategorizedStocks: (state, { categorizedStocks }) => { 262 | state.categorizedStocks = categorizedStocks 263 | }, 264 | removeCategorizedStocks: (state, { stockArticleIds }) => { 265 | state.categorizedStocks = state.categorizedStocks.filter( 266 | categorizedStock => !stockArticleIds.includes(categorizedStock.articleId) 267 | ) 268 | }, 269 | removeCategorizedStocksById: (state, { categorizedStockId }) => { 270 | state.categorizedStocks = state.categorizedStocks.filter( 271 | categorizedStock => categorizedStock.id !== categorizedStockId 272 | ) 273 | }, 274 | setIsLoading: (state, { isLoading }) => { 275 | state.isLoading = isLoading 276 | }, 277 | saveCurrentPage: (state, { currentPage }) => { 278 | state.currentPage = currentPage 279 | }, 280 | saveDisplayCategoryId: (state, { categoryId }) => { 281 | state.displayCategoryId = categoryId 282 | }, 283 | savePaging: (state, { paging }) => { 284 | state.paging = paging 285 | }, 286 | saveCategories: (state, { categories }) => { 287 | state.categories = categories 288 | }, 289 | removeCategory: (state, categoryId) => { 290 | state.categories = state.categories.filter( 291 | category => category.categoryId !== categoryId 292 | ) 293 | }, 294 | addCategory: (state, category) => { 295 | state.categories.push(category) 296 | }, 297 | updateCategory: ( 298 | _state, 299 | payload: { stateCategory: Category; categoryName: string } 300 | ) => { 301 | payload.stateCategory.name = payload.categoryName 302 | }, 303 | updateStockCategoryName: (state, category) => { 304 | state.uncategorizedStocks.map(stock => { 305 | if (stock.category && stock.category.categoryId === category.categoryId) { 306 | stock.category = category 307 | } 308 | }) 309 | }, 310 | removeCategoryFromStock: (state, categoryId) => { 311 | state.uncategorizedStocks.map(stock => { 312 | if (stock.category && stock.category.categoryId === categoryId) { 313 | stock.category = undefined 314 | } 315 | }) 316 | }, 317 | setIsCategorizing: state => { 318 | state.isCategorizing = !state.isCategorizing 319 | }, 320 | setIsCancelingCategorization: state => { 321 | state.isCancelingCategorization = !state.isCancelingCategorization 322 | }, 323 | checkStock: (_state, { stock, isChecked }) => { 324 | stock.isChecked = isChecked 325 | }, 326 | uncheckStock: state => { 327 | state.uncategorizedStocks 328 | .filter(stock => stock.isChecked) 329 | .map(stock => (stock.isChecked = !stock.isChecked)) 330 | }, 331 | updateStockCategory: (state, { stockArticleIds, category }) => { 332 | state.uncategorizedStocks.map(stock => { 333 | if (stockArticleIds.includes(stock.articleId)) { 334 | stock.category = category 335 | } 336 | }) 337 | }, 338 | resetData: state => { 339 | state.isCategorizing = false 340 | state.isCancelingCategorization = false 341 | state.currentPage = 1 342 | } 343 | } 344 | 345 | export const actions: DefineActions< 346 | QiitaActions, 347 | QiitaState, 348 | QiitaMutations, 349 | QiitaGetters 350 | > = { 351 | saveSessionIdAction: ({ commit }, sessionId) => { 352 | commit('saveSessionId', sessionId) 353 | }, 354 | cancelAction: async ({ commit }): Promise => { 355 | try { 356 | await cancelAccount() 357 | commit('saveSessionId', { sessionId: '' }) 358 | } catch (error) { 359 | if (isUnauthorized(error.code)) { 360 | commit('saveSessionId', { sessionId: '' }) 361 | } 362 | return Promise.reject(error) 363 | } 364 | }, 365 | fetchUncategorizedStocks: async ( 366 | { commit, state }, 367 | page: Page = { page: state.currentPage, perPage: 20, relation: '' } 368 | ): Promise => { 369 | try { 370 | commit('setIsLoading', { isLoading: true }) 371 | 372 | const fetchStockRequest: FetchUncategorizedStockRequest = { 373 | apiUrlBase: EnvConstant.apiUrlBase(), 374 | sessionId: state.sessionId, 375 | page: page.page, 376 | parPage: page.perPage 377 | } 378 | 379 | const response: FetchUncategorizedStockResponse = await fetchUncategorizedStocks( 380 | fetchStockRequest 381 | ) 382 | 383 | const uncategorizedStocks = response.stocks.map(fetchStock => { 384 | const date: string[] = fetchStock.stock.articleCreatedAt.split(' ') 385 | fetchStock.stock.articleCreatedAt = date[0] 386 | const uncategorizedStock: UncategorizedStock = Object.assign( 387 | fetchStock.stock, 388 | { isChecked: false, category: fetchStock.category } 389 | ) 390 | return uncategorizedStock 391 | }) 392 | 393 | commit('saveUncategorizedStocks', { uncategorizedStocks }) 394 | commit('savePaging', { paging: response.paging }) 395 | commit('saveCurrentPage', { currentPage: page.page }) 396 | } catch (error) { 397 | if (isUnauthorized(error.code)) { 398 | commit('saveSessionId', { sessionId: '' }) 399 | } 400 | commit('setIsLoading', { isLoading: false }) 401 | return Promise.reject(error) 402 | } 403 | }, 404 | fetchCategorizedStock: async ( 405 | { commit, state }, 406 | payload: FetchCategorizedStockPayload 407 | ): Promise => { 408 | try { 409 | commit('setIsLoading', { isLoading: true }) 410 | 411 | if (payload.page.page === 0) { 412 | payload.page = { 413 | page: 1, 414 | perPage: 20, 415 | relation: '' 416 | } 417 | } 418 | 419 | const fetchCategorizedStockRequest: FetchCategorizedStockRequest = { 420 | apiUrlBase: EnvConstant.apiUrlBase(), 421 | sessionId: state.sessionId, 422 | categoryId: payload.categoryId, 423 | page: payload.page.page, 424 | parPage: payload.page.perPage 425 | } 426 | 427 | const fetchCategorizedStockResponse: FetchCategorizedStockResponse = await fetchCategorizedStocks( 428 | fetchCategorizedStockRequest 429 | ) 430 | 431 | const categorizedStocks = fetchCategorizedStockResponse.stocks.map( 432 | stock => { 433 | const date: string[] = stock.articleCreatedAt.split(' ') 434 | stock.articleCreatedAt = date[0] 435 | const categorizedStock: CategorizedStock = Object.assign(stock, { 436 | isChecked: false 437 | }) 438 | return categorizedStock 439 | } 440 | ) 441 | 442 | commit('saveCategorizedStocks', { categorizedStocks }) 443 | commit('savePaging', { paging: fetchCategorizedStockResponse.paging }) 444 | commit('saveCurrentPage', { currentPage: payload.page.page }) 445 | } catch (error) { 446 | if (isUnauthorized(error.code)) { 447 | commit('saveSessionId', { sessionId: '' }) 448 | } 449 | commit('setIsLoading', { isLoading: false }) 450 | return Promise.reject(error) 451 | } 452 | }, 453 | logoutAction: async ({ commit }): Promise => { 454 | try { 455 | await logout() 456 | commit('saveSessionId', { sessionId: '' }) 457 | } catch (error) { 458 | if (isUnauthorized(error.code)) { 459 | commit('saveSessionId', { sessionId: '' }) 460 | } 461 | return Promise.reject(error) 462 | } 463 | }, 464 | saveCategory: async ({ commit, state }, category): Promise => { 465 | try { 466 | const saveCategoryRequest: SaveCategoryRequest = { 467 | apiUrlBase: EnvConstant.apiUrlBase(), 468 | name: category, 469 | sessionId: state.sessionId 470 | } 471 | 472 | const saveCategoryResponse: SaveCategoryResponse = await saveCategory( 473 | saveCategoryRequest 474 | ) 475 | 476 | const savedCategory: Category = { 477 | categoryId: saveCategoryResponse.categoryId, 478 | name: saveCategoryResponse.name 479 | } 480 | 481 | commit('addCategory', savedCategory) 482 | } catch (error) { 483 | if (isUnauthorized(error.code)) { 484 | commit('saveSessionId', { sessionId: '' }) 485 | } 486 | return Promise.reject(error) 487 | } 488 | }, 489 | fetchCategory: async ({ commit, state }): Promise => { 490 | try { 491 | const fetchCategoriesRequest: FetchCategoriesRequest = { 492 | apiUrlBase: EnvConstant.apiUrlBase(), 493 | sessionId: state.sessionId 494 | } 495 | 496 | const categories: FetchCategoriesResponse[] = await fetchCategories( 497 | fetchCategoriesRequest 498 | ) 499 | 500 | commit('saveCategories', { categories }) 501 | } catch (error) { 502 | if (isUnauthorized(error.code)) { 503 | commit('saveSessionId', { sessionId: '' }) 504 | } 505 | return Promise.reject(error) 506 | } 507 | }, 508 | updateCategory: async ( 509 | { commit, state }, 510 | updateCategoryItem: UpdateCategoryPayload 511 | ): Promise => { 512 | try { 513 | const updateCategoryRequest: UpdateCategoryRequest = { 514 | apiUrlBase: EnvConstant.apiUrlBase(), 515 | sessionId: state.sessionId, 516 | categoryId: updateCategoryItem.stateCategory.categoryId, 517 | name: updateCategoryItem.categoryName 518 | } 519 | 520 | const updateCategoryResponse: UpdateCategoryResponse = await updateCategory( 521 | updateCategoryRequest 522 | ) 523 | 524 | commit('updateCategory', { 525 | stateCategory: updateCategoryItem.stateCategory, 526 | categoryName: updateCategoryResponse.name 527 | }) 528 | 529 | commit('updateStockCategoryName', { 530 | categoryId: updateCategoryItem.stateCategory.categoryId, 531 | name: updateCategoryResponse.name 532 | }) 533 | } catch (error) { 534 | if (isUnauthorized(error.code)) { 535 | commit('saveSessionId', { sessionId: '' }) 536 | } 537 | return Promise.reject(error) 538 | } 539 | }, 540 | destroyCategory: async ( 541 | { commit, state }, 542 | categoryId: number 543 | ): Promise => { 544 | try { 545 | const destroyCategoryRequest: DestroyCategoryRequest = { 546 | apiUrlBase: EnvConstant.apiUrlBase(), 547 | sessionId: state.sessionId, 548 | categoryId 549 | } 550 | 551 | await destroyCategory(destroyCategoryRequest) 552 | 553 | commit('removeCategory', categoryId) 554 | commit('removeCategoryFromStock', categoryId) 555 | 556 | if (state.displayCategoryId === categoryId) { 557 | commit('saveDisplayCategoryId', { categoryId: 0 }) 558 | commit('resetData', {}) 559 | } 560 | } catch (error) { 561 | if (isUnauthorized(error.code)) { 562 | commit('saveSessionId', { sessionId: '' }) 563 | } 564 | return Promise.reject(error) 565 | } 566 | }, 567 | setIsCategorizing: ({ commit }) => { 568 | commit('setIsCategorizing', {}) 569 | }, 570 | setIsCancelingCategorization: ({ commit }) => { 571 | commit('setIsCancelingCategorization', {}) 572 | }, 573 | categorize: async ( 574 | { commit, state }, 575 | categorizePayload: CategorizePayload 576 | ): Promise => { 577 | try { 578 | const categorizeRequest: CategorizeRequest = { 579 | apiUrlBase: EnvConstant.apiUrlBase(), 580 | sessionId: state.sessionId, 581 | categoryId: categorizePayload.category.categoryId, 582 | articleIds: categorizePayload.stockArticleIds 583 | } 584 | 585 | await categorize(categorizeRequest) 586 | commit('uncheckStock', {}) 587 | commit('removeCategorizedStocks', { 588 | stockArticleIds: categorizePayload.stockArticleIds 589 | }) 590 | commit('updateStockCategory', { 591 | stockArticleIds: categorizePayload.stockArticleIds, 592 | category: categorizePayload.category 593 | }) 594 | } catch (error) { 595 | if (isUnauthorized(error.code)) { 596 | commit('saveSessionId', { sessionId: '' }) 597 | } 598 | return Promise.reject(error) 599 | } 600 | }, 601 | cancelCategorization: async ( 602 | { commit, state }, 603 | categorizedStockId: number 604 | ): Promise => { 605 | try { 606 | const cancelCategorizationRequest: CancelCategorizationRequest = { 607 | apiUrlBase: EnvConstant.apiUrlBase(), 608 | sessionId: state.sessionId, 609 | id: categorizedStockId 610 | } 611 | 612 | await cancelCategorization(cancelCategorizationRequest) 613 | commit('removeCategorizedStocksById', { categorizedStockId }) 614 | } catch (error) { 615 | if (isUnauthorized(error.code)) { 616 | commit('saveSessionId', { sessionId: '' }) 617 | } 618 | return Promise.reject(error) 619 | } 620 | }, 621 | checkStock: ({ commit }, stock: UncategorizedStock): void => { 622 | commit('checkStock', { stock, isChecked: !stock.isChecked }) 623 | }, 624 | saveDisplayCategoryId: ({ commit }, categoryId: number): void => { 625 | commit('saveDisplayCategoryId', { categoryId }) 626 | }, 627 | resetData: ({ commit }): void => { 628 | commit('resetData', {}) 629 | commit('saveUncategorizedStocks', { uncategorizedStocks: [] }) 630 | commit('saveCategorizedStocks', { categorizedStocks: [] }) 631 | }, 632 | setIsLoadingAction: ({ commit }, isLoading): void => { 633 | commit('setIsLoading', { isLoading }) 634 | } 635 | } 636 | 637 | export const { 638 | mapActions, 639 | mapGetters, 640 | mapMutations, 641 | mapState 642 | } = createNamespacedHelpers< 643 | QiitaState, 644 | QiitaGetters, 645 | QiitaMutations, 646 | QiitaActions 647 | >('qiita') 648 | 649 | const isUnauthorized = (statusCode: number): boolean => { 650 | return statusCode === 401 651 | } 652 | -------------------------------------------------------------------------------- /app/types/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import Vue from 'vue' 3 | export default Vue 4 | } 5 | -------------------------------------------------------------------------------- /buildspec.yml: -------------------------------------------------------------------------------- 1 | version: 0.2 2 | 3 | phases: 4 | install: 5 | runtime-versions: 6 | nodejs: 10 7 | commands: 8 | - npm install -g yarn 9 | pre_build: 10 | commands: 11 | - yarn install 12 | build: 13 | commands: 14 | - yarn build 15 | - aws s3 rm s3://${DEPLOY_STAGE}-qiita-stocker-nuxt --recursive 16 | - aws s3 sync ./.nuxt/dist/client s3://${DEPLOY_STAGE}-qiita-stocker-nuxt/_nuxt 17 | - aws s3 sync ./app/static s3://${DEPLOY_STAGE}-qiita-stocker-nuxt 18 | - yarn run deploy:${DEPLOY_STAGE} 19 | - aws cloudfront create-invalidation --distribution-id ${DISTRIBUTION_ID} --paths "/*" 20 | -------------------------------------------------------------------------------- /createEnv.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AwsRegion, 3 | ICreateEnvFileParams, 4 | createEnvFile 5 | } from '@nekonomokochan/aws-env-creator' 6 | import { isAllowedDeployStage, findAwsProfile } from './envUtils' 7 | ;(() => { 8 | const deployStage: string = process.env.DEPLOY_STAGE 9 | if (!isAllowedDeployStage(deployStage)) { 10 | return Promise.reject( 11 | new Error( 12 | '有効なステージではありません。local, dev, stg, prod が利用出来ます。' 13 | ) 14 | ) 15 | } 16 | 17 | const params: ICreateEnvFileParams = { 18 | type: '.env', 19 | outputDir: './', 20 | profile: findAwsProfile(deployStage), 21 | parameterPath: `/${deployStage}/qiita-stocker/frontend`, 22 | region: AwsRegion.ap_northeast_1 23 | } 24 | 25 | try { 26 | return createEnvFile(params) 27 | } catch (e) { 28 | return Promise.reject(new Error()) 29 | } 30 | })() 31 | -------------------------------------------------------------------------------- /deployToS3.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const s3 = require('s3') 3 | const AWS = require('aws-sdk') 4 | const deployUtils = require('./deployUtils') 5 | 6 | const deployStage = process.env.DEPLOY_STAGE 7 | if (deployUtils.isAllowedDeployStage(deployStage) === false) { 8 | return Promise.reject( 9 | new Error( 10 | '有効なステージではありません。local, dev, stg, prod が利用出来ます。' 11 | ) 12 | ) 13 | } 14 | 15 | const credentials = new AWS.SharedIniFileCredentials({ 16 | profile: deployUtils.findAwsProfile(deployStage) 17 | }) 18 | 19 | const client = s3.createClient({ 20 | s3Options: { 21 | region: 'ap-northeast-1', 22 | credentials 23 | } 24 | }) 25 | 26 | const deploy = async () => { 27 | await new Promise((resolve, reject) => { 28 | const deleteParam = { 29 | Bucket: deployUtils.findDeployS3Bucket(deployStage) 30 | } 31 | const deleteDir = client.deleteDir(deleteParam) 32 | 33 | deleteDir.on('error', function(error) { 34 | console.error('unable to delete:', error.stack) 35 | reject(new Error('failed')) 36 | }) 37 | 38 | deleteDir.on('progress', function() { 39 | console.log( 40 | 'delete progress', 41 | deleteDir.progressAmount, 42 | deleteDir.progressTotal 43 | ) 44 | }) 45 | 46 | deleteDir.on('end', function() { 47 | console.log('done delete') 48 | resolve() 49 | }) 50 | }) 51 | 52 | const params = [ 53 | { 54 | localDir: path.join(__dirname, '/.nuxt/dist/client'), 55 | s3Params: { 56 | Bucket: deployUtils.findDeployS3Bucket(deployStage), 57 | Prefix: '_nuxt' 58 | } 59 | }, 60 | { 61 | localDir: path.join(__dirname, '/app/static'), 62 | s3Params: { 63 | Bucket: deployUtils.findDeployS3Bucket(deployStage) 64 | } 65 | } 66 | ] 67 | 68 | for (const param of params) { 69 | const uploader = client.uploadDir(param) 70 | 71 | uploader.on('error', function(error) { 72 | console.error('unable to sync:', error.stack) 73 | }) 74 | 75 | uploader.on('progress', function() { 76 | console.log( 77 | 'upload progress', 78 | uploader.progressAmount, 79 | uploader.progressTotal 80 | ) 81 | }) 82 | 83 | uploader.on('end', function() { 84 | console.log('done uploading') 85 | }) 86 | } 87 | } 88 | 89 | deploy() 90 | -------------------------------------------------------------------------------- /deployUtils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 許可されたデプロイステージかどうか判定する 3 | * 4 | * @param deployStage 5 | * @return {boolean} 6 | */ 7 | exports.isAllowedDeployStage = deployStage => { 8 | return ( 9 | deployStage === 'local' || 10 | deployStage === 'dev' || 11 | deployStage === 'stg' || 12 | deployStage === 'prod' 13 | ) 14 | } 15 | 16 | /** 17 | * SecretIdsを取得する 18 | * 19 | * @param deployStage 20 | * @return {string[]} 21 | */ 22 | exports.findSecretIds = deployStage => { 23 | return [`${deployStage}/qiita-stocker`] 24 | } 25 | 26 | /** 27 | * AWSのプロファイル名を取得する 28 | * 29 | * @param deployStage 30 | * @return {string} 31 | */ 32 | exports.findAwsProfile = deployStage => { 33 | if (deployStage === 'prod') { 34 | return 'qiita-stocker-prod' 35 | } 36 | 37 | return 'qiita-stocker-dev' 38 | } 39 | 40 | /** 41 | * デプロイ先のS3Bucket名を取得する 42 | * 43 | * @param deployStage 44 | * @return {string} 45 | */ 46 | exports.findDeployS3Bucket = deployStage => { 47 | return `${deployStage}-qiita-stocker-nuxt` 48 | } 49 | -------------------------------------------------------------------------------- /envUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 許可されたデプロイステージかどうか判定する 3 | * 4 | * @param deployStage 5 | * @return {boolean} 6 | */ 7 | export const isAllowedDeployStage = (deployStage: string): boolean => 8 | deployStage === 'local' || 9 | deployStage === 'dev' || 10 | deployStage === 'stg' || 11 | deployStage === 'prod' 12 | 13 | /** 14 | * AWSのプロファイル名を取得する 15 | * 16 | * @return {string} 17 | */ 18 | export const findAwsProfile = (deployStage: string): string => { 19 | if (deployStage === 'prod') { 20 | return 'qiita-stocker-prod' 21 | } 22 | 23 | return 'qiita-stocker-dev' 24 | } 25 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleNameMapper: { 3 | '^@/(.*)$': '/app/$1', 4 | '^~/(.*)$': '/app/$1', 5 | '^vue$': 'vue/dist/vue.common.js' 6 | }, 7 | moduleFileExtensions: ['js', 'ts', 'vue', 'json'], 8 | transform: { 9 | '^.+\\.ts?$': 'ts-jest', 10 | '.*\\.(vue)$': 'vue-jest' 11 | }, 12 | collectCoverageFrom: [ 13 | '/app/components/**/*.vue', 14 | '/app/pages/**/*.vue' 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["app", "server"], 3 | "ext": "ts", 4 | "exec": "ts-node --project tsconfig.server.json ./server/server.ts" 5 | } 6 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import { Configuration } from '@nuxt/types' 2 | 3 | require('dotenv').config() 4 | 5 | const nuxtConfig: Configuration = { 6 | buildModules: ['@nuxt/typescript-build'], 7 | mode: 'universal', 8 | srcDir: 'app', 9 | env: { 10 | apiUrlBase: process.env.API_URL_BASE || 'http://localhost:3000', 11 | trackingId: process.env.TRACKING_ID || '' 12 | }, 13 | router: { 14 | middleware: ['authCookieMiddleware', 'redirectMiddleware'], 15 | extendRoutes(routes: any, resolve) { 16 | routes.push({ 17 | name: 'original_error', 18 | path: '/error', 19 | props: true, 20 | component: resolve(__dirname, 'app/pages/error.vue') 21 | }) 22 | } 23 | }, 24 | render: { 25 | compressor: (_req, _res, next) => { 26 | next() 27 | } 28 | }, 29 | /* 30 | ** Headers of the page 31 | */ 32 | head: { 33 | htmlAttrs: { 34 | prefix: 35 | 'og: http://ogp.me/ns# fb: http://ogp.me/ns/fb# website: http://ogp.me/ns/website#' 36 | }, 37 | title: 'Mindexer | Qiitaのストックを整理するためのサービスです', 38 | meta: [ 39 | { charset: 'utf-8' }, 40 | { name: 'viewport', content: 'width=device-width, initial-scale=1' }, 41 | { 42 | hid: 'description', 43 | name: 'description', 44 | content: 45 | 'Mindexerは、Qiitaのストックにカテゴリ機能を追加したサービスです。' 46 | }, 47 | { hid: 'og:url', property: 'og:url', content: `${process.env.APP_URL}` }, 48 | { hid: 'og:type', property: 'og:type', content: 'website' }, 49 | { 50 | hid: 'og:title', 51 | property: 'og:title', 52 | content: 'Mindexer | Qiitaのストックを整理するためのサービスです' 53 | }, 54 | { 55 | hid: 'og:description', 56 | property: 'og:description', 57 | content: 58 | 'Mindexerは、Qiitaのストックにカテゴリ機能を追加したサービスです。' 59 | }, 60 | { hid: 'og:site_name', property: 'og:site_name', content: 'Mindexer' }, 61 | { 62 | hid: 'og:image', 63 | property: 'og:image', 64 | content: `${process.env.APP_URL}/assets/ogp.png` 65 | }, 66 | { 67 | hid: 'twitter:card', 68 | property: 'twitter:card', 69 | content: 'summary_large_image' 70 | }, 71 | { 72 | hid: 'twitter:site', 73 | property: 'twitter:site', 74 | content: '@mindexer_org' 75 | }, 76 | 77 | { 78 | hid: 'google-site-verification', 79 | property: 'google-site-verification', 80 | content: `${process.env.GOOGLE_SITE_VERIFICATION}` 81 | } 82 | ], 83 | link: [{ rel: 'icon', type: 'image/x-icon', href: '/assets/favicon.ico' }] 84 | }, 85 | 86 | /* 87 | ** Customize the progress-bar color 88 | */ 89 | loading: { color: '#fff' }, 90 | 91 | /* 92 | ** Global CSS 93 | */ 94 | css: ['@fortawesome/fontawesome-free/css/all.css', '@/assets/style.scss'], 95 | 96 | /* 97 | ** Plugins to load before mounting the App 98 | */ 99 | plugins: [{ src: '@/plugins/ga.js', mode: 'client' }], 100 | /* 101 | ** Nuxt.js modules 102 | */ 103 | modules: ['@nuxtjs/markdownit'], 104 | markdownit: { 105 | injected: true, 106 | breaks: true, 107 | html: true, 108 | linkify: true, 109 | typography: true, 110 | quotes: '“”‘’' 111 | }, 112 | /* 113 | ** Build configuration 114 | */ 115 | build: { 116 | postcss: { 117 | preset: { 118 | features: { 119 | customProperties: false 120 | } 121 | } 122 | }, 123 | /* 124 | ** You can extend webpack config here 125 | */ 126 | extend(config, ctx) { 127 | // Run ESLint on save 128 | if (ctx.isDev && ctx.isClient) { 129 | if (!config.module) return 130 | config.module.rules.push({ 131 | enforce: 'pre', 132 | test: /\.(js|ts|vue)$/, 133 | loader: 'eslint-loader', 134 | exclude: /(node_modules)/ 135 | }) 136 | } 137 | } 138 | } 139 | } 140 | 141 | export default nuxtConfig 142 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "qiita-stocker-frontend", 3 | "version": "1.0.0", 4 | "description": "Mindexer frontend project", 5 | "private": true, 6 | "scripts": { 7 | "dev": "cross-env NODE_ENV=development NUXT_HOST=127.0.0.1 NUXT_PORT=8080 nodemon", 8 | "build": "rm -rf .nuxt dist && tsc -p tsconfig.server.json && nuxt-ts build", 9 | "start": "cross-env NODE_ENV=production node dist/server/server.js", 10 | "generate": "nuxt-ts generate", 11 | "lint": "eslint --ext .ts,.js,.vue --ignore-path .gitignore .", 12 | "format": "eslint --fix --ext .ts,.js,.vue --ignore-path .gitignore .", 13 | "precommit": "npm run lint", 14 | "test": "jest", 15 | "deployToS3:dev": "DEPLOY_STAGE=dev node deployToS3.js", 16 | "deployToS3:stg": "DEPLOY_STAGE=stg node deployToS3.js", 17 | "deployToS3:prod": "DEPLOY_STAGE=prod node deployToS3.js", 18 | "deploy:dev": "STAGE=dev NODE_ENV=production sls deploy -v", 19 | "deploy:stg": "STAGE=stg NODE_ENV=production sls deploy -v", 20 | "deploy:prod": "STAGE=prod NODE_ENV=production sls deploy -v", 21 | "remove:dev": "STAGE=dev sls remove -v", 22 | "remove:stg": "STAGE=stg sls remove -v", 23 | "remove:prod": "STAGE=prod sls remove -v", 24 | "createEnvrc:local": "DEPLOY_STAGE=local ts-node --project tsconfig.server.json createEnv.ts", 25 | "createEnvrc:dev": "DEPLOY_STAGE=dev ts-node --project tsconfig.server.json createEnv.ts", 26 | "createEnvrc:stg": "DEPLOY_STAGE=stg ts-node --project tsconfig.server.json createEnv.ts", 27 | "createEnvrc:prod": "DEPLOY_STAGE=prod ts-node --project tsconfig.server.json createEnv.ts" 28 | }, 29 | "dependencies": { 30 | "@fortawesome/fontawesome-free": "^5.12.0", 31 | "@nuxt/typescript-build": "^0.5.4", 32 | "@nuxt/typescript-runtime": "^0.3.5", 33 | "@nuxtjs/markdownit": "^1.2.7", 34 | "aws-serverless-express": "^3.3.6", 35 | "axios": "^0.21.2", 36 | "bulma": "^0.8.0", 37 | "cookie-parser": "^1.4.4", 38 | "cross-env": "^5.2.1", 39 | "dotenv": "^8.2.0", 40 | "express": "^4.17.1", 41 | "nuxt": "^2.11.0", 42 | "nuxt-property-decorator": "^2.5.0", 43 | "universal-cookie": "^4.0.3", 44 | "uuid": "^3.3.3", 45 | "vuex-type-helper": "^1.2.2" 46 | }, 47 | "devDependencies": { 48 | "@nekonomokochan/aws-env-creator": "^2.0.2", 49 | "@nuxtjs/eslint-config-typescript": "^1.0.0", 50 | "@types/aws-serverless-express": "^3.3.2", 51 | "@types/cookie-parser": "^1.4.2", 52 | "@types/dotenv": "^6.1.1", 53 | "@types/jest": "^24.0.25", 54 | "@types/universal-cookie": "^2.2.0", 55 | "@types/uuid": "^3.4.6", 56 | "@vue/test-utils": "^1.0.0-beta.30", 57 | "aws-sdk": "^2.814.0", 58 | "eslint": "^6.8.0", 59 | "eslint-config-prettier": "^6.9.0", 60 | "eslint-loader": "^3.0.3", 61 | "eslint-plugin-nuxt": "^0.5.0", 62 | "eslint-plugin-prettier": "^3.1.2", 63 | "eslint-plugin-vue": "^6.1.1", 64 | "jest": "^24.9.0", 65 | "node-sass": "^7.0.0", 66 | "nodemon": "^2.0.2", 67 | "prettier": "^1.19.1", 68 | "s3": "^4.4.0", 69 | "sass-loader": "^8.0.0", 70 | "serverless": "^1.60.4", 71 | "ts-jest": "^24.2.0", 72 | "ts-loader": "^6.2.1", 73 | "ts-node": "^8.5.4", 74 | "typescript": "^3.7.4", 75 | "vue-jest": "^3.0.5" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /server/api/qiita.ts: -------------------------------------------------------------------------------- 1 | import { Router, Request, Response } from 'express' 2 | import * as qiita from '../domain/qiita' 3 | import * as auth from '../domain/auth' 4 | 5 | const router = Router() 6 | 7 | router.get('/cancel', async (req: Request, res: Response) => { 8 | try { 9 | await qiita.cancelAccount(req.cookies[auth.COOKIE_SESSION_ID]) 10 | res.clearCookie(auth.COOKIE_SESSION_ID) 11 | return res.status(204).json() 12 | } catch (error) { 13 | return res 14 | .status(error.response.status) 15 | .json(error.response.data) 16 | .end() 17 | } 18 | }) 19 | 20 | router.get('/logout', async (req: Request, res: Response) => { 21 | try { 22 | await qiita.logout(req.cookies[auth.COOKIE_SESSION_ID]) 23 | res.clearCookie(auth.COOKIE_SESSION_ID) 24 | return res.status(204).json() 25 | } catch (error) { 26 | return res 27 | .status(error.response.status) 28 | .json(error.response.data) 29 | .end() 30 | } 31 | }) 32 | 33 | export default router 34 | -------------------------------------------------------------------------------- /server/app.ts: -------------------------------------------------------------------------------- 1 | import express, { Router } from 'express' 2 | import cookieParser from 'cookie-parser' 3 | import qiita from './api/qiita' 4 | import oauth from './auth/oauth' 5 | import { nuxt } from './core/nuxt' 6 | 7 | const app = express() 8 | const router = Router() 9 | 10 | router.use(oauth) 11 | router.use(qiita) 12 | 13 | app.use(cookieParser()) 14 | // BFF 15 | app.use('/api', router) 16 | app.use('/oauth', router) 17 | 18 | app.use(async (req, res, next) => { 19 | await nuxt.ready() 20 | nuxt.render(req, res, next) 21 | }) 22 | 23 | export default app 24 | -------------------------------------------------------------------------------- /server/auth/oauth.ts: -------------------------------------------------------------------------------- 1 | import { Router, Request, Response } from 'express' 2 | import * as auth from '../domain/auth' 3 | import { stage } from '../constants/envConstant' 4 | 5 | const router = Router() 6 | 7 | router.get('/request/signup', (_req: Request, res: Response) => { 8 | const authorizationState = auth.createAuthorizationState() 9 | const authorizationUrl = auth.createAuthorizationUrl(authorizationState) 10 | 11 | res.cookie(auth.COOKIE_AUTH_STATE, authorizationState, { 12 | path: '/', 13 | httpOnly: true 14 | }) 15 | 16 | res.cookie(auth.COOKIE_ACCOUNT_ACTION, 'signUp', { 17 | path: '/', 18 | httpOnly: true 19 | }) 20 | 21 | return res.redirect(302, authorizationUrl) 22 | }) 23 | 24 | router.get('/request/login', (_req: Request, res: Response) => { 25 | const authorizationState = auth.createAuthorizationState() 26 | const authorizationUrl = auth.createAuthorizationUrl(authorizationState) 27 | 28 | res.cookie(auth.COOKIE_AUTH_STATE, authorizationState, { 29 | path: '/', 30 | httpOnly: true 31 | }) 32 | 33 | res.cookie(auth.COOKIE_ACCOUNT_ACTION, 'login', { 34 | path: '/', 35 | httpOnly: true 36 | }) 37 | 38 | return res.redirect(302, authorizationUrl) 39 | }) 40 | 41 | router.get('/callback', async (req: Request, res: Response) => { 42 | if ( 43 | req.cookies[auth.COOKIE_AUTH_STATE] == null || 44 | req.cookies[auth.COOKIE_AUTH_STATE] !== req.query.state 45 | ) { 46 | return res.redirect(auth.redirectAppErrorUrl()) 47 | } 48 | 49 | if (req.query.code == null) return res.redirect(auth.redirectAppErrorUrl()) 50 | 51 | if ( 52 | req.cookies[auth.COOKIE_ACCOUNT_ACTION] !== 'signUp' && 53 | req.cookies[auth.COOKIE_ACCOUNT_ACTION] !== 'login' 54 | ) { 55 | return res.redirect(auth.redirectAppErrorUrl()) 56 | } 57 | 58 | try { 59 | const sessionId = await auth.fetchSessionId( 60 | req.query.code, 61 | req.cookies[auth.COOKIE_ACCOUNT_ACTION] 62 | ) 63 | res.clearCookie(auth.COOKIE_AUTH_STATE) 64 | res.clearCookie(auth.COOKIE_ACCOUNT_ACTION) 65 | 66 | const isLocal = stage() === 'local' 67 | const cookieOptions = auth.loginSessionCookieOptions(isLocal) 68 | 69 | res.cookie(auth.COOKIE_SESSION_ID, sessionId, cookieOptions) 70 | 71 | return res.redirect(auth.redirectAppUrl()) 72 | } catch (error) { 73 | return res.redirect(auth.redirectAppErrorUrl()) 74 | } 75 | }) 76 | 77 | export default router 78 | -------------------------------------------------------------------------------- /server/constants/envConstant.ts: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | 3 | export const clientId = (): string => { 4 | return typeof process.env.QIITA_CLIENT_ID === 'string' 5 | ? process.env.QIITA_CLIENT_ID 6 | : '' 7 | } 8 | 9 | export const clientSecret = (): string => { 10 | return typeof process.env.QIITA_CLIENT_SECRET === 'string' 11 | ? process.env.QIITA_CLIENT_SECRET 12 | : '' 13 | } 14 | 15 | export const apiUrlBase = (): string => { 16 | return typeof process.env.API_URL_BASE === 'string' 17 | ? process.env.API_URL_BASE 18 | : '' 19 | } 20 | 21 | export const appUrl = (): string => { 22 | return typeof process.env.APP_URL === 'string' ? process.env.APP_URL : '' 23 | } 24 | 25 | export const stage = (): string => { 26 | return typeof process.env.STAGE === 'string' ? process.env.STAGE : 'local' 27 | } 28 | -------------------------------------------------------------------------------- /server/core/nuxt.ts: -------------------------------------------------------------------------------- 1 | import config from '../../nuxt.config' 2 | const { Nuxt } = require('nuxt') 3 | 4 | config.dev = !(process.env.NODE_ENV === 'production') 5 | 6 | if (config.dev) { 7 | config.extensions = ['ts'] 8 | } 9 | 10 | export const nuxt = new Nuxt(config) 11 | 12 | export default config 13 | -------------------------------------------------------------------------------- /server/domain/auth.ts: -------------------------------------------------------------------------------- 1 | import url from 'url' 2 | import uuid from 'uuid' 3 | import { AxiosResponse, AxiosError } from 'axios' 4 | import { CookieOptions } from 'express' 5 | import QiitaApiFactory from '../factroy/api/qiitaApiFactory' 6 | import QiitaStockerApiFactory from '../factroy/api/qiitaStockerApiFactory' 7 | import { 8 | clientId, 9 | clientSecret, 10 | apiUrlBase, 11 | appUrl 12 | } from '../constants/envConstant' 13 | 14 | const qiitaApi = QiitaApiFactory.create() 15 | const qiitaStockerApi = QiitaStockerApiFactory.create() 16 | 17 | export const COOKIE_AUTH_STATE = 'authorizationState' 18 | export const COOKIE_ACCOUNT_ACTION = 'accountAction' 19 | export const COOKIE_SESSION_ID = 'sessionId' 20 | export type accountAction = 'signUp' | 'login' 21 | 22 | type QiitaStockerErrorData = { 23 | code: number 24 | message: string 25 | } 26 | 27 | export type QiitaStockerError = AxiosError & { 28 | response: AxiosResponse 29 | } 30 | 31 | export type IssueAccessTokensRequest = { 32 | client_id: string 33 | client_secret: string 34 | code: string 35 | } 36 | 37 | export type IssueAccessTokensResponse = { 38 | client_id: string 39 | scopes: string[] 40 | token: string 41 | } 42 | 43 | export type FetchAuthenticatedUserRequest = { 44 | accessToken: string 45 | } 46 | 47 | export type FetchAuthenticatedUserResponse = { 48 | id: string 49 | permanent_id: string 50 | } 51 | 52 | export type CreateAccountRequest = { 53 | apiUrlBase: string 54 | qiitaAccountId: string 55 | permanentId: string 56 | accessToken: string 57 | } 58 | 59 | export type CreateAccountResponse = { 60 | accountId: string 61 | _embedded: { sessionId: string } 62 | } 63 | 64 | export type IssueLoginSessionRequest = { 65 | apiUrlBase: string 66 | qiitaAccountId: string 67 | permanentId: string 68 | accessToken: string 69 | } 70 | 71 | export type IssueLoginSessionResponse = { 72 | sessionId: string 73 | } 74 | 75 | /** 76 | * @return {string} 77 | */ 78 | export const createAuthorizationState = (): string => { 79 | return uuid.v4() 80 | } 81 | 82 | /** 83 | * @param authorizationState 84 | * @return {string} 85 | */ 86 | export const createAuthorizationUrl = (authorizationState: string): string => { 87 | return url.format({ 88 | protocol: 'https', 89 | host: 'qiita.com', 90 | pathname: '/api/v2/oauth/authorize', 91 | query: { 92 | client_id: clientId(), 93 | scope: 'read_qiita', 94 | state: authorizationState 95 | } 96 | }) 97 | } 98 | 99 | export const redirectAppUrl = (): string => { 100 | return `${appUrl()}/stocks/all` 101 | } 102 | 103 | export const redirectAppErrorUrl = (): string => { 104 | return `${appUrl()}/error` 105 | } 106 | 107 | /** 108 | * @param authorizationCode 109 | * @param accountAction 110 | * @return {Promise} 111 | */ 112 | export const fetchSessionId = async ( 113 | authorizationCode: string, 114 | accountAction: accountAction 115 | ): Promise => { 116 | const issueAccessTokensRequest: IssueAccessTokensRequest = { 117 | client_id: clientId(), 118 | client_secret: clientSecret(), 119 | code: authorizationCode 120 | } 121 | 122 | const issueAccessTokenResponse = await qiitaApi.issueAccessToken( 123 | issueAccessTokensRequest 124 | ) 125 | 126 | const fetchAuthenticatedUserRequest: FetchAuthenticatedUserRequest = { 127 | accessToken: issueAccessTokenResponse.token 128 | } 129 | 130 | const authenticatedUser = await qiitaApi.fetchAuthenticatedUser( 131 | fetchAuthenticatedUserRequest 132 | ) 133 | 134 | let sessionId = '' 135 | 136 | switch (accountAction) { 137 | case 'signUp': { 138 | sessionId = await createAccount( 139 | issueAccessTokenResponse.token, 140 | authenticatedUser 141 | ) 142 | break 143 | } 144 | case 'login': { 145 | sessionId = await issueLoginSession( 146 | issueAccessTokenResponse.token, 147 | authenticatedUser 148 | ) 149 | break 150 | } 151 | default: { 152 | const _: never = accountAction 153 | sessionId = _ 154 | break 155 | } 156 | } 157 | return sessionId 158 | } 159 | 160 | const createAccount = async ( 161 | token: string, 162 | authenticatedUser: FetchAuthenticatedUserResponse 163 | ): Promise => { 164 | const createAccountRequest: CreateAccountRequest = { 165 | apiUrlBase: apiUrlBase(), 166 | qiitaAccountId: authenticatedUser.id, 167 | permanentId: authenticatedUser.permanent_id, 168 | accessToken: token 169 | } 170 | 171 | const createAccountResponse = await qiitaStockerApi.createAccount( 172 | createAccountRequest 173 | ) 174 | 175 | return createAccountResponse._embedded.sessionId 176 | } 177 | 178 | const issueLoginSession = async ( 179 | token: string, 180 | authenticatedUser: FetchAuthenticatedUserResponse 181 | ): Promise => { 182 | const issueLoginSessionRequest: IssueLoginSessionRequest = { 183 | apiUrlBase: apiUrlBase(), 184 | qiitaAccountId: authenticatedUser.id, 185 | permanentId: authenticatedUser.permanent_id, 186 | accessToken: token 187 | } 188 | 189 | const issueLoginSessionResponse = await qiitaStockerApi.issueLoginSession( 190 | issueLoginSessionRequest 191 | ) 192 | return issueLoginSessionResponse.sessionId 193 | } 194 | 195 | export const loginSessionCookieOptions = (isLocal = false): CookieOptions => { 196 | const nowDate = new Date() 197 | // ログインセッションが無効になるのは30日後なのでそれより早めに29日でCookieは無効にする 198 | nowDate.setDate(nowDate.getDate() + 29) 199 | 200 | const secure = !isLocal 201 | 202 | return { 203 | expires: nowDate, 204 | httpOnly: true, 205 | path: '/', 206 | secure 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /server/domain/qiita.ts: -------------------------------------------------------------------------------- 1 | import QiitaStockerApiFactory from '../factroy/api/qiitaStockerApiFactory' 2 | import { apiUrlBase } from '../constants/envConstant' 3 | 4 | const qiitaStockerApi = QiitaStockerApiFactory.create() 5 | 6 | export type CancelAccountRequest = { 7 | apiUrlBase: string 8 | sessionId: string 9 | } 10 | export type LogoutRequest = { 11 | apiUrlBase: string 12 | sessionId: string 13 | } 14 | 15 | export const cancelAccount = (sessionId: string): Promise => { 16 | const cancelAccountRequest: CancelAccountRequest = { 17 | apiUrlBase: apiUrlBase(), 18 | sessionId 19 | } 20 | 21 | return qiitaStockerApi.cancelAccount(cancelAccountRequest) 22 | } 23 | 24 | export const logout = (sessionId: string): Promise => { 25 | const logoutRequest: LogoutRequest = { 26 | apiUrlBase: apiUrlBase(), 27 | sessionId 28 | } 29 | 30 | return qiitaStockerApi.logout(logoutRequest) 31 | } 32 | -------------------------------------------------------------------------------- /server/domain/qiitaApiInterface.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FetchAuthenticatedUserRequest, 3 | FetchAuthenticatedUserResponse, 4 | IssueAccessTokensRequest, 5 | IssueAccessTokensResponse 6 | } from './auth' 7 | 8 | export type Api = { 9 | issueAccessToken( 10 | request: IssueAccessTokensRequest 11 | ): Promise 12 | fetchAuthenticatedUser( 13 | request: FetchAuthenticatedUserRequest 14 | ): Promise 15 | } 16 | -------------------------------------------------------------------------------- /server/domain/qiitaStockerApiInterface.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CreateAccountRequest, 3 | CreateAccountResponse, 4 | IssueLoginSessionRequest, 5 | IssueLoginSessionResponse 6 | } from './auth' 7 | 8 | import { CancelAccountRequest, LogoutRequest } from './qiita' 9 | 10 | export type Api = { 11 | createAccount(request: CreateAccountRequest): Promise 12 | issueLoginSession( 13 | request: IssueLoginSessionRequest 14 | ): Promise 15 | cancelAccount(request: CancelAccountRequest): Promise 16 | logout(request: LogoutRequest): Promise 17 | } 18 | -------------------------------------------------------------------------------- /server/factroy/api/qiitaApiFactory.ts: -------------------------------------------------------------------------------- 1 | import QiitaApi from '../../repositories/qiitaApi' 2 | import { Api } from '../../domain/qiitaApiInterface' 3 | 4 | export default class QiitaApiFactory { 5 | static create(): Api { 6 | return new QiitaApi() 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /server/factroy/api/qiitaStockerApiFactory.ts: -------------------------------------------------------------------------------- 1 | import QiitaStockerApi from '../../repositories/qiitaStockerApi' 2 | import { Api } from '../../domain/qiitaStockerApiInterface' 3 | 4 | export default class QiitaStockerApiFactory { 5 | static create(): Api { 6 | return new QiitaStockerApi() 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /server/lambda.ts: -------------------------------------------------------------------------------- 1 | import lambda from 'aws-lambda' 2 | import awsServerlessExpress from 'aws-serverless-express' 3 | import app from './app' 4 | 5 | const server = awsServerlessExpress.createServer(app) 6 | 7 | module.exports.render = ( 8 | event: lambda.APIGatewayEvent, 9 | context: lambda.Context 10 | ) => { 11 | awsServerlessExpress.proxy(server, event, context) 12 | } 13 | -------------------------------------------------------------------------------- /server/repositories/qiitaApi.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosResponse, AxiosError } from 'axios' 2 | import { Api } from '../domain/qiitaApiInterface' 3 | import { 4 | IssueAccessTokensRequest, 5 | IssueAccessTokensResponse, 6 | FetchAuthenticatedUserResponse, 7 | FetchAuthenticatedUserRequest 8 | } from '../domain/auth' 9 | 10 | export default class QiitaApi implements Api { 11 | /** 12 | * @param request 13 | * @return {Promise} 14 | */ 15 | issueAccessToken( 16 | request: IssueAccessTokensRequest 17 | ): Promise { 18 | return axios 19 | .post( 20 | `https://qiita.com/api/v2/access_tokens`, 21 | request 22 | ) 23 | .then((axiosResponse: AxiosResponse) => { 24 | return Promise.resolve(axiosResponse.data) 25 | }) 26 | .catch((axiosError: AxiosError) => { 27 | return Promise.reject(axiosError) 28 | }) 29 | } 30 | 31 | /** 32 | * @param request 33 | * @return {Promise} 34 | */ 35 | fetchAuthenticatedUser( 36 | request: FetchAuthenticatedUserRequest 37 | ): Promise { 38 | return axios 39 | .get( 40 | `https://qiita.com/api/v2/authenticated_user`, 41 | { 42 | headers: { Authorization: `Bearer ${request.accessToken}` } 43 | } 44 | ) 45 | .then((axiosResponse: AxiosResponse) => { 46 | return Promise.resolve(axiosResponse.data) 47 | }) 48 | .catch((axiosError: AxiosError) => { 49 | return Promise.reject(axiosError) 50 | }) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /server/repositories/qiitaStockerApi.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosResponse } from 'axios' 2 | import { Api } from '../domain/qiitaStockerApiInterface' 3 | import { 4 | CreateAccountRequest, 5 | CreateAccountResponse, 6 | QiitaStockerError, 7 | IssueLoginSessionRequest, 8 | IssueLoginSessionResponse 9 | } from '../domain/auth' 10 | import { CancelAccountRequest, LogoutRequest } from '../domain/qiita' 11 | 12 | export default class QiitaStockerApi implements Api { 13 | createAccount(request: CreateAccountRequest): Promise { 14 | return axios 15 | .post( 16 | `${request.apiUrlBase}/api/accounts`, 17 | { 18 | qiitaAccountId: request.qiitaAccountId, 19 | permanentId: request.permanentId, 20 | accessToken: request.accessToken 21 | }, 22 | { 23 | headers: { 24 | 'Content-Type': 'application/json' 25 | } 26 | } 27 | ) 28 | .then((axiosResponse: AxiosResponse) => { 29 | return Promise.resolve(axiosResponse.data) 30 | }) 31 | .catch((axiosError: QiitaStockerError) => { 32 | return Promise.reject(axiosError) 33 | }) 34 | } 35 | 36 | issueLoginSession( 37 | request: IssueLoginSessionRequest 38 | ): Promise { 39 | return axios 40 | .post( 41 | `${request.apiUrlBase}/api/login-sessions`, 42 | { 43 | qiitaAccountId: request.qiitaAccountId, 44 | permanentId: request.permanentId, 45 | accessToken: request.accessToken 46 | }, 47 | { 48 | headers: { 49 | 'Content-Type': 'application/json' 50 | } 51 | } 52 | ) 53 | .then((axiosResponse: AxiosResponse) => { 54 | return Promise.resolve(axiosResponse.data) 55 | }) 56 | .catch((axiosError: QiitaStockerError) => { 57 | return Promise.reject(axiosError) 58 | }) 59 | } 60 | 61 | cancelAccount(request: CancelAccountRequest): Promise { 62 | return axios 63 | .delete(`${request.apiUrlBase}/api/accounts`, { 64 | headers: { 65 | Authorization: `Bearer ${request.sessionId}` 66 | } 67 | }) 68 | .then(() => { 69 | return Promise.resolve() 70 | }) 71 | .catch((axiosError: QiitaStockerError) => { 72 | return Promise.reject(axiosError) 73 | }) 74 | } 75 | 76 | logout(request: LogoutRequest): Promise { 77 | return axios 78 | .delete(`${request.apiUrlBase}/api/login-sessions`, { 79 | headers: { 80 | Authorization: `Bearer ${request.sessionId}` 81 | } 82 | }) 83 | .then(() => { 84 | return Promise.resolve() 85 | }) 86 | .catch((axiosError: QiitaStockerError) => { 87 | return Promise.reject(axiosError) 88 | }) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /server/server.ts: -------------------------------------------------------------------------------- 1 | import consola from 'consola' 2 | import app from './app' 3 | import config, { nuxt } from './core/nuxt' 4 | const { Builder } = require('nuxt') 5 | 6 | const start = async () => { 7 | await nuxt.ready() 8 | 9 | // Build only in dev mode 10 | if (config.dev) { 11 | const builder = new Builder(nuxt) 12 | await builder.build() 13 | } 14 | 15 | app.listen(8080, '127.0.0.1') 16 | consola.ready({ 17 | message: `Server listening on http://127.0.0.1:8080`, 18 | badge: true 19 | }) 20 | } 21 | start() 22 | -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | service: qiita-stocker-nuxt 2 | 3 | provider: 4 | name: aws 5 | runtime: nodejs12.x 6 | stage: ${env:STAGE} 7 | region: ap-northeast-1 8 | logRetentionInDays: 30 9 | iamRoleStatements: 10 | - Effect: 'Allow' 11 | Action: 12 | - 'lambda:InvokeFunction' 13 | Resource: 14 | - Fn::Join: 15 | - ':' 16 | - - arn:aws:lambda 17 | - Ref: AWS::Region 18 | - Ref: AWS::AccountId 19 | - function:${self:service}-${opt:stage, self:provider.stage}-* 20 | environment: 21 | STAGE: ${env:STAGE} 22 | NODE_ENV: ${env:NODE_ENV} 23 | QIITA_CLIENT_ID: ${env:QIITA_CLIENT_ID} 24 | QIITA_CLIENT_SECRET: ${env:QIITA_CLIENT_SECRET} 25 | API_URL_BASE: ${env:API_URL_BASE} 26 | APP_URL: ${env:APP_URL} 27 | GOOGLE_SITE_VERIFICATION: ${env:GOOGLE_SITE_VERIFICATION} 28 | TRACKING_ID: ${env:TRACKING_ID} 29 | 30 | package: 31 | individually: true 32 | exclude: 33 | - .git/** 34 | - .github/** 35 | - .idea/** 36 | - coverage/** 37 | - node_modules/.bin/** 38 | - node_modules/.cache/** 39 | - node_modules/.cache/** 40 | - app/** 41 | - server/** 42 | - test/** 43 | - .editorconfig 44 | - .gitignore 45 | - .prettierrc 46 | - createEnv.ts 47 | - envUtils.ts 48 | - jest.config.js 49 | - LICENSE 50 | - nodemon.json 51 | - package.json 52 | - qiita-stocker-frontend.iml 53 | - README.md 54 | - tsconfig.json 55 | - tsconfig.server.json 56 | - yarn-error.log 57 | - yarn.lock 58 | 59 | functions: 60 | render: 61 | handler: dist/server/lambda.render 62 | events: 63 | - http: 64 | path: '/' 65 | method: get 66 | - http: 67 | path: '{proxy+}' 68 | method: get 69 | - http: 70 | path: '/api/{proxy+}' 71 | method: any 72 | provisionedConcurrency: 1 73 | -------------------------------------------------------------------------------- /test/Logo.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import Logo from '@/components/Logo.vue' 3 | 4 | describe('Logo', () => { 5 | test('is a Vue instance', () => { 6 | const wrapper = mount(Logo) 7 | expect(wrapper.isVueInstance()).toBeTruthy() 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "moduleResolution": "node", 5 | "target": "esnext", 6 | "sourceMap": true, 7 | "removeComments": true, 8 | "experimentalDecorators": true, 9 | "noUnusedLocals": true, 10 | "allowUnreachableCode": false, 11 | "allowUnusedLabels": false, 12 | "allowSyntheticDefaultImports": true, 13 | "allowJs": true, 14 | "strict": true, 15 | "noImplicitReturns": true, 16 | "esModuleInterop": true, 17 | "skipLibCheck": true, 18 | "baseUrl": ".", 19 | "rootDir": ".", 20 | "paths": { 21 | "@/*": [ 22 | "./app/*" 23 | ] 24 | }, 25 | "types": [ 26 | "@types/node", 27 | "@nuxt/types", 28 | "@types/jest" 29 | ], 30 | "typeRoots": [ 31 | "app/types" 32 | ], 33 | "lib": [ 34 | "dom", 35 | "esnext", 36 | "esnext.asynciterable" 37 | ] 38 | }, 39 | "exclude": [ 40 | "node_modules" 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /tsconfig.server.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "target": "es2017", 6 | "outDir": "dist" 7 | }, 8 | "include": [ 9 | "server/server.ts", 10 | "server/lambda.ts" 11 | ] 12 | } 13 | --------------------------------------------------------------------------------