├── src
├── constants
│ ├── generic.js
│ ├── navigation.js
│ ├── editor.js
│ └── api.js
├── actions
│ ├── stories.js
│ ├── mock.js
│ ├── navbar.js
│ ├── navigation.js
│ ├── story.js
│ ├── cache.js
│ ├── auth.js
│ ├── your-stories.js
│ ├── generic.js
│ └── editor.js
├── utils
│ ├── time.js
│ ├── localStorage.js
│ ├── auth.js
│ ├── forms.js
│ ├── api.js
│ ├── generic.js
│ └── array-extensions.js
├── components
│ ├── tag.js
│ ├── stories-container.js
│ ├── story-card-container.js
│ ├── logo.js
│ ├── content-container.js
│ ├── editor
│ │ ├── publish.js
│ │ ├── share.js
│ │ ├── save.js
│ │ ├── mark.js
│ │ ├── title.js
│ │ ├── editor.js
│ │ ├── link-dialog.js
│ │ ├── share-dialog.js
│ │ ├── node.js
│ │ ├── index.js
│ │ ├── tags-dialog.js
│ │ └── effects-menu.js
│ ├── auth
│ │ ├── text-field.js
│ │ ├── auth-form.js
│ │ ├── login.js
│ │ └── register.js
│ ├── snackbar.js
│ ├── stories
│ │ ├── index.js
│ │ └── story-card.js
│ ├── your-stories
│ │ ├── story-card.js
│ │ └── index.js
│ ├── page-wrapper.js
│ ├── navbar.js
│ └── story
│ │ └── index.js
├── index.js
├── reducers
│ ├── stories.js
│ ├── navbar.js
│ ├── auth.js
│ ├── your-stories.js
│ ├── navigation.js
│ ├── generic.js
│ ├── story.js
│ ├── index.js
│ ├── cache.js
│ └── editor.js
├── store.js
├── sagas
│ ├── story.js
│ ├── api.js
│ ├── auth.js
│ ├── index.js
│ ├── your-stories.js
│ ├── editor.js
│ └── generic.js
├── pages.js
├── middleware.js
├── App.js
├── validators
│ └── forms.js
├── layouts
│ └── main.js
├── registerServiceWorker.js
└── mocks
│ └── state.js
├── .env
├── public
├── favicon.ico
├── manifest.json
└── index.html
├── .gitignore
├── README.md
└── package.json
/src/constants/generic.js:
--------------------------------------------------------------------------------
1 | export const TICK = 200
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
1 | REACT_APP_MAIN_API_URL=http://localhost:5000/api/
2 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/radzionc/simple-blog-front/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/src/constants/navigation.js:
--------------------------------------------------------------------------------
1 | export const PAGES_WITH_NAVBAR = ['stories', 'editor', 'yourStories', 'story']
--------------------------------------------------------------------------------
/src/actions/stories.js:
--------------------------------------------------------------------------------
1 | import { createAction } from 'redux-act'
2 |
3 | export const receiveStories = createAction()
--------------------------------------------------------------------------------
/src/actions/mock.js:
--------------------------------------------------------------------------------
1 | import { createAction } from 'redux-act'
2 |
3 | export const receiveMockState = createAction()
4 |
--------------------------------------------------------------------------------
/src/actions/navbar.js:
--------------------------------------------------------------------------------
1 | import { createAction } from 'redux-act'
2 |
3 | export const toggleDropdown = createAction()
4 |
--------------------------------------------------------------------------------
/src/actions/navigation.js:
--------------------------------------------------------------------------------
1 | import { createAction } from 'redux-act'
2 |
3 | export const to = createAction()
4 | export const toStory = createAction()
5 |
--------------------------------------------------------------------------------
/src/actions/story.js:
--------------------------------------------------------------------------------
1 | import { createAction } from 'redux-act'
2 |
3 | export const receiveStory = createAction()
4 | export const toggleLike = createAction()
5 |
--------------------------------------------------------------------------------
/src/utils/time.js:
--------------------------------------------------------------------------------
1 | import { DateTime } from 'luxon'
2 |
3 |
4 | export const timestampForHuman = timestamp => DateTime.fromMillis(timestamp * 1000).toLocaleString({ month: 'long', day: 'numeric' })
--------------------------------------------------------------------------------
/src/components/tag.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Chip } from '@material-ui/core'
3 |
4 | export default ({ ...chipProps }) => (
5 |
9 | )
--------------------------------------------------------------------------------
/src/actions/cache.js:
--------------------------------------------------------------------------------
1 | import { createAction } from 'redux-act'
2 |
3 | export const updateState = createAction()
4 |
5 | export const saveCache = createAction()
6 |
7 | export const removeStateReceivedFrom = createAction()
8 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import App from './App';
4 | import registerServiceWorker from './registerServiceWorker';
5 |
6 | ReactDOM.render(, document.getElementById('root'));
7 | registerServiceWorker();
8 |
--------------------------------------------------------------------------------
/src/actions/auth.js:
--------------------------------------------------------------------------------
1 | import { createAction } from 'redux-act'
2 |
3 | export const submitLogin = createAction()
4 | export const submitRegister = createAction()
5 |
6 | export const receiveAuthData = createAction()
7 | export const unauthorizeUser = createAction()
8 |
--------------------------------------------------------------------------------
/src/actions/your-stories.js:
--------------------------------------------------------------------------------
1 | import { createAction } from 'redux-act'
2 |
3 | export const selectTab = createAction()
4 | export const remove = createAction()
5 | export const edit = createAction()
6 | export const receiveStoriesForTab = createAction()
7 | export const clear = createAction()
--------------------------------------------------------------------------------
/src/utils/localStorage.js:
--------------------------------------------------------------------------------
1 | export const takeIfExists = (key, type = String) => {
2 | const item = localStorage.getItem(key)
3 | if (item) {
4 | return type === Number
5 | ? Number.parseFloat(item)
6 | : type === Object ? JSON.parse(item) : item
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/src/components/stories-container.js:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 |
3 | const StoriesContainer = styled.div`
4 | margin: 40px;
5 | display: flex;
6 | flex-direction: row;
7 | flex-wrap: wrap;
8 | justify-content: center;
9 | `
10 |
11 | export default StoriesContainer
--------------------------------------------------------------------------------
/src/reducers/stories.js:
--------------------------------------------------------------------------------
1 | import { createReducer } from 'redux-act'
2 | import * as a from '../actions/stories'
3 |
4 | const getDefaultState = page => ({
5 | stories: []
6 | })
7 |
8 | export default () => createReducer({
9 | [a.receiveStories]: (state, stories) => ({ ...state, stories })
10 | }, getDefaultState())
--------------------------------------------------------------------------------
/src/store.js:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware } from 'redux'
2 | import { composeWithDevTools } from 'redux-devtools-extension'
3 |
4 | import reducer from './reducers'
5 | import middleware from './middleware'
6 |
7 | export default createStore(
8 | reducer,
9 | composeWithDevTools(applyMiddleware(...middleware))
10 | )
11 |
--------------------------------------------------------------------------------
/src/sagas/story.js:
--------------------------------------------------------------------------------
1 | import { select } from 'redux-saga/effects'
2 | import { callWith401Handle } from './api'
3 | import { TOGGLE_LIKE } from '../constants/api'
4 | import { post } from '../utils/api'
5 |
6 | export function* toggleLike() {
7 | const { navigation: { storyId } } = yield select()
8 | yield callWith401Handle(post, TOGGLE_LIKE(storyId))
9 | }
--------------------------------------------------------------------------------
/src/pages.js:
--------------------------------------------------------------------------------
1 | export { default as login } from './components/auth/login'
2 | export { default as register } from './components/auth/register'
3 | export { default as stories } from './components/stories'
4 | export { default as editor } from './components/editor'
5 | export { default as yourStories } from './components/your-stories'
6 | export { default as story } from './components/story'
7 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/src/actions/generic.js:
--------------------------------------------------------------------------------
1 | import { createAction } from 'redux-act'
2 |
3 | export const startApp = createAction()
4 | export const enterPage = createAction()
5 | export const exitPage = createAction()
6 | export const moveMouse = createAction()
7 | export const changePageSize = createAction()
8 | export const tick = createAction()
9 | export const toggleSnackbar = createAction()
10 |
--------------------------------------------------------------------------------
/src/components/story-card-container.js:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 |
3 | import { Card } from '@material-ui/core'
4 |
5 | const StyledCard = styled(Card)`
6 | && {
7 | cursor: pointer;
8 | width: 480px;
9 | margin: 20px;
10 | display: flex;
11 | flex-direction: column;
12 | justify-content: space-between
13 | }
14 | `
15 |
16 | export default StyledCard
--------------------------------------------------------------------------------
/src/utils/auth.js:
--------------------------------------------------------------------------------
1 | /* global localStorage:true */
2 |
3 | export const loggedIn = () => {
4 | const expirationTime = localStorage.getItem('tokenExpirationTime')
5 | return new Date().getTime() / 1000 < parseInt(expirationTime, 10)
6 | }
7 |
8 | export const unlogin = () => {
9 | localStorage.removeItem('token')
10 | localStorage.removeItem('tokenExpirationTime')
11 | }
12 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": "./index.html",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/logo.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 |
4 | const Logo = styled.h1`
5 | cursor: ${props => props.clickable ? 'pointer' : 'default' };
6 | margin: 10px;
7 | text-align: center;
8 | font-family: 'Dancing Script', cursive;
9 | `
10 |
11 | export default ({ onClick }) => Simple Blog
--------------------------------------------------------------------------------
/src/components/content-container.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 |
4 | const Container = styled.div`
5 | display: flex;
6 | justify-content: center;
7 | margin-bottom: 20px;
8 | `
9 | const ContainerInner = styled.div`
10 | width: 720px;
11 | `
12 |
13 | export default ({ children }) => (
14 |
15 |
16 | {children}
17 |
18 |
19 | )
--------------------------------------------------------------------------------
/src/constants/editor.js:
--------------------------------------------------------------------------------
1 | export const MAX_TITLE_LENGTH = 60
2 | export const SAVE_PERIOD = 4000
3 | export const TAGS_LIMIT = 5
4 |
5 | export const MARKS = {
6 | BOLD: 'bold',
7 | ITALIC: 'italic',
8 | CODE: 'code'
9 | }
10 |
11 | export const BLOCKS = {
12 | LINK: 'link',
13 | HEADING_ONE: 'heading-one',
14 | HEADING_TWO: 'heading-two',
15 | NUMBERED_LIST: 'numbered-list',
16 | BULLETED_LIST: 'bulleted-list',
17 | QUOTE: 'block-quote',
18 | IMAGE: 'image'
19 | }
--------------------------------------------------------------------------------
/src/components/editor/publish.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Button } from '@material-ui/core'
3 |
4 | import * as actions from '../../actions/editor'
5 | import { connectTo } from '../../utils/generic';
6 |
7 | export default connectTo(
8 | state => state.editor,
9 | actions,
10 | ({ toggleTagsMenu }) => {
11 | return (
12 |
15 | )
16 | }
17 | )
--------------------------------------------------------------------------------
/src/components/editor/share.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Button } from '@material-ui/core'
3 |
4 | import * as actions from '../../actions/editor'
5 | import { connectTo } from '../../utils/generic';
6 |
7 | export default connectTo(
8 | state => state.editor,
9 | actions,
10 | ({ toggleShareDialog }) => {
11 | return (
12 |
15 | )
16 | }
17 | )
--------------------------------------------------------------------------------
/src/components/editor/save.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Button } from '@material-ui/core'
3 |
4 | import * as actions from '../../actions/editor'
5 | import { connectTo } from '../../utils/generic';
6 |
7 | export default connectTo(
8 | state => state.editor,
9 | actions,
10 | ({ changesSaved, save }) => {
11 | return changesSaved ? (
12 |
Saved
13 | ) : (
14 |
17 | )
18 | }
19 | )
--------------------------------------------------------------------------------
/src/sagas/api.js:
--------------------------------------------------------------------------------
1 | import { call, put } from 'redux-saga/effects'
2 | import { unauthorizeUser } from '../actions/auth'
3 |
4 | export function* callWith401Handle(...args) {
5 | if (process.env.REACT_APP_MOCK) return
6 | try {
7 | const data = yield call(...args)
8 | return data
9 | } catch (err) {
10 | console.info('request with error: ', err)
11 | if (err.status === 401) {
12 | yield put(unauthorizeUser())
13 | } else {
14 | throw err
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/reducers/navbar.js:
--------------------------------------------------------------------------------
1 | import { createReducer } from 'redux-act'
2 | import * as a from '../actions/navbar'
3 |
4 | const getState = () => ({
5 | dropdownOpen: false,
6 | dropdownAnchor: undefined
7 | })
8 |
9 | export default _ =>
10 | createReducer(
11 | {
12 | [a.toggleDropdown]: (state, dropdownAnchor) => {
13 | const dropdownOpen = !state.dropdownOpen
14 | return {
15 | ...state,
16 | dropdownOpen,
17 | dropdownAnchor: dropdownOpen ? dropdownAnchor : undefined
18 | }
19 | }
20 | },
21 | getState()
22 | )
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Front-End for Simple Blog
2 |
3 | >
4 |
5 | 
6 |
7 | ## Requirements:
8 | - node.js
9 | - running [backend](https://github.com/RodionChachura/simple-blog-back)
10 |
11 |
12 |
13 | ## Run locally
14 | ```bash
15 | git clone https://github.com/RodionChachura/simple-blog-front
16 | cd simple-blog-front
17 | npm install
18 | npm start
19 | ```
20 |
21 | ## Technologies
22 | * React
23 | * Redux
24 | * Redux-Saga
25 |
26 | ## [Blog post](https://geekrodion.com/blog/asp-react-blog)
27 |
28 | ## License
29 |
30 | MIT © [RodionChachura](https://geekrodion.com)
31 |
--------------------------------------------------------------------------------
/src/utils/forms.js:
--------------------------------------------------------------------------------
1 | export const submitHandler = (onSubmit, enabledSubmit) => e => {
2 | e.preventDefault()
3 | if (enabledSubmit) onSubmit()
4 | }
5 |
6 | export const submitAsyncValidation = (
7 | handleSubmit,
8 | enabledSubmit,
9 | onSubmit
10 | ) => e => {
11 | e.preventDefault()
12 | enabledSubmit &&
13 | handleSubmit(
14 | values =>
15 | new Promise((resolve, reject) => onSubmit({ values, resolve, reject }))
16 | )(e)
17 | }
18 |
19 | export const isValid = (state, formName) => isFormValid(state.form[formName])
20 |
21 | export const isFormValid = form => form && !form.syncErrors && !form.pristine
22 |
--------------------------------------------------------------------------------
/src/reducers/auth.js:
--------------------------------------------------------------------------------
1 | import { createReducer } from 'redux-act'
2 |
3 | import * as a from '../actions/auth'
4 | import { takeIfExists } from '../utils/localStorage'
5 |
6 | const getDefaultState = _ => ({
7 | token: takeIfExists('token'),
8 | id: takeIfExists('id'),
9 | tokenExpirationTime: takeIfExists('tokenExpirationTime', Number),
10 | })
11 |
12 | export default _ =>
13 | createReducer(
14 | {
15 | [a.receiveAuthData]: (state, { token, tokenExpirationTime, id }) => ({
16 | ...state,
17 | id,
18 | token,
19 | tokenExpirationTime
20 | }),
21 | [a.unauthorizeUser]: () => ({})
22 | },
23 | getDefaultState()
24 | )
25 |
--------------------------------------------------------------------------------
/src/components/auth/text-field.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 | import { TextField } from '@material-ui/core'
4 |
5 | const TextFieldForRender = ({
6 | input,
7 | label,
8 | meta: { active, error, warning },
9 | ...custom
10 | }) => {
11 | const message = !active ? error || warning : undefined
12 | const showError = Boolean(message && input.value)
13 | return (
14 |
21 | )
22 | }
23 |
24 | export default styled(TextFieldForRender)`
25 | && {
26 | margin: 5px 0;
27 | }
28 | `
--------------------------------------------------------------------------------
/src/components/editor/mark.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 | import { lime } from '@material-ui/core/colors'
4 |
5 | import { switchCase } from '../../utils/generic';
6 |
7 | import { MARKS } from '../../constants/editor'
8 |
9 |
10 | const Code = styled.code`
11 | font-family: monospace;
12 | background: ${lime[200]};
13 | padding: 3px;
14 | `
15 |
16 | export default ({ children, mark: { type }, attributes }) => switchCase(
17 | {
18 | [MARKS.BOLD]: () => {children},
19 | [MARKS.ITALIC]: () => {children},
20 | [MARKS.CODE]: () => {children}
21 | },
22 | type,
23 | () => null
24 | )
--------------------------------------------------------------------------------
/src/reducers/your-stories.js:
--------------------------------------------------------------------------------
1 | import { createReducer } from 'redux-act'
2 | import * as a from '../actions/your-stories'
3 |
4 | const getState = () => ({
5 | tab: 'drafts',
6 | drafts: undefined,
7 | published: undefined,
8 | shared: undefined
9 | })
10 |
11 | export default _ =>
12 | createReducer(
13 | {
14 | [a.selectTab]: (state, tab) => ({ ...state, tab }),
15 | [a.remove]: (state, storyId) => ({
16 | ...state,
17 | [state.tab]: state[state.tab].filter(story => story.id !== storyId)
18 | }),
19 | [a.receiveStoriesForTab]: (state, { stories, tab }) => ({
20 | ...state,
21 | [tab]: stories
22 | }),
23 | [a.clear]: () => getState()
24 | },
25 | getState()
26 | )
--------------------------------------------------------------------------------
/src/components/editor/title.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { TextField } from '@material-ui/core'
3 | import styled from 'styled-components'
4 |
5 | import * as actions from '../../actions/editor'
6 | import { connectTo } from '../../utils/generic';
7 |
8 | const Container = styled.div`
9 | width: '100%';
10 | display: flex;
11 | justify-content: center;
12 | margin: 20px;
13 | `
14 |
15 | export default connectTo(
16 | state => state.editor,
17 | actions,
18 | ({ changeTitle, title }) => (
19 |
20 | changeTitle(value)}
23 | value={title}
24 | label='Title'
25 | />
26 |
27 | )
28 | )
--------------------------------------------------------------------------------
/src/reducers/navigation.js:
--------------------------------------------------------------------------------
1 | import { createReducer } from 'redux-act'
2 | import * as a from '../actions/navigation'
3 |
4 | import { unauthorizeUser } from '../actions/auth'
5 | import { loggedIn } from '../utils/auth'
6 |
7 | const getDefaultState = page => ({
8 | page,
9 | storyId: undefined
10 | })
11 |
12 | const forward = (state, page) => ({ state, page })
13 |
14 | export default _ =>
15 | createReducer(
16 | {
17 | [a.to]: forward,
18 | [a.toStory]: (state, storyId) => ({ ...state, page: 'story', storyId }),
19 | [unauthorizeUser]: state => forward(state, 'login'),
20 | },
21 | getDefaultState(process.env.REACT_APP_MOCK
22 | ? undefined
23 | : loggedIn() ? 'stories' : 'login'
24 | )
25 | )
26 |
--------------------------------------------------------------------------------
/src/reducers/generic.js:
--------------------------------------------------------------------------------
1 | import { createReducer } from 'redux-act'
2 | import * as a from '../actions/generic'
3 |
4 | const getDefaultState = () => ({
5 | pageWidth: window.innerWidth,
6 | pageHeight: window.innerHeight,
7 | mouseX: 0,
8 | mouseY: 0,
9 | snackbarMessage: ''
10 | })
11 |
12 | export default () =>
13 | createReducer(
14 | {
15 | [a.changePageSize]: (state, { width, height }) => ({
16 | ...state,
17 | pageWidth: width,
18 | pageHeight: height
19 | }),
20 | [a.moveMouse]: (state, { mouseX, mouseY }) => ({
21 | ...state,
22 | mouseX,
23 | mouseY
24 | }),
25 | [a.toggleSnackbar]: (state, snackbarMessage) => ({ ...state, snackbarMessage }),
26 | },
27 | getDefaultState()
28 | )
29 |
--------------------------------------------------------------------------------
/src/constants/api.js:
--------------------------------------------------------------------------------
1 | export const BACKEND = process.env.REACT_APP_MAIN_API_URL
2 |
3 | const AUTH = `${BACKEND}auth/`
4 | export const LOGIN = `${AUTH}login`
5 | export const REGISTER = `${AUTH}register`
6 |
7 | export const STORIES = `${BACKEND}stories/`
8 | export const CREATE_STORY = STORIES
9 | export const DELETE_STORY = STORIES
10 | export const UPDATE_STORY = storyId => `${STORIES}${storyId}`
11 | export const PUBLISH_STORY = storyId => `${STORIES}${storyId}/publish`
12 |
13 | export const USER_STORIES = userId => `${STORIES}user/${userId}`
14 | export const STORY_DETAIL = storyId => `${STORIES}${storyId}`
15 | export const DRAFTS = `${STORIES}drafts`
16 | export const SHARED = `${STORIES}shared`
17 | export const TOGGLE_LIKE = storyId => `${STORIES}${storyId}/toggleLike`
18 | export const SHARE = storyId => `${STORIES}${storyId}/share`
19 |
20 |
--------------------------------------------------------------------------------
/src/sagas/auth.js:
--------------------------------------------------------------------------------
1 | import { put, call } from 'redux-saga/effects'
2 | import { SubmissionError } from 'redux-form'
3 |
4 | import { to } from '../actions/navigation'
5 | import { receiveAuthData } from '../actions/auth'
6 | import { LOGIN, REGISTER } from '../constants/api'
7 | import { post } from '../utils/api'
8 | import { startApp } from '../actions/generic'
9 |
10 | const authSaga = (url, thanGoTo) =>
11 | function*({ payload: { values, reject } }) {
12 | try {
13 | const authData = yield call(post, url, values)
14 | yield put(receiveAuthData(authData))
15 | yield put(startApp())
16 | yield put(to(thanGoTo))
17 | } catch ({ status, message }) {
18 | yield call(reject, new SubmissionError(message))
19 | }
20 | }
21 |
22 | export const submitLogin = authSaga(LOGIN, 'stories')
23 | export const submitRegister = authSaga(REGISTER, 'stories')
24 |
--------------------------------------------------------------------------------
/src/reducers/story.js:
--------------------------------------------------------------------------------
1 | import { createReducer } from 'redux-act'
2 | import { Value } from 'slate'
3 |
4 | import * as a from '../actions/story'
5 |
6 | const getDefaultState = () => ({
7 | storyId: undefined,
8 | ownerUsername: undefined,
9 | ownerId: undefined,
10 | publishTime: undefined,
11 | titile: undefined,
12 | content: undefined,
13 | tags: [],
14 | likesNumber: 0,
15 | liked: false,
16 | })
17 |
18 | export default _ =>
19 | createReducer(
20 | {
21 | [a.receiveStory]: (state, story) => ({
22 | ...state,
23 | ...story,
24 | storyId: story.id,
25 | content: Value.fromJSON(JSON.parse(story.content)),
26 | }),
27 | [a.toggleLike]: (state) => ({
28 | ...state,
29 | liked: !state.liked,
30 | likesNumber: state.liked ? state.likesNumber - 1 : state.likesNumber + 1
31 | })
32 | },
33 | getDefaultState()
34 | )
35 |
--------------------------------------------------------------------------------
/src/middleware.js:
--------------------------------------------------------------------------------
1 | import createSagaMiddleware from 'redux-saga'
2 |
3 | import { unauthorizeUser } from './actions/auth'
4 |
5 | export const sagaMiddleware = createSagaMiddleware()
6 |
7 | const localStorageMiddleware = store => next => action => {
8 | if (action.type === unauthorizeUser.getType()) {
9 | localStorage.clear()
10 | }
11 |
12 | const prevState = store.getState()
13 | const result = next(action)
14 | const nextState = store.getState()
15 |
16 | if (prevState.auth.token !== nextState.auth.token && nextState.auth.token) {
17 | localStorage.setItem('token', nextState.auth.token)
18 | localStorage.setItem(
19 | 'tokenExpirationTime',
20 | nextState.auth.tokenExpirationTime
21 | )
22 | localStorage.setItem('id', nextState.auth.id)
23 | localStorage.setItem('email', nextState.auth.email)
24 | }
25 | return result
26 | }
27 |
28 | export default [sagaMiddleware, localStorageMiddleware]
29 |
--------------------------------------------------------------------------------
/src/actions/editor.js:
--------------------------------------------------------------------------------
1 | import { createAction } from 'redux-act'
2 |
3 | export const changeTitle = createAction()
4 |
5 | export const toggleEffect = createAction()
6 | export const save = createAction()
7 | export const changeContent = createAction()
8 | export const startRequest = createAction()
9 | export const successfulSave = createAction()
10 | export const successfulCreation = createAction()
11 | export const changeLink = createAction()
12 | export const exitLinkPrompt = createAction()
13 | export const submitLink = createAction()
14 |
15 | export const toggleTagsMenu = createAction()
16 | export const editTag = createAction()
17 | export const submitTag = createAction()
18 | export const deleteTag = createAction()
19 | export const publish = createAction()
20 |
21 | export const receiveStoryForEdit = createAction()
22 | export const updateStory = createAction()
23 | export const clear = createAction()
24 |
25 | export const toggleShareDialog = createAction()
26 | export const share = createAction()
27 | export const changeUserToShareName = createAction()
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Provider } from 'react-redux'
3 |
4 | import './utils/array-extensions'
5 |
6 | import store from './store'
7 | import saga from './sagas/'
8 | import Root from './layouts/main'
9 | import { sagaMiddleware } from './middleware'
10 |
11 | import { receiveMockState } from './actions/mock'
12 |
13 | import { loggedIn } from './utils/auth'
14 | import { startApp } from './actions/generic'
15 |
16 | const App = () => {
17 | return (
18 |
19 |
20 |
21 | )
22 | }
23 |
24 | export default App
25 |
26 | sagaMiddleware.run(saga)
27 |
28 | loggedIn() && store.dispatch(startApp())
29 |
30 | if (process.env.REACT_APP_MOCK) {
31 | import('./mocks/state.js').then(module => {
32 | const state = store.getState()
33 | store.dispatch(
34 | receiveMockState(
35 | Object.entries(state).reduce(
36 | (acc, [key, value]) => ({
37 | ...acc,
38 | [key]: { ...value, ...module.MOCK_STATE[key] }
39 | }),
40 | {}
41 | )
42 | )
43 | )
44 | })
45 | }
46 |
--------------------------------------------------------------------------------
/src/components/snackbar.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Snackbar, IconButton } from '@material-ui/core'
3 | import CloseIcon from '@material-ui/icons/Close'
4 |
5 | import { connectTo } from '../utils/generic';
6 | import { toggleSnackbar } from '../actions/generic'
7 |
8 | export default connectTo(
9 | state => state.generic,
10 | { toggleSnackbar },
11 | ({ snackbarMessage, toggleSnackbar }) => (
12 | 0}
18 | autoHideDuration={3000}
19 | onClose={() => toggleSnackbar('')}
20 | onExited={() => toggleSnackbar('')}
21 | ContentProps={{
22 | 'aria-describedby': 'message-id',
23 | }}
24 | message={{snackbarMessage}}
25 | action={[
26 | toggleSnackbar('')}
31 | >
32 |
33 | ,
34 | ]}
35 | />
36 | )
37 | )
--------------------------------------------------------------------------------
/src/sagas/index.js:
--------------------------------------------------------------------------------
1 | import { takeLatest } from 'redux-saga/effects'
2 |
3 | import * as genericActions from '../actions/generic'
4 | import * as genericSagas from './generic'
5 |
6 | import * as authActions from '../actions/auth'
7 | import * as authSagas from './auth'
8 |
9 | import * as editorActions from '../actions/editor'
10 | import * as editorSagas from './editor'
11 |
12 | import * as yourStoriesActions from '../actions/your-stories'
13 | import * as yourStoriesSagas from './your-stories'
14 |
15 | import * as storyActions from '../actions/story'
16 | import * as storySagas from './story'
17 |
18 | export default function* saga() {
19 | const relations = [
20 | [genericActions, genericSagas],
21 | [authActions, authSagas],
22 | [editorActions, editorSagas],
23 | [yourStoriesActions, yourStoriesSagas],
24 | [storyActions, storySagas]
25 | ]
26 |
27 | for (const [actions, sagas] of relations) {
28 | for (const [actionName, action] of Object.entries(actions)) {
29 | const saga = sagas[actionName]
30 | if (saga) yield takeLatest(action.getType(), saga)
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/stories/index.js:
--------------------------------------------------------------------------------
1 | import _ from 'lodash'
2 | import React from 'react'
3 |
4 | import Page from '../page-wrapper'
5 | import { connectTo } from '../../utils/generic';
6 | import StoriesContainer from '../stories-container';
7 | import StoryCard from './story-card'
8 | import { toStory } from '../../actions/navigation';
9 | import { timestampForHuman } from '../../utils/time';
10 |
11 | export default connectTo(
12 | state => state.stories,
13 | { toStory },
14 | ({ stories, toStory }) => (
15 |
16 |
17 | {_.sortBy(stories, ['publishTime']).reverse().map((story, number) => {
18 | const date = timestampForHuman(story.publishTime)
19 | const dateText = `Published on ${date}`
20 |
21 | return (
22 | toStory(story.id)}
29 | />
30 | )
31 | })}
32 |
33 |
34 | )
35 | )
--------------------------------------------------------------------------------
/src/components/your-stories/story-card.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { CardActions, CardContent, Button, Typography } from '@material-ui/core'
4 | import { noPropogation } from '../../utils/generic';
5 | import StoryCardContainer from '../story-card-container'
6 |
7 | export default ({ title , dateText, onEdit, onDelete, onClick }) => {
8 | return (
9 |
10 |
11 |
12 | {dateText}
13 |
14 |
15 | {title}
16 |
17 |
18 |
19 | { onEdit && (
20 |
23 | )}
24 | { onDelete && (
25 |
28 | )}
29 |
30 |
31 | )
32 | }
--------------------------------------------------------------------------------
/src/components/editor/editor.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Editor } from 'slate-react'
3 | import { Block } from 'slate'
4 |
5 | import { connectTo } from '../../utils/generic';
6 | import * as actions from '../../actions/editor'
7 |
8 | import Mark from './mark'
9 | import Node from './node'
10 |
11 | const schema = {
12 | document: {
13 | last: { type: 'paragraph' },
14 | normalize: (change, { code, node }) => {
15 | if (code === 'last_child_type_invalid') {
16 | const paragraph = Block.create('paragraph')
17 | return change.insertNodeByKey(node.key, node.nodes.size, paragraph)
18 | }
19 | },
20 | },
21 | blocks: {
22 | image: {
23 | isVoid: true,
24 | },
25 | },
26 | }
27 |
28 | export default connectTo(
29 | state => state.editor,
30 | actions,
31 | ({ content, changeContent }) => {
32 | return (
33 | changeContent(value)}
40 | renderNode={Node}
41 | renderMark={Mark}
42 | />
43 | )
44 | }
45 | )
--------------------------------------------------------------------------------
/src/components/stories/story-card.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 |
4 | import { CardContent, Typography } from '@material-ui/core'
5 | import StoryCardContainer from '../story-card-container'
6 | import Tag from '../tag'
7 |
8 | const TagsContainer = styled.div`
9 | display: flex;
10 | flex-direction: row;
11 | justify-content: center;
12 | align-items: center;
13 | flex-wrap: wrap;
14 | `
15 |
16 | export default ({ title , dateText, onClick, ownerUsername, tags }) => {
17 | return (
18 |
19 |
20 |
21 | {dateText}
22 |
23 |
24 | {title}
25 |
26 |
27 | {tags.map(tag => (
28 |
32 | ))}
33 |
34 |
35 | written by {ownerUsername}
36 |
37 |
38 |
39 | )
40 | }
--------------------------------------------------------------------------------
/src/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers, createStore } from 'redux'
2 | import { reducer as formReducer } from 'redux-form'
3 |
4 | import navigation from './navigation'
5 | import auth from './auth'
6 | import cache from './cache'
7 | import navbar from './navbar'
8 | import editor from './editor'
9 | import yourStories from './your-stories'
10 | import story from './story'
11 | import stories from './stories'
12 | import generic from './generic'
13 |
14 | import { unauthorizeUser } from '../actions/auth'
15 | import { receiveMockState } from '../actions/mock'
16 |
17 | const form = () => formReducer
18 |
19 | const getNewReducer = _ =>
20 | combineReducers(
21 | Object.entries({
22 | navigation,
23 | auth,
24 | cache,
25 | form,
26 | navbar,
27 | editor,
28 | yourStories,
29 | story,
30 | stories,
31 | generic
32 | }).reduce(
33 | (acc, [key, value]) => ({
34 | ...acc,
35 | [key]: value()
36 | }),
37 | {}
38 | )
39 | )
40 |
41 | const reducer = getNewReducer()
42 |
43 | export default (state, action) => {
44 | if (action.type === unauthorizeUser.getType()) {
45 | return reducer(createStore(getNewReducer()).getState())
46 | }
47 |
48 | if (action.type === receiveMockState.getType()) {
49 | return reducer(action.payload)
50 | }
51 |
52 | return reducer(state, action)
53 | }
54 |
--------------------------------------------------------------------------------
/src/sagas/your-stories.js:
--------------------------------------------------------------------------------
1 | import { select, put } from 'redux-saga/effects'
2 | import { callWith401Handle } from './api'
3 | import { DRAFTS, USER_STORIES, DELETE_STORY, SHARED } from '../constants/api'
4 | import { get, del } from '../utils/api'
5 | import { receiveStoriesForTab } from '../actions/your-stories';
6 | import { receiveStoryForEdit } from '../actions/editor';
7 | import { to } from '../actions/navigation'
8 | import { allStories } from '../reducers/editor';
9 |
10 | export function* selectTab({ payload }) {
11 | const { yourStories, auth: { id } } = yield select()
12 | if (yourStories[payload]) return
13 |
14 | const endpoint = payload === 'drafts' ? DRAFTS : payload === 'shared' ? SHARED : USER_STORIES(id)
15 | const response = yield callWith401Handle(get, endpoint)
16 | const actionPayload = { stories: payload === 'shared' ? response.usersDrafts : response.stories, tab: payload }
17 | yield put(receiveStoriesForTab(actionPayload))
18 | }
19 |
20 | export function* remove({ payload }) {
21 | yield callWith401Handle(del, DELETE_STORY, payload)
22 | }
23 |
24 | export function* edit({ payload }) {
25 | const { yourStories: { drafts, published, shared, tab } } = yield select()
26 |
27 | const story = allStories({ drafts, published, shared }).find(story => story.id === payload)
28 | yield put(receiveStoryForEdit({ ...story, owner: tab !== 'shared' }))
29 | yield put(to('editor'))
30 | }
--------------------------------------------------------------------------------
/src/components/editor/link-dialog.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Button from '@material-ui/core/Button';
3 | import TextField from '@material-ui/core/TextField';
4 | import Dialog from '@material-ui/core/Dialog';
5 | import DialogActions from '@material-ui/core/DialogActions';
6 | import DialogContent from '@material-ui/core/DialogContent';
7 | import DialogTitle from '@material-ui/core/DialogTitle';
8 |
9 | import { connectTo } from '../../utils/generic'
10 | import * as actions from '../../actions/editor'
11 |
12 | export default connectTo(
13 | state => state.editor,
14 | actions,
15 | ({ changeLink, exitLinkPrompt, submitLink }) => (
16 |
43 | )
44 | )
--------------------------------------------------------------------------------
/src/components/auth/auth-form.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 | import { Paper, Button } from '@material-ui/core'
4 |
5 | import Logo from '../logo'
6 | import Page from '../page-wrapper'
7 | import { submitAsyncValidation} from '../../utils/forms'
8 |
9 | const Form = styled.form`
10 | display: flex;
11 | flex-direction: column;
12 | `
13 |
14 | const Container = styled(Paper)`
15 | width: 320px;
16 | padding: 20px;
17 | `
18 |
19 |
20 | const SubmitButton = styled(Button)`
21 | && {
22 | margin-top: 20px;
23 | }
24 | `
25 |
26 |
27 | const BottomText = styled(Button)`
28 | && {
29 | margin-top: 10px;
30 | }
31 | `
32 |
33 | export default ({ handleSubmit, enabledSubmit, onSubmit, submitText, onBottomTextClick, bottomText, fields }) => {
34 | const pageStyle = {
35 | height: '100vh',
36 | display: 'flex',
37 | justifyContent: 'center',
38 | alignItems: 'center',
39 | flexDirection: 'column'
40 | }
41 | return (
42 |
43 |
44 |
51 |
52 | {bottomText}
53 |
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/src/components/editor/share-dialog.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Button from '@material-ui/core/Button';
3 | import TextField from '@material-ui/core/TextField';
4 | import Dialog from '@material-ui/core/Dialog';
5 | import DialogActions from '@material-ui/core/DialogActions';
6 | import DialogContent from '@material-ui/core/DialogContent';
7 | import DialogTitle from '@material-ui/core/DialogTitle';
8 |
9 | import { connectTo } from '../../utils/generic'
10 | import * as actions from '../../actions/editor'
11 |
12 | export default connectTo(
13 | state => state.editor,
14 | actions,
15 | ({ changeUserToShareName, toggleShareDialog, share }) => (
16 |
43 | )
44 | )
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "front",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@aspnet/signalr": "^1.0.4",
7 | "@fortawesome/fontawesome": "^1.1.8",
8 | "@fortawesome/fontawesome-free-solid": "^5.0.13",
9 | "@fortawesome/fontawesome-svg-core": "^1.2.4",
10 | "@fortawesome/react-fontawesome": "^0.1.3",
11 | "@material-ui/core": "^3.1.0",
12 | "@material-ui/icons": "^3.0.1",
13 | "immutable": "^3.8.2",
14 | "lodash": "^4.17.11",
15 | "luxon": "^1.4.2",
16 | "react": "^16.5.2",
17 | "react-document-title": "^2.0.3",
18 | "react-dom": "^16.5.2",
19 | "react-hotkeys": "^1.1.4",
20 | "react-redux": "^5.0.7",
21 | "react-scripts": "1.1.5",
22 | "redux": "^4.0.0",
23 | "redux-act": "^1.7.4",
24 | "redux-form": "^7.4.2",
25 | "redux-saga": "^0.16.0",
26 | "slate": "^0.41.2",
27 | "slate-react": "^0.18.10",
28 | "styled-components": "^3.4.9"
29 | },
30 | "scripts": {
31 | "start": "react-scripts start",
32 | "build": "react-scripts build",
33 | "test": "react-scripts test --env=jsdom",
34 | "eject": "react-scripts eject",
35 | "mockrun": "REACT_APP_MOCK=true react-scripts start"
36 | },
37 | "devDependencies": {
38 | "lint-staged": "^7.3.0",
39 | "prettier": "^1.14.3",
40 | "redux-devtools-extension": "^2.13.5"
41 | },
42 | "lint-staged": {
43 | "*.{js,json}": [
44 | "prettier --single-quote --write",
45 | "git add"
46 | ]
47 | },
48 | "prettier": {
49 | "semi": false
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/sagas/editor.js:
--------------------------------------------------------------------------------
1 | import { put, select } from 'redux-saga/effects'
2 |
3 | import { CREATE_STORY, UPDATE_STORY, PUBLISH_STORY, SHARE } from '../constants/api'
4 | import { post, patch } from '../utils/api'
5 | import { callWith401Handle } from './api'
6 | import { successfulSave, successfulCreation } from "../actions/editor";
7 | import { toStory } from '../actions/navigation'
8 |
9 | export function* storyUpdatePayload() {
10 | const { editor: { title, content, tags } } = yield select()
11 | return {
12 | title,
13 | content: JSON.stringify(content.toJSON()),
14 | tags
15 | }
16 | }
17 |
18 | export function* save() {
19 | const { editor: { storyId } } = yield select()
20 | const payload = yield storyUpdatePayload()
21 | if (!storyId) {
22 |
23 | const { storyId } = yield callWith401Handle(post, CREATE_STORY, payload)
24 | yield put(successfulCreation(storyId))
25 | } else {
26 | yield callWith401Handle(patch, UPDATE_STORY(storyId), payload)
27 | yield put(successfulSave())
28 | }
29 | }
30 |
31 | export function* publish() {
32 | const { editor: { changesSaved, storyId } } = yield select()
33 | if (!changesSaved) {
34 | yield save()
35 | }
36 | yield callWith401Handle(post, PUBLISH_STORY(storyId))
37 | yield put(toStory(storyId))
38 | }
39 |
40 | export function* share() {
41 | const { editor: { userToShareName, storyId } } = yield select()
42 | try {
43 | yield callWith401Handle(post, SHARE(storyId), { username: userToShareName })
44 | } catch(err) {
45 | // to: for now simply print error in console
46 | console.info('fail to share: ', err)
47 | }
48 | }
--------------------------------------------------------------------------------
/src/components/auth/login.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Field, reduxForm } from 'redux-form'
3 |
4 | import { submitLogin } from '../../actions/auth'
5 | import { to } from '../../actions/navigation'
6 | import { required, email, minLength6, lengthLessThan40 } from '../../validators/forms';
7 | import { connectTo } from '../../utils/generic';
8 | import { isValid} from '../../utils/forms'
9 | import TextField from './text-field'
10 | import AuthForm from './auth-form'
11 |
12 | export default connectTo(
13 | state => ({
14 | enabledSubmit: isValid(state, 'login')
15 | }),
16 | { to, submitLogin },
17 | reduxForm({ form: 'login' })(
18 | ({
19 | handleSubmit,
20 | enabledSubmit,
21 | submitLogin,
22 | to
23 | }) => {
24 | const fields = [
25 | ,
33 |
41 | ]
42 | return (
43 | to('register')}
50 | bottomText="Don't have an account? Register"
51 | />
52 | )
53 | }
54 | )
55 | )
--------------------------------------------------------------------------------
/src/components/editor/node.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 | import { purple } from '@material-ui/core/colors'
4 |
5 | import { switchCase } from '../../utils/generic';
6 | import { BLOCKS} from '../../constants/editor'
7 |
8 |
9 | const Quote = styled.blockquote`
10 | border-left: 2px solid #ddd;
11 | margin-left: 0;
12 | margin-right: 0;
13 | padding-left: 10px;
14 | color: #aaa;
15 | font-style: italic;
16 | `
17 |
18 | const HeadingOne = styled.h1`
19 | padding: 10px 0;
20 | `
21 |
22 | const HeadingTwo = styled.h2`
23 | padding: 8px 0;
24 | `
25 |
26 | const BulletedList = styled.ul`
27 | margin: 0 40px;
28 | `
29 |
30 | const NumberedList = styled.ol`
31 | padding: 0 40px;
32 | `
33 |
34 | const Image = styled.img`
35 | max-width: 100%;
36 | max-height: 20em;
37 | box-shadow: ${props => (props.selected ? `0 0 0 2px ${purple[200]}` : 'none')};
38 | `
39 |
40 |
41 | export default ({ attributes, children, node, isFocused }) => switchCase(
42 | {
43 | [BLOCKS.QUOTE]: () => {children}
,
44 | [BLOCKS.HEADING_ONE]: () => {children},
45 | [BLOCKS.HEADING_TWO]: () => {children},
46 | [BLOCKS.BULLETED_LIST]: () => {children},
47 | [BLOCKS.NUMBERED_LIST]: () => {children},
48 | [BLOCKS.LINK]: () => {children},
49 | [BLOCKS.IMAGE]: () => ,
50 | 'list-item': () => {children}
51 | },
52 | node.type,
53 | () => null,
54 | )
--------------------------------------------------------------------------------
/src/utils/api.js:
--------------------------------------------------------------------------------
1 | class RequestError {
2 | constructor(status, message) {
3 | this.status = status
4 | this.message = message
5 | }
6 | }
7 |
8 | export const headers = () => ({
9 | 'Content-Type': 'application/json',
10 | Authorization: `Bearer ${localStorage.getItem('token')}`
11 | })
12 |
13 | export const makePostOptions = data => ({
14 | method: 'POST',
15 | mode: 'cors',
16 | headers: headers(),
17 | body: JSON.stringify(data)
18 | })
19 |
20 | export const makePatchOptions = data => ({
21 | ...makePostOptions(data),
22 | method: 'PATCH'
23 | })
24 |
25 | export const getOptions = () => ({
26 | method: 'GET',
27 | headers: headers()
28 | })
29 |
30 | export const deleteOptions = () => ({
31 | method: 'DELETE',
32 | mode: 'cors',
33 | headers: headers()
34 | })
35 |
36 | const request = (url, options) =>
37 | fetch(url, options).then(response => {
38 | const { status } = response
39 |
40 | if (status === 204) return {}
41 | const json = response.json()
42 | if (status >= 200 && status < 400) return json
43 | return json.then(message => {
44 | throw new RequestError(status, message)
45 | })
46 | })
47 |
48 | export const plainGet = url =>
49 | request(url, {
50 | method: 'GET',
51 | header: { 'Content-Type': 'application/json' }
52 | })
53 | export const plainPost = (url, data) =>
54 | request(url, {
55 | method: 'POST',
56 | body: JSON.stringify(data)
57 | })
58 | export const get = url => request(url, getOptions())
59 | export const post = (url, data) => request(url, makePostOptions(data))
60 | export const patch = (url, data) => request(url, makePatchOptions(data))
61 | export const del = (url, id) => request(url + id, deleteOptions())
62 |
--------------------------------------------------------------------------------
/src/reducers/cache.js:
--------------------------------------------------------------------------------
1 | import { createReducer } from 'redux-act'
2 |
3 | import * as a from '../actions/cache'
4 | import { receiveStoriesForTab, clear as clearYourStories } from '../actions/your-stories'
5 | import { receiveStory } from '../actions/story';
6 | import { receiveStories } from '../actions/stories'
7 |
8 | export const getDefaultState = _ => ({
9 | stateReceived: {
10 | login: true,
11 | register: true,
12 | editor: true,
13 | yourStories: false,
14 | story: false,
15 | stories: false
16 | }
17 | })
18 |
19 | const changeStateReceived = (state, page, value) => ({
20 | ...state,
21 | stateReceived: {
22 | ...state.stateReceived,
23 | [page]: value
24 | }
25 | })
26 |
27 |
28 | export default _ =>
29 | createReducer(
30 | {
31 | // only for sagas usage
32 | [a.updateState]: (state, newState) => ({ ...state, ...newState }),
33 | [a.saveCache]: (state, { page, projectId, pageState }) => ({
34 | ...state,
35 | stateReceived: {
36 | ...state.stateReceived,
37 | [page]: false
38 | },
39 | [page]: {
40 | ...state[page],
41 | [projectId]: pageState
42 | },
43 | }),
44 | [a.removeStateReceivedFrom]: (state, page) => ({
45 | ...state,
46 | stateReceived: {
47 | ...state.stateReceived,
48 | [page]: false
49 | }
50 | }),
51 | [receiveStoriesForTab]: state => changeStateReceived(state, 'yourStories', true),
52 | [clearYourStories]: state => changeStateReceived(state, 'yourStories', false),
53 | [receiveStory]: state => changeStateReceived(state, 'story', true),
54 | [receiveStories]: state => changeStateReceived(state, 'stories', true)
55 | },
56 | getDefaultState()
57 | )
58 |
--------------------------------------------------------------------------------
/src/validators/forms.js:
--------------------------------------------------------------------------------
1 | import _ from 'lodash'
2 |
3 | export const EMAIL_REGEX = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
4 |
5 | export const WEBSITE_REGEX = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/
6 |
7 | const minLength = min => value =>
8 | value && value.length < min ? `Length should be more than ${min}` : undefined
9 |
10 | export const required = value => (value ? undefined : 'Required')
11 |
12 | export const moreThan = (
13 | limit,
14 | message = `Should be more than ${limit}`
15 | ) => n => (n > limit ? undefined : message)
16 |
17 | export const lessThan = (
18 | limit,
19 | message = `Should be less than ${limit}`
20 | ) => n => (n < limit ? undefined : message)
21 |
22 | export const integer = n =>
23 | n.toString().includes('.') ? 'Should be integer' : undefined
24 |
25 | export const lengthMoreThan = (
26 | limit,
27 | message = `Length should be more than ${limit}`
28 | ) => str => (!str || str.length < limit ? message : undefined)
29 |
30 | export const lengthLessThan = (
31 | limit,
32 | message = `Length should be less than ${limit}`
33 | ) => str => (!str || str.length > limit ? message : undefined)
34 |
35 | export const minLength6 = minLength(6)
36 |
37 | export const minLenght4 = minLength(4)
38 |
39 | export const lengthLessThan40 = lengthLessThan(40)
40 |
41 | export const email = value =>
42 | !value || !EMAIL_REGEX.test(value) ? 'Invalid email address' : undefined
43 |
44 | export const uniqueAmong = (values, message = 'Should be unique') => v =>
45 | v && _.includes(values, v) ? message : undefined
46 |
47 | export const website = value =>
48 | value && !WEBSITE_REGEX.test(value) ? 'Invalid website' : undefined
49 |
--------------------------------------------------------------------------------
/src/components/editor/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 |
4 | import Page from '../page-wrapper'
5 | import EffectsMenu from './effects-menu'
6 | import Title from './title'
7 | import Save from './save'
8 | import Publish from './publish'
9 | import Share from './share'
10 | import Editor from './editor'
11 | import LinkDialog from './link-dialog'
12 | import TagsDialog from './tags-dialog'
13 | import ShareDialog from './share-dialog'
14 | import { connectTo } from '../../utils/generic';
15 | import ContentContainer from '../content-container'
16 |
17 | const SIDE_PADDING = 50;
18 |
19 | const TopLine = styled.div`
20 | width: 100%;
21 | height: 80px;
22 | display: flex;
23 | flex-direction: row;
24 | justify-content: space-between;
25 | align-items: center;
26 | padding: 0 ${SIDE_PADDING}px;
27 | `
28 |
29 | const Right = styled.div`
30 | display: flex;
31 | flex-direction: row;
32 | `
33 |
34 | const Space = styled.div`
35 | width: 20px;
36 | `
37 |
38 | export default connectTo(
39 | state => state.editor,
40 | {},
41 | ({ linkPrompt, tagsMenuOpen, shareDialogOpen, owner }) => {
42 | return (
43 |
44 |
45 |
46 | {owner ? (
47 |
48 |
49 |
50 |
51 |
52 | ) :
53 | }
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 | { linkPrompt && }
62 | { tagsMenuOpen && }
63 | { shareDialogOpen && }
64 |
65 | )
66 | }
67 | )
68 |
--------------------------------------------------------------------------------
/src/components/auth/register.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Field, reduxForm } from 'redux-form'
3 |
4 | import { submitRegister } from '../../actions/auth'
5 | import { to } from '../../actions/navigation'
6 | import { required, email, minLength6, lengthLessThan40, minLenght4 } from '../../validators/forms';
7 | import { connectTo } from '../../utils/generic';
8 | import { isValid} from '../../utils/forms'
9 | import TextField from './text-field'
10 | import AuthForm from './auth-form'
11 |
12 | export default connectTo(
13 | state => ({
14 | enabledSubmit: isValid(state, 'register')
15 | }),
16 | { to, submitRegister },
17 | reduxForm({ form: 'register' })(
18 | ({
19 | handleSubmit,
20 | enabledSubmit,
21 | submitRegister,
22 | to
23 | }) => {
24 | const fields = [
25 | ,
33 | ,
41 |
49 | ]
50 | return (
51 | to('login')}
58 | bottomText="Already have an account? Login"
59 | />
60 | )
61 | }
62 | )
63 | )
--------------------------------------------------------------------------------
/src/layouts/main.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import Navbar from '../components/navbar'
4 |
5 | import * as pages from '../pages'
6 |
7 | import { to } from '../actions/navigation'
8 | import { moveMouse, changePageSize } from '../actions/generic'
9 |
10 | import { connectTo } from '../utils/generic'
11 | import { PAGES_WITH_NAVBAR } from '../constants/navigation'
12 |
13 | import styled from 'styled-components'
14 |
15 | const Layout = styled.div`
16 | min-height: 100%;
17 | display: flex;
18 | flex-direction: column;
19 | align-items: stretch;
20 | `
21 |
22 | class MainLayout extends React.Component {
23 | render() {
24 | const { page } = this.props
25 | if (!page) return 'No page was specified'
26 | const Page = pages[page]
27 | return (
28 |
29 | {PAGES_WITH_NAVBAR.includes(page) && }
30 |
31 |
32 | )
33 | }
34 |
35 | // no need to remove event listeneres declared in this block
36 | // since it will not be unmounted
37 | componentDidMount() {
38 | const { moveMouse, changePageSize } = this.props
39 | window.addEventListener('popstate', this.popstate)
40 | window.addEventListener('resize', () =>
41 | changePageSize({ width: window.innerWidth, height: window.innerHeight })
42 | )
43 | document.addEventListener('mousemove', ({ clientX, clientY }) =>
44 | moveMouse({ mouseX: clientX, mouseY: clientY })
45 | )
46 | }
47 |
48 | // to: custom back button handle
49 | popstate = () => {
50 | // const { page, currentProject, to } = this.props
51 | // if (page === 'Dashboard') return
52 |
53 | // window.history.pushState({ }, '', '')
54 | // to((page === 'ProjectDetails' || !currentProject) ? 'Dashboard' : 'ProjectDetails')
55 | }
56 | }
57 |
58 | export default connectTo(
59 | state => ({
60 | page: state.navigation.page
61 | }),
62 | { moveMouse, changePageSize, to },
63 | MainLayout
64 | )
65 |
--------------------------------------------------------------------------------
/src/components/page-wrapper.js:
--------------------------------------------------------------------------------
1 | import _ from 'lodash'
2 | import DocumentTitle from 'react-document-title'
3 | import CircularProgress from '@material-ui/core/CircularProgress';
4 | import styled from 'styled-components'
5 |
6 | import React from 'react'
7 | import { HotKeys } from 'react-hotkeys'
8 | import { connectTo } from '../utils/generic'
9 | import { enterPage, exitPage } from '../actions/generic'
10 |
11 | import Snackbar from './snackbar'
12 |
13 |
14 | const Loading = styled.div`
15 | height: 100vh;
16 | width: 100%;
17 | display: flex;
18 | justify-content: center;
19 | align-items: center;
20 | `
21 |
22 | class PageWrapper extends React.Component {
23 | render() {
24 | const {
25 | children,
26 | keyMap,
27 | handlers,
28 | stateReceived,
29 | page,
30 | documentTitle = 'Simple Blog',
31 | style
32 | } = this.props
33 | this.page = page
34 | return stateReceived || process.env.REACT_APP_MOCK ? (
35 |
36 | {_.isEmpty(keyMap) ? (
37 |
38 |
39 | {children}
40 |
41 | ) : (
42 |
48 |
49 | {children}
50 |
51 | )}
52 |
53 | ) : (
54 |
55 |
56 |
57 | )
58 | }
59 |
60 | componentDidMount() {
61 | if (!process.env.REACT_APP_MOCK) this.props.enterPage()
62 | }
63 |
64 | componentWillUnmount() {
65 | if (!process.env.REACT_APP_MOCK) this.props.exitPage(this.page)
66 | }
67 | }
68 |
69 | export default connectTo(
70 | state => ({
71 | page: state.navigation.page,
72 | stateReceived: state.cache.stateReceived[state.navigation.page]
73 | }),
74 | { enterPage, exitPage },
75 | PageWrapper
76 | )
77 |
--------------------------------------------------------------------------------
/src/components/navbar.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { AppBar, Toolbar, Menu, MenuItem, IconButton } from '@material-ui/core'
3 | import { AccountCircle } from '@material-ui/icons'
4 | import styled from 'styled-components'
5 |
6 | import * as actions from '../actions/navbar'
7 | import { to } from '../actions/navigation'
8 | import { unauthorizeUser } from '../actions/auth'
9 | import { connectTo } from '../utils/generic'
10 |
11 | import Logo from './logo'
12 |
13 |
14 | const StyledToolbar = styled(Toolbar)`
15 | display: flex;
16 | flex-direction: row;
17 | justify-content: space-between;
18 | `
19 |
20 | const Navbar = ({ to, unauthorizeUser, dropdownOpen, dropdownAnchor, toggleDropdown }) => {
21 | const itemHandler = func => () => {
22 | toggleDropdown()
23 | func()
24 | }
25 | return (
26 |
27 |
28 | to('stories')}/>
29 |
30 |
toggleDropdown(currentTarget)}
34 | color="inherit"
35 | >
36 |
37 |
38 |
56 |
57 |
58 |
59 | )
60 | }
61 |
62 | export default connectTo(
63 | state => state.navbar,
64 | { ...actions, to, unauthorizeUser },
65 | Navbar
66 | )
--------------------------------------------------------------------------------
/src/utils/generic.js:
--------------------------------------------------------------------------------
1 | import _ from 'lodash'
2 | import { clone, setWith, curry } from 'lodash/fp'
3 |
4 | import { connect } from 'react-redux'
5 | import { bindActionCreators } from 'redux'
6 |
7 | export const connectTo = (mapStateToProps, actions, Container) => {
8 | const mapDispatchToProps = dispatch => bindActionCreators(actions, dispatch)
9 | return connect(
10 | mapStateToProps,
11 | mapDispatchToProps
12 | )(Container)
13 | }
14 |
15 | export const callIf = (condition, func) => (condition ? func : _ => null)
16 |
17 | export const getUniqueName = (name, otherNames) => {
18 | const suffixes = otherNames
19 | .filter(n => n.startsWith(name))
20 | .map(n => n.slice(name.length))
21 | if (!suffixes.includes('')) return name
22 |
23 | let number = 1
24 | while (number) {
25 | const strNumber = number.toString()
26 | if (!suffixes.includes(strNumber)) return name + strNumber
27 | number++
28 | }
29 | }
30 |
31 | export const takeFromState = (state, stateObjectName, fields) =>
32 | _.pick(state[stateObjectName], fields)
33 |
34 | export const setIn = curry((obj, path, value) =>
35 | setWith(clone, path, value, clone(obj))
36 | )
37 |
38 | export const firstUpperWords = (text, length) =>
39 | text
40 | .split(' ')
41 | .map(element => element[0].toUpperCase())
42 | .splice(0, length)
43 |
44 | export const sliceWithDots = (text, length) => {
45 | return text.length > length ? text.slice(0, length) + '...' : text
46 | }
47 |
48 | export const pluralize = (text, amount) => {
49 | return amount === 1 ? text : text + 's'
50 | }
51 |
52 | export const pascalToText = text =>
53 | _.capitalize(text.replace(/([A-Z][a-z])/g, ' $1').replace(/(\d)/g, ' $1'))
54 |
55 | export const snakeToText = text => _.capitalize(text.split('_').join(' '))
56 | export const def = v => v !== undefined
57 |
58 | export const logArgs = func => (...args) => {
59 | console.info(`${func.name} args: `, ...args)
60 | return func(...args)
61 | }
62 |
63 | export const switchCase = (cases, key, defaultCase) => {
64 | const func = cases[key]
65 | return func ? func() : defaultCase()
66 | }
67 |
68 | export const noPropogation = func => e => {
69 | e.stopPropagation()
70 | func()
71 | }
--------------------------------------------------------------------------------
/src/components/editor/tags-dialog.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components'
3 | import { TextField, Dialog, DialogActions, DialogContent, DialogTitle, Button, Paper } from '@material-ui/core';
4 |
5 | import { connectTo } from '../../utils/generic'
6 | import * as actions from '../../actions/editor'
7 | import { TAGS_LIMIT } from '../../constants/editor';
8 | import Tag from '../tag'
9 |
10 | const InputLine = styled.div`
11 | display: flex;
12 | flex-direction: row;
13 | align-items: center;
14 | `
15 |
16 | const ChipsContainer = styled(Paper)`
17 | && {
18 | margin: 10px 0;
19 | display: flex;
20 | flex-wrap: wrap;
21 | align-items: center;
22 | justify-content: center;
23 | }
24 | `
25 |
26 | export default connectTo(
27 | state => state.editor,
28 | actions,
29 | ({ editTag, submitTag, deleteTag, tags, editingTag, toggleTagsMenu, publish }) => (
30 |
73 | )
74 | )
--------------------------------------------------------------------------------
/src/components/editor/effects-menu.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { MenuList, MenuItem, Paper } from '@material-ui/core'
3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
4 | import {
5 | faBold,
6 | faItalic,
7 | faQuoteRight,
8 | faLink,
9 | faCode,
10 | faListOl,
11 | faListUl,
12 | faHeading,
13 | faImage
14 | } from '@fortawesome/fontawesome-free-solid'
15 | import styled from 'styled-components'
16 |
17 | import * as actions from '../../actions/editor'
18 | import { connectTo } from '../../utils/generic'
19 | import { MARKS, BLOCKS } from '../../constants/editor'
20 |
21 | const Container = styled(Paper)`
22 | width: 50px;
23 | position: fixed;
24 | right: 20px;
25 | bottom: 20px;
26 | `
27 |
28 | const Effect = styled(MenuItem)`
29 | && {
30 | display: flex;
31 | justify-content: center;
32 | }
33 | `
34 |
35 | export default connectTo(
36 | state => state.editor,
37 | actions,
38 | ({ toggleEffect, content, linkPrompt }) => {
39 | const isSelected = type => {
40 | if (Object.values(MARKS).includes(type)) {
41 | return content.activeMarks.some(mark => mark.type === type)
42 | }
43 | if (type === BLOCKS.IMAGE) return linkPrompt === BLOCKS.IMAGE
44 | if (type === BLOCKS.LINK) return content.inlines.some(inline => inline.type === BLOCKS.LINK)
45 |
46 | return content.blocks.some(node => node.type === type)
47 | }
48 | const items = [
49 | [faBold, MARKS.BOLD],
50 | [faItalic, MARKS.ITALIC],
51 | [faCode, MARKS.CODE],
52 | [faLink, BLOCKS.LINK],
53 | [faImage, BLOCKS.IMAGE],
54 | [faHeading, BLOCKS.HEADING_ONE],
55 | [faHeading, BLOCKS.HEADING_TWO],
56 | [faListOl, BLOCKS.NUMBERED_LIST],
57 | [faListUl, BLOCKS.BULLETED_LIST],
58 | [faQuoteRight, BLOCKS.QUOTE],
59 | ].map(([ icon, effect ]) => (
60 | toggleEffect(effect)}
64 | >
65 |
69 |
70 | ))
71 |
72 | return (
73 |
74 |
75 | {items}
76 |
77 |
78 | )
79 | }
80 | )
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 |
12 |
13 |
14 |
15 |
16 |
25 | React App
26 |
27 |
28 |
31 |
32 |
42 |
43 |
64 |
65 |
--------------------------------------------------------------------------------
/src/components/story/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Editor } from 'slate-react'
3 | import { Typography, IconButton } from '@material-ui/core'
4 | import { Favorite, FavoriteBorder } from '@material-ui/icons'
5 | import styled from 'styled-components'
6 |
7 | import { connectTo } from '../../utils/generic';
8 |
9 | import ContentContainer from '../content-container'
10 | import Mark from '../editor/mark'
11 | import Node from '../editor/node'
12 | import Page from '../page-wrapper'
13 | import Tag from '../tag'
14 | import { timestampForHuman } from '../../utils/time';
15 | import * as actions from '../../actions/story'
16 |
17 | const Info = styled.div`
18 | margin: 20px;
19 | `
20 |
21 | const Chips = styled.div`
22 | display: flex;
23 | flex-direction: row;
24 | `
25 |
26 | const Likes = styled.div`
27 | display: flex;
28 | flex-direction: row;
29 | align-items: center;
30 | `
31 |
32 | const LikesNumber = styled.p`
33 | margin-left: 10px;
34 | `
35 |
36 | export default connectTo(
37 | state => ({
38 | ...state.story,
39 | userId: state.auth.id
40 | }),
41 | actions,
42 | ({ title, content, publishTime, ownerUsername, ownerId, tags, userId, likesNumber, liked, toggleLike }) => {
43 | return (
44 |
45 |
46 |
47 |
48 | author: {ownerUsername}
49 |
50 |
51 | {timestampForHuman(publishTime)}
52 |
53 |
54 | {title}
55 | { content && (
56 |
63 | )}
64 |
65 | {
66 | tags.map(tag => (
67 |
71 | ))
72 | }
73 |
74 |
75 |
79 | {liked ? : }
80 |
81 | {likesNumber}
82 |
83 |
84 |
85 | )
86 | }
87 | )
--------------------------------------------------------------------------------
/src/components/your-stories/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 | import { AppBar, Tabs, Tab, Typography } from '@material-ui/core'
4 |
5 | import { connectTo } from '../../utils/generic'
6 | import * as actions from '../../actions/your-stories'
7 | import StoryCard from './story-card'
8 | import Page from '../page-wrapper'
9 | import { toStory } from '../../actions/navigation';
10 | import { timestampForHuman } from '../../utils/time';
11 |
12 | import StoriesContainer from '../stories-container'
13 |
14 | const UsernameLine = styled.div`
15 | display: flex;
16 | justify-content: center;
17 | align-items: center;
18 | margin: 20px;
19 | `
20 |
21 | export default connectTo(
22 | state => state.yourStories,
23 | ({ ...actions, toStory }),
24 | ({ selectTab, remove, drafts, published, shared, tab, edit, toStory }) => {
25 | const stories = { drafts, published, shared }[tab]
26 | const tabs = ['drafts', 'published', 'shared']
27 | const value = tabs.indexOf(tab)
28 | const renderStories = stories => (
29 |
30 | {
31 | stories.map((story, number) => {
32 | const dateValue = story[tab === 'published' ? 'publishTime' : 'lastEditTime']
33 | const date = timestampForHuman(dateValue)
34 | const dateText = `${tab === 'published' ? 'Published on' : 'Last edit'} ${date}`
35 | return (
36 | edit(story.id)}
41 | onDelete={tab !== 'shared' ? () => remove(story.id) : undefined}
42 | onClick={() => tab === 'published' ? toStory(story.id) : edit(story.id) }
43 | />
44 | )
45 | })
46 |
47 | }
48 |
49 | )
50 |
51 | return (
52 |
53 |
54 | selectTab(tabs[v])}>
55 |
56 |
57 |
58 |
59 |
60 | {stories && (tab === 'shared' ?
61 | stories.map(({ username, drafts }) => (
62 |
63 |
64 |
65 | {username} drafts
66 |
67 |
68 | {renderStories(drafts)}
69 |
70 | ))
71 | : renderStories(stories)
72 | )}
73 |
74 | )
75 | }
76 | )
--------------------------------------------------------------------------------
/src/utils/array-extensions.js:
--------------------------------------------------------------------------------
1 | import _ from 'lodash'
2 |
3 | /*eslint no-extend-native: */
4 | const extensions = [
5 | [
6 | 'last_',
7 | function() {
8 | return this.length > 0 ? this[this.length - 1] : undefined
9 | }
10 | ],
11 | [
12 | 'take_',
13 | function(property) {
14 | return this.map(el => el[property])
15 | }
16 | ],
17 | [
18 | 'some_',
19 | function(method, ...args) {
20 | return this.some(el => el[method](...args))
21 | }
22 | ],
23 | [
24 | 'every_',
25 | function(method, ...args) {
26 | return this.every(el => el[method](...args))
27 | }
28 | ],
29 | [
30 | 'map_',
31 | function(method, ...args) {
32 | return this.map(el => el[method](...args))
33 | }
34 | ],
35 | [
36 | 'find_',
37 | function(method, ...args) {
38 | return this.find(el => el[method](...args))
39 | }
40 | ],
41 | [
42 | 'without_',
43 | function(...args) {
44 | return this.reduce(
45 | (acc, el) => (args.includes(el) ? acc : [...acc, el]),
46 | []
47 | )
48 | }
49 | ],
50 | [
51 | 'flatten_',
52 | function() {
53 | return this.reduce((acc, el) => [...acc, ...el], [])
54 | }
55 | ],
56 | [
57 | 'sum_',
58 | function() {
59 | return this.reduce((acc, el) => acc + el, 0)
60 | }
61 | ],
62 | [
63 | 'empty_',
64 | function() {
65 | return this.length === 0
66 | }
67 | ],
68 | [
69 | 'withoutLast_',
70 | function() {
71 | return this.slice(0, this.length - 1)
72 | }
73 | ],
74 | [
75 | 'previous_',
76 | function(item) {
77 | if (this.length < 2) return
78 |
79 | const index = this.indexOf(item)
80 | if (index < 0) return
81 |
82 | return index === 0 ? this.last_() : this[index - 1]
83 | }
84 | ],
85 | [
86 | 'next_',
87 | function(item) {
88 | if (this.length < 2) return
89 |
90 | const index = this.indexOf(item)
91 | if (index < 0) return
92 |
93 | return index === this.length - 1 ? this[0] : this[index + 1]
94 | }
95 | ],
96 | [
97 | 'sameAs_',
98 | function(other) {
99 | return _.isEqual(this.sort(), other.sort())
100 | }
101 | ],
102 | [
103 | 'pushIf_',
104 | function(condition, item) {
105 | return condition ? [...this, item] : this
106 | }
107 | ],
108 | [
109 | 'orderBy_',
110 | function(prop) {
111 | return _.orderBy(this, [prop], ['ask'])
112 | }
113 | ],
114 | [
115 | 'uniq_',
116 | function() {
117 | return _.uniq(this)
118 | }
119 | ],
120 | [
121 | 'reverse_',
122 | function() {
123 | return this.reduce((acc, item) => [item, ...acc], [])
124 | }
125 | ],
126 | [
127 | 'withoutOnce_',
128 | function(element) {
129 | const index = this.indexOf(element)
130 | return [...this.slice(0, index), ...this.slice(index + 1)]
131 | }
132 | ],
133 | [
134 | 'replace_',
135 | function(oldElement, newElement) {
136 | return this.map(
137 | element => (element === oldElement ? newElement : element)
138 | )
139 | }
140 | ],
141 | [
142 | 'replaceAtIndex_',
143 | function(index, element) {
144 | return [...this.slice(0, index), element, ...this.slice(index + 1)]
145 | }
146 | ],
147 | [
148 | 'removeAtIndex_',
149 | function(index) {
150 | return [...this.slice(0, index), ...this.slice(index + 1)]
151 | }
152 | ],
153 | [
154 | 'withoutUndef_',
155 | function() {
156 | return this.filter(el => el !== undefined)
157 | }
158 | ],
159 | [
160 | 'sameAs_',
161 | function(other) {
162 | return _.isEqual(this.sort(), other.sort())
163 | }
164 | ],
165 | [
166 | 'allTrue_',
167 | function() {
168 | return this.every(v => v)
169 | }
170 | ]
171 | ]
172 | for (const [name, func] of extensions) {
173 | Array.prototype[name] = func
174 | }
175 |
176 | export default {}
177 |
--------------------------------------------------------------------------------
/src/sagas/generic.js:
--------------------------------------------------------------------------------
1 | import { select, put, call, take, spawn } from 'redux-saga/effects'
2 | import { delay, eventChannel } from 'redux-saga';
3 |
4 | import { TICK } from '../constants/generic'
5 | import { tick as tickAction, toggleSnackbar } from '../actions/generic';
6 | import { SAVE_PERIOD } from '../constants/editor';
7 | import { save, clear as clearEditor, updateStory } from '../actions/editor';
8 | import { clear as clearYourStories } from '../actions/your-stories'
9 | import { receiveStory } from '../actions/story'
10 | import { receiveStories } from '../actions/stories'
11 | import { selectTab } from '../actions/your-stories';
12 | import { callWith401Handle } from './api'
13 | import { get } from '../utils/api'
14 | import { STORY_DETAIL, STORIES } from '../constants/api';
15 | import { removeStateReceivedFrom } from '../actions/cache';
16 | import * as signalR from '@aspnet/signalr'
17 |
18 |
19 | const enters = {
20 | yourStories: function*(state) {
21 | yield put(selectTab(state.yourStories.tab))
22 | },
23 | story: function*(state) {
24 | const storyId = state.navigation.storyId
25 | const story = yield callWith401Handle(get, STORY_DETAIL(storyId))
26 | yield put(receiveStory(story))
27 | },
28 | stories: function*(state) {
29 | const { stories } = yield callWith401Handle(get, STORIES)
30 | yield put(receiveStories(stories))
31 | }
32 | }
33 |
34 | export function* enterPage() {
35 | const state = yield select()
36 | const pageName = state.navigation.page
37 | const entersFunc = enters[pageName]
38 | if (entersFunc) yield entersFunc(state)
39 | }
40 |
41 | function* listenNotifications() {
42 | const connection = new signalR.HubConnectionBuilder()
43 | .withUrl("http://localhost:5000/notifications", { accessTokenFactory: () => localStorage.token })
44 | .build()
45 |
46 | let attempt = 0
47 | let connected = false
48 | while(attempt < 10 && !connected) {
49 | attempt++
50 | connected = true
51 | try {
52 | yield call(() => connection.start())
53 | console.info('SignalR: successfully connected')
54 | } catch(err) {
55 | console.info(`SignalR: attempt ${attempt}: failed to connect`)
56 | yield call(delay, 1000)
57 | connected = false
58 | }
59 | }
60 |
61 | if (connected) {
62 | const getEventChannel = connection => eventChannel(emit => {
63 | const handler = data => { emit(data) }
64 | connection.on('notification', handler)
65 | return () => { connection.off() }
66 | })
67 |
68 | const channel = yield call(getEventChannel, connection)
69 | while(true) {
70 | const { notificationType, payload } = yield take(channel)
71 | if (['LIKE', 'UNLIKE'].includes(notificationType)) {
72 | const message = `${payload.username} ${notificationType.toLowerCase()}d "${payload.storyTitle}"`
73 | yield put(toggleSnackbar(message))
74 | } else if (notificationType === 'SHARE') {
75 | const message = `${payload.username} invited you to edit his story: "${payload.storyTitle}"`
76 | yield put(toggleSnackbar(message))
77 | } else if (notificationType === 'STORY_EDIT') {
78 | const { navigation, editor } = yield select()
79 | if (navigation.page === 'editor' && editor.storyId === payload.id) {
80 | yield put(updateStory(payload))
81 | }
82 | }
83 | }
84 | }
85 | }
86 |
87 | export function* startApp() {
88 | window.history.pushState({}, '', '')
89 |
90 | yield spawn(listenNotifications)
91 |
92 | function* ticking() {
93 | yield put(tickAction())
94 | yield call(delay, TICK)
95 | yield* ticking()
96 | }
97 | yield* ticking()
98 | }
99 |
100 | const exits = {
101 | editor: function* () {
102 | yield put(clearEditor())
103 | },
104 | yourStories: function* () {
105 | yield put(clearYourStories())
106 | },
107 | story: function* () {
108 | yield put(removeStateReceivedFrom('story'))
109 | }
110 | }
111 |
112 | export function* exitPage({ payload }) {
113 | const state = yield select()
114 |
115 | const exitsFunc = exits[payload]
116 | if (exitsFunc) yield exitsFunc(state)
117 | }
118 |
119 | export function* tick() {
120 | const { navigation: { page } } = yield select()
121 | if (page === 'editor') {
122 | const { editor: { lastSave, lastEdit, saving } } = yield select()
123 | if (!saving && lastEdit && lastEdit > lastSave && Date.now() - lastSave > SAVE_PERIOD) {
124 | yield put(save())
125 | }
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/src/registerServiceWorker.js:
--------------------------------------------------------------------------------
1 | // In production, we register a service worker to serve assets from local cache.
2 |
3 | // This lets the app load faster on subsequent visits in production, and gives
4 | // it offline capabilities. However, it also means that developers (and users)
5 | // will only see deployed updates on the "N+1" visit to a page, since previously
6 | // cached resources are updated in the background.
7 |
8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
9 | // This link also includes instructions on opting out of this behavior.
10 |
11 | const isLocalhost = Boolean(
12 | window.location.hostname === 'localhost' ||
13 | // [::1] is the IPv6 localhost address.
14 | window.location.hostname === '[::1]' ||
15 | // 127.0.0.1/8 is considered localhost for IPv4.
16 | window.location.hostname.match(
17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
18 | )
19 | );
20 |
21 | export default function register() {
22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
23 | // The URL constructor is available in all browsers that support SW.
24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
25 | if (publicUrl.origin !== window.location.origin) {
26 | // Our service worker won't work if PUBLIC_URL is on a different origin
27 | // from what our page is served on. This might happen if a CDN is used to
28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
29 | return;
30 | }
31 |
32 | window.addEventListener('load', () => {
33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
34 |
35 | if (isLocalhost) {
36 | // This is running on localhost. Lets check if a service worker still exists or not.
37 | checkValidServiceWorker(swUrl);
38 |
39 | // Add some additional logging to localhost, pointing developers to the
40 | // service worker/PWA documentation.
41 | navigator.serviceWorker.ready.then(() => {
42 | console.log(
43 | 'This web app is being served cache-first by a service ' +
44 | 'worker. To learn more, visit https://goo.gl/SC7cgQ'
45 | );
46 | });
47 | } else {
48 | // Is not local host. Just register service worker
49 | registerValidSW(swUrl);
50 | }
51 | });
52 | }
53 | }
54 |
55 | function registerValidSW(swUrl) {
56 | navigator.serviceWorker
57 | .register(swUrl)
58 | .then(registration => {
59 | registration.onupdatefound = () => {
60 | const installingWorker = registration.installing;
61 | installingWorker.onstatechange = () => {
62 | if (installingWorker.state === 'installed') {
63 | if (navigator.serviceWorker.controller) {
64 | // At this point, the old content will have been purged and
65 | // the fresh content will have been added to the cache.
66 | // It's the perfect time to display a "New content is
67 | // available; please refresh." message in your web app.
68 | console.log('New content is available; please refresh.');
69 | } else {
70 | // At this point, everything has been precached.
71 | // It's the perfect time to display a
72 | // "Content is cached for offline use." message.
73 | console.log('Content is cached for offline use.');
74 | }
75 | }
76 | };
77 | };
78 | })
79 | .catch(error => {
80 | console.error('Error during service worker registration:', error);
81 | });
82 | }
83 |
84 | function checkValidServiceWorker(swUrl) {
85 | // Check if the service worker can be found. If it can't reload the page.
86 | fetch(swUrl)
87 | .then(response => {
88 | // Ensure service worker exists, and that we really are getting a JS file.
89 | if (
90 | response.status === 404 ||
91 | response.headers.get('content-type').indexOf('javascript') === -1
92 | ) {
93 | // No service worker found. Probably a different app. Reload the page.
94 | navigator.serviceWorker.ready.then(registration => {
95 | registration.unregister().then(() => {
96 | window.location.reload();
97 | });
98 | });
99 | } else {
100 | // Service worker found. Proceed as normal.
101 | registerValidSW(swUrl);
102 | }
103 | })
104 | .catch(() => {
105 | console.log(
106 | 'No internet connection found. App is running in offline mode.'
107 | );
108 | });
109 | }
110 |
111 | export function unregister() {
112 | if ('serviceWorker' in navigator) {
113 | navigator.serviceWorker.ready.then(registration => {
114 | registration.unregister();
115 | });
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/src/mocks/state.js:
--------------------------------------------------------------------------------
1 | import { Value } from 'slate'
2 |
3 | export const MOCK_STATE = {
4 | navigation: {
5 | page: 'editor'
6 | },
7 | editor: {
8 | "title": "Increaser mindset: Reflecting",
9 | "content": Value.fromJSON({"object":"value","document":{"object":"document","data":{},"nodes":[{"object":"block","type":"paragraph","data":{},"nodes":[{"object":"text","leaves":[{"object":"leaf","text":"This is part of the series about “","marks":[]}]},{"object":"inline","type":"link","data":{"href":"https://medium.com/@geekrodion/increaser-mindset-dc828a2bcd4d"},"nodes":[{"object":"text","leaves":[{"object":"leaf","text":"Increaser mindset","marks":[]}]}]},{"object":"text","leaves":[{"object":"leaf","text":"”.","marks":[]}]}]},{"object":"block","type":"paragraph","data":{},"nodes":[{"object":"text","leaves":[{"object":"leaf","text":"","marks":[]}]}]},{"object":"block","type":"image","data":{"src":"https://cdn-images-1.medium.com/max/2000/1*q-kzL5NfuZCZG3Pvdi_dwg.jpeg"},"nodes":[{"object":"text","leaves":[{"object":"leaf","text":"","marks":[]}]}]},{"object":"block","type":"paragraph","data":{},"nodes":[{"object":"text","leaves":[{"object":"leaf","text":"In this article, I will talk about how the evening practice of thinking can help you to be more relaxed and productive.","marks":[]}]}]},{"object":"block","type":"paragraph","data":{},"nodes":[{"object":"text","leaves":[{"object":"leaf","text":"","marks":[]}]}]},{"object":"block","type":"paragraph","data":{},"nodes":[{"object":"text","leaves":[{"object":"leaf","text":"One time I began to notice that by the end of the day I become tired. My thoughts were always intertwined. The process of falling asleep become longer. At morning I was fighting with myself to wake up, but lose this battle most of the time. It was a hard time: work, university, and Increaser took all my time. In one of those evenings, I decided to take a walk. I went to the stadium near my home and began to pass a circle after a circle. While walking, I think about each of my activity separately from others. After that walk, I fell asleep without chaotic thinking in the head.","marks":[]}]}]},{"object":"block","type":"paragraph","data":{},"nodes":[{"object":"text","leaves":[{"object":"leaf","text":"","marks":[]}]}]},{"object":"block","type":"paragraph","data":{},"nodes":[{"object":"text","leaves":[{"object":"leaf","text":"From that evening I started practicing it every evening. And now the process look this way.","marks":[]}]}]},{"object":"block","type":"paragraph","data":{},"nodes":[{"object":"text","leaves":[{"object":"leaf","text":"","marks":[]}]}]},{"object":"block","type":"heading-two","data":{},"nodes":[{"object":"text","leaves":[{"object":"leaf","text":"Rethink the day","marks":[]}]}]},{"object":"block","type":"paragraph","data":{},"nodes":[{"object":"text","leaves":[{"object":"leaf","text":"First I am going through my day. I give attention to my actions, habits and human interactions during the day and learn lessons from them. During this evening session, I can understand in which moments I went wrong and what was a better way to deal with it. By doing rethinking about the day — I free myself from thinking about my problems and actions during the day. Since I don’t need to bother myself — I will analyze them at evening anyway. And this is very important for your productivity during the day since you can’t be focused while having chaotic thinking about problems in the background of your mind.","marks":[]}]}]},{"object":"block","type":"heading-two","data":{},"nodes":[{"object":"text","leaves":[{"object":"leaf","text":"","marks":[]}]}]},{"object":"block","type":"heading-two","data":{},"nodes":[{"object":"text","leaves":[{"object":"leaf","text":"Think about life projects","marks":[]}]}]},{"object":"block","type":"paragraph","data":{},"nodes":[{"object":"text","leaves":[{"object":"leaf","text":"Next part is thinking about my projects. Projects are the main activities in your life that matter. At this part, I separate each project from the others. And analyze what I have done in this project. It easy to forget about the activity that matters. Or spend fewer efforts on it in comparison to other ones, that matter less.","marks":[]}]}]},{"object":"block","type":"paragraph","data":{},"nodes":[{"object":"text","leaves":[{"object":"leaf","text":"","marks":[]}]}]},{"object":"block","type":"heading-two","data":{},"nodes":[{"object":"text","leaves":[{"object":"leaf","text":"Planning next day","marks":[]}]}]},{"object":"block","type":"paragraph","data":{},"nodes":[{"object":"text","leaves":[{"object":"leaf","text":"The last part of the routing is planning your next day. Without a plan, you are lost in tomorrow. ","marks":[]}]}]},{"object":"block","type":"paragraph","data":{},"nodes":[{"object":"text","leaves":[{"object":"leaf","text":"For example, if you don’t know for sure will you run tomorrow or go to the swimming pool this uncertainty will increase the probability that you will not follow your morning routine by turning off your alarm.","marks":[]}]}]},{"object":"block","type":"paragraph","data":{},"nodes":[{"object":"text","leaves":[{"object":"leaf","text":"","marks":[]}]}]},{"object":"block","type":"heading-two","data":{},"nodes":[{"object":"text","leaves":[{"object":"leaf","text":"Conclusion","marks":[]}]}]},{"object":"block","type":"paragraph","data":{},"nodes":[{"object":"text","leaves":[{"object":"leaf","text":"Being busy is not the same as being productive and being busy doesn’t mean that you follow the path of self-improvement. When you don’t give yourself time to think you can easily get lost on your road. Your mind becomes fixed on activities, you do most of the time, and you do not see all the possibilities around you. You can go through the same faults over and over again without understanding it.","marks":[]}]}]},{"object":"block","type":"paragraph","data":{},"nodes":[{"object":"text","leaves":[{"object":"leaf","text":"","marks":[]}]}]},{"object":"block","type":"paragraph","data":{},"nodes":[{"object":"text","leaves":[{"object":"leaf","text":"Clap if you enjoy 😎","marks":[{"object":"mark","type":"bold","data":{}}]}]}]}]}}),
10 | "lastSave": 1538326402041,
11 | "lastEdit": 1538326401879,
12 | "changesSaved": true,
13 | "link": "",
14 | "tags": ["Productivity", "Increaser", "Mindset", "Meditation"],
15 | "tagsMenuOpen": true,
16 | "editingTag": ""
17 | }
18 | }
--------------------------------------------------------------------------------
/src/reducers/editor.js:
--------------------------------------------------------------------------------
1 | import { createReducer } from 'redux-act'
2 | import { Value } from 'slate'
3 |
4 | import * as a from '../actions/editor'
5 | import { tick } from '../actions/generic'
6 | import { MAX_TITLE_LENGTH, MARKS, BLOCKS } from '../constants/editor';
7 |
8 | export const allStories = ({ drafts, published, shared }) => [drafts, published, shared ? shared.map(s => s.drafts).flatten_() : undefined].withoutUndef_().flatten_()
9 |
10 | const getDefaultState = () => ({
11 | title: '',
12 | content: Value.fromJSON({
13 | document: {
14 | nodes: [
15 | {
16 | object: 'block',
17 | type: 'paragraph',
18 | },
19 | ],
20 | },
21 | }),
22 | storyId: undefined,
23 | lastSave: Date.now(),
24 | lastEdit: undefined,
25 | changesSaved: true,
26 | linkPrompt: undefined,
27 | link: '',
28 | tags: [],
29 | tagsMenuOpen: false,
30 | shareDialogOpen: false,
31 | userToShareName: '',
32 | editingTag: '',
33 | saving: false,
34 | owner: true
35 | })
36 |
37 | const updateLastEdit = (oldState, newState) => {
38 | const sameContent = JSON.stringify(oldState.content) === JSON.stringify(newState.content)
39 | const sameTags = oldState.tags.sameAs_(newState.tags)
40 | const sameTitle = oldState.title === newState.title
41 | return {
42 | ...newState,
43 | lastEdit: [sameContent, sameTags, sameTitle].allTrue_() ? oldState.lastEdit : Date.now()
44 | }
45 | }
46 |
47 | const willUpdateLastEdit = func => (oldState, payload) => {
48 | const newState = func(oldState, payload)
49 | return updateLastEdit(oldState, newState)
50 | }
51 |
52 | const hasLink = value => value.inlines.some(inline => inline.type === BLOCKS.LINK)
53 | const unwrapLink = change => change.unwrapInline(BLOCKS.LINK)
54 | const wrapLink = (change, href) => change.wrapInline({
55 | type: BLOCKS.LINK,
56 | data: { href }
57 | })
58 |
59 | const insertImage = (change, src) => change.insertBlock({
60 | type: BLOCKS.IMAGE,
61 | data: { src }
62 | })
63 |
64 | export default () => createReducer(
65 | {
66 | [a.changeTitle]: willUpdateLastEdit((state, title) => ({
67 | ...state,
68 | title: title.slice(0, MAX_TITLE_LENGTH),
69 | })),
70 | [a.changeContent]: willUpdateLastEdit((state, content) => ({
71 | ...state,
72 | content
73 | })),
74 | [a.save]: (state) => ({
75 | ...state,
76 | saving: true
77 | }),
78 | [a.successfulSave]: (state) => ({
79 | ...state,
80 | lastSave: Date.now(),
81 | saving: false
82 | }),
83 | [a.successfulCreation]: (state, storyId) => ({
84 | ...state,
85 | storyId,
86 | saving: false
87 | }),
88 | [tick]: (state) => ({
89 | ...state,
90 | changesSaved: !state.lastEdit || state.lastSave > state.lastEdit
91 | }),
92 | [a.toggleEffect]: (state, type) => {
93 | try {
94 | const value = state.content
95 | const change = value.change()
96 | const { document } = value
97 | const hasBlock = type => value.blocks.some(node => node.type === type)
98 | const isList = hasBlock('list-item')
99 |
100 | if (type === BLOCKS.LINK) {
101 |
102 | if (hasLink(value)) {
103 | change.call(unwrapLink)
104 | return updateLastEdit(state, { ...state, content: change.value })
105 | }
106 | // no way to create link when nothing is selected
107 | if (!value.selection.isExpanded) return state
108 | return updateLastEdit(state, { ...state, content: change.value, linkPrompt: BLOCKS.LINK })
109 | }
110 | if (type === BLOCKS.IMAGE) {
111 | return updateLastEdit(state, { ...state, content: change.value, linkPrompt: BLOCKS.IMAGE })
112 | }
113 | if (Object.values(MARKS).includes(type)) {
114 | return updateLastEdit(
115 | state,
116 | { ...state, content: change.toggleMark(type).value }
117 | )
118 | } else {
119 | if (!['bulleted-list', 'numbered-list'].includes(type)) {
120 | const isActive = hasBlock(type)
121 |
122 | if (isList) {
123 | change
124 | .setBlocks(isActive ? 'paragraph' : type)
125 | .unwrapBlock('bulleted-list')
126 | .unwrapBlock('numbered-list')
127 | } else {
128 | change.setBlocks(isActive ? 'paragraph' : type)
129 | }
130 | } else {
131 | // Handle the extra wrapping required for list buttons.
132 | const isType = value.blocks.some(block => !!document.getClosest(block.key, parent => parent.type === type))
133 |
134 | if (isList && isType) {
135 | change
136 | .setBlocks('paragraph')
137 | .unwrapBlock('bulleted-list')
138 | .unwrapBlock('numbered-list')
139 | } else if (isList) {
140 | change
141 | .unwrapBlock(
142 | type === 'bulleted-list' ? 'numbered-list' : 'bulleted-list'
143 | )
144 | .wrapBlock(type)
145 | } else {
146 | change.setBlocks('list-item').wrapBlock(type)
147 | }
148 | }
149 |
150 | return updateLastEdit(state, { ...state, content: change.value })
151 | }
152 | } catch(err) {
153 | console.info('fail to execute effect')
154 | return state
155 | }
156 | },
157 | [a.exitLinkPrompt]: state => ({ ...state, linkPrompt: undefined, link: '' }),
158 | [a.changeLink]: (state, link) => ({ ...state, link }),
159 | [a.submitLink]: state => {
160 | if (!state.link) return ({ ...state, linkPrompt: undefined })
161 |
162 | const change = state.content.change()
163 | change.call(state.linkPrompt === BLOCKS.LINK ? wrapLink : insertImage, state.link)
164 | return updateLastEdit(state, { ...state, content: change.value, linkPrompt: undefined, link: '', })
165 | },
166 | [a.toggleTagsMenu]: state => ({
167 | ...state,
168 | tagsMenuOpen: !state.tagsMenuOpen
169 | }),
170 | [a.editTag]: (state, editingTag) => ({
171 | ...state,
172 | editingTag
173 | }),
174 | [a.submitTag]: willUpdateLastEdit(state => ({
175 | ...state,
176 | tags: (state.editingTag ? [ ...state.tags, state.editingTag] : state.tags).uniq_(),
177 | editingTag: '',
178 | })),
179 | [a.deleteTag]: willUpdateLastEdit((state, tag) => ({
180 | ...state,
181 | tags: state.tags.without_(tag)
182 | })),
183 | [a.receiveStoryForEdit]: (state, story) => ({
184 | ...getDefaultState(),
185 | storyId: story.id,
186 | title: story.title,
187 | content: Value.fromJSON(JSON.parse(story.content)),
188 | tags: story.tags,
189 | lastSave: Date.now(),
190 | owner: story.owner
191 | }),
192 | [a.updateStory]: (state, { title, tags, lastEditTime, content }) => ({
193 | ...state,
194 | title,
195 | tags,
196 | lastSave: lastEditTime * 1000,
197 | content: Value.fromJSON(JSON.parse(content)),
198 | }),
199 | [a.clear]: () => getDefaultState(),
200 | [a.toggleShareDialog]: state => ({
201 | ...state,
202 | shareDialogOpen: !state.shareDialogOpen
203 | }),
204 | [a.share]: state => ({
205 | ...state,
206 | shareDialogOpen: false
207 | }),
208 | [a.changeUserToShareName]: (state, userToShareName) => ({
209 | ...state,
210 | userToShareName
211 | })
212 | },
213 | getDefaultState()
214 | )
--------------------------------------------------------------------------------