Compare commits

...

32 Commits

Author SHA1 Message Date
e50a929d8b Styling 2020-03-14 08:23:15 +01:00
34923713f5 Change Layout 2020-03-13 13:30:37 +01:00
60f92904cc copy books to build dir 2020-03-13 12:00:29 +01:00
c2379ff768 write script to download top100 gutenberg books 2020-03-13 11:57:25 +01:00
182b20a3ee Move jsconfig to project root 2020-03-13 11:56:49 +01:00
8a08342e3c Update dependencies 2020-03-13 11:55:35 +01:00
7ce9aac9cb allow console errors and warnings 2020-03-13 11:49:27 +01:00
067e1ccda6 Change styling 2020-03-13 09:49:47 +01:00
a81a5a2458 Add storybook 2020-03-07 17:45:25 +01:00
4656f5b280 Remove unused imports 2020-02-03 18:26:35 +01:00
d69487b048 externalize package 2020-02-03 18:24:48 +01:00
5afe62bdcc Use quick scrolling 2020-02-02 14:32:45 +01:00
12fd1e3981 Remove development code 2020-02-02 14:31:48 +01:00
1ba18b7341 Use madge to generate dep tree 2020-02-02 10:17:44 +01:00
5267561d66 Use lib function to fetch book 2020-02-02 10:17:32 +01:00
ecc9e90bec explicitly resolve packages in config 2020-02-02 10:02:31 +01:00
2de1c47ed6 Uninstall redux 2020-02-02 09:47:51 +01:00
cbbe821fde Update packages 2020-02-02 09:32:34 +01:00
60443f481e fix warn 2020-02-01 21:23:56 +01:00
5a32f3de0c Add warning on undefined select 2020-02-01 21:21:01 +01:00
366c338528 Move to store/index.js 2020-02-01 18:58:13 +01:00
444ef30587 Process action creators too 2020-02-01 18:57:58 +01:00
16cdc1acf7 Use new store 2020-02-01 18:13:16 +01:00
653627033a Pass whole action to reducerBranch 2020-02-01 18:09:03 +01:00
4b98f6f654 add useDispatch hook 2020-02-01 18:06:29 +01:00
7f2201c945 change doc 2020-02-01 17:21:18 +01:00
ccd684fef0 add Selector functionality 2020-02-01 17:21:08 +01:00
7434b1e17e Rename and add logging 2020-02-01 15:53:44 +01:00
3255c42253 Don't use JSX 2020-02-01 14:51:53 +01:00
6fbca8a306 create usage example 2020-02-01 12:18:39 +01:00
c4d5c35950 Create context store 2020-02-01 12:18:32 +01:00
30aa1e95dc create first draft 2020-02-01 10:01:18 +01:00
46 changed files with 8945 additions and 1297 deletions

View File

@ -1,4 +1,4 @@
{
"extends": ["react-app", "prettier"],
"rules": { "no-console": "warn" }
"rules": { "no-console": ["warn", "allow": ["warn", "error"]] }
}

3
.gitignore vendored
View File

@ -1,3 +1,4 @@
node_modules/
build/
tmp/
tmp/
books/

13
.storybook/main.js Normal file
View File

@ -0,0 +1,13 @@
module.exports = {
stories: ['../src/components/**/*.stories.js'],
addons: [
'@storybook/addon-knobs/register',
'@storybook/addon-actions',
'@storybook/addon-links'
],
webpackFinal: async config => {
// do mutation to the config
return config
}
}

8
.storybook/preview.js Normal file
View File

@ -0,0 +1,8 @@
import { addDecorator } from '@storybook/react'
import { withInfo } from '@storybook/addon-info'
import { withKnobs } from '@storybook/addon-knobs'
import { withConsole } from '@storybook/addon-console'
addDecorator(withInfo({ inline: true }))
addDecorator(withKnobs)
addDecorator((storyFn, context) => withConsole()(storyFn)(context))

View File

@ -0,0 +1,23 @@
module.exports = {
module: {
rules: [
{
test: /\.css$/,
use: [
{
loader: 'style-loader'
},
{
loader: 'css-loader',
options: {
modules: true
}
},
{
loader: 'postcss-loader'
}
]
}
]
}
}

View File

@ -2,7 +2,10 @@ module.exports = api => {
// caching the babel config
api.cache.using(() => process.env.NODE_ENV)
return {
presets: ['@babel/preset-env', '@babel/preset-react']
presets: [
require.resolve('@babel/preset-env'),
require.resolve('@babel/preset-react')
]
// plugins: [api.env('development') && 'react-refresh/babel'].filter(Boolean)
}
}

6
jsconfig.json Normal file
View File

@ -0,0 +1,6 @@
{
"compilerOptions": {
"baseUrl": "src"
},
"include": ["src"]
}

