├── .circleci └── config.yml ├── .eslintrc ├── .gitignore ├── README.md ├── package.json ├── public ├── favicon.ico ├── index.html ├── loading.gif ├── logo.png └── manifest.json └── src ├── components ├── ErrorIndicator.js ├── LoadingIndicator.js ├── OAuthContainer.js ├── Root.js ├── home │ ├── bar │ │ ├── LoginContainer.js │ │ ├── UserContainer.js │ │ └── index.js │ ├── index.js │ ├── list │ │ ├── AuthenticatedContainer.js │ │ ├── GistList.js │ │ ├── MyGistListContainer.js │ │ ├── NotAuthenticated.js │ │ ├── PublicGistListContainer.js │ │ └── index.js │ └── main │ │ ├── EditGistContainer.js │ │ ├── GistEditor.js │ │ ├── GistMetadata.js │ │ ├── GistView.js │ │ ├── NewGistCotainer.js │ │ ├── TopContainer.js │ │ ├── ViewGistContainer.js │ │ ├── index.js │ │ └── me │ │ ├── LogoutContainer.js │ │ ├── UserContainer.js │ │ └── index.js └── slide │ └── index.js ├── index.css ├── index.js ├── index.test.js ├── infrastructure ├── DispatchUtil.js ├── GitHub.js ├── OAuthTokenService.js ├── PreferenceStorage.js ├── PromiseAction.js ├── PromiseReducer.js └── PromiseState.js ├── models ├── EditingGist.js ├── EditingGistFile.js ├── GistCriteria.js ├── OAuthState.js └── OAuthToken.js ├── repositories ├── OAuthStateRepository.js └── OAuthTokenRepository.js └── state ├── gists ├── actionCreators.js ├── actionTypes.js ├── reducers.js └── sagas.js ├── initialState.js ├── oauth ├── actionCreators.js ├── actionTypes.js ├── reducers.js └── sagas.js ├── reducers.js ├── sagas.js ├── store.js └── user ├── actionCreators.js ├── actionTypes.js ├── reducers.js └── sagas.js /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/node:6 6 | working_directory: ~/gistnote 7 | steps: 8 | - checkout 9 | - restore_cache: 10 | keys: 11 | - v1-dependencies-{{ checksum "package.json" }} 12 | - v1-dependencies- 13 | - run: yarn install 14 | - save_cache: 15 | paths: 16 | - node_modules 17 | key: v1-dependencies-{{ checksum "package.json" }} 18 | 19 | - run: yarn test 20 | - run: yarn run build 21 | 22 | - persist_to_workspace: 23 | root: . 24 | paths: 25 | - build 26 | 27 | deploy: 28 | docker: 29 | - image: circleci/node:6 30 | working_directory: ~/gistnote 31 | steps: 32 | - attach_workspace: 33 | at: . 34 | - deploy: 35 | name: GitHub Pages 36 | command: | 37 | cd build 38 | cp -av index.html 404.html 39 | git init . 40 | git config user.name circle 41 | git config user.email circle@example.com 42 | git add . 43 | git commit -m Release 44 | echo "$NETRC" > "$HOME/.netrc" 45 | git remote add origin "$GITHUB_ORIGIN" 46 | git push origin master -f 47 | 48 | workflows: 49 | version: 2 50 | build-and-deploy: 51 | jobs: 52 | - build 53 | - deploy: 54 | requires: 55 | - build 56 | filters: 57 | branches: 58 | only: /^release\/production$/ 59 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "react-app" 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | 23 | package-lock.json 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Gistnote [![CircleCI](https://circleci.com/gh/int128/gistnote.svg?style=shield)](https://circleci.com/gh/int128/gistnote) 2 | ======== 3 | 4 | Gistnote is a Gist client app based on HTML5 and JavaScript. 5 | 6 | It is deployed on GitHub Pages: https://gistnote.github.io 7 | 8 | 9 | ## Architecture 10 | 11 | The client app sends requests directly to GitHub API using CORS, except OAuth 2 authorization request because the client secret should not be exposed. 12 | 13 | * Client app 14 | * GitHub Pages 15 | * HTML5 16 | * ECMAScript 6 17 | * React 18 | * Redux Saga 19 | * React Router Redux 20 | * Immutable.js 21 | * React Syntax Highlighter 22 | * Remark React 23 | * Bootswatch 24 | * Server app 25 | * Google Cloud Functions (WIP) 26 | 27 | 28 | ## Build 29 | 30 | Run on localhost: 31 | 32 | ```sh 33 | npm start 34 | ``` 35 | 36 | Build the production: 37 | 38 | ```sh 39 | npm run build 40 | ``` 41 | 42 | 43 | ## Contributions 44 | 45 | This is an open source software licensed under the Apache License Version 2.0. 46 | Feel free to open an issue or pull request. 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gistnote", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "immutable": "^3.8.1", 7 | "query-string": "^5.0.0", 8 | "react": "^15.6.1", 9 | "react-dom": "^15.6.1", 10 | "react-redux": "^5.0.5", 11 | "react-router-dom": "^4.1.2", 12 | "react-router-redux": "^5.0.0-alpha.6", 13 | "react-syntax-highlighter": "^5.6.2", 14 | "redux": "^3.7.2", 15 | "redux-logger": "^3.0.6", 16 | "redux-saga": "^0.15.4", 17 | "remark": "^8.0.0", 18 | "remark-react": "^4.0.0" 19 | }, 20 | "scripts": { 21 | "start": "react-scripts start", 22 | "build": "react-scripts build", 23 | "test": "react-scripts test --env=jsdom", 24 | "eject": "react-scripts eject" 25 | }, 26 | "devDependencies": { 27 | "mock-local-storage": "^1.0.4", 28 | "react-scripts": "^1.0.10" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/int128/gistnote/5436d9a703211cf89a6037a5825d456712e0ade4/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Gistnote 11 | 12 | 13 |
14 | 15 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /public/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/int128/gistnote/5436d9a703211cf89a6037a5825d456712e0ade4/public/loading.gif -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/int128/gistnote/5436d9a703211cf89a6037a5825d456712e0ade4/public/logo.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Gistnote", 3 | "name": "Gistnote", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "256x256", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/components/ErrorIndicator.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default ({error}) => ( 4 |
5 | {error.message ? error.message : error.toString()} 6 |
7 | ) 8 | -------------------------------------------------------------------------------- /src/components/LoadingIndicator.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default () => ( 4 | Loading 5 | ) 6 | -------------------------------------------------------------------------------- /src/components/OAuthContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { bindActionCreators } from 'redux'; 4 | import { connect } from 'react-redux'; 5 | import { Link } from 'react-router-dom'; 6 | import queryString from 'query-string'; 7 | import PromiseState from '../infrastructure/PromiseState'; 8 | 9 | import { acquireSession } from '../state/oauth/actionCreators'; 10 | 11 | import LoadingIndicator from './LoadingIndicator'; 12 | import ErrorIndicator from './ErrorIndicator'; 13 | 14 | class OAuthContainer extends React.Component { 15 | static propTypes = { 16 | location: PropTypes.object.isRequired, 17 | session: PropTypes.instanceOf(PromiseState).isRequired, 18 | } 19 | 20 | componentDidMount() { 21 | const { code, state } = queryString.parse(this.props.location.search); 22 | if (code && state) { 23 | this.props.acquireSession(code, state); 24 | } 25 | } 26 | 27 | render() { 28 | return ( 29 |
30 | {this.props.session.mapIf({ 31 | invalid: () =>

Back

, 32 | loading: () => ( 33 |
34 | 35 |

Signing In...

36 |
37 | ), 38 | rejected: payload => ( 39 |
40 | 41 |

Back

42 |
43 | ), 44 | })} 45 |
46 | ); 47 | } 48 | } 49 | 50 | const mapStateToProps = state => ({ 51 | session: state.session, 52 | }); 53 | 54 | const mapDispatchToProps = dispatch => bindActionCreators({ 55 | acquireSession, 56 | }, dispatch) 57 | 58 | export default connect(mapStateToProps, mapDispatchToProps)(OAuthContainer); 59 | -------------------------------------------------------------------------------- /src/components/Root.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Route, Switch } from 'react-router-dom'; 3 | 4 | import Home from './home'; 5 | import Slide from './slide'; 6 | import OAuthContainer from './OAuthContainer'; 7 | 8 | export default () => ( 9 | 10 | 11 | 12 | 13 | 14 | ) 15 | -------------------------------------------------------------------------------- /src/components/home/bar/LoginContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { bindActionCreators } from 'redux'; 3 | import { connect } from 'react-redux'; 4 | import { preventDefaultEvent } from '../../../infrastructure/DispatchUtil'; 5 | 6 | import { login } from '../../../state/oauth/actionCreators'; 7 | 8 | class LoginContainer extends React.Component { 9 | render() { 10 | return ( 11 | 18 | ); 19 | } 20 | } 21 | 22 | const mapStateToProps = state => ({ 23 | }); 24 | 25 | const mapDispatchToProps = dispatch => bindActionCreators({ 26 | login, 27 | }, dispatch) 28 | 29 | export default connect(mapStateToProps, mapDispatchToProps)(LoginContainer); 30 | -------------------------------------------------------------------------------- /src/components/home/bar/UserContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { bindActionCreators } from 'redux'; 4 | import { connect } from 'react-redux'; 5 | import { Link } from 'react-router-dom'; 6 | import PromiseState from '../../../infrastructure/PromiseState'; 7 | 8 | import { readUserProfile } from '../../../state/user/actionCreators'; 9 | 10 | import LoadingIndicator from '../../LoadingIndicator'; 11 | 12 | class UserContainer extends React.Component { 13 | static propTypes = { 14 | userProfile: PropTypes.instanceOf(PromiseState).isRequired, 15 | } 16 | 17 | componentDidMount() { 18 | this.props.readUserProfile(); 19 | } 20 | 21 | render() { 22 | return this.props.userProfile.mapIf({ 23 | loading: () => , 24 | resolved: payload => , 25 | }); 26 | } 27 | } 28 | 29 | const User = ({userProfile}) => ( 30 | 38 | ); 39 | 40 | const mapStateToProps = state => ({ 41 | userProfile: state.userProfile, 42 | }); 43 | 44 | const mapDispatchToProps = dispatch => bindActionCreators({ 45 | readUserProfile, 46 | }, dispatch) 47 | 48 | export default connect(mapStateToProps, mapDispatchToProps)(UserContainer); 49 | -------------------------------------------------------------------------------- /src/components/home/bar/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { bindActionCreators } from 'redux'; 4 | import { connect } from 'react-redux'; 5 | import PromiseState from '../../../infrastructure/PromiseState'; 6 | 7 | import UserContainer from './UserContainer'; 8 | import LoginContainer from './LoginContainer'; 9 | 10 | class BarContainer extends React.Component { 11 | static propTypes = { 12 | session: PropTypes.instanceOf(PromiseState).isRequired, 13 | } 14 | 15 | render() { 16 | return this.props.session.mapIf({ 17 | invalid: () => , 18 | rejected: () => , 19 | resolved: () => , 20 | }); 21 | } 22 | } 23 | 24 | const mapStateToProps = state => ({ 25 | session: state.session, 26 | }); 27 | 28 | const mapDispatchToProps = dispatch => bindActionCreators({ 29 | }, dispatch) 30 | 31 | export default connect(mapStateToProps, mapDispatchToProps)(BarContainer); 32 | -------------------------------------------------------------------------------- /src/components/home/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | import ListContainer from './list'; 5 | import MainContainer from './main'; 6 | import BarContainer from './bar'; 7 | 8 | export default () => ( 9 |
10 |
11 |
12 | 19 | 20 |
21 |
22 |
23 | 24 |
25 | 26 |
27 |
28 |
29 | ) 30 | -------------------------------------------------------------------------------- /src/components/home/list/AuthenticatedContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { bindActionCreators } from 'redux'; 4 | import { connect } from 'react-redux'; 5 | import { preventDefaultEvent } from '../../../infrastructure/DispatchUtil'; 6 | 7 | import GistCriteria from '../../../models/GistCriteria'; 8 | 9 | import { changeGistCriteria } from '../../../state/gists/actionCreators'; 10 | 11 | import PublicGistListContainer from './PublicGistListContainer'; 12 | import MyGistListContainer from './MyGistListContainer'; 13 | 14 | class AuthenticatedContainer extends React.Component { 15 | static propTypes = { 16 | gistCriteria: PropTypes.instanceOf(GistCriteria).isRequired, 17 | } 18 | 19 | render() { 20 | const { gistCriteria, changeGistCriteria } = this.props; 21 | return ( 22 |
23 | 37 |
38 | {(() => { 39 | switch (gistCriteria.type) { 40 | case GistCriteria.types.PUBLIC: 41 | return ; 42 | case GistCriteria.types.MY: 43 | return ; 44 | default: 45 | return null; 46 | } 47 | })()} 48 |
49 |
50 | ); 51 | } 52 | } 53 | 54 | const activeIf = condition => condition ? 'active' : null; 55 | 56 | const mapStateToProps = state => ({ 57 | gistCriteria: state.gistCriteria, 58 | }); 59 | 60 | const mapDispatchToProps = dispatch => bindActionCreators({ 61 | changeGistCriteria, 62 | }, dispatch) 63 | 64 | export default connect(mapStateToProps, mapDispatchToProps)(AuthenticatedContainer); 65 | -------------------------------------------------------------------------------- /src/components/home/list/GistList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Link } from 'react-router-dom'; 4 | import { preventDefaultEvent } from '../../../infrastructure/DispatchUtil'; 5 | 6 | import LoadingIndicator from '../../LoadingIndicator'; 7 | 8 | const GistList = ({gists, activeGist, nextAction, nextActionInProgress}) => ( 9 |
10 | {gists.map(gist => ( 11 | 15 | ))} 16 | {nextActionInProgress ? ( 17 |
  • 18 | ) : ( 19 | 20 | more... 21 | 22 | )} 23 |
    24 | ) 25 | 26 | GistList.propTypes = { 27 | gists: PropTypes.array.isRequired, 28 | activeGist: PropTypes.object, 29 | nextActionInProgress: PropTypes.bool.isRequired, 30 | } 31 | 32 | export default GistList 33 | 34 | const GistListItem = ({gist, active}) => ( 35 | 36 |
    {gist.description || gist.id}
    37 |
    {gist.updated_at}
    38 |
    39 | {gist.public ? null : ( 40 | 41 | )} 42 |
    43 |
    44 | 45 | ) 46 | 47 | GistListItem.propTypes = { 48 | gist: PropTypes.object.isRequired, 49 | active: PropTypes.bool.isRequired, 50 | } 51 | -------------------------------------------------------------------------------- /src/components/home/list/MyGistListContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { bindActionCreators } from 'redux'; 4 | import { connect } from 'react-redux'; 5 | import { Link } from 'react-router-dom'; 6 | import PromiseState from '../../../infrastructure/PromiseState'; 7 | 8 | import GistCriteria from '../../../models/GistCriteria'; 9 | 10 | import { listGists, listNextGists } from '../../../state/gists/actionCreators'; 11 | 12 | import GistList from './GistList'; 13 | import LoadingIndicator from '../../LoadingIndicator'; 14 | import ErrorIndicator from '../../ErrorIndicator'; 15 | 16 | class MyGistListContainer extends React.Component { 17 | static propTypes = { 18 | gistList: PropTypes.instanceOf(PromiseState).isRequired, 19 | gistListPagenation: PropTypes.instanceOf(PromiseState).isRequired, 20 | gist: PropTypes.instanceOf(PromiseState).isRequired, 21 | } 22 | 23 | componentDidMount() { 24 | this.props.listGists(GistCriteria.MY); 25 | } 26 | 27 | render() { 28 | const { gistList, gistListPagenation, gist, editingGist, listNextGists } = this.props; 29 | return gistList.mapIf({ 30 | loading: () =>
  • , 31 | rejected: payload => , 32 | resolved: payload => 33 |
    34 | payload.isNew())}/> 35 | listNextGists(gistListPagenation.mapIfResolved())} 38 | nextActionInProgress={gistListPagenation.isLoading()}/> 39 |
    , 40 | }); 41 | } 42 | } 43 | 44 | const NewLink = ({active}) => ( 45 | 47 | New Gist 48 | 49 | ); 50 | 51 | const mapStateToProps = state => ({ 52 | gistList: state.gistList, 53 | gistListPagenation: state.gistListPagenation, 54 | gist: state.gist, 55 | editingGist: state.editingGist, 56 | }); 57 | 58 | const mapDispatchToProps = dispatch => bindActionCreators({ 59 | listGists, 60 | listNextGists, 61 | }, dispatch) 62 | 63 | export default connect(mapStateToProps, mapDispatchToProps)(MyGistListContainer); 64 | -------------------------------------------------------------------------------- /src/components/home/list/NotAuthenticated.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { preventDefaultEvent } from '../../../infrastructure/DispatchUtil'; 3 | 4 | import PublicGistListContainer from './PublicGistListContainer'; 5 | 6 | export default () => ( 7 |
    8 | 13 |
    14 | 15 |
    16 |
    17 | ) 18 | -------------------------------------------------------------------------------- /src/components/home/list/PublicGistListContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { bindActionCreators } from 'redux'; 4 | import { connect } from 'react-redux'; 5 | import PromiseState from '../../../infrastructure/PromiseState'; 6 | 7 | import GistCriteria from '../../../models/GistCriteria'; 8 | 9 | import { listGists, listNextGists } from '../../../state/gists/actionCreators'; 10 | 11 | import GistList from './GistList'; 12 | import LoadingIndicator from '../../LoadingIndicator'; 13 | import ErrorIndicator from '../../ErrorIndicator'; 14 | 15 | class PublicGistListContainer extends React.Component { 16 | static propTypes = { 17 | gistList: PropTypes.instanceOf(PromiseState).isRequired, 18 | gistListPagenation: PropTypes.instanceOf(PromiseState).isRequired, 19 | gist: PropTypes.instanceOf(PromiseState).isRequired, 20 | } 21 | 22 | componentDidMount() { 23 | this.props.listGists(GistCriteria.PUBLIC); 24 | } 25 | 26 | render() { 27 | const { gistList, gistListPagenation, gist, listNextGists } = this.props; 28 | return gistList.mapIf({ 29 | loading: () =>
  • , 30 | rejected: payload => , 31 | resolved: payload => 32 |
    33 | listNextGists(gistListPagenation.mapIfResolved())} 36 | nextActionInProgress={gistListPagenation.isLoading()}/> 37 |
    , 38 | }); 39 | } 40 | } 41 | 42 | const mapStateToProps = state => ({ 43 | gistList: state.gistList, 44 | gistListPagenation: state.gistListPagenation, 45 | gist: state.gist, 46 | }); 47 | 48 | const mapDispatchToProps = dispatch => bindActionCreators({ 49 | listGists, 50 | listNextGists, 51 | }, dispatch) 52 | 53 | export default connect(mapStateToProps, mapDispatchToProps)(PublicGistListContainer); 54 | -------------------------------------------------------------------------------- /src/components/home/list/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { bindActionCreators } from 'redux'; 4 | import { connect } from 'react-redux'; 5 | import PromiseState from '../../../infrastructure/PromiseState'; 6 | 7 | import AuthenticatedContainer from './AuthenticatedContainer'; 8 | import NotAuthenticated from './NotAuthenticated'; 9 | 10 | class ListContainer extends React.Component { 11 | static propTypes = { 12 | session: PropTypes.instanceOf(PromiseState).isRequired, 13 | } 14 | 15 | render() { 16 | return this.props.session.mapIf({ 17 | invalid: () => , 18 | rejected: () => , 19 | resolved: () => , 20 | }); 21 | } 22 | } 23 | 24 | const mapStateToProps = state => ({ 25 | session: state.session, 26 | }); 27 | 28 | const mapDispatchToProps = dispatch => bindActionCreators({ 29 | }, dispatch) 30 | 31 | export default connect(mapStateToProps, mapDispatchToProps)(ListContainer); 32 | -------------------------------------------------------------------------------- /src/components/home/main/EditGistContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { bindActionCreators } from 'redux'; 4 | import { connect } from 'react-redux'; 5 | import { Seq, is } from 'immutable'; 6 | import { Link } from 'react-router-dom'; 7 | import PromiseState from '../../../infrastructure/PromiseState'; 8 | 9 | import { 10 | readGist, 11 | invalidateGist, 12 | changeEditingGist, 13 | updateGist, 14 | } from '../../../state/gists/actionCreators'; 15 | 16 | import GistEditor from './GistEditor'; 17 | import LoadingIndicator from '../../LoadingIndicator'; 18 | import ErrorIndicator from '../../ErrorIndicator'; 19 | 20 | class EditGistContainer extends React.Component { 21 | static propTypes = { 22 | editingGist: PropTypes.instanceOf(PromiseState).isRequired, 23 | updatedGist: PropTypes.instanceOf(PromiseState).isRequired, 24 | } 25 | 26 | componentDidMount() { 27 | this.props.readGist(this.props.match.params.id); 28 | } 29 | 30 | componentDidUpdate(prevProps) { 31 | if (!is(Seq(this.props.match.params), Seq(prevProps.match.params))) { 32 | this.props.readGist(this.props.match.params.id); 33 | } 34 | } 35 | 36 | componentWillUnmount() { 37 | this.props.invalidateGist(); 38 | } 39 | 40 | render() { 41 | const { 42 | editingGist, 43 | updatedGist, 44 | changeEditingGist, 45 | updateGist, 46 | } = this.props; 47 | return editingGist.mapIf({ 48 | loading: () => , 49 | rejected: payload => , 50 | resolved: payload => 51 | 58 | {updatedGist.isLoading() ? : null} 59 |   60 | 65 |   66 | 68 | Cancel 69 | 70 | 71 | }/>, 72 | }); 73 | } 74 | } 75 | 76 | const mapStateToProps = state => ({ 77 | editingGist: state.editingGist, 78 | updatedGist: state.updatedGist, 79 | }); 80 | 81 | const mapDispatchToProps = dispatch => bindActionCreators({ 82 | readGist, 83 | invalidateGist, 84 | changeEditingGist, 85 | updateGist, 86 | }, dispatch) 87 | 88 | export default connect(mapStateToProps, mapDispatchToProps)(EditGistContainer); 89 | -------------------------------------------------------------------------------- /src/components/home/main/GistEditor.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import remark from 'remark'; 4 | import remarkReact from 'remark-react'; 5 | import SyntaxHighlighter from 'react-syntax-highlighter'; 6 | import { githubGist } from 'react-syntax-highlighter/dist/styles'; 7 | import lowlight from 'lowlight'; 8 | 9 | import EditingGist from '../../../models/EditingGist'; 10 | 11 | import GistMetadata from './GistMetadata'; 12 | import ErrorIndicator from '../../ErrorIndicator'; 13 | 14 | const GistEditor = ({ 15 | editingGist, 16 | changeEditingGist, 17 | disabled, 18 | error, 19 | form, 20 | }) => ( 21 |
    22 | 25 | {editingGist.originalGist ? ( 26 | 27 | ) : null} 28 | {editingGist.files.map(file => 29 | changeEditingGist(editingGist.setFile(file))}/> 32 | ).toList()} 33 | {error ? : null} 34 |
    35 |
    36 | 43 |
    44 |
    45 | {form} 46 |
    47 |
    48 |
    49 |
    50 | Tips for Slideshow: 51 |   52 | Split slide pages by --- separator. 53 |
    54 |
    55 | ) 56 | 57 | GistEditor.propTypes = { 58 | editingGist: PropTypes.instanceOf(EditingGist).isRequired, 59 | } 60 | 61 | export default GistEditor 62 | 63 | const GistDescription = ({disabled, editingGist, changeEditingGist}) => ( 64 | changeEditingGist(editingGist.setDescription(e.target.value))}/> 69 | ) 70 | 71 | const GistFile = ({file, onChange, disabled}) => ( 72 |
    73 |

    {file.originalFilename}

    74 | {file.remove ? ( 75 | 76 | ) : ( 77 |
    78 |
    79 |
    80 |
    81 | onChange(file.renameTo(e.target.value))}/> 85 |
    86 |
    87 | 92 |
    93 |
    94 |
    95 |
    96 | {file.language} 97 |
    98 |
    99 | 103 |
    104 | {file && file.isMarkdown() ? ( 105 | 106 | ) : ( 107 | 108 | )} 109 |
    110 |
    111 |
    112 |
    113 |
    114 | )} 115 |
    116 | ) 117 | 118 | const ToBeRemoved = ({file, onChange}) => ( 119 |
    120 |
    121 | To be removed. 122 |   123 | onChange(file.toggleRemove())}>Cancel 124 |
    125 |
    126 | ) 127 | 128 | const Markdown = ({content}) => ( 129 |
    130 |
    131 | {remark().use(remarkReact).processSync(content).contents} 132 |
    133 |
    134 | ) 135 | 136 | const Highlight = ({content, language}) => { 137 | let effectiveLanguage; 138 | if (language && lowlight.getLanguage(language)) { 139 | effectiveLanguage = language; 140 | } 141 | return ( 142 |
    143 |
    144 | 145 | {content} 146 | 147 |
    148 |
    149 | ); 150 | } 151 | -------------------------------------------------------------------------------- /src/components/home/main/GistMetadata.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const GistMetadata = ({gist}) => ( 5 |
      6 |
    • 7 | {gist.owner ? ( 8 | 9 |  {gist.owner.login} 10 | 11 | ) : ( 12 |  Anonymous 13 | )} 14 |
    • 15 |
    • 16 | 17 |   18 | Created 19 |
    • 20 |
    • 21 | 22 |   23 | Updated 24 |
    • 25 |
    • 26 | 27 |  Gist 28 | 29 |
    • 30 | {gist.public ? null : ( 31 |
    • 32 |  Private 33 |
    • 34 | )} 35 |
    36 | ) 37 | 38 | GistMetadata.propTypes = { 39 | gist: PropTypes.object.isRequired, 40 | } 41 | 42 | export default GistMetadata 43 | -------------------------------------------------------------------------------- /src/components/home/main/GistView.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Link } from 'react-router-dom'; 4 | import { Seq } from 'immutable'; 5 | import remark from 'remark'; 6 | import remarkReact from 'remark-react'; 7 | import SyntaxHighlighter from 'react-syntax-highlighter'; 8 | import { githubGist } from 'react-syntax-highlighter/dist/styles'; 9 | import lowlight from 'lowlight'; 10 | 11 | import GistMetadata from './GistMetadata'; 12 | 13 | const GistContent = ({gist}) => ( 14 |
    15 |

    {gist.description || gist.id}

    16 |
    17 | 18 |
    19 |
    20 | 21 |
    22 |
    23 | {Seq(gist.files).map((file, key) => ).toList()} 24 |
    25 | ) 26 | 27 | GistContent.propTypes = { 28 | gist: PropTypes.object.isRequired, 29 | } 30 | 31 | export default GistContent 32 | 33 | const GistNavigation = ({gist}) => ( 34 |
      35 |
    • 36 | 37 |  Slideshow 38 | 39 |
    • 40 |
    • 41 | 42 |  Edit 43 | 44 |
    • 45 |
    46 | ) 47 | 48 | const GistFile = ({file}) => ( 49 |
    50 |

    {file.filename}

    51 | {file.language} 52 | {file.language === 'Markdown' ? ( 53 | 54 | ) : ( 55 | 56 | )} 57 |
    58 | ) 59 | 60 | const Markdown = ({content}) => ( 61 |
    62 |
    63 | {remark().use(remarkReact).processSync(content).contents} 64 |
    65 |
    66 | ) 67 | 68 | const Highlight = ({content, language}) => { 69 | let effectiveLanguage; 70 | if (language && lowlight.getLanguage(language)) { 71 | effectiveLanguage = language; 72 | } 73 | return ( 74 |
    75 |
    76 | 77 | {content} 78 | 79 |
    80 |
    81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /src/components/home/main/NewGistCotainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { bindActionCreators } from 'redux'; 4 | import { connect } from 'react-redux'; 5 | import PromiseState from '../../../infrastructure/PromiseState'; 6 | 7 | import { 8 | newEditingGist, 9 | invalidateGist, 10 | changeEditingGist, 11 | createGist, 12 | } from '../../../state/gists/actionCreators'; 13 | 14 | import GistEditor from './GistEditor'; 15 | import LoadingIndicator from '../../LoadingIndicator'; 16 | import ErrorIndicator from '../../ErrorIndicator'; 17 | 18 | class NewGistContainer extends React.Component { 19 | static propTypes = { 20 | editingGist: PropTypes.instanceOf(PromiseState).isRequired, 21 | createdGist: PropTypes.instanceOf(PromiseState).isRequired, 22 | } 23 | 24 | componentDidMount() { 25 | this.props.newEditingGist(); 26 | } 27 | 28 | componentWillUnmount() { 29 | this.props.invalidateGist(); 30 | } 31 | 32 | render() { 33 | const { 34 | editingGist, 35 | createdGist, 36 | changeEditingGist, 37 | createGist, 38 | } = this.props; 39 | return editingGist.mapIf({ 40 | loading: () => , 41 | rejected: payload => , 42 | resolved: payload => 43 | 50 | {createdGist.isLoading() ? : null} 51 |   52 | 57 |   58 | 63 | 64 | }/>, 65 | }); 66 | } 67 | } 68 | 69 | const mapStateToProps = state => ({ 70 | editingGist: state.editingGist, 71 | createdGist: state.createdGist, 72 | }); 73 | 74 | const mapDispatchToProps = dispatch => bindActionCreators({ 75 | newEditingGist, 76 | invalidateGist, 77 | changeEditingGist, 78 | createGist, 79 | }, dispatch) 80 | 81 | export default connect(mapStateToProps, mapDispatchToProps)(NewGistContainer); 82 | -------------------------------------------------------------------------------- /src/components/home/main/TopContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { bindActionCreators } from 'redux'; 4 | import { connect } from 'react-redux'; 5 | import PromiseState from '../../../infrastructure/PromiseState'; 6 | 7 | import { login } from '../../../state/oauth/actionCreators'; 8 | 9 | class ViewGistContainer extends React.Component { 10 | static propTypes = { 11 | session: PropTypes.instanceOf(PromiseState).isRequired, 12 | } 13 | 14 | render() { 15 | return ( 16 |
    17 |
    18 |
    19 | logo 20 |

    Gistnote

    21 |

    Evernote like Gist client app

    22 | {this.props.session.mapIf({ 23 | invalid: () => , 24 | rejected: () => , 25 | })} 26 |
    27 |
    28 |

    Gistnote is a Gist client app based on JavaScript. © Hidetake Iwata, 2015.

    29 |

    Thanks to Metro Uinvert Dock Icon.

    30 |
    31 |
    32 | ); 33 | } 34 | } 35 | 36 | const SignIn = ({action}) => ( 37 |

    38 | 41 |

    42 | ); 43 | 44 | const mapStateToProps = state => ({ 45 | session: state.session, 46 | }); 47 | 48 | const mapDispatchToProps = dispatch => bindActionCreators({ 49 | login, 50 | }, dispatch) 51 | 52 | export default connect(mapStateToProps, mapDispatchToProps)(ViewGistContainer); 53 | -------------------------------------------------------------------------------- /src/components/home/main/ViewGistContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { bindActionCreators } from 'redux'; 4 | import { connect } from 'react-redux'; 5 | import { Seq, is } from 'immutable'; 6 | import PromiseState from '../../../infrastructure/PromiseState'; 7 | 8 | import { readGist, invalidateGist } from '../../../state/gists/actionCreators'; 9 | 10 | import GistView from './GistView'; 11 | import LoadingIndicator from '../../LoadingIndicator'; 12 | import ErrorIndicator from '../../ErrorIndicator'; 13 | 14 | class ViewGistContainer extends React.Component { 15 | static propTypes = { 16 | gist: PropTypes.instanceOf(PromiseState).isRequired, 17 | } 18 | 19 | componentDidMount() { 20 | this.props.readGist(this.props.match.params.id); 21 | } 22 | 23 | componentDidUpdate(prevProps) { 24 | if (!is(Seq(this.props.match.params), Seq(prevProps.match.params))) { 25 | this.props.readGist(this.props.match.params.id); 26 | } 27 | } 28 | 29 | componentWillUnmount() { 30 | this.props.invalidateGist(); 31 | } 32 | 33 | render() { 34 | return this.props.gist.mapIf({ 35 | loading: () =>

    , 36 | resolved: payload => , 37 | rejected: payload => , 38 | }); 39 | } 40 | } 41 | 42 | const mapStateToProps = state => ({ 43 | gist: state.gist, 44 | }); 45 | 46 | const mapDispatchToProps = dispatch => bindActionCreators({ 47 | readGist, 48 | invalidateGist, 49 | }, dispatch) 50 | 51 | export default connect(mapStateToProps, mapDispatchToProps)(ViewGistContainer); 52 | -------------------------------------------------------------------------------- /src/components/home/main/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Route, Switch } from 'react-router-dom'; 3 | 4 | import MeCotainer from './me'; 5 | import NewGistCotainer from './NewGistCotainer'; 6 | import ViewGistContainer from './ViewGistContainer'; 7 | import EditGistContainer from './EditGistContainer'; 8 | import TopContainer from './TopContainer'; 9 | 10 | export default () => ( 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | ) 19 | -------------------------------------------------------------------------------- /src/components/home/main/me/LogoutContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { bindActionCreators } from 'redux'; 3 | import { connect } from 'react-redux'; 4 | import { preventDefaultEvent } from '../../../../infrastructure/DispatchUtil'; 5 | 6 | import { logout } from '../../../../state/oauth/actionCreators'; 7 | 8 | class LogoutContainer extends React.Component { 9 | render() { 10 | return ( 11 |

    12 | 15 |

    16 | ); 17 | } 18 | } 19 | 20 | const mapStateToProps = state => ({ 21 | }); 22 | 23 | const mapDispatchToProps = dispatch => bindActionCreators({ 24 | logout, 25 | }, dispatch) 26 | 27 | export default connect(mapStateToProps, mapDispatchToProps)(LogoutContainer); 28 | -------------------------------------------------------------------------------- /src/components/home/main/me/UserContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { bindActionCreators } from 'redux'; 4 | import { connect } from 'react-redux'; 5 | import PromiseState from '../../../../infrastructure/PromiseState'; 6 | 7 | import { readUserProfile } from '../../../../state/user/actionCreators'; 8 | 9 | import LoadingIndicator from '../../../LoadingIndicator'; 10 | 11 | class UserContainer extends React.Component { 12 | static propTypes = { 13 | userProfile: PropTypes.instanceOf(PromiseState).isRequired, 14 | } 15 | 16 | componentDidMount() { 17 | this.props.readUserProfile(); 18 | } 19 | 20 | render() { 21 | return this.props.userProfile.mapIf({ 22 | loading: () => , 23 | resolved: payload => , 24 | }); 25 | } 26 | } 27 | 28 | const User = ({userProfile}) => ( 29 |
    30 |
    31 | avatar 33 |

    {userProfile.name}

    34 |

    {userProfile.bio}

    35 |

    @{userProfile.login}

    36 |
    37 | {userProfile.location} 38 |   39 | {userProfile.public_gists} Public Gists 40 |
    41 |
    42 |
    43 | ); 44 | 45 | const mapStateToProps = state => ({ 46 | userProfile: state.userProfile, 47 | }); 48 | 49 | const mapDispatchToProps = dispatch => bindActionCreators({ 50 | readUserProfile, 51 | }, dispatch) 52 | 53 | export default connect(mapStateToProps, mapDispatchToProps)(UserContainer); 54 | -------------------------------------------------------------------------------- /src/components/home/main/me/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import UserContainer from './UserContainer'; 4 | import LogoutContainer from './LogoutContainer'; 5 | 6 | export default () => ( 7 |
    8 |
    9 | 10 | 11 |
    12 | ) 13 | -------------------------------------------------------------------------------- /src/components/slide/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default () => ( 4 |
    5 |

    Slide

    6 |
    7 | ) 8 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | .navbar { 2 | margin-bottom: 0; 3 | } 4 | 5 | pre { 6 | border: none; 7 | } 8 | 9 | .autosize-textarea { 10 | position: relative; 11 | min-height: 15em; 12 | } 13 | 14 | .autosize-textarea textarea { 15 | position: absolute; 16 | width: 50%; 17 | top: 0; 18 | bottom: 0; 19 | left: 0; 20 | } 21 | 22 | .jumbotron { 23 | background-color: inherit; 24 | } 25 | 26 | .gn-user-avatar { 27 | width: 32px; 28 | height: 32px; 29 | border-radius: 16px; 30 | margin-left: .5em; 31 | } 32 | 33 | .gn-gists { 34 | position: fixed; 35 | top: 0; 36 | bottom: 0; 37 | padding-left: 0; 38 | padding-right: 0; 39 | overflow-y: auto; 40 | } 41 | 42 | .gn-gists-list-item { 43 | overflow-x: hidden; 44 | } 45 | 46 | .gn-gists-list-item-active { 47 | font-weight: bold; 48 | background-color: #eee; 49 | } 50 | 51 | .gn-gists-list-item-title { 52 | height: 3em; 53 | overflow-y: hidden; 54 | } 55 | 56 | .gn-gists-list-item-updated { 57 | color: #888; 58 | font-size: 80%; 59 | } 60 | 61 | .gn-gists-list-item-icons { 62 | color: #ccc; 63 | font-size: 80%; 64 | } 65 | 66 | .gn-gist-edit-form { 67 | margin-top: 2em; 68 | margin-bottom: 2em; 69 | } 70 | 71 | .gn-slide-go-view { 72 | z-index: 10; 73 | position: fixed; 74 | bottom: 5px; 75 | left: 5px; 76 | } 77 | 78 | .gn-copyright { 79 | margin-top: 3em; 80 | font-size: 90%; 81 | color: #444; 82 | } 83 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Provider } from 'react-redux'; 4 | import { ConnectedRouter } from 'react-router-redux'; 5 | import createHistory from 'history/createBrowserHistory' 6 | 7 | import store from './state/store'; 8 | 9 | import Root from './components/Root'; 10 | 11 | import './index.css'; 12 | 13 | const history = createHistory(); 14 | 15 | ReactDOM.render( 16 | 17 | 18 | 19 | 20 | , 21 | document.getElementById('root') 22 | ); 23 | -------------------------------------------------------------------------------- /src/index.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Provider } from 'react-redux'; 4 | import { ConnectedRouter } from 'react-router-redux'; 5 | import createHistory from 'history/createBrowserHistory' 6 | import MockLocalStorage from 'mock-local-storage'; 7 | 8 | import store from './state/store'; 9 | 10 | import Root from './components/Root'; 11 | 12 | const history = createHistory(); 13 | 14 | it('renders without crashing', () => { 15 | const div = document.createElement('div'); 16 | ReactDOM.render( 17 | 18 | 19 | 20 | 21 | , 22 | div 23 | ); 24 | }); 25 | -------------------------------------------------------------------------------- /src/infrastructure/DispatchUtil.js: -------------------------------------------------------------------------------- 1 | export const preventDefaultEvent = f => e => { 2 | e.preventDefault(); 3 | if (f) { 4 | return f(); 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/infrastructure/GitHub.js: -------------------------------------------------------------------------------- 1 | import queryString from 'query-string'; 2 | 3 | import GistCriteria from '../models/GistCriteria'; 4 | import OAuthToken from '../models/OAuthToken'; 5 | 6 | export default class GitHub { 7 | static endpoint = 'https://api.github.com' 8 | static scope = 'gist,public_repo' 9 | 10 | constructor(token = OAuthToken.NONE) { 11 | this.token = token; 12 | } 13 | 14 | fetch(url, options = {}) { 15 | const headers = new Headers(options.headers); 16 | const authorization = this.token.getAuthorizationHeader(); 17 | if (authorization) { 18 | headers.set('authorization', authorization); 19 | } 20 | return fetch(url, { 21 | mode: 'cors', 22 | ...options, 23 | headers, 24 | }).then(response => { 25 | if (response.ok) { 26 | return response.json().then(body => { 27 | const link = response.headers.get('link'); 28 | if (link) { 29 | const matches = link.match(/<(.+?)>; rel="next"/); 30 | if (matches) { 31 | body._link_next = matches[1]; 32 | } 33 | } 34 | return body; 35 | }); 36 | } else { 37 | return response.json() 38 | .then(body => {throw body}) 39 | .catch(e => {throw e}); 40 | } 41 | }); 42 | } 43 | 44 | fetchNext(current) { 45 | return this.fetch(current._link_next); 46 | } 47 | 48 | getUser() { 49 | return this.fetch(`${GitHub.endpoint}/user`); 50 | } 51 | 52 | listGists(owner) { 53 | let url; 54 | switch (owner.type) { 55 | case GistCriteria.types.PUBLIC: 56 | url = `${GitHub.endpoint}/gists/public`; 57 | break; 58 | case GistCriteria.types.MY: 59 | url = `${GitHub.endpoint}/gists`; 60 | break; 61 | case GistCriteria.types.USER: 62 | url = `${GitHub.endpoint}/users/${owner.username}/gists`; 63 | break; 64 | default: 65 | throw new Error(`owner.type must be one of ${GistCriteria.types} but ${owner.type}`); 66 | } 67 | return this.fetch(url); 68 | } 69 | 70 | getGistContent(id) { 71 | return this.fetch(`${GitHub.endpoint}/gists/${id}`); 72 | } 73 | 74 | createGist(gist) { 75 | return this.fetch(`${GitHub.endpoint}/gists`, { 76 | method: 'POST', 77 | headers: {'Content-Type': 'application/json'}, 78 | body: JSON.stringify(gist), 79 | }); 80 | } 81 | 82 | updateGist(id, gist) { 83 | return this.fetch(`${GitHub.endpoint}/gists/${id}`, { 84 | method: 'PATCH', 85 | headers: {'Content-Type': 'application/json'}, 86 | body: JSON.stringify(gist), 87 | }); 88 | } 89 | 90 | getRepository(owner, repo) { 91 | return this.fetch(`${GitHub.endpoint}/repos/${owner}/${repo}`); 92 | } 93 | 94 | createIssue(owner, repo, issue) { 95 | return this.fetch(`${GitHub.endpoint}/repos/${owner}/${repo}/issues`, { 96 | method: 'POST', 97 | headers: {'Content-Type': 'application/json'}, 98 | body: JSON.stringify(issue), 99 | }); 100 | } 101 | 102 | static authorizeUrl({client_id, redirect_uri, scope, state}) { 103 | const params = queryString.stringify({ 104 | client_id, redirect_uri, scope, state, 105 | }); 106 | return `https://github.com/login/oauth/authorize?${params}`; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/infrastructure/OAuthTokenService.js: -------------------------------------------------------------------------------- 1 | import OAuthToken from '../models/OAuthToken'; 2 | 3 | const env = process.env.NODE_ENV; 4 | 5 | export default class OAuthTokenService { 6 | static endpoint = 'https://us-central1-gistnote.cloudfunctions.net'; 7 | 8 | fetchAuthorizationRequest() { 9 | return fetch(`${OAuthTokenService.endpoint}/access_token?env=${env}`, { 10 | mode: 'cors', 11 | }).then(response => response.json()); 12 | } 13 | 14 | requestAccessToken(code) { 15 | return fetch(`${OAuthTokenService.endpoint}/access_token`, { 16 | mode: 'cors', 17 | method: 'POST', 18 | headers: {'Content-Type': 'application/json'}, 19 | body: JSON.stringify({code, env}), 20 | }) 21 | .then(response => response.json()) 22 | .then(body => { 23 | if (body.error) { 24 | throw new Error(body.error_description); 25 | } else { 26 | return new OAuthToken(body); 27 | } 28 | }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/infrastructure/PreferenceStorage.js: -------------------------------------------------------------------------------- 1 | export default class PreferenceStorage { 2 | constructor(key) { 3 | this.key = key; 4 | } 5 | 6 | get() { 7 | try { 8 | return JSON.parse(localStorage.getItem(this.key)); 9 | } catch (e) { 10 | console.warn(e); 11 | return null; 12 | } 13 | } 14 | 15 | save(json) { 16 | localStorage.setItem(this.key, JSON.stringify(json)); 17 | } 18 | 19 | remove() { 20 | localStorage.removeItem(this.key); 21 | } 22 | 23 | poll() { 24 | return new Promise(resolve => { 25 | const callback = e => { 26 | if (e.storageArea === localStorage && e.key === this.key) { 27 | window.removeEventListener('storage', callback); 28 | resolve(); 29 | } 30 | } 31 | window.addEventListener('storage', callback); 32 | }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/infrastructure/PromiseAction.js: -------------------------------------------------------------------------------- 1 | export default class PromiseAction { 2 | static resolvedTypeOf = actionType => `${actionType}_RESOLVED` 3 | static resolved(actionType, payload) { 4 | return { 5 | type: PromiseAction.resolvedTypeOf(actionType), 6 | payload, 7 | }; 8 | } 9 | 10 | static rejectedTypeOf = actionType => `${actionType}_REJECTED` 11 | static rejected(actionType, payload) { 12 | return { 13 | type: PromiseAction.rejectedTypeOf(actionType), 14 | payload, 15 | }; 16 | } 17 | 18 | static invalidateTypeOf = actionType => `${actionType}_INVALIDATE` 19 | static invalidate(actionType) { 20 | return { 21 | type: PromiseAction.invalidateTypeOf(actionType), 22 | }; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/infrastructure/PromiseReducer.js: -------------------------------------------------------------------------------- 1 | import { Seq } from 'immutable'; 2 | 3 | import PromiseState from './PromiseState'; 4 | 5 | export default ({ 6 | type, 7 | types = [type], 8 | mapResolved = payload => payload, 9 | handle = state => state, 10 | }) => 11 | (state = PromiseState.INVALID, action) => { 12 | const value = Seq(types) 13 | .map(type => { 14 | switch (action.type) { 15 | case type: 16 | return PromiseState.LOADING; 17 | case `${type}_RESOLVED`: 18 | return PromiseState.resolved(mapResolved(action.payload)); 19 | case `${type}_REJECTED`: 20 | return PromiseState.rejected(action.payload); 21 | case `${type}_INVALIDATE`: 22 | return PromiseState.INVALID; 23 | default: 24 | return null; 25 | } 26 | }) 27 | .find(value => value !== null); 28 | 29 | if (value === undefined) { 30 | return handle(state, action); 31 | } else { 32 | return value; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/infrastructure/PromiseState.js: -------------------------------------------------------------------------------- 1 | import { Record } from 'immutable'; 2 | 3 | export default class PromiseState extends Record({ 4 | state: 'INVALID', 5 | payload: null, 6 | }) { 7 | static stateTypes = { 8 | INVALID: 'INVALID', 9 | LOADING: 'LOADING', 10 | RESOLVED: 'RESOLVED', 11 | REJECTED: 'REJECTED', 12 | } 13 | 14 | static INVALID = new PromiseState(); 15 | static LOADING = new PromiseState({state: PromiseState.stateTypes.LOADING}); 16 | 17 | static resolved = payload => new PromiseState({state: PromiseState.stateTypes.RESOLVED, payload}); 18 | static rejected = payload => new PromiseState({state: PromiseState.stateTypes.REJECTED, payload}); 19 | 20 | isInvalid() { 21 | return this.state === PromiseState.stateTypes.INVALID; 22 | } 23 | 24 | isLoading() { 25 | return this.state === PromiseState.stateTypes.LOADING; 26 | } 27 | 28 | isResolved() { 29 | return this.state === PromiseState.stateTypes.RESOLVED; 30 | } 31 | 32 | isRejected() { 33 | return this.state === PromiseState.stateTypes.REJECTED; 34 | } 35 | 36 | mapIf({ 37 | invalid = () => null, 38 | loading = () => null, 39 | resolved = () => null, 40 | rejected = () => null, 41 | }) { 42 | switch (this.state) { 43 | case PromiseState.stateTypes.INVALID: 44 | return invalid(); 45 | case PromiseState.stateTypes.LOADING: 46 | return loading(); 47 | case PromiseState.stateTypes.RESOLVED: 48 | return resolved(this.payload); 49 | case PromiseState.stateTypes.REJECTED: 50 | return rejected(this.payload); 51 | default: 52 | throw new Error(`unknown state ${this.state}`); 53 | } 54 | } 55 | 56 | mapIfResolved(f = payload => payload, valueIfNotResolved = null) { 57 | if (this.isResolved()) { 58 | return f(this.payload); 59 | } else { 60 | return valueIfNotResolved; 61 | } 62 | } 63 | 64 | mapIfRejected(f = payload => payload, valueIfNotRejected = null) { 65 | if (this.isRejected()) { 66 | return f(this.payload); 67 | } else { 68 | return valueIfNotRejected; 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/models/EditingGist.js: -------------------------------------------------------------------------------- 1 | import { Record, Seq } from 'immutable'; 2 | 3 | import EditingGistFile from './EditingGistFile'; 4 | 5 | /** 6 | * @see https://developer.github.com/v3/gists/#edit-a-gist 7 | */ 8 | export default class EditingGist extends Record({ 9 | public: undefined, 10 | originalGist: null, 11 | description: '', 12 | files: Seq.of(EditingGistFile.createNew()), 13 | }) { 14 | static createFromExistentGist(originalGist) { 15 | return new EditingGist({ 16 | originalGist, 17 | description: originalGist.description, 18 | files: Seq(originalGist.files).valueSeq().map((file, index) => 19 | EditingGistFile.createFromGistContentFile(index, file)), 20 | }); 21 | } 22 | 23 | static createNew() { 24 | return new EditingGist(); 25 | } 26 | 27 | isNew() { 28 | return this.originalGist === null; 29 | } 30 | 31 | setDescription(value) { 32 | return this.set('description', value); 33 | } 34 | 35 | setFile(file) { 36 | return this.set('files', this.files.map(currentFile => { 37 | if (currentFile.id === file.id) { 38 | return file; 39 | } else { 40 | return currentFile; 41 | } 42 | })); 43 | } 44 | 45 | addNewFile() { 46 | return this.set('files', 47 | this.files.concat([EditingGistFile.createNew(this.files.size)])); 48 | } 49 | 50 | setAsPublic() { 51 | return this.set('public', true); 52 | } 53 | 54 | setAsPrivate() { 55 | return this.set('public', false); 56 | } 57 | 58 | toGitHubRequest() { 59 | return { 60 | public: this.public, 61 | description: this.description, 62 | files: this.files 63 | .map(file => Seq(file.toGitHubRequest())) 64 | .reduce((r, file) => Seq(r).concat(file)) 65 | .toJS(), 66 | }; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/models/EditingGistFile.js: -------------------------------------------------------------------------------- 1 | import { Record } from 'immutable'; 2 | 3 | /** 4 | * @see https://developer.github.com/v3/gists/#edit-a-gist 5 | */ 6 | export default class EditingGistFile extends Record({ 7 | id: null, 8 | originalFilename: null, 9 | filename: null, 10 | content: null, 11 | language: null, 12 | remove: false, 13 | }) { 14 | static createFromGistContentFile(id, {filename, content, language}) { 15 | return new EditingGistFile({ 16 | id, 17 | originalFilename: filename, 18 | filename, 19 | content, 20 | language, 21 | }); 22 | } 23 | 24 | static createNew(id = 0) { 25 | return new EditingGistFile({ 26 | id, 27 | filename: `gistfile${id}.txt`, 28 | content: '', 29 | }); 30 | } 31 | 32 | renameTo(value) { 33 | return this.set('filename', value); 34 | } 35 | 36 | setContent(value) { 37 | return this.set('content', value); 38 | } 39 | 40 | toggleRemove() { 41 | return this.set('remove', !this.remove); 42 | } 43 | 44 | isMarkdown() { 45 | return this.language === 'Markdown' || (this.filename && this.filename.match(/\.md$/)); 46 | } 47 | 48 | toGitHubRequest() { 49 | const createNewFile = this.originalFilename === null; 50 | if (createNewFile) { 51 | if (this.remove) { 52 | return {}; 53 | } else { 54 | return { 55 | [this.filename]: { 56 | content: this.content, 57 | } 58 | }; 59 | } 60 | } else { 61 | if (this.remove) { 62 | return {[this.originalFilename]: null}; 63 | } else { 64 | const renameFile = this.originalFilename !== this.filename; 65 | return { 66 | [this.filename]: { 67 | filename: renameFile ? this.filename : undefined, 68 | content: this.content, 69 | } 70 | }; 71 | } 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/models/GistCriteria.js: -------------------------------------------------------------------------------- 1 | import { Record } from 'immutable'; 2 | 3 | export default class GistCriteria extends Record({ 4 | type: null, 5 | username: null, 6 | }) { 7 | static types = { 8 | PUBLIC: 'PUBLIC', 9 | MY: 'MY', 10 | USER: 'USER', 11 | } 12 | 13 | static PUBLIC = new GistCriteria({type: GistCriteria.types.PUBLIC}) 14 | static MY = new GistCriteria({type: GistCriteria.types.MY}) 15 | 16 | static createdBy = username => new GistCriteria({type: GistCriteria.types.USER, username}) 17 | } 18 | -------------------------------------------------------------------------------- /src/models/OAuthState.js: -------------------------------------------------------------------------------- 1 | import { Record } from 'immutable'; 2 | 3 | export default class OAuthState extends Record({ 4 | state: null, 5 | backPath: null, 6 | }) { 7 | static NONE = new OAuthState(); 8 | 9 | static backPath(backPath) { 10 | const state = Math.random().toString(36).replace(/[^a-z]+/g, ''); 11 | return new OAuthState({state, backPath}); 12 | } 13 | 14 | verifyState(state) { 15 | return this.state === state; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/models/OAuthToken.js: -------------------------------------------------------------------------------- 1 | import { Record } from 'immutable'; 2 | 3 | export default class OAuthToken extends Record({ 4 | access_token: null, 5 | scope: null, 6 | }) { 7 | static NONE = new OAuthToken() 8 | 9 | isValid() { 10 | return this.access_token !== null; 11 | } 12 | 13 | getAuthorizationHeader() { 14 | if (this.isValid()) { 15 | return `token ${this.access_token}`; 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/repositories/OAuthStateRepository.js: -------------------------------------------------------------------------------- 1 | import OAuthState from '../models/OAuthState'; 2 | 3 | const OAUTH_STATE = 'OAUTH_STATE'; 4 | 5 | export default class OAuthStateRepository { 6 | /** 7 | * @returns {OAuthState} 8 | */ 9 | get() { 10 | try { 11 | const json = sessionStorage.getItem(OAUTH_STATE); 12 | return new OAuthState(JSON.parse(json)); 13 | } catch (error) { 14 | return OAuthState.NONE; 15 | } 16 | } 17 | 18 | /** 19 | * @param {OAuthState} oauthState 20 | */ 21 | save(oauthState) { 22 | sessionStorage.setItem(OAUTH_STATE, JSON.stringify(oauthState.toJS())); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/repositories/OAuthTokenRepository.js: -------------------------------------------------------------------------------- 1 | import PromiseState from '../infrastructure/PromiseState'; 2 | import PreferenceStorage from '../infrastructure/PreferenceStorage'; 3 | 4 | import OAuthToken from '../models/OAuthToken'; 5 | 6 | const OAUTH_TOKEN = 'OAUTH_TOKEN'; 7 | 8 | export default class OAuthTokenRepository { 9 | storage = new PreferenceStorage(OAUTH_TOKEN); 10 | 11 | isPresent() { 12 | return this.storage.get() !== null; 13 | } 14 | 15 | get() { 16 | const json = this.storage.get(); 17 | if (json !== null) { 18 | return new OAuthToken(json); 19 | } else { 20 | return OAuthToken.NONE; 21 | } 22 | } 23 | 24 | getAsPromiseState() { 25 | if (this.isPresent()) { 26 | return PromiseState.resolved(); 27 | } else { 28 | return PromiseState.INVALID; 29 | } 30 | } 31 | 32 | save(oauthToken) { 33 | return this.storage.save(oauthToken.toJS()); 34 | } 35 | 36 | remove() { 37 | this.storage.remove(); 38 | } 39 | 40 | poll() { 41 | return this.storage.poll(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/state/gists/actionCreators.js: -------------------------------------------------------------------------------- 1 | import PromiseAction from '../../infrastructure/PromiseAction'; 2 | 3 | import * as actionTypes from './actionTypes' 4 | 5 | export const changeGistCriteria = payload => ({type: actionTypes.CHANGE_GIST_CRITERIA, payload}) 6 | 7 | export const listGists = owner => ({type: actionTypes.LIST_GISTS, owner}) 8 | export const listNextGists = pagenation => ({type: actionTypes.LIST_NEXT_GISTS, pagenation}) 9 | 10 | export const readGist = id => ({type: actionTypes.READ_GIST, id}) 11 | export const createGist = payload => ({type: actionTypes.CREATE_GIST, payload}) 12 | export const updateGist = payload => ({type: actionTypes.UPDATE_GIST, payload}) 13 | export const invalidateGist = () => PromiseAction.invalidate(actionTypes.READ_GIST) 14 | 15 | export const newEditingGist = () => ({type: actionTypes.NEW_EDITING_GIST}) 16 | export const changeEditingGist = payload => ({type: actionTypes.CHANGE_EDITING_GIST, payload}) 17 | -------------------------------------------------------------------------------- /src/state/gists/actionTypes.js: -------------------------------------------------------------------------------- 1 | export const CHANGE_GIST_CRITERIA = 'CHANGE_GIST_CRITERIA' 2 | 3 | export const LIST_GISTS = 'LIST_GISTS' 4 | export const LIST_NEXT_GISTS = 'LIST_NEXT_GISTS' 5 | 6 | export const READ_GIST = 'READ_GIST' 7 | export const CREATE_GIST = 'CREATE_GIST' 8 | export const UPDATE_GIST = 'UPDATE_GIST' 9 | 10 | export const NEW_EDITING_GIST = 'NEW_EDITING_GIST' 11 | export const CHANGE_EDITING_GIST = 'CHANGE_EDITING_GIST' 12 | -------------------------------------------------------------------------------- /src/state/gists/reducers.js: -------------------------------------------------------------------------------- 1 | import PromiseAction from '../../infrastructure/PromiseAction'; 2 | import PromiseReducer from '../../infrastructure/PromiseReducer'; 3 | import PromiseState from '../../infrastructure/PromiseState'; 4 | 5 | import * as actionTypes from './actionTypes'; 6 | 7 | import GistCriteria from '../../models/GistCriteria'; 8 | import EditingGist from '../../models/EditingGist'; 9 | 10 | export function gistCriteria(state = GistCriteria.MY, action) { 11 | switch (action.type) { 12 | case actionTypes.CHANGE_GIST_CRITERIA: 13 | return action.payload; 14 | default: 15 | return state; 16 | } 17 | } 18 | 19 | export const gistList = PromiseReducer({ 20 | type: actionTypes.LIST_GISTS, 21 | handle: (state, action) => { 22 | switch (action.type) { 23 | case PromiseAction.resolvedTypeOf(actionTypes.LIST_NEXT_GISTS): 24 | return PromiseState.resolved([...state.payload, ...action.payload]); 25 | default: 26 | return state; 27 | } 28 | }, 29 | }) 30 | 31 | export const gistListPagenation = PromiseReducer({ 32 | types: [actionTypes.LIST_GISTS, actionTypes.LIST_NEXT_GISTS], 33 | }) 34 | 35 | export const gist = PromiseReducer({type: actionTypes.READ_GIST}) 36 | 37 | export const createdGist = PromiseReducer({type: actionTypes.CREATE_GIST}) 38 | 39 | export const updatedGist = PromiseReducer({type: actionTypes.UPDATE_GIST}) 40 | 41 | export const editingGist = PromiseReducer({ 42 | type: actionTypes.READ_GIST, 43 | mapResolved: payload => EditingGist.createFromExistentGist(payload), 44 | handle: (state, action) => { 45 | switch (action.type) { 46 | case actionTypes.NEW_EDITING_GIST: 47 | return PromiseState.resolved(EditingGist.createNew()); 48 | case actionTypes.CHANGE_EDITING_GIST: 49 | return PromiseState.resolved(action.payload); 50 | default: 51 | return state; 52 | } 53 | }, 54 | }) 55 | -------------------------------------------------------------------------------- /src/state/gists/sagas.js: -------------------------------------------------------------------------------- 1 | import { takeEvery, put } from 'redux-saga/effects'; 2 | import { push } from 'react-router-redux'; 3 | import GitHub from '../../infrastructure/GitHub'; 4 | import PromiseAction from '../../infrastructure/PromiseAction'; 5 | import OAuthTokenRepository from '../../repositories/OAuthTokenRepository'; 6 | 7 | import * as actionTypes from './actionTypes'; 8 | 9 | function* listGists({type, owner}) { 10 | const oauthTokenRepository = new OAuthTokenRepository(); 11 | const github = new GitHub(oauthTokenRepository.get()); 12 | try { 13 | const payload = yield github.listGists(owner); 14 | yield put(PromiseAction.resolved(type, payload)); 15 | } catch (error) { 16 | yield put(PromiseAction.rejected(type, error)); 17 | } 18 | } 19 | 20 | function* listNextGists({type, pagenation}) { 21 | const oauthTokenRepository = new OAuthTokenRepository(); 22 | const github = new GitHub(oauthTokenRepository.get()); 23 | try { 24 | const payload = yield github.fetchNext(pagenation); 25 | yield put(PromiseAction.resolved(type, payload)); 26 | } catch (error) { 27 | yield put(PromiseAction.rejected(type, error)); 28 | } 29 | } 30 | 31 | function* readGist({type, id}) { 32 | const oauthTokenRepository = new OAuthTokenRepository(); 33 | const github = new GitHub(oauthTokenRepository.get()); 34 | try { 35 | const payload = yield github.getGistContent(id); 36 | yield put(PromiseAction.resolved(type, payload)); 37 | } catch (error) { 38 | yield put(PromiseAction.rejected(type, error)); 39 | } 40 | } 41 | 42 | function* createGist({type, payload}) { 43 | const oauthTokenRepository = new OAuthTokenRepository(); 44 | const github = new GitHub(oauthTokenRepository.get()); 45 | try { 46 | const created = yield github.createGist(payload.toGitHubRequest()); 47 | yield put(PromiseAction.resolved(type, created)); 48 | yield put(push(`/${created.id}`)); 49 | } catch (error) { 50 | yield put(PromiseAction.rejected(type, error)); 51 | } 52 | } 53 | 54 | function* updateGist({type, payload}) { 55 | const oauthTokenRepository = new OAuthTokenRepository(); 56 | const github = new GitHub(oauthTokenRepository.get()); 57 | try { 58 | const { id } = payload.originalGist; 59 | const updated = yield github.updateGist(id, payload.toGitHubRequest()); 60 | yield put(PromiseAction.resolved(type, updated)); 61 | yield put(push(`/${id}`)); 62 | } catch (error) { 63 | yield put(PromiseAction.rejected(type, error)); 64 | } 65 | } 66 | 67 | export default function* () { 68 | yield takeEvery(actionTypes.LIST_GISTS, listGists); 69 | yield takeEvery(actionTypes.LIST_NEXT_GISTS, listNextGists); 70 | yield takeEvery(actionTypes.READ_GIST, readGist); 71 | yield takeEvery(actionTypes.CREATE_GIST, createGist); 72 | yield takeEvery(actionTypes.UPDATE_GIST, updateGist); 73 | } 74 | -------------------------------------------------------------------------------- /src/state/initialState.js: -------------------------------------------------------------------------------- 1 | import OAuthTokenRepository from '../repositories/OAuthTokenRepository'; 2 | 3 | export default () => { 4 | return { 5 | session: new OAuthTokenRepository().getAsPromiseState(), 6 | }; 7 | } 8 | -------------------------------------------------------------------------------- /src/state/oauth/actionCreators.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from './actionTypes' 2 | 3 | export const login = () => ({type: actionTypes.LOGIN}) 4 | export const logout = () => ({type: actionTypes.LOGOUT}) 5 | 6 | export const acquireSession = (code, state) => ({type: actionTypes.ACQUIRE_SESSION, code, state}) 7 | export const invalidateSession = () => ({type: actionTypes.INVALIDATE_SESSION}) 8 | -------------------------------------------------------------------------------- /src/state/oauth/actionTypes.js: -------------------------------------------------------------------------------- 1 | export const LOGIN = 'LOGIN' 2 | export const LOGOUT = 'LOGOUT' 3 | 4 | export const ACQUIRE_SESSION = 'ACQUIRE_SESSION' 5 | export const INVALIDATE_SESSION = 'INVALIDATE_SESSION' 6 | -------------------------------------------------------------------------------- /src/state/oauth/reducers.js: -------------------------------------------------------------------------------- 1 | import PromiseState from '../../infrastructure/PromiseState'; 2 | import PromiseReducer from '../../infrastructure/PromiseReducer'; 3 | 4 | import * as actionTypes from './actionTypes'; 5 | 6 | export const session = PromiseReducer({ 7 | type: actionTypes.ACQUIRE_SESSION, 8 | handle: (state, action) => { 9 | switch (action.type) { 10 | case actionTypes.INVALIDATE_SESSION: 11 | return PromiseState.INVALID; 12 | default: 13 | return state; 14 | } 15 | } 16 | }) 17 | -------------------------------------------------------------------------------- /src/state/oauth/sagas.js: -------------------------------------------------------------------------------- 1 | import { takeEvery, put, fork } from 'redux-saga/effects'; 2 | import { push, replace } from 'react-router-redux'; 3 | import PromiseAction from '../../infrastructure/PromiseAction'; 4 | import GitHub from '../../infrastructure/GitHub'; 5 | import OAuthTokenService from '../../infrastructure/OAuthTokenService'; 6 | 7 | import OAuthTokenRepository from '../../repositories/OAuthTokenRepository'; 8 | import OAuthStateRepository from '../../repositories/OAuthStateRepository'; 9 | 10 | import OAuthState from '../../models/OAuthState'; 11 | 12 | import * as actionTypes from './actionTypes'; 13 | import { invalidateSession } from './actionCreators'; 14 | 15 | function* login() { 16 | const oauthState = OAuthState.backPath(window.location.pathname); 17 | const oauthStateRepository = new OAuthStateRepository(); 18 | oauthStateRepository.save(oauthState); 19 | 20 | const oauthTokenService = new OAuthTokenService(); 21 | const authorizationRequest = yield oauthTokenService.fetchAuthorizationRequest(); 22 | 23 | window.location.href = GitHub.authorizeUrl({ 24 | client_id: authorizationRequest.client_id, 25 | redirect_uri: `${window.location.origin}/oauth`, 26 | scope: 'gist,public_repo', 27 | state: oauthState.state, 28 | }); 29 | } 30 | 31 | function* acquireSession({type, code, state}) { 32 | const oauthStateRepository = new OAuthStateRepository(); 33 | const oauthState = oauthStateRepository.get(); 34 | if (oauthState.verifyState(state)) { 35 | try { 36 | const oauthTokenService = new OAuthTokenService(); 37 | const oauthToken = yield oauthTokenService.requestAccessToken(code); 38 | const oauthTokenRepository = new OAuthTokenRepository(); 39 | oauthTokenRepository.save(oauthToken); 40 | yield put(PromiseAction.resolved(type, oauthToken)); 41 | yield put(replace(oauthState.backPath)); 42 | } catch (error) { 43 | yield put(PromiseAction.rejected(type, error)); 44 | } 45 | } else { 46 | yield put(PromiseAction.rejected(type, new Error('Invalid state'))); 47 | } 48 | } 49 | 50 | function* pollSession() { 51 | const oauthTokenRepository = new OAuthTokenRepository(); 52 | while (true) { 53 | yield oauthTokenRepository.poll(); 54 | const oauthToken = oauthTokenRepository.get(); 55 | if (oauthToken.isValid()) { 56 | yield put(PromiseAction.resolved(actionTypes.ACQUIRE_SESSION, oauthToken)); 57 | } else { 58 | yield put(invalidateSession()); 59 | } 60 | } 61 | } 62 | 63 | function* logout() { 64 | const oauthTokenRepository = new OAuthTokenRepository(); 65 | oauthTokenRepository.remove(); 66 | yield put(invalidateSession()); 67 | yield put(push('/')); 68 | } 69 | 70 | export default function* () { 71 | yield takeEvery(actionTypes.LOGIN, login); 72 | yield takeEvery(actionTypes.ACQUIRE_SESSION, acquireSession); 73 | yield takeEvery(actionTypes.LOGOUT, logout); 74 | 75 | yield fork(pollSession); 76 | } 77 | -------------------------------------------------------------------------------- /src/state/reducers.js: -------------------------------------------------------------------------------- 1 | import { routerReducer } from 'react-router-redux'; 2 | 3 | import * as gists from './gists/reducers'; 4 | import * as user from './user/reducers'; 5 | import * as oauth from './oauth/reducers'; 6 | 7 | export default { 8 | routing: routerReducer, 9 | ...gists, 10 | ...user, 11 | ...oauth, 12 | }; 13 | -------------------------------------------------------------------------------- /src/state/sagas.js: -------------------------------------------------------------------------------- 1 | import { fork } from 'redux-saga/effects'; 2 | 3 | import gists from './gists/sagas'; 4 | import user from './user/sagas'; 5 | import oauth from './oauth/sagas'; 6 | 7 | export default function* () { 8 | yield fork(gists); 9 | yield fork(user); 10 | yield fork(oauth); 11 | } 12 | -------------------------------------------------------------------------------- /src/state/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, combineReducers } from 'redux'; 2 | import createSagaMiddleware from 'redux-saga'; 3 | import { routerMiddleware } from 'react-router-redux'; 4 | 5 | import reducers from './reducers'; 6 | import initialState from './initialState'; 7 | import rootSaga from './sagas'; 8 | 9 | export default history => { 10 | const devMiddlewares = []; 11 | if (process.env.NODE_ENV === 'development') { 12 | const { logger } = require('redux-logger'); 13 | devMiddlewares.push(logger); 14 | } 15 | 16 | const sagaMiddleware = createSagaMiddleware(); 17 | const store = createStore( 18 | combineReducers(reducers), 19 | initialState(), 20 | applyMiddleware(sagaMiddleware, routerMiddleware(history), ...devMiddlewares)); 21 | 22 | sagaMiddleware.run(rootSaga); 23 | 24 | return store; 25 | } 26 | -------------------------------------------------------------------------------- /src/state/user/actionCreators.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from './actionTypes' 2 | 3 | export const readUserProfile = () => ({type: actionTypes.READ_USER_PROFILE}) 4 | -------------------------------------------------------------------------------- /src/state/user/actionTypes.js: -------------------------------------------------------------------------------- 1 | export const READ_USER_PROFILE = 'READ_USER_PROFILE' 2 | -------------------------------------------------------------------------------- /src/state/user/reducers.js: -------------------------------------------------------------------------------- 1 | import PromiseReducer from '../../infrastructure/PromiseReducer'; 2 | 3 | import * as actionTypes from './actionTypes'; 4 | 5 | export const userProfile = PromiseReducer({type: actionTypes.READ_USER_PROFILE}) 6 | -------------------------------------------------------------------------------- /src/state/user/sagas.js: -------------------------------------------------------------------------------- 1 | import { takeEvery, put } from 'redux-saga/effects'; 2 | import GitHub from '../../infrastructure/GitHub'; 3 | import PromiseAction from '../../infrastructure/PromiseAction'; 4 | import OAuthTokenRepository from '../../repositories/OAuthTokenRepository'; 5 | 6 | import * as actionTypes from './actionTypes'; 7 | import { invalidateSession } from '../oauth/actionCreators'; 8 | 9 | function* readUserProfile({type}) { 10 | const oauthTokenRepository = new OAuthTokenRepository(); 11 | const github = new GitHub(oauthTokenRepository.get()); 12 | try { 13 | const payload = yield github.getUser(); 14 | yield put(PromiseAction.resolved(type, payload)); 15 | } catch (error) { 16 | yield put(invalidateSession()); 17 | yield put(PromiseAction.rejected(type, error)); 18 | } 19 | } 20 | 21 | export default function* () { 22 | yield takeEvery(actionTypes.READ_USER_PROFILE, readUserProfile); 23 | } 24 | --------------------------------------------------------------------------------