Compare commits
32 Commits
dc35932027
...
e50a929d8b
Author | SHA1 | Date | |
---|---|---|---|
e50a929d8b | |||
34923713f5 | |||
60f92904cc | |||
c2379ff768 | |||
182b20a3ee | |||
8a08342e3c | |||
7ce9aac9cb | |||
067e1ccda6 | |||
a81a5a2458 | |||
4656f5b280 | |||
d69487b048 | |||
5afe62bdcc | |||
12fd1e3981 | |||
1ba18b7341 | |||
5267561d66 | |||
ecc9e90bec | |||
2de1c47ed6 | |||
cbbe821fde | |||
60443f481e | |||
5a32f3de0c | |||
366c338528 | |||
444ef30587 | |||
16cdc1acf7 | |||
653627033a | |||
4b98f6f654 | |||
7f2201c945 | |||
ccd684fef0 | |||
7434b1e17e | |||
3255c42253 | |||
6fbca8a306 | |||
c4d5c35950 | |||
30aa1e95dc |
@ -1,4 +1,4 @@
|
||||
{
|
||||
"extends": ["react-app", "prettier"],
|
||||
"rules": { "no-console": "warn" }
|
||||
"rules": { "no-console": ["warn", "allow": ["warn", "error"]] }
|
||||
}
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
build/
|
||||
tmp/
|
||||
tmp/
|
||||
books/
|
13
.storybook/main.js
Normal file
13
.storybook/main.js
Normal 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
8
.storybook/preview.js
Normal 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))
|
23
.storybook/webpack.config.js
Normal file
23
.storybook/webpack.config.js
Normal file
@ -0,0 +1,23 @@
|
||||
module.exports = {
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: [
|
||||
{
|
||||
loader: 'style-loader'
|
||||
},
|
||||
{
|
||||
loader: 'css-loader',
|
||||
options: {
|
||||
modules: true
|
||||
}
|
||||
},
|
||||
{
|
||||
loader: 'postcss-loader'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
@ -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
6
jsconfig.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
9322
package-lock.json
generated
9322
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
76
package.json
76
package.json
@ -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
66
scripts/downloadTop.js
Normal 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()
|
17
src/App.css
17
src/App.css
@ -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;
|
||||
}
|
||||
|
47
src/App.js
47
src/App.js
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -1,5 +1,5 @@
|
||||
.wrapper {
|
||||
overflow: hidden;
|
||||
.indicator {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.border {
|
41
src/components/Indicator.js
Normal file
41
src/components/Indicator.js
Normal 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>
|
||||
</>
|
||||
)
|
25
src/components/Indicator.stories.js
Normal file
25
src/components/Indicator.stories.js
Normal 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
30
src/components/Offset.js
Normal 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>
|
||||
}
|
41
src/components/Offset.stories.js
Normal file
41
src/components/Offset.stories.js
Normal 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>
|
||||
)
|
@ -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>
|
||||
))}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
|
@ -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 = () => {
|
||||
|
@ -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>
|
||||
|
25
src/components/RsvpWidget.js
Normal file
25
src/components/RsvpWidget.js
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
@ -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>
|
||||
|
@ -16,4 +16,5 @@
|
||||
}
|
||||
.suffix {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
@ -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>
|
||||
}
|
||||
|
13
src/components/Segment.stories.js
Normal file
13
src/components/Segment.stories.js
Normal 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>
|
||||
}
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
|
@ -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
|
||||
}
|
||||
|
15
src/components/Slider.stories.js
Normal file
15
src/components/Slider.stories.js
Normal 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} />
|
@ -1,7 +1,8 @@
|
||||
.area {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
height: 100px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.load {
|
||||
|
@ -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>
|
||||
|
@ -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'
|
||||
|
@ -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>
|
||||
}
|
||||
|
@ -38,7 +38,6 @@ export const CursorList = ({ items, onSelect = noop }) => {
|
||||
useEffect(() => {
|
||||
if (selectedRef.current) {
|
||||
selectedRef.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest'
|
||||
})
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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 })
|
@ -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
|
||||
|
@ -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
28
src/styles/Container.css
Normal 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
7
src/styles/Container.js
Normal 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
6
src/styles/Div.js
Normal 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
7
src/styles/Flex.css
Normal file
@ -0,0 +1,7 @@
|
||||
.container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.mainItem {
|
||||
flex: 1;
|
||||
}
|
19
src/styles/Flex.js
Normal file
19
src/styles/Flex.js
Normal 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>
|
||||
)
|
||||
}
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user