9322
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,56 +10,68 @@
"serve": "ws -d build --compress --hostname 0.0.0.0",
"test": "tests",
"lint": "eslint src",
"format": "prettier --write src/**/*.js"
"format": "prettier --write src/**/*.js",
"graph": "mkdir -p build && madge src --exclude '.css$' --image build/dep-graph.svg",
"storybook": "start-storybook -p 6006 --ci",
"build-storybook": "build-storybook"
},
"author": "Alfred Melch (dev@melch.pro)",
"license": "ISC",
"dependencies": {
"axios": "^0.19.0",
"axios": "^0.19.2",
"classnames": "^2.2.6",
"debounce": "^1.2.0",
"i18next": "^19.1.0",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-i18next": "^11.3.1",
"react-icons": "^3.8.0",
"react-redux": "^7.1.3",
"redux": "^4.0.4",
"regenerator-runtime": "^0.13.3",
"i18next": "^19.3.2",
"potent-reducer": "^0.1.0",
"react": "^16.13.0",
"react-dom": "^16.13.0",
"react-i18next": "^11.3.3",
"react-icons": "^3.9.0",
"regenerator-runtime": "^0.13.5",
"reselect": "^4.0.0",
"reselect-tools": "0.0.7"
},
"devDependencies": {
"@babel/core": "^7.7.5",
"@babel/preset-env": "^7.7.6",
"@babel/preset-react": "^7.7.4",
"@pmmmwh/react-refresh-webpack-plugin": "^0.1.1",
"babel-eslint": "^10.0.3",
"@babel/core": "^7.8.7",
"@babel/preset-env": "^7.8.7",
"@babel/preset-react": "^7.8.3",
"@pmmmwh/react-refresh-webpack-plugin": "^0.2.0",
"@storybook/addon-actions": "^5.3.14",
"@storybook/addon-console": "^1.2.1",
"@storybook/addon-info": "^5.3.14",
"@storybook/addon-knobs": "^5.3.14",
"@storybook/addon-links": "^5.3.14",
"@storybook/addons": "^5.3.14",
"@storybook/react": "^5.3.14",
"babel-eslint": "^10.1.0",
"babel-loader": "^8.0.6",
"css-loader": "^3.3.2",
"eslint": "^6.7.2",
"eslint-config-prettier": "^6.7.0",
"eslint-config-react-app": "^5.1.0",
"eslint-plugin-flowtype": "^4.5.2",
"eslint-plugin-import": "^2.19.1",
"copy-webpack-plugin": "^5.1.1",
"css-loader": "^3.4.2",
"eslint": "^6.8.0",
"eslint-config-prettier": "^6.10.0",
"eslint-config-react-app": "^5.2.0",
"eslint-plugin-flowtype": "^4.6.0",
"eslint-plugin-import": "^2.20.1",
"eslint-plugin-jsx-a11y": "^6.2.3",
"eslint-plugin-react": "^7.17.0",
"eslint-plugin-react-hooks": "^2.3.0",
"eslint-plugin-react": "^7.19.0",
"eslint-plugin-react-hooks": "^2.5.0",
"html-webpack-plugin": "^4.0.0-beta.11",
"local-web-server": "^3.0.7",
"mini-css-extract-plugin": "^0.8.0",
"local-web-server": "^4.0.0",
"madge": "^3.8.0",
"mini-css-extract-plugin": "^0.9.0",
"optimize-css-assets-webpack-plugin": "^5.0.3",
"postcss-flexbugs-fixes": "^4.1.0",
"postcss-flexbugs-fixes": "^4.2.0",
"postcss-loader": "^3.0.0",
"postcss-normalize": "^8.0.1",
"postcss-preset-env": "^6.7.0",
"prettier": "^1.19.1",
"react-refresh": "^0.7.0",
"style-loader": "^1.0.1",
"webpack": "^4.41.2",
"webpack-bundle-analyzer": "^3.6.0",
"webpack-cli": "^3.3.10",
"webpack-dev-server": "^3.9.0",
"prop-types": "^15.7.2",
"react-refresh": "^0.8.0",
"style-loader": "^1.1.3",
"webpack": "^4.42.0",
"webpack-bundle-analyzer": "^3.6.1",
"webpack-cli": "^3.3.11",
"webpack-dev-server": "^3.10.3",
"webpackbar": "^4.0.0"
}
}

66
scripts/downloadTop.js Normal file
View File

@ -0,0 +1,66 @@
const fs = require('fs')
const path = require('path')
const axios = require('axios')
const booksDir = path.join(__dirname, '..', 'books')
function zip(...arrs) {
const resultLength = Math.min(...arrs.map(a => a.length))
return new Array(resultLength).fill(0).map((_, i) => arrs.map(a => a[i]))
}
async function main() {
const scoresPage = await axios
.get('https://www.gutenberg.org/browse/scores/top')
.then(res => res.data)
const top100listItems = scoresPage.match(/<li>.*<\/li>/g).slice(0, 100)
const ids = []
const titles = []
for (let listItem of top100listItems) {
const [, id, title] = listItem.match(/<a href="\/ebooks\/(.*?)">(.*?)<\/a>/)
ids.push(id)
titles.push(title)
}
for (let id of ids) {
fetchBook(id)
.then(text => writeBookToFile(id, text))
.catch(err => {
console.warn(err)
})
}
try {
fs.mkdirSync(booksDir)
} catch {}
const indexFilePath = path.join(booksDir, 'index.json')
const indexObj = Object.fromEntries(zip(ids, titles))
fs.writeFileSync(indexFilePath, JSON.stringify(indexObj, undefined, 2))
}
async function fetchBook(id) {
try {
return await axios
.get(`https://www.gutenberg.org/files/${id}/${id}-0.txt`)
.then(res => res.data)
} catch {}
try {
return await axios(`https://www.gutenberg.org/ebooks/${id}.txt.utf-8`).then(
res => res.data
)
} catch {}
throw Error(`Could not fetch book with id ${id}`)
}
function writeBookToFile(id, text) {
try {
fs.mkdirSync(booksDir)
} catch {}
const filepath = path.join(booksDir, `${id}.txt`)
fs.writeFileSync(filepath, text)
}
main()

