├── 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 | ![all text](https://cdn-images-1.medium.com/max/800/1*MDXR5eddScIqHYop-IL9sg.png) 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 | 21 | Enter the URL 22 | 23 | changeLink(value)} 32 | /> 33 | 34 | 35 | 38 | 41 | 42 | 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 |
47 | 48 | {fields} 49 | {submitText} 50 | 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 | 21 | Enter username 22 | 23 | changeUserToShareName(value)} 32 | /> 33 | 34 | 35 | 38 | 41 | 42 | 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 | <Space/> 58 | <Editor/> 59 | </ContentContainer> 60 | <EffectsMenu/> 61 | { linkPrompt && <LinkDialog/> } 62 | { tagsMenuOpen && <TagsDialog/> } 63 | { shareDialogOpen && <ShareDialog/> } 64 | </Page> 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 | <Field 26 | name="email" 27 | key="email" 28 | component={TextField} 29 | label="Email" 30 | type="text" 31 | validate={[required, email]} 32 | />, 33 | <Field 34 | name="username" 35 | key="username" 36 | component={TextField} 37 | label="Username" 38 | type="text" 39 | validate={[required, minLenght4]} 40 | />, 41 | <Field 42 | name="password" 43 | key="password" 44 | component={TextField} 45 | label="Password" 46 | type="password" 47 | validate={[required, minLength6, lengthLessThan40]} 48 | /> 49 | ] 50 | return ( 51 | <AuthForm 52 | fields={fields} 53 | handleSubmit={handleSubmit} 54 | enabledSubmit={enabledSubmit} 55 | onSubmit={submitRegister} 56 | submitText='Register' 57 | onBottomTextClick={() => 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 | <Layout> 29 | {PAGES_WITH_NAVBAR.includes(page) && <Navbar />} 30 | <Page /> 31 | </Layout> 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 | <DocumentTitle title={documentTitle}> 36 | {_.isEmpty(keyMap) ? ( 37 | <div style={style}> 38 | <Snackbar/> 39 | {children} 40 | </div> 41 | ) : ( 42 | <HotKeys 43 | style={style} 44 | keyMap={keyMap} 45 | handlers={handlers} 46 | focused 47 | > 48 | <Snackbar/> 49 | {children} 50 | </HotKeys> 51 | )} 52 | </DocumentTitle> 53 | ) : ( 54 | <Loading> 55 | <CircularProgress/> 56 | </Loading> 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 | <AppBar position='static'> 27 | <StyledToolbar> 28 | <Logo onClick={() => to('stories')}/> 29 | <div> 30 | <IconButton 31 | aria-owns={dropdownOpen ? 'menu-appbar' : null} 32 | aria-haspopup="true" 33 | onClick={({ currentTarget }) => toggleDropdown(currentTarget)} 34 | color="inherit" 35 | > 36 | <AccountCircle /> 37 | </IconButton> 38 | <Menu 39 | id="menu-appbar" 40 | anchorEl={dropdownAnchor} 41 | anchorOrigin={{ 42 | vertical: 'top', 43 | horizontal: 'right', 44 | }} 45 | transformOrigin={{ 46 | vertical: 'top', 47 | horizontal: 'right', 48 | }} 49 | open={dropdownOpen} 50 | onClose={toggleDropdown} 51 | > 52 | <MenuItem onClick={itemHandler(unauthorizeUser)}>Sign out</MenuItem> 53 | <MenuItem onClick={itemHandler(() => to('editor'))}>New story</MenuItem> 54 | <MenuItem onClick={itemHandler(() => to('yourStories'))}>Stories</MenuItem> 55 | </Menu> 56 | </div> 57 | </StyledToolbar> 58 | </AppBar> 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 | <Dialog 31 | open={true} 32 | onClose={toggleTagsMenu} 33 | aria-labelledby="form-dialog-tags" 34 | > 35 | <DialogTitle id="form-dialog-tags"> 36 | Add or change tags (up to {TAGS_LIMIT}) so readers know what your story is about 37 | </DialogTitle> 38 | <DialogContent> 39 | {tags.length < 5 && ( 40 | <InputLine> 41 | <TextField 42 | autoFocus 43 | margin="dense" 44 | label="Tag" 45 | type="text" 46 | fullWidth 47 | value={editingTag} 48 | onChange={({ target: { value } }) => editTag(value)} 49 | /> 50 | <Button onClick={submitTag} size='small' variant='outlined' color='primary'> 51 | Add 52 | </Button> 53 | </InputLine> 54 | )} 55 | <ChipsContainer> 56 | { 57 | tags.map(tag => ( 58 | <Tag 59 | key={tag} 60 | label={tag} 61 | onDelete={() => deleteTag(tag)} 62 | /> 63 | )) 64 | } 65 | </ChipsContainer> 66 | </DialogContent> 67 | <DialogActions> 68 | <Button style={{ margin: 'auto' }} color='primary' variant='contained' onClick={publish}> 69 | Publish 70 | </Button> 71 | </DialogActions> 72 | </Dialog> 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 | <Effect 61 | key={effect} 62 | selected={isSelected(effect)} 63 | onClick={() => toggleEffect(effect)} 64 | > 65 | <FontAwesomeIcon 66 | icon={icon} 67 | size={effect !== BLOCKS.HEADING_TWO ? 'sm' : 'xs'} 68 | /> 69 | </Effect> 70 | )) 71 | 72 | return ( 73 | <Container> 74 | <MenuList open={true}> 75 | {items} 76 | </MenuList> 77 | </Container> 78 | ) 79 | } 80 | ) -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html lang="en"> 3 | <head> 4 | <meta charset="utf-8"> 5 | <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> 6 | <meta name="theme-color" content="#000000"> 7 | <!-- 8 | manifest.json provides metadata used when your web app is added to the 9 | homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/ 10 | --> 11 | <link rel="manifest" href="%PUBLIC_URL%/manifest.json"> 12 | <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico"> 13 | <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500"> 14 | <link href="https://fonts.googleapis.com/css?family=Dancing+Script:700" rel="stylesheet"> 15 | <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons"> 16 | <!-- 17 | Notice the use of %PUBLIC_URL% in the tags above. 18 | It will be replaced with the URL of the `public` folder during the build. 19 | Only files inside the `public` folder can be referenced from the HTML. 20 | 21 | Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will 22 | work correctly both with client-side routing and a non-root public URL. 23 | Learn how to configure a non-root public URL by running `npm run build`. 24 | --> 25 | <title>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 | ) --------------------------------------------------------------------------------