├── .circleci └── config.yml ├── .env.development ├── .env.production ├── .env.test ├── .eslintrc.json ├── .firebaserc ├── .gitignore ├── .storybook ├── addons.js ├── config.js └── webpack.config.js ├── LICENSE ├── README.md ├── config-overrides.js ├── firebase.json ├── firestore.indexes.json ├── firestore.rules ├── functions ├── .eslintrc.json ├── .gitignore ├── build │ └── css │ │ ├── mail.css │ │ └── report.css ├── emails │ ├── report │ │ ├── html.pug │ │ └── subject.pug │ └── welcome │ │ ├── html.pug │ │ └── subject.pug ├── package.json ├── src │ ├── consts │ │ ├── count-type.ts │ │ ├── feed-type.ts │ │ ├── feeds │ │ │ ├── atom.ts │ │ │ ├── feed.ts │ │ │ ├── rss1.ts │ │ │ └── rss2.ts │ │ └── fetch-response │ │ │ └── hatena-star.ts │ ├── entities.ts │ ├── fetcher.ts │ ├── fetchers │ │ ├── count-fetchers │ │ │ ├── count-jsoon-fetcher.ts │ │ │ ├── facebook-fetcher.ts │ │ │ ├── hatenabookmark-fetcher.ts │ │ │ ├── hatenastar-fetcher.ts │ │ │ └── pocket-fetcher.ts │ │ └── feed-fetcher.ts │ ├── firebase.ts │ ├── index.ts │ ├── mail-ga.ts │ ├── mail-transport.ts │ ├── pubsub.ts │ ├── repositories │ │ └── mail-lock-repository.ts │ ├── responses.ts │ ├── send-daily-report-mail.ts │ ├── send-welcome-mail.ts │ └── sleep.ts ├── tsconfig.json └── yarn.lock ├── package.json ├── public ├── .well-known │ └── assetlinks.json ├── apple-touch-icon.png ├── browserconfig.xml ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── icon-192x192.png ├── icon-512x512.png ├── images │ ├── facebook-icon.png │ ├── hatenabookmark-icon.png │ ├── hatenastar-icon.png │ ├── pocket-icon.png │ ├── twincle-perticle.svg │ ├── twitter-icon.png │ └── welcome-image.png ├── index.html ├── manifest.json ├── mstile-150x150.png └── safari-pinned-tab.svg ├── renovate.json ├── src ├── App.tsx ├── __tests__ │ ├── App.test.tsx │ ├── firebase-rules.test.ts │ └── save-count-response.test.ts ├── assets │ └── images │ │ ├── facebook-icon.png │ │ ├── feedback_icon120.png │ │ ├── feedback_icon160.png │ │ ├── hatenabookmark-icon.png │ │ ├── hatenastar-icon.png │ │ ├── pocket-icon.png │ │ ├── twincle-perticle.svg │ │ ├── twitter-icon.png │ │ └── welcome-image.png ├── components │ ├── atoms │ │ ├── Anker │ │ │ └── index.tsx │ │ ├── Button │ │ │ ├── index.stories.tsx │ │ │ └── index.tsx │ │ ├── CountUpAnimation │ │ │ ├── index.stories.tsx │ │ │ └── index.tsx │ │ ├── Favicon │ │ │ ├── index.stories.tsx │ │ │ └── index.tsx │ │ ├── ScrollView │ │ │ ├── index.stories.tsx │ │ │ └── index.ts │ │ ├── ServiceIcon │ │ │ ├── index.stories.tsx │ │ │ └── index.tsx │ │ ├── Spinner │ │ │ ├── index.stories.tsx │ │ │ └── index.tsx │ │ ├── TwincleAnimation │ │ │ ├── index.stories.tsx │ │ │ └── index.tsx │ │ └── Wrapper │ │ │ └── index.tsx │ ├── base-style.ts │ ├── molecules │ │ ├── CountButton │ │ │ ├── index.stories.tsx │ │ │ └── index.tsx │ │ ├── HeaderLoadingIndicator │ │ │ ├── index.stories.tsx │ │ │ └── index.tsx │ │ ├── LoadingView │ │ │ ├── index.stories.tsx │ │ │ └── index.tsx │ │ └── PlainCell │ │ │ ├── index.stories.tsx │ │ │ └── index.tsx │ ├── organisms │ │ ├── AddBlogForm │ │ │ ├── index.stories.tsx │ │ │ └── index.tsx │ │ ├── AnimatedCountButton │ │ │ ├── index.stories.tsx │ │ │ └── index.tsx │ │ ├── BlogCell │ │ │ ├── index.stories.tsx │ │ │ └── index.tsx │ │ ├── EntryCell │ │ │ ├── index.stories.tsx │ │ │ └── index.tsx │ │ ├── Header │ │ │ ├── index.stories.tsx │ │ │ └── index.tsx │ │ ├── SettingCell │ │ │ └── index.tsx │ │ └── SettingSectionHeader │ │ │ ├── index.stories.tsx │ │ │ └── index.tsx │ ├── pages │ │ ├── AddBlogPage │ │ │ └── index.tsx │ │ ├── AuthPage │ │ │ └── index.tsx │ │ ├── BlogsPage │ │ │ └── index.tsx │ │ ├── FeedPage │ │ │ └── index.tsx │ │ ├── IndexPage │ │ │ └── index.tsx │ │ ├── PrivarcyPage │ │ │ ├── index.stories.tsx │ │ │ └── index.tsx │ │ ├── SettingPage │ │ │ └── index.tsx │ │ ├── SettingsPage │ │ │ └── index.tsx │ │ ├── SignInPage │ │ │ └── index.tsx │ │ ├── TermPage │ │ │ ├── index.stories.tsx │ │ │ └── index.tsx │ │ └── WelcomePage │ │ │ ├── index.stories.tsx │ │ │ └── index.tsx │ ├── properties.ts │ └── templates │ │ ├── PageLayout │ │ └── index.tsx │ │ ├── ScrollToTop │ │ └── index.tsx │ │ └── SmartphoneLayout │ │ ├── index.stories.tsx │ │ └── index.tsx ├── firebase.ts ├── ga.ts ├── index.tsx ├── models │ ├── consts │ │ ├── count-type.ts │ │ ├── feed-type.ts │ │ ├── feeds │ │ │ ├── atom.ts │ │ │ ├── feed.ts │ │ │ ├── rss1.ts │ │ │ └── rss2.ts │ │ └── fetch-response │ │ │ └── hatena-star.ts │ ├── entities.ts │ ├── fetchers │ │ ├── blog-fetcher.ts │ │ ├── count-fetchers │ │ │ ├── count-jsoon-fetcher.ts │ │ │ ├── facebook-fetcher.ts │ │ │ ├── hatenabookmark-fetcher.ts │ │ │ ├── hatenastar-fetcher.ts │ │ │ └── pocket-fetcher.ts │ │ ├── cross-origin-fetch.ts │ │ ├── feed-fetcher.ts │ │ └── functions.ts │ ├── repositories │ │ ├── app-repository.ts │ │ ├── blog-repository.ts │ │ ├── item-repository.ts │ │ └── user-repository.ts │ ├── responses.ts │ └── save-count-response.ts ├── react-app-env.d.ts ├── redux │ ├── app-reducer.ts │ ├── create-store.ts │ ├── sagas │ │ ├── feed-saga.ts │ │ ├── feed-sagas │ │ │ ├── count-jsoon-saga.ts │ │ │ ├── facebook-saga.ts │ │ │ ├── hatenabookmark-saga.ts │ │ │ ├── hatenastar-saga.ts │ │ │ └── pocket-saga.ts │ │ ├── ga-saga.ts │ │ └── user-saga.ts │ └── slices │ │ ├── add-blog.ts │ │ ├── blog.ts │ │ ├── delete-blog.ts │ │ ├── feeds.ts │ │ ├── settings.ts │ │ └── user.ts ├── serviceWorker.ts ├── utils │ └── canonicalize.ts └── withTracker.tsx ├── tsconfig.json ├── tsconfig.test.json └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | - image: cimg/openjdk:17.0.0-node 10 | 11 | working_directory: ~/repo 12 | 13 | steps: 14 | - checkout 15 | 16 | # Download and cache dependencies 17 | - restore_cache: 18 | keys: 19 | - v2-dependencies-{{ checksum "package.json" }} 20 | # fallback to using the latest cache if no exact match is found 21 | - v2-dependencies- 22 | 23 | - run: yarn install 24 | 25 | - save_cache: 26 | paths: 27 | - node_modules 28 | - ~/.cache 29 | key: v2-dependencies-{{ checksum "package.json" }} 30 | 31 | - restore_cache: 32 | keys: 33 | - v2-dependencies-functions-{{ checksum "functions/package.json" }} 34 | - v2-dependencies-functions- 35 | 36 | - run: cd functions && yarn install && cd .. 37 | 38 | - save_cache: 39 | paths: 40 | - functions/node_modules 41 | key: v2-dependencies-functions-{{ checksum "functions/package.json" }} 42 | 43 | 44 | # run tests! 45 | - run: yarn eslint 46 | - run: yarn test:compile 47 | - run: 48 | command: yarn test:compile 49 | working_directory: functions 50 | - run: yarn firebase use development 51 | - run: cd functions && yarn build && cd .. 52 | - run: yarn firebase setup:emulators:firestore && yarn firebase setup:emulators:ui 53 | - run: 54 | command: yarn firebase emulators:start --only firestore,functions 55 | background: true 56 | - run: dockerize -wait tcp://localhost:8080 -timeout 1m 57 | - run: 58 | name: Run tests with JUnit as reporter 59 | command: yarn test:ci --runInBand --reporters=default --reporters=jest-junit 60 | environment: 61 | JEST_JUNIT_OUTPUT_DIR: ./reports/junit/ 62 | 63 | - store_test_results: 64 | path: ./reports/junit/ 65 | - store_artifacts: 66 | path: ./reports/junit 67 | 68 | - persist_to_workspace: 69 | root: . 70 | paths: 71 | - . 72 | 73 | deploy_development: 74 | docker: 75 | - image: circleci/node:17.2 76 | working_directory: ~/repo 77 | steps: 78 | - attach_workspace: 79 | at: . 80 | - run: yarn build 81 | - run: yarn firebase deploy --project development 82 | - run: 83 | name: Deploy Storybook 84 | command: yarn deploy-storybook --ci 85 | environment: 86 | NODE_OPTIONS: --openssl-legacy-provider 87 | 88 | deploy_production: 89 | docker: 90 | - image: circleci/node:17.2 91 | working_directory: ~/repo 92 | steps: 93 | - attach_workspace: 94 | at: . 95 | - run: yarn build:production 96 | - run: yarn firebase deploy --project production 97 | 98 | workflows: 99 | version: 2 100 | test_and_deploy: 101 | jobs: 102 | - build: 103 | filters: 104 | branches: 105 | ignore: gh-pages 106 | - deploy_development: 107 | requires: 108 | - build 109 | filters: 110 | branches: 111 | only: master 112 | - request_manual_testing: 113 | type: approval 114 | requires: 115 | - build 116 | filters: 117 | branches: 118 | only: master 119 | - deploy_production: 120 | requires: 121 | - request_manual_testing 122 | filters: 123 | branches: 124 | only: master 125 | 126 | -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | REACT_APP_FIREBASE_API_KEY="AIzaSyBxWFRf0NnBcC8Uf9JJggjkOlaGGAdZwvE" 2 | REACT_APP_FIREBASE_AUTH_DOMAIN="feedback-5e26f.firebaseapp.com" 3 | REACT_APP_FIREBASE_DATABASE_URL="https://feedback-5e26f.firebaseio.com" 4 | REACT_APP_FIREBASE_PROJECT_ID="feedback-5e26f" 5 | REACT_APP_FIREBASE_STORAGE_BUCKET="feedback-5e26f.appspot.com" 6 | REACT_APP_FIREBASE_MESSAGING_SENDER_ID="844615095944" 7 | REACT_APP_FIREBASE_APP_ID="1:844615095944:web:3b6fe8414b4ae415" 8 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | REACT_APP_FIREBASE_API_KEY="AIzaSyDpx-BDpeJA3Vd2R3bS1fQuH8n0v1C-yhk" 2 | REACT_APP_FIREBASE_AUTH_DOMAIN="blog-feedback.app" 3 | REACT_APP_FIREBASE_DATABASE_URL="https://blog-feedback.firebaseio.com" 4 | REACT_APP_FIREBASE_PROJECT_ID="blog-feedback" 5 | REACT_APP_FIREBASE_STORAGE_BUCKET="blog-feedback.appspot.com" 6 | REACT_APP_FIREBASE_MESSAGING_SENDER_ID="238414963480" 7 | REACT_APP_FIREBASE_APP_ID="1:238414963480:web:65a4dcd523710d5f" 8 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | REACT_APP_FIREBASE_API_KEY="AIzaSyBxWFRf0NnBcC8Uf9JJggjkOlaGGAdZwvE" 2 | REACT_APP_FIREBASE_AUTH_DOMAIN="feedback-5e26f.firebaseapp.com" 3 | REACT_APP_FIREBASE_DATABASE_URL="https://feedback-5e26f.firebaseio.com" 4 | REACT_APP_FIREBASE_PROJECT_ID="feedback-5e26f" 5 | REACT_APP_FIREBASE_STORAGE_BUCKET="feedback-5e26f.appspot.com" 6 | REACT_APP_FIREBASE_MESSAGING_SENDER_ID="844615095944" 7 | REACT_APP_FIREBASE_APP_ID="1:844615095944:web:3b6fe8414b4ae415" 8 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "react-app", 4 | "plugin:prettier/recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "development": "feedback-5e26f", 4 | "production": "blog-feedback", 5 | "default": "feedback-5e26f" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | functions/node_modules 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | 23 | .cache 24 | .firebase 25 | firestore-debug.log 26 | firebase-debug.log -------------------------------------------------------------------------------- /.storybook/addons.js: -------------------------------------------------------------------------------- 1 | import '@storybook/addon-actions/register'; 2 | import '@storybook/addon-links/register'; 3 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure } from '@storybook/react'; 2 | 3 | const context = require.context('../src/components', true, /.stories.tsx/); 4 | function loadStories() { 5 | context.keys().forEach(filename => context(filename)); 6 | } 7 | 8 | configure(loadStories, module); 9 | -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ config }) => { 2 | config.module.rules.push({ 3 | test: /\.(ts|tsx)$/, 4 | exclude: /src\/__tests__/, 5 | loader: require.resolve('babel-loader'), 6 | options: { 7 | presets: [['react-app', { flow: false, typescript: true }]], 8 | }, 9 | }); 10 | config.resolve.extensions.push(".ts", ".tsx"); 11 | return config; 12 | }; 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Satoshi Asano 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 | [![CircleCI](https://circleci.com/gh/ninjinkun/blog-feedback-app.svg?style=svg&circle-token=f9faff2d125195261cccf6cf8f6c9aabd1733603)](https://circleci.com/gh/ninjinkun/blog-feedback-app) 2 | 3 | BlogFeedback is a PWA visualize your blog's impact. 4 | 5 | 6 | 7 | This app is based on below modules and services. 8 | - React 9 | - create-react-app 10 | - styled-component 11 | - Redux 12 | - redux-thunk 13 | - redux-saga 14 | - Firebase 15 | - Authentication 16 | - Cloud Firestore 17 | - Cloud Functions 18 | 19 | # Commands 20 | ## Run 21 | ``` 22 | $ yarn start 23 | ``` 24 | 25 | ## Storybook 26 | You can access Storybook [here](https://ninjinkun.github.io/blog-feedback-app/). 27 | 28 | or 29 | 30 | ``` 31 | $ yarn storybook 32 | ``` 33 | 34 | ## Staging Build 35 | ``` 36 | $ yarn build 37 | ``` 38 | 39 | ## Production Build 40 | ``` 41 | $ yarn build:production 42 | ``` 43 | 44 | # Test 45 | ``` 46 | $ yarn firebase serve --only firestore 47 | $ yarn test 48 | ``` 49 | -------------------------------------------------------------------------------- /config-overrides.js: -------------------------------------------------------------------------------- 1 | module.exports = function override(config) { 2 | const fallback = config.resolve.fallback || {}; 3 | Object.assign(fallback, { 4 | stream: require.resolve('stream-browserify'), 5 | }); 6 | config.resolve.fallback = fallback; 7 | return config; 8 | }; 9 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "firestore": { 3 | "rules": "firestore.rules", 4 | "indexes": "firestore.indexes.json" 5 | }, 6 | "hosting": { 7 | "public": "build", 8 | "ignore": [ 9 | "firebase.json", 10 | "**/.*", 11 | "**/node_modules/**" 12 | ], 13 | "rewrites": [ 14 | { 15 | "source": "**", 16 | "destination": "/index.html" 17 | } 18 | ], 19 | "headers": [ 20 | { 21 | "source": "/service-worker.js", 22 | "headers": [ 23 | { 24 | "key": "Cache-Control", 25 | "value": "no-cache" 26 | } 27 | ] 28 | } 29 | ] 30 | }, 31 | "functions": { 32 | "predeploy": [ 33 | "npm --prefix \"$RESOURCE_DIR\" run eslint", 34 | "npm --prefix \"$RESOURCE_DIR\" run build" 35 | ] 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /firestore.indexes.json: -------------------------------------------------------------------------------- 1 | { 2 | "indexes": [] 3 | } -------------------------------------------------------------------------------- /firestore.rules: -------------------------------------------------------------------------------- 1 | service cloud.firestore { 2 | match /databases/{database}/documents { 3 | match /users/{userId}/blogs/{blogId} { 4 | function isUndefined(resource, field) { 5 | return !resource.keys().hasAny([field]) 6 | } 7 | 8 | allow read: if request.auth.uid == userId; 9 | allow delete: if request.auth.uid == userId; 10 | allow create, update: if request.auth.uid == userId 11 | && request.resource.data.keys().hasOnly(["title", "url", "feedURL", "feedType", "timestamp", "services", "sendReport"]) 12 | && request.resource.data.title is string 13 | && request.resource.data.url is string 14 | && request.resource.data.feedURL is string 15 | && request.resource.data.feedType is string 16 | && request.resource.data.timestamp is timestamp 17 | && request.resource.data.sendReport is bool 18 | && request.resource.data.services is map 19 | && request.resource.data.services.keys().hasOnly(["twitter", "countjsoon", "facebook", "hatenabookmark", "hatenastar", "pocket"]) 20 | && request.resource.data.services.twitter is bool 21 | && request.resource.data.services.countjsoon is bool 22 | && request.resource.data.services.facebook is bool 23 | && request.resource.data.services.hatenabookmark is bool 24 | && request.resource.data.services.hatenastar is bool 25 | && request.resource.data.services.pocket is bool; 26 | 27 | match /items/{itemId} { 28 | function checkCountFields(resource, countType) { 29 | return isUndefined(resource, countType) 30 | || (resource[countType] is map 31 | && resource[countType].hasOnly(["count", "timestamp"]) 32 | && resource[countType].count is number 33 | && resource[countType].timestamp is timestamp); 34 | } 35 | function checkCountMap(resource, field) { 36 | return isUndefined(resource, field) 37 | || (resource[field] is map 38 | && checkCountFields(resource[field], "countjsoon") 39 | && checkCountFields(resource[field], "facebook") 40 | && checkCountFields(resource[field], "hatenabookmark") 41 | && checkCountFields(resource[field], "hatenastar") 42 | && checkCountFields(resource[field], "pocket")); 43 | } 44 | 45 | allow read: if request.auth.uid == userId; 46 | allow delete: if request.auth.uid == userId; 47 | allow create, update: if request.auth.uid == userId 48 | && request.resource.data.keys().hasAll(["title", "url", "published"]) 49 | && request.resource.data.title is string 50 | && request.resource.data.url is string 51 | && request.resource.data.published is timestamp 52 | && checkCountMap(request.resource.data, "count") 53 | && checkCountMap(request.resource.data, "prevCount") 54 | && checkCountMap(request.resource.data, "yesterdayCount"); 55 | } 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /functions/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": [ 4 | "plugin:@typescript-eslint/recommended", 5 | "prettier" 6 | ], 7 | "rules": { 8 | "@typescript-eslint/no-use-before-define": "off" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /functions/.gitignore: -------------------------------------------------------------------------------- 1 | ## Compiled JavaScript files 2 | **/*.js 3 | **/*.js.map 4 | 5 | # Typescript v1 declaration files 6 | typings/ 7 | .runtimeconfig.json -------------------------------------------------------------------------------- /functions/build/css/mail.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: auto; 3 | padding: 0; 4 | max-width: 480px; 5 | } 6 | 7 | header { 8 | background-color: rgba(0, 71, 132, 1); 9 | height: 20px; 10 | 11 | } 12 | 13 | .content { 14 | margin: 0 8px; 15 | padding: 0 8px; 16 | text-align: start; 17 | } 18 | 19 | .icon { 20 | width: 64px; 21 | height: 64px; 22 | } 23 | 24 | .buttons { 25 | width: 100%; 26 | text-align: center; 27 | margin: 16px 0; 28 | } 29 | 30 | .primary-button { 31 | border-radius: 4px; 32 | border-width: 0; 33 | font-size: 1rem; 34 | background-color: #51c300; 35 | color: white; 36 | padding: 8px 16px; 37 | display: inline-block; 38 | margin: 8px 0; 39 | } 40 | 41 | a.primary-button { 42 | text-decoration: none; 43 | } 44 | 45 | a.primary-button:hover { 46 | opacity: 0.7; 47 | } 48 | 49 | .line { 50 | margin: 16px 0; 51 | } 52 | 53 | .signature { 54 | list-style: none; 55 | padding: 0; 56 | margin: 0; 57 | font-size: 0.9em; 58 | } -------------------------------------------------------------------------------- /functions/build/css/report.css: -------------------------------------------------------------------------------- 1 | .body { 2 | margin: auto; 3 | padding: 0; 4 | max-width: 480px; 5 | border-left: 1px solid rgb(221, 221, 221); 6 | border-right: 1px solid rgb(221, 221, 221); 7 | } 8 | 9 | .wrapper { 10 | width: 100%; 11 | padding-top: 1px; 12 | background-color: rgb(246, 246, 246); 13 | } 14 | 15 | .entry-cells { 16 | padding-right: 24px; 17 | } 18 | 19 | .entry-cells-notice { 20 | font-size: 0.9em; 21 | margin: 6px 12px; 22 | padding: 4px; 23 | } 24 | 25 | .entry-cell { 26 | margin: 6px 12px; 27 | padding: 8px; 28 | border: 1px solid rgb(221, 221, 221); 29 | background-color: white; 30 | box-shadow: rgb(225, 225, 225) 0px 0px 1px 0px; 31 | box-sizing: border-box; 32 | width: 100%; 33 | } 34 | 35 | .entry-cell:first-child { 36 | margin: 12px 12px 6px; 37 | } 38 | 39 | a.entry-cell-link { 40 | text-decoration: none; 41 | color: rgb(20, 23, 26); 42 | } 43 | 44 | a.entry-cell-link:link { 45 | color: rgb(20, 23, 26); 46 | } 47 | 48 | a.entry-cell-link:visited { 49 | color: rgb(20, 23, 26); 50 | } 51 | 52 | .entry-cell-favicon-wrapper { 53 | vertical-align: top; 54 | width: 16px; 55 | } 56 | 57 | .entry-cell-favicon { 58 | width: 16px; 59 | height: 16px; 60 | max-width: 16px; 61 | max-height: 16px; 62 | margin-top: 4px; 63 | } 64 | 65 | .entry-cell-content { 66 | width: 100%; 67 | } 68 | 69 | .entry-cell-content-title { 70 | font-size: 1rem; 71 | margin: 0 0 0 8px; 72 | } 73 | 74 | .entry-cell-content-buttons { 75 | width: 100%; 76 | margin-left: 2px; 77 | overflow: auto; 78 | } 79 | 80 | .entry-cell-content-buttons-button-wrapper { 81 | position: relative; 82 | } 83 | 84 | .entry-cell-content-buttons-button-link { 85 | position: relative; 86 | } 87 | 88 | .entry-cell-content-buttons-button-wrapper-button { 89 | position: relative; 90 | margin: 0.2rem; 91 | 92 | font-weight: 700; 93 | line-height: 1; 94 | text-align: center; 95 | font-size: 0.8rem; 96 | color: rgb(26, 26, 26); 97 | text-decoration: none; 98 | border-radius: 4px; 99 | transition: liner 0.2s ease 0s; 100 | padding: 0.2rem; 101 | background: linear-gradient(rgb(246, 246, 246), rgb(238, 238, 238)); 102 | border-width: 1px; 103 | border-style: solid; 104 | border-color: rgb(204, 204, 204); 105 | border-image: initial; 106 | padding: 0; 107 | float: left; 108 | display: block; 109 | min-width: 58px; 110 | width: -webkit-fit-content; 111 | width: -moz-fit-content; 112 | width: fit-content; 113 | } 114 | 115 | .entry-cell-content-buttons-button-wrapper-button:hover { 116 | opacity: 0.7; 117 | } 118 | 119 | .entry-cell-content-buttons-button-wrapper-button-icon-wrapper { 120 | width: 24px; 121 | height: 24px; 122 | } 123 | 124 | .entry-cell-content-buttons-button-wrapper-button-icon { 125 | width: 24px; 126 | height: 24px; 127 | } 128 | 129 | .entry-cell-content-buttons-button-wrapper-button-count { 130 | font-weight: 700; 131 | font-size: 0.8rem; 132 | text-align: center; 133 | margin-left: 2px; 134 | width: 100%; 135 | } 136 | 137 | .entry-cell-content-buttons-button-wrapper-button-count-text { 138 | font-weight: normal; 139 | font-size: 0.7rem; 140 | margin-left: 2px; 141 | } 142 | 143 | .entry-cell-content-buttons-button-wrapper-button-count-update { 144 | font-weight: 700; 145 | color: #3bba07; 146 | } 147 | -------------------------------------------------------------------------------- /functions/emails/report/html.pug: -------------------------------------------------------------------------------- 1 | head 2 | link(rel="stylesheet", href="/css/mail.css", data-inline) 3 | link(rel="stylesheet", href="/css/report.css", data-inline) 4 | body.body 5 | header 6 | .wrapper 7 | .entry-cells 8 | p.entry-cells-notice 本日のシェア数レポートをお届けします。 9 | br 10 | | ( ) 内の数字は昨日の同時刻からのアップデート分です。 11 | each item in items 12 | - const show = item.counts.some(count => count.updatedCount > 0) 13 | if show || sendForce 14 | table.entry-cell 15 | tr 16 | td.entry-cell-favicon-wrapper 17 | a.entry-cell-link(href=`${item.url}`) 18 | img.entry-cell-favicon(src=`https://www.google.com/s2/favicons?domain=${blogURL}`) 19 | td 20 | a.entry-cell-link(href=`${item.url}`) 21 | h3.entry-cell-content-title #{item.title} 22 | tr.entry-cell-content 23 | td 24 | td 25 | .entry-cell-content-buttons 26 | each count in item.counts 27 | .entry-cell-content-buttons-button-wrapper 28 | - const iconType = count.type !== 'countjsoon' ? count.type : 'twitter' 29 | if count.link 30 | table.entry-cell-content-buttons-button-wrapper-button 31 | tr 32 | td.entry-cell-content-buttons-button-wrapper-button-icon-wrapper 33 | a.entry-cell-link.entry-cell-content-buttons-button-link(href=`${count.link}`) 34 | img.entry-cell-content-buttons-button-wrapper-button-icon(src=`https://blog-feedback.app/images/${iconType}-icon.png`) 35 | td.entry-cell-content-buttons-button-wrapper-button-count 36 | a.entry-cell-link.entry-cell-content-buttons-button-link(href=`${count.link}`) #{count.count} 37 | if count.updatedCount > 0 38 | a.entry-cell-link.entry-cell-content-buttons-button-link(href=`${count.link}`) 39 | span.entry-cell-content-buttons-button-wrapper-button-count-text ( 40 | span.entry-cell-content-buttons-button-wrapper-button-count-update +#{count.updatedCount} 41 | | ) 42 | else 43 | table.entry-cell-content-buttons-button-wrapper-button 44 | tr 45 | td.entry-cell-content-buttons-button-wrapper-button-icon-wrapper 46 | img.entry-cell-content-buttons-button-wrapper-button-icon(src=`https://blog-feedback.app/images/${iconType}-icon.png`) 47 | td.entry-cell-content-buttons-button-wrapper-button-count 48 | | #{count.count} 49 | if count.updatedCount > 0 50 | span.entry-cell-content-buttons-button-wrapper-button-count-text ( 51 | span.entry-cell-content-buttons-button-wrapper-button-count-update +#{count.updatedCount} 52 | | ) 53 | 54 | .buttons 55 | - const blogId = encodeURIComponent(blogURL) 56 | a.primary-button(href=`https://blog-feedback.app/blogs/${blogId}`) 57 | span BlogFeedbackを開く 58 | 59 | .entry-cells-notice このメール機能はα版です。現在多くのユーザー様にご利用頂けるよう調整をしていますが、今後予告なく配信を取りやめる場合があります。 60 | .content 61 | hr.line 62 | ul.signature 63 | li BlogFeedback運営 64 | a(href="https://twitter.com/ninjinkun") @ninjinkun 65 | li 改善等のご要望は、上記の Twittter アカウントまたは 66 | a(href="https://github.com/ninjinkun/blog-feedback-app") GitHub 67 | | の Issues までお寄せください。 68 | li このメールの停止はBlogFeedbackの 69 | a(href="https://blog-feedback.app/settings") 設定 70 | | より行うことができます 71 | img(src=`${ga}`) 72 | -------------------------------------------------------------------------------- /functions/emails/report/subject.pug: -------------------------------------------------------------------------------- 1 | = `[+${updatedCounts}] ${blogTitle}のシェア数レポート` 2 | -------------------------------------------------------------------------------- /functions/emails/welcome/html.pug: -------------------------------------------------------------------------------- 1 | head 2 | link(rel="stylesheet", href="/css/mail.css", data-inline) 3 | body 4 | header 5 | div.content 6 | div.buttons 7 | img.icon(src="https://blog-feedback.app/icon-192x192.png") 8 | 9 | p BlogFeedbackにご登録いただきまして、誠にありがとうございます。 10 | br 11 | | 今後ともよろしくお願いいたします。 12 | 13 | div.buttons 14 | a.primary-button(href="https://blog-feedback.app") 15 | span BlogFeedbackを開く 16 | 17 | hr.line 18 | ul.signature 19 | li BlogFeedback運営 20 | a(href="https://twitter.com/ninjinkun") @ninjinkun 21 | li 改善等のご要望は、上記の Twittter アカウントまたは 22 | a(href="https://github.com/ninjinkun/blog-feedback-app") GitHub 23 | | の Issues までお寄せください。 24 | img(src=`${ga}`) 25 | -------------------------------------------------------------------------------- /functions/emails/welcome/subject.pug: -------------------------------------------------------------------------------- 1 | = `ご登録ありがとうございます` 2 | -------------------------------------------------------------------------------- /functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "functions", 3 | "scripts": { 4 | "eslint": "eslint './src/**/*.{ts,tsx}'", 5 | "build": "tsc", 6 | "test:compile": "tsc --noEmit", 7 | "serve": "npm run build && firebase serve --only functions", 8 | "shell": "npm run build && firebase functions:shell", 9 | "start": "npm run shell", 10 | "deploy": "firebase deploy --only functions", 11 | "logs": "firebase functions:log", 12 | "eslint:fix": "eslint --fix './src/**/*.{ts,tsx}'" 13 | }, 14 | "main": "build/index.js", 15 | "dependencies": { 16 | "@google-cloud/pubsub": "^2.19.3", 17 | "axios": "^0.26.1", 18 | "charset": "^1.0.1", 19 | "cheerio": "^1.0.0-rc.10", 20 | "email-templates": "^8.1.0", 21 | "firebase-admin": "~10.0.2", 22 | "firebase-functions": "^3.20.1", 23 | "iconv-lite": "^0.6.3", 24 | "lodash": "^4.17.21", 25 | "nodemailer": "^6.7.3", 26 | "qs": "^6.10.3", 27 | "uuid": "^8.3.2", 28 | "xml-js": "^1.6.11", 29 | "xmldom": "^0.6.0" 30 | }, 31 | "devDependencies": { 32 | "@types/charset": "^1.0.2", 33 | "@types/cheerio": "^0.22.31", 34 | "@types/email-templates": "^8.0.4", 35 | "@types/lodash": "^4.14.182", 36 | "@types/nodemailer": "^6.4.4", 37 | "@types/uuid": "^8.3.4", 38 | "@types/xmldom": "^0.1.31", 39 | "@typescript-eslint/eslint-plugin": "^5.21.0", 40 | "eslint": "^8.12.0", 41 | "eslint-config-prettier": "^8.5.0", 42 | "eslint-plugin-prettier": "^4.0.0", 43 | "prettier": "^2.6.2", 44 | "typescript": "~4.6.3" 45 | }, 46 | "private": true, 47 | "engines": { 48 | "node": "14" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /functions/src/consts/count-type.ts: -------------------------------------------------------------------------------- 1 | export enum CountType { 2 | Twitter = 'twitter', 3 | CountJsoon = 'countjsoon', 4 | Facebook = 'facebook', 5 | HatenaBookmark = 'hatenabookmark', 6 | HatenaStar = 'hatenastar', 7 | Pocket = 'pocket', 8 | } 9 | 10 | export function toServiceURL(type: CountType, url: string): string | null { 11 | switch (type) { 12 | case CountType.Twitter: 13 | return `https://twitter.com/search?q=${encodeURIComponent(url)}&f=live`; 14 | case CountType.CountJsoon: 15 | return `https://twitter.com/search?q=${encodeURIComponent(url)}&f=live`; 16 | case CountType.HatenaBookmark: 17 | if (url.match(/^https/)) { 18 | return `https://b.hatena.ne.jp/entry/s/${url.replace(/^https:\/\//, '')}`; 19 | } else { 20 | return `https://b.hatena.ne.jp/entry/${url.replace(/^http:\/\//, '')}`; 21 | } 22 | case CountType.HatenaStar: 23 | return `https://s.hatena.ne.jp/mobile/entry?uri=${encodeURIComponent(url)}`; 24 | default: 25 | return null; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /functions/src/consts/feed-type.ts: -------------------------------------------------------------------------------- 1 | export enum FeedType { 2 | RSS = 'rss', 3 | Atom = 'atom', 4 | } 5 | -------------------------------------------------------------------------------- /functions/src/consts/feeds/atom.ts: -------------------------------------------------------------------------------- 1 | export interface Atom { 2 | feed: Feed; 3 | } 4 | 5 | interface Feed { 6 | title: Text; 7 | link: Link | Link[]; 8 | entry: Entry[]; 9 | } 10 | 11 | interface Entry { 12 | id: Text; 13 | link: Link | Link[]; 14 | title: Text | FeedBurnerTitle; 15 | updated: Text; 16 | published?: Text; 17 | 'feedburner:origLink'?: Text; 18 | } 19 | 20 | export interface Link { 21 | _attributes: { 22 | href: string; 23 | rel?: string; 24 | }; 25 | } 26 | 27 | interface Text { 28 | _text: string; 29 | } 30 | 31 | interface FeedBurnerTitle { 32 | _cdata: string; 33 | } 34 | -------------------------------------------------------------------------------- /functions/src/consts/feeds/feed.ts: -------------------------------------------------------------------------------- 1 | import { Atom } from './atom'; 2 | import { RSS1 } from './rss1'; 3 | import { RSS2 } from './rss2'; 4 | 5 | // xml-js decoded types 6 | export type Feed = RSS1 | RSS2 | Atom; 7 | -------------------------------------------------------------------------------- /functions/src/consts/feeds/rss1.ts: -------------------------------------------------------------------------------- 1 | export interface RSS1 { 2 | 'rdf:RDF': RSS; 3 | } 4 | 5 | interface RSS { 6 | item: Item[]; 7 | channel: Channel; 8 | } 9 | 10 | interface Channel { 11 | title: Text; 12 | link: Text; 13 | } 14 | 15 | interface Item { 16 | title: Text; 17 | link: Text; 18 | 'dc:date': Text; 19 | } 20 | 21 | interface Text { 22 | _text: string; 23 | } 24 | -------------------------------------------------------------------------------- /functions/src/consts/feeds/rss2.ts: -------------------------------------------------------------------------------- 1 | export interface RSS2 { 2 | rss: RSS; 3 | } 4 | 5 | interface RSS { 6 | channel: Channel; 7 | } 8 | 9 | interface Channel { 10 | title: Text; 11 | link: Text; 12 | item: Item[]; 13 | } 14 | 15 | interface Item { 16 | title: Text; 17 | link: Text; 18 | pubDate: Text; 19 | } 20 | 21 | interface Text { 22 | _text: string; 23 | _cdata?: string; 24 | } 25 | -------------------------------------------------------------------------------- /functions/src/consts/fetch-response/hatena-star.ts: -------------------------------------------------------------------------------- 1 | export interface HatenaStarReponse { 2 | entries: Entry[]; 3 | } 4 | 5 | interface Entry { 6 | uri: string; 7 | stars?: Star[]; 8 | colored_stars?: ColorStar[]; 9 | } 10 | 11 | interface ColorStar { 12 | color: string; 13 | stars: Star[]; 14 | } 15 | 16 | interface Star { 17 | name: string; 18 | quote: string; 19 | } 20 | -------------------------------------------------------------------------------- /functions/src/entities.ts: -------------------------------------------------------------------------------- 1 | import { firestore } from 'firebase-admin'; 2 | import { FeedType } from './consts/feed-type'; 3 | 4 | export interface BlogEntity { 5 | title: string; 6 | url: string; 7 | feedURL: string; 8 | feedType: FeedType; 9 | services?: Services; 10 | sendReport: boolean; 11 | } 12 | 13 | export interface Services { 14 | twitter: boolean; 15 | countjsoon: boolean; 16 | facebook: boolean; 17 | hatenabookmark: boolean; 18 | hatenastar: boolean; 19 | pocket?: boolean; 20 | } 21 | 22 | export interface ItemEntity { 23 | title: string; 24 | url: string; 25 | published: firestore.Timestamp; 26 | counts: { [key: string]: CountEntity }; // key is CountType 27 | prevCounts: { [key: string]: CountEntity }; // 10 minutes before 28 | yesterdayCounts?: { [key: string]: CountEntity }; 29 | } 30 | 31 | export interface CountEntity { 32 | count: number; 33 | timestamp: firestore.Timestamp; 34 | } 35 | -------------------------------------------------------------------------------- /functions/src/fetcher.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosResponse } from 'axios'; 2 | import * as iconv from 'iconv-lite'; 3 | import charset = require('charset'); 4 | 5 | export async function fetchText(url: string): Promise { 6 | return fetch(url, 'text/html,application/xhtml+xml,application/xml,application/rss+xml,application/atom+xml'); 7 | } 8 | 9 | export function fetchJSON(url: string): Promise { 10 | return fetch(url, 'application/json,text/json') 11 | } 12 | 13 | async function fetch(url: string, acceptHeader: string): Promise { 14 | const res: AxiosResponse = await axios.get(url, { 15 | responseType: 'arraybuffer', 16 | headers: { 17 | 'User-Agent': 'BlogFeedback/0.1', 18 | 'Accept': acceptHeader, 19 | }, 20 | }); 21 | const encoding: string = charset(res.headers); 22 | return encoding ? iconv.decode(res.data, encoding) : res.data.toString('utf8'); 23 | } 24 | -------------------------------------------------------------------------------- /functions/src/fetchers/count-fetchers/count-jsoon-fetcher.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { CountType } from '../../consts/count-type'; 3 | import { CountResponse } from '../../responses'; 4 | import { chunk, flatten } from 'lodash'; 5 | import { sleep } from '../../sleep'; 6 | 7 | export async function fetchCountJsoonCounts( 8 | urls: string[], 9 | maxFetchCount = 20, 10 | chunkNum = 10 11 | ): Promise { 12 | const slicedURLs = urls.slice(0, maxFetchCount - 1); 13 | const chunkedURLs = chunk(slicedURLs, chunkNum); 14 | const counts = await Promise.all(chunkedURLs.map(chunkedURL => fetchCountJsoonCountChunk(chunkedURL))); 15 | return flatten(counts); 16 | } 17 | 18 | async function fetchCountJsoonCountChunk(urls: string[], delayMsec = 200): Promise { 19 | await sleep(delayMsec); 20 | const counts = await Promise.all(urls.map(url => fetchCountJsoonCount(url))); 21 | return counts.filter(c => c !== undefined); 22 | } 23 | 24 | export async function fetchCountJsoonCount(url: string): Promise { 25 | try { 26 | const response = await axios.get<{ count: number }>( 27 | `https://jsoon.digitiminimi.com/twitter/count.json?url=${encodeURIComponent(url)}`, 28 | { timeout: 10 * 1000 } 29 | ); 30 | const json = response.data; 31 | const { count } = json; 32 | if (count !== undefined) { 33 | return { url, count, type: CountType.CountJsoon }; 34 | } else { 35 | return undefined; 36 | throw new Error('maybe you must register count.jsoon: ' + url); 37 | } 38 | } catch (e) { 39 | console.warn(e); 40 | return undefined; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /functions/src/fetchers/count-fetchers/facebook-fetcher.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosResponse } from 'axios'; 2 | import { CountType } from '../../consts/count-type'; 3 | import { CountResponse } from '../../responses'; 4 | import * as functions from 'firebase-functions'; 5 | import * as qs from 'qs'; 6 | 7 | interface FacebookBatchResponse { 8 | body: string; 9 | code: number; 10 | headers: { 11 | name: string; 12 | value: string; 13 | }; 14 | } 15 | 16 | interface FacebookResponse { 17 | id: string; 18 | og_object: { 19 | engagement: { 20 | count: number; 21 | }; 22 | }; 23 | } 24 | 25 | export async function fetchFacebookCounts(urls: string[], maxFetchCount=30): Promise { 26 | const batch = urls.slice(0, maxFetchCount - 1).map(url => ({ 27 | method: 'GET', 28 | 'relative_url': `?id=${encodeURIComponent(url)}&fields=og_object{engagement}`, 29 | })); 30 | const accessToken = functions.config().facebook.access_token; 31 | const response = await axios.post>( 32 | 'https://graph.facebook.com/', 33 | qs.stringify({ 34 | 'access_token': accessToken, 35 | batch: JSON.stringify(batch), 36 | }), 37 | { timeout: 10 * 1000 } 38 | ); 39 | return response.data 40 | .map((json: FacebookBatchResponse) => JSON.parse(json.body)) 41 | .filter((json: FacebookResponse) => json.hasOwnProperty('og_object')) 42 | .map(({ id, og_object: ogObject }: FacebookResponse) => ({ 43 | url: id, 44 | count: ogObject.engagement.count, 45 | type: CountType.Facebook, 46 | })); 47 | } 48 | -------------------------------------------------------------------------------- /functions/src/fetchers/count-fetchers/hatenabookmark-fetcher.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { CountType } from '../../consts/count-type'; 3 | import { CountResponse } from '../../responses'; 4 | 5 | export async function fetchHatenaBookmarkCounts(urls: string[], maxFetchCount = 50): Promise { 6 | const slicedURLs = urls.slice(0, maxFetchCount - 1); 7 | const apiUrl: string = 8 | 'https://b.hatena.ne.jp/entry.counts?' + slicedURLs.map((i: string) => `url=${encodeURIComponent(i)}`).join('&'); 9 | const response = await axios.get(apiUrl, { timeout: 10 * 1000 }); 10 | const json = response.data; 11 | return Object.keys(json).map( 12 | (url): CountResponse => { 13 | return { url, count: json[url], type: CountType.HatenaBookmark }; 14 | } 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /functions/src/fetchers/count-fetchers/hatenastar-fetcher.ts: -------------------------------------------------------------------------------- 1 | import { sum } from 'lodash'; 2 | import { CountType } from '../../consts/count-type'; 3 | import { HatenaStarReponse } from '../../consts/fetch-response/hatena-star'; 4 | import { CountResponse } from '../../responses'; 5 | import axios from 'axios'; 6 | 7 | export async function fetchHatenaStarCounts(urls: string[], maxFetchCount = 40): Promise { 8 | const slicedURLs = urls.slice(0, maxFetchCount - 1); 9 | const apiURL = 'https://s.hatena.com/entry.json?' + slicedURLs.map(url => `uri=${encodeURIComponent(url)}`).join('&'); 10 | const response = await axios.get(apiURL, { timeout: 10 * 1000 }); 11 | const json = response.data; 12 | return json.entries.map(({ uri, stars, colored_stars: coloredStars }) => ({ 13 | url: uri, 14 | count: (stars && stars.length + sum(coloredStars && coloredStars.map(c => c.stars.length))) || 0, 15 | type: CountType.HatenaStar, 16 | })); 17 | } 18 | -------------------------------------------------------------------------------- /functions/src/fetchers/count-fetchers/pocket-fetcher.ts: -------------------------------------------------------------------------------- 1 | import { CountType } from '../../consts/count-type'; 2 | import { CountResponse } from '../../responses'; 3 | import axios from 'axios'; 4 | import { chunk, flatten } from 'lodash'; 5 | import { sleep } from '../../sleep'; 6 | 7 | export async function fetchPocketCounts( 8 | urls: string[], 9 | maxFetchCount = 40, 10 | chunkNum = 10 11 | ): Promise { 12 | const slicedURLs = urls.slice(0, maxFetchCount - 1); 13 | const chunkedURLs = chunk(slicedURLs, chunkNum); 14 | const counts = await Promise.all(chunkedURLs.map(chunkedURL => fetchPocketCountChunk(chunkedURL))); 15 | return flatten(counts); 16 | } 17 | 18 | async function fetchPocketCountChunk(urls: string[], delayMsec = 400): Promise { 19 | await sleep(delayMsec); 20 | const counts = await Promise.all(urls.map(url => fetchPocketCount(url))); 21 | return counts.filter(c => c !== undefined); 22 | } 23 | 24 | export async function fetchPocketCount(url: string): Promise { 25 | try { 26 | const apiURL = `https://widgets.getpocket.com/api/saves?url=${encodeURIComponent(url)}`; 27 | const response = await axios.get<{ saves: number }>(apiURL, { timeout: 10 * 1000 }); 28 | const json = response.data; 29 | const count = json.saves; 30 | return { url, count, type: CountType.Pocket }; 31 | } catch (e) { 32 | console.warn(e); 33 | return undefined; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /functions/src/fetchers/feed-fetcher.ts: -------------------------------------------------------------------------------- 1 | import * as xmljs from 'xml-js'; 2 | import { FeedType } from '../consts/feed-type'; 3 | import { Atom, Link } from '../consts/feeds/atom'; 4 | import { Feed } from '../consts/feeds/feed'; 5 | import { RSS1 } from '../consts/feeds/rss1'; 6 | import { RSS2 } from '../consts/feeds/rss2'; 7 | import { FeedResponse, ItemResponse } from '../responses'; 8 | import { fetchText } from '../fetcher'; 9 | 10 | export async function fetchFeed(feedURL: string): Promise { 11 | const xml = await fetchText(feedURL); 12 | const sanitizedXML = xml.replace(/(?:)/g, ''); 13 | try { 14 | const json = xmljs.xml2js(sanitizedXML, { 15 | compact: true, 16 | }) as Feed; 17 | if ('feed' in json) { 18 | return handleAtom(json); 19 | } else if ('rdf:RDF' in json) { 20 | return handleRSS1(json); 21 | } else if ('rss' in json) { 22 | return handleRSS2(json); 23 | } else { 24 | throw new Error('Invalid Atom feed'); 25 | } 26 | } catch (e) { 27 | throw new Error('Invalid XML Feed' + e); 28 | } 29 | } 30 | 31 | function handleAtom(atom: Atom): FeedResponse { 32 | const items = atom.feed.entry.map( 33 | (entry): ItemResponse => { 34 | const { title, link, published, updated, 'feedburner:origLink': feedburnerLink } = entry; 35 | const url = ((): string => { 36 | if (feedburnerLink) { 37 | return feedburnerLink._text; 38 | } else { 39 | return handleAtomLink(link); 40 | } 41 | })(); 42 | const item = { 43 | title: ('_cdata' in title && title._cdata) || ('_text' in title && title._text) || '', 44 | url, 45 | published: new Date((published && published._text) || updated._text), 46 | }; 47 | return item; 48 | } 49 | ); 50 | return { 51 | title: atom.feed.title._text, 52 | url: handleAtomLink(atom.feed.link), 53 | feedType: FeedType.Atom, 54 | items, 55 | }; 56 | } 57 | 58 | function handleRSS1(rss1: RSS1): FeedResponse { 59 | const { 'rdf:RDF': rdf } = rss1; 60 | const items = rdf.item.map( 61 | (item): ItemResponse => { 62 | const { title, link, 'dc:date': date } = item; 63 | return { title: title._text, url: link._text, published: new Date(date._text) }; 64 | } 65 | ); 66 | return { 67 | title: rdf.channel.title._text, 68 | url: rdf.channel.link._text, 69 | feedType: FeedType.RSS, 70 | items, 71 | }; 72 | } 73 | 74 | function handleRSS2(rss2: RSS2): FeedResponse { 75 | const items = rss2.rss.channel.item.map( 76 | (item): ItemResponse => { 77 | const { title, link, pubDate } = item; 78 | return { 79 | title: title._cdata || title._text, 80 | url: normalizeMediumURL(link._text), 81 | published: new Date(pubDate._text), 82 | }; 83 | } 84 | ); 85 | return { 86 | title: rss2.rss.channel.title._cdata || rss2.rss.channel.title._text, 87 | url: normalizeMediumURL(rss2.rss.channel.link._text), 88 | items, 89 | feedType: FeedType.RSS, 90 | }; 91 | } 92 | 93 | function normalizeMediumURL(url: string): string { 94 | return url.replace(/\?source=([^&]+)/, ''); 95 | } 96 | 97 | function handleAtomLink(link: Link | Link[]): string { 98 | if (link instanceof Array) { 99 | const relLinks = link.filter(l => l._attributes.rel && l._attributes.rel === 'alternate'); 100 | return (relLinks.length && relLinks[0]._attributes.href) || link[0]._attributes.href; 101 | } else { 102 | return link._attributes.href; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /functions/src/firebase.ts: -------------------------------------------------------------------------------- 1 | 2 | import * as admin from 'firebase-admin'; 3 | 4 | admin.initializeApp(); 5 | export const db = admin.firestore(); 6 | db.settings({ 7 | timestampsInSnapshots: true 8 | }); 9 | -------------------------------------------------------------------------------- /functions/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as functions from 'firebase-functions'; 2 | import { flatten, shuffle } from 'lodash'; 3 | import { auth, firestore } from 'firebase-admin'; 4 | import { v4 as uuidv4 } from 'uuid'; 5 | import { fetchText } from './fetcher'; 6 | import { sendWelcomeMail as sendWelcomeMailAction } from './send-welcome-mail'; 7 | import { crowlAndSendMail } from './send-daily-report-mail'; 8 | import { db } from './firebase'; 9 | import { pubsub } from './pubsub'; 10 | import { sleep } from './sleep'; 11 | 12 | export const crossOriginFetch = functions.region('asia-northeast1').https.onCall(async (data, context) => { 13 | if (!(context.auth && context.auth.uid)) { 14 | throw new Error('Authorization Error'); 15 | } 16 | const { url } = data; 17 | const res = await fetchText(url); 18 | return { body: res }; 19 | }); 20 | 21 | export const sendWelcomeMail = functions 22 | .region('asia-northeast1') 23 | .auth.user() 24 | .onCreate(async (user) => { 25 | if (!user.uid) { 26 | throw new Error('Authorization Error'); 27 | } 28 | const { uid, email } = user; 29 | await sendWelcomeMailAction(email, uid); 30 | console.log('mail sent'); 31 | return true; 32 | }); 33 | 34 | interface MailMessage { 35 | uid: string; 36 | email: string; 37 | blogURL: string; 38 | uuid: string; 39 | forceSend: boolean; 40 | } 41 | 42 | const timeoutSeconds = 540; 43 | export const dailyReportMail = functions 44 | .runWith({ timeoutSeconds }) 45 | .region('asia-northeast1') 46 | .pubsub.schedule('every day 09:00') 47 | .timeZone('Asia/Tokyo') 48 | .onRun(async () => { 49 | const users = await auth().listUsers(1000); 50 | const blogSnapshots = await Promise.all( 51 | users.users.map(async user => { 52 | return [ 53 | user.uid, 54 | user.email, 55 | await db 56 | .collection('users') 57 | .doc(user.uid) 58 | .collection('blogs') 59 | .where('sendReport', '==', true) 60 | .get(), 61 | ] as [string, string, firestore.QuerySnapshot]; 62 | }) 63 | ); 64 | 65 | const uidBlogIds = flatten( 66 | blogSnapshots.map(([uid, email, blogSnapshot]) => { 67 | const blogURLs = blogSnapshot.docs.map(blog => decodeURIComponent(blog.id)); 68 | return blogURLs.map(blogURL => [uid, email, blogURL] as [string, string, string]); 69 | }) 70 | ); 71 | const jobExecTime = 100; 72 | const sleepChunk = (timeoutSeconds * 1000) / uidBlogIds.length - jobExecTime * uidBlogIds.length; 73 | 74 | const topic = pubsub.topic('send-report-mail'); 75 | 76 | for (const [uid, email, blogURL] of shuffle(uidBlogIds)) { 77 | const uuid = uuidv4(); 78 | const message: MailMessage = { email, uid, blogURL, uuid, forceSend: false }; 79 | await topic.publish(Buffer.from(JSON.stringify(message))); 80 | console.log(`UUID: ${uuid}, uid: ${uid}, blogURL: ${blogURL}`); 81 | await sleep(sleepChunk); 82 | } 83 | return true; 84 | }); 85 | 86 | export const subscribeSendReportMail = functions 87 | .region('asia-northeast1') 88 | .pubsub.topic('send-report-mail') 89 | .onPublish(async message => { 90 | const { email, uid, blogURL, uuid, forceSend }: MailMessage = message.json; 91 | await crowlAndSendMail(email, uid, blogURL, uuid, forceSend); 92 | return true; 93 | }); 94 | 95 | export const sendTestReportMail = functions.region('asia-northeast1').https.onCall(async (data, context) => { 96 | const { blogURL } = data; 97 | if (!context.auth) { 98 | throw new Error('Authorization Error'); 99 | } 100 | if (!blogURL) { 101 | throw new Error('Blog URL is missing'); 102 | } 103 | const user = await auth().getUser(context.auth.uid); 104 | const { email, uid } = user; 105 | const uuid = uuidv4(); // dummy 106 | const topic = pubsub.topic('send-report-mail'); 107 | const message: MailMessage = { email, uid, blogURL, uuid, forceSend: true }; 108 | await topic.publish(Buffer.from(JSON.stringify(message))); 109 | return true; 110 | }); 111 | -------------------------------------------------------------------------------- /functions/src/mail-ga.ts: -------------------------------------------------------------------------------- 1 | import * as qs from 'qs'; 2 | export function gaImageSrc(userId: string, documentTitle: string, documentPath: string): string { 3 | const trackingId = 'UA-36926308-2'; 4 | const cid = Math.floor(Math.random() * 0x7FFFFFFF) + "." + Math.floor(Date.now() / 1000); 5 | const paramString = qs.stringify({ 6 | v: 1, 7 | tid: trackingId, 8 | uid: userId, 9 | cid, 10 | t: 'event', 11 | ec: 'email', 12 | ea: 'open', 13 | dt: documentTitle, 14 | dp: documentPath, 15 | }); 16 | return `http://www.google-analytics.com/collect?${paramString}`; 17 | } 18 | -------------------------------------------------------------------------------- /functions/src/mail-transport.ts: -------------------------------------------------------------------------------- 1 | import * as functions from 'firebase-functions'; 2 | import { createTransport, Transporter } from 'nodemailer'; 3 | 4 | export function transport(): Transporter { 5 | const gmailEmail = functions.config().gmail.email; 6 | const gmailPassword = functions.config().gmail.password; 7 | 8 | return createTransport({ 9 | port: 465, 10 | host: 'smtp.gmail.com', 11 | secure: true, 12 | auth: { 13 | user: gmailEmail, 14 | pass: gmailPassword 15 | } 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /functions/src/pubsub.ts: -------------------------------------------------------------------------------- 1 | import { PubSub } from '@google-cloud/pubsub'; 2 | export const pubsub = new PubSub(); 3 | -------------------------------------------------------------------------------- /functions/src/repositories/mail-lock-repository.ts: -------------------------------------------------------------------------------- 1 | 2 | import { firestore } from 'firebase-admin'; 3 | import { db } from '../firebase'; 4 | import { WriteResult } from '@google-cloud/firestore'; 5 | 6 | export interface MailLock { 7 | taskUUID: string; 8 | } 9 | 10 | export function createMailLock(uuid: string, taskUUID: string): Promise { 11 | return db.collection('daily-mail-lock').doc(uuid).create({ 12 | taskUUID, 13 | timestamp: firestore.FieldValue.serverTimestamp(), 14 | }); 15 | } 16 | 17 | export async function getMailLock(uuid: string): Promise { 18 | const doc = await db.collection('daily-mail-lock').doc(uuid).get(); 19 | if (doc.exists) { 20 | return doc.data() as MailLock; 21 | } else { 22 | return undefined; 23 | } 24 | } -------------------------------------------------------------------------------- /functions/src/responses.ts: -------------------------------------------------------------------------------- 1 | import { CountType } from './consts/count-type'; 2 | import { FeedType } from './consts/feed-type'; 3 | 4 | export interface BlogResponse { 5 | title: string; 6 | url: string; 7 | feedURL: string; 8 | feedType: FeedType; 9 | isHatenaBlog: boolean; 10 | } 11 | 12 | export interface FeedResponse { 13 | title: string; 14 | url: string; 15 | items: ItemResponse[]; 16 | feedType: FeedType; 17 | } 18 | 19 | export interface ItemResponse { 20 | title: string; 21 | url: string; 22 | published: Date; 23 | } 24 | 25 | export interface CountResponse { 26 | type: CountType; 27 | url: string; 28 | count: number; 29 | } 30 | -------------------------------------------------------------------------------- /functions/src/send-welcome-mail.ts: -------------------------------------------------------------------------------- 1 | import { transport } from './mail-transport'; 2 | import { gaImageSrc } from './mail-ga'; 3 | import EmailTemplate = require('email-templates'); 4 | 5 | export function sendWelcomeMail(to: string, userId: string): Promise { 6 | const email = new EmailTemplate({ 7 | message: { 8 | from: '"BlogFeedback" ' 9 | }, 10 | transport: transport(), 11 | }); 12 | 13 | return email.send({ 14 | template: 'welcome', 15 | message: { 16 | to, 17 | }, 18 | locals: { 19 | ga: gaImageSrc(userId, 'welcome', '/mail/welcome'), 20 | }, 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /functions/src/sleep.ts: -------------------------------------------------------------------------------- 1 | export function sleep(msec: number): Promise { 2 | return new Promise(resolve => setTimeout(resolve, msec)); 3 | } 4 | -------------------------------------------------------------------------------- /functions/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es6"], 4 | "module": "commonjs", 5 | "noImplicitReturns": true, 6 | "outDir": "build", 7 | "sourceMap": true, 8 | "target": "es6", 9 | "skipLibCheck": true 10 | }, 11 | "compileOnSave": true, 12 | "include": [ 13 | "src" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blog-feedback-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@reduxjs/toolkit": "^1.8.1", 7 | "env-cmd": "^10.1.0", 8 | "eslint-config-prettier": "^8.5.0", 9 | "eslint-plugin-prettier": "^4.0.0", 10 | "fetch-jsonp": "^1.2.1", 11 | "firebase": "^9.6.11", 12 | "firebaseui": "^6.0.1", 13 | "first-input-delay": "^0.1.3", 14 | "history": "^5.3.0", 15 | "lodash": "^4.17.21", 16 | "normalize.css": "^8.0.1", 17 | "react": "^18.0.0", 18 | "react-art": "18.0.0", 19 | "react-dom": "^18.0.0", 20 | "react-firebaseui": "^6.0.0", 21 | "react-ga": "^3.3.0", 22 | "react-icons": "^4.3.1", 23 | "react-md-spinner": "^1.0.0", 24 | "react-redux": "^7.2.8", 25 | "react-router": "^5.2.1", 26 | "react-router-dom": "^5.3.1", 27 | "react-scripts": "5.0.1", 28 | "react-spring": "^8.0.27", 29 | "react-toggle": "^4.1.2", 30 | "redux": "^4.1.2", 31 | "redux-devtools-extension": "^2.13.9", 32 | "redux-saga": "^1.1.3", 33 | "redux-thunk": "^2.4.1", 34 | "styled-components": "^5.3.5", 35 | "ua-parser-js": "^1.0.2", 36 | "xml-js": "^1.6.11" 37 | }, 38 | "scripts": { 39 | "start": "react-app-rewired start", 40 | "build:production": "react-app-rewired build", 41 | "build": "env-cmd -f .env.development react-app-rewired build", 42 | "test": "react-app-rewired test --env=jsdom", 43 | "test:ci": "CI=true react-app-rewired test --env=jsdom", 44 | "storybook": "start-storybook -p 9009 -s public", 45 | "build-storybook": "build-storybook -s public", 46 | "storybook:visual": "start-storybook -p 9001 -s ./build", 47 | "deploy-storybook": "storybook-to-ghpages", 48 | "test:compile": "tsc --noEmit", 49 | "eslint": "eslint './src/**/*.{ts,tsx}'", 50 | "eslint:fix": "eslint --fix './src/**/*.{ts,tsx}'", 51 | "prettier": "prettier --write './{src,__tests__}/**/*.{ts,tsx}'", 52 | "precommit": "lint-staged" 53 | }, 54 | "devDependencies": { 55 | "@firebase/testing": "0.20.11", 56 | "@storybook/addon-actions": "^6.4.22", 57 | "@storybook/addon-info": "^5.3.21", 58 | "@storybook/addon-links": "^6.4.22", 59 | "@storybook/addons": "^6.4.22", 60 | "@storybook/react": "^6.4.22", 61 | "@storybook/storybook-deployer": "^2.8.11", 62 | "@storybook/theming": "^6.4.22", 63 | "@types/lodash": "^4.14.182", 64 | "@types/node": "^9.6.61", 65 | "@types/react": "^17.0.44", 66 | "@types/react-dom": "^17.0.16", 67 | "@types/react-redux": "^7.1.24", 68 | "@types/react-router-dom": "^5.3.3", 69 | "@types/react-toggle": "^4.0.3", 70 | "@types/storybook__addon-actions": "^3.4.3", 71 | "@types/storybook__react": "^4.0.2", 72 | "@types/styled-components": "^5.1.25", 73 | "@types/ua-parser-js": "^0.7.36", 74 | "babel-loader": "^8.2.5", 75 | "firebase-tools": "10.6.0", 76 | "husky": "^7.0.4", 77 | "jest-junit": "^13.1.0", 78 | "lint-staged": "^12.3.8", 79 | "prettier": "^2.6.2", 80 | "react-app-rewired": "^2.2.1", 81 | "react-docgen-typescript-webpack-plugin": "^1.1.0", 82 | "stream-browserify": "^3.0.0", 83 | "typescript": "4.6.3" 84 | }, 85 | "browserslist": [ 86 | "defaults" 87 | ], 88 | "prettier": { 89 | "semi": true, 90 | "singleQuote": true, 91 | "printWidth": 120, 92 | "trailingComma": "es5" 93 | }, 94 | "lint-staged": { 95 | "*.{ts,tsx}": [ 96 | "eslint --fix", 97 | "git add" 98 | ] 99 | }, 100 | "license": "MIT" 101 | } 102 | -------------------------------------------------------------------------------- /public/.well-known/assetlinks.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "relation": ["delegate_permission/common.handle_all_urls"], 3 | "target": { 4 | "namespace": "android_app", 5 | "package_name": "app.blogfeedback", 6 | "sha256_cert_fingerprints": 7 | ["3A:52:28:3F:1A:CC:4B:69:97:55:A1:4F:16:4E:D7:6F:6E:03:28:39:CA:38:80:EE:E4:BA:66:4C:D7:3A:F8:95"] 8 | } 9 | }] -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ninjinkun/blog-feedback-app/e9264307e3108627fa5dd8fcb91b6c102fc72e12/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ninjinkun/blog-feedback-app/e9264307e3108627fa5dd8fcb91b6c102fc72e12/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ninjinkun/blog-feedback-app/e9264307e3108627fa5dd8fcb91b6c102fc72e12/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ninjinkun/blog-feedback-app/e9264307e3108627fa5dd8fcb91b6c102fc72e12/public/favicon.ico -------------------------------------------------------------------------------- /public/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ninjinkun/blog-feedback-app/e9264307e3108627fa5dd8fcb91b6c102fc72e12/public/icon-192x192.png -------------------------------------------------------------------------------- /public/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ninjinkun/blog-feedback-app/e9264307e3108627fa5dd8fcb91b6c102fc72e12/public/icon-512x512.png -------------------------------------------------------------------------------- /public/images/facebook-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ninjinkun/blog-feedback-app/e9264307e3108627fa5dd8fcb91b6c102fc72e12/public/images/facebook-icon.png -------------------------------------------------------------------------------- /public/images/hatenabookmark-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ninjinkun/blog-feedback-app/e9264307e3108627fa5dd8fcb91b6c102fc72e12/public/images/hatenabookmark-icon.png -------------------------------------------------------------------------------- /public/images/hatenastar-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ninjinkun/blog-feedback-app/e9264307e3108627fa5dd8fcb91b6c102fc72e12/public/images/hatenastar-icon.png -------------------------------------------------------------------------------- /public/images/pocket-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ninjinkun/blog-feedback-app/e9264307e3108627fa5dd8fcb91b6c102fc72e12/public/images/pocket-icon.png -------------------------------------------------------------------------------- /public/images/twincle-perticle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /public/images/twitter-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ninjinkun/blog-feedback-app/e9264307e3108627fa5dd8fcb91b6c102fc72e12/public/images/twitter-icon.png -------------------------------------------------------------------------------- /public/images/welcome-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ninjinkun/blog-feedback-app/e9264307e3108627fa5dd8fcb91b6c102fc72e12/public/images/welcome-image.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | BlogFeedback -ブログの反響を可視化- 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 32 |
33 | 34 | 35 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Feedback", 3 | "name": "BlogFeedback", 4 | "icons": [ 5 | { 6 | "src": "/icon-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/icon-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "rgba(0, 71, 132, 1)", 17 | "background_color": "rgba(0, 71, 132, 1)", 18 | "start_url": "/", 19 | "display": "standalone" 20 | } -------------------------------------------------------------------------------- /public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ninjinkun/blog-feedback-app/e9264307e3108627fa5dd8fcb91b6c102fc72e12/public/mstile-150x150.png -------------------------------------------------------------------------------- /public/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@teppeis" 4 | ] 5 | 6 | } 7 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Provider } from 'react-redux'; 3 | import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; 4 | 5 | import { GlobalStyle } from './components/base-style'; 6 | import ScrollToTop from './components/templates/ScrollToTop/index'; 7 | import SmartphoneLayout from './components/templates/SmartphoneLayout/index'; 8 | import { initializeFirebase } from './firebase'; 9 | import { appStore } from './redux/create-store'; 10 | 11 | import AddBlogPage from './components/pages/AddBlogPage/index'; 12 | import AuthPage from './components/pages/AuthPage/index'; 13 | import BlogsPage from './components/pages/BlogsPage/index'; 14 | import FeedPage from './components/pages/FeedPage/index'; 15 | import IndexPage from './components/pages/IndexPage/index'; 16 | import PrivacyPage from './components/pages/PrivarcyPage/index'; 17 | import SettingPage from './components/pages/SettingPage/index'; 18 | import SettingsPage from './components/pages/SettingsPage/index'; 19 | import SignInPage from './components/pages/SignInPage/index'; 20 | import TermPage from './components/pages/TermPage/index'; 21 | import { initializeGoogleAnalytics } from './ga'; 22 | import withTracker from './withTracker'; 23 | 24 | initializeFirebase(); 25 | initializeGoogleAnalytics(); 26 | 27 | const App = () => ( 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | ); 55 | export default App; 56 | -------------------------------------------------------------------------------- /src/__tests__/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import App from '../App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | }); 9 | -------------------------------------------------------------------------------- /src/__tests__/firebase-rules.test.ts: -------------------------------------------------------------------------------- 1 | import * as firebase from '@firebase/testing'; 2 | import fs from 'fs'; 3 | import { db as firebaseDB, serverTimestamp } from '../models/repositories/app-repository'; 4 | 5 | import { saveBlog } from '../models/repositories/blog-repository'; 6 | import { itemRef, saveItem } from '../models/repositories/item-repository'; 7 | import { userRef } from '../models/repositories/user-repository'; 8 | 9 | const projectIdBase = 'firestore-emulator-example-' + Date.now(); 10 | let testNumber = 0; 11 | 12 | function getProjectId() { 13 | return `${projectIdBase}-${testNumber}`; 14 | } 15 | 16 | function authedApp(auth?: object) { 17 | return firebase 18 | .initializeTestApp({ 19 | projectId: getProjectId(), 20 | auth, 21 | }) 22 | .firestore(); 23 | } 24 | 25 | // mock app's firestore initalizer 26 | jest.mock('../models/repositories/app-repository'); 27 | // firebase/testing mocks serverTimestamp. We need to replace it. 28 | 29 | const rules = fs.readFileSync('firestore.rules', 'utf8'); 30 | 31 | beforeAll(() => { 32 | testNumber++; 33 | 34 | return firebase.loadFirestoreRules({ 35 | projectId: getProjectId(), 36 | rules, 37 | }); 38 | }); 39 | 40 | afterAll(() => Promise.all(firebase.apps().map((app) => app.delete()))); 41 | 42 | describe('/users', () => { 43 | it('save unauthorized user', async () => { 44 | const db = authedApp(undefined); 45 | firebaseDB.mockReturnValue(db); 46 | const user = userRef('ninjinkun'); 47 | await firebase.assertFails(user.set({ birthday: 'January 1' })); 48 | }); 49 | 50 | it('save to invalid collection', async () => { 51 | const db = authedApp({ uid: 'ninjinkun' }); 52 | await firebase.assertFails(db.doc('test/ninjinkun').set({ hoge: 'fuga' })); 53 | }); 54 | }); 55 | 56 | describe('/users/:user_id/blogs', () => { 57 | it('save exepct blogs field', async () => { 58 | const db = authedApp({ uid: 'ninjinkun' }); 59 | firebaseDB.mockReturnValue(db); 60 | const user = userRef('ninjinkun'); 61 | 62 | await firebase.assertFails(user.set({ hoge: 'fuga' })); 63 | }); 64 | 65 | it('save blog', async () => { 66 | const db = authedApp({ uid: 'ninjinkun' }); 67 | firebaseDB.mockReturnValue(db); 68 | serverTimestamp.mockReturnValue(firebase.firestore.FieldValue.serverTimestamp()); 69 | 70 | await firebase.assertSucceeds( 71 | saveBlog( 72 | 'ninjinkun', 73 | 'https://ninjinkun.hatenablog.com/', 74 | "ninjinkun's diary", 75 | 'https://ninjinkun.hatenablog.com/feed', 76 | 'atom', 77 | false, 78 | true, 79 | false, 80 | true, 81 | true, 82 | false, 83 | false 84 | ) 85 | ); 86 | }); 87 | 88 | it('save other users blog', async () => { 89 | const db = authedApp({ uid: 'daikonkun' }); 90 | firebaseDB.mockReturnValue(db); 91 | serverTimestamp.mockReturnValue(firebase.firestore.FieldValue.serverTimestamp()); 92 | 93 | await firebase.assertFails( 94 | saveBlog( 95 | 'ninjinkun', 96 | 'https://ninjinkun.hatenablog.com/', 97 | "ninjinkun's diary", 98 | 'https://ninjinkun.hatenablog.com/feed', 99 | 'atom', 100 | false, 101 | true, 102 | false, 103 | true, 104 | true, 105 | false, 106 | false 107 | ) 108 | ); 109 | }); 110 | }); 111 | 112 | describe('/users/:user_id/blogs/:blog_id/items', () => { 113 | it('save items', async () => { 114 | const db = authedApp({ uid: 'ninjinkun' }); 115 | firebaseDB.mockReturnValue(db); 116 | serverTimestamp.mockReturnValue(firebase.firestore.FieldValue.serverTimestamp()); 117 | 118 | await firebase.assertSucceeds( 119 | saveItem( 120 | 'ninjinkun', 121 | 'https://ninjinkun.hatenablog.com/', 122 | 'https://ninjinkun.hatenablog.com/entry/123456', 123 | 'Developing blog-feedback-app', 124 | new Date(), 125 | { facebook: { count: 10, timestamp: firebase.firestore.Timestamp.now() } }, 126 | {} 127 | ) 128 | ); 129 | }); 130 | 131 | it('save invalid items', async () => { 132 | const db = authedApp({ uid: 'ninjinkun' }); 133 | firebaseDB.mockReturnValue(db); 134 | 135 | const item = itemRef( 136 | 'ninjinkun', 137 | 'https://ninjinkun.hatenablog.com/', 138 | 'https://ninjinkun.hatenablog.com/entry/123457' 139 | ); 140 | await firebase.assertFails( 141 | item.set({ 142 | title: 'Developing blog-feedback-app', 143 | url: 'https://ninjinkun.hatenablog.com/entry/123457', 144 | published: 'hoge', 145 | }) 146 | ); 147 | }); 148 | 149 | it('save invalid count items', async () => { 150 | const db = authedApp({ uid: 'ninjinkun' }); 151 | firebaseDB.mockReturnValue(db); 152 | serverTimestamp.mockReturnValue(firebase.firestore.FieldValue.serverTimestamp()); 153 | 154 | const item = itemRef( 155 | 'ninjinkun', 156 | 'https://ninjinkun.hatenablog.com/', 157 | 'https://ninjinkun.hatenablog.com/entry/123458' 158 | ); 159 | 160 | await firebase.assertFails( 161 | item.set({ 162 | title: 'Developing blog-feedback-app', 163 | url: 'https://ninjinkun.hatenablog.com/entry/123458', 164 | published: new Date(), 165 | count: { facebook: { count: 'fuga', timestamp: serverTimestamp() } }, 166 | }) 167 | ); 168 | }); 169 | }); 170 | -------------------------------------------------------------------------------- /src/__tests__/save-count-response.test.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase/compat/app'; 2 | import 'firebase/compat/firestore'; 3 | import { CountType } from '../models/consts/count-type'; 4 | import { ItemEntity } from '../models/entities'; 5 | import { CountResponse, ItemResponse } from '../models/responses'; 6 | import { createSaveEntities } from '../models/save-count-response'; 7 | 8 | describe('create save entities', () => { 9 | it('equeal', () => { 10 | expect(createSaveEntities(firebaseEntities, feedItemResponse, countsReponse)).toEqual(result); 11 | }); 12 | }); 13 | 14 | const firebaseEntities: ItemEntity[] = [ 15 | { 16 | title: 'Ikyu Frontend Meetupを開催しました', 17 | url: 'https://user-first.ikyu.co.jp/entry/2018/12/14/154352', 18 | published: new firebase.firestore.Timestamp(1544769832, 0), 19 | counts: { 20 | facebook: { count: 4, timestamp: new firebase.firestore.Timestamp(1544787808, 337000000) }, 21 | hatenabookmark: { count: 6, timestamp: new firebase.firestore.Timestamp(1544787808, 337000000) }, 22 | }, 23 | prevCounts: { 24 | facebook: { count: 3, timestamp: new firebase.firestore.Timestamp(1544787295, 410000000) }, 25 | hatenabookmark: { count: 4, timestamp: new firebase.firestore.Timestamp(1544775366, 395000000) }, 26 | }, 27 | }, 28 | ]; 29 | 30 | const feedItemResponse: ItemResponse[] = [ 31 | { 32 | title: 'Ikyu Frontend Meetupを開催しました', 33 | url: 'https://user-first.ikyu.co.jp/entry/2018/12/14/154352', 34 | published: new Date('2018-12-14T06:43:52.000Z'), 35 | }, 36 | ]; 37 | 38 | const countsReponse: CountResponse[] = [ 39 | { url: 'https://user-first.ikyu.co.jp/entry/2018/12/14/154352', count: 4, type: 'facebook' as CountType }, 40 | { url: 'https://user-first.ikyu.co.jp/entry/2018/12/14/154352', count: 18, type: 'hatenabookmark' as CountType }, 41 | ]; 42 | 43 | const result = [ 44 | { 45 | url: 'https://user-first.ikyu.co.jp/entry/2018/12/14/154352', 46 | title: 'Ikyu Frontend Meetupを開催しました', 47 | published: new Date('2018-12-14T06:43:52.000Z'), 48 | itemCounts: { 49 | facebook: { count: 4, timestamp: new firebase.firestore.Timestamp(1544787808, 337000000) }, 50 | hatenabookmark: { count: 18, timestamp: expect.anything() }, 51 | }, 52 | prevCounts: { 53 | facebook: { count: 4, timestamp: new firebase.firestore.Timestamp(1544787808, 337000000) }, 54 | hatenabookmark: { count: 6, timestamp: new firebase.firestore.Timestamp(1544787808, 337000000) }, 55 | }, 56 | }, 57 | ]; 58 | -------------------------------------------------------------------------------- /src/assets/images/facebook-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ninjinkun/blog-feedback-app/e9264307e3108627fa5dd8fcb91b6c102fc72e12/src/assets/images/facebook-icon.png -------------------------------------------------------------------------------- /src/assets/images/feedback_icon120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ninjinkun/blog-feedback-app/e9264307e3108627fa5dd8fcb91b6c102fc72e12/src/assets/images/feedback_icon120.png -------------------------------------------------------------------------------- /src/assets/images/feedback_icon160.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ninjinkun/blog-feedback-app/e9264307e3108627fa5dd8fcb91b6c102fc72e12/src/assets/images/feedback_icon160.png -------------------------------------------------------------------------------- /src/assets/images/hatenabookmark-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ninjinkun/blog-feedback-app/e9264307e3108627fa5dd8fcb91b6c102fc72e12/src/assets/images/hatenabookmark-icon.png -------------------------------------------------------------------------------- /src/assets/images/hatenastar-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ninjinkun/blog-feedback-app/e9264307e3108627fa5dd8fcb91b6c102fc72e12/src/assets/images/hatenastar-icon.png -------------------------------------------------------------------------------- /src/assets/images/pocket-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ninjinkun/blog-feedback-app/e9264307e3108627fa5dd8fcb91b6c102fc72e12/src/assets/images/pocket-icon.png -------------------------------------------------------------------------------- /src/assets/images/twincle-perticle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/assets/images/twitter-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ninjinkun/blog-feedback-app/e9264307e3108627fa5dd8fcb91b6c102fc72e12/src/assets/images/twitter-icon.png -------------------------------------------------------------------------------- /src/assets/images/welcome-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ninjinkun/blog-feedback-app/e9264307e3108627fa5dd8fcb91b6c102fc72e12/src/assets/images/welcome-image.png -------------------------------------------------------------------------------- /src/components/atoms/Anker/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import * as properties from '../../properties'; 3 | 4 | const Anker = styled.a` 5 | color: ${properties.colors.link}; 6 | font-weight: ${properties.fontWeights.bold}; 7 | text-decoration: underline; 8 | &:link { 9 | color: ${properties.colors.link}; 10 | font-weight: ${properties.fontWeights.bold}; 11 | } 12 | &:visited { 13 | color: ${properties.colors.link}; 14 | font-weight: ${properties.fontWeights.bold}; 15 | } 16 | `; 17 | 18 | export default Anker; 19 | -------------------------------------------------------------------------------- /src/components/atoms/Button/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import { storiesOf } from '@storybook/react'; 2 | import React from 'react'; 3 | import Button, { PrimaryButton, WarningButton } from './index'; 4 | 5 | storiesOf('atoms/Button', module) 6 | .add('デフォルト', () => ) 7 | .add('プライマリ', () => プライマリ) 8 | .add('警告', () => 警告); 9 | -------------------------------------------------------------------------------- /src/components/atoms/Button/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import * as properties from '../../properties'; 3 | 4 | const baseStyle = ` 5 | border-radius: 4px; 6 | border-width: 0; 7 | display: flex; 8 | font-weight: 700; 9 | font-size: 1rem; 10 | line-height: 1; 11 | padding: 0.8rem; 12 | text-decoration: none; 13 | align-items: center; 14 | cursor: pointer; 15 | border-radius: 4px; 16 | text-align: center; 17 | transition: ${properties.fadeAnimation}; 18 | &:hover { 19 | opacity: ${properties.hoverFeedbackOpacity}; 20 | } 21 | `; 22 | 23 | const ButtonBase = styled.button` 24 | ${baseStyle} 25 | `; 26 | 27 | const AnkerButtonBase = styled.a` 28 | ${baseStyle} 29 | `; 30 | 31 | const buttonStyle = ` 32 | background-color: inherit; 33 | border: ${properties.border}; 34 | color: ${properties.colors.gray}; 35 | `; 36 | 37 | export const AnkerButton = styled(AnkerButtonBase)` 38 | ${buttonStyle} 39 | `; 40 | 41 | export const Button = styled(ButtonBase)` 42 | ${buttonStyle} 43 | `; 44 | 45 | const primaryButtonStyle = ` 46 | background-color: ${properties.colors.primary}; 47 | color: ${properties.colors.white}; 48 | &:link { 49 | color: white; 50 | } 51 | &:visited { 52 | color: white; 53 | } 54 | `; 55 | 56 | export const PrimaryAnkerButton = styled(AnkerButtonBase)` 57 | ${primaryButtonStyle} 58 | `; 59 | 60 | export const PrimaryButton = styled(ButtonBase)` 61 | ${primaryButtonStyle} 62 | `; 63 | 64 | export const WarningButton = styled(ButtonBase)` 65 | background-color: ${properties.colors.warning}; 66 | color: ${properties.colors.white}; 67 | `; 68 | 69 | export default Button; 70 | -------------------------------------------------------------------------------- /src/components/atoms/CountUpAnimation/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import { storiesOf } from '@storybook/react'; 2 | import React from 'react'; 3 | import CountUp from './index'; 4 | 5 | storiesOf('atoms/CountUpAnimation', module).add('default', () => ( 6 | 7 | {(count) =>
{CountQueuingStrategy}
} 8 |
9 | )); 10 | -------------------------------------------------------------------------------- /src/components/atoms/CountUpAnimation/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { animated, useSpring } from 'react-spring'; 3 | 4 | type ChildRenderer = (count: number) => React.ReactNode; 5 | 6 | type Props = { 7 | start: number; 8 | end: number; 9 | children: ChildRenderer; 10 | }; 11 | 12 | const CountUp: React.FunctionComponent = ({ children, start, end, ...props }) => { 13 | const style = useSpring<{ value: number }>({ 14 | from: { value: start }, 15 | value: end, 16 | config: { duration: 500, easing: (t) => t }, 17 | }); 18 | return {children && children(style.value.interpolate((v) => Math.round(v)))}; 19 | }; 20 | 21 | export default CountUp; 22 | -------------------------------------------------------------------------------- /src/components/atoms/Favicon/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import { storiesOf } from '@storybook/react'; 2 | import React from 'react'; 3 | import Favicon from './index'; 4 | 5 | const favicon: string = 6 | 'https://cdn.image.st-hatena.com/image/favicon/a6840b49a29b5d04ac068e459e7ddc8676bdc3db/version=1/https%3A%2F%2Fcdn.user.blog.st-hatena.com%2Fcustom_blog_icon%2F115833353%2F1514258254698267'; 7 | 8 | storiesOf('atoms/Favicon', module).add('デフォルト', () => ); 9 | -------------------------------------------------------------------------------- /src/components/atoms/Favicon/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { faviconSize } from '../../properties'; 3 | 4 | const Favicon = styled.img` 5 | width: ${faviconSize}; 6 | height: ${faviconSize}; 7 | max-width: ${faviconSize}; 8 | max-height: ${faviconSize}; 9 | `; 10 | 11 | export default Favicon; 12 | -------------------------------------------------------------------------------- /src/components/atoms/ScrollView/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import { storiesOf } from '@storybook/react'; 2 | import React from 'react'; 3 | import ScrollView from './index'; 4 | 5 | storiesOf('atoms/ScrollView', module).add('default', () => ( 6 | 7 | {[...Array(1000).keys()].map((i) => ( 8 | {i} 9 | ))} 10 | 11 | )); 12 | -------------------------------------------------------------------------------- /src/components/atoms/ScrollView/index.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | // inspired from ScrollView via React Native for Web 4 | const ScrollView = styled.div` 5 | display: flex; 6 | flex-direction: column; 7 | overflow-y: scroll; 8 | overflow-x: hidden; 9 | transform: translateZ(0px); 10 | flex-shrink: 0; 11 | flex-grow: 1; 12 | flex-basis: auto; 13 | box-sizing: border-box; 14 | -webkit-overflow-scrolling: touch; 15 | `; 16 | 17 | export default ScrollView; 18 | -------------------------------------------------------------------------------- /src/components/atoms/ServiceIcon/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import { storiesOf } from '@storybook/react'; 2 | import React from 'react'; 3 | import { CountType } from '../../../models/consts/count-type'; 4 | import ServiceIcon from './index'; 5 | 6 | storiesOf('atoms/ServiceIcon', module) 7 | .add('Twitter', () => ) 8 | .add('Facebook', () => ) 9 | .add('HatenaBookmark', () => ); 10 | -------------------------------------------------------------------------------- /src/components/atoms/ServiceIcon/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { CountType } from './../../../models/consts/count-type'; 4 | 5 | type Props = { 6 | type: CountType; 7 | }; 8 | 9 | const ServiceIcon = ({ ...props }: Props) => { 10 | switch (props.type) { 11 | case CountType.Twitter: 12 | return ; 13 | case CountType.CountJsoon: 14 | return ; 15 | case CountType.Facebook: 16 | return ; 17 | case CountType.HatenaBookmark: 18 | return ; 19 | case CountType.HatenaStar: 20 | return ; 21 | case CountType.Pocket: 22 | return ; 23 | default: 24 | return null; 25 | } 26 | }; 27 | 28 | export default ServiceIcon; 29 | 30 | export const TwitterIcon = ({ ...props }) => ; 31 | export const FacebookIcon = ({ ...props }) => ; 32 | export const HatenaBookmarkIcon = ({ ...props }) => ; 33 | export const HatenaStarIcon = ({ ...props }) => ; 34 | export const PocketIcon = ({ ...props }) => ; 35 | 36 | const Img = styled.img` 37 | width: 24px; 38 | height: 24px; 39 | min-width: 24px; 40 | min-height: 24px; 41 | `; 42 | -------------------------------------------------------------------------------- /src/components/atoms/Spinner/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import { storiesOf } from '@storybook/react'; 2 | import React from 'react'; 3 | import Spinner from './index'; 4 | 5 | storiesOf('atoms/Spinner', module).add('default', () => ); 6 | -------------------------------------------------------------------------------- /src/components/atoms/Spinner/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import MDSpinner from 'react-md-spinner'; 3 | import styled from 'styled-components'; 4 | import { colorsBlanding } from '../../properties'; 5 | 6 | const Spinner = styled(MDSpinner)` 7 | width: 100%; 8 | height: 100%; 9 | display: flex; 10 | position: relative; 11 | `; 12 | 13 | const ColoredSpinner: React.FunctionComponent = (...props) => ( 14 | 15 | ); 16 | 17 | export default ColoredSpinner; 18 | -------------------------------------------------------------------------------- /src/components/atoms/TwincleAnimation/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import { storiesOf } from '@storybook/react'; 2 | import React from 'react'; 3 | import styled from 'styled-components'; 4 | import Twincle from './index'; 5 | 6 | storiesOf('atoms/TwincleAnimation', module) 7 | .add('animate', () => ( 8 | 9 | 10 | 11 | )) 12 | .add('no animate', () => ( 13 | 14 | 15 | 16 | )); 17 | 18 | const Content = styled.div` 19 | width: 200px; 20 | height: 30px; 21 | background: #373737; 22 | border-radius: 7px; 23 | box-shadow: rgba(0, 0, 0, 0.3) 0px 5px 15px 0px; 24 | `; 25 | -------------------------------------------------------------------------------- /src/components/atoms/TwincleAnimation/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled, { keyframes } from 'styled-components'; 3 | 4 | type Props = { 5 | animate: boolean; 6 | twincleNum?: number; 7 | }; 8 | 9 | const TwincleAnimation: React.FC = ({ children, animate, twincleNum = 8, ...props }) => ( 10 | 11 | {animate ? [...Array(twincleNum).keys()].map((i) => ) : undefined} 12 | {children} 13 | 14 | ); 15 | 16 | export default TwincleAnimation; 17 | 18 | const AnimatedSpark: React.FC<{}> = (props) => { 19 | const [top, left] = [randomInt(0, 100), randomInt(0, 100)]; 20 | const [toTranlateX, toTranslateY] = [randomInt(-10, 10), randomInt(-10, 10)]; 21 | const rotate360 = keyframes` 22 | 0% { 23 | transform: translate3d(0, 0, 0) rotate(0deg); 24 | opacity: 0; 25 | } 26 | 30% { 27 | opacity: 1; 28 | } 29 | 50% { 30 | opacity: 1; 31 | } 32 | 30% { 33 | opacity: 1; 34 | } 35 | 100% { 36 | transform: translate3d(${toTranlateX}px, ${toTranslateY}px, 0) rotate(${randomInt(180, 360)}deg); 37 | opacity: 0; 38 | } 39 | `; 40 | const StyledSpark = styled(Spark)` 41 | top: ${top}%; 42 | left: ${left}%; 43 | position: absolute; 44 | will-change: transform; 45 | animation: ${rotate360} ${randomFloat(1, 2)}s linear infinite; 46 | z-index: 100; 47 | height: 24px; 48 | width: 24px; 49 | pointer-events: none; 50 | margin: -9px -9px 0 0; 51 | `; 52 | return ; 53 | }; 54 | 55 | function randomInt(min: number, max: number) { 56 | return Math.floor(Math.random() * (max - min + 1)) + min; 57 | } 58 | 59 | function randomFloat(min: number, max: number) { 60 | return Math.random() * (max - min + 1) + min; 61 | } 62 | 63 | const Spark = (props: any) => twincle particle; 64 | 65 | const Wrapper = styled.div` 66 | position: relative; 67 | display: flex; 68 | flex-direction: column; 69 | flex-wrap: wrap; 70 | `; 71 | -------------------------------------------------------------------------------- /src/components/atoms/Wrapper/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Wrapper = styled.div` 4 | display: flex; 5 | flex-direction: column; 6 | `; 7 | 8 | export default Wrapper; 9 | -------------------------------------------------------------------------------- /src/components/base-style.ts: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from 'styled-components'; 2 | import { UAParser } from 'ua-parser-js'; 3 | import { colors, fontFamily } from './properties'; 4 | 5 | const parser = new UAParser(); 6 | const result = parser.getResult(); 7 | // Android Chrome kills their "pull to refresh" when overscroll-behavior-y is none. 8 | const isAndroidChrome = result.browser.name === 'Chrome' && result.os.name === 'Android'; 9 | 10 | export const GlobalStyle = createGlobalStyle` 11 | html { 12 | width: 100%; 13 | height: 100%; 14 | } 15 | body { 16 | width: 100%; 17 | height: 100%; 18 | overflow-y: scroll; 19 | overscroll-behavior-y: ${isAndroidChrome ? 'auto' : 'none'}; 20 | overflow-x: hidden; 21 | -webkit-overflow-scrolling: touch; 22 | margin: 0; 23 | } 24 | #root { 25 | height: 100vh; 26 | font-family: ${fontFamily}; 27 | } 28 | a { 29 | text-decoration: none; 30 | } 31 | a:link { 32 | color: ${colors.black}; 33 | } 34 | a:visited { 35 | color: ${colors.black}; 36 | } 37 | `; 38 | -------------------------------------------------------------------------------- /src/components/molecules/CountButton/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import { storiesOf } from '@storybook/react'; 2 | import React from 'react'; 3 | import { CountType } from '../../../models/consts/count-type'; 4 | import CountButton from './index'; 5 | 6 | storiesOf('molecules/CountButton', module) 7 | .add('Twitter', () => ) 8 | .add('Facebook', () => ) 9 | .add('HatenaBookmark', () => ); 10 | -------------------------------------------------------------------------------- /src/components/molecules/CountButton/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { animated } from 'react-spring'; 3 | import styled from 'styled-components'; 4 | import { CountType } from '../../../models/consts/count-type'; 5 | import { AnkerButton } from '../../atoms/Button'; 6 | import ServiceIcon from '../../atoms/ServiceIcon'; 7 | import * as properties from '../../properties'; 8 | 9 | type Props = { 10 | href?: string; 11 | target?: string; 12 | type: CountType; 13 | count?: number; 14 | }; 15 | 16 | const CountButton: React.FunctionComponent = ({ count, type, children, href, ...props }) => ( 17 | 18 | 19 | {count !== undefined ? {count} : -} 20 | {children} 21 | 22 | ); 23 | 24 | export default CountButton; 25 | 26 | const StyledButton = styled(AnkerButton)` 27 | padding: 0.2rem; 28 | font-size: ${properties.fontSizes.s}; 29 | background: linear-gradient(${properties.colorsValue.grayPale}, #eee); 30 | border: 1px solid #ccc; 31 | color: ${properties.colorsValue.grayDark}; 32 | `; 33 | 34 | const CuontLabel = styled(animated.span)` 35 | margin-left: 0.2rem; 36 | border-radius: 8px; 37 | width: 100%; 38 | `; 39 | 40 | const UndefinedCuontLabel = styled(CuontLabel)` 41 | color: ${properties.colors.gray}; 42 | `; 43 | -------------------------------------------------------------------------------- /src/components/molecules/HeaderLoadingIndicator/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import { storiesOf } from '@storybook/react'; 2 | import React, { useState } from 'react'; 3 | import styled from 'styled-components'; 4 | import HeaederLoadingIndicator from './index'; 5 | 6 | type Item = { 7 | label: string; 8 | ratio: number; 9 | loading: boolean; 10 | }; 11 | 12 | const initialItems = [ 13 | { 14 | label: '', 15 | ratio: 0, 16 | loading: false, 17 | }, 18 | { 19 | label: 'はてなブックマークを読み込んでいます', 20 | ratio: 20, 21 | loading: true, 22 | }, 23 | { 24 | label: 'Facebookを読み込んでいます', 25 | ratio: 50, 26 | loading: true, 27 | }, 28 | { 29 | label: 'Pocketを読み込んでいます', 30 | ratio: 80, 31 | loading: true, 32 | }, 33 | { 34 | label: '', 35 | ratio: 100, 36 | loading: false, 37 | }, 38 | ]; 39 | 40 | const Test: React.FC = (props) => { 41 | const [index, setIndex] = useState(0); 42 | const [item, setItems] = useState(initialItems[index]); 43 | 44 | const clickButton = () => { 45 | setIndex(index === initialItems.length - 1 ? 0 : index + 1); 46 | setItems(initialItems[index]); 47 | }; 48 | 49 | return ( 50 | 51 | 52 | 53 | ); 54 | }; 55 | 56 | storiesOf('molecules/HeaederLoadingIndicator', module).add('default', () => ); 57 | 58 | const Wrapper = styled.div` 59 | width: 100%; 60 | `; 61 | -------------------------------------------------------------------------------- /src/components/molecules/HeaderLoadingIndicator/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import MDSpinner from 'react-md-spinner'; 3 | import { animated, useSpring, useTransition } from 'react-spring'; 4 | import styled from 'styled-components'; 5 | import * as properties from '../../properties'; 6 | 7 | type Props = { 8 | ratio?: number; 9 | label?: string; 10 | loading: boolean; 11 | }; 12 | 13 | const HeaderLoadingIndicator: React.FC = ({ loading, label, ratio, ...props }) => { 14 | const [prevLabel, setPrevLabel] = useState(undefined); 15 | 16 | if (label !== prevLabel) { 17 | setPrevLabel(label); 18 | } 19 | const spring = useSpring({ 20 | backgroundColor: loading ? properties.colorsValue.grayDark : properties.colorsBlanding.accent, 21 | }); 22 | const trans = useTransition([label, prevLabel], (t) => t || '', { 23 | from: { opacity: 0, transform: `translate3d(0, -100%, 0)` }, 24 | enter: { opacity: 1, transform: `translate3d(0, 0, 0)` }, 25 | // tslint:disable-next-line:jsx-alignment 26 | leave: { opacity: 0, transform: `translate3d(0, 100%, 0)` }, 27 | }); 28 | return ( 29 | 30 | 31 | 32 | {loading ? : undefined} 33 | 34 | {trans.map(({ item, props, key }, i) => ( 35 | 38 | ))} 39 | 40 | {loading ? ( 41 | 42 | {`${ratio}%`} 43 | 44 | ) : null} 45 | 46 | 47 | 48 | ); 49 | }; 50 | 51 | export default HeaderLoadingIndicator; 52 | 53 | const Content = styled.div` 54 | display: grid; 55 | grid-template-columns: 40px 4fr 40px; 56 | grid-template-areas: 'spinner label ratio'; 57 | width: 100%; 58 | height: 100%; 59 | max-width: 600px; 60 | overflow: hidden; 61 | min-height: 20px; 62 | margin-left: auto; 63 | margin-right: auto; 64 | `; 65 | 66 | const Wrapper = styled.div` 67 | width: 100%; 68 | color: white; 69 | will-change: opacity; 70 | `; 71 | 72 | const SpinnerWrapper = styled.div` 73 | grid-area: spinner; 74 | display: flex; 75 | justify-content: flex-start; 76 | align-items: center; 77 | margin-left: 4px; 78 | `; 79 | 80 | const Spinner = styled(MDSpinner)``; 81 | 82 | const LabelWrapper = styled.div` 83 | grid-area: label; 84 | justify-content: center; 85 | align-items: center; 86 | position: relative; 87 | `; 88 | 89 | const Label = styled(animated.div)` 90 | width: 100%; 91 | height: 100%; 92 | display: flex; 93 | justify-content: center; 94 | align-items: center; 95 | position: absolute; 96 | will-change: transform, opacity; 97 | overflow: hidden; 98 | font-size: ${properties.fontSizes.s}; 99 | `; 100 | 101 | const Ratio = styled.div` 102 | display: flex; 103 | font-size: ${properties.fontSizes.s}; 104 | grid-area: ratio; 105 | margin-right: 4px; 106 | justify-content: flex-end; 107 | align-items: center; 108 | `; 109 | -------------------------------------------------------------------------------- /src/components/molecules/LoadingView/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import { storiesOf } from '@storybook/react'; 2 | import React from 'react'; 3 | import LoadingView from './index'; 4 | 5 | storiesOf('molecules/LoadingView', module).add('default', () => ); 6 | -------------------------------------------------------------------------------- /src/components/molecules/LoadingView/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import Spinner from '../../atoms/Spinner/index'; 5 | import Wrapper from '../../atoms/Wrapper/index'; 6 | 7 | const LoadingView: React.FunctionComponent = (...props) => ( 8 | 9 | 10 | 11 | ); 12 | export default LoadingView; 13 | 14 | const SpinnerContainer = styled(Wrapper)` 15 | width: 100%; 16 | height: 100%; 17 | align-items: center; 18 | padding: 16px 0; 19 | `; 20 | -------------------------------------------------------------------------------- /src/components/molecules/PlainCell/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import { storiesOf } from '@storybook/react'; 2 | import React from 'react'; 3 | import PlainCell from './index'; 4 | 5 | storiesOf('molecules/PlainCell', module).add('default', () => ( 6 | 7 |
PlainCell
8 |
9 | )); 10 | -------------------------------------------------------------------------------- /src/components/molecules/PlainCell/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import * as properties from '../../properties'; 4 | 5 | const PlainCell: React.FunctionComponent = ({ children, ...props }) => ( 6 | 7 | {children} 8 | 9 | 10 | ); 11 | 12 | export default PlainCell; 13 | 14 | const CellWrapper = styled.div` 15 | display: flex; 16 | flex-direction: column; 17 | padding: ${properties.baseMargin}; 18 | background-color: ${properties.colors.white}; 19 | margin: 0; 20 | padding: 16px 8px 0px 8px; 21 | transition: ${properties.hoverAnimation}; 22 | &:hover { 23 | background-color: ${properties.colors.hover}; 24 | } 25 | `; 26 | 27 | const ContentWrapper = styled.div` 28 | display: flex; 29 | flex-direction: row; 30 | align-items: unset; 31 | `; 32 | 33 | const Underline = styled.span` 34 | border-bottom: ${properties.border}; 35 | margin: 8px 0 0 ${8 + 16}px; 36 | `; 37 | -------------------------------------------------------------------------------- /src/components/organisms/AddBlogForm/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import { action } from '@storybook/addon-actions'; 2 | import { storiesOf } from '@storybook/react'; 3 | import React from 'react'; 4 | import AddBlogForm from './index'; 5 | 6 | const handleSubmit = (url: string, enabled: boolean) => action(`submit ${url} ${enabled}`)(); 7 | 8 | storiesOf('organisms/AddBlogForm', module) 9 | .add('default', () => ) 10 | .add('loading', () => ) 11 | .add('error', () => ); 12 | -------------------------------------------------------------------------------- /src/components/organisms/AddBlogForm/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { MdError } from 'react-icons/md'; 3 | import Toggle from 'react-toggle'; 4 | import 'react-toggle/style.css'; 5 | import styled from 'styled-components'; 6 | import { PrimaryButton } from '../../atoms/Button'; 7 | import Spinner from '../../atoms/Spinner/index'; 8 | import Wrapper from '../../atoms/Wrapper/index'; 9 | import * as properties from '../../properties'; 10 | 11 | type Props = { 12 | handleSubmit: (url: string, reportMailEnabled: boolean) => any; 13 | loading: boolean; 14 | errorMessage?: string; 15 | url?: string; 16 | clearURL?: () => void; 17 | }; 18 | 19 | const AddBlogForm: React.FC = (props) => { 20 | const [inputURL, setInputURL] = useState(''); 21 | const [reportMailEnabled, setReportMailEnabled] = useState(true); 22 | const { loading, errorMessage, url, clearURL } = props; 23 | 24 | const handleSubmit = (event: React.FormEvent) => { 25 | event.preventDefault(); 26 | props.handleSubmit(url || inputURL, reportMailEnabled); 27 | }; 28 | 29 | return ( 30 | 31 | ) => handleSubmit(e)}> 32 | 33 | ブログのURLを入力してください 34 | ) => { 39 | if (clearURL) { 40 | clearURL(); 41 | } 42 | setInputURL((e.target as HTMLInputElement).value); 43 | }} 44 | /> 45 | 46 | 47 | 48 | デイリーレポートメールを購読する (α版) 49 | 50 | 毎朝シェア数が増加しているとメールが届きます。 51 |
52 | この設定はブログの設定画面から変更できます。 53 |
54 |
55 | ) => 61 | setReportMailEnabled((e.target as HTMLInputElement).checked) 62 | } 63 | /> 64 |
65 | 66 |
67 | {errorMessage ? ( 68 | 69 | {errorMessage} 70 | 71 | ) : null} 72 | {loading ? ( 73 | 74 | 75 | 76 | ) : null} 77 |
78 | ); 79 | }; 80 | 81 | export default AddBlogForm; 82 | 83 | const StyledWrapper = styled(Wrapper)` 84 | background-color: ${properties.colors.grayPale}; 85 | align-items: center; 86 | padding: 16px; 87 | `; 88 | 89 | const Text = styled.p` 90 | font-size: ${properties.fontSizes.m}; 91 | margin: 16px; 92 | `; 93 | 94 | const SpinnerWrapper = styled(Wrapper)` 95 | margin: 16px; 96 | `; 97 | 98 | const ErrorIcon = styled(MdError)` 99 | color: ${properties.colors.red}; 100 | margin-right: 4px; 101 | `; 102 | 103 | const ErrorWrapper = styled(Wrapper)` 104 | flex-direction: row; 105 | margin: 16px; 106 | background: #ffecec; 107 | border: 1px solid #f5aca6; 108 | border-radius: 4px; 109 | padding: 8px; 110 | color: ${properties.colors.grayDark}; 111 | align-items: center; 112 | font-size: ${properties.fontSizes.s}; 113 | `; 114 | 115 | const StyledForm = styled.form` 116 | display: flex; 117 | flex-direction: column; 118 | align-items: center; 119 | width: 100%; 120 | `; 121 | 122 | const StyledLabel = styled.label` 123 | display: flex; 124 | flex-direction: column; 125 | width: 100%; 126 | align-items: center; 127 | justify-content: center; 128 | `; 129 | 130 | const URLField = styled.input` 131 | margin: 16px; 132 | padding: 8px; 133 | width: 100%; 134 | display: inline-block; 135 | box-sizing: border-box; 136 | font-size: ${properties.fontSizes.m}; 137 | border: 1px solid ${properties.colors.grayLight}; 138 | border-radius: 4px; 139 | `; 140 | 141 | const ReportMailWrapper = styled(Wrapper)` 142 | flex-direction: row; 143 | border: 1px solid ${properties.colors.grayLight}; 144 | padding: 8px; 145 | border-radius: 4px; 146 | align-items: center; 147 | margin-bottom: 16px; 148 | `; 149 | 150 | const ReportMailLabel = styled.label` 151 | display: flex; 152 | flex-direction: column; 153 | `; 154 | 155 | const ReprotMailTitle = styled.span` 156 | font-size: ${properties.fontSizes.s}; 157 | font-weight: ${properties.fontWeights.bold}; 158 | `; 159 | 160 | const ReprotMailDescription = styled.p` 161 | font-size: ${properties.fontSizes.xs}; 162 | margin: 4px 0; 163 | `; 164 | 165 | const Switch = styled(Toggle)` 166 | margin-left: 12px; 167 | `; 168 | -------------------------------------------------------------------------------- /src/components/organisms/AnimatedCountButton/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import { storiesOf } from '@storybook/react'; 2 | import React from 'react'; 3 | import { CountType } from '../../../models/consts/count-type'; 4 | import AnimatedCountButton from './index'; 5 | 6 | storiesOf('organisms/AnimatedCountButton', module) 7 | .add('not animate', () => ) 8 | .add('animate', () => ); 9 | -------------------------------------------------------------------------------- /src/components/organisms/AnimatedCountButton/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import { CountType } from '../../../models/consts/count-type'; 5 | import CountUpAnimation from '../../atoms/CountUpAnimation/index'; 6 | import TwincleAnimation from '../../atoms/TwincleAnimation/index'; 7 | import CountButton from '../../molecules/CountButton/index'; 8 | 9 | type Props = { 10 | animate: boolean; 11 | type: CountType; 12 | count?: number; 13 | href?: string; 14 | target?: string; 15 | }; 16 | 17 | type States = { 18 | prevCount?: number; 19 | }; 20 | 21 | const AnimatedCountButton: React.FC = (props) => { 22 | const [state, setState] = useState({}); 23 | const { animate, type, count, href, target } = props; 24 | const { prevCount } = state; 25 | 26 | if (count !== undefined) { 27 | if (prevCount !== count) { 28 | setState({ prevCount: count }); 29 | } 30 | return ( 31 | 32 | 33 | {(value) => } 34 | 35 | 36 | ); 37 | } else { 38 | return ; 39 | } 40 | }; 41 | 42 | export default AnimatedCountButton; 43 | 44 | const StyledTwincleAnimation = styled(TwincleAnimation)` 45 | display: flex; 46 | position: relative; 47 | flex-grow: 1; 48 | `; 49 | 50 | const StyledCountButton = styled(CountButton)` 51 | position: relative; 52 | margin: 0.2rem; 53 | flex-grow: 1; 54 | `; 55 | -------------------------------------------------------------------------------- /src/components/organisms/BlogCell/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import { storiesOf } from '@storybook/react'; 2 | import React from 'react'; 3 | import styled from 'styled-components'; 4 | import BlogCell from './index'; 5 | 6 | const favicon: string = 7 | 'https://cdn.image.st-hatena.com/image/favicon/a6840b49a29b5d04ac068e459e7ddc8676bdc3db/version=1/https%3A%2F%2Fcdn.user.blog.st-hatena.com%2Fcustom_blog_icon%2F115833353%2F1514258254698267'; 8 | 9 | const Background = styled.div` 10 | height: 100%; 11 | width: 100%; 12 | `; 13 | 14 | storiesOf('organisms/BlogCell', module).add('default', () => ( 15 | 16 | 17 | 18 | )); 19 | -------------------------------------------------------------------------------- /src/components/organisms/BlogCell/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import Favicon from '../../atoms/Favicon/index'; 4 | import PlainCell from '../../molecules/PlainCell/index'; 5 | import * as properties from '../../properties'; 6 | 7 | type Props = { 8 | favicon: string; 9 | title: string; 10 | }; 11 | 12 | const BlogCell = ({ favicon, title, ...props }: Props) => ( 13 | 14 | 15 | {title} 16 | 17 | ); 18 | 19 | export default BlogCell; 20 | 21 | const Title = styled.h3` 22 | font-size: ${properties.fontSizes.m}; 23 | margin: 0 8px 8px 8px; 24 | `; 25 | -------------------------------------------------------------------------------- /src/components/organisms/EntryCell/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import { storiesOf } from '@storybook/react'; 2 | import React from 'react'; 3 | import styled from 'styled-components'; 4 | import { CountType } from '../../../models/consts/count-type'; 5 | import EntryCell from './index'; 6 | 7 | const counts = [ 8 | { type: CountType.Twitter, count: undefined, animate: false }, 9 | { type: CountType.Facebook, count: 100, animate: true }, 10 | { type: CountType.HatenaBookmark, count: 1000, animate: false }, 11 | ]; 12 | 13 | const favicon: string = 14 | 'https://cdn.image.st-hatena.com/image/favicon/a6840b49a29b5d04ac068e459e7ddc8676bdc3db/version=1/https%3A%2F%2Fcdn.user.blog.st-hatena.com%2Fcustom_blog_icon%2F115833353%2F1514258254698267'; 15 | 16 | const Background = styled.div` 17 | background-color: #eee; 18 | height: 100%; 19 | width: 100%; 20 | `; 21 | 22 | storiesOf('organisms/EntryCell', module) 23 | .add('default', () => ( 24 | 25 | 26 | 27 | )) 28 | .add('animate', () => ( 29 | 30 | 31 | 32 | )); 33 | -------------------------------------------------------------------------------- /src/components/organisms/EntryCell/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { CountType, toServiceURL } from '../../../models/consts/count-type'; 4 | import Favicon from '../../atoms/Favicon/index'; 5 | import * as properties from '../../properties'; 6 | import AnimatedCountButton from '../AnimatedCountButton/index'; 7 | 8 | export type Count = { 9 | type: CountType; 10 | count?: number; 11 | animate: boolean; 12 | }; 13 | 14 | type Props = { 15 | favicon: string; 16 | title: string; 17 | counts: Count[]; 18 | url: string; 19 | }; 20 | 21 | const EntryCell: React.FunctionComponent = ({ favicon, title, counts, url, ...props }) => ( 22 | 23 | 24 | 25 | {title} 26 | 27 | {counts.map((count) => ( 28 | 36 | ))} 37 | 38 | 39 | 40 | ); 41 | 42 | export default EntryCell; 43 | 44 | const AnkerWrapper = styled.a` 45 | display: flex; 46 | flex-direction: row; 47 | padding: 8px; 48 | background: ${properties.colorsValue.white}; 49 | border: ${properties.border}; 50 | margin: 6px 12px; 51 | box-sizing: border-box; 52 | box-shadow: 0 0 1px 0 #e1e1e1; 53 | &:first-child { 54 | margin: 12px 12px 6px 12px; 55 | } 56 | &:last-child { 57 | margin: 6px 12px 12px 12px; 58 | } 59 | transition: ${properties.hoverAnimation}; 60 | &:hover { 61 | background-color: ${properties.colors.hover}; 62 | } 63 | `; 64 | 65 | const ContentWrapper = styled.div` 66 | display: flex; 67 | flex-direction: column; 68 | width: 100%; 69 | `; 70 | 71 | const Title = styled.h3` 72 | font-size: ${properties.fontSizes.m}; 73 | margin: 0 8px 8px 8px; 74 | `; 75 | 76 | const ButtonWrapper = styled.div` 77 | display: flex; 78 | width: 100%; 79 | justify-content: stretch; 80 | flex-wrap: wrap; 81 | `; 82 | -------------------------------------------------------------------------------- /src/components/organisms/Header/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import { storiesOf } from '@storybook/react'; 2 | import React from 'react'; 3 | import Header from './index'; 4 | 5 | storiesOf('organisms/Header', module) 6 | .add('default', () =>
) 7 | .add('Long Title', () => ( 8 |
9 | )); 10 | -------------------------------------------------------------------------------- /src/components/organisms/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FiPlus } from 'react-icons/fi'; 3 | import { MdArrowBack, MdSettings } from 'react-icons/md'; 4 | import styled from 'styled-components'; 5 | import * as properties from '../../properties'; 6 | 7 | import { NavLink } from 'react-router-dom'; 8 | import HeaderLoadingIndicator from '../../molecules/HeaderLoadingIndicator/index'; 9 | 10 | export type HeaderProps = { 11 | title: string; 12 | loadingLabel?: string; 13 | loadingRatio?: number; 14 | loading?: boolean; 15 | backButtonLink?: string; 16 | addButtonLink?: string; 17 | settingButtonLink?: string; 18 | }; 19 | 20 | const Header = ({ 21 | backButtonLink, 22 | addButtonLink, 23 | settingButtonLink, 24 | loadingRatio, 25 | loadingLabel, 26 | loading, 27 | title, 28 | ...props 29 | }: HeaderProps) => ( 30 | 31 | 32 | 33 | {backButtonLink ? ( 34 | 35 | 36 | 37 | ) : settingButtonLink ? ( 38 | 39 | 40 | 41 | ) : ( 42 | 43 | )} 44 | 45 | {title} 46 | 47 | {addButtonLink ? ( 48 | 49 | 50 | 51 | ) : ( 52 | 53 | )} 54 | 55 | 56 | 57 | ); 58 | 59 | export default Header; 60 | 61 | const HeaderLayout = styled.header` 62 | font-family: ${properties.fontFamily}; 63 | height: ${properties.headerHeight}; 64 | display: flex; 65 | flex-direction: column; 66 | border-width: 0; 67 | background-color: ${properties.colorsBlanding.accent}; 68 | color: white; 69 | `; 70 | 71 | const HeaderContent = styled.div` 72 | display: flex; 73 | flex-direction: row; 74 | box-sizing: border-box; 75 | flex-basis: 100%; 76 | align-items: center; 77 | margin-left: auto; 78 | margin-right: auto; 79 | width: 100%; 80 | max-width: 600px; 81 | `; 82 | 83 | const TitleLayout = styled.div` 84 | justify-content: center; 85 | display: flex; 86 | box-pack: center; 87 | box-sizing: border-box; 88 | flex: 1 1 auto; 89 | overflow: hidden; 90 | `; 91 | 92 | const Title = styled.h1` 93 | font-size: 1.25rem; 94 | font-weight: bold; 95 | white-space: nowrap; 96 | overflow: hidden; 97 | `; 98 | 99 | const StyledLink = styled(NavLink)` 100 | display: flex; 101 | width: 40px; 102 | color: white; 103 | align-items: center; 104 | &:link { 105 | color: white; 106 | } 107 | &:visited { 108 | color: white; 109 | } 110 | `; 111 | 112 | const BackButton = styled(MdArrowBack)` 113 | cursor: pointer; 114 | padding: 8px; 115 | flex: 0 0 auto; 116 | `; 117 | 118 | const AddButton = styled(FiPlus)` 119 | cursor: pointer; 120 | padding: 8px; 121 | flex: 0 0 auto; 122 | `; 123 | 124 | const SettingsButton = styled(MdSettings)` 125 | cursor: pointer; 126 | padding: 8px; 127 | flex: 0 0 auto; 128 | `; 129 | 130 | const Spacer = styled.div` 131 | width: 40px; 132 | padding: 8px; 133 | `; 134 | 135 | const UnderLine = styled.div` 136 | background-color: rgba(0, 0, 0, 0.298039); 137 | display: flex; 138 | height: ${properties.lineWidth}; 139 | flex-basis: ${properties.lineWidth}; 140 | `; 141 | -------------------------------------------------------------------------------- /src/components/organisms/SettingCell/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import styled from 'styled-components'; 3 | import Wrapper from '../../atoms/Wrapper/index'; 4 | import PlainCell from '../../molecules/PlainCell/index'; 5 | import * as properties from '../../properties'; 6 | 7 | type Props = { 8 | LeftIcon?: ReactElement<{}>; 9 | RightIcon?: ReactElement<{}>; 10 | title: string; 11 | description?: ReactElement<{}>; 12 | }; 13 | 14 | const SettingCell: React.FunctionComponent = ({ LeftIcon, RightIcon, title, description, ...props }) => ( 15 | 16 | 17 | 18 | {LeftIcon} 19 | 20 | {title} 21 | {description} 22 | 23 | 24 | {RightIcon} 25 | 26 | 27 | ); 28 | 29 | export default SettingCell; 30 | 31 | const Title = styled.h3` 32 | font-size: ${properties.fontSizes.m}; 33 | margin: 0 8px 8px 0px; 34 | `; 35 | 36 | const CountentWrapper = styled(Wrapper)` 37 | flex-direction: row; 38 | justify-content: space-between; 39 | width: 100%; 40 | align-items: center; 41 | `; 42 | 43 | const Left = styled(Wrapper)` 44 | flex-direction: row; 45 | width: 100%; 46 | `; 47 | 48 | const Right = styled(Wrapper)` 49 | flex-direction: row; 50 | margin-left: 12px; 51 | `; 52 | 53 | const TitleWrapper = styled.div` 54 | flex-direction: row; 55 | margin-left: 8px; 56 | `; 57 | 58 | const Description = styled.div` 59 | font-size: ${properties.fontSizes.s}; 60 | margin: 0; 61 | `; 62 | -------------------------------------------------------------------------------- /src/components/organisms/SettingSectionHeader/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import { storiesOf } from '@storybook/react'; 2 | import React from 'react'; 3 | import SettingSectionHeader from './index'; 4 | 5 | storiesOf('organisms/SettingSectionHeader', module).add('default', () => ( 6 | Blog Settings 7 | )); 8 | -------------------------------------------------------------------------------- /src/components/organisms/SettingSectionHeader/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import * as properties from '../../properties'; 3 | 4 | const SectionHeader = styled.div` 5 | display: flex; 6 | width: 100%; 7 | padding: 25px 16px 8px 8px; 8 | font-weight: ${properties.fontWeights.bold}; 9 | font-size: ${properties.fontSizes.s}; 10 | color: ${properties.colors.gray}; 11 | background-color: ${properties.colors.grayPale}; 12 | border-top: 1px solid ${properties.colors.grayLight}; 13 | border-bottom: 1px solid ${properties.colors.grayLight}; 14 | position: relative; 15 | top: -1px; 16 | `; 17 | 18 | export default SectionHeader; 19 | -------------------------------------------------------------------------------- /src/components/pages/AddBlogPage/index.tsx: -------------------------------------------------------------------------------- 1 | import { getAuth } from '@firebase/auth'; 2 | import React, { useEffect, useState } from 'react'; 3 | import { useDispatch, useSelector } from 'react-redux'; 4 | import { Redirect, RouteComponentProps } from 'react-router-dom'; 5 | import styled from 'styled-components'; 6 | import { AddBlogState, addBlogSlice, addBlog } from '../../../redux/slices/add-blog'; 7 | import { AppState } from '../../../redux/app-reducer'; 8 | import { BlogState } from '../../../redux/slices/blog'; 9 | import Button from '../../atoms/Button/index'; 10 | import Wrapper from '../../atoms/Wrapper/index'; 11 | import AddBlogForm from '../../organisms/AddBlogForm/index'; 12 | import * as properties from '../../properties'; 13 | import PageLayout from '../../templates/PageLayout/index'; 14 | 15 | type States = { 16 | fillInURL?: string; 17 | }; 18 | 19 | const AddBlogPage: React.FC = () => { 20 | const [state, setState] = useState({}); 21 | const addBlogState = useSelector((state) => state.addBlog); 22 | const blogState = useSelector((state) => state.blog); 23 | const dispatch = useDispatch(); 24 | 25 | useEffect(() => { 26 | return () => { 27 | dispatch(addBlogSlice.actions.reset()); 28 | }; 29 | }, [dispatch]); 30 | 31 | const handleSubmit = (url: string, reportMailEnabled: boolean) => { 32 | dispatch(addBlog(getAuth(), url, reportMailEnabled)); 33 | }; 34 | 35 | const fillIn = (url: string) => { 36 | setState({ fillInURL: url }); 37 | }; 38 | 39 | const clearURL = () => { 40 | setState({ fillInURL: undefined }); 41 | }; 42 | 43 | const { loading, error, finished, blogURL } = addBlogState; 44 | const { blogs } = blogState; 45 | if (finished && blogURL) { 46 | return ; 47 | } else { 48 | return ( 49 | 55 | 56 | handleSubmit(url, reportMailEnabled)} 58 | loading={loading} 59 | errorMessage={ 60 | error && `${error.message}(エラーが続く場合はRSSのURLを直接入力するとうまくいくことがあります)` 61 | } 62 | url={state.fillInURL} 63 | clearURL={() => clearURL()} 64 | /> 65 | 66 | {!(blogs !== undefined && blogs.length) && ( 67 | 68 | 69 | まず試してみたい場合は、以下からおすすめブログのURLを入力できます 70 | fillIn('https://user-first.ikyu.co.jp/')}> 71 | 一休.com Developers Blog 72 | 73 | fillIn('https://ninjinkun.hatenablog.com/')}> 74 | ninjinkun's diary 75 | 76 | 77 | 78 | )} 79 | 80 | ); 81 | } 82 | }; 83 | 84 | const FormWrapper = styled(Wrapper)` 85 | margin-top: 20vh; 86 | `; 87 | 88 | const SuggestionWrapper = styled(Wrapper)` 89 | align-items: center; 90 | `; 91 | 92 | const SuggestionContentWrapper = styled(Wrapper)` 93 | align-items: center; 94 | margin: 16px; 95 | padding: 16px; 96 | border: 1px dashed ${properties.colors.gray}; 97 | border-radius: 4px; 98 | width: fit-content; 99 | `; 100 | 101 | const SuggestionText = styled.p` 102 | font-size: ${properties.fontSizes.s}; 103 | margin-top: 0; 104 | `; 105 | 106 | const SeggestionButton = styled(Button)` 107 | margin: 4px; 108 | `; 109 | 110 | export default AddBlogPage; 111 | -------------------------------------------------------------------------------- /src/components/pages/AuthPage/index.tsx: -------------------------------------------------------------------------------- 1 | import { getAuth } from '@firebase/auth'; 2 | import React, { Fragment, useEffect } from 'react'; 3 | import { useSelector, useDispatch } from 'react-redux'; 4 | import { Redirect, RouteComponentProps, withRouter } from 'react-router-dom'; 5 | import { UserState, fetchUser } from '../../../redux/slices/user'; 6 | import { AppState } from '../../../redux/app-reducer'; 7 | import LoadingView from '../../molecules/LoadingView/index'; 8 | import PageLayout from '../../templates/PageLayout/index'; 9 | 10 | const AuthPage: React.FC = (props) => { 11 | const userState = useSelector((state) => state.user); 12 | const dispatch = useDispatch(); 13 | 14 | const { children, location } = props; 15 | 16 | useEffect(() => { 17 | dispatch(fetchUser(getAuth())); 18 | return () => undefined; 19 | }, [dispatch]); 20 | 21 | const { user, loading } = userState; 22 | if (user) { 23 | return {children}; 24 | } else if (loading) { 25 | return ( 26 | 31 | 32 | 33 | ); 34 | } else { 35 | return ; 36 | } 37 | }; 38 | 39 | export default withRouter(AuthPage); 40 | -------------------------------------------------------------------------------- /src/components/pages/BlogsPage/index.tsx: -------------------------------------------------------------------------------- 1 | import { getAuth } from '@firebase/auth'; 2 | import React, { useEffect } from 'react'; 3 | import styled from 'styled-components'; 4 | 5 | import { useDispatch, useSelector } from 'react-redux'; 6 | import { Link, RouteComponentProps } from 'react-router-dom'; 7 | import { AppState } from '../../../redux/app-reducer'; 8 | import { BlogState, fetchBlogs } from '../../../redux/slices/blog'; 9 | import { PrimaryAnkerButton } from '../../atoms/Button/index'; 10 | import ScrollView from '../../atoms/ScrollView/index'; 11 | import Wrapper from '../../atoms/Wrapper/index'; 12 | import LoadingView from '../../molecules/LoadingView/index'; 13 | import BlogCell from '../../organisms/BlogCell/index'; 14 | import * as properties from '../../properties'; 15 | import PageLayout from '../../templates/PageLayout/index'; 16 | 17 | const BlogsPage: React.FC = () => { 18 | const blog = useSelector((state) => state.blog); 19 | const dispatch = useDispatch(); 20 | useEffect(() => { 21 | dispatch(fetchBlogs(getAuth())); 22 | }, [dispatch]); 23 | 24 | const { blogs, loading } = blog; 25 | return ( 26 | 33 | {(() => { 34 | if (blogs && blogs.length) { 35 | return ( 36 | 37 | {blogs.map((blog) => ( 38 | 39 | 40 | 41 | ))} 42 | 43 | ); 44 | } else if (!loading && blogs && blogs.length === 0) { 45 | return ( 46 | 47 | ご登録ありがとうございます 48 | 49 |

ブログを追加して利用を開始しましょう

50 | ブログを追加する 51 |
52 | ); 53 | } else { 54 | return ; 55 | } 56 | })()} 57 |
58 | ); 59 | }; 60 | 61 | export default BlogsPage; 62 | 63 | const StyledScrollView = styled(ScrollView)` 64 | background-color: white; 65 | min-height: 100%; 66 | `; 67 | 68 | const AddBlogWrapper = styled(Wrapper)` 69 | justify-content: center; 70 | align-items: center; 71 | padding: 16px; 72 | `; 73 | 74 | const StyledPrimaryButton = styled(PrimaryAnkerButton)` 75 | justify-content: center; 76 | `; 77 | 78 | const WelcomeImage = styled.img` 79 | width: 235px; 80 | height: 185px; 81 | margin: 16px; 82 | `; 83 | 84 | const Title = styled.h2` 85 | margin-top: 16px; 86 | color: ${properties.colors.grayDark}; 87 | `; 88 | -------------------------------------------------------------------------------- /src/components/pages/IndexPage/index.tsx: -------------------------------------------------------------------------------- 1 | import { getAuth } from '@firebase/auth'; 2 | import React, { useEffect } from 'react'; 3 | 4 | import { useDispatch, useSelector } from 'react-redux'; 5 | import { Redirect, RouteComponentProps } from 'react-router-dom'; 6 | import { AppState } from '../../../redux/app-reducer'; 7 | import { UserState, fetchUser } from '../../../redux/slices/user'; 8 | import LoadingView from '../../molecules/LoadingView/index'; 9 | import PageLayout from '../../templates/PageLayout/index'; 10 | import WelcomePage from '../WelcomePage/index'; 11 | 12 | const IndexPage: React.FC = () => { 13 | const user = useSelector((state) => state.user); 14 | const dispatch = useDispatch(); 15 | useEffect(() => { 16 | dispatch(fetchUser(getAuth())); 17 | return () => undefined; 18 | }, [dispatch]); 19 | 20 | const { loading, user: userData } = user; 21 | if (loading) { 22 | return ( 23 | 28 | 29 | 30 | ); 31 | } else if (userData) { 32 | return ; 33 | } else { 34 | return ; 35 | } 36 | }; 37 | 38 | export default IndexPage; 39 | -------------------------------------------------------------------------------- /src/components/pages/PrivarcyPage/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import { storiesOf } from '@storybook/react'; 2 | import React from 'react'; 3 | import { BrowserRouter as Router, Route } from 'react-router-dom'; 4 | import PrivacyPage from './index'; 5 | 6 | storiesOf('pages/PrivacyPage', module).add('defalut', () => ( 7 | 8 | 9 | 10 | )); 11 | -------------------------------------------------------------------------------- /src/components/pages/SettingsPage/index.tsx: -------------------------------------------------------------------------------- 1 | import { getAuth } from '@firebase/auth'; 2 | import React, { useEffect } from 'react'; 3 | import styled from 'styled-components'; 4 | 5 | import { FiGithub } from 'react-icons/fi'; 6 | import { MdAssignment, MdAssignmentInd, MdLaunch } from 'react-icons/md'; 7 | import { useDispatch, useSelector } from 'react-redux'; 8 | import { RouteComponentProps } from 'react-router'; 9 | import { Link } from 'react-router-dom'; 10 | import { signOut } from '../../../redux/slices/user'; 11 | import { AppState } from '../../../redux/app-reducer'; 12 | import { BlogState, fetchBlogs } from '../../../redux/slices/blog'; 13 | import { Button } from '../../atoms/Button/index'; 14 | import ScrollView from '../../atoms/ScrollView/index'; 15 | import Wrapper from '../../atoms/Wrapper/index'; 16 | import LoadingView from '../../molecules/LoadingView/index'; 17 | import BlogCell from '../../organisms/BlogCell/index'; 18 | import SettingCell from '../../organisms/SettingCell/index'; 19 | import SectionHeader from '../../organisms/SettingSectionHeader/index'; 20 | import * as properties from '../../properties'; 21 | import PageLayout from '../../templates/PageLayout/index'; 22 | 23 | const SettingsPage: React.FC = () => { 24 | const blogState = useSelector((state) => state.blog); 25 | const { blogs, loading } = blogState; 26 | const dispatch = useDispatch(); 27 | 28 | useEffect(() => { 29 | dispatch(fetchBlogs(getAuth())); 30 | return () => undefined; 31 | }, [dispatch]); 32 | 33 | const blogCells = ( 34 | 35 | {(blogs && blogs.length) || loading ? ブログの設定 : undefined} 36 | {(() => { 37 | if (blogs && blogs.length) { 38 | return blogs.map((blog) => ( 39 | 40 | 41 | 42 | )); 43 | } else if (loading) { 44 | return ; 45 | } 46 | })()} 47 | 48 | ); 49 | 50 | return ( 51 | 57 | 58 | {blogCells} 59 | ユーザーの設定 60 | 61 | dispatch(signOut(getAuth()))}>ログアウト 62 | 63 | サービスの情報 64 | 65 | } /> 66 | 67 | 68 | } /> 69 | 70 | 71 | } 74 | RightIcon={} 75 | /> 76 | 77 | 78 | 79 | ); 80 | }; 81 | 82 | export default SettingsPage; 83 | 84 | const SignOutButtonWrapper = styled(Wrapper)` 85 | padding: 16px; 86 | `; 87 | 88 | const SignOutButton = styled(Button)` 89 | width: 100%; 90 | justify-content: center; 91 | `; 92 | 93 | const StyledScrollView = styled(ScrollView)` 94 | background-color: ${properties.colors.white}; 95 | min-height: 100%; 96 | `; 97 | -------------------------------------------------------------------------------- /src/components/pages/SignInPage/index.tsx: -------------------------------------------------------------------------------- 1 | import { getAuth } from '@firebase/auth'; 2 | import React, { useEffect } from 'react'; 3 | import styled from 'styled-components'; 4 | import firebase from 'firebase/compat/app'; 5 | import 'firebase/compat/auth'; 6 | 7 | import { Location } from 'history'; 8 | import { StyledFirebaseAuth } from 'react-firebaseui'; 9 | import { useDispatch, useSelector } from 'react-redux'; 10 | import { Redirect, RouteComponentProps } from 'react-router-dom'; 11 | import { UserState, fetchUser } from '../../../redux/slices/user'; 12 | import { AppState } from '../../../redux/app-reducer'; 13 | import Anker from '../../atoms/Anker/index'; 14 | import Wrapper from '../../atoms/Wrapper/index'; 15 | import LoadingView from '../../molecules/LoadingView/index'; 16 | import * as properties from '../../properties'; 17 | import PageLayout from '../../templates/PageLayout/index'; 18 | 19 | type Props = RouteComponentProps<{}, {}, { from?: Location }>; 20 | 21 | const SignInPage: React.FC = (props) => { 22 | const userState = useSelector((state) => state.user); 23 | const dispatch = useDispatch(); 24 | 25 | useEffect(() => { 26 | dispatch(fetchUser(getAuth())); 27 | return () => undefined; 28 | }, [dispatch]); 29 | 30 | const { loading, user } = userState; 31 | if (user) { 32 | const from = (props.location.state && props.location.state.from) || 'blogs'; 33 | return ; 34 | } else { 35 | return ( 36 | 42 | {(() => { 43 | if (loading && !user) { 44 | return ; 45 | } else { 46 | return ( 47 | 48 | 49 | 50 | 51 | 52 | 続行すると、 53 | 54 | 利用規約 55 | 56 | および 57 | 58 | プライバシーポリシー 59 | 60 | に同意したことになります。 61 | 62 | 63 | SNSログインの情報は認証とメールアドレスの登録のみに使用されます。無断でSNSに投稿されることはありません。 64 | 65 | 登録したデータはプライベートになり、他のユーザーから閲覧されることはありません。 66 | 67 | 68 | ); 69 | } 70 | })()} 71 | 72 | ); 73 | } 74 | }; 75 | 76 | export default SignInPage; 77 | 78 | // Configure FirebaseUI. 79 | const uiConfig = { 80 | signInFlow: 'popup', 81 | // Redirect to /signedIn after sign in is successful. Alternatively you can provide a callbacks.signInSuccess function. 82 | signInSuccessUrl: '/signin', 83 | // We will display Google and Facebook as auth providers. 84 | signInOptions: [ 85 | firebase.auth.TwitterAuthProvider.PROVIDER_ID, 86 | firebase.auth.FacebookAuthProvider.PROVIDER_ID, 87 | firebase.auth.GoogleAuthProvider.PROVIDER_ID, 88 | ], 89 | }; 90 | 91 | const StyledWrapper = styled(Wrapper)` 92 | align-items: center; 93 | margin-top: 16px; 94 | `; 95 | 96 | const TextWrapper = styled(Wrapper)` 97 | max-width: 360px; 98 | padding: 16px 24px 0 24px; 99 | `; 100 | 101 | const Text = styled.p` 102 | font-size: ${properties.fontSizes.s}; 103 | color: ${properties.colors.grayDark}; 104 | line-height: 1.4em; 105 | margin: 0.5em 0; 106 | `; 107 | -------------------------------------------------------------------------------- /src/components/pages/TermPage/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import { storiesOf } from '@storybook/react'; 2 | import React from 'react'; 3 | import { BrowserRouter as Router, Route } from 'react-router-dom'; 4 | import TermPage from './index'; 5 | 6 | storiesOf('pages/TermPage', module).add('defalut', () => ( 7 | 8 | 9 | 10 | )); 11 | -------------------------------------------------------------------------------- /src/components/pages/WelcomePage/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import { storiesOf } from '@storybook/react'; 2 | import React from 'react'; 3 | import WelcomePage from './index'; 4 | 5 | storiesOf('pages/WelcomePage', module).add('defalu', () => ); 6 | -------------------------------------------------------------------------------- /src/components/pages/WelcomePage/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import Anker from '../../atoms/Anker/index'; 4 | import { PrimaryAnkerButton } from '../../atoms/Button/index'; 5 | import ScrollView from '../../atoms/ScrollView/index'; 6 | import Wrapper from '../../atoms/Wrapper/index'; 7 | import * as properties from '../../properties'; 8 | import PageLayout from '../../templates/PageLayout/index'; 9 | 10 | const WelcomePage = () => ( 11 | 16 | 17 | 18 | BlogFeedbackへようこそ! 19 | 20 | BlogFeedbackはブログのソーシャルボタンの数を集計し、反響を確認できるサービスです。 21 | 22 | 23 | 30 | 31 | 32 | ユーザー登録 / ログインへ進む(無料) 33 | 34 | 35 | 36 | 利用規約 37 | 38 | / 39 | 40 | プライバシーポリシー 41 | 42 | 43 | 44 | 45 | GitHub 46 | 47 | 48 | 49 | 50 | 51 | ); 52 | 53 | export default WelcomePage; 54 | 55 | const StyledScrollView = styled(ScrollView)` 56 | padding: 16px; 57 | min-height: 100%; 58 | `; 59 | 60 | const Title = styled.h2` 61 | color: ${properties.colors.grayDark}; 62 | font-size: ${properties.fontSizes.xl}; 63 | `; 64 | 65 | const BodyWrapper = styled(Wrapper)` 66 | font-size: ${properties.fontSizes.m}; 67 | align-items: center; 68 | `; 69 | 70 | const MessageWrapper = styled(Wrapper)` 71 | color: ${properties.colors.grayDark}; 72 | font-size: ${properties.fontSizes.m}; 73 | max-width: 30em; 74 | line-height: 1.5em; 75 | `; 76 | 77 | const ImageWrapper = styled(Wrapper)` 78 | margin: 24px 16px 16px 16px; 79 | `; 80 | 81 | const DemoVideo = styled.iframe` 82 | width: 270px; 83 | height: 584px; 84 | box-shadow: 0 0 4px 0 ${properties.colors.grayLight}; 85 | `; 86 | 87 | const SigninButtonWrapper = styled(Wrapper)` 88 | justify-content: center; 89 | margin: 24px 16px 12px 16px; 90 | `; 91 | 92 | const TermAndPrivacyWrapper = styled(Wrapper)` 93 | flex-direction: row; 94 | margin: 8px 0 8px 0; 95 | font-size: ${properties.fontSizes.s}; 96 | `; 97 | 98 | const Slash = styled.p` 99 | margin: 0 0.3em 0 0.3em; 100 | `; 101 | 102 | const Text = styled.p` 103 | font-size: ${properties.fontSizes.s}; 104 | color: ${properties.colors.grayDark}; 105 | line-height: 1.4em; 106 | margin: 0.5em 0; 107 | `; 108 | -------------------------------------------------------------------------------- /src/components/properties.ts: -------------------------------------------------------------------------------- 1 | export const colorsValue = { 2 | white: '#fff', 3 | black: 'rgb(20, 23, 26)', 4 | gray: '#8c8c8c', 5 | grayDark: '#1a1a1a', 6 | grayLight: '#ddd', 7 | grayPale: '#f6f6f6', 8 | green: '#51c300', 9 | red: '#f0163a', 10 | }; 11 | 12 | export const colorsBlanding = { 13 | base: colorsValue.white, 14 | link: '#365899', 15 | linkVisited: 'none', 16 | linkHover: 'none', 17 | linkActive: 'none', 18 | success: 'none', 19 | danger: 'none', 20 | warning: colorsValue.red, 21 | info: colorsValue.green, 22 | primary: colorsValue.green, 23 | secondary: 'none', 24 | accent: 'rgba(0, 71, 132, 1)', 25 | selected: colorsValue.grayPale, 26 | hover: 'rgb(245, 248, 250)', 27 | }; 28 | 29 | export const colorsMedia = { 30 | text: colorsValue.black, 31 | text_outlined: colorsValue.white, 32 | tip: colorsValue.grayDark, 33 | line: colorsValue.grayLight, 34 | infoLayer1: colorsValue.grayPale, 35 | infoLayer2: colorsValue.white, 36 | card: colorsValue.white, 37 | }; 38 | 39 | export const colors = { ...colorsValue, ...colorsBlanding, ...colorsMedia }; 40 | 41 | export const fontSizes = { 42 | xxs: 'none', 43 | xs: '.6rem', 44 | s: '.8rem', 45 | m: '1rem', 46 | l: '1.2rem', 47 | xl: '1.4rem', 48 | xxl: '1.6rem', 49 | xxxl: '1.8rem', 50 | xxxxl: '2.0rem', 51 | }; 52 | 53 | export const fontWeights = { 54 | default: 400, 55 | bold: 700, 56 | }; 57 | 58 | export const space = '0.5rem'; 59 | 60 | export const headerHeight = '64px'; 61 | export const lineWidth = '1px'; 62 | export const lineStyle = 'solid'; 63 | export const border = `${lineWidth} ${lineStyle} ${colorsMedia.line}`; 64 | 65 | export const hoverFeedbackOpacity = 0.7; 66 | export const hoverAnimationDuration = '.1s'; 67 | export const hoverAnimationTiming = 'ease-out'; 68 | export const hoverAnimation = `${hoverAnimationDuration} ${hoverAnimationTiming}`; 69 | 70 | export const fadeAnimationDuration = '.2s'; 71 | export const fadeAnimationTiming = 'liner'; 72 | export const fadeAnimation = `${fadeAnimationDuration} ${fadeAnimationTiming}`; 73 | 74 | export const zHeader = 10; 75 | 76 | export const breakPointS = 'min-width: 768px'; 77 | 78 | export const fontFamily = 79 | '-apple-system, ".SFNSText-Regular", "San Francisco", BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", "Lucida Grande", Arial, sans-serif'; 80 | 81 | export const faviconSize = '16px'; 82 | export const baseMargin = '8px'; 83 | -------------------------------------------------------------------------------- /src/components/templates/PageLayout/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import Wrapper from '../../atoms/Wrapper/index'; 4 | import Header, { HeaderProps } from '../../organisms/Header/index'; 5 | 6 | type Props = { header: HeaderProps }; 7 | 8 | const PageLayout: React.FunctionComponent = ({ children, header, ...props }) => { 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 | {children} 16 | 17 | ); 18 | }; 19 | 20 | export default PageLayout; 21 | 22 | const BodyWrapper = styled(Wrapper)` 23 | min-height: 100vh; 24 | `; 25 | 26 | const FixedHeader = styled(Header)` 27 | position: fixed; 28 | width: 100%; 29 | display: flex; 30 | flex-direction: column; 31 | left: 0; 32 | right: 0; 33 | `; 34 | 35 | const HeaderWrapper = styled(Wrapper)` 36 | z-index: 100; 37 | position: relative; 38 | width: 100%; 39 | align-items: center; 40 | `; 41 | 42 | const HeaderSpacer = styled.div` 43 | min-height: 64px; 44 | width: 100%; 45 | `; 46 | -------------------------------------------------------------------------------- /src/components/templates/ScrollToTop/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { RouteComponentProps, withRouter } from 'react-router-dom'; 3 | 4 | /** 5 | * This hack is from 6 | * https://reacttraining.com/react-router/web/guides/scroll-restoration 7 | */ 8 | type Props = RouteComponentProps<{}>; 9 | 10 | class ScrollToTop extends React.Component { 11 | componentDidUpdate(prevProps: Props) { 12 | if (this.props.location !== prevProps.location) { 13 | window.scrollTo(0, 0); 14 | } 15 | } 16 | 17 | render() { 18 | return this.props.children; 19 | } 20 | } 21 | 22 | export default withRouter(ScrollToTop); 23 | -------------------------------------------------------------------------------- /src/components/templates/SmartphoneLayout/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import { storiesOf } from '@storybook/react'; 2 | import React from 'react'; 3 | import SmartphoneLayout from './index'; 4 | 5 | storiesOf('templates/SmartphoneLayout', module).add('default', () => brabrabra); 6 | -------------------------------------------------------------------------------- /src/components/templates/SmartphoneLayout/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import * as properties from '../../properties'; 4 | 5 | const SmartphoneLayout: React.FunctionComponent<{}> = ({ children, ...props }) => ( 6 | 7 | {children} 8 | 9 | ); 10 | 11 | export default SmartphoneLayout; 12 | 13 | const Content = styled.div` 14 | max-width: 600px; 15 | margin-left: auto; 16 | margin-right: auto; 17 | border-left: solid 1px ${properties.colorsValue.grayLight}; 18 | border-right: solid 1px ${properties.colorsValue.grayLight}; 19 | `; 20 | 21 | const Background = styled.div` 22 | background: ${properties.colorsValue.grayPale}; 23 | width: 100%; 24 | min-height: 100vh; 25 | `; 26 | -------------------------------------------------------------------------------- /src/firebase.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase/compat/app'; 2 | import 'firebase/compat/auth'; 3 | import 'firebase/compat/firestore'; 4 | import 'firebase/compat/functions'; 5 | import 'firebase/compat/performance'; 6 | 7 | export function initializeFirebase() { 8 | validateDotEnv(); 9 | 10 | const config = { 11 | apiKey: process.env.REACT_APP_FIREBASE_API_KEY, 12 | authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN, 13 | databaseURL: process.env.REACT_APP_FIREBASE_DATABASE_URL, 14 | projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID, 15 | storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET, 16 | messagingSenderId: process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID, 17 | appID: process.env.REACT_APP_FIREBASE_APP_ID, 18 | }; 19 | firebase.initializeApp(config); 20 | 21 | // firebase.performance(app); 22 | } 23 | 24 | const DotEnvKeys = [ 25 | 'REACT_APP_FIREBASE_API_KEY', 26 | 'REACT_APP_FIREBASE_AUTH_DOMAIN', 27 | 'REACT_APP_FIREBASE_DATABASE_URL', 28 | 'REACT_APP_FIREBASE_STORAGE_BUCKET', 29 | 'REACT_APP_FIREBASE_MESSAGING_SENDER_ID', 30 | ]; 31 | 32 | function validateDotEnv() { 33 | if (!process.env) { 34 | throw new Error(`.env file is missing.`); 35 | } 36 | for (const key of DotEnvKeys) { 37 | if (!process.env[key]) { 38 | throw new Error(`${key} is missing in .env file.`); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/ga.ts: -------------------------------------------------------------------------------- 1 | import ReactGA from 'react-ga'; 2 | 3 | export function initializeGoogleAnalytics() { 4 | switch (process.env.NODE_ENV) { 5 | case 'production': 6 | ReactGA.initialize('UA-36926308-2'); 7 | break; 8 | case 'development': 9 | ReactGA.initialize('', { debug: true }); 10 | break; 11 | case 'test': 12 | ReactGA.initialize('', { testMode: true }); 13 | break; 14 | default: 15 | break; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import 'normalize.css'; 2 | import React from 'react'; 3 | import { render } from 'react-dom'; 4 | 5 | import App from './App'; 6 | import { register } from './serviceWorker'; 7 | 8 | // App registration and rendering 9 | render(, document.getElementById('root')); 10 | 11 | register(); 12 | -------------------------------------------------------------------------------- /src/models/consts/count-type.ts: -------------------------------------------------------------------------------- 1 | export enum CountType { 2 | Twitter = 'twitter', 3 | CountJsoon = 'countjsoon', 4 | Facebook = 'facebook', 5 | HatenaBookmark = 'hatenabookmark', 6 | HatenaStar = 'hatenastar', 7 | Pocket = 'pocket', 8 | } 9 | 10 | export function toServiceURL(type: CountType, url: string): string | null { 11 | switch (type) { 12 | case CountType.Twitter: 13 | return `https://twitter.com/search?q=${encodeURIComponent(url)}&f=live`; 14 | case CountType.CountJsoon: 15 | return `https://twitter.com/search?q=${encodeURIComponent(url)}&f=live`; 16 | case CountType.HatenaBookmark: 17 | if (url.match(/^https/)) { 18 | return `https://b.hatena.ne.jp/entry/s/${url.replace(/^https:\/\//, '')}`; 19 | } else { 20 | return `https://b.hatena.ne.jp/entry/${url.replace(/^http:\/\//, '')}`; 21 | } 22 | case CountType.HatenaStar: 23 | return `https://s.hatena.ne.jp/mobile/entry?uri=${encodeURIComponent(url)}`; 24 | default: 25 | return null; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/models/consts/feed-type.ts: -------------------------------------------------------------------------------- 1 | export enum FeedType { 2 | RSS = 'rss', 3 | Atom = 'atom', 4 | } 5 | -------------------------------------------------------------------------------- /src/models/consts/feeds/atom.ts: -------------------------------------------------------------------------------- 1 | export type Atom = { 2 | feed: Feed; 3 | }; 4 | 5 | type Feed = { 6 | title: Text; 7 | link: Link | Link[]; 8 | entry: Entry[]; 9 | }; 10 | 11 | type Entry = { 12 | id: Text; 13 | link: Link | Link[]; 14 | title: Text | FeedBurnerTitle; 15 | updated: Text; 16 | published?: Text; 17 | 'feedburner:origLink'?: Text; 18 | }; 19 | 20 | export type Link = { 21 | _attributes: { 22 | href: string; 23 | rel?: string; 24 | }; 25 | }; 26 | 27 | type Text = { 28 | _text: string; 29 | }; 30 | 31 | type FeedBurnerTitle = { 32 | _cdata: string; 33 | }; 34 | -------------------------------------------------------------------------------- /src/models/consts/feeds/feed.ts: -------------------------------------------------------------------------------- 1 | import { Atom } from './atom'; 2 | import { RSS1 } from './rss1'; 3 | import { RSS2 } from './rss2'; 4 | 5 | // xml-js decoded types 6 | export type Feed = RSS1 | RSS2 | Atom; 7 | -------------------------------------------------------------------------------- /src/models/consts/feeds/rss1.ts: -------------------------------------------------------------------------------- 1 | export type RSS1 = { 2 | 'rdf:RDF': RSS; 3 | }; 4 | 5 | type RSS = { 6 | item: Item[]; 7 | channel: Channel; 8 | }; 9 | 10 | type Channel = { 11 | title: Text; 12 | link: Text; 13 | }; 14 | 15 | type Item = { 16 | title: Text; 17 | link: Text; 18 | 'dc:date': Text; 19 | }; 20 | 21 | type Text = { 22 | _text: string; 23 | }; 24 | -------------------------------------------------------------------------------- /src/models/consts/feeds/rss2.ts: -------------------------------------------------------------------------------- 1 | export type RSS2 = { 2 | rss: RSS; 3 | }; 4 | 5 | type RSS = { 6 | channel: Channel; 7 | }; 8 | 9 | type Channel = { 10 | title: Text; 11 | link: Text; 12 | item: Item[]; 13 | }; 14 | 15 | type Item = { 16 | title: Text; 17 | link: Text; 18 | pubDate: Text; 19 | }; 20 | 21 | type Text = { 22 | _text: string; 23 | _cdata?: string; 24 | }; 25 | -------------------------------------------------------------------------------- /src/models/consts/fetch-response/hatena-star.ts: -------------------------------------------------------------------------------- 1 | export type HatenaStarReponse = { 2 | entries: Entry[]; 3 | }; 4 | 5 | type Entry = { 6 | uri: string; 7 | stars?: Star[]; 8 | colored_stars?: ColorStar[]; 9 | }; 10 | 11 | type ColorStar = { 12 | color: string; 13 | stars: Star[]; 14 | }; 15 | 16 | type Star = { 17 | name: string; 18 | quote: string; 19 | }; 20 | -------------------------------------------------------------------------------- /src/models/entities.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase/compat/app'; 2 | import 'firebase/compat/auth'; 3 | import 'firebase/compat/firestore'; 4 | 5 | import { FeedType } from './consts/feed-type'; 6 | 7 | export type BlogEntity = { 8 | title: string; 9 | url: string; 10 | feedURL: string; 11 | feedType: FeedType; 12 | services?: Services; 13 | sendReport: boolean; 14 | }; 15 | 16 | export type Services = { 17 | twitter: boolean; 18 | countjsoon: boolean; 19 | facebook: boolean; 20 | hatenabookmark: boolean; 21 | hatenastar: boolean; 22 | pocket?: boolean; 23 | }; 24 | 25 | export type ItemEntity = { 26 | title: string; 27 | url: string; 28 | published: firebase.firestore.Timestamp; 29 | counts: { [key: string]: CountEntity }; // key is CountType 30 | prevCounts: { [key: string]: CountEntity }; // 10 minutes before 31 | yesterdayCounts?: { [key: string]: CountEntity }; 32 | }; 33 | 34 | export type CountEntity = { 35 | count: number; 36 | timestamp: firebase.firestore.Timestamp; 37 | }; 38 | -------------------------------------------------------------------------------- /src/models/fetchers/blog-fetcher.ts: -------------------------------------------------------------------------------- 1 | import unescape from 'lodash/unescape'; 2 | import { FeedType } from '../consts/feed-type'; 3 | import { BlogResponse } from '../responses'; 4 | import { crossOriginFetch } from './functions'; 5 | 6 | export async function fetchBlog(blogURL: string): Promise { 7 | const result = await crossOriginFetch(blogURL); 8 | const htmlText = result.data.body; 9 | 10 | if (!htmlText) { 11 | throw new Error('Blog not found'); 12 | } else { 13 | const parser = new DOMParser(); 14 | const doc = parser.parseFromString(htmlText, 'text/html'); 15 | const linkTagSnapshots = doc.evaluate( 16 | `/html/head/link[@rel='alternate']`, 17 | doc, 18 | null, 19 | XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, 20 | null 21 | ); 22 | 23 | // prefer Atom than RSS 24 | const feedURLType = detectFeedURLAndType(linkTagSnapshots); 25 | if (!feedURLType) { 26 | throw new Error('Feed not found: ' + blogURL); 27 | } 28 | const { feedURL: parsedFeedURL, type } = feedURLType; 29 | 30 | // detect HatenaBlog 31 | const dataBrandAttributeSnapshots = doc.evaluate( 32 | '/html/@data-admin-domain', 33 | doc, 34 | null, 35 | XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, 36 | null 37 | ); 38 | const isHatenaBlog = !!( 39 | dataBrandAttributeSnapshots.snapshotLength && 40 | (dataBrandAttributeSnapshots.snapshotItem(0) as HTMLDataElement).value === '//blog.hatena.ne.jp' 41 | ); 42 | 43 | return { 44 | title: unescape(doc.title), 45 | url: blogURL, 46 | feedURL: feedURL(parsedFeedURL, blogURL), 47 | feedType: type, 48 | isHatenaBlog, 49 | }; 50 | } 51 | } 52 | 53 | function detectFeedURLAndType(snapshots: XPathResult): { feedURL: string; type: FeedType } | undefined { 54 | for (let i = 0; i < snapshots.snapshotLength; i++) { 55 | const item = snapshots.snapshotItem(i) as HTMLAnchorElement; 56 | switch (item.type) { 57 | case 'application/atom+xml': 58 | return { feedURL: item.href, type: FeedType.Atom }; 59 | case 'application/rss+xml': 60 | return { feedURL: item.href, type: FeedType.RSS }; 61 | default: 62 | break; 63 | } 64 | } 65 | } 66 | 67 | function feedURL(parsedFeedURL: string, baseURL: string) { 68 | const { host, protocol } = new URL(baseURL); 69 | const { pathname, search } = new URL(parsedFeedURL); 70 | return `${protocol}//${host}${pathname}${search}`; 71 | } 72 | -------------------------------------------------------------------------------- /src/models/fetchers/count-fetchers/count-jsoon-fetcher.ts: -------------------------------------------------------------------------------- 1 | import fetchJsonp from 'fetch-jsonp'; 2 | import { CountType } from '../../consts/count-type'; 3 | import { CountResponse } from '../../responses'; 4 | 5 | export async function fetchCountJsoonCount(url: string): Promise { 6 | const response = await fetchJsonp(`https://jsoon.digitiminimi.com/twitter/count.json?url=${encodeURIComponent(url)}`); 7 | const json = await response.json(); 8 | const { count } = json; 9 | if (count !== undefined) { 10 | return { url, count, type: CountType.CountJsoon }; 11 | } else { 12 | throw new Error('maybe you must register count.jsoon: ' + url); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/models/fetchers/count-fetchers/facebook-fetcher.ts: -------------------------------------------------------------------------------- 1 | import { CountType } from '../../consts/count-type'; 2 | import { CountResponse } from '../../responses'; 3 | 4 | export function fetchFacebookCounts(urls: string[]): Array> { 5 | return urls.map((url) => fetchFacebookCount(url)); 6 | } 7 | 8 | export async function fetchFacebookCount(url: string): Promise { 9 | const response = await fetch( 10 | `https://graph.facebook.com/?id=${encodeURIComponent(url)}&fields=og_object{engagement}` 11 | ); 12 | const json = await response.json(); 13 | if (json.hasOwnProperty('og_object') && json.og_object.hasOwnProperty('engagement')) { 14 | return { url: json.id, count: json.og_object.engagement.count, type: CountType.Facebook }; 15 | } else { 16 | return { url: json.id, count: 0, type: CountType.Facebook }; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/models/fetchers/count-fetchers/hatenabookmark-fetcher.ts: -------------------------------------------------------------------------------- 1 | import fetchJsonp from 'fetch-jsonp'; 2 | import { CountType } from '../../consts/count-type'; 3 | import { CountResponse } from '../../responses'; 4 | 5 | export async function fetchHatenaBookmarkCounts(urls: string[]): Promise { 6 | const apiUrl: string = 7 | 'https://b.hatena.ne.jp/entry.counts?' + urls.map((i: string) => `url=${encodeURIComponent(i)}`).join('&'); 8 | const response = await fetchJsonp(apiUrl); 9 | const json = await response.json(); 10 | return Object.keys(json).map((url): CountResponse => { 11 | return { url, count: json[url], type: CountType.HatenaBookmark }; 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /src/models/fetchers/count-fetchers/hatenastar-fetcher.ts: -------------------------------------------------------------------------------- 1 | import { sum } from 'lodash'; 2 | import { CountType } from '../../consts/count-type'; 3 | import { HatenaStarReponse } from '../../consts/fetch-response/hatena-star'; 4 | import { CountResponse } from '../../responses'; 5 | import { crossOriginFetch } from '../functions'; 6 | 7 | export async function fetchHatenaStarCounts(urls: string[]): Promise { 8 | const apiURL = 'https://s.hatena.com/entry.json?' + urls.map((url) => `uri=${encodeURIComponent(url)}`).join('&'); 9 | const response = await crossOriginFetch(apiURL); 10 | const json: HatenaStarReponse = JSON.parse(response.data.body); 11 | return json.entries.map(({ uri, stars, colored_stars }) => ({ 12 | url: uri, 13 | count: (stars && stars.length + sum(colored_stars && colored_stars.map((c) => c.stars.length))) || 0, 14 | type: CountType.HatenaStar, 15 | })); 16 | } 17 | -------------------------------------------------------------------------------- /src/models/fetchers/count-fetchers/pocket-fetcher.ts: -------------------------------------------------------------------------------- 1 | import { CountType } from '../../consts/count-type'; 2 | import { CountResponse } from '../../responses'; 3 | import { crossOriginFetch } from '../functions'; 4 | 5 | export async function fetchPocketCount(url: string): Promise { 6 | const apiURL = `https://widgets.getpocket.com/api/saves?url=${encodeURIComponent(url)}`; 7 | const response = await crossOriginFetch(apiURL); 8 | const json = JSON.parse(response.data.body); 9 | const count = json.saves; 10 | return { url, count, type: CountType.Pocket }; 11 | } 12 | -------------------------------------------------------------------------------- /src/models/fetchers/cross-origin-fetch.ts: -------------------------------------------------------------------------------- 1 | import { getApp } from '@firebase/app'; 2 | import { getFunctions, httpsCallable } from '@firebase/functions'; 3 | 4 | type Response = { 5 | body: string; 6 | }; 7 | 8 | export const crossOriginFetch = (url: string) => 9 | httpsCallable<{ url: string }, Response>(getFunctions(getApp(), 'asia-northeast1'), 'crossOriginFetch'); 10 | -------------------------------------------------------------------------------- /src/models/fetchers/feed-fetcher.ts: -------------------------------------------------------------------------------- 1 | import xmljs from 'xml-js'; 2 | import { FeedType } from '../consts/feed-type'; 3 | import { Atom, Link } from '../consts/feeds/atom'; 4 | import { Feed } from '../consts/feeds/feed'; 5 | import { RSS1 } from '../consts/feeds/rss1'; 6 | import { RSS2 } from '../consts/feeds/rss2'; 7 | import { FeedResponse, ItemResponse } from '../responses'; 8 | import { crossOriginFetch } from './functions'; 9 | 10 | export async function fetchFeed(feedURL: string): Promise { 11 | const response = await crossOriginFetch(feedURL); 12 | const xml = response.data.body; 13 | const sanitizedXML = xml.replace(/(?:)/g, ''); 14 | try { 15 | const json = xmljs.xml2js(sanitizedXML, { 16 | compact: true, 17 | }) as Feed; 18 | if ('feed' in json) { 19 | return handleAtom(json); 20 | } else if ('rdf:RDF' in json) { 21 | return handleRSS1(json); 22 | } else if ('rss' in json) { 23 | return handleRSS2(json); 24 | } else { 25 | throw new Error('Invalid Atom feed'); 26 | } 27 | } catch (e) { 28 | throw new Error('Invalid XML Feed' + e); 29 | } 30 | } 31 | 32 | function handleAtom(atom: Atom): FeedResponse { 33 | const items = atom.feed.entry.map((entry): ItemResponse => { 34 | const { title, link, published, updated, 'feedburner:origLink': feedburnerLink } = entry; 35 | const url = (() => { 36 | if (feedburnerLink) { 37 | return feedburnerLink._text; 38 | } else { 39 | return handleAtomLink(link); 40 | } 41 | })(); 42 | const item = { 43 | title: ('_cdata' in title && title._cdata) || ('_text' in title && title._text) || '', 44 | url, 45 | published: new Date((published && published._text) || updated._text), 46 | }; 47 | return item; 48 | }); 49 | return { 50 | title: atom.feed.title._text, 51 | url: handleAtomLink(atom.feed.link), 52 | feedType: FeedType.Atom, 53 | items, 54 | }; 55 | } 56 | 57 | function handleRSS1(rss1: RSS1): FeedResponse { 58 | const { 'rdf:RDF': rdf } = rss1; 59 | const items = rdf.item.map((item): ItemResponse => { 60 | const { title, link, 'dc:date': date } = item; 61 | return { title: title._text, url: link._text, published: new Date(date._text) }; 62 | }); 63 | return { 64 | title: rdf.channel.title._text, 65 | url: rdf.channel.link._text, 66 | feedType: FeedType.RSS, 67 | items, 68 | }; 69 | } 70 | 71 | function handleRSS2(rss2: RSS2): FeedResponse { 72 | const items = rss2.rss.channel.item.map((item): ItemResponse => { 73 | const { title, link, pubDate } = item; 74 | return { 75 | title: title._cdata || title._text, 76 | url: normalizeMediumURL(link._text), 77 | published: new Date(pubDate._text), 78 | }; 79 | }); 80 | return { 81 | title: rss2.rss.channel.title._cdata || rss2.rss.channel.title._text, 82 | url: normalizeMediumURL(rss2.rss.channel.link._text), 83 | items, 84 | feedType: FeedType.RSS, 85 | }; 86 | } 87 | 88 | function normalizeMediumURL(url: string) { 89 | return url.replace(/\?source=([^&]+)/, ''); 90 | } 91 | 92 | function handleAtomLink(link: Link | Link[]): string { 93 | if (link instanceof Array) { 94 | const relLinks = link.filter((l) => l._attributes.rel && l._attributes.rel === 'alternate'); 95 | return (relLinks.length && relLinks[0]._attributes.href) || link[0]._attributes.href; 96 | } else { 97 | return link._attributes.href; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/models/fetchers/functions.ts: -------------------------------------------------------------------------------- 1 | import { getApp } from '@firebase/app'; 2 | import { getFunctions, httpsCallable, HttpsCallableResult } from '@firebase/functions'; 3 | 4 | type Response = { 5 | body: string; 6 | }; 7 | 8 | export function crossOriginFetch(url: string): Promise> { 9 | const crossOriginFetch = httpsCallable<{ url: string }, Response>( 10 | getFunctions(getApp(), 'asia-northeast1'), 11 | 'crossOriginFetch' 12 | ); 13 | return crossOriginFetch({ url }); 14 | } 15 | -------------------------------------------------------------------------------- /src/models/repositories/app-repository.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase/compat/app'; 2 | import 'firebase/compat/auth'; 3 | import 'firebase/compat/firestore'; 4 | 5 | export function db(): firebase.firestore.Firestore { 6 | return firebase.firestore(); 7 | } 8 | 9 | export function serverTimestamp(): firebase.firestore.FieldValue { 10 | return firebase.firestore.FieldValue.serverTimestamp(); 11 | } 12 | 13 | export function writeBatch(): firebase.firestore.WriteBatch { 14 | return db().batch(); 15 | } 16 | -------------------------------------------------------------------------------- /src/models/repositories/blog-repository.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase/compat/app'; 2 | import 'firebase/compat/auth'; 3 | import 'firebase/compat/firestore'; 4 | 5 | import { BlogEntity } from './../entities'; 6 | import { serverTimestamp } from './app-repository'; 7 | import { userRef } from './user-repository'; 8 | 9 | export async function findAllBlogs(userId: string): Promise { 10 | const snapshot = await userRef(userId).collection('blogs').get(); 11 | 12 | const items = snapshot.docs 13 | .map((i: firebase.firestore.DocumentSnapshot) => i.data()) 14 | .filter((i) => i !== undefined) as firebase.firestore.DocumentData[]; 15 | 16 | return items as BlogEntity[]; 17 | } 18 | 19 | export function blogRef(userId: string, blogUrl: string): firebase.firestore.DocumentReference { 20 | return userRef(userId).collection('blogs').doc(encodeURIComponent(blogUrl)); 21 | } 22 | 23 | export async function findBlog(userId: string, blogUrl: string): Promise { 24 | const snapshot = await blogRef(userId, blogUrl).get(); 25 | const entity = snapshot.data() as BlogEntity; 26 | const needsServicesBackwardCompat = !entity.services; 27 | if (needsServicesBackwardCompat) { 28 | entity.services = { 29 | twitter: true, 30 | countjsoon: false, 31 | facebook: true, 32 | hatenabookmark: true, 33 | hatenastar: true, 34 | pocket: true, 35 | }; 36 | } 37 | if (entity.services && entity.services.pocket === undefined) { 38 | entity.services.pocket = true; 39 | } 40 | return entity; 41 | } 42 | 43 | export function saveBlog( 44 | userId: string, 45 | blogURL: string, 46 | blogTitle: string, 47 | feedURL: string, 48 | feedType: string, 49 | reportEnabled: boolean, 50 | twitterEnabled: boolean, 51 | countJsonnEnabled: boolean, 52 | facebookEnabled: boolean, 53 | hatenaBookmarkEnabled: boolean, 54 | hatenaStarEnabled: boolean, 55 | pocketEnabled: boolean 56 | ): Promise { 57 | return blogRef(userId, blogURL).set({ 58 | title: blogTitle, 59 | url: blogURL, 60 | feedURL, 61 | feedType, 62 | timestamp: serverTimestamp(), 63 | sendReport: reportEnabled, 64 | services: { 65 | twitter: twitterEnabled, 66 | countjsoon: countJsonnEnabled, 67 | facebook: facebookEnabled, 68 | hatenabookmark: hatenaBookmarkEnabled, 69 | hatenastar: hatenaStarEnabled, 70 | pocket: pocketEnabled, 71 | }, 72 | }); 73 | } 74 | 75 | export function saveBlogSetting( 76 | userId: string, 77 | blogURL: string, 78 | reportEnabled: boolean, 79 | twitterEnabled: boolean, 80 | countJsonnEnabled: boolean, 81 | facebookEnabled: boolean, 82 | hatenaBookmarkEnabled: boolean, 83 | hatenaStarEnabled: boolean, 84 | pocketEnabled: boolean 85 | ) { 86 | return blogRef(userId, blogURL).set( 87 | { 88 | timestamp: serverTimestamp(), 89 | sendReport: reportEnabled, 90 | services: { 91 | twitter: twitterEnabled, 92 | countjsoon: countJsonnEnabled, 93 | facebook: facebookEnabled, 94 | hatenabookmark: hatenaBookmarkEnabled, 95 | hatenastar: hatenaStarEnabled, 96 | pocket: pocketEnabled, 97 | }, 98 | }, 99 | { merge: true } 100 | ); 101 | } 102 | 103 | export function deleteBlog(userId: string, blogURL: string): Promise { 104 | return blogRef(userId, blogURL).delete(); 105 | } 106 | -------------------------------------------------------------------------------- /src/models/repositories/item-repository.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase/compat/app'; 2 | import 'firebase/compat/auth'; 3 | import 'firebase/compat/firestore'; 4 | 5 | import { ItemEntity } from '../entities'; 6 | import { serverTimestamp, writeBatch } from './app-repository'; 7 | import { blogRef } from './blog-repository'; 8 | 9 | export function itemRef(userId: string, blogUrl: string, itemUrl: string): firebase.firestore.DocumentReference { 10 | return blogRef(userId, blogUrl).collection('items').doc(encodeURIComponent(itemUrl)); 11 | } 12 | 13 | export async function findAllItems(userId: string, blogUrl: string): Promise { 14 | const snapshot = await blogRef(userId, blogUrl).collection('items').orderBy('published', 'desc').get(); 15 | const items: firebase.firestore.DocumentData[] = snapshot.docs 16 | .map((i: firebase.firestore.DocumentSnapshot) => i.data()) 17 | .filter((i?: firebase.firestore.DocumentData) => !!i) as firebase.firestore.DocumentData[]; 18 | return items.map((i): ItemEntity => { 19 | const { title, url, published, counts, prevCounts } = i; 20 | return { title, url, published, counts, prevCounts }; 21 | }); 22 | } 23 | 24 | export type CountSaveEntity = { 25 | count: number; 26 | timestamp: firebase.firestore.FieldValue; 27 | }; 28 | 29 | export type CountSaveEntities = { 30 | [key: string]: CountSaveEntity | undefined; 31 | }; 32 | 33 | export function saveItem( 34 | userId: string, 35 | blogUrl: string, 36 | url: string, 37 | title: string, 38 | published: Date, 39 | counts: CountSaveEntities, 40 | prevCounts: CountSaveEntities 41 | ) { 42 | return itemRef(userId, blogUrl, url).set({ 43 | title, 44 | url, 45 | published, 46 | counts, 47 | prevCounts, 48 | timestamp: serverTimestamp(), 49 | }); 50 | } 51 | 52 | export function saveItemBatch( 53 | batch: firebase.firestore.WriteBatch, 54 | userId: string, 55 | blogUrl: string, 56 | url: string, 57 | title: string, 58 | published: Date, 59 | counts: CountSaveEntities, 60 | prevCounts: CountSaveEntities 61 | ): firebase.firestore.WriteBatch { 62 | return batch.set(itemRef(userId, blogUrl, url), { 63 | title, 64 | url, 65 | published, 66 | counts, 67 | prevCounts, 68 | timestamp: serverTimestamp(), 69 | }); 70 | } 71 | 72 | export async function deleteItemsBatch(userId: string, blogUrl: string, batchSize: number = 50): Promise { 73 | const snapshots = await blogRef(userId, blogUrl).collection('items').get(); 74 | const docs = snapshots.docs; 75 | const promsies: Array> = []; 76 | for (let i = 0; i <= docs.length; i += batchSize) { 77 | const batch = writeBatch(); 78 | const slicedDocs = docs.slice(i, i + batchSize); 79 | slicedDocs.forEach((d) => batch.delete(d.ref)); 80 | promsies.push(batch.commit()); 81 | } 82 | return Promise.all(promsies); 83 | } 84 | -------------------------------------------------------------------------------- /src/models/repositories/user-repository.ts: -------------------------------------------------------------------------------- 1 | import { db } from './app-repository'; 2 | import firebase from 'firebase/compat/app'; 3 | import 'firebase/compat/auth'; 4 | import 'firebase/compat/firestore'; 5 | 6 | export function userRef(userId: string): firebase.firestore.DocumentReference { 7 | return db().collection('users').doc(userId); 8 | } 9 | -------------------------------------------------------------------------------- /src/models/responses.ts: -------------------------------------------------------------------------------- 1 | import { CountType } from './consts/count-type'; 2 | import { FeedType } from './consts/feed-type'; 3 | 4 | export type BlogResponse = { 5 | title: string; 6 | url: string; 7 | feedURL: string; 8 | feedType: FeedType; 9 | isHatenaBlog: boolean; 10 | }; 11 | 12 | export type FeedResponse = { 13 | title: string; 14 | url: string; 15 | items: ItemResponse[]; 16 | feedType: FeedType; 17 | }; 18 | 19 | export type ItemResponse = { 20 | title: string; 21 | url: string; 22 | published: Date; 23 | }; 24 | 25 | export type CountResponse = { 26 | type: CountType; 27 | url: string; 28 | count: number; 29 | }; 30 | -------------------------------------------------------------------------------- /src/models/save-count-response.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@firebase/auth'; 2 | import { Timestamp } from '@firebase/firestore'; 3 | import { CountType } from './consts/count-type'; 4 | import { CountEntity, ItemEntity } from './entities'; 5 | import { serverTimestamp, writeBatch } from './repositories/app-repository'; 6 | import { CountSaveEntities, saveItemBatch } from './repositories/item-repository'; 7 | import { CountResponse, ItemResponse } from './responses'; 8 | 9 | // TODO: refactor!! 10 | 11 | export async function saveFeedsAndCounts( 12 | user: User, 13 | blogURL: string, 14 | firebaseEntities: ItemEntity[], 15 | feedItemsResponse: ItemResponse[], 16 | countsResponse: CountResponse[], 17 | countTypes: CountType[] = [CountType.Facebook, CountType.HatenaBookmark] 18 | ): Promise { 19 | const batch = writeBatch(); 20 | const saveEntities = createSaveEntities(firebaseEntities, feedItemsResponse, countsResponse, countTypes); 21 | for (const item of saveEntities) { 22 | saveItemBatch(batch, user.uid, blogURL, item.url, item.title, item.published, item.itemCounts, item.prevCounts); 23 | } 24 | if (saveEntities.length) { 25 | return batch.commit(); 26 | } else { 27 | return; 28 | } 29 | } 30 | 31 | export function createSaveEntities( 32 | firebaseEntities: ItemEntity[], 33 | feedItemsResponse: ItemResponse[], 34 | countsResponse: CountResponse[], 35 | countTypes: CountType[] = [CountType.Facebook, CountType.HatenaBookmark] 36 | ): ItemSaveEntity[] { 37 | const counts = countsResponse.filter((count: CountResponse) => count && count.count > 0); 38 | const countTypeMaps: { [key: string]: Map } = {}; 39 | for (const countType of countTypes) { 40 | countTypeMaps[countType] = new Map( 41 | counts.filter((count) => count.type === countType).map((c) => [c.url, c] as [string, CountResponse]) 42 | ); 43 | } 44 | 45 | const firebaseMap = new Map(firebaseEntities.map((i) => [i.url, i] as [string, ItemEntity])); 46 | 47 | const result: ItemSaveEntity[] = []; 48 | 49 | for (const item of feedItemsResponse) { 50 | const itemCounts: CountSaveEntities = {}; 51 | const prevCounts: CountSaveEntities = {}; 52 | const firebaseItem = firebaseMap.get(item.url); 53 | const isTitleChanged = !firebaseItem || (firebaseItem && item.title !== firebaseItem.title); 54 | let shouldSave = isTitleChanged; 55 | 56 | for (const countType of countTypes) { 57 | const typeMap = countTypeMaps[countType]; 58 | 59 | const typeCount = typeMap.get(item.url); 60 | const firebaseTypeCount = firebaseItem && firebaseItem.counts && firebaseItem.counts[countType]; 61 | itemCounts[countType] = createItemCount(typeCount, firebaseTypeCount); 62 | if (!itemCounts[countType]) { 63 | delete itemCounts[countType]; 64 | } 65 | 66 | const prevTypeCount = firebaseItem && firebaseItem.prevCounts && firebaseItem.prevCounts[countType]; 67 | prevCounts[countType] = createPrevItemCount(prevTypeCount, firebaseTypeCount, itemCounts[countType]); 68 | if (!prevCounts[countType]) { 69 | delete prevCounts[countType]; 70 | } 71 | 72 | const willSaveTypeCount = itemCounts[countType]; 73 | const isTypeCountChanged = 74 | !firebaseTypeCount || 75 | !!(willSaveTypeCount && firebaseTypeCount && willSaveTypeCount.count !== firebaseTypeCount.count); 76 | 77 | const willSavePrevTypeCount = prevCounts[countType]; 78 | const isPrevTypeCountChanged = !!( 79 | prevTypeCount && 80 | willSavePrevTypeCount && 81 | prevTypeCount.count !== willSavePrevTypeCount.count 82 | ); 83 | 84 | shouldSave = shouldSave || isTypeCountChanged || isPrevTypeCountChanged; 85 | } 86 | 87 | if (shouldSave) { 88 | result.push({ 89 | url: item.url, 90 | title: item.title, 91 | published: item.published, 92 | itemCounts, 93 | prevCounts, 94 | }); 95 | } 96 | } 97 | return result; 98 | } 99 | 100 | function createItemCount(fetchedCount: CountResponse | undefined, firebaseCount: CountEntity | undefined) { 101 | const isNewCount = fetchedCount && !firebaseCount; 102 | const isUpdatedCount = fetchedCount && firebaseCount && fetchedCount.count > firebaseCount.count; 103 | if ((isNewCount || isUpdatedCount) && fetchedCount) { 104 | return { 105 | count: fetchedCount.count, 106 | timestamp: serverTimestamp(), 107 | }; 108 | } else { 109 | return firebaseCount; 110 | } 111 | } 112 | 113 | function createPrevItemCount( 114 | prevCount: CountEntity | undefined, 115 | firebaseCount: CountEntity | undefined, 116 | newCount: any 117 | ) { 118 | const isExistSavedPrevCount = !!prevCount; 119 | const is10MunitesLaterUntilLastUpdate = 120 | firebaseCount && firebaseCount.timestamp.seconds < Timestamp.now().seconds - 60 * 10; 121 | if (is10MunitesLaterUntilLastUpdate) { 122 | // move current count to prevCount 123 | return firebaseCount; 124 | } else if (isExistSavedPrevCount) { 125 | // insert same prevCount last inserted 126 | return prevCount; 127 | } else if (!isExistSavedPrevCount && newCount) { 128 | return newCount; 129 | } 130 | } 131 | 132 | type ItemSaveEntity = { 133 | url: string; 134 | title: string; 135 | published: Date; 136 | itemCounts: CountSaveEntities; 137 | prevCounts: CountSaveEntities; 138 | }; 139 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/redux/app-reducer.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import { userSlice } from './slices/user'; 3 | import { blogSlice } from './slices/blog'; 4 | import { deleteBlogSlice } from './slices/delete-blog'; 5 | import { feedsSlice } from './slices/feeds'; 6 | import { addBlogSlice } from './slices/add-blog'; 7 | import { settingsSlice } from './slices/settings'; 8 | 9 | export const appReducer = combineReducers({ 10 | blog: blogSlice.reducer, 11 | user: userSlice.reducer, 12 | addBlog: addBlogSlice.reducer, 13 | deleteBlog: deleteBlogSlice.reducer, 14 | feeds: feedsSlice.reducer, 15 | settings: settingsSlice.reducer, 16 | }); 17 | 18 | export type AppState = ReturnType; 19 | -------------------------------------------------------------------------------- /src/redux/create-store.ts: -------------------------------------------------------------------------------- 1 | import { configureStore, getDefaultMiddleware } from '@reduxjs/toolkit'; 2 | import createSagaMiddleware from 'redux-saga'; 3 | import { appReducer } from './app-reducer'; 4 | import feedSaga from './sagas/feed-saga'; 5 | import gaSaga from './sagas/ga-saga'; 6 | 7 | const sagaMiddleware = createSagaMiddleware(); 8 | export const appStore = configureStore({ 9 | reducer: appReducer, 10 | middleware: [ 11 | ...getDefaultMiddleware({ 12 | serializableCheck: false, 13 | }), 14 | sagaMiddleware, 15 | ], 16 | }); 17 | sagaMiddleware.run(feedSaga); 18 | sagaMiddleware.run(gaSaga); 19 | -------------------------------------------------------------------------------- /src/redux/sagas/feed-saga.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@firebase/auth'; 2 | import { clone } from 'lodash'; 3 | import flatten from 'lodash/flatten'; 4 | import { all, call, put, takeLatest } from 'redux-saga/effects'; 5 | import { CountType } from '../../models/consts/count-type'; 6 | import { BlogEntity, ItemEntity } from '../../models/entities'; 7 | import { fetchFeed as fetchFeedAction } from '../../models/fetchers/feed-fetcher'; 8 | import { findBlog } from '../../models/repositories/blog-repository'; 9 | import { findAllItems } from '../../models/repositories/item-repository'; 10 | import { CountResponse, FeedResponse, ItemResponse } from '../../models/responses'; 11 | import { saveFeedsAndCounts } from '../../models/save-count-response'; 12 | import { fetchCountJsoonCounts } from './feed-sagas/count-jsoon-saga'; 13 | import { fetchFacebookCounts } from './feed-sagas/facebook-saga'; 14 | import { fetchHatenaBookmarkCounts } from './feed-sagas/hatenabookmark-saga'; 15 | import { fetchHatenaStarCounts } from './feed-sagas/hatenastar-saga'; 16 | import { fetchPocketCounts } from './feed-sagas/pocket-saga'; 17 | import { fetchFiresbaseUser } from './user-saga'; 18 | import { feedsSlice } from '../slices/feeds'; 19 | 20 | export default function* feedSaga() { 21 | yield takeLatest(feedsSlice.actions.startFetchAndSave, handleFetchAction); 22 | } 23 | 24 | // main 25 | function* handleFetchAction(action: ReturnType) { 26 | const { blogURL, auth } = action.payload; 27 | 28 | const user: User = yield call(fetchFiresbaseUser, auth); 29 | 30 | const blogEntity: BlogEntity = yield call(firebaseBlog, user, blogURL); 31 | const { services, feedURL } = blogEntity; 32 | 33 | const [firebaseItems, fetchedItems]: [ItemEntity[], ItemResponse[]] = yield all([ 34 | call(firebaseFeed, user, blogURL), 35 | call(fetchFeed, blogURL, feedURL), 36 | ]); 37 | 38 | const urls = fetchedItems.map((i) => i.url); 39 | const countServices = []; 40 | const countTypes: CountType[] = []; 41 | if (services) { 42 | const { countjsoon, hatenabookmark, hatenastar, pocket, facebook } = services; 43 | if (countjsoon) { 44 | countServices.push(call(fetchCountJsoonCounts, blogURL, urls)); 45 | countTypes.push(CountType.CountJsoon); 46 | } 47 | if (hatenabookmark) { 48 | countServices.push(call(fetchHatenaBookmarkCounts, blogURL, urls)); 49 | countTypes.push(CountType.HatenaBookmark); 50 | } 51 | if (hatenastar) { 52 | countServices.push(call(fetchHatenaStarCounts, blogURL, urls)); 53 | countTypes.push(CountType.HatenaStar); 54 | } 55 | if (pocket) { 56 | countServices.push(call(fetchPocketCounts, blogURL, urls)); 57 | countTypes.push(CountType.Pocket); 58 | } 59 | if (facebook) { 60 | countServices.push(call(fetchFacebookCounts, blogURL, urls)); 61 | countTypes.push(CountType.Facebook); 62 | } 63 | } 64 | 65 | const counts: CountResponse[] = flatten(yield all(countServices)); 66 | yield call(saveBlogFeedItemsAndCounts, user, blogURL, firebaseItems, fetchedItems, counts, countTypes); 67 | } 68 | 69 | function* firebaseBlog(user: User, blogURL: string) { 70 | try { 71 | yield put(feedsSlice.actions.firebaseBlogRequest(blogURL)); 72 | const blogEntity: BlogEntity = yield call(findBlog, user.uid, blogURL); 73 | yield put(feedsSlice.actions.firebaseBlogResponse({ blogURL, blogEntity })); 74 | return blogEntity; 75 | } catch (error) { 76 | if (error instanceof Error) { 77 | yield put(feedsSlice.actions.fetchAndSaveError({ blogURL, error })); 78 | } 79 | } 80 | } 81 | 82 | function* firebaseFeed(user: User, blogURL: string) { 83 | try { 84 | yield put(feedsSlice.actions.firebaseFeedRequest(blogURL)); 85 | const items: ItemEntity[] = yield call(findAllItems, user.uid, blogURL); 86 | yield put(feedsSlice.actions.firebaseFeedResponse({ blogURL, items })); 87 | return items; 88 | } catch (error) { 89 | if (error instanceof Error) { 90 | yield put(feedsSlice.actions.fetchAndSaveError({ blogURL, error })); 91 | } 92 | } 93 | } 94 | 95 | function* fetchFeed(blogURL: string, feedURL: string) { 96 | try { 97 | yield put(feedsSlice.actions.fetchRSSRequest(blogURL)); 98 | const feed: FeedResponse = yield call(fetchFeedAction, feedURL); 99 | yield put(feedsSlice.actions.fetchRSSResponse({ blogURL, items: feed.items })); 100 | return feed.items; 101 | } catch (e) { 102 | if (e instanceof Error) { 103 | yield put(feedsSlice.actions.fetchRSSError({ blogURL, error: clone(e) })); 104 | } 105 | } 106 | } 107 | 108 | function* saveBlogFeedItemsAndCounts( 109 | user: User, 110 | blogURL: string, 111 | firebaseItems: ItemEntity[], 112 | fetchedItems: ItemResponse[], 113 | counts: CountResponse[], 114 | countTypes: CountType[] 115 | ) { 116 | try { 117 | yield put(feedsSlice.actions.firebaseSaveRequst({ blogURL, firebaseItems, fetchedItems, counts })); 118 | yield call(saveFeedsAndCounts, user, blogURL, firebaseItems, fetchedItems, counts, countTypes); 119 | yield put(feedsSlice.actions.firebaseSaveResponse(blogURL)); 120 | } catch (error) { 121 | if (error instanceof Error) { 122 | yield put(feedsSlice.actions.firebaseSaveError({ blogURL, error })); 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/redux/sagas/feed-sagas/count-jsoon-saga.ts: -------------------------------------------------------------------------------- 1 | import chunk from 'lodash/chunk'; 2 | import flatten from 'lodash/flatten'; 3 | import { all, call, delay, put } from 'redux-saga/effects'; 4 | import { fetchCountJsoonCount as fetchCountJsoonCountActoun } from '../../../models/fetchers/count-fetchers/count-jsoon-fetcher'; 5 | import { CountResponse } from '../../../models/responses'; 6 | import { feedsSlice } from '../../slices/feeds'; 7 | 8 | export function* fetchCountJsoonCounts(blogURL: string, urls: string[], maxFetchCount: number = 30) { 9 | try { 10 | yield put(feedsSlice.actions.fetchCountJSOONCountRequest(blogURL)); 11 | const slicedURLs = urls.slice(0, maxFetchCount - 1); 12 | const chunkedURLs = chunk(slicedURLs, 10); 13 | const counts: CountResponse[][] = []; 14 | for (const urls of chunkedURLs) { 15 | counts.push(yield call(fetchCountJsoonCount, urls)); 16 | } 17 | const flattenedCounts = flatten(counts); 18 | yield put(feedsSlice.actions.fetchCountJSOONCountResponse({ blogURL, counts: flattenedCounts })); 19 | return flattenedCounts; 20 | } catch (error) { 21 | if (error instanceof Error) { 22 | yield put(feedsSlice.actions.fetchCountJSOONCountError({ blogURL, error })); 23 | } 24 | } 25 | } 26 | 27 | function* fetchCountJsoonCount(urls: string[], delayMsec: number = 100) { 28 | const count: CountResponse[] = yield all(urls.map((url) => call(fetchCountJsoonCountActoun, url))); 29 | yield delay(delayMsec); 30 | return count; 31 | } 32 | -------------------------------------------------------------------------------- /src/redux/sagas/feed-sagas/facebook-saga.ts: -------------------------------------------------------------------------------- 1 | import chunk from 'lodash/chunk'; 2 | import flatten from 'lodash/flatten'; 3 | import { all, call, delay, put } from 'redux-saga/effects'; 4 | import { fetchFacebookCount } from '../../../models/fetchers/count-fetchers/facebook-fetcher'; 5 | import { CountResponse } from '../../../models/responses'; 6 | import { feedsSlice } from '../../slices/feeds'; 7 | 8 | export function* fetchFacebookCounts(blogURL: string, urls: string[], maxFetchCount: number = 20) { 9 | try { 10 | yield put(feedsSlice.actions.fetchFacebookCountsRequest(blogURL)); 11 | const slicedURLs = urls.slice(0, maxFetchCount - 1); 12 | const chunkedURLs = chunk(slicedURLs, 4); 13 | const counts: CountResponse[][] = []; 14 | for (const urls of chunkedURLs) { 15 | counts.push(yield call(fetchFacebookCountChunk, urls)); 16 | } 17 | const flattenedCounts = flatten(counts); 18 | yield put(feedsSlice.actions.fetchFacebookCountResponse({ blogURL, counts: flattenedCounts })); 19 | return flattenedCounts; 20 | } catch (error) { 21 | if (error instanceof Error) { 22 | yield put(feedsSlice.actions.fetchFacebookCountError({ blogURL, error })); 23 | } 24 | } 25 | } 26 | 27 | export function* fetchFacebookCountChunk(urls: string[], delayMsec: number = 800) { 28 | const count: CountResponse[] = yield all(urls.map((url) => call(fetchFacebookCount, url))); 29 | yield delay(delayMsec); 30 | return count; 31 | } 32 | -------------------------------------------------------------------------------- /src/redux/sagas/feed-sagas/hatenabookmark-saga.ts: -------------------------------------------------------------------------------- 1 | import { call, put } from 'redux-saga/effects'; 2 | import { fetchHatenaBookmarkCounts as fetchHatenaBookmarkCountsAction } from '../../../models/fetchers/count-fetchers/hatenabookmark-fetcher'; 3 | import { CountResponse } from '../../../models/responses'; 4 | import { feedsSlice } from '../../slices/feeds'; 5 | 6 | export function* fetchHatenaBookmarkCounts(blogURL: string, urls: string[], maxFetchCount: number = 50) { 7 | try { 8 | yield put(feedsSlice.actions.fetchHatenaBookmarkCountRequest(blogURL)); 9 | const slicedURLs = urls.slice(0, maxFetchCount - 1); 10 | const counts: CountResponse[] = yield call(fetchHatenaBookmarkCountsAction, slicedURLs); 11 | yield put(feedsSlice.actions.fetchHatenaBookmarkCountResponse({ blogURL, counts })); 12 | return counts; 13 | } catch (error) { 14 | if (error instanceof Error) { 15 | yield put(feedsSlice.actions.fetchHatenaBookmarkCountError({ blogURL, error })); 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/redux/sagas/feed-sagas/hatenastar-saga.ts: -------------------------------------------------------------------------------- 1 | import { call, put } from 'redux-saga/effects'; 2 | import { fetchHatenaStarCounts as fetchHatenaStarCountsAction } from '../../../models/fetchers/count-fetchers/hatenastar-fetcher'; 3 | import { CountResponse } from '../../../models/responses'; 4 | import { feedsSlice } from '../../slices/feeds'; 5 | 6 | export function* fetchHatenaStarCounts(blogURL: string, urls: string[], maxFetchCount: number = 50) { 7 | try { 8 | yield put(feedsSlice.actions.fetchHatenaStarCountRequest(blogURL)); 9 | const slicedURLs = urls.slice(0, maxFetchCount - 1); 10 | const counts: CountResponse[] = yield call(fetchHatenaStarCountsAction, slicedURLs); 11 | yield put(feedsSlice.actions.fetchHatenaStarCountResponse({ blogURL, counts })); 12 | return counts; 13 | } catch (error) { 14 | if (error instanceof Error) { 15 | yield put(feedsSlice.actions.fetchHatenaStarCountError({ blogURL, error })); 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/redux/sagas/feed-sagas/pocket-saga.ts: -------------------------------------------------------------------------------- 1 | import { chunk, flatten } from 'lodash'; 2 | import { all, call, delay, put } from 'redux-saga/effects'; 3 | import { fetchPocketCount } from '../../../models/fetchers/count-fetchers/pocket-fetcher'; 4 | import { CountResponse } from '../../../models/responses'; 5 | import { feedsSlice } from '../../slices/feeds'; 6 | 7 | export function* fetchPocketCounts(blogURL: string, urls: string[], maxFetchCount: number = 30) { 8 | try { 9 | yield put(feedsSlice.actions.fetchPocketCountRequest(blogURL)); 10 | const slicedURLs = urls.slice(0, maxFetchCount - 1); 11 | const chunkedURLs = chunk(slicedURLs, 10); 12 | const counts: CountResponse[][] = []; 13 | for (const urls of chunkedURLs) { 14 | counts.push(yield call(fetchPocketCountChunk, urls)); 15 | } 16 | const flattenedCounts = flatten(counts); 17 | yield put(feedsSlice.actions.fetchPocketCountResponse({ blogURL, counts: flattenedCounts })); 18 | return flattenedCounts; 19 | } catch (error) { 20 | if (error instanceof Error) { 21 | yield put(feedsSlice.actions.fetchPocketCountError({ blogURL, error })); 22 | } 23 | } 24 | } 25 | 26 | function* fetchPocketCountChunk(urls: string[], delayMsec: number = 100) { 27 | const count: CountResponse[] = yield all(urls.map((url) => call(fetchPocketCount, url))); 28 | yield delay(delayMsec); 29 | return count; 30 | } 31 | -------------------------------------------------------------------------------- /src/redux/sagas/ga-saga.ts: -------------------------------------------------------------------------------- 1 | import ReactGA from 'react-ga'; 2 | import { takeLatest } from 'redux-saga/effects'; 3 | import { userSlice } from '../slices/user'; 4 | 5 | export default function* gaSaga() { 6 | yield takeLatest(userSlice.actions.firebaseUserResponse.type, handleUserAction); 7 | } 8 | 9 | function* handleUserAction(action: ReturnType) { 10 | const { payload: user } = action; 11 | yield ReactGA.set({ userId: user.uid }); 12 | return undefined; 13 | } 14 | -------------------------------------------------------------------------------- /src/redux/sagas/user-saga.ts: -------------------------------------------------------------------------------- 1 | import { Auth, User } from '@firebase/auth'; 2 | import { call, put } from 'redux-saga/effects'; 3 | import { userSlice, currenUserOronAuthStateChanged } from '../slices/user'; 4 | 5 | export function* fetchFiresbaseUser(auth: Auth) { 6 | yield put(userSlice.actions.firebaseUserRequest()); 7 | try { 8 | const user: User = yield call(currenUserOronAuthStateChanged, auth); 9 | yield put(userSlice.actions.firebaseUserResponse(JSON.parse(JSON.stringify(user)))); 10 | return user; 11 | } catch (e) { 12 | if (e instanceof Error) { 13 | yield put(userSlice.actions.firebaseUnauthorizedError(e)); 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/redux/slices/add-blog.ts: -------------------------------------------------------------------------------- 1 | import { Auth } from '@firebase/auth'; 2 | import { createSlice, PayloadAction, ThunkAction } from '@reduxjs/toolkit'; 3 | import { currenUserOronAuthStateChanged } from './user'; 4 | import { BlogResponse, FeedResponse } from '../../models/responses'; 5 | import { saveBlog } from '../../models/repositories/blog-repository'; 6 | import { fetchFeed } from '../../models/fetchers/feed-fetcher'; 7 | import { fetchBlog } from '../../models/fetchers/blog-fetcher'; 8 | 9 | export type AddBlogState = { 10 | loading: boolean; 11 | finished: boolean; 12 | blogURL?: string; 13 | error?: Error; 14 | }; 15 | 16 | export const initialState: AddBlogState = { 17 | loading: false, 18 | finished: false, 19 | }; 20 | 21 | export const addBlogSlice = createSlice({ 22 | name: 'addBlog', 23 | initialState, 24 | reducers: { 25 | fetchBlogRequest() {}, 26 | fetchBlogResponse(state, action: PayloadAction) {}, 27 | fetchBlogError(state, action: PayloadAction) {}, 28 | fetchFeedRequest() {}, 29 | fetchFeedResponse(state, action: PayloadAction) {}, 30 | fetchFeedError(state, action: PayloadAction) {}, 31 | firebaseSaveRequst(state) { 32 | return { ...state, finished: false, loading: true }; 33 | }, 34 | firebaseSaveResponse(state, action: PayloadAction<{ blogURL: string }>) { 35 | const { blogURL } = action.payload; 36 | return { ...state, error: undefined, finished: true, blogURL, loading: false }; 37 | }, 38 | firebaseSaveError(state, action: PayloadAction) { 39 | const error = action.payload; 40 | return { ...state, error, finished: false, loading: false }; 41 | }, 42 | reset(state) { 43 | return initialState; 44 | }, 45 | }, 46 | }); 47 | 48 | export type AddBlogThunkAction = ThunkAction; 49 | export function addBlog(auth: Auth, blogURL: string, reportMailEnabled: boolean): AddBlogThunkAction { 50 | return async (dispatch) => { 51 | const user = await currenUserOronAuthStateChanged(auth); 52 | let blogResponse: BlogResponse | undefined; 53 | try { 54 | dispatch(addBlogSlice.actions.fetchBlogRequest()); 55 | const response = await fetchBlog(blogURL); 56 | dispatch(addBlogSlice.actions.fetchBlogResponse(response)); 57 | 58 | blogResponse = response; 59 | } catch (e) { 60 | try { 61 | dispatch(addBlogSlice.actions.fetchFeedRequest()); 62 | const feed = await fetchFeed(blogURL); 63 | dispatch(addBlogSlice.actions.fetchFeedResponse(feed)); 64 | 65 | const { url, title, feedType } = feed; 66 | blogResponse = { 67 | url, 68 | title, 69 | feedURL: blogURL, 70 | feedType, 71 | isHatenaBlog: false, 72 | }; 73 | } catch (e) { 74 | dispatch(addBlogSlice.actions.fetchFeedError(new Error('No blog ' + e))); 75 | } 76 | } 77 | 78 | if (user && blogResponse) { 79 | dispatch(addBlogSlice.actions.firebaseSaveRequst()); 80 | // default services are Twitter, Facebook and HatenaBookmark. (HatenaStar is only HatenaBlog) 81 | await saveBlog( 82 | user.uid, 83 | blogResponse.url, 84 | blogResponse.title, 85 | blogResponse.feedURL, 86 | blogResponse.feedType, 87 | reportMailEnabled, 88 | true, 89 | false, 90 | true, 91 | true, 92 | blogResponse.isHatenaBlog, 93 | true 94 | ); 95 | dispatch(addBlogSlice.actions.firebaseSaveResponse({ blogURL: blogResponse.url })); 96 | } else { 97 | dispatch(addBlogSlice.actions.firebaseSaveError(new Error('Blog missing'))); 98 | } 99 | }; 100 | } 101 | -------------------------------------------------------------------------------- /src/redux/slices/blog.ts: -------------------------------------------------------------------------------- 1 | import { Auth } from '@firebase/auth'; 2 | import { BlogEntity } from '../../models/entities'; 3 | import { createSlice, PayloadAction, ThunkAction } from '@reduxjs/toolkit'; 4 | import { currenUserOronAuthStateChanged } from './user'; 5 | import { findAllBlogs } from '../../models/repositories/blog-repository'; 6 | import { deleteBlogSlice } from './delete-blog'; 7 | 8 | export type BlogState = { 9 | blogs?: BlogEntity[]; 10 | loading: boolean; 11 | error?: Error; 12 | }; 13 | 14 | export const initialState: BlogState = { 15 | loading: false, 16 | }; 17 | 18 | export const blogSlice = createSlice({ 19 | name: 'blog', 20 | initialState, 21 | reducers: { 22 | firebaseBlogsRequest(state) { 23 | return { ...state, loading: true }; 24 | }, 25 | firebaseBlogsResponse(state, action: PayloadAction) { 26 | return { ...state, blogs: action.payload, loading: false }; 27 | }, 28 | firebaseBlogsError(state, action: PayloadAction) { 29 | return state; 30 | }, 31 | }, 32 | extraReducers(builder) { 33 | builder.addCase(deleteBlogSlice.actions.deleteBlogResponse, (state, action: PayloadAction) => { 34 | const blogURL = action.payload; 35 | let blogs; 36 | if (state.blogs) { 37 | blogs = state.blogs.filter((b) => b.url !== blogURL); 38 | } 39 | return { ...state, blogs }; 40 | }); 41 | }, 42 | }); 43 | 44 | export function fetchBlogs(auth: Auth): ThunkAction { 45 | return async (dispatch) => { 46 | let user; 47 | try { 48 | user = await currenUserOronAuthStateChanged(auth); 49 | } catch (e) { 50 | throw e; 51 | } 52 | try { 53 | dispatch(blogSlice.actions.firebaseBlogsRequest()); 54 | const blogs = await findAllBlogs(user.uid); 55 | dispatch(blogSlice.actions.firebaseBlogsResponse(blogs)); 56 | } catch (e) { 57 | if (e instanceof Error) { 58 | dispatch(blogSlice.actions.firebaseBlogsError(e)); 59 | } 60 | } 61 | }; 62 | } 63 | -------------------------------------------------------------------------------- /src/redux/slices/delete-blog.ts: -------------------------------------------------------------------------------- 1 | import { Auth } from '@firebase/auth'; 2 | import { createSlice, PayloadAction, ThunkAction } from '@reduxjs/toolkit'; 3 | import { currenUserOronAuthStateChanged } from './user'; 4 | import * as BlogRepo from '../../models/repositories/blog-repository'; 5 | import * as ItemsRepo from '../../models/repositories/item-repository'; 6 | 7 | export type DeleteBlogState = { 8 | loading: boolean; 9 | blogURL?: string; 10 | finished: boolean; 11 | error?: Error; 12 | }; 13 | 14 | export const initialState: DeleteBlogState = { 15 | loading: false, 16 | finished: false, 17 | }; 18 | 19 | export const deleteBlogSlice = createSlice({ 20 | name: 'deleteBlog', 21 | initialState, 22 | reducers: { 23 | deleteBlogRequest(state, action: PayloadAction) { 24 | return { ...state, finished: false, loading: true }; 25 | }, 26 | deleteBlogResponse(state, action: PayloadAction) { 27 | const blogURL = action.payload; 28 | return { ...state, error: undefined, finished: true, blogURL, loading: false }; 29 | }, 30 | deleteBlogError(state, action: PayloadAction<{ blogURL: string; error: Error }>) { 31 | const { error } = action.payload; 32 | return { ...state, error, finished: false, loading: false }; 33 | }, 34 | reset() { 35 | return initialState; 36 | }, 37 | }, 38 | }); 39 | 40 | type TA = ThunkAction; 41 | export function deleteBlog(auth: Auth, blogURL: string): TA { 42 | return async (dispatch) => { 43 | try { 44 | const user = await currenUserOronAuthStateChanged(auth); 45 | dispatch(deleteBlogSlice.actions.deleteBlogRequest(blogURL)); 46 | await ItemsRepo.deleteItemsBatch(user.uid, blogURL); 47 | await BlogRepo.deleteBlog(user.uid, blogURL); 48 | dispatch(deleteBlogSlice.actions.deleteBlogResponse(blogURL)); 49 | } catch (error) { 50 | if (error instanceof Error) { 51 | dispatch(deleteBlogSlice.actions.deleteBlogError({ blogURL, error })); 52 | } 53 | } 54 | }; 55 | } 56 | -------------------------------------------------------------------------------- /src/redux/slices/settings.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, createNextState, PayloadAction, ThunkAction } from '@reduxjs/toolkit'; 2 | import { getApp } from '@firebase/app'; 3 | import { Auth } from '@firebase/auth'; 4 | import { getFunctions, httpsCallable } from '@firebase/functions'; 5 | 6 | import { saveBlogSetting } from '../../models/repositories/blog-repository'; 7 | import { currenUserOronAuthStateChanged } from './user'; 8 | 9 | export type SettingState = { 10 | loading: boolean; 11 | }; 12 | 13 | export const settingInitialState: SettingState = { loading: false }; 14 | 15 | export type SettingsState = { 16 | settings: { [key: string]: SettingState }; 17 | }; 18 | 19 | export const initialState: SettingsState = { settings: {} }; 20 | 21 | export const settingsSlice = createSlice({ 22 | name: 'settings', 23 | initialState, 24 | reducers: { 25 | saveSettingRequest(state, action: PayloadAction) {}, 26 | saveSettingResponse( 27 | state, 28 | action: PayloadAction<{ 29 | blogURL: string; 30 | twitter: boolean; 31 | countjsoon: boolean; 32 | facebook: boolean; 33 | hatenabookmark: boolean; 34 | hatenastar: boolean; 35 | pocket: boolean; 36 | sendReport: boolean; 37 | }> 38 | ) {}, 39 | saveSettingError(state, action: PayloadAction<{ blogURL: string; error: Error }>) {}, 40 | firebaseSendTestReportMailRequest(state, action: PayloadAction) { 41 | const blogURL = action.payload; 42 | return updateStates(state, blogURL, { loading: true }); 43 | }, 44 | firebaseSendTestReportMailResponse(state, action: PayloadAction) { 45 | const blogURL = action.payload; 46 | return updateStates(state, blogURL, { loading: false }); 47 | }, 48 | firebaseSendTestMailError(state, action: PayloadAction<{ blogURL: string; error: Error }>) { 49 | const { blogURL } = action.payload; 50 | return updateStates(state, blogURL, { loading: false }); 51 | }, 52 | }, 53 | }); 54 | 55 | function updateStates(state: SettingsState, blogURL: string, updateState: Partial) { 56 | return createNextState(state, (draft) => { 57 | const prevState = draft.settings[blogURL] || settingInitialState; 58 | const newState = { ...prevState, ...updateState }; 59 | draft.settings[blogURL] = newState; 60 | return draft; 61 | }); 62 | } 63 | 64 | type STA = ThunkAction; 65 | 66 | export function saveSetting( 67 | auth: Auth, 68 | blogURL: string, 69 | reportEnabled: boolean, 70 | twitterEnabled: boolean, 71 | countJsoonEnabled: boolean, 72 | facebookEnabled: boolean, 73 | hatenaBookmarkEnabled: boolean, 74 | hatenaStarEnabled: boolean, 75 | pocketEnabled: boolean 76 | ): STA { 77 | return async (dispatch) => { 78 | dispatch(settingsSlice.actions.saveSettingRequest(blogURL)); 79 | try { 80 | const user = await currenUserOronAuthStateChanged(auth); 81 | await saveBlogSetting( 82 | user.uid, 83 | blogURL, 84 | reportEnabled, 85 | twitterEnabled, 86 | countJsoonEnabled, 87 | facebookEnabled, 88 | hatenaBookmarkEnabled, 89 | hatenaStarEnabled, 90 | pocketEnabled 91 | ); 92 | dispatch( 93 | settingsSlice.actions.saveSettingResponse({ 94 | blogURL, 95 | twitter: twitterEnabled, 96 | countjsoon: countJsoonEnabled, 97 | facebook: facebookEnabled, 98 | hatenabookmark: hatenaBookmarkEnabled, 99 | hatenastar: hatenaStarEnabled, 100 | pocket: pocketEnabled, 101 | sendReport: reportEnabled, 102 | }) 103 | ); 104 | } catch (error) { 105 | if (error instanceof Error) { 106 | dispatch(settingsSlice.actions.saveSettingError({ blogURL, error })); 107 | } 108 | } 109 | }; 110 | } 111 | 112 | type MTA = ThunkAction; 113 | export function sendTestReportMail(blogURL: string): MTA { 114 | return async (dispatch) => { 115 | dispatch(settingsSlice.actions.firebaseSendTestReportMailRequest(blogURL)); 116 | try { 117 | await httpsCallable(getFunctions(getApp(), 'asia-northeast1'), 'sendTestReportMail')({ blogURL }); 118 | dispatch(settingsSlice.actions.firebaseSendTestReportMailResponse(blogURL)); 119 | } catch (error) { 120 | if (error instanceof Error) { 121 | dispatch(settingsSlice.actions.firebaseSendTestMailError({ blogURL, error })); 122 | } 123 | } 124 | }; 125 | } 126 | -------------------------------------------------------------------------------- /src/redux/slices/user.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, ThunkAction, PayloadAction } from '@reduxjs/toolkit'; 2 | import { User, Auth } from '@firebase/auth'; 3 | 4 | export type UserState = { 5 | user?: User; 6 | loading: boolean; 7 | }; 8 | 9 | export const initialState: UserState = { loading: true }; 10 | 11 | export const userSlice = createSlice({ 12 | name: 'user', 13 | initialState, 14 | reducers: { 15 | firebaseUserRequest(state) { 16 | return { ...state, loading: true }; 17 | }, 18 | firebaseUserResponse(state, action: PayloadAction) { 19 | return { ...state, user: action.payload, loading: false }; 20 | }, 21 | firebaseUnauthorizedError(state, action: PayloadAction) { 22 | return { ...state, loading: false }; 23 | }, 24 | firebaseSignoutResponse(state) { 25 | return { ...initialState, loading: false }; 26 | }, 27 | firebaseSignoutRequest(state) { 28 | return state; 29 | }, 30 | firebaseSignoutError(state, action: PayloadAction) { 31 | return state; 32 | }, 33 | }, 34 | }); 35 | 36 | export function fetchUser(auth: Auth): ThunkAction { 37 | return async (dispatch) => { 38 | try { 39 | dispatch(userSlice.actions.firebaseUserRequest()); 40 | const user = await currenUserOronAuthStateChanged(auth); 41 | dispatch(userSlice.actions.firebaseUserResponse(JSON.parse(JSON.stringify(user)))); 42 | } catch (e) { 43 | if (e instanceof Error) { 44 | dispatch(userSlice.actions.firebaseUnauthorizedError(e)); 45 | } 46 | } 47 | }; 48 | } 49 | 50 | export function onAuthStateChanged(auth: Auth): Promise { 51 | return new Promise((resolve, reject) => { 52 | auth.onAuthStateChanged((user) => { 53 | if (user) { 54 | resolve(user); 55 | } else { 56 | reject(new Error('User login failed')); 57 | } 58 | }); 59 | }); 60 | } 61 | 62 | export async function currenUserOronAuthStateChanged(auth: Auth): Promise { 63 | const currentUser = auth.currentUser; 64 | if (currentUser) { 65 | return currentUser; 66 | } else { 67 | return onAuthStateChanged(auth); 68 | } 69 | } 70 | 71 | export function signOut(auth: Auth): ThunkAction { 72 | return async (dispatch) => { 73 | try { 74 | dispatch(userSlice.actions.firebaseSignoutRequest()); 75 | await auth.signOut(); 76 | dispatch(userSlice.actions.firebaseSignoutResponse()); 77 | } catch (e) { 78 | if (e instanceof Error) { 79 | dispatch(userSlice.actions.firebaseSignoutError(e)); 80 | } 81 | } 82 | }; 83 | } 84 | -------------------------------------------------------------------------------- /src/serviceWorker.ts: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read http://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/) 19 | ); 20 | 21 | type Config = { 22 | onSuccess?: (registration: ServiceWorkerRegistration) => void; 23 | onUpdate?: (registration: ServiceWorkerRegistration) => void; 24 | }; 25 | 26 | export function register(config?: Config) { 27 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 28 | // The URL constructor is available in all browsers that support SW. 29 | const publicUrl = new URL((process as { env: { [key: string]: string } }).env.PUBLIC_URL, window.location.href); 30 | if (publicUrl.origin !== window.location.origin) { 31 | // Our service worker won't work if PUBLIC_URL is on a different origin 32 | // from what our page is served on. This might happen if a CDN is used to 33 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 34 | return; 35 | } 36 | 37 | window.addEventListener('load', () => { 38 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 39 | 40 | if (isLocalhost) { 41 | // This is running on localhost. Let's check if a service worker still exists or not. 42 | checkValidServiceWorker(swUrl, config); 43 | 44 | // Add some additional logging to localhost, pointing developers to the 45 | // service worker/PWA documentation. 46 | // tslint:disable-next-line:no-floating-promises 47 | navigator.serviceWorker.ready.then(() => { 48 | // tslint:disable-next-line:no-console 49 | console.log( 50 | 'This web app is being served cache-first by a service ' + 51 | 'worker. To learn more, visit http://bit.ly/CRA-PWA' 52 | ); 53 | }); 54 | } else { 55 | // Is not localhost. Just register service worker 56 | registerValidSW(swUrl, config); 57 | } 58 | }); 59 | } 60 | } 61 | 62 | function registerValidSW(swUrl: string, config?: Config) { 63 | navigator.serviceWorker 64 | .register(swUrl) 65 | .then((registration) => { 66 | registration.onupdatefound = () => { 67 | const installingWorker = registration.installing; 68 | if (installingWorker === null) { 69 | return; 70 | } 71 | installingWorker.onstatechange = () => { 72 | if (installingWorker.state === 'installed') { 73 | if (navigator.serviceWorker.controller) { 74 | // At this point, the updated precached content has been fetched, 75 | // but the previous service worker will still serve the older 76 | // content until all client tabs are closed. 77 | // tslint:disable-next-line:no-console 78 | console.log( 79 | 'New content is available and will be used when all ' + 80 | 'tabs for this page are closed. See http://bit.ly/CRA-PWA.' 81 | ); 82 | 83 | // Execute callback 84 | if (config && config.onUpdate) { 85 | config.onUpdate(registration); 86 | } 87 | } else { 88 | // At this point, everything has been precached. 89 | // It's the perfect time to display a 90 | // "Content is cached for offline use." message. 91 | // tslint:disable-next-line:no-console 92 | console.log('Content is cached for offline use.'); 93 | 94 | // Execute callback 95 | if (config && config.onSuccess) { 96 | config.onSuccess(registration); 97 | } 98 | } 99 | } 100 | }; 101 | }; 102 | }) 103 | .catch((error) => { 104 | // tslint:disable-next-line:no-console 105 | console.error('Error during service worker registration:', error); 106 | }); 107 | } 108 | 109 | function checkValidServiceWorker(swUrl: string, config?: Config) { 110 | // Check if the service worker can be found. If it can't reload the page. 111 | fetch(swUrl) 112 | .then((response) => { 113 | // Ensure service worker exists, and that we really are getting a JS file. 114 | const contentType = response.headers.get('content-type'); 115 | if (response.status === 404 || (contentType !== null && contentType.indexOf('javascript') === -1)) { 116 | // No service worker found. Probably a different app. Reload the page. 117 | // tslint:disable-next-line:no-floating-promises 118 | navigator.serviceWorker.ready.then((registration) => { 119 | // tslint:disable-next-line:no-floating-promises 120 | registration.unregister().then(() => { 121 | window.location.reload(); 122 | }); 123 | }); 124 | } else { 125 | // Service worker found. Proceed as normal. 126 | registerValidSW(swUrl, config); 127 | } 128 | }) 129 | .catch(() => { 130 | // tslint:disable-next-line:no-console 131 | console.log('No internet connection found. App is running in offline mode.'); 132 | }); 133 | } 134 | 135 | export function unregister() { 136 | if ('serviceWorker' in navigator) { 137 | // tslint:disable-next-line:no-floating-promises 138 | navigator.serviceWorker.ready.then((registration) => { 139 | // tslint:disable-next-line:no-floating-promises 140 | registration.unregister(); 141 | }); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/utils/canonicalize.ts: -------------------------------------------------------------------------------- 1 | export function canonicalize(url: string) { 2 | // Firebase hosting on production and web browsers return single slash protocol part such as "http:/" 3 | if (/^http:\/[^/]/.test(url)) { 4 | return url.replace(/^http:\//, 'http://'); 5 | } else if (/^https:\/[^/]/.test(url)) { 6 | return url.replace(/^https:\//, 'https://'); 7 | } else { 8 | return url; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/withTracker.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import ReactGA, { FieldsObject } from 'react-ga'; 3 | import { RouteComponentProps } from 'react-router'; 4 | 5 | // https://github.com/react-ga/react-ga/wiki/React-Router-v4-withTracker 6 | const withTracker =