View File

@ -4,7 +4,7 @@ html {
padding: 0;
font-family: Arial, Helvetica, sans-serif;
line-height: 1.5;
background-color: #fce6cb;
background-color: #f8f8f8;
}
html {
@ -12,19 +12,10 @@ html {
}
header {
border-bottom: 2px solid white;
margin-bottom: 1em;
display: flex;
align-items: center;
padding: 0px 10px;
}
header h1 {
flex: 1;
background-color: antiquewhite;
}
footer {
border-top: 2px solid white;
margin-top: 1em;
padding: 0 10px;
background-color: antiquewhite;
margin-top: 2em;
}

View File

@ -1,25 +1,60 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { RsvpReader } from './components/RsvpReader'
import { SearchBar } from './components/SearchBar'
import './App.css'
import { LangSelect } from './components/LangSelect'
import { Container } from './styles/Container'
import { Flex, FlexMain } from './styles/Flex'
import { RsvpWidget } from 'components/RsvpWidget'
import { Options } from 'components/Options'
import { TextInput } from 'components/TextInput'
import { TextOutput } from 'components/TextOutput'
import { TotalTime } from 'components/TotalTime'
import { Div } from 'styles/Div'
export const App = () => {
const { t } = useTranslation()
return (
<>
<header>
<h1>{t('title')}</h1>
<SearchBar></SearchBar>
<LangSelect />
<Container>
<Flex alignItems="center" flexWrap="wrap">
<FlexMain>
<h1>{t('title')}</h1>
</FlexMain>
<SearchBar></SearchBar>
<LangSelect />
</Flex>
</Container>
</header>
<main>
<RsvpReader></RsvpReader>
<Container>
<h2>Widget</h2>
<Div maxWidth="600px" margin="0 auto">
<RsvpWidget />
</Div>
<Flex flexWrap="wrap">
<FlexMain padding="10px" minWidth="300px">
<h2>Options</h2>
<Options />
</FlexMain>
<FlexMain padding="10px" minWidth="300px">
<h2>Text input</h2>
<TextInput />
</FlexMain>
</Flex>
<h2>Text Output</h2>
<TextOutput />
<h2>Time needed</h2>
<TotalTime />
</Container>
</main>
<footer>Made by Alfred Melch</footer>
<footer>
<Container>Made by Alfred Melch</Container>
</footer>
</>
)
}

View File

@ -1,20 +1,10 @@
import React from 'react'
import { useDispatch } from 'react-redux'
import { setText, setLang } from '../store/actions'
import { getBook } from '../lib/gutenberg'
import styles from './Book.css'
export const Book = ({ author, language, rights, subject, title, id }) => {
const dispatch = useDispatch()
const handleClick = async () => {
dispatch(setText(await getBook(id)))
dispatch(setLang(language[0]))
}
export const Book = ({ author, language, title }) => {
return (
<div className={styles.book} onClick={handleClick}>
<div className={styles.book}>
<div className={styles.title}>{title.join(' ')}</div>
<div className={styles.author}>{author[0]}</div>
<div className={styles.language}>{language.join(' ')}</div>

View File

@ -1,5 +1,5 @@
.wrapper {
overflow: hidden;
.indicator {
text-align: center;
}
.border {

View File

@ -0,0 +1,41 @@
import React from 'react'
import styles from './Indicator.css'
export const Indicator = ({ type = 'border', children }) => {
const IndicatorType = indicatorByType(type)
return (
<div className={styles.indicator}>
<IndicatorType>{children}</IndicatorType>
</div>
)
}
const indicatorByType = type => {
switch (type) {
case 'border':
return BorderIndicator
case 'pipe':
return PipeIndicator
default:
throw Error(`Indicator of type ${type} is not defined`)
}
}
const BorderIndicator = ({ children }) => (
<>
<div className={styles.border}></div>
<div className={styles.marker}></div>
{children}
<div className={styles.marker}></div>
<div className={styles.border}></div>
</>
)
const PipeIndicator = ({ children }) => (
<>
<div>|</div>
{children}
<div>|</div>
</>
)

View File

@ -0,0 +1,25 @@
import React from 'react'
import { Indicator } from './Indicator'
import { Segment } from './Segment'
import { radios } from '@storybook/addon-knobs'
export default {
component: Indicator,
title: 'Indicator'
}
const typeKnob = () =>
radios('Type', { Border: 'border', Pipe: 'pipe' }, 'border')
export const HappyPath = () => {
return <Indicator type={typeKnob()}>Hello World</Indicator>
}
export const WithSegment = () => {
return (
<Indicator type={typeKnob()}>
<Segment>Hello World</Segment>
</Indicator>
)
}

30
src/components/Offset.js Normal file
View File

@ -0,0 +1,30 @@
import React from 'react'
import PropTypes from 'prop-types'
import { useSelector } from 'store'
import { selectOffset } from 'store/selectors'
/**
* Wrapper that is of double width while hiding its overflow.
* Place children with offset (from -50 to 50, 0 is center)
*/
export const Offset = ({ offset = 0, children }) => {
offset = (offset - 50) / 2
return (
<div style={{ overflow: 'hidden' }}>
<div style={{ width: '200%' }}>
<div style={{ position: 'relative', left: `${offset}%` }}>
{children}
</div>
</div>
</div>
)
}
Offset.propTypes = {
offset: PropTypes.number
}
export const RsvpOffset = ({ children }) => {
const offset = useSelector(selectOffset)
return <Offset offset={offset}>{children}</Offset>
}

View File

@ -0,0 +1,41 @@
import React from 'react'
import { number } from '@storybook/addon-knobs'
import { Offset } from './Offset'
import { Indicator } from './Indicator'
import { Segment } from './Segment'
export default {
component: Offset,
title: 'Offset'
}
const offsetKnob = () =>
number('Offset', 0, {
range: true,
min: -50,
max: 50,
step: 1
})
export const BlockElement = () => (
<Offset offset={offsetKnob()}>
<div style={{ textAlign: 'center' }}>This text is centered</div>
</Offset>
)
export const InlineElement = () => (
<Offset offset={offsetKnob()}>
This a long text to see that inline elements get pushed far to the left. To
be precise they get pushed half the container with to the left. Use
text-align: center for better display.
</Offset>
)
export const WithIndicator = () => (
<Offset offset={offsetKnob()}>
<Indicator>
<Segment>Hello World</Segment>
</Indicator>
</Offset>
)

View File

@ -1,49 +1,45 @@
import React from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { debounce } from 'debounce'
import { setMaxLength, setWpm, setOffset, setLang } from '../store/actions'
import { Slider } from './Slider'
import { selectOffset, selectLang } from '../store/selectors'
import { useSelector, useDispatch } from '../store'
import { useTranslation } from 'react-i18next'
const availableLanguages = ['en', 'de']
export const Options = () => {
const { t } = useTranslation()
const dispatch = useDispatch()
const maxLength = useSelector(state => state.maxLength)
const wpm = useSelector(state => state.wpm)
const offset = useSelector(selectOffset)
const lang = useSelector(selectLang)
const { setMaxLength, setWpm, setOffset, setLang } = useDispatch()
return (
<div>
<h2>{t('options.title')}</h2>
<Slider
title={t('options.maxLength')}
min={3}
max={15}
value={maxLength}
onChange={debounce(val => dispatch(setMaxLength(val)), 100)}
onChange={val => setMaxLength(val)}
/>
<Slider
title={t('options.wpm')}
min={100}
max={1000}
value={wpm}
onChange={debounce(val => dispatch(setWpm(val)), 50)}
onChange={val => setWpm(val)}
/>
<Slider
title={t('options.offset')}
min={-50}
max={50}
value={offset}
onChange={val => dispatch(setOffset(val))}
onChange={val => setOffset(val)}
/>
<label>
Language:
<select value={lang} onChange={e => dispatch(setLang(e.target.value))}>
<select value={lang} onChange={e => setLang(e.target.value)}>
{availableLanguages.map(l => (
<option key={l}>{l}</option>
))}

View File

@ -1,28 +0,0 @@
import React from 'react'
import { useSelector } from 'react-redux'
import styles from './PivotMarker.css'
import { selectOffset } from '../store/selectors'
export const PipeMarker = ({ children }) => (
<div>
<div style={{ textAlign: 'center' }}>|</div>
{children}
<div style={{ textAlign: 'center' }}>|</div>
</div>
)
export const BorderMarker = ({ children }) => {
const offset = useSelector(selectOffset)
return (
<div>
<div className={styles.border}></div>
<div className={styles.wrapper}>
<div className={styles.marker} style={{ left: offset + '%' }}></div>
{children}
<div className={styles.marker} style={{ left: offset + '%' }}></div>
</div>
<div className={styles.border}></div>
</div>
)
}

View File

@ -1,36 +1,37 @@
import React from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { useDispatch, useSelector } from '../store'
import {
selectHasNextSegment,
selectRunning,
selectInterval
} from '../store/selectors'
import { incrementSegment, stop, pause, start } from '../store/actions'
import { FiPlay, FiPause, FiSquare } from 'react-icons/fi'
import { useInterval } from './generics/useInterval'
import { IconButton } from '../styles/IconButton'
export const PlayerControl = () => {
const dispatch = useDispatch()
const running = useSelector(selectRunning)
const hasNext = useSelector(selectHasNextSegment)
const interval = useSelector(selectInterval)
const { pause, start, stop, incSegment } = useDispatch()
useInterval(
() => {
if (!hasNext) dispatch(pause())
else dispatch(incrementSegment())
if (!hasNext) pause()
else incSegment()
},
running ? interval : null
)
return (
<>
<IconButton Icon={FiSquare} onClick={() => dispatch(stop())} />
<IconButton Icon={FiSquare} onClick={() => stop()} />
<IconButton
Icon={running ? FiPause : FiPlay}
onClick={() => dispatch(running ? pause() : start())}
onClick={() => (running ? pause() : start())}
disabled={!hasNext}
/>
</>

View File

@ -1,5 +1,5 @@
import React from 'react'
import { useSelector } from 'react-redux'
import { useSelector } from '../store'
import { selectCurrentSegmentIndex, selectSegments } from '../store/selectors'
export const Progress = () => {

View File

@ -1,15 +1,11 @@
import React from 'react'
import { TextInput } from './TextInput'
import { TextOutput } from './TextOutput'
import { SegmentControl } from './SegmentControl'
import { Segment } from './Segment'
import { Options } from './Options'
import { PlayerControl } from './PlayerControl'
import styles from './RsvpReader.css'
import { BorderMarker } from './PivotMarker'
import { Progress } from './Progress'
import { TotalTime } from './TotalTime'
import { RsvpWidget } from './RsvpWidget'
export const RsvpReader = () => {
return (
@ -18,15 +14,7 @@ export const RsvpReader = () => {
<TextInput />
</div>
<div className={styles.mainItem}>
<Progress />
<BorderMarker>
<Segment />
</BorderMarker>
<div className={styles.controls}>
<SegmentControl>
<PlayerControl />
</SegmentControl>
</div>
<RsvpWidget />
<Options></Options>
<TotalTime />
</div>

View File

@ -0,0 +1,25 @@
import React from 'react'
import { Progress } from './Progress'
import { RsvpOffset } from './Offset'
import { Indicator } from './Indicator'
import { RsvpSegment } from './Segment'
import { SegmentControl } from './SegmentControl'
import { PlayerControl } from './PlayerControl'
export const RsvpWidget = () => {
return (
<>
<Progress />
<RsvpOffset>
<Indicator>
<RsvpSegment />
</Indicator>
</RsvpOffset>
<div style={{ textAlign: 'center' }}>
<SegmentControl>
<PlayerControl />
</SegmentControl>
</div>
</>
)
}

View File

@ -2,11 +2,11 @@ import React, { useState, useEffect, useCallback } from 'react'
import { debounce } from 'debounce'
import classNames from 'classnames'
import { FiSearch } from 'react-icons/fi'
import { useDispatch } from 'react-redux'
import { useTranslation } from 'react-i18next'
import axios from 'axios'
import { useDispatch } from '../store'
import { search } from '../lib/gutenberg'
import { useTranslation } from 'react-i18next'
import { search, getBook } from '../lib/gutenberg'
import { Spinner } from '../styles/Spinner'
import { Dropdown } from './generics/Dropdown'
import { Book } from './Book'
@ -14,16 +14,15 @@ import { Book } from './Book'
import styles from './SearchBar.css'
import { CursorList } from './generics/CursorList'
import { setText, setLang } from '../store/actions.js'
export const SearchBar = () => {
const { t } = useTranslation()
const dispatch = useDispatch()
const [searchTerm, setSearchTerm] = useState('')
const [results, setResults] = useState([])
const [loading, setLoading] = useState(false)
const [isFocused, setFocus] = useState(false)
const { setText, setLang } = useDispatch()
const debouncedSearch = useCallback(
debounce(async term => {
await search(term, 100).then(setResults)
@ -32,12 +31,10 @@ export const SearchBar = () => {
[]
)
const handleClick = async idx => {
const handleSelect = async idx => {
const { id, language } = results[idx]
const url = `https://gutenberg.muperfredi.de/texts/${id}/stripped-body`
const text = await axios.get(url).then(res => res.data.body)
dispatch(setText(text))
dispatch(setLang(language[0]))
setText(await getBook(id))
setLang(language[0])
}
useEffect(() => {
@ -72,7 +69,7 @@ export const SearchBar = () => {
items={results.map(entry => (
<Book {...entry} />
))}
onSelect={handleClick}
onSelect={handleSelect}
/>
</Dropdown>
</div>

View File

@ -16,4 +16,5 @@
}
.suffix {
flex: 1;
text-align: left;
}

View File

@ -1,19 +1,27 @@
import React from 'react'
import { useSelector } from 'react-redux'
import { selectPivotizedSegment, selectOffset } from '../store/selectors'
import PropTypes from 'prop-types'
import styles from './Segment.css'
import { pivotize } from '../lib/pivotize'
import { selectCurrentSegment } from 'store/selectors'
import { useSelector } from 'store'
export const Segment = () => {
const [prefix, pivot, suffix] = useSelector(selectPivotizedSegment)
const offset = useSelector(selectOffset)
export const Segment = ({ children = '' }) => {
const [prefix, pivot, suffix] = pivotize(children)
return (
<div className={styles.wrapper}>
<div className={styles.container} style={{ left: offset + '%' }}>
<span className={styles.prefix}>{prefix}</span>
<span className={styles.pivot}>{pivot}</span>
<span className={styles.suffix}>{suffix}</span>
</div>
<div className={styles.container}>
<span className={styles.prefix}>{prefix}</span>
<span className={styles.pivot}>{pivot}</span>
<span className={styles.suffix}>{suffix}</span>
</div>
)
}
Segment.propTypes = {
children: PropTypes.string.isRequired
}
export const RsvpSegment = () => {
const segment = useSelector(selectCurrentSegment)
return <Segment>{segment}</Segment>
}

View File

@ -0,0 +1,13 @@
import React from 'react'
import { text } from '@storybook/addon-knobs'
import { Segment } from './Segment'
export default {
component: Segment,
title: 'Segment'
}
export const HappyPath = () => {
return <Segment>{text('Text', 'Hello')}</Segment>
}

View File

@ -1,12 +1,11 @@
import React from 'react'
import { useDispatch, useSelector } from 'react-redux'
import {
FiSkipBack,
FiSkipForward,
FiRewind,
FiFastForward
} from 'react-icons/fi'
import { useDispatch, useSelector } from '../store'
import { IconButton } from '../styles/IconButton'
import {
@ -16,45 +15,39 @@ import {
selectHasNextWord
} from '../store/selectors'
import {
incrementSentence,
incrementWord,
decrementSentence,
decrementWord
} from '../store/actions'
export const SegmentControl = ({ children }) => {
const dispatch = useDispatch()
const hasPrevSentence = useSelector(selectHasPrevSentence)
const hasNextSentence = useSelector(selectHasNextSentence)
const hasPrevWord = useSelector(selectHasPrevWord)
const hasNextWord = useSelector(selectHasNextWord)
const { decSentence, decWord } = useDispatch()
const { incSentence, incWord } = useDispatch()
return (
<>
<IconButton
title="Previous Sentence"
onClick={() => dispatch(decrementSentence())}
onClick={() => decSentence()}
Icon={FiRewind}
disabled={!hasPrevSentence}
/>
<IconButton
Icon={FiSkipBack}
title="Previous Word"
onClick={() => dispatch(decrementWord())}
onClick={() => decWord()}
disabled={!hasPrevWord}
/>
{children}
<IconButton
Icon={FiSkipForward}
title="Next Word"
onClick={() => dispatch(incrementWord())}
onClick={() => incWord()}
disabled={!hasNextWord}
/>
<IconButton
Icon={FiFastForward}
title="Next Sentence"
onClick={() => dispatch(incrementSentence())}
onClick={() => incSentence()}
disabled={!hasNextSentence}
/>
</>

View File

@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef } from 'react'
import PropTypes from 'prop-types'
import styles from './Slider.css'
@ -8,14 +9,15 @@ export const Slider = ({
value,
defaultValue,
name,
min,
max,
step,
min = 0,
max = 100,
step = 1,
onChange
}) => {
value = value || defaultValue || (max - min) / 2 + min
// use ref for onChange to not trigger state updates on rerenders of parent
const onChangeRef = useRef(null)
const [internalValue, setInternalValue] = useState(value || defaultValue)
const [internalValue, setInternalValue] = useState(value)
// trigger external update on internal change
useEffect(() => {
@ -61,3 +63,15 @@ export const Slider = ({
}
const calcWidth = num => `${(String(num).length + 1) * 12}px`
Slider.propTypes = {
title: PropTypes.string,
description: PropTypes.string,
value: PropTypes.number,
defaultValue: PropTypes.number,
name: PropTypes.string,
min: PropTypes.number,
max: PropTypes.number,
step: PropTypes.number,
onChange: PropTypes.func
}

View File

@ -0,0 +1,15 @@
import React from 'react'
import { action } from '@storybook/addon-actions'
import { Slider } from './Slider'
export default {
component: Slider,
title: 'Slider'
}
export const Default = () => <Slider onChange={action()} />
export const CustomRange = () => <Slider onChange={action()} min={5} max={15} />
export const CustomStep = () => <Slider onChange={action()} step={5} />

View File

@ -1,7 +1,8 @@
.area {
box-sizing: border-box;
width: 100%;
height: 300px;
height: 100px;
resize: vertical;
}
.load {

View File

@ -1,7 +1,5 @@
import React, { useState } from 'react'
import { useDispatch } from 'react-redux'
import { setText } from '../store/actions.js'
import { useDispatch } from '../store'
import styles from './TextInput.css'
@ -10,7 +8,7 @@ const lorem =
export const TextInput = () => {
const [text, setTextState] = useState(lorem)
const dispatch = useDispatch()
const { setText } = useDispatch()
return (
<div>
<textarea
@ -18,7 +16,7 @@ export const TextInput = () => {
defaultValue={text}
onInput={e => setTextState(e.target.value)}
></textarea>
<button className={styles.load} onClick={() => dispatch(setText(text))}>
<button className={styles.load} onClick={() => setText(text)}>
Load
</button>
</div>

View File

@ -1,5 +1,5 @@
import React from 'react'
import { useSelector } from 'react-redux'
import { useSelector } from '../store'
import classNames from 'classnames'
import { getNextSmallerNumber } from '../lib/array-util.js'

View File

@ -1,5 +1,5 @@
import React from 'react'
import { useSelector } from 'react-redux'
import { useSelector } from '../store'
import { selectTotalTime } from '../store/selectors'
function formatTime(totalSeconds) {
@ -12,10 +12,5 @@ function formatTime(totalSeconds) {
export const TotalTime = () => {
const millis = useSelector(selectTotalTime)
return (
<div>
<h2>Time needed</h2>
Time needed for Text: {formatTime(millis / 1000)}
</div>
)
return <span>Time needed for Text: {formatTime(millis / 1000)}</span>
}

View File

@ -38,7 +38,6 @@ export const CursorList = ({ items, onSelect = noop }) => {
useEffect(() => {
if (selectedRef.current) {
selectedRef.current.scrollIntoView({
behavior: 'smooth',
block: 'nearest'
})
}

View File

@ -2,10 +2,9 @@ import 'regenerator-runtime/runtime'
import React from 'react'
import ReactDOM from 'react-dom'
import { Provider } from 'react-redux'
import { Provider } from './store'
import { App } from './App'
import { store } from './store/index.js'
import './i18n'
@ -16,8 +15,9 @@ function createRootElement() {
body.appendChild(root)
return root
}
ReactDOM.render(
<Provider store={store}>
<Provider>
<App />
</Provider>,
createRootElement()

View File

@ -1,18 +0,0 @@
export const setText = text => ({ type: 'SET_TEXT', payload: text })
export const resetSegment = () => ({ type: 'SET_CURRENT_SEGMENT', payload: 0 })
export const incrementSegment = () => ({ type: 'INC_SEGMENT' })
export const decrementSegment = () => ({ type: 'DEC_SEGMENT' })
export const incrementWord = () => ({ type: 'INC_WORD' })
export const decrementWord = () => ({ type: 'DEC_WORD' })
export const incrementSentence = () => ({ type: 'INC_SENTENCE' })
export const decrementSentence = () => ({ type: 'DEC_SENTENCE' })
export const setMaxLength = length => ({
type: 'SET_MAX_LENGTH',
payload: length
})
export const setWpm = wpm => ({ type: 'SET_WPM', payload: wpm })
export const setOffset = offset => ({ type: 'SET_OFFSET', payload: offset })
export const start = () => ({ type: 'START' })
export const stop = () => ({ type: 'STOP' })
export const pause = () => ({ type: 'PAUSE' })
export const setLang = lang => ({ type: 'SET_LANG', payload: lang })

View File

@ -1,14 +1,81 @@
import { createStore } from 'redux'
import { registerSelectors, getStateWith } from 'reselect-tools'
import { createStore } from 'potent-reducer'
import {
safeSelectNextWord,
safeSelectNextSentence,
safeSelectPrevWord,
safeSelectPrevSentence,
safeSelectNextSegment,
safeSelectPrevSegment
} from './selectors'
import { reducerFn, initialState } from './reducer'
import * as selectors from './selectors'
const initialState = {
originalText: 'Sample Text',
curIdx: 0,
maxLength: 10,
isPlaying: false,
wpm: 300,
offset: -15,
running: false,
lang: 'en',
textDisplay: {
mode: 'pagination',
options: {
pagination: { pageLength: 50 },
segments: { prev: 9, next: 40 },
sentences: { prev: 1, next: 8 }
}
}
}
export const store = createStore(
reducerFn,
const thunks = {
setText: text => ({ text }),
resetSegment: () => {},
incSegment: () => {},
decSegment: () => {},
incWord: () => {},
decWord: () => {},
incSentence: () => {},
decSentence: () => {},
setMaxLength: maxLength => ({ maxLength }),
setWpm: wpm => ({ wpm }),
setOffset: offset => ({ offset }),
start: () => {},
stop: () => {},
pause: () => {},
setLang: lang => ({ lang })
}
const reducer = {
SET_TEXT: (state, { text }) => ({ ...state, originalText: text, curIdx: 0 }),
RESET_SEGMENT: state => ({ ...state, curIdx: 0 }),
INC_SEGMENT: state => ({ ...state, curIdx: safeSelectNextSegment(state) }),
DEC_SEGMENT: state => ({ ...state, curIdx: safeSelectPrevSegment(state) }),
INC_WORD: state => ({ ...state, curIdx: safeSelectNextWord(state) }),
DEC_WORD: state => ({ ...state, curIdx: safeSelectPrevWord(state) }),
INC_SENTENCE: state => ({ ...state, curIdx: safeSelectNextSentence(state) }),
DEC_SENTENCE: state => ({ ...state, curIdx: safeSelectPrevSentence(state) }),
SET_MAX_LENGTH: (state, { maxLength }) => ({
...state,
maxLength,
running: false,
curIdx: 0
}),
SET_WPM: (state, { wpm }) => ({ ...state, wpm }),
SET_OFFSET: (state, { offset }) => ({ ...state, offset }),
START: state => ({ ...state, running: true }),
STOP: state => ({ ...state, running: false, curIdx: 0 }),
PAUSE: state => ({ ...state, running: false }),
SET_LANG: (state, { lang }) => ({ ...state, lang })
}
const store = createStore({
reducer,
thunks,
initialState,
window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
)
registerSelectors(selectors)
getStateWith(() => store.getState())
logging: false,
warnOnUndefinedSelect: true
})
export const Provider = store.Provider
export const useStore = store.useStore
export const useSelector = store.useSelector
export const useDispatch = store.useDispatch

View File

@ -1,58 +0,0 @@
import {
safeSelectNextWord,
safeSelectNextSentence,
safeSelectPrevWord,
safeSelectPrevSentence,
safeSelectNextSegment,
safeSelectPrevSegment
} from './selectors'
export const initialState = {
originalText: 'Sample Text',
curIdx: 0,
maxLength: 10,
isPlaying: false,
wpm: 300,
offset: -15,
running: false,
lang: 'en',
textDisplay: {
mode: 'pagination',
options: {
pagination: { pageLength: 50 },
segments: { prev: 9, next: 40 },
sentences: { prev: 1, next: 8 }
}
}
}
const reducer = {
SET_TEXT: (state, payload) => ({
...state,
originalText: payload,
curIdx: 0
}),
SET_CURRENT_SEGMENT: (state, payload) => ({ ...state, curIdx: payload }),
INC_SEGMENT: state => ({ ...state, curIdx: safeSelectNextSegment(state) }),
DEC_SEGMENT: state => ({ ...state, curIdx: safeSelectPrevSegment(state) }),
INC_WORD: state => ({ ...state, curIdx: safeSelectNextWord(state) }),
DEC_WORD: state => ({ ...state, curIdx: safeSelectPrevWord(state) }),
INC_SENTENCE: state => ({ ...state, curIdx: safeSelectNextSentence(state) }),
DEC_SENTENCE: state => ({ ...state, curIdx: safeSelectPrevSentence(state) }),
SET_MAX_LENGTH: (state, payload) => ({
...state,
maxLength: payload,
running: false,
curIdx: 0
}),
SET_WPM: (state, payload) => ({ ...state, wpm: payload }),
SET_OFFSET: (state, payload) => ({ ...state, offset: payload }),
START: state => ({ ...state, running: true }),
STOP: state => ({ ...state, running: false, curIdx: 0 }),
PAUSE: state => ({ ...state, running: false }),
SET_LANG: (state, payload) => ({ ...state, lang: payload })
}
export const reducerFn = (state, { type, payload }) => {
return reducer[type] ? reducer[type](state, payload) : state
}

28
src/styles/Container.css Normal file
View File

@ -0,0 +1,28 @@
.container {
margin: 0 auto;
}
/* Small devices (landscape phones, 576px and up) */
@media (min-width: 576px) {
}
/* Medium devices (tablets, 768px and up) */
@media (min-width: 768px) {
.container {
width: 80%;
}
}
/* Large devices (desktops, 992px and up) */
@media (min-width: 992px) {
.container {
width: 80%;
}
}
/* Extra large devices (large desktops, 1200px and up) */
@media (min-width: 1200px) {
.container {
width: 70%;
}
}

7
src/styles/Container.js Normal file
View File

@ -0,0 +1,7 @@
import React from 'react'
import styles from './Container.css'
export const Container = ({ children }) => {
return <div className={styles.container}>{children}</div>
}

6
src/styles/Div.js Normal file
View File

@ -0,0 +1,6 @@
import React from 'react'
/** A generic div. Mainly for development. Try to use as sparse as possible */
export const Div = ({ children, ...rest }) => {
return <div style={rest}>{children}</div>
}

7
src/styles/Flex.css Normal file
View File

@ -0,0 +1,7 @@
.container {
display: flex;
}
.mainItem {
flex: 1;
}

19
src/styles/Flex.js Normal file
View File

@ -0,0 +1,19 @@
import React from 'react'
import styles from './Flex.css'
export const Flex = ({ children, ...rest }) => {
return (
<div className={styles.container} style={rest}>
{children}
</div>
)
}
export const FlexMain = ({ children, ...rest }) => {
return (
<div className={styles.mainItem} style={rest}>
{children}
</div>
)
}

View File

@ -1,5 +1,6 @@
const path = require('path')
const CopyPlugin = require('copy-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin')
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer')
@ -25,16 +26,16 @@ module.exports = (env, argv) => {
{
test: /\.js$/,
exclude: /node_modules/,
loader: 'babel-loader'
loader: require.resolve('babel-loader')
},
// process css. css modules are enabled.
{
test: /\.css$/,
use: [
isEnvDevelopment && 'style-loader',
isEnvDevelopment && require.resolve('style-loader'),
isEnvProduction && MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
loader: require.resolve('css-loader'),
options: {
modules: {
localIdentName: isEnvProduction
@ -43,7 +44,7 @@ module.exports = (env, argv) => {
}
}
},
'postcss-loader'
require.resolve('postcss-loader')
].filter(Boolean)
}
]
@ -58,7 +59,9 @@ module.exports = (env, argv) => {
name: entrypoint => `runtime-${entrypoint.name}`
}
},
resolve: { modules: ['src', 'node_modules'] },
plugins: [
new CopyPlugin([{ from: 'books', to: 'books' }]),
// creat an index.html
new HtmlWebpackPlugin(),
// show a progress bar