├── .babelrc.json ├── .eslintrc ├── .gitattributes ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc.json ├── .storybook ├── .babelrc.json ├── main.js ├── mock │ ├── followers.tsx │ ├── index.tsx │ ├── legacySubscriber.tsx │ ├── subscriber.tsx │ └── users.tsx └── preview.tsx ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── package.json ├── rollup.config.js ├── src ├── api │ └── helpers.tsx ├── components │ ├── TwitchContext.ts │ ├── TwitchProvider.tsx │ ├── atoms │ │ ├── FollowerCount.tsx │ │ ├── LatestFollower.tsx │ │ ├── LatestSubscriber.tsx │ │ ├── SubscriberCount.tsx │ │ ├── UserDisplayName.tsx │ │ └── index.tsx │ ├── molecules │ │ ├── FollowerGoal.tsx │ │ ├── SubscriberGoal.tsx │ │ ├── index.tsx │ │ └── internal │ │ │ └── LoadingBar.tsx │ └── templates │ │ ├── TwitchWrapper.tsx │ │ └── games │ │ └── Overwatch.tsx ├── constants.tsx ├── hooks │ ├── index.ts │ ├── useTwitchApi.ts │ ├── useTwitchCurrentUser.ts │ ├── useTwitchFollowers.ts │ ├── useTwitchLegacySubscriptions.ts │ ├── useTwitchSubscriptions.ts │ └── useTwitchUsers.ts ├── index.tsx ├── interfaces.tsx └── stories │ ├── Introduction.stories.mdx │ ├── MoleculeFollowerGoal.stories.tsx │ ├── MoleculeSubscriberGoal.stories.tsx │ ├── TemplateGameOverwatch.stories.tsx │ ├── TemplateGameOverwatch.tsx │ └── UserDisplayName.stories.tsx ├── tsconfig.all.json └── tsconfig.json /.babelrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/env", 5 | { 6 | "loose": true, 7 | "bugfixes": true, 8 | "shippedProposals": true, 9 | "corejs": 3, 10 | "useBuiltIns": "entry", 11 | "include": [ 12 | "@babel/proposal-nullish-coalescing-operator", 13 | "@babel/proposal-optional-chaining" 14 | ] 15 | } 16 | ], 17 | ["@babel/react", { "runtime": "automatic" }], 18 | "@babel/typescript" 19 | ], 20 | "plugins": [ 21 | ["@babel/transform-runtime", { "useESModules": true }], 22 | ["@emotion", { "sourceMap": false, "autoLabel": "never" }] 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "reportUnusedDisableDirectives": true, 4 | "parser": "@typescript-eslint/parser", 5 | "extends": [ 6 | "plugin:react/recommended", 7 | "plugin:@typescript-eslint/recommended" 8 | ], 9 | "plugins": ["@typescript-eslint", "react-hooks"], 10 | "rules": { 11 | "no-restricted-imports": [ 12 | 1, 13 | { 14 | "paths": [ 15 | { 16 | "name": "react", 17 | "importNames": ["default"], 18 | "message": "Use named imports instead." 19 | }, 20 | { 21 | "name": "react-dom", 22 | "importNames": ["default"], 23 | "message": "Use named imports instead." 24 | } 25 | ] 26 | } 27 | ], 28 | "@typescript-eslint/ban-types": [ 29 | 1, 30 | { 31 | "extendDefaults": true, 32 | "types": { 33 | "React.FC": true, 34 | "FC": true, 35 | "FunctionComponent": true 36 | } 37 | } 38 | ], 39 | "@typescript-eslint/explicit-module-boundary-types": 0, 40 | "react-hooks/rules-of-hooks": 2, 41 | "react-hooks/exhaustive-deps": 1, 42 | "react/jsx-uses-react": 0, 43 | "react/prop-types": 0, 44 | "react/react-in-jsx-scope": 0 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | test: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v2 9 | - uses: actions/setup-node@v1 10 | with: 11 | node-version: '15.x' 12 | - name: npm install 13 | run: npm i 14 | - name: Prettier 15 | run: npm run prettier:ci 16 | - name: ESLint 17 | run: npm run eslint 18 | - name: Typecheck 19 | run: npm run typecheck 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /package-lock.json 4 | 5 | # production 6 | /lib 7 | 8 | # misc 9 | .DS_Store 10 | .env.local 11 | .env.development.local 12 | .env.test.local 13 | .env.production.local 14 | 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | 19 | .eslintcache 20 | 21 | .env 22 | 23 | # IDE 24 | .idea 25 | *.iml 26 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps = true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | lib 2 | node_modules 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "arrowParens": "avoid" 4 | } 5 | -------------------------------------------------------------------------------- /.storybook/.babelrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/env", 5 | { 6 | "loose": true, 7 | "bugfixes": true, 8 | "shippedProposals": true, 9 | "corejs": 3, 10 | "useBuiltIns": "entry", 11 | "include": [ 12 | "@babel/proposal-nullish-coalescing-operator", 13 | "@babel/proposal-optional-chaining" 14 | ] 15 | } 16 | ], 17 | ["@babel/react", { "runtime": "automatic" }], 18 | "@babel/typescript" 19 | ], 20 | "plugins": [["@babel/transform-runtime", { "useESModules": true }]] 21 | } 22 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ['../src/**/*.stories.*'], 3 | addons: ['@storybook/addon-links', '@storybook/addon-essentials'], 4 | reactOptions: { 5 | fastRefresh: true, 6 | strictMode: true, 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /.storybook/mock/followers.tsx: -------------------------------------------------------------------------------- 1 | import { TwitchUsersFollowsResponse } from '../../src/hooks'; 2 | 3 | const followers: Record = { 4 | me: { 5 | total: 76, 6 | data: [ 7 | { 8 | from_id: '82384475', 9 | from_name: 'Zwitttt', 10 | to_id: '66727133', 11 | to_name: 'WoxxyTheFool', 12 | followed_at: '2020-12-08T12:06:59Z', 13 | }, 14 | { 15 | from_id: '40231741', 16 | from_name: 'Makisi1337', 17 | to_id: '66727133', 18 | to_name: 'WoxxyTheFool', 19 | followed_at: '2020-12-08T10:10:05Z', 20 | }, 21 | { 22 | from_id: '37455254', 23 | from_name: '4ntsu_', 24 | to_id: '66727133', 25 | to_name: 'WoxxyTheFool', 26 | followed_at: '2020-12-08T01:36:44Z', 27 | }, 28 | { 29 | from_id: '50560004', 30 | from_name: 'SchwarzerRegenZ', 31 | to_id: '66727133', 32 | to_name: 'WoxxyTheFool', 33 | followed_at: '2020-12-08T00:55:47Z', 34 | }, 35 | { 36 | from_id: '602206724', 37 | from_name: 'Gokusaishiki', 38 | to_id: '66727133', 39 | to_name: 'WoxxyTheFool', 40 | followed_at: '2020-12-07T22:04:54Z', 41 | }, 42 | { 43 | from_id: '51560916', 44 | from_name: 'dreisan', 45 | to_id: '66727133', 46 | to_name: 'WoxxyTheFool', 47 | followed_at: '2020-12-07T22:02:47Z', 48 | }, 49 | { 50 | from_id: '514390976', 51 | from_name: 'roll_san', 52 | to_id: '66727133', 53 | to_name: 'WoxxyTheFool', 54 | followed_at: '2020-12-07T21:56:31Z', 55 | }, 56 | { 57 | from_id: '151141695', 58 | from_name: 'callmemadoka', 59 | to_id: '66727133', 60 | to_name: 'WoxxyTheFool', 61 | followed_at: '2020-12-07T21:41:45Z', 62 | }, 63 | { 64 | from_id: '163651709', 65 | from_name: 'Ironstar14', 66 | to_id: '66727133', 67 | to_name: 'WoxxyTheFool', 68 | followed_at: '2020-12-07T03:29:05Z', 69 | }, 70 | { 71 | from_id: '75501329', 72 | from_name: 'antonve', 73 | to_id: '66727133', 74 | to_name: 'WoxxyTheFool', 75 | followed_at: '2020-12-07T01:38:33Z', 76 | }, 77 | { 78 | from_id: '47181100', 79 | from_name: 'alephtwo', 80 | to_id: '66727133', 81 | to_name: 'WoxxyTheFool', 82 | followed_at: '2020-12-07T01:28:43Z', 83 | }, 84 | { 85 | from_id: '76536976', 86 | from_name: 'SimpleCookie', 87 | to_id: '66727133', 88 | to_name: 'WoxxyTheFool', 89 | followed_at: '2020-12-07T00:05:10Z', 90 | }, 91 | { 92 | from_id: '510501416', 93 | from_name: 'pjotado', 94 | to_id: '66727133', 95 | to_name: 'WoxxyTheFool', 96 | followed_at: '2020-12-06T23:08:00Z', 97 | }, 98 | { 99 | from_id: '95867229', 100 | from_name: 'xelgand', 101 | to_id: '66727133', 102 | to_name: 'WoxxyTheFool', 103 | followed_at: '2020-12-06T22:46:42Z', 104 | }, 105 | { 106 | from_id: '78051796', 107 | from_name: 'bk201tf', 108 | to_id: '66727133', 109 | to_name: 'WoxxyTheFool', 110 | followed_at: '2020-12-06T22:26:58Z', 111 | }, 112 | { 113 | from_id: '254903537', 114 | from_name: 'steven_rafferty', 115 | to_id: '66727133', 116 | to_name: 'WoxxyTheFool', 117 | followed_at: '2020-12-06T22:24:59Z', 118 | }, 119 | { 120 | from_id: '58719705', 121 | from_name: 'mezaroulis', 122 | to_id: '66727133', 123 | to_name: 'WoxxyTheFool', 124 | followed_at: '2020-12-06T21:33:46Z', 125 | }, 126 | { 127 | from_id: '42070773', 128 | from_name: 'officialmadkun', 129 | to_id: '66727133', 130 | to_name: 'WoxxyTheFool', 131 | followed_at: '2020-12-06T21:22:50Z', 132 | }, 133 | { 134 | from_id: '41642269', 135 | from_name: 'steenuil', 136 | to_id: '66727133', 137 | to_name: 'WoxxyTheFool', 138 | followed_at: '2020-12-06T21:17:09Z', 139 | }, 140 | { 141 | from_id: '479293720', 142 | from_name: 'fancyyellow', 143 | to_id: '66727133', 144 | to_name: 'WoxxyTheFool', 145 | followed_at: '2020-12-06T21:15:27Z', 146 | }, 147 | ], 148 | pagination: { cursor: 'eowpoioegrigrjepieorgjeori' }, 149 | }, 150 | }; 151 | 152 | export { followers }; 153 | -------------------------------------------------------------------------------- /.storybook/mock/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from 'fetch-mock'; 2 | export { followers } from './followers'; 3 | export { subscriber } from './subscriber'; 4 | export { legacySubscriber } from './legacySubscriber'; 5 | export { users } from './users'; 6 | -------------------------------------------------------------------------------- /.storybook/mock/legacySubscriber.tsx: -------------------------------------------------------------------------------- 1 | import { TwitchLegacyChannelSubscriptionsResponse } from '../../src/hooks'; 2 | 3 | const legacySubscriber: Record< 4 | string, 5 | TwitchLegacyChannelSubscriptionsResponse 6 | > = { 7 | me: { 8 | _total: 6, 9 | subscriptions: [ 10 | { 11 | created_at: '2021-01-02T21:55:04Z', 12 | _id: '53054d5c37f0fc559947c7c9c141817959e1d5df', 13 | sub_plan: '1000', 14 | sub_plan_name: "Woxxy's Egg Fund", 15 | is_gift: false, 16 | user: { 17 | display_name: 'Tizianoreica', 18 | type: 'user', 19 | bio: 20 | "Antonio. Italian. Live in Malmo. Computer engineer. Rubik's Cube addicted. Gym rat. In fruit and vegetables I trust", 21 | created_at: '2015-03-25T18:05:11Z', 22 | updated_at: '2020-12-31T11:28:17Z', 23 | name: 'tizianoreica', 24 | _id: '86313806', 25 | logo: 26 | 'https://static-cdn.jtvnw.net/jtv_user_pictures/26e9d4d3-0882-48d5-b185-9b5a700cb0bd-profile_image-300x300.jpg', 27 | }, 28 | sender: null, 29 | }, 30 | ], 31 | }, 32 | }; 33 | 34 | export { legacySubscriber }; 35 | -------------------------------------------------------------------------------- /.storybook/mock/subscriber.tsx: -------------------------------------------------------------------------------- 1 | import { TwitchBroadcasterSubscriptionsResponse } from '../../src/hooks'; 2 | 3 | const subscriber: Record = { 4 | me: { 5 | data: [ 6 | { 7 | broadcaster_id: '66727133', 8 | broadcaster_name: 'WoxxyTheFool', 9 | gifter_id: '', 10 | gifter_name: '', 11 | is_gift: false, 12 | plan_name: 'Channel Subscription (woxxythefool)', 13 | tier: '1000', 14 | user_id: '86151642', 15 | user_name: 'siestanyan', 16 | }, 17 | { 18 | broadcaster_id: '66727133', 19 | broadcaster_name: 'WoxxyTheFool', 20 | gifter_id: '', 21 | gifter_name: '', 22 | is_gift: false, 23 | plan_name: 'Channel Subscription (woxxythefool)', 24 | tier: '1000', 25 | user_id: '38390176', 26 | user_name: 'Tristan97122', 27 | }, 28 | { 29 | broadcaster_id: '66727133', 30 | broadcaster_name: 'WoxxyTheFool', 31 | gifter_id: '', 32 | gifter_name: '', 33 | is_gift: false, 34 | plan_name: 'Channel Subscription (woxxythefool)', 35 | tier: '1000', 36 | user_id: '172466407', 37 | user_name: 'YukiseKimi', 38 | }, 39 | { 40 | broadcaster_id: '66727133', 41 | broadcaster_name: 'WoxxyTheFool', 42 | gifter_id: '', 43 | gifter_name: '', 44 | is_gift: false, 45 | plan_name: 'Channel Subscription (woxxythefool)', 46 | tier: '1000', 47 | user_id: '144429147', 48 | user_name: 'mayhemydg', 49 | }, 50 | { 51 | broadcaster_id: '66727133', 52 | broadcaster_name: 'WoxxyTheFool', 53 | gifter_id: '', 54 | gifter_name: '', 55 | is_gift: false, 56 | plan_name: 'Channel Subscription (woxxythefool)', 57 | tier: '1000', 58 | user_id: '25938625', 59 | user_name: 'zohead', 60 | }, 61 | { 62 | broadcaster_id: '66727133', 63 | broadcaster_name: 'WoxxyTheFool', 64 | gifter_id: '', 65 | gifter_name: '', 66 | is_gift: false, 67 | plan_name: 'Channel Subscription (woxxythefool): $24.99 Sub', 68 | tier: '3000', 69 | user_id: '66727133', 70 | user_name: 'WoxxyTheFool', 71 | }, 72 | ], 73 | pagination: { cursor: 'iewjopewkfopewfopewpofkwpkfpdsgiojerwpfg' }, 74 | }, 75 | }; 76 | 77 | export { subscriber }; 78 | -------------------------------------------------------------------------------- /.storybook/mock/users.tsx: -------------------------------------------------------------------------------- 1 | import { TwitchUsersResponse } from '../../src/hooks'; 2 | 3 | const users: Record = { 4 | me: { 5 | data: [ 6 | { 7 | broadcaster_type: 'affiliate', 8 | created_at: '2014-07-19T18:25:51.044873Z', 9 | description: 10 | 'I\'m a Software Developer, Manager, Videogamer, Anime Watcher and an absolute Memer. Reviews: "This is a very high quality stream for having just 5 viewers" "You look younger every time I see you" "Can you make your oven RGB too? (yes)"', 11 | display_name: 'WoxxyTheFool', 12 | email: 'woxxysamazingmail@gmail.com', 13 | id: '66727133', 14 | login: 'woxxythefool', 15 | offline_image_url: 16 | 'https://static-cdn.jtvnw.net/jtv_user_pictures/531ddd2c-2a00-4e31-9820-20649a8f9488-channel_offline_image-1920x1080.jpeg', 17 | profile_image_url: 18 | 'https://static-cdn.jtvnw.net/jtv_user_pictures/7675e11d-880d-4c77-bfe2-b38a93d591bf-profile_image-300x300.png', 19 | type: '', 20 | view_count: 452, 21 | }, 22 | ], 23 | }, 24 | }; 25 | 26 | export { users }; 27 | -------------------------------------------------------------------------------- /.storybook/preview.tsx: -------------------------------------------------------------------------------- 1 | import { TwitchContext } from '../src/components/TwitchContext'; 2 | import { Story } from '@storybook/react/types-6-0'; 3 | import mock, { followers, legacySubscriber, subscriber, users } from './mock'; 4 | import { TWITCH_API_ENDPOINT } from '../src/constants'; 5 | import { TWITCH_API_LEGACY_ENDPOINT } from '../src/constants'; 6 | 7 | export const parameters = { 8 | actions: { argTypesRegex: '^on[A-Z].*' }, 9 | }; 10 | 11 | export const decorators = [ 12 | (Story: Story) => { 13 | mock.restore(); 14 | mock.mock(`${TWITCH_API_ENDPOINT}users`, users.me); 15 | mock.mock(`begin:${TWITCH_API_ENDPOINT}users/follows`, followers.me); 16 | mock.mock(`begin:${TWITCH_API_ENDPOINT}subscriptions`, subscriber.me); 17 | mock.mock( 18 | `begin:${TWITCH_API_LEGACY_ENDPOINT}channels/${users.me.data[0].id}/subscriptions`, 19 | legacySubscriber.me 20 | ); 21 | 22 | return ( 23 | 29 | 30 | 31 | ); 32 | }, 33 | ]; 34 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.formatOnSave": true, 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 woxxy on GitHub, @woxxy on Twitter, WoxxyTheFool on Twitch 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Twitch Components 2 | 3 | React Twitch Components is a library of React components built with the [Atomic Design methodology](https://bradfrost.com/blog/post/atomic-web-design/), meant to be used as an overlay for your streams. 4 | 5 | The components automatically load the information from your Twitch account and keep up to date. They are easy to style, extend, and automate with. 6 | 7 | I am [@WoxxyTheFool on Twitch](https://twitch.tv/woxxythefool) and I stream coding, including coding this project. 8 | 9 | # Quick Example 10 | 11 | ```tsx 12 | const App = () => { 13 | return ( 14 | 15 | has followers. 16 | 17 | ); 18 | }; 19 | 20 | export default App; 21 | ``` 22 | 23 | # Goals 24 | 25 | - Create the building blocks to display static and dynamic Twitch information in a stream 26 | - Provide a set of hooks and providers that simplify the fetching and updating of information 27 | - Allow for intricate automation in order to bring new experiences to the viewers 28 | 29 | ## Usage 30 | 31 | - Prepare your React env (16.8+), in example with Create React App 32 | - `npm install react-twitch-components` 33 | - Wrap your components with `` 34 | - Use the components from this library within the provider. 35 | 36 | To see the available components, look into `src/components` or look into the styleguide. 37 | 38 | ## Running the styleguide 39 | 40 | `npm run storybook` and then go to localhost:6006 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-twitch-components", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@babel/runtime": "^7.12.5", 7 | "@emotion/css": "^11.0.0", 8 | "@emotion/react": "^11.1.2", 9 | "@emotion/styled": "^11.0.0", 10 | "swr": "^0.3.9", 11 | "webfontloader": "^1.6.28" 12 | }, 13 | "devDependencies": { 14 | "@babel/core": "^7.12.9", 15 | "@babel/plugin-transform-runtime": "^7.12.1", 16 | "@babel/preset-env": "^7.12.7", 17 | "@babel/preset-react": "^7.12.7", 18 | "@babel/preset-typescript": "^7.12.7", 19 | "@emotion/babel-plugin": "^11.1.2", 20 | "@fortawesome/fontawesome-svg-core": "^1.2.32", 21 | "@fortawesome/free-brands-svg-icons": "^5.15.1", 22 | "@fortawesome/free-regular-svg-icons": "^5.15.1", 23 | "@fortawesome/free-solid-svg-icons": "^5.15.1", 24 | "@fortawesome/react-fontawesome": "^0.1.13", 25 | "@rollup/plugin-babel": "^5.2.2", 26 | "@rollup/plugin-node-resolve": "^11.0.0", 27 | "@storybook/react": "^6.1.10", 28 | "@types/react": "^17.0.0", 29 | "@types/react-dom": "^17.0.0", 30 | "@types/webfontloader": "^1.6.32", 31 | "@typescript-eslint/eslint-plugin": "^4.9.1", 32 | "@typescript-eslint/parser": "^4.9.1", 33 | "eslint": "^7.15.0", 34 | "eslint-plugin-react": "^7.21.5", 35 | "eslint-plugin-react-hooks": "^4.2.0", 36 | "fetch-mock": "^9.11.0", 37 | "prettier": "2.2.1", 38 | "react": "17.0.1", 39 | "react-dom": "17.0.1", 40 | "rollup": "^2.34.2", 41 | "typescript": "~4.1.2" 42 | }, 43 | "peerDependencies": { 44 | "react": "^16.14 || ^17.0", 45 | "react-dom": "^16.14 || ^17.0" 46 | }, 47 | "scripts": { 48 | "build": "rollup --config && tsc", 49 | "start": "start-storybook -p 6006", 50 | "build-storybook": "build-storybook", 51 | "prettier": "prettier --write .", 52 | "prettier:ci": "prettier --check .", 53 | "eslint": "eslint --ext ts,tsx --max-warnings 0 -f codeframe --cache --color src", 54 | "typecheck": "tsc -p tsconfig.all.json" 55 | }, 56 | "browserslist": [ 57 | "last 2 chrome version" 58 | ], 59 | "files": [ 60 | "lib" 61 | ], 62 | "module": "./lib/index.js", 63 | "types": "./lib/index.d.ts" 64 | } 65 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { isAbsolute } from 'path'; 2 | import { babel } from '@rollup/plugin-babel'; 3 | import nodeResolve from '@rollup/plugin-node-resolve'; 4 | 5 | const extensions = ['.ts', '.tsx']; 6 | 7 | export default { 8 | input: 'src/index.tsx', 9 | output: { 10 | file: 'lib/index.js', 11 | format: 'es', 12 | preferConst: true, 13 | sourcemap: true, 14 | }, 15 | plugins: [ 16 | babel({ 17 | babelHelpers: 'runtime', 18 | extensions, 19 | }), 20 | nodeResolve({ extensions }), 21 | ], 22 | external: id => !id.startsWith('.') && !isAbsolute(id), 23 | }; 24 | -------------------------------------------------------------------------------- /src/api/helpers.tsx: -------------------------------------------------------------------------------- 1 | import { TWITCH_CLIENT_ID } from '../constants'; 2 | 3 | const oauthParams = new URLSearchParams({ 4 | client_id: TWITCH_CLIENT_ID, 5 | redirect_uri: 'http://localhost:3000', 6 | response_type: 'token', 7 | scope: ['user:read:email', 'channel:read:subscriptions'].join(' '), 8 | }); 9 | 10 | const oauthUrl = `https://id.twitch.tv/oauth2/authorize?${oauthParams}`; 11 | 12 | export const redirectForToken = (): void => { 13 | window.location.replace(oauthUrl); 14 | }; 15 | -------------------------------------------------------------------------------- /src/components/TwitchContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react'; 2 | 3 | // TODO: what else should be stored in the context? 4 | interface TwitchContextValue { 5 | accessToken: string; 6 | clientId: string; 7 | } 8 | 9 | export const TwitchContext = createContext( 10 | undefined 11 | ); 12 | 13 | export const useTwitchContext = () => { 14 | const context = useContext(TwitchContext); 15 | 16 | if (context === undefined) { 17 | throw new Error('useTwitchContext must be used within a TwitchProvider'); 18 | } 19 | 20 | return context; 21 | }; 22 | -------------------------------------------------------------------------------- /src/components/TwitchProvider.tsx: -------------------------------------------------------------------------------- 1 | import { redirectForToken } from '../api/helpers'; 2 | import { TwitchContext } from './TwitchContext'; 3 | import { TWITCH_CLIENT_ID } from '../constants'; 4 | 5 | export const TwitchProvider = ({ children }: { children: React.ReactNode }) => { 6 | const hashParams = new URLSearchParams(document.location.hash.substr(1)); 7 | const accessToken = hashParams.get('access_token'); 8 | 9 | if (!accessToken) { 10 | redirectForToken(); 11 | return null; 12 | } 13 | 14 | return ( 15 | 21 | {children} 22 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /src/components/atoms/FollowerCount.tsx: -------------------------------------------------------------------------------- 1 | import { useTwitchFollowers } from '../../hooks'; 2 | 3 | export const FollowerCount = () => { 4 | const { data } = useTwitchFollowers(); 5 | return <>{data?.total}; 6 | }; 7 | -------------------------------------------------------------------------------- /src/components/atoms/LatestFollower.tsx: -------------------------------------------------------------------------------- 1 | import { useTwitchFollowers } from '../../hooks'; 2 | 3 | interface LatestFollowerProps { 4 | indexFromLatest?: number; 5 | } 6 | 7 | export const LatestFollower = ({ 8 | indexFromLatest = 0, 9 | }: LatestFollowerProps) => { 10 | const { data } = useTwitchFollowers(); 11 | return <>{data?.data[indexFromLatest]?.from_name}; 12 | }; 13 | -------------------------------------------------------------------------------- /src/components/atoms/LatestSubscriber.tsx: -------------------------------------------------------------------------------- 1 | import { useTwitchLegacySubscriptions } from '../../hooks/useTwitchLegacySubscriptions'; 2 | 3 | export const LatestSubscriber = () => { 4 | const { data } = useTwitchLegacySubscriptions(); 5 | return <>{data?.subscriptions[0]?.user.display_name}; 6 | }; 7 | -------------------------------------------------------------------------------- /src/components/atoms/SubscriberCount.tsx: -------------------------------------------------------------------------------- 1 | import { useTwitchLegacySubscriptions } from '../../hooks/useTwitchLegacySubscriptions'; 2 | 3 | export const SubscriberCount = () => { 4 | const { data } = useTwitchLegacySubscriptions(); 5 | return <>{data?._total}; 6 | }; 7 | -------------------------------------------------------------------------------- /src/components/atoms/UserDisplayName.tsx: -------------------------------------------------------------------------------- 1 | import { useTwitchCurrentUser } from '../../hooks'; 2 | 3 | export const UserDisplayName = () => { 4 | const currentUser = useTwitchCurrentUser(); 5 | return <>{currentUser?.display_name}; 6 | }; 7 | -------------------------------------------------------------------------------- /src/components/atoms/index.tsx: -------------------------------------------------------------------------------- 1 | export { FollowerCount } from './FollowerCount'; 2 | export { LatestFollower } from './LatestFollower'; 3 | export { LatestSubscriber } from './LatestSubscriber'; 4 | export { SubscriberCount } from './SubscriberCount'; 5 | export { UserDisplayName } from './UserDisplayName'; 6 | -------------------------------------------------------------------------------- /src/components/molecules/FollowerGoal.tsx: -------------------------------------------------------------------------------- 1 | import { useTwitchFollowers } from '../../hooks'; 2 | import { LoadingBarClassNames, LoadingBar } from './internal/LoadingBar'; 3 | 4 | interface FollowerGoalProps { 5 | goal: number; 6 | classNames?: LoadingBarClassNames; 7 | } 8 | 9 | export const FollowerGoal = ({ goal, classNames }: FollowerGoalProps) => { 10 | const { data } = useTwitchFollowers(); 11 | 12 | if (data == null) { 13 | return null; 14 | } 15 | 16 | return ; 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/molecules/SubscriberGoal.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingBarClassNames, LoadingBar } from './internal/LoadingBar'; 2 | import { useTwitchLegacySubscriptions } from '../../hooks/useTwitchLegacySubscriptions'; 3 | 4 | interface SubscriberGoalProps { 5 | goal: number; 6 | classNames?: LoadingBarClassNames; 7 | } 8 | 9 | export const SubscriberGoal = ({ goal, classNames }: SubscriberGoalProps) => { 10 | const { data } = useTwitchLegacySubscriptions(); 11 | 12 | if (data == null) { 13 | return null; 14 | } 15 | 16 | return ; 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/molecules/index.tsx: -------------------------------------------------------------------------------- 1 | export { FollowerGoal } from './FollowerGoal'; 2 | export { SubscriberGoal } from './SubscriberGoal'; 3 | -------------------------------------------------------------------------------- /src/components/molecules/internal/LoadingBar.tsx: -------------------------------------------------------------------------------- 1 | import { css, cx } from '@emotion/css'; 2 | 3 | const progressBarStyles = css` 4 | border: 1px solid #000000; 5 | display: flex; 6 | flex-grow: 1; 7 | height: 100%; 8 | `; 9 | 10 | const progressBarEmptyStyles = css` 11 | display: flex; 12 | justify-content: center; 13 | align-items: center; 14 | height: 100%; 15 | flex-grow: 1; 16 | flex-shrink: 1; 17 | min-width: 0; 18 | text-align: right; 19 | overflow: hidden; 20 | `; 21 | 22 | export interface LoadingBarClassNames { 23 | progressBar?: string; 24 | progressBarFilled?: string; 25 | progressBarEmpty?: string; 26 | } 27 | 28 | interface LoadingBarProps { 29 | count: number; 30 | goal: number; 31 | classNames?: LoadingBarClassNames; 32 | } 33 | 34 | export const LoadingBar = ({ count, goal, classNames }: LoadingBarProps) => { 35 | const progressBarFilledStyles = css` 36 | width: ${Math.min((count * 100) / goal, 100)}%; 37 | height: 100%; 38 | flex-shrink: 0; 39 | text-align: center; 40 | background: gray; 41 | color: white; 42 | display: flex; 43 | justify-content: center; 44 | align-items: center; 45 | overflow: hidden; 46 | `; 47 | 48 | return ( 49 |
50 |
53 | {count} 54 |
55 |
56 | {goal} 57 |
58 |
59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /src/components/templates/TwitchWrapper.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | 3 | interface TwitchWrapperProps { 4 | streamWidth: string; 5 | streamHeight: string; 6 | } 7 | 8 | export const TwitchWrapper = styled.div` 9 | position: relative; 10 | width: ${({ streamWidth }: TwitchWrapperProps) => streamWidth}; 11 | height: ${({ streamHeight }: TwitchWrapperProps) => streamHeight}; 12 | `; 13 | -------------------------------------------------------------------------------- /src/components/templates/games/Overwatch.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { ReactNode } from 'react'; 3 | import { FollowerGoal } from '../../molecules/FollowerGoal'; 4 | import { css } from '@emotion/css'; 5 | import { SubscriberGoal } from '../../molecules/SubscriberGoal'; 6 | import WebFont from 'webfontloader'; 7 | 8 | WebFont.load({ 9 | google: { 10 | families: ['Teko:600'], 11 | }, 12 | }); 13 | 14 | export const UltimateWrapperTemplate = styled.div` 15 | width: 600px; 16 | height: 200px; 17 | position: absolute; 18 | bottom: 50px; 19 | left: 50%; 20 | margin-left: -300px; 21 | display: flex; 22 | `; 23 | 24 | export interface UltimateSideProps { 25 | align?: 'right' | 'left'; 26 | margin?: string; 27 | upper?: boolean; 28 | } 29 | 30 | export const UltimateSideTemplate = styled.div` 31 | flex: 1; 32 | height: 100%; 33 | transform: skew( 34 | ${({ align = 'left' }: UltimateSideProps) => 35 | align === 'left' ? '10deg, -15deg' : '-10deg, 15deg'} 36 | ) 37 | rotate( 38 | ${({ align = 'left', upper = false }: UltimateSideProps) => 39 | !upper 40 | ? align === 'left' 41 | ? '11.5' 42 | : '-11.5' 43 | : align === 'left' 44 | ? '12.5' 45 | : '-12.5'}deg 46 | ); 47 | text-align: ${({ align = 'left' }: UltimateSideProps) => align}; 48 | font-family: 'Teko', sans-serif; 49 | ${({ upper = false }: UltimateSideProps) => 50 | upper && `position: absolute; bottom: 115%; height: auto; width: 40%`}; 51 | ${({ upper = false, align = 'left' }: UltimateSideProps) => 52 | upper && (align === 'right' ? `right: -60px;` : `left: -60px;`)}; 53 | `; 54 | 55 | export const UltimateCenterTemplate = styled.div` 56 | width: 162px; 57 | height: 100%; 58 | flex-shrink: 0; 59 | `; 60 | 61 | export const UltimateSideTitle = styled.div` 62 | display: flex; 63 | width: 100%; 64 | color: rgba(255, 201, 88, 0.8); 65 | border-bottom: 2px solid rgba(240, 240, 240, 0.18); 66 | ${({ align = 'left', margin = '0px' }: UltimateSideProps) => 67 | `margin-${align === 'left' ? 'right' : 'left'}: ${margin}`}; 68 | font-size: 23px; 69 | margin-bottom: 3px; 70 | -webkit-text-stroke: 1px rgba(0, 0, 0, 0.4); 71 | 72 | transform: skew( 73 | ${({ align = 'left' }: UltimateSideProps) => 74 | align === 'left' ? '-5deg, -0deg' : '5deg, 0deg'} 75 | ); 76 | 77 | & path { 78 | stroke: rgba(0, 0, 0, 0.5); 79 | stroke-width: 10; 80 | } 81 | `; 82 | 83 | export const UltimateSideIcon = styled.div` 84 | flex-grow: 1; 85 | padding: 0 8px; 86 | text-align: ${({ align = 'left' }: UltimateSideProps) => 87 | align === 'left' ? 'right' : 'left'}; 88 | `; 89 | 90 | export const UltimateSideValue = styled.div` 91 | display: inline-block; 92 | margin: 5px 0 25px; 93 | text-align: ${({ align = 'left' }: UltimateSideProps) => align}; 94 | background-color: rgba(255, 255, 255, 0.55); 95 | color: rgba(0, 0, 0, 0.65); 96 | padding: 6px 6px 3px; 97 | border-radius: 7px; 98 | font-size: 23px; 99 | transform: skew( 100 | ${({ align = 'left' }: UltimateSideProps) => 101 | align === 'left' ? '-5deg, -0deg' : '5deg, 0deg'} 102 | ); 103 | `; 104 | 105 | export const UltimateLoadingBarWrapper = styled.div` 106 | height: 30px; 107 | `; 108 | 109 | const progressBarClassNames = { 110 | progressBarFilled: css` 111 | background: rgba(255, 201, 88, 0.8); 112 | -webkit-text-stroke: 1px rgba(0, 0, 0, 0.2); 113 | font-size: 20px; 114 | `, 115 | progressBarEmpty: css` 116 | background: rgba(255, 255, 255, 0.55); 117 | color: rgba(0, 0, 0, 0.65); 118 | `, 119 | progressBar: css` 120 | border-color: rgba(240, 240, 240, 0.18); 121 | border-radius: 4px; 122 | overflow: hidden; 123 | `, 124 | }; 125 | 126 | export interface OverwatchUltimateElements { 127 | title: ReactNode; 128 | value?: ReactNode; 129 | rawValue?: ReactNode; 130 | icon?: ReactNode; 131 | } 132 | 133 | interface UltimateDetailProps { 134 | elements: OverwatchUltimateElements; 135 | align?: 'right' | 'left'; 136 | } 137 | 138 | export const UltimateDetail = ({ 139 | elements, 140 | align = 'left', 141 | }: UltimateDetailProps) => { 142 | return ( 143 | <> 144 | 145 | {align === 'right' && ( 146 | {elements.icon} 147 | )} 148 | {elements.title} 149 | {align === 'left' && ( 150 | {elements.icon} 151 | )} 152 | 153 | {elements.value && ( 154 | {elements.value} 155 | )} 156 | {elements.rawValue} 157 | 158 | ); 159 | }; 160 | 161 | interface OverwatchUltimateProps { 162 | upperLeft?: OverwatchUltimateElements[]; 163 | upperRight?: OverwatchUltimateElements[]; 164 | left?: OverwatchUltimateElements[]; 165 | right?: OverwatchUltimateElements[]; 166 | } 167 | 168 | export const OverwatchUltimate = ({ 169 | upperLeft, 170 | upperRight, 171 | left, 172 | right, 173 | }: OverwatchUltimateProps) => { 174 | return ( 175 | <> 176 | 177 | 178 | {upperLeft && 179 | upperLeft.map((elements, index) => ( 180 | 181 | ))} 182 | 183 | 184 | {upperRight && 185 | upperRight.map((elements, index) => ( 186 | 187 | ))} 188 | 189 | 190 | {left && 191 | left.map((elements, index) => ( 192 | 193 | ))} 194 | 195 | 196 | 197 | {right && 198 | right.map((elements, index) => ( 199 | 200 | ))} 201 | 202 | 203 | 204 | ); 205 | }; 206 | 207 | export const OverwatchUltimateFollowersGoal = ( 208 | props: React.ComponentProps 209 | ) => { 210 | return ( 211 | 212 | 213 | 214 | ); 215 | }; 216 | 217 | export const OverwatchUltimateSubscriberGoal = ( 218 | props: React.ComponentProps 219 | ) => { 220 | return ( 221 | 222 | 223 | 224 | ); 225 | }; 226 | 227 | const CameraWrapper = styled.div` 228 | position: absolute; 229 | top: 100px; 230 | left: 50px; 231 | `; 232 | 233 | const CameraUsername = styled.div` 234 | position: absolute; 235 | bottom: 10px; 236 | left: 20px; 237 | font-family: 'Teko', sans-serif; 238 | color: rgba(255, 201, 88, 0.8); 239 | font-size: 28px; 240 | -webkit-text-stroke: 1px rgba(0, 0, 0, 0.4); 241 | `; 242 | 243 | const CameraFrame = styled.div` 244 | border: 6px solid rgba(255, 201, 88, 0.8); 245 | width: 440px; 246 | height: 310px; 247 | border-radius: 7px; 248 | `; 249 | 250 | export const OverwatchCamera = ({ children }: { children: ReactNode }) => { 251 | return ( 252 | 253 | {children} 254 | 255 | 256 | ); 257 | }; 258 | -------------------------------------------------------------------------------- /src/constants.tsx: -------------------------------------------------------------------------------- 1 | export const TWITCH_API_ENDPOINT = 'https://api.twitch.tv/helix/'; 2 | export const TWITCH_API_LEGACY_ENDPOINT = 'https://api.twitch.tv/kraken/'; 3 | export const TWITCH_CLIENT_ID = '9031tpad4quocef3dgtu0bbnbupmdr'; 4 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useTwitchCurrentUser'; 2 | export * from './useTwitchFollowers'; 3 | export * from './useTwitchSubscriptions'; 4 | export * from './useTwitchLegacySubscriptions'; 5 | export * from './useTwitchUsers'; 6 | -------------------------------------------------------------------------------- /src/hooks/useTwitchApi.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr'; 2 | import { useTwitchContext } from '../components/TwitchContext'; 3 | import { TWITCH_API_ENDPOINT, TWITCH_API_LEGACY_ENDPOINT } from '../constants'; 4 | 5 | const requestInit: RequestInit = { 6 | method: 'GET', 7 | mode: 'cors', 8 | cache: 'no-store', 9 | }; 10 | 11 | const twitchApiFetcher = async ( 12 | url: string, 13 | headers: HeadersInit 14 | ): Promise => { 15 | const response = await fetch(url, { ...requestInit, headers }); 16 | return response.json(); 17 | }; 18 | 19 | export const useTwitchApi = (path: string | null) => { 20 | const { accessToken, clientId } = useTwitchContext(); 21 | const url = path === null ? null : `${TWITCH_API_ENDPOINT}${path}`; 22 | const headers: HeadersInit = { 23 | 'client-id': clientId, 24 | Authorization: `Bearer ${accessToken}`, 25 | }; 26 | return useSWR(url, url => twitchApiFetcher(url, headers), { 27 | refreshInterval: 10000, 28 | }); 29 | }; 30 | 31 | export const useLegacyTwitchApi = (path: string | null) => { 32 | const { accessToken, clientId } = useTwitchContext(); 33 | const url = path === null ? null : `${TWITCH_API_LEGACY_ENDPOINT}${path}`; 34 | const headers: HeadersInit = { 35 | 'client-id': clientId, 36 | Authorization: `OAuth ${accessToken}`, 37 | Accept: 'application/vnd.twitchtv.v5+json', 38 | }; 39 | return useSWR(url, url => twitchApiFetcher(url, headers), { 40 | refreshInterval: 10000, 41 | }); 42 | }; 43 | -------------------------------------------------------------------------------- /src/hooks/useTwitchCurrentUser.ts: -------------------------------------------------------------------------------- 1 | import { useTwitchUsers } from './useTwitchUsers'; 2 | 3 | export const useTwitchCurrentUser = () => { 4 | const { data } = useTwitchUsers(); 5 | return data?.data[0]; 6 | }; 7 | -------------------------------------------------------------------------------- /src/hooks/useTwitchFollowers.ts: -------------------------------------------------------------------------------- 1 | import { useTwitchCurrentUser } from './useTwitchCurrentUser'; 2 | import { useTwitchApi } from './useTwitchApi'; 3 | import { TwitchPagination } from '../interfaces'; 4 | 5 | interface TwitchUsersFollows { 6 | from_id: string; 7 | from_name: string; 8 | to_id: string; 9 | to_name: string; 10 | followed_at: string; 11 | } 12 | 13 | export interface TwitchUsersFollowsResponse { 14 | total: number; 15 | data: readonly TwitchUsersFollows[]; 16 | pagination: TwitchPagination; 17 | } 18 | 19 | /** 20 | * https://dev.twitch.tv/docs/api/reference#get-users-follows 21 | */ 22 | export const useTwitchFollowers = () => { 23 | const currentUser = useTwitchCurrentUser(); 24 | let path = null; 25 | if (currentUser != null) { 26 | const params = new URLSearchParams({ to_id: currentUser.id }); 27 | path = `users/follows?${params}`; 28 | } 29 | return useTwitchApi(path); 30 | }; 31 | -------------------------------------------------------------------------------- /src/hooks/useTwitchLegacySubscriptions.ts: -------------------------------------------------------------------------------- 1 | import { useTwitchCurrentUser } from './useTwitchCurrentUser'; 2 | import { useLegacyTwitchApi } from './useTwitchApi'; 3 | 4 | interface TwitchLegacyUser { 5 | _id: string; 6 | bio: string; 7 | created_at: string; 8 | display_name: string; 9 | logo: string; 10 | name: string; 11 | type: string; 12 | updated_at: string; 13 | } 14 | 15 | interface TwitchLegacyChannelSubscriptions { 16 | _id: string; 17 | created_at: string; 18 | sub_plan: '1000' | '2000' | '3000'; 19 | sub_plan_name: string; 20 | is_gift: boolean; // not declared in docs 21 | user: TwitchLegacyUser; 22 | sender: null; // not declared in docs 23 | } 24 | 25 | export interface TwitchLegacyChannelSubscriptionsResponse { 26 | _total: number; 27 | subscriptions: readonly TwitchLegacyChannelSubscriptions[]; 28 | } 29 | 30 | /** 31 | * Legacy API, this is currently offering totals and ordering that the latest API isn't 32 | * https://dev.twitch.tv/docs/v5/reference/channels#get-channel-subscribers 33 | */ 34 | export const useTwitchLegacySubscriptions = () => { 35 | const currentUser = useTwitchCurrentUser(); 36 | let path = null; 37 | if (currentUser != null) { 38 | path = `channels/${currentUser.id}/subscriptions?direction=desc&limit=1`; 39 | } 40 | return useLegacyTwitchApi(path); 41 | }; 42 | -------------------------------------------------------------------------------- /src/hooks/useTwitchSubscriptions.ts: -------------------------------------------------------------------------------- 1 | import { useTwitchCurrentUser } from './useTwitchCurrentUser'; 2 | import { useTwitchApi } from './useTwitchApi'; 3 | import { TwitchPagination } from '../interfaces'; 4 | 5 | interface TwitchBroadcasterSubscriber { 6 | broadcaster_id: string; 7 | broadcaster_name: string; 8 | is_gift: boolean; 9 | gifter_name: string; // not declared in docs 10 | gifter_id: string; // not declared in docs 11 | tier: '1000' | '2000' | '3000'; 12 | plan_name: string; 13 | user_id: string; 14 | user_name: string; 15 | } 16 | 17 | export interface TwitchBroadcasterSubscriptionsResponse { 18 | data: readonly TwitchBroadcasterSubscriber[]; 19 | pagination: TwitchPagination; 20 | } 21 | 22 | /** 23 | * https://dev.twitch.tv/docs/api/reference#get-broadcaster-subscriptions 24 | */ 25 | export const useTwitchSubscriptions = () => { 26 | const currentUser = useTwitchCurrentUser(); 27 | let path = null; 28 | if (currentUser != null) { 29 | const params = new URLSearchParams({ broadcaster_id: currentUser.id }); 30 | path = `subscriptions?${params}`; 31 | } 32 | return useTwitchApi(path); 33 | }; 34 | -------------------------------------------------------------------------------- /src/hooks/useTwitchUsers.ts: -------------------------------------------------------------------------------- 1 | import { useTwitchApi } from './useTwitchApi'; 2 | 3 | export interface TwitchUser { 4 | id: string; 5 | login: string; 6 | display_name: string; 7 | type: 'staff' | 'admin' | 'global_mod' | ''; 8 | broadcaster_type: '' | 'affiliate' | 'partner'; 9 | description: string; 10 | profile_image_url: string; 11 | offline_image_url: string; 12 | view_count: number; 13 | email: string; 14 | created_at: string; 15 | } 16 | 17 | export interface TwitchUsersResponse { 18 | data: readonly TwitchUser[]; 19 | } 20 | 21 | /** 22 | * https://dev.twitch.tv/docs/api/reference#get-users 23 | */ 24 | export const useTwitchUsers = () => { 25 | return useTwitchApi('users'); 26 | }; 27 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './components/atoms'; 2 | export * from './components/molecules'; 3 | export { TwitchWrapper } from './components/templates/TwitchWrapper'; 4 | export { TwitchProvider } from './components/TwitchProvider'; 5 | export { 6 | OverwatchCamera, 7 | OverwatchUltimate, 8 | OverwatchUltimateFollowersGoal, 9 | OverwatchUltimateSubscriberGoal, 10 | } from './components/templates/games/Overwatch'; 11 | -------------------------------------------------------------------------------- /src/interfaces.tsx: -------------------------------------------------------------------------------- 1 | export interface TwitchPagination { 2 | cursor: string; 3 | } 4 | -------------------------------------------------------------------------------- /src/stories/Introduction.stories.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/addon-docs/blocks'; 2 | import Code from './assets/code-brackets.svg'; 3 | import Colors from './assets/colors.svg'; 4 | import Comments from './assets/comments.svg'; 5 | import Direction from './assets/direction.svg'; 6 | import Flow from './assets/flow.svg'; 7 | import Plugin from './assets/plugin.svg'; 8 | import Repo from './assets/repo.svg'; 9 | import StackAlt from './assets/stackalt.svg'; 10 | 11 | 12 | 13 | 116 | 117 | # Welcome to Storybook 118 | 119 | Storybook helps you build UI components in isolation from your app's business logic, data, and context. 120 | That makes it easy to develop hard-to-reach states. Save these UI states as **stories** to revisit during development, testing, or QA. 121 | 122 | Browse example stories now by navigating to them in the sidebar. 123 | View their code in the `src/stories` directory to learn how they work. 124 | We recommend building UIs with a [**component-driven**](https://componentdriven.org) process starting with atomic components and ending with pages. 125 | 126 |
Configure
127 | 128 | 174 | 175 |
Learn
176 | 177 | 215 | 216 |
217 | TipEdit the Markdown in{' '} 218 | src/stories/Introduction.stories.mdx 219 |
220 | -------------------------------------------------------------------------------- /src/stories/MoleculeFollowerGoal.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps } from 'react'; 2 | // also exported from '@storybook/react' if you can deal with breaking changes in 6.1 3 | import { Story, Meta } from '@storybook/react/types-6-0'; 4 | import { FollowerGoal } from '../components/molecules'; 5 | 6 | export default { 7 | title: 'Molecule/FollowerGoal', 8 | component: FollowerGoal, 9 | argTypes: {}, 10 | } as Meta; 11 | 12 | const Template: Story> = args => { 13 | return ; 14 | }; 15 | 16 | export const Base = Template.bind({}); 17 | Base.args = { 18 | goal: 128, 19 | }; 20 | -------------------------------------------------------------------------------- /src/stories/MoleculeSubscriberGoal.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps } from 'react'; 2 | // also exported from '@storybook/react' if you can deal with breaking changes in 6.1 3 | import { Story, Meta } from '@storybook/react/types-6-0'; 4 | import { SubscriberGoal } from '../components/molecules'; 5 | 6 | export default { 7 | title: 'Molecule/SubcriberGoal', 8 | component: SubscriberGoal, 9 | argTypes: {}, 10 | } as Meta; 11 | 12 | const Template: Story> = args => { 13 | return ; 14 | }; 15 | 16 | export const Base = Template.bind({}); 17 | Base.args = { 18 | goal: 128, 19 | }; 20 | -------------------------------------------------------------------------------- /src/stories/TemplateGameOverwatch.stories.tsx: -------------------------------------------------------------------------------- 1 | // also exported from '@storybook/react' if you can deal with breaking changes in 6.1 2 | import { Story, Meta } from '@storybook/react/types-6-0'; 3 | import { TemplateGameOverwatchStory } from './TemplateGameOverwatch'; 4 | 5 | export default { 6 | title: 'template/Overwatch', 7 | component: TemplateGameOverwatchStory, 8 | argTypes: {}, 9 | } as Meta; 10 | 11 | const Template: Story = () => ; 12 | 13 | export const Base = Template.bind({}); 14 | Base.args = {}; 15 | -------------------------------------------------------------------------------- /src/stories/TemplateGameOverwatch.tsx: -------------------------------------------------------------------------------- 1 | import { TwitchProvider } from '../components/TwitchProvider'; 2 | import { TwitchWrapper } from '../components/templates/TwitchWrapper'; 3 | import { 4 | OverwatchCamera, 5 | OverwatchUltimate, 6 | OverwatchUltimateFollowersGoal, 7 | OverwatchUltimateSubscriberGoal, 8 | } from '../components/templates/games/Overwatch'; 9 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 10 | import { faTwitch, faTwitter } from '@fortawesome/free-brands-svg-icons'; 11 | import { 12 | faCrown, 13 | faTicketAlt, 14 | faUserFriends, 15 | faUserPlus, 16 | } from '@fortawesome/free-solid-svg-icons'; 17 | import { 18 | LatestFollower, 19 | LatestSubscriber, 20 | UserDisplayName, 21 | } from '../components/atoms'; 22 | 23 | export const TemplateGameOverwatchStory = () => { 24 | return ( 25 | 26 | 27 | 28 | 29 | 30 | , 35 | rawValue: , 36 | }, 37 | ]} 38 | upperRight={[ 39 | { 40 | title: 'Subscriber Goal', 41 | icon: , 42 | rawValue: , 43 | }, 44 | ]} 45 | left={[ 46 | { 47 | title: 'Twitch Username', 48 | icon: , 49 | value: , 50 | }, 51 | { 52 | title: 'Twitter Username', 53 | icon: , 54 | value: '@woxxy', 55 | }, 56 | ]} 57 | right={[ 58 | { 59 | title: 'Latest Follower', 60 | icon: , 61 | value: , 62 | }, 63 | { 64 | title: 'Latest Subscriber', 65 | icon: , 66 | value: , 67 | }, 68 | ]} 69 | /> 70 | 71 | 72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /src/stories/UserDisplayName.stories.tsx: -------------------------------------------------------------------------------- 1 | // also exported from '@storybook/react' if you can deal with breaking changes in 6.1 2 | import { Story, Meta } from '@storybook/react/types-6-0'; 3 | import { UserDisplayName } from '../components/atoms'; 4 | 5 | export default { 6 | title: 'atoms/UserDisplayName', 7 | component: UserDisplayName, 8 | argTypes: {}, 9 | } as Meta; 10 | 11 | const Template: Story = () => { 12 | return ; 13 | }; 14 | 15 | export const Base = Template.bind({}); 16 | Base.args = {}; 17 | -------------------------------------------------------------------------------- /tsconfig.all.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "emitDeclarationOnly": false, 5 | "noEmit": true 6 | }, 7 | "include": ["src/**/*"] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "declaration": true, 5 | "declarationDir": "lib", 6 | "emitDeclarationOnly": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "isolatedModules": true, 9 | "jsx": "preserve", 10 | "lib": ["esnext", "dom", "dom.iterable"], 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "noImplicitReturns": true, 14 | "noUnusedLocals": true, 15 | "pretty": true, 16 | "strict": true, 17 | "target": "esnext", 18 | "types": [] 19 | }, 20 | "files": ["src/index.tsx"] 21 | } 22 | --------------------------------------------------------------------------------