( 7 | WrappedComponent: React.ComponentType

, 8 | options: FieldsObject = {} 9 | ) => { 10 | const trackPage = (page: string) => { 11 | ReactGA.set({ page, ...options }); 12 | ReactGA.pageview(page); 13 | }; 14 | 15 | return (props: P) => { 16 | useEffect(() => { 17 | trackPage(props.location.pathname); 18 | }, [props.location.pathname]); 19 | 20 | return ; 21 | }; 22 | }; 23 | 24 | export default withTracker; 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "lib": [ 6 | "es6", 7 | "es5", 8 | "dom" 9 | ], 10 | "allowJs": false, 11 | "jsx": "react-jsx", 12 | "sourceMap": true, 13 | "outDir": "./build/dist", 14 | "rootDir": "./src", 15 | "strict": true, 16 | "noImplicitAny": true, 17 | "moduleResolution": "node", 18 | "rootDirs": [ 19 | "./src", 20 | "./types" 21 | ], 22 | "types": [ 23 | "react", 24 | "react-dom" 25 | ], 26 | "allowSyntheticDefaultImports": true, 27 | "esModuleInterop": true, 28 | "skipLibCheck": true, 29 | "forceConsistentCasingInFileNames": true, 30 | "resolveJsonModule": true, 31 | "isolatedModules": true, 32 | "noFallthroughCasesInSwitch": true, 33 | "noEmit": true 34 | }, 35 | "include": [ 36 | "./src/**/*" 37 | ], 38 | "exclude": [ 39 | "node_modules", 40 | "build", 41 | "scripts", 42 | "acceptance-tests", 43 | "webpack", 44 | "jest", 45 | "src/setupTests.ts", 46 | "src/**/**.test.*" 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs" 5 | } 6 | } --------------------------------------------------------------------------------