├── packages ├── stats-lambda │ ├── Procfile │ ├── .browserslistrc │ ├── Dockerfile │ ├── .env.sample │ ├── serverless.yml │ ├── config │ │ ├── twitter.js │ │ ├── aws.js │ │ └── youtube.js │ ├── .eslintrc.json │ ├── graphql.js │ ├── resolvers │ │ ├── clients │ │ │ ├── twitter.js │ │ │ ├── cache.js │ │ │ ├── youtube.js │ │ │ ├── simplecast-client.js │ │ │ ├── overall-stats.js │ │ │ └── soundcloud.js │ │ └── index.js │ ├── server.js │ ├── package.json │ └── schema │ │ └── index.js └── stats-pages │ ├── .browserslistrc │ ├── src │ ├── components │ │ ├── DashboardView.module.scss │ │ ├── OverallStatsTimeSeries.module.scss │ │ ├── ui │ │ │ ├── Divider.module.scss │ │ │ ├── Title.module.scss │ │ │ ├── Divider.js │ │ │ ├── Title.js │ │ │ ├── Badge.module.scss │ │ │ ├── List.module.scss │ │ │ ├── Toggle.scss │ │ │ ├── Card.js │ │ │ ├── List.js │ │ │ ├── Badge.js │ │ │ └── Card.module.scss │ │ ├── Loading.js │ │ ├── Icons.module.scss │ │ ├── Navigation.module.scss │ │ ├── tabs │ │ │ ├── OverallValuesTabView.module.scss │ │ │ ├── EpisodesTabView.js │ │ │ ├── TotalListensTabView.js │ │ │ └── OverallValuesTabView.js │ │ ├── TopEpisodesChart.scss │ │ ├── OverallValue.scss │ │ ├── Loading.scss │ │ ├── WhatsUpToday.scss │ │ ├── Navigation.js │ │ ├── TopBottomNEpisodes.scss │ │ ├── EpisodesChart.js │ │ ├── OverallValue.js │ │ ├── OverallStatsTimeSeries.js │ │ ├── DashboardView.js │ │ ├── TopBottomNEpisodes.js │ │ ├── WhatsUpToday.js │ │ ├── TopEpisodesChart.js │ │ └── Icons.js │ ├── queries │ │ ├── invalidate-cache.mutation.js │ │ └── dashboard.query.js │ ├── index.js │ ├── api │ │ ├── overall-compare-service.js │ │ └── episode-stats-service.js │ ├── App.js │ ├── index.scss │ └── serviceWorker.js │ ├── Dockerfile │ ├── public │ ├── favicon.ico │ ├── manifest.json │ └── index.html │ ├── .eslintrc.json │ └── package.json ├── .prettierrc ├── lerna.json ├── docker-compose.yml ├── prbuildspec.yml ├── .gitignore ├── package.json └── README.md /packages/stats-lambda/Procfile: -------------------------------------------------------------------------------- 1 | web: node ./packages/stats-lambda/server.js -------------------------------------------------------------------------------- /packages/stats-lambda/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 0.2% 2 | not dead 3 | not ie <= 11 4 | not op_mini all -------------------------------------------------------------------------------- /packages/stats-pages/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 0.2% 2 | not dead 3 | not ie <= 11 4 | not op_mini all -------------------------------------------------------------------------------- /packages/stats-pages/src/components/DashboardView.module.scss: -------------------------------------------------------------------------------- 1 | .summary { 2 | width: 100%; 3 | } 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/OverallStatsTimeSeries.module.scss: -------------------------------------------------------------------------------- 1 | .chartContainer { 2 | margin-top: 1rem; 3 | } 4 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/ui/Divider.module.scss: -------------------------------------------------------------------------------- 1 | .divider { 2 | border-top: 1px solid var(--divider); 3 | } 4 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/ui/Title.module.scss: -------------------------------------------------------------------------------- 1 | .title { 2 | margin: 0; 3 | font-size: 2rem; 4 | font-weight: 500; 5 | } 6 | -------------------------------------------------------------------------------- /packages/stats-lambda/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:8.10.0-alpine 2 | 3 | RUN mkdir /stats 4 | WORKDIR /stats 5 | 6 | COPY . . 7 | EXPOSE 4000 8 | RUN yarn -------------------------------------------------------------------------------- /packages/stats-pages/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:8.10.0-alpine 2 | 3 | RUN mkdir /stats 4 | WORKDIR /stats 5 | 6 | COPY . . 7 | EXPOSE 3000 8 | RUN yarn -------------------------------------------------------------------------------- /packages/stats-pages/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msusur/codefiction-stats-graphql/HEAD/packages/stats-pages/public/favicon.ico -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": [ 3 | "packages/*" 4 | ], 5 | "npmClient": "yarn", 6 | "useWorkspaces": true, 7 | "version": "1.0.0" 8 | } 9 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/Loading.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './Loading.scss'; 3 | 4 | const Loading = () =>
; 5 | 6 | export default Loading; 7 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/Icons.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | user-select: none; 3 | width: 1em; 4 | height: 1em; 5 | display: inline-block; 6 | fill: currentColor; 7 | flex-shrink: 0; 8 | font-size: 1.5em; 9 | } 10 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/ui/Divider.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styles from './Divider.module.scss'; 3 | 4 | const Divider = () => { 5 | return
; 6 | }; 7 | 8 | export default Divider; 9 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/Navigation.module.scss: -------------------------------------------------------------------------------- 1 | .nav { 2 | margin: 2em 0; 3 | 4 | .content { 5 | display: flex; 6 | justify-content: space-between; 7 | align-items: center; 8 | } 9 | 10 | .logo { 11 | fill: #e76953; 12 | height: 42px; 13 | width: 42px; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/ui/Title.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cls from 'classnames'; 3 | import styles from './Title.module.scss'; 4 | 5 | const Title = ({ value, className }) => { 6 | return

{value}

; 7 | }; 8 | 9 | export default Title; 10 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/tabs/OverallValuesTabView.module.scss: -------------------------------------------------------------------------------- 1 | .cards { 2 | display: flex; 3 | justify-content: space-between; 4 | width: 100%; 5 | flex-direction: column; 6 | 7 | @media (min-width: 768px) { 8 | flex-direction: row; 9 | } 10 | } 11 | 12 | .noPadding { 13 | padding: 0; 14 | } 15 | -------------------------------------------------------------------------------- /packages/stats-lambda/.env.sample: -------------------------------------------------------------------------------- 1 | SECRET=SECRET 2 | YOUTUBE=SECRET 3 | YOUTUBE_KEY=SECRET 4 | TWITTER_CONSUMER_API_KEY=SECRET 5 | TWITTER_CONSUMER_API_SECRET_KEY=SECRET 6 | TWITTER_ACCESS_TOKEN=SECRET 7 | TWITTER_ACCESS_SECRET=SECRET 8 | AMAZON_AWS_ACCESS_KEY=SECRET 9 | AMAZON_AWS_ACCESS_SECRET_KEY=SECRET 10 | ENGINE_API_KEY=SECRET -------------------------------------------------------------------------------- /packages/stats-pages/src/queries/invalidate-cache.mutation.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | export const invalidateCacheMutation = gql` 4 | mutation InvalidateCache { 5 | invalidateCache { 6 | podcasts { 7 | title 8 | } 9 | } 10 | } 11 | `; 12 | 13 | export default invalidateCacheMutation; 14 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | stats-api: 5 | ports: 6 | - "4000:4000" 7 | build: 8 | context: ./packages/stats-lambda/. 9 | command: yarn start 10 | stats-pages: 11 | ports: 12 | - "3000:3000" 13 | build: 14 | context: ./packages/stats-pages/. 15 | command: yarn start -------------------------------------------------------------------------------- /packages/stats-pages/src/components/TopEpisodesChart.scss: -------------------------------------------------------------------------------- 1 | .dashboard { 2 | &--items { 3 | margin-right: 40px; 4 | float: left; 5 | } 6 | 7 | &--items-container { 8 | display: flex; 9 | margin-top: 1.5rem; 10 | flex-direction: column; 11 | 12 | @media (min-width: 768px) { 13 | flex-direction: row; 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/stats-lambda/serverless.yml: -------------------------------------------------------------------------------- 1 | service: apollo-lambda 2 | provider: 3 | name: aws 4 | runtime: nodejs8.10 5 | region: eu-west-1 6 | functions: 7 | graphql: 8 | # this is formatted as . 9 | handler: graphql.graphqlHandler 10 | events: 11 | - http: 12 | path: graphql 13 | method: post 14 | cors: true -------------------------------------------------------------------------------- /packages/stats-pages/src/components/ui/Badge.module.scss: -------------------------------------------------------------------------------- 1 | .badge { 2 | display: inline-flex; 3 | font-weight: 700; 4 | font-size: 1.1rem; 5 | color: #5ccd97; 6 | justify-content: center; 7 | align-items: center; 8 | 9 | &.equal { 10 | color: #000; 11 | } 12 | 13 | &.danger { 14 | background: var(--danger); 15 | color: #fff; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /prbuildspec.yml: -------------------------------------------------------------------------------- 1 | version: 0.2 2 | 3 | phases: 4 | install: 5 | commands: 6 | - npm install yarn -g 7 | - yarn 8 | - yarn bootstrap 9 | pre_build: 10 | commands: 11 | - yarn lint 12 | build: 13 | commands: 14 | - yarn build 15 | artifacts: 16 | files: 17 | - '**/*' 18 | base-directory: 'packages/stats-pages/build' 19 | -------------------------------------------------------------------------------- /packages/stats-pages/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [{ 5 | "src": "favicon.ico", 6 | "sizes": "64x64 32x32 24x24 16x16", 7 | "type": "image/x-icon" 8 | }], 9 | "start_url": ".", 10 | "display": "standalone", 11 | "theme_color": "#000000", 12 | "background_color": "#ffffff" 13 | } -------------------------------------------------------------------------------- /packages/stats-lambda/config/twitter.js: -------------------------------------------------------------------------------- 1 | const twitterConfig = { 2 | CONSUMER_API_KEYS: { 3 | API_KEY: process.env.TWITTER_CONSUMER_API_KEY, 4 | API_SECRET_KEY: process.env.TWITTER_CONSUMER_API_SECRET_KEY, 5 | }, 6 | ACCESS_KEYS: { 7 | ACCESS_TOKEN: process.env.TWITTER_ACCESS_TOKEN, 8 | ACCESS_SECRET: process.env.TWITTER_ACCESS_SECRET, 9 | }, 10 | }; 11 | 12 | module.exports = { twitterConfig }; 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules 3 | /.pnp 4 | .pnp.js 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | build 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | 24 | .vscode/ 25 | 26 | # serverless stuff 27 | .serverless 28 | admin.env 29 | _meta 30 | -------------------------------------------------------------------------------- /packages/stats-lambda/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true 5 | }, 6 | "extends": ["airbnb-base", "prettier"], 7 | "plugins": ["import", "prettier", "standard"], 8 | "parserOptions": { 9 | "ecmaVersion": 2017 10 | }, 11 | "rules": { 12 | "prettier/prettier": ["error"], 13 | "no-unused-vars": "warn", 14 | "no-console": "off", 15 | "class-methods-use-this": "off" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/OverallValue.scss: -------------------------------------------------------------------------------- 1 | .dashboard { 2 | text-align: left; 3 | 4 | &--label { 5 | font-size: 1em; 6 | font-weight: 500; 7 | } 8 | &--value { 9 | color: #303030; 10 | font-weight: 700; 11 | font-size: 2.3em; 12 | display: flex; 13 | align-items: center; 14 | } 15 | &--badge { 16 | font-size: 14px; 17 | color: var(--badge); 18 | margin-left: 0.5em; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/stats-lambda/graphql.js: -------------------------------------------------------------------------------- 1 | const { ApolloServer } = require('apollo-server-lambda'); 2 | const resolvers = require('./resolvers/index'); 3 | const typeDefs = require('./schema/index'); 4 | 5 | const server = new ApolloServer({ 6 | typeDefs, 7 | resolvers, 8 | engine: { 9 | apiKey: process.env.ENGINE_API_KEY, 10 | }, 11 | cacheControl: true, 12 | }); 13 | exports.graphqlHandler = server.createHandler({ 14 | cors: { 15 | origin: true, 16 | credentials: true, 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/ui/List.module.scss: -------------------------------------------------------------------------------- 1 | .list { 2 | &.unstyled { 3 | list-style: none; 4 | margin: 0; 5 | padding: 0; 6 | margin-bottom: 0.5em; 7 | } 8 | } 9 | 10 | .listItem { 11 | background: var(--card); 12 | box-shadow: var(--box-shadow); 13 | padding: 0.75em 1em; 14 | margin-bottom: 0.5em; 15 | border: var(--card-border); 16 | border-radius: 6px; 17 | font-size: 1rem; 18 | 19 | &:hover { 20 | background: var(--hover-color); 21 | color: #fff; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/ui/Toggle.scss: -------------------------------------------------------------------------------- 1 | .toggle { 2 | svg { 3 | font-size: 18px; 4 | color: #fff; 5 | } 6 | &.react-toggle--checked { 7 | .react-toggle-track { 8 | background-color: #e76953; 9 | } 10 | &:hover:not(.react-toggle--disabled) { 11 | .react-toggle-track { 12 | background: #be5846; 13 | } 14 | } 15 | } 16 | .react-toggle-track-x, 17 | .react-toggle-track-check { 18 | display: flex; 19 | justify-content: center; 20 | align-items: center; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/stats-lambda/config/aws.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk'); 2 | 3 | const SERVICE_NAME = 'dynamodb'; 4 | const REGION = 'eu-west-1'; 5 | const configuration = { 6 | region: REGION, 7 | endpoint: `https://${SERVICE_NAME}.${REGION}.amazonaws.com`, 8 | accessKeyId: process.env.AMAZON_AWS_ACCESS_KEY, 9 | secretAccessKey: process.env.AMAZON_AWS_ACCESS_SECRET_KEY, 10 | }; 11 | 12 | console.log(`DynamoDb endpoint is: ${configuration.endpoint}`); 13 | AWS.config.update(configuration); 14 | module.exports = { 15 | dynamoClient: new AWS.DynamoDB.DocumentClient(), 16 | }; 17 | -------------------------------------------------------------------------------- /packages/stats-lambda/config/youtube.js: -------------------------------------------------------------------------------- 1 | const youtubeConfig = { 2 | web: { 3 | client_id: 4 | '1068620534768-opg46t5vkb5364196u6tf2dkna6dir49.apps.googleusercontent.com', 5 | project_id: 'codefiction-221700', 6 | auth_uri: 'https://accounts.google.com/o/oauth2/auth', 7 | token_uri: 'https://www.googleapis.com/oauth2/v3/token', 8 | auth_provider_x509_cert_url: 'https://www.googleapis.com/oauth2/v1/certs', 9 | client_secret: process.env.YOUTUBE, 10 | }, 11 | key: process.env.YOUTUBE_KEY, 12 | channel_id: 'UCq3oLmam_8au66Hyw2-BJtg', 13 | }; 14 | 15 | module.exports = { youtubeConfig }; 16 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/ui/Card.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cls from 'classnames'; 3 | import styles from './Card.module.scss'; 4 | 5 | const Card = ({ children, title, icon, className, ...otherProps }) => { 6 | const Icon = icon; 7 | return ( 8 |
9 | {title && ( 10 |
11 |

{title}

12 | {icon && } 13 |
14 | )} 15 | {children} 16 |
17 | ); 18 | }; 19 | 20 | export default Card; 21 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/Loading.scss: -------------------------------------------------------------------------------- 1 | .loading { 2 | display: inline-block; 3 | width: 64px; 4 | height: 64px; 5 | margin:0 auto; 6 | display: flex; 7 | padding: 10px; 8 | } 9 | 10 | .loading:after { 11 | content: ' '; 12 | align-content: center; 13 | display: block; 14 | width: 46px; 15 | height: 46px; 16 | margin: 1px; 17 | border-radius: 50%; 18 | border: 5px solid #000; 19 | border-color: #000 transparent; 20 | animation: loading 1.2s linear infinite; 21 | } 22 | 23 | @keyframes loading { 24 | 0% { 25 | transform: rotate(0deg); 26 | } 27 | 100% { 28 | transform: rotate(360deg); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/ui/List.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cls from 'classnames'; 3 | import styles from './List.module.scss'; 4 | 5 | export const List = ({ children, className, unstyled, ...otherProps }) => { 6 | return ( 7 |
    11 | {children} 12 |
13 | ); 14 | }; 15 | 16 | export const ListItem = ({ children, className, ...otherProps }) => { 17 | return ( 18 |
  • 19 | {children} 20 |
  • 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/ui/Badge.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cls from 'classnames'; 3 | import styles from './Badge.module.scss'; 4 | import { ChevronUp, ChevronDown } from '../Icons'; 5 | 6 | const Badge = ({ children, value, className }) => { 7 | const danger = value < 0; 8 | const equal = value === 0; 9 | 10 | return ( 11 |
    18 | {danger ? : } 19 | {children} 20 |
    21 | ); 22 | }; 23 | 24 | export default Badge; 25 | -------------------------------------------------------------------------------- /packages/stats-pages/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Codefiction Dashboard 12 | 13 | 14 | 15 | 18 |
    19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/WhatsUpToday.scss: -------------------------------------------------------------------------------- 1 | .whatsup-today { 2 | &--main { 3 | width: 100%; 4 | 5 | @media (min-width: 768px) { 6 | width: 50%; 7 | } 8 | } 9 | 10 | &--header { 11 | display: flex; 12 | align-items: center; 13 | margin-bottom: 1em; 14 | @media (max-width: 991.98px) { 15 | justify-content: space-between; 16 | } 17 | } 18 | &--refresh { 19 | margin-left: 1em; 20 | cursor: pointer; 21 | } 22 | &--animate { 23 | animation: spin 1.5s infinite linear; 24 | } 25 | } 26 | 27 | @keyframes spin { 28 | from { 29 | transform: scale(1) rotate(0deg); 30 | } 31 | to { 32 | transform: scale(1) rotate(360deg); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/Navigation.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cls from 'classnames'; 3 | import Toggle from 'react-toggle'; 4 | import 'react-toggle/style.css'; 5 | import styles from './Navigation.module.scss'; 6 | import './ui/Toggle.scss'; 7 | import { Logo, Sun, Moon } from './Icons'; 8 | 9 | const Navigation = ({ toggleTheme, isThemeDark }) => ( 10 | 24 | ); 25 | 26 | export default Navigation; 27 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/TopBottomNEpisodes.scss: -------------------------------------------------------------------------------- 1 | .dashboard { 2 | &--head-row { 3 | padding: 10px; 4 | input { 5 | width: 100%; 6 | } 7 | } 8 | &--up-down-button { 9 | float: right; 10 | cursor: pointer; 11 | } 12 | &--fix-head { 13 | overflow-y: auto; 14 | height: 100px; 15 | th { 16 | position: sticky; 17 | top: 0; 18 | } 19 | } 20 | } 21 | 22 | table { 23 | border-collapse: collapse; 24 | width: 100%; 25 | } 26 | 27 | th, 28 | td { 29 | padding: 8px 16px; 30 | } 31 | 32 | th { 33 | background: var(--table-header); 34 | } 35 | 36 | td.align-center { 37 | text-align: center; 38 | } 39 | 40 | th.w-65 { 41 | width: 65%; 42 | } 43 | 44 | .table-striped>tbody>tr:nth-of-type(odd) { 45 | background-color: var(--table-stripped); 46 | } 47 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/ui/Card.module.scss: -------------------------------------------------------------------------------- 1 | .card { 2 | background: var(--card); 3 | box-shadow: var(--shadow); 4 | border-radius: 6px; 5 | padding: 1.5rem; 6 | margin-top: 1em; 7 | border: var(--card-border); 8 | flex: 1 0 auto; 9 | 10 | @media (min-width: 768px) { 11 | margin-right: 1em; 12 | margin-left: 1em; 13 | } 14 | 15 | &:first-child { 16 | margin-left: 0; 17 | } 18 | 19 | &:last-child { 20 | margin-right: 0; 21 | } 22 | 23 | &:only-child { 24 | margin-left: 0; 25 | margin-right: 0; 26 | } 27 | 28 | .cardHeader { 29 | display: flex; 30 | justify-content: space-between; 31 | 32 | .cardTitle { 33 | color: var(--card-title); 34 | font-weight: 600; 35 | margin: 0; 36 | font-size: 1.1rem; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/stats-pages/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "extends": ["airbnb", "plugin:prettier/recommended", "prettier/react"], 7 | "globals": { 8 | "Atomics": "readonly", 9 | "SharedArrayBuffer": "readonly" 10 | }, 11 | "parser": "babel-eslint", 12 | "parserOptions": { 13 | "ecmaVersion": 2017, 14 | "sourceType": "module", 15 | "ecmaFeatures": { 16 | "jsx": true, 17 | "modules": true 18 | } 19 | }, 20 | "plugins": ["react", "prettier"], 21 | "rules": { 22 | "prettier/prettier": ["error"], 23 | "no-unused-vars": "warn", 24 | "class-methods-use-this": "off", 25 | "import/no-named-as-default": 0, 26 | "react/prop-types": 0, 27 | "react/jsx-filename-extension": 0, 28 | "no-console": 0 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/stats-pages/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { ApolloClient, HttpLink, InMemoryCache } from 'apollo-boost'; 4 | import { ApolloProvider } from '@apollo/react-hooks'; 5 | import App from './App'; 6 | 7 | import * as serviceWorker from './serviceWorker'; 8 | 9 | import './index.scss'; 10 | 11 | const client = new ApolloClient({ 12 | link: new HttpLink({ 13 | uri: 'https://codefiction-stats.herokuapp.com/graphql', 14 | }), 15 | cache: new InMemoryCache(), 16 | }); 17 | 18 | ReactDOM.render( 19 | 20 | 21 | , 22 | document.getElementById('root') 23 | ); 24 | 25 | // If you want your app to work offline and load faster, you can change 26 | // unregister() to register() below. Note this comes with some pitfalls. 27 | // Learn more about service workers: http://bit.ly/CRA-PWA 28 | serviceWorker.unregister(); 29 | -------------------------------------------------------------------------------- /packages/stats-lambda/resolvers/clients/twitter.js: -------------------------------------------------------------------------------- 1 | const Twit = require('twit'); 2 | const { twitterConfig } = require('../../config/twitter'); 3 | 4 | class TwitterClient { 5 | constructor() { 6 | this.twit = new Twit({ 7 | consumer_key: twitterConfig.CONSUMER_API_KEYS.API_KEY, 8 | consumer_secret: twitterConfig.CONSUMER_API_KEYS.API_SECRET_KEY, 9 | access_token: twitterConfig.ACCESS_KEYS.ACCESS_TOKEN, 10 | access_token_secret: twitterConfig.ACCESS_KEYS.ACCESS_SECRET, 11 | }); 12 | } 13 | 14 | getFollowers() { 15 | const tweets = this.twit 16 | .get('followers/ids', { 17 | screen_name: 'codefictiontech', 18 | }) 19 | .then(response => { 20 | return { 21 | followersCount: response.data.ids.length, 22 | }; 23 | }) 24 | .catch(err => { 25 | throw err; 26 | }); 27 | 28 | return tweets; 29 | } 30 | } 31 | 32 | module.exports = TwitterClient; 33 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/EpisodesChart.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | BarChart, 4 | Bar, 5 | XAxis, 6 | CartesianGrid, 7 | Tooltip, 8 | ResponsiveContainer, 9 | } from 'recharts'; 10 | import { EpisodeStatsService } from '../api/episode-stats-service'; 11 | import Card from './ui/Card'; 12 | 13 | const EpisodesChart = ({ podcast }) => { 14 | const stats = new EpisodeStatsService(); 15 | const statValues = stats.getTimeSeries(podcast.episodes); 16 | 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | ); 29 | }; 30 | 31 | export default EpisodesChart; 32 | -------------------------------------------------------------------------------- /packages/stats-lambda/server.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * This server is only for local development 4 | * Actual server implementation is in graphql.js 5 | * 6 | */ 7 | 8 | // Read the local environment variables from .env file. 9 | require('dotenv').config(); 10 | const express = require('express'); 11 | const { ApolloServer } = require('apollo-server-express'); 12 | 13 | const resolvers = require('./resolvers/index'); 14 | const typeDefs = require('./schema/index'); 15 | 16 | const server = new ApolloServer({ 17 | typeDefs, 18 | resolvers, 19 | introspection: true, 20 | engine: { 21 | apiKey: process.env.ENGINE_API_KEY, 22 | }, 23 | }); 24 | 25 | const app = express(); 26 | app.set('port', process.env.PORT || 4000); 27 | server.applyMiddleware({ app }); 28 | app.use('/', express.static('build')); 29 | app.listen(app.get('port'), () => 30 | console.log( 31 | `Server ready at http://localhost:${app.get('port')}${server.graphqlPath}` 32 | ) 33 | ); 34 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/OverallValue.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './OverallValue.scss'; 3 | import * as numeral from 'numeral'; 4 | import OverallCompareService from '../api/overall-compare-service'; 5 | import Badge from './ui/Badge'; 6 | 7 | const OverallValue = ({ series, valueKey, value, text }) => { 8 | const compare = new OverallCompareService(series); 9 | const comparedValue = valueKey 10 | ? compare.setAndCompareValue(valueKey, value) 11 | : undefined; 12 | return ( 13 |
    14 | {text &&
    {text}
    } 15 |
    16 | {valueKey ? numeral(comparedValue.currentValue).format(0, 0) : value} 17 | {comparedValue && ( 18 | 19 | {comparedValue.ratio}% 20 | 21 | )} 22 |
    23 |
    24 | ); 25 | }; 26 | 27 | export default OverallValue; 28 | -------------------------------------------------------------------------------- /packages/stats-pages/src/queries/dashboard.query.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | export const DashboardQuery = gql` 4 | { 5 | podcasts { 6 | overallStats { 7 | total_listens 8 | } 9 | title 10 | episodes { 11 | title 12 | id 13 | enclosure_url 14 | details { 15 | episode_url 16 | } 17 | guid 18 | downloads(orderBy: desc) { 19 | total 20 | by_interval { 21 | downloads_total 22 | interval 23 | } 24 | } 25 | } 26 | } 27 | youtube { 28 | statistics { 29 | subscriberCount 30 | } 31 | videos { 32 | snippet { 33 | title 34 | resourceId { 35 | videoId 36 | } 37 | } 38 | statistics { 39 | viewCount 40 | } 41 | } 42 | } 43 | twitter { 44 | followersCount 45 | } 46 | overallTimeSeries { 47 | twitter 48 | youtube 49 | podcast 50 | createdOn 51 | } 52 | } 53 | `; 54 | export default DashboardQuery; 55 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/OverallStatsTimeSeries.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | LineChart, 4 | Line, 5 | XAxis, 6 | CartesianGrid, 7 | Tooltip, 8 | ResponsiveContainer, 9 | } from 'recharts'; 10 | import styles from './OverallStatsTimeSeries.module.scss'; 11 | 12 | const OverallStatsTimeSeries = ({ title, data, dataKey }) => { 13 | const chartProps = { 14 | title, 15 | items: data, 16 | key: dataKey, 17 | }; 18 | 19 | if (!chartProps.items || chartProps.items.length === 0) { 20 | return
    Henuz yeterli veri yok.
    ; 21 | } 22 | 23 | return ( 24 | 25 | 26 | 27 | 28 | 29 | 36 | 37 | 38 | ); 39 | }; 40 | 41 | export default OverallStatsTimeSeries; 42 | -------------------------------------------------------------------------------- /packages/stats-pages/src/api/overall-compare-service.js: -------------------------------------------------------------------------------- 1 | const fixCharLengthToTwo = number => (number < 10 ? `0${number}` : number); 2 | 3 | export class OverallCompareService { 4 | constructor(series) { 5 | this.series = series; 6 | } 7 | 8 | setAndCompareValue(key, currentValue) { 9 | const yesterday = new Date(); 10 | yesterday.setDate(yesterday.getDate() - 1); 11 | const createdOn = `${yesterday.getDate()}.${fixCharLengthToTwo( 12 | yesterday.getMonth() + 1 13 | )}.${yesterday.getFullYear()}`; 14 | const lastDayStat = this.series.filter(item => { 15 | if (item.createdOn === createdOn) { 16 | return item; 17 | } 18 | return null; 19 | }); 20 | 21 | if (lastDayStat.length <= 0) { 22 | return { 23 | currentValue, 24 | existingValue: -1, 25 | }; 26 | } 27 | const existingValue = lastDayStat[0][key]; 28 | const ratio = Math.ceil( 29 | ((currentValue - existingValue) / existingValue) * 100 30 | ); 31 | return { 32 | currentValue, 33 | existingValue, 34 | ratio, 35 | }; 36 | } 37 | } 38 | 39 | export default OverallCompareService; 40 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/tabs/EpisodesTabView.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { compareTwoStrings } from 'string-similarity'; 3 | import TopBottomNEpisodes from '../TopBottomNEpisodes'; 4 | 5 | export class EpisodesTabView extends Component { 6 | render() { 7 | const { videos, episodes } = this.props; 8 | const mappedEpisodes = episodes.map(episode => { 9 | const episodeRefined = episode; 10 | episodeRefined.grandTotal = episode.downloads.total; 11 | const filteredVideos = videos.filter(video => { 12 | const result = 13 | compareTwoStrings(video.snippet.title, episode.title) * 100 > 60; 14 | return result; 15 | }); 16 | if (filteredVideos.length > 0) { 17 | const videoRef = filteredVideos[0]; 18 | episodeRefined.videoRef = videoRef; 19 | episodeRefined.grandTotal += parseInt( 20 | episodeRefined.videoRef.statistics.viewCount, 21 | 10 22 | ); 23 | } 24 | 25 | return episodeRefined; 26 | }); 27 | return ; 28 | } 29 | } 30 | 31 | export default EpisodesTabView; 32 | -------------------------------------------------------------------------------- /packages/stats-pages/src/api/episode-stats-service.js: -------------------------------------------------------------------------------- 1 | const MONTHS_NAMES_TR = [ 2 | 'Ocak', 3 | 'Şubat', 4 | 'Mart', 5 | 'Nisan', 6 | 'Mayıs', 7 | 'Haziran', 8 | 'Temmuz', 9 | 'Ağustos', 10 | 'Eylül', 11 | 'Ekim', 12 | 'Kasım', 13 | 'Aralık', 14 | ]; 15 | 16 | export class EpisodeStatsService { 17 | getTimeSeries(episodes) { 18 | const months = {}; 19 | MONTHS_NAMES_TR.forEach(month => { 20 | months[month] = 0; 21 | }); 22 | episodes.map(episode => { 23 | if (!episode.downloads || !episode.downloads.by_interval) { 24 | return {}; 25 | } 26 | return episode.downloads.by_interval.forEach(item => { 27 | const month = MONTHS_NAMES_TR[new Date(item.interval).getMonth()]; 28 | months[month] = 29 | (months[month] ? months[month] : 0) + item.downloads_total; 30 | }); 31 | }); 32 | 33 | let data = []; 34 | 35 | Object.keys(months).forEach(key => { 36 | data = [ 37 | ...data, 38 | { 39 | month: key, 40 | listens: months[key], 41 | }, 42 | ]; 43 | }); 44 | 45 | return data; 46 | } 47 | } 48 | 49 | export default EpisodeStatsService; 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "codefiction-stats-graphql", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "Codefiction Podcast statistics application using Graphql and React components.", 6 | "main": "index.js", 7 | "scripts": { 8 | "build": "lerna run build --parallel", 9 | "dev": "lerna run dev --parallel", 10 | "deploy": "lerna run deploy", 11 | "start": "lerna run --scope stats-lambda start", 12 | "start:react": "lerna run --scope stats-pages start", 13 | "start:all": "lerna run start", 14 | "test": "lerna run --stream --concurrency 1 test", 15 | "bootstrap": "lerna bootstrap", 16 | "lint": "lerna run lint --parallel", 17 | "lint-autofix": "lerna run lint-autofix --parallel" 18 | }, 19 | "husky": { 20 | "hooks": { 21 | "pre-commit": "npm run lint" 22 | } 23 | }, 24 | "workspaces": [ 25 | "packages/*" 26 | ], 27 | "repository": { 28 | "type": "git", 29 | "url": "git+https://github.com/msusur/codefiction-stats-graphql.git" 30 | }, 31 | "keywords": [], 32 | "author": "", 33 | "license": "ISC", 34 | "bugs": { 35 | "url": "https://github.com/msusur/codefiction-stats-graphql/issues" 36 | }, 37 | "homepage": "https://codefiction.tech", 38 | "devDependencies": { 39 | "lerna": "^3.13.1", 40 | "husky": "^1.3.1" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/stats-pages/src/App.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { useQuery } from '@apollo/react-hooks'; 3 | import Helmet from 'react-helmet'; 4 | import DashboardView from './components/DashboardView'; 5 | import DashboardQuery from './queries/dashboard.query'; 6 | import Navigation from './components/Navigation'; 7 | import Loading from './components/Loading'; 8 | 9 | export const App = () => { 10 | const { loading, data } = useQuery(DashboardQuery, { 11 | notifyOnNetworkStatusChange: true, 12 | onError: ({ graphQLErrors, networkError }) => { 13 | if (graphQLErrors) { 14 | graphQLErrors.forEach(async err => { 15 | console.log(`[GraphQL error]: ${err.extensions.code}`); 16 | }); 17 | } 18 | if (networkError) { 19 | console.log(`[Network error]: ${networkError}`); 20 | } 21 | }, 22 | }); 23 | 24 | const [theme, setTheme] = useState('light'); 25 | 26 | useEffect(() => { 27 | setTheme(localStorage.getItem('theme') || 'light'); 28 | }, []); 29 | 30 | const toggleTheme = () => 31 | setTheme(oldTheme => { 32 | const newTheme = oldTheme === 'dark' ? 'light' : 'dark'; 33 | localStorage.setItem('theme', newTheme); 34 | return newTheme; 35 | }); 36 | 37 | return ( 38 | <> 39 | 40 | 41 | 42 | 43 | {loading ? : } 44 | 45 | ); 46 | }; 47 | 48 | export default App; 49 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/DashboardView.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TotalListensTabView from './tabs/TotalListensTabView'; 3 | import OverallValuesTabView from './tabs/OverallValuesTabView'; 4 | import EpisodesTabView from './tabs/EpisodesTabView'; 5 | import EpisodesChart from './EpisodesChart'; 6 | 7 | import styles from './DashboardView.module.scss'; 8 | import WhatsUpToday from './WhatsUpToday'; 9 | 10 | export const DashboardView = ({ results }) => { 11 | const { twitter, overallTimeSeries, podcasts, youtube } = results; 12 | const whatsUpTodayContext = { 13 | twitter, 14 | overallTimeSeries, 15 | podcasts, 16 | youtube, 17 | }; 18 | 19 | return ( 20 |
    21 |
    22 | 23 | 29 |
    30 |
    31 | 35 |
    36 |
    37 | 41 |
    42 |
    43 | 44 |
    45 |
    46 | ); 47 | }; 48 | 49 | export default DashboardView; 50 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/TopBottomNEpisodes.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactTable from 'react-table'; 3 | import 'react-table/react-table.css'; 4 | import './TopBottomNEpisodes.scss'; 5 | import Card from './ui/Card'; 6 | import { PlayIcon, MusicIcon } from './Icons'; 7 | 8 | const columns = [ 9 | { 10 | Header: 'Bölüm Adı', 11 | accessor: 'title', 12 | width: 620, 13 | filterable: true, 14 | }, 15 | { 16 | Header: 'Dinlenme', 17 | accessor: 'stats.total_listens', 18 | }, 19 | { 20 | Header: 'Youtube İzlenme', 21 | accessor: 'videoRef.statistics.viewCount', 22 | }, 23 | { 24 | Header: 'Toplam', 25 | accessor: 'grandTotal', 26 | }, 27 | { 28 | Header: 'Dinle', 29 | Cell: row => ( 30 | <> 31 | 37 | 38 | 39 | {row.original.videoRef && ( 40 | 48 | 49 | 50 | )} 51 | 52 | ), 53 | }, 54 | ]; 55 | 56 | const TopBottomNEpisodes = ({ episodes }) => ( 57 | 58 | 64 | 65 | ); 66 | 67 | export default TopBottomNEpisodes; 68 | -------------------------------------------------------------------------------- /packages/stats-lambda/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stats-lambda", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "server.js", 6 | "scripts": { 7 | "start": "node server.js", 8 | "start:server": "node server.js", 9 | "deploy": "serverless deploy", 10 | "lint": "eslint --ext .js ./", 11 | "lint-autofix": "eslint --ext .js ./ --fix" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "ISC", 16 | "dependencies": { 17 | "apollo-boost": "^0.1.20", 18 | "apollo-server-express": "^2.1.0", 19 | "apollo-server-lambda": "^2.4.6", 20 | "aws-sdk": "^2.350.0", 21 | "body-parser": "^1.18.3", 22 | "dotenv": "^6.2.0", 23 | "express": "^4.16.4", 24 | "google-auth-library": "^3.1.1", 25 | "googleapis": "^38.0.0", 26 | "graphql": "^14.1.1", 27 | "graphql-tools": "^4.0.3", 28 | "http-debug": "^0.1.2", 29 | "moment": "^2.29.2", 30 | "node-cache": "^4.2.0", 31 | "node-cache-promise": "^1.0.0", 32 | "numeral": "^2.0.6", 33 | "redis": "^2.8.0", 34 | "redis-commands": "^1.4.0", 35 | "simplecast-api-client": "^2.0.0", 36 | "string-similarity": "^3.0.0", 37 | "then-redis": "^2.0.1", 38 | "twit": "^2.2.11", 39 | "unstated": "^2.1.1", 40 | "youtube-api": "^2.0.10" 41 | }, 42 | "devDependencies": { 43 | "eslint": "^5.15.3", 44 | "eslint-config-airbnb-base": "^13.1.0", 45 | "eslint-config-prettier": "^4.1.0", 46 | "eslint-plugin-import": "^2.14.0", 47 | "eslint-plugin-jsx-a11y": "^6.1.1", 48 | "eslint-plugin-node": "^8.0.1", 49 | "eslint-plugin-prettier": "^3.0.1", 50 | "eslint-plugin-promise": "^4.0.1", 51 | "eslint-plugin-standard": "^4.0.0", 52 | "prettier": "1.15.2", 53 | "serverless": "^1.42.3" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /packages/stats-pages/src/components/tabs/TotalListensTabView.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import Select from 'react-select'; 3 | import TopEpisodesChart from '../TopEpisodesChart'; 4 | import Card from '../ui/Card'; 5 | 6 | export class TotalListensTabView extends Component { 7 | constructor(props) { 8 | super(props); 9 | this.state = { options: [], selectedItem: '' }; 10 | } 11 | 12 | componentDidMount = () => { 13 | this.mapEpisodes(); 14 | }; 15 | 16 | mapEpisodes = () => { 17 | const { episodes } = this.props; 18 | const options = episodes.map(episode => ({ 19 | label: episode.title, 20 | value: episode.title, 21 | original: episode, 22 | })); 23 | this.setState({ 24 | options, 25 | }); 26 | }; 27 | 28 | render() { 29 | const { youtubeVideos } = this.props; 30 | const { options, selectedValue, selectedItem } = this.state; 31 | return ( 32 | 36 |