├── .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 [](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 |
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 |
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 |
12 | -
13 |
16 |
17 |
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 |
31 | -
32 |
33 | {userProfile.name}
34 |
35 |
36 |
37 |
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 |
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 |
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 |
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 |
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 |

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 |

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 |
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 |
--------------------------------------------------------------------------------