├── .node-version ├── app ├── common │ ├── components │ │ ├── Mark │ │ │ ├── styles.js │ │ │ └── index.js │ │ ├── KanjiStroke │ │ │ ├── index.js │ │ │ ├── Loadable.js │ │ │ ├── StrokeLoader │ │ │ │ └── styles.js │ │ │ └── styles.js │ │ ├── AddSynonym │ │ │ ├── index.js │ │ │ └── JishoSearchLink.js │ │ ├── PitchDiagram │ │ │ ├── index.js │ │ │ ├── styles.js │ │ │ ├── constants.js │ │ │ ├── PitchDiagramList.js │ │ │ └── __tests__ │ │ │ │ └── testPatterns.js │ │ ├── VocabSynonym │ │ │ ├── index.js │ │ │ └── VocabSynonymList.js │ │ ├── Strike │ │ │ └── index.js │ │ ├── SentencePair │ │ │ ├── styles.js │ │ │ ├── MarkedSentence │ │ │ │ ├── styles.js │ │ │ │ └── index.js │ │ │ └── RevealSentence │ │ │ │ ├── index.js │ │ │ │ └── styles.js │ │ ├── H2 │ │ │ └── index.js │ │ ├── H6 │ │ │ └── index.js │ │ ├── H1 │ │ │ └── index.js │ │ ├── H3 │ │ │ └── index.js │ │ ├── H4 │ │ │ └── index.js │ │ ├── H5 │ │ │ └── index.js │ │ ├── ScrollToTop │ │ │ ├── __tests__ │ │ │ │ └── __snapshots__ │ │ │ │ │ └── index.test.js.snap │ │ │ ├── ScrollTopButton │ │ │ │ ├── index.js │ │ │ │ └── styles.js │ │ │ └── index.js │ │ ├── Ul │ │ │ └── index.js │ │ ├── VocabMeaning │ │ │ └── styles.js │ │ ├── Divider │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── ReadingLinks │ │ │ └── styles.js │ │ ├── PageWrapper │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── VocabListToggleButton │ │ │ ├── styles.js │ │ │ └── index.js │ │ ├── Element │ │ │ ├── styles.js │ │ │ └── index.js │ │ ├── Ruby │ │ │ ├── __tests__ │ │ │ │ └── index.test.js │ │ │ ├── styles.js │ │ │ └── index.js │ │ ├── IconLink │ │ │ ├── styles.js │ │ │ ├── __tests__ │ │ │ │ └── index.test.js │ │ │ └── index.js │ │ ├── Img │ │ │ └── index.js │ │ ├── P │ │ │ └── index.js │ │ ├── Container │ │ │ ├── styles.js │ │ │ └── index.js │ │ ├── IconButton │ │ │ └── styles.js │ │ ├── Spinner │ │ │ └── index.js │ │ ├── StreakIcon │ │ │ ├── __tests__ │ │ │ │ └── index.test.js │ │ │ └── index.js │ │ ├── BackgroundImg │ │ │ └── index.js │ │ ├── Icon │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── TextAreaControls │ │ │ ├── styles.js │ │ │ └── index.js │ │ ├── VocabList │ │ │ ├── VocabChip │ │ │ │ └── styles.js │ │ │ └── VocabCard │ │ │ │ └── styles.js │ │ ├── TagsList │ │ │ ├── utils │ │ │ │ └── getTagColors.js │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── Loadable │ │ │ ├── index.js │ │ │ └── DefaultLoadingComponentProvider.js │ │ ├── LockButton │ │ │ └── index.js │ │ ├── VocabResetButton │ │ │ └── index.js │ │ ├── A │ │ │ └── styles.js │ │ ├── LogoLink │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── Button │ │ │ └── __tests__ │ │ │ │ └── __snapshots__ │ │ │ │ └── index.test.js.snap │ │ ├── TextAreaAutoSize │ │ │ └── styles.js │ │ └── Toggle │ │ │ └── index.js │ ├── assets │ │ ├── img │ │ │ ├── 404.png │ │ │ ├── home.jpg │ │ │ ├── logo.png │ │ │ ├── sent.png │ │ │ ├── yatta.jpg │ │ │ ├── browserstack.png │ │ │ ├── logo-square.png │ │ │ ├── maintenance.png │ │ │ ├── untrained.svg │ │ │ ├── untrained3.svg │ │ │ └── apprentice.svg │ │ ├── loops │ │ │ ├── hypno.jpg │ │ │ ├── hypno.mp4 │ │ │ ├── confused.jpg │ │ │ ├── confused.mp4 │ │ │ ├── eating.jpg │ │ │ ├── eating.mp4 │ │ │ ├── eating.webm │ │ │ ├── hypno.webm │ │ │ ├── running.jpg │ │ │ ├── running.mp4 │ │ │ ├── running.webm │ │ │ └── confused.webm │ │ └── fonts │ │ │ ├── Ubuntu-B-subset.woff │ │ │ ├── Ubuntu-B-subset.woff2 │ │ │ ├── Ubuntu-BI-subset.woff │ │ │ ├── Ubuntu-BI-subset.woff2 │ │ │ ├── Ubuntu-C-subset.woff │ │ │ ├── Ubuntu-C-subset.woff2 │ │ │ ├── Ubuntu-L-subset.woff │ │ │ ├── Ubuntu-L-subset.woff2 │ │ │ ├── Ubuntu-LI-subset.woff │ │ │ ├── Ubuntu-LI-subset.woff2 │ │ │ ├── Ubuntu-M-subset.woff │ │ │ ├── Ubuntu-M-subset.woff2 │ │ │ ├── Ubuntu-MI-subset.woff │ │ │ ├── Ubuntu-MI-subset.woff2 │ │ │ ├── Ubuntu-R-subset.woff │ │ │ ├── Ubuntu-R-subset.woff2 │ │ │ ├── Ubuntu-RI-subset.woff │ │ │ ├── Ubuntu-RI-subset.woff2 │ │ │ ├── NunitoSans-B-subset.woff │ │ │ ├── NunitoSans-L-subset.woff │ │ │ ├── NunitoSans-R-subset.woff │ │ │ ├── NunitoSans-B-subset.woff2 │ │ │ ├── NunitoSans-BI-subset.woff │ │ │ ├── NunitoSans-BI-subset.woff2 │ │ │ ├── NunitoSans-BL-subset.woff │ │ │ ├── NunitoSans-BL-subset.woff2 │ │ │ ├── NunitoSans-BLI-subset.woff │ │ │ ├── NunitoSans-BLI-subset.woff2 │ │ │ ├── NunitoSans-EB-subset.woff │ │ │ ├── NunitoSans-EB-subset.woff2 │ │ │ ├── NunitoSans-EBI-subset.woff │ │ │ ├── NunitoSans-EBI-subset.woff2 │ │ │ ├── NunitoSans-EL-subset.woff │ │ │ ├── NunitoSans-EL-subset.woff2 │ │ │ ├── NunitoSans-ELI-subset.woff │ │ │ ├── NunitoSans-ELI-subset.woff2 │ │ │ ├── NunitoSans-L-subset.woff2 │ │ │ ├── NunitoSans-LI-subset.woff │ │ │ ├── NunitoSans-LI-subset.woff2 │ │ │ ├── NunitoSans-R-subset.woff2 │ │ │ ├── NunitoSans-RI-subset.woff │ │ │ ├── NunitoSans-RI-subset.woff2 │ │ │ ├── NunitoSans-SB-subset.woff │ │ │ ├── NunitoSans-SB-subset.woff2 │ │ │ ├── NunitoSans-SBI-subset.woff │ │ │ ├── NunitoSans-SBI-subset.woff2 │ │ │ ├── Ubuntu-B-subset-subset.woff │ │ │ └── Ubuntu-B-subset-subset.woff2 │ ├── utils │ │ ├── devLog.js │ │ ├── __tests__ │ │ │ ├── __snapshots__ │ │ │ │ ├── getDateInWords.test.js.snap │ │ │ │ └── splitKeepingDelimiter.test.js.snap │ │ │ ├── calculatePercentage.test.js │ │ │ ├── getDateInWords.test.js │ │ │ ├── getSrsRankName.test.js │ │ │ ├── randomInsert.test.js │ │ │ ├── dateOrFalse.test.js │ │ │ ├── parseTags.test.js │ │ │ ├── randomHexColor.test.js │ │ │ ├── stripTilde.test.js │ │ │ ├── typeOf.test.js │ │ │ ├── createDict.test.js │ │ │ ├── groupByRank.test.js │ │ │ ├── splitKeepingDelimiter.test.js │ │ │ ├── toUniqueStringsArray.test.js │ │ │ ├── determineCriticality.test.js │ │ │ ├── smoothScrollY.test.js │ │ │ └── pluralize.test.js │ │ ├── randomInsert.js │ │ ├── stripTilde.js │ │ ├── randomHexColor.js │ │ ├── parseTags.js │ │ ├── calculatePercentage.js │ │ ├── dateOrFalse.js │ │ ├── formatSrsCounts.js │ │ ├── getDateInWords.js │ │ ├── mockOnSubmit.js │ │ ├── groupByRank.js │ │ ├── typeOf.js │ │ ├── getSrsRankName.js │ │ ├── pluralize.js │ │ ├── shouldUpdateDeepEqual.js │ │ ├── auth.js │ │ ├── toUniqueStringsArray.js │ │ ├── formatUpcomingReviews.js │ │ ├── createDict.js │ │ ├── determineCriticality.js │ │ ├── splitKeepingDelimiter.js │ │ ├── filterRomajiReadings.js │ │ ├── condenseReadings.js │ │ ├── caseKeys.js │ │ └── smoothScrollY.js │ ├── actions.js │ ├── styles │ │ ├── sizing.js │ │ ├── shadows.js │ │ └── media.js │ ├── persistence.js │ ├── selectors.js │ └── validations.js ├── features │ ├── landing │ │ ├── index.js │ │ ├── MultiLogin.js │ │ └── Input.js │ ├── vocab │ │ ├── Entry │ │ │ ├── index.js │ │ │ ├── VocabStats │ │ │ │ ├── Status.js │ │ │ │ └── StreakStatus.js │ │ │ └── Entry.js │ │ ├── Level │ │ │ ├── index.js │ │ │ ├── Notice.js │ │ │ └── reducer.js │ │ ├── Levels │ │ │ ├── index.js │ │ │ ├── Loadable.js │ │ │ └── logic.js │ │ ├── actions.js │ │ ├── reducer.js │ │ └── logic.js │ ├── dashboard │ │ ├── index.js │ │ ├── UpcomingReviewsChart │ │ │ ├── VacationImageLoadable.js │ │ │ ├── HourTick.js │ │ │ ├── BarLabel.js │ │ │ └── DayTick.js │ │ └── SrsChart │ │ │ ├── styles.js │ │ │ └── SrsLegend.js │ ├── announcements │ │ ├── index.js │ │ ├── Loadable.js │ │ ├── actions.js │ │ ├── reducer.js │ │ ├── logic.js │ │ └── styles.js │ ├── search │ │ ├── actions.js │ │ ├── selectors.js │ │ └── reducer.js │ ├── quiz │ │ ├── QuizSession │ │ │ ├── constants.js │ │ │ ├── QuizAnswer │ │ │ │ ├── selectors.js │ │ │ │ └── reducer.js │ │ │ ├── QuizInfo │ │ │ │ ├── selectors.js │ │ │ │ ├── styles.js │ │ │ │ └── reducer.js │ │ │ ├── QuizHeader │ │ │ │ └── ProgressBar.js │ │ │ ├── QuizQuestion │ │ │ │ └── Question.js │ │ │ └── styles.js │ │ └── QuizSummary │ │ │ ├── QuizSummaryHeader │ │ │ ├── styles.js │ │ │ └── SessionLink │ │ │ │ └── index.js │ │ │ └── QuizSummarySections │ │ │ ├── styles.js │ │ │ ├── StripeHeading │ │ │ ├── index.js │ │ │ └── styles.js │ │ │ ├── LastActivity.js │ │ │ ├── PercentageBar │ │ │ ├── styles.js │ │ │ └── index.js │ │ │ └── VocabListRanked.js │ ├── synonyms │ │ ├── actions.js │ │ └── selectors.js │ ├── notifications │ │ ├── constants.js │ │ ├── reducer.js │ │ └── actions.js │ ├── settings │ │ ├── actions.js │ │ ├── LastWkSync.js │ │ ├── RangeField.js │ │ ├── SelectField.js │ │ └── InputField.js │ ├── reviews │ │ └── actions.js │ ├── user │ │ ├── actions.js │ │ └── reducer.js │ └── navigation │ │ ├── NavLink │ │ ├── index.js │ │ ├── LogoutLink.js │ │ └── NavLink.js │ │ ├── Hamburger.js │ │ └── OffCanvasMenu │ │ ├── index.js │ │ └── styles.js ├── favicon.ico ├── favicon.png ├── pages │ ├── AboutPage │ │ ├── Loadable.js │ │ └── PayPalDonate.js │ ├── DevPage │ │ ├── Loadable.js │ │ └── index.js │ ├── HomePage │ │ ├── Loadable.js │ │ └── index.js │ ├── ContactPage │ │ └── Loadable.js │ ├── LandingPage │ │ ├── Loadable.js │ │ └── styles.js │ ├── NotFoundPage │ │ └── Loadable.js │ ├── SettingsPage │ │ ├── Loadable.js │ │ └── index.js │ ├── VocabEntryPage │ │ ├── Loadable.js │ │ └── index.js │ ├── VocabLevelPage │ │ ├── Loadable.js │ │ └── index.js │ ├── QuizSessionPage │ │ └── Loadable.js │ ├── QuizSummaryPage │ │ └── Loadable.js │ ├── VocabLevelsPage │ │ ├── Loadable.js │ │ └── index.js │ └── ConfirmResetPasswordPage │ │ └── Loadable.js └── reducers │ └── appReducer.js ├── .prettierignore ├── internals ├── mocks │ ├── cssModule.js │ └── image.js ├── testing │ └── setupTests.js └── scripts │ ├── helpers │ ├── xmark.js │ ├── checkmark.js │ └── progress.js │ └── analyze.js ├── server ├── argv.js ├── port.js ├── middlewares │ ├── frontendMiddleware.js │ ├── addProdMiddlewares.js │ └── addDevMiddlewares.js └── logger.js ├── .prettierrc ├── jsconfig.json ├── .gitignore ├── .travis.yml ├── DEPENDENCY_UPGRADE_NOTES.md ├── .editorconfig ├── jest.config.js ├── .vscode └── launch.json ├── .github ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md └── LICENSE.md /.node-version: -------------------------------------------------------------------------------- 1 | 16.18.1 2 | -------------------------------------------------------------------------------- /app/common/components/Mark/styles.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/styles.js 2 | **/*.js.hbs 3 | package.json -------------------------------------------------------------------------------- /internals/mocks/cssModule.js: -------------------------------------------------------------------------------- 1 | module.exports = 'CSS_MODULE'; 2 | -------------------------------------------------------------------------------- /internals/mocks/image.js: -------------------------------------------------------------------------------- 1 | module.exports = 'IMAGE_MOCK'; 2 | -------------------------------------------------------------------------------- /app/features/landing/index.js: -------------------------------------------------------------------------------- 1 | export { default } from "./MultiLogin"; 2 | -------------------------------------------------------------------------------- /app/features/vocab/Entry/index.js: -------------------------------------------------------------------------------- 1 | export { default } from "./Entry"; 2 | -------------------------------------------------------------------------------- /app/features/vocab/Level/index.js: -------------------------------------------------------------------------------- 1 | export { default } from "./Level"; 2 | -------------------------------------------------------------------------------- /app/features/dashboard/index.js: -------------------------------------------------------------------------------- 1 | export { default } from "./Dashboard"; 2 | -------------------------------------------------------------------------------- /app/features/vocab/Levels/index.js: -------------------------------------------------------------------------------- 1 | export { default } from "./Levels"; 2 | -------------------------------------------------------------------------------- /server/argv.js: -------------------------------------------------------------------------------- 1 | module.exports = require('minimist')(process.argv.slice(2)); 2 | -------------------------------------------------------------------------------- /app/common/components/KanjiStroke/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './StrokeLoader'; 2 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /app/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/favicon.png -------------------------------------------------------------------------------- /app/features/announcements/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './AnnouncementList'; 2 | -------------------------------------------------------------------------------- /app/common/components/AddSynonym/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './AddSynonymForm'; 2 | -------------------------------------------------------------------------------- /app/common/components/PitchDiagram/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './PitchDiagramList'; 2 | -------------------------------------------------------------------------------- /app/common/components/VocabSynonym/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './VocabSynonymList'; 2 | -------------------------------------------------------------------------------- /app/common/assets/img/404.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/img/404.png -------------------------------------------------------------------------------- /app/common/assets/img/home.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/img/home.jpg -------------------------------------------------------------------------------- /app/common/assets/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/img/logo.png -------------------------------------------------------------------------------- /app/common/assets/img/sent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/img/sent.png -------------------------------------------------------------------------------- /app/common/assets/img/yatta.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/img/yatta.jpg -------------------------------------------------------------------------------- /app/common/assets/loops/hypno.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/loops/hypno.jpg -------------------------------------------------------------------------------- /app/common/assets/loops/hypno.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/loops/hypno.mp4 -------------------------------------------------------------------------------- /app/common/assets/loops/confused.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/loops/confused.jpg -------------------------------------------------------------------------------- /app/common/assets/loops/confused.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/loops/confused.mp4 -------------------------------------------------------------------------------- /app/common/assets/loops/eating.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/loops/eating.jpg -------------------------------------------------------------------------------- /app/common/assets/loops/eating.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/loops/eating.mp4 -------------------------------------------------------------------------------- /app/common/assets/loops/eating.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/loops/eating.webm -------------------------------------------------------------------------------- /app/common/assets/loops/hypno.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/loops/hypno.webm -------------------------------------------------------------------------------- /app/common/assets/loops/running.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/loops/running.jpg -------------------------------------------------------------------------------- /app/common/assets/loops/running.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/loops/running.mp4 -------------------------------------------------------------------------------- /app/common/assets/loops/running.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/loops/running.webm -------------------------------------------------------------------------------- /app/common/assets/img/browserstack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/img/browserstack.png -------------------------------------------------------------------------------- /app/common/assets/img/logo-square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/img/logo-square.png -------------------------------------------------------------------------------- /app/common/assets/img/maintenance.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/img/maintenance.png -------------------------------------------------------------------------------- /app/common/assets/loops/confused.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/loops/confused.webm -------------------------------------------------------------------------------- /server/port.js: -------------------------------------------------------------------------------- 1 | const argv = require('./argv'); 2 | 3 | module.exports = parseInt(argv.port || process.env.PORT || '3000', 10); 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "singleQuote": true, 4 | "printWidth": 100, 5 | "trailingComma": "es5" 6 | } 7 | -------------------------------------------------------------------------------- /app/common/assets/fonts/Ubuntu-B-subset.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/Ubuntu-B-subset.woff -------------------------------------------------------------------------------- /app/common/assets/fonts/Ubuntu-B-subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/Ubuntu-B-subset.woff2 -------------------------------------------------------------------------------- /app/common/assets/fonts/Ubuntu-BI-subset.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/Ubuntu-BI-subset.woff -------------------------------------------------------------------------------- /app/common/assets/fonts/Ubuntu-BI-subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/Ubuntu-BI-subset.woff2 -------------------------------------------------------------------------------- /app/common/assets/fonts/Ubuntu-C-subset.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/Ubuntu-C-subset.woff -------------------------------------------------------------------------------- /app/common/assets/fonts/Ubuntu-C-subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/Ubuntu-C-subset.woff2 -------------------------------------------------------------------------------- /app/common/assets/fonts/Ubuntu-L-subset.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/Ubuntu-L-subset.woff -------------------------------------------------------------------------------- /app/common/assets/fonts/Ubuntu-L-subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/Ubuntu-L-subset.woff2 -------------------------------------------------------------------------------- /app/common/assets/fonts/Ubuntu-LI-subset.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/Ubuntu-LI-subset.woff -------------------------------------------------------------------------------- /app/common/assets/fonts/Ubuntu-LI-subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/Ubuntu-LI-subset.woff2 -------------------------------------------------------------------------------- /app/common/assets/fonts/Ubuntu-M-subset.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/Ubuntu-M-subset.woff -------------------------------------------------------------------------------- /app/common/assets/fonts/Ubuntu-M-subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/Ubuntu-M-subset.woff2 -------------------------------------------------------------------------------- /app/common/assets/fonts/Ubuntu-MI-subset.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/Ubuntu-MI-subset.woff -------------------------------------------------------------------------------- /app/common/assets/fonts/Ubuntu-MI-subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/Ubuntu-MI-subset.woff2 -------------------------------------------------------------------------------- /app/common/assets/fonts/Ubuntu-R-subset.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/Ubuntu-R-subset.woff -------------------------------------------------------------------------------- /app/common/assets/fonts/Ubuntu-R-subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/Ubuntu-R-subset.woff2 -------------------------------------------------------------------------------- /app/common/assets/fonts/Ubuntu-RI-subset.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/Ubuntu-RI-subset.woff -------------------------------------------------------------------------------- /app/common/assets/fonts/Ubuntu-RI-subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/Ubuntu-RI-subset.woff2 -------------------------------------------------------------------------------- /app/common/assets/fonts/NunitoSans-B-subset.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/NunitoSans-B-subset.woff -------------------------------------------------------------------------------- /app/common/assets/fonts/NunitoSans-L-subset.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/NunitoSans-L-subset.woff -------------------------------------------------------------------------------- /app/common/assets/fonts/NunitoSans-R-subset.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/NunitoSans-R-subset.woff -------------------------------------------------------------------------------- /app/common/assets/fonts/NunitoSans-B-subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/NunitoSans-B-subset.woff2 -------------------------------------------------------------------------------- /app/common/assets/fonts/NunitoSans-BI-subset.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/NunitoSans-BI-subset.woff -------------------------------------------------------------------------------- /app/common/assets/fonts/NunitoSans-BI-subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/NunitoSans-BI-subset.woff2 -------------------------------------------------------------------------------- /app/common/assets/fonts/NunitoSans-BL-subset.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/NunitoSans-BL-subset.woff -------------------------------------------------------------------------------- /app/common/assets/fonts/NunitoSans-BL-subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/NunitoSans-BL-subset.woff2 -------------------------------------------------------------------------------- /app/common/assets/fonts/NunitoSans-BLI-subset.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/NunitoSans-BLI-subset.woff -------------------------------------------------------------------------------- /app/common/assets/fonts/NunitoSans-BLI-subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/NunitoSans-BLI-subset.woff2 -------------------------------------------------------------------------------- /app/common/assets/fonts/NunitoSans-EB-subset.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/NunitoSans-EB-subset.woff -------------------------------------------------------------------------------- /app/common/assets/fonts/NunitoSans-EB-subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/NunitoSans-EB-subset.woff2 -------------------------------------------------------------------------------- /app/common/assets/fonts/NunitoSans-EBI-subset.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/NunitoSans-EBI-subset.woff -------------------------------------------------------------------------------- /app/common/assets/fonts/NunitoSans-EBI-subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/NunitoSans-EBI-subset.woff2 -------------------------------------------------------------------------------- /app/common/assets/fonts/NunitoSans-EL-subset.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/NunitoSans-EL-subset.woff -------------------------------------------------------------------------------- /app/common/assets/fonts/NunitoSans-EL-subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/NunitoSans-EL-subset.woff2 -------------------------------------------------------------------------------- /app/common/assets/fonts/NunitoSans-ELI-subset.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/NunitoSans-ELI-subset.woff -------------------------------------------------------------------------------- /app/common/assets/fonts/NunitoSans-ELI-subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/NunitoSans-ELI-subset.woff2 -------------------------------------------------------------------------------- /app/common/assets/fonts/NunitoSans-L-subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/NunitoSans-L-subset.woff2 -------------------------------------------------------------------------------- /app/common/assets/fonts/NunitoSans-LI-subset.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/NunitoSans-LI-subset.woff -------------------------------------------------------------------------------- /app/common/assets/fonts/NunitoSans-LI-subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/NunitoSans-LI-subset.woff2 -------------------------------------------------------------------------------- /app/common/assets/fonts/NunitoSans-R-subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/NunitoSans-R-subset.woff2 -------------------------------------------------------------------------------- /app/common/assets/fonts/NunitoSans-RI-subset.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/NunitoSans-RI-subset.woff -------------------------------------------------------------------------------- /app/common/assets/fonts/NunitoSans-RI-subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/NunitoSans-RI-subset.woff2 -------------------------------------------------------------------------------- /app/common/assets/fonts/NunitoSans-SB-subset.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/NunitoSans-SB-subset.woff -------------------------------------------------------------------------------- /app/common/assets/fonts/NunitoSans-SB-subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/NunitoSans-SB-subset.woff2 -------------------------------------------------------------------------------- /app/common/assets/fonts/NunitoSans-SBI-subset.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/NunitoSans-SBI-subset.woff -------------------------------------------------------------------------------- /app/common/assets/fonts/NunitoSans-SBI-subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/NunitoSans-SBI-subset.woff2 -------------------------------------------------------------------------------- /app/common/assets/fonts/Ubuntu-B-subset-subset.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/Ubuntu-B-subset-subset.woff -------------------------------------------------------------------------------- /app/common/assets/fonts/Ubuntu-B-subset-subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kaniwani/kw-frontend/HEAD/app/common/assets/fonts/Ubuntu-B-subset-subset.woff2 -------------------------------------------------------------------------------- /app/pages/AboutPage/Loadable.js: -------------------------------------------------------------------------------- 1 | import Loadable from 'common/components/Loadable'; 2 | 3 | export default Loadable({ 4 | loader: () => import('./index'), 5 | }); 6 | -------------------------------------------------------------------------------- /app/pages/DevPage/Loadable.js: -------------------------------------------------------------------------------- 1 | import Loadable from 'common/components/Loadable'; 2 | 3 | export default Loadable({ 4 | loader: () => import('./index'), 5 | }); 6 | -------------------------------------------------------------------------------- /app/pages/HomePage/Loadable.js: -------------------------------------------------------------------------------- 1 | import Loadable from 'common/components/Loadable'; 2 | 3 | export default Loadable({ 4 | loader: () => import('./index'), 5 | }); 6 | -------------------------------------------------------------------------------- /app/pages/ContactPage/Loadable.js: -------------------------------------------------------------------------------- 1 | import Loadable from 'common/components/Loadable'; 2 | 3 | export default Loadable({ 4 | loader: () => import('./index'), 5 | }); 6 | -------------------------------------------------------------------------------- /app/pages/LandingPage/Loadable.js: -------------------------------------------------------------------------------- 1 | import Loadable from 'common/components/Loadable'; 2 | 3 | export default Loadable({ 4 | loader: () => import('./index'), 5 | }); 6 | -------------------------------------------------------------------------------- /app/pages/NotFoundPage/Loadable.js: -------------------------------------------------------------------------------- 1 | import Loadable from 'common/components/Loadable'; 2 | 3 | export default Loadable({ 4 | loader: () => import('./index'), 5 | }); 6 | -------------------------------------------------------------------------------- /app/pages/SettingsPage/Loadable.js: -------------------------------------------------------------------------------- 1 | import Loadable from 'common/components/Loadable'; 2 | 3 | export default Loadable({ 4 | loader: () => import('./index'), 5 | }); 6 | -------------------------------------------------------------------------------- /app/pages/VocabEntryPage/Loadable.js: -------------------------------------------------------------------------------- 1 | import Loadable from 'common/components/Loadable'; 2 | 3 | export default Loadable({ 4 | loader: () => import('./index'), 5 | }); 6 | -------------------------------------------------------------------------------- /app/pages/VocabLevelPage/Loadable.js: -------------------------------------------------------------------------------- 1 | import Loadable from 'common/components/Loadable'; 2 | 3 | export default Loadable({ 4 | loader: () => import('./index'), 5 | }); 6 | -------------------------------------------------------------------------------- /app/features/announcements/Loadable.js: -------------------------------------------------------------------------------- 1 | import Loadable from 'common/components/Loadable'; 2 | 3 | export default Loadable({ 4 | loader: () => import('./index'), 5 | }); 6 | -------------------------------------------------------------------------------- /app/features/vocab/Levels/Loadable.js: -------------------------------------------------------------------------------- 1 | import Loadable from 'common/components/Loadable'; 2 | 3 | export default Loadable({ 4 | loader: () => import('./index'), 5 | }); 6 | -------------------------------------------------------------------------------- /app/pages/QuizSessionPage/Loadable.js: -------------------------------------------------------------------------------- 1 | import Loadable from 'common/components/Loadable'; 2 | 3 | export default Loadable({ 4 | loader: () => import('./index'), 5 | }); 6 | -------------------------------------------------------------------------------- /app/pages/QuizSummaryPage/Loadable.js: -------------------------------------------------------------------------------- 1 | import Loadable from 'common/components/Loadable'; 2 | 3 | export default Loadable({ 4 | loader: () => import('./index'), 5 | }); 6 | -------------------------------------------------------------------------------- /app/pages/VocabLevelsPage/Loadable.js: -------------------------------------------------------------------------------- 1 | import Loadable from 'common/components/Loadable'; 2 | 3 | export default Loadable({ 4 | loader: () => import('./index'), 5 | }); 6 | -------------------------------------------------------------------------------- /app/common/components/KanjiStroke/Loadable.js: -------------------------------------------------------------------------------- 1 | import Loadable from 'common/components/Loadable'; 2 | 3 | export default Loadable({ 4 | loader: () => import('./index'), 5 | }); 6 | -------------------------------------------------------------------------------- /app/pages/ConfirmResetPasswordPage/Loadable.js: -------------------------------------------------------------------------------- 1 | import Loadable from 'common/components/Loadable'; 2 | 3 | export default Loadable({ 4 | loader: () => import('./index'), 5 | }); 6 | -------------------------------------------------------------------------------- /internals/testing/setupTests.js: -------------------------------------------------------------------------------- 1 | // enzyme setup 2 | const { configure } = require('enzyme'); 3 | const Adapter = require('enzyme-adapter-react-16'); 4 | 5 | configure({ adapter: new Adapter() }); 6 | -------------------------------------------------------------------------------- /app/common/utils/devLog.js: -------------------------------------------------------------------------------- 1 | import { IS_DEV_ENV } from 'common/constants'; 2 | 3 | export default (...msg) => { 4 | if (IS_DEV_ENV) { 5 | console.log(...msg); // eslint-disable-line 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /app/features/dashboard/UpcomingReviewsChart/VacationImageLoadable.js: -------------------------------------------------------------------------------- 1 | import Loadable from 'common/components/Loadable'; 2 | 3 | export default Loadable({ 4 | loader: () => import('./VacationImage'), 5 | }); 6 | -------------------------------------------------------------------------------- /app/common/utils/__tests__/__snapshots__/getDateInWords.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`getDateInWords() should return a string formatted date 1`] = `"Dec 2nd 2014, 12:00am"`; 4 | -------------------------------------------------------------------------------- /app/common/components/Strike/index.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Strike = styled.span` 4 | text-decoration: ${({ color }) => `line-through${color ? ` ${color}` : ''}`}; 5 | `; 6 | 7 | export default Strike; 8 | -------------------------------------------------------------------------------- /app/common/components/SentencePair/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Wrapper = styled.div` 4 | display: flex; 5 | flex-flow: column nowrap; 6 | align-items: inherit; 7 | max-width: 42rem; 8 | `; 9 | -------------------------------------------------------------------------------- /app/common/components/H2/index.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { beta, headingRhythm } from 'common/styles/typography'; 3 | 4 | const H2 = styled.h2` 5 | ${beta} 6 | ${headingRhythm} 7 | `; 8 | 9 | export default H2; 10 | -------------------------------------------------------------------------------- /app/common/components/H6/index.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { zeta, headingRhythm } from 'common/styles/typography'; 3 | 4 | const H6 = styled.h6` 5 | ${zeta} 6 | ${headingRhythm} 7 | `; 8 | 9 | export default H6; 10 | -------------------------------------------------------------------------------- /app/common/components/H1/index.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { alpha, headingRhythm } from 'common/styles/typography'; 3 | 4 | const H1 = styled.h1` 5 | ${alpha} 6 | ${headingRhythm} 7 | `; 8 | 9 | export default H1; 10 | -------------------------------------------------------------------------------- /app/common/components/H3/index.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { gamma, headingRhythm } from 'common/styles/typography'; 3 | 4 | const H3 = styled.h3` 5 | ${gamma} 6 | ${headingRhythm} 7 | `; 8 | 9 | export default H3; 10 | -------------------------------------------------------------------------------- /app/common/components/H4/index.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { delta, headingRhythm } from 'common/styles/typography'; 3 | 4 | const H4 = styled.h4` 5 | ${delta} 6 | ${headingRhythm} 7 | `; 8 | 9 | export default H4; 10 | -------------------------------------------------------------------------------- /app/common/components/H5/index.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { epsilon, headingRhythm } from 'common/styles/typography'; 3 | 4 | const H5 = styled.h5` 5 | ${epsilon} 6 | ${headingRhythm} 7 | `; 8 | 9 | export default H5; 10 | -------------------------------------------------------------------------------- /app/common/components/PitchDiagram/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { gutter } from 'common/styles/layout'; 3 | 4 | export const Wrapper = styled.div` 5 | ${gutter()} 6 | display: inline-flex; 7 | flex-flow: column nowrap; 8 | `; 9 | -------------------------------------------------------------------------------- /internals/scripts/helpers/xmark.js: -------------------------------------------------------------------------------- 1 | const chalk = require('chalk'); 2 | 3 | /** 4 | * Adds mark cross symbol 5 | */ 6 | function addXMark(callback) { 7 | process.stdout.write(chalk.red(' ✘')); 8 | if (callback) callback(); 9 | } 10 | 11 | module.exports = addXMark; 12 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "app", 4 | "module": "commonjs", 5 | "target": "es2017", 6 | "jsx": "react" 7 | }, 8 | "paths": { 9 | "*": ["*", "app/*"] 10 | }, 11 | "exclude": ["node_modules", "**/node_modules/*"] 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # secrets 2 | .env 3 | .sentryclirc 4 | **/requests.http 5 | 6 | # Don't check auto-generated stuff into git 7 | coverage 8 | build 9 | node_modules 10 | stats.json 11 | 12 | # Cruft 13 | .DS_Store 14 | npm-debug.log 15 | .idea 16 | .chrome 17 | .vscode 18 | yarn-error.log 19 | -------------------------------------------------------------------------------- /app/features/announcements/actions.js: -------------------------------------------------------------------------------- 1 | import { createActions } from "redux-actions"; 2 | import { ASYNC } from "common/actions"; 3 | 4 | export const { announcements } = createActions({ 5 | ANNOUNCEMENTS: { 6 | LOAD: ASYNC, 7 | }, 8 | }); 9 | 10 | export default announcements; 11 | -------------------------------------------------------------------------------- /app/features/search/actions.js: -------------------------------------------------------------------------------- 1 | import { createActions } from "redux-actions"; 2 | import { SYNC, ASYNC } from "common/actions"; 3 | 4 | export const { search } = createActions({ 5 | SEARCH: { 6 | QUERY: ASYNC, 7 | CLEAR: SYNC, 8 | }, 9 | }); 10 | 11 | export default search; 12 | -------------------------------------------------------------------------------- /internals/scripts/helpers/checkmark.js: -------------------------------------------------------------------------------- 1 | const chalk = require('chalk'); 2 | 3 | /** 4 | * Adds mark check symbol 5 | */ 6 | function addCheckMark(callback) { 7 | process.stdout.write(chalk.green(' ✓')); 8 | if (callback) callback(); 9 | } 10 | 11 | module.exports = addCheckMark; 12 | -------------------------------------------------------------------------------- /app/common/utils/randomInsert.js: -------------------------------------------------------------------------------- 1 | import { random } from 'lodash'; 2 | 3 | function randomInsert(arr = [], item) { 4 | if (item == null) return arr; 5 | const loc = random(arr.length); 6 | return [...arr.slice(0, loc), item, ...arr.slice(loc)]; 7 | } 8 | 9 | export default randomInsert; 10 | -------------------------------------------------------------------------------- /app/common/components/ScrollToTop/__tests__/__snapshots__/index.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` should match the baseline snapshot 1`] = ` 4 | 9 | `; 10 | -------------------------------------------------------------------------------- /app/features/quiz/QuizSession/constants.js: -------------------------------------------------------------------------------- 1 | export const INITIAL_QUEUE_LIMIT = 100; 2 | export const SUBSEQUENT_QUEUE_LIMIT = 50; 3 | export const MINIMUM_QUEUE_COUNT = 3; 4 | export const WRAP_UP_STARTING_COUNT = 10; 5 | export const SESSION_CATEGORIES = { 6 | LESSONS: 'lessons', 7 | REVIEWS: 'reviews', 8 | }; 9 | -------------------------------------------------------------------------------- /app/features/synonyms/actions.js: -------------------------------------------------------------------------------- 1 | import { createActions } from "redux-actions"; 2 | import { SYNC, ASYNC } from "common/actions"; 3 | 4 | export const { synonyms } = createActions({ 5 | SYNONYMS: { 6 | ADD: ASYNC, 7 | REMOVE: ASYNC, 8 | BATCH_UPDATE: SYNC, 9 | }, 10 | }); 11 | 12 | export default synonyms; 13 | -------------------------------------------------------------------------------- /app/features/notifications/constants.js: -------------------------------------------------------------------------------- 1 | export const POSITIONS = { 2 | TOP_RIGHT: 'TopRight', 3 | BOTTOM_RIGHT: 'BottomRight', 4 | BOTTOM_LEFT: 'BottomLeft', 5 | TOP_LEFT: 'TopLeft', 6 | }; 7 | 8 | export const TYPES = { 9 | SUCCESS: 'SUCCESS', 10 | WARNING: 'WARNING', 11 | INFO: 'INFO', 12 | ERROR: 'ERROR', 13 | }; 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | os: osx # at least until https://github.com/tcoopman/image-webpack-loader/issues/142 is fixed 2 | language: node_js 3 | 4 | node_js: 5 | - 16 6 | 7 | script: npm run test && npm run build 8 | 9 | notifications: 10 | email: 11 | on_failure: change 12 | 13 | cache: 14 | yarn: true 15 | directories: 16 | - node_modules 17 | -------------------------------------------------------------------------------- /app/common/utils/stripTilde.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Removes '〜' or '~' from text 3 | * @param {String} [text=''] string to remove all tildes from 4 | * @return {String} cleaned string 5 | * @example 6 | * stripTilde('〜回') 7 | * // => '回' 8 | */ 9 | function stripTilde(text = '') { 10 | return text.replace(/[〜~]/gi, ''); 11 | } 12 | 13 | export default stripTilde; 14 | -------------------------------------------------------------------------------- /app/features/settings/actions.js: -------------------------------------------------------------------------------- 1 | import { createActions } from "redux-actions"; 2 | import { ASYNC } from "common/actions"; 3 | 4 | export const { settings } = createActions({ 5 | SETTINGS: { 6 | SAVE: ASYNC, 7 | CHANGE_USERNAME: ASYNC, 8 | CHANGE_PASSWORD: ASYNC, 9 | RESET_PROGRESS: ASYNC, 10 | }, 11 | }); 12 | 13 | export default settings; 14 | -------------------------------------------------------------------------------- /app/common/components/KanjiStroke/StrokeLoader/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | import { gutter } from 'common/styles/layout'; 4 | 5 | export const Wrapper = styled.div` 6 | ${gutter()} 7 | `; 8 | 9 | export const Text = styled.div` 10 | ${gutter({ position: 'horizontal', mod: 2 })} 11 | font-weight: 400; 12 | font-size: 1.3em; 13 | `; 14 | -------------------------------------------------------------------------------- /app/common/utils/randomHexColor.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-bitwise */ 2 | /** 3 | * Creates a randomised hex color string 4 | * @return {String} hex color 5 | * @example 6 | * randomHexColor() 7 | * // => '#ed6ae7' 8 | */ 9 | const randomHexColor = () => '#000000'.replace(/0/g, () => (~~(Math.random() * 16)).toString(16)); 10 | /* eslint-enable no-bitwise */ 11 | 12 | export default randomHexColor; 13 | -------------------------------------------------------------------------------- /app/features/reviews/actions.js: -------------------------------------------------------------------------------- 1 | import { createActions } from 'redux-actions'; 2 | import { SYNC, ASYNC } from 'common/actions'; 3 | 4 | export const { review } = createActions({ 5 | REVIEW: { 6 | LOAD: ASYNC, 7 | LOCK: ASYNC, 8 | UNLOCK: ASYNC, 9 | RESET: ASYNC, 10 | UPDATE: SYNC, 11 | BATCH_UPDATE: SYNC, 12 | UPDATE_NOTES: ASYNC, 13 | }, 14 | }); 15 | 16 | export default review; 17 | -------------------------------------------------------------------------------- /DEPENDENCY_UPGRADE_NOTES.md: -------------------------------------------------------------------------------- 1 | # Dependency Upgrade Notes 2 | 3 | The following dependencies will require a fair amount of refactoring to upgrade, replace, or remove. 4 | Proceed with caution. 5 | 6 | - date-fns > 1.x 7 | - react-hotkeys > 1.x 8 | - react-redux > 5.x 9 | - react-router-dom > 4.x 10 | - react-router-redux > 5.x 11 | - rebass > 2.x 12 | - redux-form > 7.x 13 | -------------------------------------------------------------------------------- /app/common/utils/parseTags.js: -------------------------------------------------------------------------------- 1 | import { TAGS } from 'common/constants'; 2 | 3 | const parseTags = (tags = []) => 4 | tags.reduce((list, tag) => { 5 | if (!Object.keys(TAGS).includes(tag)) { 6 | console.warn(`Invalid tag "${tag}" passed to parseTags()`); // eslint-disable-line no-console 7 | return list; 8 | } 9 | return list.concat(TAGS[tag]); 10 | }, []); 11 | 12 | export default parseTags; 13 | -------------------------------------------------------------------------------- /app/common/components/Ul/index.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { resetList } from 'common/styles/utils'; 3 | import { gutter } from 'common/styles/layout'; 4 | 5 | const Ul = styled.ul` 6 | margin-top: 0; 7 | margin-bottom: 0; 8 | ${({ plainList }) => plainList && resetList} 9 | ${({ plainList }) => plainList ? gutter() : gutter({ position: 'vertical' })} 10 | `; 11 | 12 | export default Ul; 13 | -------------------------------------------------------------------------------- /app/common/utils/__tests__/calculatePercentage.test.js: -------------------------------------------------------------------------------- 1 | import calculatePercentage from '../calculatePercentage'; 2 | 3 | describe('calculatePercentage', () => { 4 | it('should properly calculate percentage', () => { 5 | expect(calculatePercentage(50, 100)).toEqual(50); 6 | }); 7 | 8 | it('should return 0 when attempting to divide by zero', () => { 9 | expect(calculatePercentage(0)).toEqual(0); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /app/features/user/actions.js: -------------------------------------------------------------------------------- 1 | import { createActions } from 'redux-actions'; 2 | import { SYNC, ASYNC } from 'common/actions'; 3 | 4 | export const { user } = createActions({ 5 | USER: { 6 | QUIZ_COUNTS: ASYNC, 7 | LOAD: ASYNC, 8 | RESET_PASSWORD: ASYNC, 9 | CONFIRM_RESET_PASSWORD: ASYNC, 10 | REGISTER: ASYNC, 11 | LOGIN: ASYNC, 12 | LOGOUT: SYNC, 13 | }, 14 | }); 15 | 16 | export default user; 17 | -------------------------------------------------------------------------------- /app/features/navigation/NavLink/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import NavLink from "./NavLink"; 4 | import CountLink from "./CountLink"; 5 | import LogoutLink from "./LogoutLink"; 6 | 7 | export default (props) => { 8 | if (props.hasCount) { 9 | return ; 10 | } 11 | if (props.name === "logout") { 12 | return ; 13 | } 14 | return ; 15 | }; 16 | -------------------------------------------------------------------------------- /app/features/vocab/actions.js: -------------------------------------------------------------------------------- 1 | import { createActions } from 'redux-actions'; 2 | import { SYNC, ASYNC } from 'common/actions'; 3 | 4 | export const { vocab } = createActions({ 5 | VOCAB: { 6 | REPORT: ASYNC, 7 | BATCH_UPDATE: SYNC, 8 | LEVELS: { 9 | LOAD: ASYNC, 10 | }, 11 | LEVEL: { 12 | LOAD: ASYNC, 13 | LOCK: ASYNC, 14 | UNLOCK: ASYNC, 15 | }, 16 | }, 17 | }); 18 | 19 | export default vocab; 20 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_style = space 8 | indent_size = 2 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [*.py] 16 | indent_style = space 17 | indent_size = 4 18 | 19 | # protection for yaml if we change [*] to tabs 20 | [*.yml] 21 | indent_style = space 22 | indent_size = 2 23 | -------------------------------------------------------------------------------- /app/common/utils/__tests__/getDateInWords.test.js: -------------------------------------------------------------------------------- 1 | import getDateInWords from "../getDateInWords"; 2 | 3 | describe("getDateInWords()", () => { 4 | it("should return 'N/A' if no date passed", () => { 5 | expect(getDateInWords()).toBe("N/A"); 6 | }); 7 | 8 | it("should return a string formatted date", () => { 9 | const staticDate = new Date(2014, 11, 2); // Dec 2 2014 10 | expect(getDateInWords(staticDate)).toMatchSnapshot(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /app/features/quiz/QuizSummary/QuizSummaryHeader/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | import H2 from 'common/components/H2'; 4 | 5 | export const Wrapper = styled.div` 6 | display: flex; 7 | flex-flow: row wrap; 8 | align-items: center; 9 | align-content: center; 10 | justify-content: space-between; 11 | flex: 1 1 auto; 12 | `; 13 | 14 | export const Heading = styled(H2)` 15 | font-weight: 500; 16 | margin-left: auto; 17 | `; 18 | -------------------------------------------------------------------------------- /app/features/quiz/QuizSession/QuizAnswer/selectors.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | import { getState } from 'common/selectors'; 3 | 4 | export const UI_DOMAIN = 'quizAnswer'; 5 | export const selectAnswer = getState(UI_DOMAIN); 6 | export const selectAnswerDisabled = createSelector(selectAnswer, getState('isDisabled', false)); 7 | export const selectAnswerIgnored = createSelector(selectAnswer, getState('isIgnored', false)); 8 | 9 | export default selectAnswer; 10 | -------------------------------------------------------------------------------- /app/common/components/VocabMeaning/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | import H3 from 'common/components/H3'; 4 | import P from 'common/components/P'; 5 | import { gutter } from 'common/styles/layout'; 6 | 7 | export const PrimaryMeaning = styled(H3)` 8 | ${gutter()} 9 | `; 10 | 11 | export const SecondaryMeanings = styled(P)` 12 | ${gutter({ position: 'horizontal' })} 13 | padding-top: 0 !important; 14 | margin: 0; 15 | font-style: italic; 16 | `; 17 | -------------------------------------------------------------------------------- /app/common/utils/__tests__/getSrsRankName.test.js: -------------------------------------------------------------------------------- 1 | import getSrsRankName from "../getSrsRankName"; 2 | 3 | describe("getSrsRankName", () => { 4 | it("should default to rank one with no params", () => 5 | expect(getSrsRankName()).toMatchSnapshot()); 6 | 7 | it("should return expected ranks for given streak numbers", () => { 8 | [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10].forEach((num) => 9 | expect(getSrsRankName(num)).toMatchSnapshot() 10 | ); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /app/common/utils/calculatePercentage.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Calculates percentage as an integer, but always rounds down 3 | * (99.9, 100) => 99 (percent) 4 | * 5 | * @param {Number} numerator Partial 6 | * @param {Number} denominator Total 7 | * @return {Number} Percentage as integer 8 | */ 9 | export default function calculatePercentage(numerator, denominator) { 10 | // "|| 0" to guard against dividing 0 by 0 => NaN 11 | return Math.floor((numerator / denominator) * 100) || 0; 12 | } 13 | -------------------------------------------------------------------------------- /app/common/utils/__tests__/randomInsert.test.js: -------------------------------------------------------------------------------- 1 | import randomInsert from '../randomInsert'; 2 | 3 | describe('randomInsert', () => { 4 | it('sane defaults', () => { 5 | expect(randomInsert()).toEqual([]); 6 | expect(randomInsert([])).toEqual([]); 7 | expect(randomInsert([], 1)).toEqual([1]); 8 | }); 9 | 10 | it('inserts item', () => { 11 | const arr = Array.from({ length: 10 }, (_, i) => i); 12 | expect(randomInsert(arr, 10)).not.toEqual(arr); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /app/common/utils/dateOrFalse.js: -------------------------------------------------------------------------------- 1 | import { parse } from 'date-fns'; 2 | import typeOf from './typeOf'; 3 | 4 | // create a date instance from date-like (date, datestring etc) else false 5 | // used when rehydrating from localstorage/json which won't store real Date instances 6 | const dateOrFalse = (date) => { 7 | const shouldParse = !!date && (typeOf(date) === 'date' || typeOf(date) === 'string'); 8 | return shouldParse ? parse(date) : false; 9 | }; 10 | 11 | export default dateOrFalse; 12 | -------------------------------------------------------------------------------- /app/common/utils/formatSrsCounts.js: -------------------------------------------------------------------------------- 1 | import { titleCase } from 'voca'; 2 | import { SRS_COLORS } from 'common/styles/colors'; 3 | 4 | const formatSrsCounts = (data = {}) => { 5 | const entries = Object.entries(data); 6 | if (!entries.length) { 7 | return []; 8 | } 9 | return entries.map(([name, value], index) => ({ 10 | name: titleCase(name), 11 | value: +value, 12 | fill: Object.values(SRS_COLORS)[index], 13 | })); 14 | }; 15 | 16 | export default formatSrsCounts; 17 | -------------------------------------------------------------------------------- /app/features/vocab/Entry/VocabStats/Status.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import P from 'common/components/P'; 5 | 6 | Status.propTypes = { 7 | text: PropTypes.string.isRequired, 8 | status: PropTypes.oneOfType([ 9 | PropTypes.string, 10 | PropTypes.number, 11 | ]).isRequired, 12 | }; 13 | 14 | function Status({ text, status }) { 15 | return ( 16 |

{text}: {status}

17 | ); 18 | } 19 | 20 | export default Status; 21 | -------------------------------------------------------------------------------- /app/pages/DevPage/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | This page is just for isolated testing of components within the app using Hot Module Replacement. 3 | Content is not important, welcome to clear it anytime. 4 | */ 5 | 6 | import React from 'react'; 7 | 8 | class DevPage extends React.Component { 9 | state = {}; 10 | // updateState = () => 11 | // this.setState((prevState) => ({ })); 12 | 13 | render() { 14 | return
Hello Devpage
; 15 | } 16 | } 17 | 18 | export default DevPage; 19 | -------------------------------------------------------------------------------- /app/common/components/Divider/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { StyledDivider } from "./styles"; 4 | 5 | Divider.propTypes = { 6 | fade: PropTypes.bool, 7 | fullWidth: PropTypes.bool, 8 | color: PropTypes.string, 9 | }; 10 | 11 | Divider.defaultProps = { 12 | fade: false, 13 | fullWidth: false, 14 | color: "grey", 15 | }; 16 | 17 | function Divider(props) { 18 | return ; 19 | } 20 | 21 | export default Divider; 22 | -------------------------------------------------------------------------------- /app/common/utils/getDateInWords.js: -------------------------------------------------------------------------------- 1 | import { format } from 'date-fns'; 2 | import { DATE_TIME_FORMAT } from 'common/constants'; 3 | 4 | /** 5 | * Formats a Date() object to a human readable string 6 | * @param {Date} date js Date() 7 | * @return {String} date in words 8 | * @example 9 | * format(new Date(2014, 6, 2), DATE_TIME_FORMAT) 10 | * // => Wednesday 2 July 2014 11 | */ 12 | export default function getDateInWords(date) { 13 | return date != null ? format(date, DATE_TIME_FORMAT) : 'N/A'; 14 | } 15 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleDirectories: ['node_modules', 'app'], 3 | moduleNameMapper: { 4 | '.*\\.(css|less|styl|scss|sass)$': '/internals/mocks/cssModule.js', 5 | '.*\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': 6 | '/internals/mocks/image.js', 7 | }, 8 | snapshotSerializers: ['enzyme-to-json/serializer'], 9 | setupFiles: ['raf/polyfill', '/internals/testing/setupTests.js'], 10 | testRegex: '__tests__/.*\\.test\\.js$', 11 | }; 12 | -------------------------------------------------------------------------------- /app/reducers/appReducer.js: -------------------------------------------------------------------------------- 1 | import { handleActions } from 'redux-actions'; 2 | 3 | import { LOCATION_CHANGE } from 'react-router-redux'; 4 | 5 | const initialState = { 6 | fromPath: '', 7 | }; 8 | 9 | let fromPath = ''; 10 | 11 | const appReducer = handleActions( 12 | { 13 | [LOCATION_CHANGE]: (state, action) => { 14 | const prevPath = fromPath; 15 | fromPath = action.payload.pathname; 16 | return { ...state, fromPath: prevPath }; 17 | }, 18 | }, 19 | initialState 20 | ); 21 | 22 | export default appReducer; 23 | -------------------------------------------------------------------------------- /app/common/utils/mockOnSubmit.js: -------------------------------------------------------------------------------- 1 | import { SubmissionError } from "redux-form"; 2 | 3 | const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 4 | 5 | const mockOnSubmit = (mockErrors = {}) => (values) => sleep(1000).then(() => { 6 | // simulate server latency 7 | if (Object.keys(mockErrors).length) { 8 | throw new SubmissionError(mockErrors); 9 | } else { 10 | window.alert(`You submitted:\n\n${JSON.stringify(values, null, 2)}`); // eslint-disable-line no-alert 11 | } 12 | }); 13 | 14 | export default mockOnSubmit; 15 | -------------------------------------------------------------------------------- /app/features/quiz/QuizSession/QuizInfo/selectors.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | import { getState } from 'common/selectors'; 3 | 4 | export const UI_DOMAIN = 'quizInfo'; 5 | export const selectInfo = getState(UI_DOMAIN); 6 | export const selectInfoOpen = createSelector(selectInfo, getState('isOpen', false)); 7 | export const selectInfoDisabled = createSelector(selectInfo, getState('isDisabled', true)); 8 | export const selectInfoDetailLevel = createSelector(selectInfo, getState('detailLevel', 0)); 9 | 10 | export default selectInfo; 11 | -------------------------------------------------------------------------------- /app/common/utils/__tests__/dateOrFalse.test.js: -------------------------------------------------------------------------------- 1 | import dateOrFalse from '../dateOrFalse'; 2 | 3 | describe('dateOrFalse', () => { 4 | it('returns date or false', () => { 5 | expect(dateOrFalse()).toBe(false); 6 | expect(dateOrFalse(null)).toBe(false); 7 | expect(dateOrFalse(true)).toBe(false); 8 | expect(dateOrFalse(false)).toBe(false); 9 | expect(dateOrFalse('')).toBe(false); 10 | expect(dateOrFalse({})).toBe(false); 11 | expect(dateOrFalse([])).toBe(false); 12 | expect(dateOrFalse(new Date())).toBeDefined(); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /server/middlewares/frontendMiddleware.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | 3 | module.exports = (app, options) => { 4 | const isProd = process.env.NODE_ENV === 'production'; 5 | 6 | if (isProd) { 7 | const addProdMiddlewares = require('./addProdMiddlewares'); 8 | addProdMiddlewares(app, options); 9 | } else { 10 | const webpackConfig = require('../../internals/webpack/webpack.dev.babel'); 11 | const addDevMiddlewares = require('./addDevMiddlewares'); 12 | addDevMiddlewares(app, webpackConfig); 13 | } 14 | 15 | return app; 16 | }; 17 | -------------------------------------------------------------------------------- /app/common/assets/img/untrained.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/common/components/ReadingLinks/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | import A from 'common/components/A'; 4 | 5 | import { gutter } from 'common/styles/layout'; 6 | 7 | export const Li = styled.li` 8 | display: inline-flex; 9 | max-width: 100%; 10 | vertical-align: middle; 11 | align-items: center; 12 | `; 13 | 14 | export const VocabLink = styled(A)` 15 | ${gutter({ position: 'vertical' })} 16 | ${gutter({ position: 'horizontal', mod: 1.5 })} 17 | display: block; 18 | font-size: 0.9em; 19 | text-decoration: underline; 20 | `; 21 | -------------------------------------------------------------------------------- /app/common/utils/groupByRank.js: -------------------------------------------------------------------------------- 1 | import { SRS_RANKS } from 'common/constants'; 2 | import getSrsRankName from 'common/utils/getSrsRankName'; 3 | 4 | function groupByRank(items = []) { 5 | const ranks = { 6 | [SRS_RANKS.ZERO]: [], 7 | [SRS_RANKS.ONE]: [], 8 | [SRS_RANKS.TWO]: [], 9 | [SRS_RANKS.THREE]: [], 10 | [SRS_RANKS.FOUR]: [], 11 | [SRS_RANKS.FIVE]: [], 12 | }; 13 | 14 | items.forEach((item) => { 15 | ranks[getSrsRankName(item.streak)].push(item.id); 16 | }); 17 | 18 | return ranks; 19 | } 20 | 21 | export default groupByRank; 22 | -------------------------------------------------------------------------------- /app/common/components/SentencePair/MarkedSentence/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | import P from 'common/components/P'; 4 | import Mark from 'common/components/Mark'; 5 | 6 | import { transparent, grey, purple } from 'common/styles/colors'; 7 | import { gamma } from 'common/styles/typography'; 8 | 9 | export const Sentence = styled(P)` 10 | line-height: 1; 11 | color: ${grey[7]}; 12 | ${gamma}; 13 | `; 14 | 15 | export const VocabMark = styled(Mark).attrs({ 16 | bgColor: transparent, 17 | color: purple[4], 18 | pad: false, 19 | })``; 20 | -------------------------------------------------------------------------------- /app/common/components/PageWrapper/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { Wrapper } from './styles'; 5 | 6 | PageWrapper.propTypes = { 7 | fullWidth: PropTypes.bool, 8 | fullWidthBg: PropTypes.bool, 9 | }; 10 | 11 | PageWrapper.defaultProps = { 12 | fullWidth: false, 13 | fullWidthBg: false, 14 | }; 15 | 16 | function PageWrapper({ fullWidth, fullWidthBg, ...props }) { 17 | return ( 18 | 19 | ); 20 | } 21 | 22 | export default PageWrapper; 23 | -------------------------------------------------------------------------------- /app/features/search/selectors.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | import { getState, getBy } from 'common/selectors'; 3 | 4 | export const DOMAIN = 'search'; 5 | export const selectSearch = getState(DOMAIN); 6 | 7 | export const selectSearchResultIds = createSelector(selectSearch, getState('ids', [])); 8 | export const selectIsSearching = createSelector(selectSearch, getBy('isSearching', Boolean)); 9 | export const selectIsSearchComplete = createSelector( 10 | selectSearch, 11 | getBy('isSearchComplete', Boolean) 12 | ); 13 | 14 | export default selectSearch; 15 | -------------------------------------------------------------------------------- /app/common/utils/__tests__/parseTags.test.js: -------------------------------------------------------------------------------- 1 | import parseTags from "../parseTags"; 2 | 3 | describe("parseTags()", () => { 4 | it("sane default", () => { 5 | expect(parseTags()).toEqual([]); 6 | }); 7 | 8 | it("single tag", () => { 9 | expect(parseTags(["n"])).toEqual(["Noun"]); 10 | }); 11 | 12 | it("multi tags", () => { 13 | expect(parseTags(["n", "adj", "vs"])).toEqual(["Noun", "Adjective", "Suru Verb"]); 14 | }); 15 | 16 | it("skips invalid tags", () => { 17 | expect(parseTags(["n", "derp", "vs"])).toEqual(["Noun", "Suru Verb"]); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /app/features/notifications/reducer.js: -------------------------------------------------------------------------------- 1 | import { handleActions, combineActions } from 'redux-actions'; 2 | 3 | import actions from './actions'; 4 | 5 | export const initialState = []; 6 | 7 | export const notificationsReducer = handleActions( 8 | { 9 | [combineActions(actions.success, actions.info, actions.warning, actions.error)]: ( 10 | state, 11 | { payload } 12 | ) => [...state, payload], 13 | [actions.remove]: (state, { payload }) => state.filter((item) => item.id !== payload.id), 14 | }, 15 | initialState 16 | ); 17 | 18 | export default notificationsReducer; 19 | -------------------------------------------------------------------------------- /app/common/components/VocabListToggleButton/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | import IconButton from 'common/components/IconButton'; 4 | import { gutter } from 'common/styles/layout'; 5 | import { fastEaseQuad } from 'common/styles/animation'; 6 | 7 | export const ToggleButton = styled(IconButton)` 8 | ${gutter({ position: 'horizontal', mod: 1.5 })} 9 | line-height: 1; 10 | font-size: 1.3em; 11 | transition: all ${fastEaseQuad}, transform 100ms linear; 12 | transform: scale(1); 13 | 14 | &:active { 15 | opacity: 1; 16 | transform: scale(.9); 17 | } 18 | `; 19 | -------------------------------------------------------------------------------- /app/common/utils/typeOf.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns type of provided value with normalized strings. 3 | * IE 'array' instead of '[object Array]' for []. 4 | * 5 | * @param {Any} value value to test 6 | * @return {String} type (number, nan, object, array, map, set, regexp, date, function etc) 7 | */ 8 | export default function typeOf(value) { 9 | switch (true) { 10 | case (Number.isNaN(value)): return 'nan'; 11 | case (value === null): return 'null'; 12 | case (value !== Object(value)): return typeof value; 13 | default: return ({}).toString.call(value).slice(8, -1).toLowerCase(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/features/notifications/actions.js: -------------------------------------------------------------------------------- 1 | import { createActions } from 'redux-actions'; 2 | import cuid from 'cuid'; 3 | import { TYPES } from './constants'; 4 | 5 | export const { notify } = createActions({ 6 | NOTIFY: { 7 | SUCCESS: (payload) => ({ ...payload, id: cuid(), type: TYPES.SUCCESS }), 8 | INFO: (payload) => ({ ...payload, id: cuid(), type: TYPES.INFO }), 9 | WARNING: (payload) => ({ ...payload, id: cuid(), type: TYPES.WARNING }), 10 | ERROR: (payload) => ({ ...payload, id: cuid(), type: TYPES.ERROR }), 11 | REMOVE: (id) => ({ id }), 12 | }, 13 | }); 14 | 15 | export default notify; 16 | -------------------------------------------------------------------------------- /app/common/actions.js: -------------------------------------------------------------------------------- 1 | import { createActions } from 'redux-actions'; 2 | 3 | // optional meta as 2nd arg 4 | const actionSignature = [(payload = {}) => payload, (payload, meta) => meta]; 5 | 6 | // { type: TYPE, payload [, meta ] } 7 | export const SYNC = actionSignature; 8 | 9 | // { type: TYPE.REQUEST|SUCCESS|FAILURE, payload [, meta ] } 10 | export const ASYNC = { 11 | REQUEST: actionSignature, 12 | SUCCESS: actionSignature, 13 | FAILURE: actionSignature, 14 | // CANCEL: actionSignature, 15 | }; 16 | 17 | export const { app } = createActions({ 18 | APP: { 19 | CAPTURE_ERROR: SYNC, 20 | }, 21 | }); 22 | -------------------------------------------------------------------------------- /app/common/components/Element/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { 3 | alignContentMixin, 4 | alignItemsMixin, 5 | alignSelfMixin, 6 | flexCenterMixin, 7 | flexMixin, 8 | flexShorthandMixin, 9 | fullRowMixin, 10 | justifyContentMixin, 11 | textAlignMixin, 12 | } from 'common/styles/layout'; 13 | 14 | export const StyledElement = styled.div` 15 | ${fullRowMixin} 16 | ${flexMixin} 17 | ${alignContentMixin} 18 | ${alignItemsMixin} 19 | ${alignSelfMixin} 20 | ${justifyContentMixin} 21 | ${textAlignMixin} 22 | ${flexCenterMixin} 23 | ${flexShorthandMixin} 24 | `; 25 | -------------------------------------------------------------------------------- /app/features/dashboard/UpcomingReviewsChart/HourTick.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { grey } from 'common/styles/colors'; 5 | 6 | const HourTick = ({ x, y, payload }) => ( 7 | 8 | 9 | {payload.value} 10 | 11 | 12 | ); 13 | 14 | /* eslint-disable react/require-default-props */ 15 | HourTick.propTypes = { 16 | x: PropTypes.number, 17 | y: PropTypes.number, 18 | payload: PropTypes.object, 19 | }; 20 | 21 | export default HourTick; 22 | -------------------------------------------------------------------------------- /app/common/components/PitchDiagram/constants.js: -------------------------------------------------------------------------------- 1 | export const WEBLIO_QUERY_URL = 'https://www.weblio.jp/content?query='; 2 | 3 | export const HIRA_DIGRAPHS = ['ぁ', 'ぃ', 'ぅ', 'ぇ', 'ぉ', 'ゃ', 'ゅ', 'ょ', 'ゎ', 'ゕ', 'ゖ']; 4 | export const KATA_DIGRAPHS = ['ァ', 'ィ', 'ゥ', 'ェ', 'ォ', 'ャ', 'ュ', 'ョ', 'ヮ', 'ヵ', 'ヶ']; 5 | 6 | export const PATTERN_NAMES = { 7 | HEIBAN: { 8 | EN: 'heiban', 9 | JA: '平板', 10 | }, 11 | ATAMADAKA: { 12 | EN: 'atamadaka', 13 | JA: '頭高', 14 | }, 15 | NAKADAKA: { 16 | EN: 'nakadaka', 17 | JA: '中高', 18 | }, 19 | ODAKA: { 20 | EN: 'odaka', 21 | JA: '尾高', 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /app/common/styles/sizing.js: -------------------------------------------------------------------------------- 1 | export const siteMaxWidth = 1300; // For use with Math operations 2 | export const siteMaxWidthpx = '1300px'; 3 | 4 | export const mod1 = 1.125; // major third (mobile) 5 | export const mod2 = 1.333; // perfect fourth (desktop) 6 | 7 | export const gutters = { 8 | mobile: { 9 | outer: (0.7 * mod1) / 2, 10 | inner: (0.4 * mod1) / 2, 11 | }, 12 | desktop: { 13 | outer: (1 * mod2) / 2, 14 | inner: (0.5 * mod2) / 2, 15 | }, 16 | }; 17 | 18 | // Borders 19 | export const borderRadius = '.2em'; 20 | export const borderRadiusSmall = '3px'; 21 | export const borderWidth = '.1em'; 22 | -------------------------------------------------------------------------------- /app/common/utils/getSrsRankName.js: -------------------------------------------------------------------------------- 1 | import { SRS_RANKS } from 'common/constants'; 2 | 3 | /** 4 | * Returns name of srs rank from provided number 5 | * 6 | * @param {Number} streak Current srs rank 7 | * @return {String} Rank name 8 | */ 9 | export default function getSrsRankName(streak) { 10 | switch (true) { 11 | case (streak > 8): return SRS_RANKS.FIVE; 12 | case (streak > 7): return SRS_RANKS.FOUR; 13 | case (streak > 6): return SRS_RANKS.THREE; 14 | case (streak > 4): return SRS_RANKS.TWO; 15 | case (streak > 0): return SRS_RANKS.ONE; 16 | default: return SRS_RANKS.ZERO; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/common/utils/pluralize.js: -------------------------------------------------------------------------------- 1 | // default, can pass other schemas to pluralize as needed 2 | const makeSchema = (subject) => ({ 3 | single: subject, 4 | plural: `${subject}s`, 5 | }); 6 | 7 | const makeGetText = (schema) => (descriptor) => `${schema[descriptor]}`; 8 | 9 | export function pluralize(subject = '', amount = 1, schema) { 10 | const thisSchema = schema || makeSchema(subject); 11 | const getText = makeGetText(thisSchema); 12 | const count = Math.abs(parseInt(amount, 10)); 13 | const singular = count === 1; 14 | 15 | return getText(singular ? 'single' : 'plural'); 16 | } 17 | 18 | export default pluralize; 19 | -------------------------------------------------------------------------------- /app/common/components/SentencePair/RevealSentence/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import { Wrapper, Sentence, RevealIcon } from "./styles"; 5 | 6 | RevealSentence.propTypes = { 7 | sentence: PropTypes.string, 8 | }; 9 | 10 | RevealSentence.defaultProps = { 11 | sentence: '', 12 | }; 13 | 14 | function RevealSentence({ sentence }) { 15 | return sentence && ( 16 | 17 | 18 | {sentence} 19 | 20 | 21 | 22 | ); 23 | } 24 | 25 | export default RevealSentence; 26 | -------------------------------------------------------------------------------- /app/features/dashboard/UpcomingReviewsChart/BarLabel.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { black } from 'common/styles/colors'; 5 | 6 | const BarLabel = ({ x, y, width, value }) => 7 | value > 0 ? ( 8 | 9 | {value} 10 | 11 | ) : null; 12 | 13 | /* eslint-disable react/require-default-props */ 14 | BarLabel.propTypes = { 15 | x: PropTypes.number, 16 | y: PropTypes.number, 17 | width: PropTypes.number, 18 | value: PropTypes.number, 19 | }; 20 | 21 | export default BarLabel; 22 | -------------------------------------------------------------------------------- /app/common/utils/__tests__/randomHexColor.test.js: -------------------------------------------------------------------------------- 1 | import randomHexColor from "../randomHexColor"; 2 | 3 | describe("randomHexColor()", () => { 4 | it("should return a hex color string", () => { 5 | const hexColorStringRegex = new RegExp( 6 | /^#?(?:(?:[0-9a-fA-F]{2}){3}|(?:[0-9a-fA-F]){3})$/ 7 | ); 8 | expect(randomHexColor()).toMatch(hexColorStringRegex); 9 | }); 10 | 11 | it("should be different on subsequent calls", () => { 12 | const colors = Array.from({ length: 50 }).map(() => randomHexColor()); 13 | const uniqueColors = new Set(colors); 14 | expect(colors.length).toEqual(uniqueColors.size); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /app/common/utils/shouldUpdateDeepEqual.js: -------------------------------------------------------------------------------- 1 | import { isEqual } from 'lodash'; 2 | import { shouldUpdate } from 'recompose'; 3 | 4 | /** 5 | * HoC which applies shouldUpdate comparing deep equal for provided prop names, else tests every prop 6 | * @param {Array} propNames props to test if deep equal 7 | * @return {Function} HoC expecting a component 8 | */ 9 | const shouldUpdateDeepEqual = (propNames) => 10 | shouldUpdate((props, nextProps) => 11 | Array.isArray(propNames) 12 | ? propNames.some((propName) => !isEqual(props[propName], nextProps[propName])) 13 | : !isEqual(props, nextProps)); 14 | 15 | export default shouldUpdateDeepEqual; 16 | -------------------------------------------------------------------------------- /app/common/utils/auth.js: -------------------------------------------------------------------------------- 1 | export const getToken = () => localStorage.getItem('jwt'); 2 | export const setToken = (token) => localStorage.setItem('jwt', token); 3 | export const clearToken = () => localStorage.removeItem('jwt'); 4 | export const hasToken = () => { 5 | let token; 6 | try { 7 | token = getToken(); 8 | } catch (error) { 9 | // eslint-disable-next-line no-alert 10 | window.alert( 11 | 'You appear to have LocalStorage disabled or you are in private browsing mode. KaniWani will not function correctly if it cannot store local data. Please enable LocalStorage and try again.' 12 | ); 13 | } 14 | return token != null; 15 | }; 16 | -------------------------------------------------------------------------------- /app/common/styles/shadows.js: -------------------------------------------------------------------------------- 1 | export const textThin = '1px 1px 1px rgba(59, 59, 59, .4)'; 2 | export const textLight = '2px 2px 1px rgba(59, 59, 59, .5)'; 3 | export const innerLight = 'inset 3px 3px 8px 0 rgba(59, 59, 59, 0.25)'; 4 | export const innerTop = 'inset 1px 10px 9px -6px rgba(59, 59, 59, 0.25)'; 5 | export const innerMedium = 'inset 0 3px 20px -8px rgba(59, 59, 59, 0.25)'; 6 | export const outerLight = '0 0 25px -5px rgba(59, 59, 59, 0.25)'; 7 | export const outerLine = '0 0 0.1em 0.1em rgba(0, 0, 0, 0.3)'; 8 | export const bottomLight = '0 1px 10px rgba(59, 59, 59, 0.1)'; 9 | 10 | export const shadowBox = 'box-shadow: 0.05rem 0.05rem 0.1rem rgba(0,0,0, 0.2);'; 11 | -------------------------------------------------------------------------------- /app/common/utils/toUniqueStringsArray.js: -------------------------------------------------------------------------------- 1 | import { isString, uniq } from 'lodash'; 2 | 3 | /** 4 | * Takes a comma separated string or array and casts to an array of unique strings 5 | * @param {String|Array} data strings 6 | * @return {Array} unique strings 7 | */ 8 | const toUniqueStringsArray = (data = []) => { 9 | const isCommaString = isString(data) && data.includes(', '); 10 | if (isCommaString) { 11 | return uniq(data.split(', ')); 12 | } else if (Array.isArray(data)) { 13 | return uniq(data); 14 | } else if (isString(data) && data.length) { 15 | return [data]; 16 | } 17 | return []; 18 | }; 19 | 20 | export default toUniqueStringsArray; 21 | -------------------------------------------------------------------------------- /app/common/assets/img/untrained3.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/common/components/Ruby/__tests__/index.test.js: -------------------------------------------------------------------------------- 1 | import 'jest-styled-components'; 2 | import React from 'react'; 3 | import { render } from 'enzyme'; 4 | 5 | import Ruby from '../index'; 6 | 7 | describe('', () => { 8 | it('no furi provided: render entire reading over kanji block', () => { 9 | const renderedComponent = render(); 10 | expect(renderedComponent).toMatchSnapshot(); 11 | }); 12 | 13 | it('furi provided: render readings over related kanji blocks', () => { 14 | const renderedComponent = render(); 15 | expect(renderedComponent).toMatchSnapshot(); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /app/features/quiz/QuizSummary/QuizSummarySections/styles.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | import { gutter } from "common/styles/layout"; 4 | 5 | import H2 from "common/components/H2"; 6 | import H3 from "common/components/H3"; 7 | 8 | // prettier-ignore 9 | export const Section = styled.section` 10 | ${gutter({ type: "inner", position: "vertical" })} 11 | ${gutter({ type: "outer", position: "horizontal" })}; 12 | `; 13 | 14 | // prettier-ignore 15 | export const Heading = styled(H3)` 16 | ${gutter({ type: "outer", position: "horizontal" })} 17 | text-transform: capitalize; 18 | `; 19 | 20 | export const Placeholder = styled(H2)` 21 | font-weight: 400; 22 | `; 23 | -------------------------------------------------------------------------------- /app/common/utils/__tests__/__snapshots__/splitKeepingDelimiter.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`splitKeepingDelimiter() should accept a custom delimiter 1`] = ` 4 | Array [ 5 | "This", 6 | ";", 7 | "is", 8 | ";", 9 | "a", 10 | ";", 11 | "custom example", 12 | ] 13 | `; 14 | 15 | exports[`splitKeepingDelimiter() should accept regex flags 1`] = ` 16 | Array [ 17 | "Split ignoring ", 18 | "WORD", 19 | " case", 20 | ] 21 | `; 22 | 23 | exports[`splitKeepingDelimiter() should split words by comma as default 1`] = ` 24 | Array [ 25 | "This", 26 | ",", 27 | "is", 28 | ",", 29 | "a", 30 | ",", 31 | "default example", 32 | ] 33 | `; 34 | -------------------------------------------------------------------------------- /app/common/components/Ruby/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | import { gutter } from 'common/styles/layout'; 4 | 5 | export const Wrapper = styled.div` 6 | ${gutter()} 7 | display: inline-flex; 8 | `; 9 | 10 | export const Block = styled.div` 11 | display: flex; 12 | line-height: 1; 13 | flex-flow: column nowrap; 14 | justify-content: flex-end; 15 | align-items: center; 16 | align-self: flex-end; 17 | `; 18 | 19 | export const Furi = styled.div` 20 | font-size: 0.95em; 21 | letter-spacing: -0.025em; 22 | padding-bottom: 0.2em; 23 | opacity: 0.9; 24 | user-select: none; 25 | `; 26 | 27 | export const Chars = styled.div` 28 | font-size: 2.3em; 29 | `; 30 | -------------------------------------------------------------------------------- /app/common/components/IconLink/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | import A from 'common/components/A'; 4 | import { ghost } from 'common/styles/utils'; 5 | import { fastEaseQuad } from 'common/styles/animation'; 6 | 7 | export const Link = styled(A)` 8 | transition: all ${fastEaseQuad}, transform 100ms linear; 9 | cursor: pointer; 10 | opacity: .7; 11 | ${({ visuallyHidden }) => visuallyHidden && ghost} 12 | 13 | &:focus, 14 | &:hover { 15 | opacity: 1; 16 | outline: none; 17 | } 18 | 19 | &:active { 20 | opacity: 1; 21 | } 22 | 23 | & > * { 24 | transform: scale(1); 25 | &:active { 26 | transform: scale(.9); 27 | } 28 | } 29 | `; 30 | -------------------------------------------------------------------------------- /app/common/components/Img/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components'; 4 | 5 | const StyledImg = styled.img` 6 | display: block; 7 | max-width: 100%; 8 | height: auto; 9 | `; 10 | 11 | Img.propTypes = { 12 | src: PropTypes.oneOfType([ 13 | PropTypes.string, 14 | PropTypes.object, 15 | ]).isRequired, 16 | alt: PropTypes.string.isRequired, 17 | className: PropTypes.string, 18 | }; 19 | 20 | Img.defaultProps = { 21 | className: '', 22 | }; 23 | 24 | function Img({ className, src, alt }) { 25 | return ( 26 | 27 | ); 28 | } 29 | 30 | export default Img; 31 | -------------------------------------------------------------------------------- /app/features/navigation/Hamburger.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components'; 4 | import IconButton from 'common/components/IconButton'; 5 | 6 | // prettier-ignore 7 | const Toggle = styled(IconButton)` margin: 0 !important;`; 8 | 9 | const Hamburger = ({ size, title, onToggle }) => ( 10 | 11 | ); 12 | 13 | Hamburger.propTypes = { 14 | title: PropTypes.string, 15 | size: PropTypes.string, 16 | onToggle: PropTypes.func.isRequired, 17 | }; 18 | 19 | Hamburger.defaultProps = { 20 | title: 'Toggle Menu', 21 | size: '2.5em', 22 | }; 23 | 24 | export default Hamburger; 25 | -------------------------------------------------------------------------------- /app/common/components/VocabSynonym/VocabSynonymList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import VocabSynonym from 'common/components/VocabSynonym/VocabSynonym'; 5 | import Element from 'common/components/Element'; 6 | 7 | VocabSynonymList.propTypes = { 8 | ids: PropTypes.arrayOf(PropTypes.number).isRequired, 9 | reviewId: PropTypes.number.isRequired, 10 | }; 11 | 12 | export function VocabSynonymList({ ids, reviewId }) { 13 | return ( 14 | ids.length > 0 && ( 15 | 16 | {ids.map((id) => )} 17 | 18 | ) 19 | ); 20 | } 21 | 22 | export default VocabSynonymList; 23 | -------------------------------------------------------------------------------- /internals/scripts/analyze.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const shelljs = require('shelljs'); 4 | const animateProgress = require('./helpers/progress'); 5 | const addCheckMark = require('./helpers/checkmark'); 6 | 7 | const progress = animateProgress('Generating stats'); 8 | 9 | // Generate stats.json file with webpack 10 | shelljs.exec( 11 | 'webpack --config internals/webpack/webpack.prod.babel.js --profile --json > stats.json', 12 | addCheckMark.bind(null, callback) // Output a checkmark on completion 13 | ); 14 | 15 | // Called after webpack has finished generating the stats.json file 16 | function callback() { 17 | clearInterval(progress); 18 | shelljs.exec('webpack-bundle-analyzer stats.json'); 19 | } 20 | -------------------------------------------------------------------------------- /app/common/components/P/index.js: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components'; 2 | import { epsilon, bodyRhythm } from 'common/styles/typography'; 3 | 4 | const alignMixin = ({ align }) => css` 5 | margin-left: ${align === 'center' ? 'auto' : 0}; 6 | margin-right: ${align === 'center' ? 'auto' : 0}; 7 | `; 8 | 9 | const textAlignMixin = ({ textAlign }) => textAlign 10 | && css` 11 | text-align: ${textAlign}; 12 | `; 13 | 14 | const constrainMixin = ({ constrain }) => constrain 15 | && css` 16 | max-width: 45em; 17 | `; 18 | 19 | export const P = styled.p` 20 | ${epsilon} 21 | ${bodyRhythm} 22 | ${alignMixin} 23 | ${textAlignMixin} 24 | ${constrainMixin} 25 | `; 26 | 27 | export default P; 28 | -------------------------------------------------------------------------------- /app/common/utils/formatUpcomingReviews.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-return-assign */ 2 | import { format, addHours, addDays } from 'date-fns'; 3 | 4 | const formatUpcomingReviews = (data = []) => { 5 | let extraDays = 0; 6 | const getFutureDayName = (daysAhead = 0) => format(addDays(new Date(), daysAhead), 'dddd'); 7 | const genDay = (hour) => (hour === '12am' ? getFutureDayName((extraDays += 1)) : ''); 8 | const genHour = (index) => `${format(addHours(new Date(), index), 'ha')}`; 9 | 10 | return data.reduce((list, value, index) => { 11 | const hour = genHour(index); 12 | return list.concat({ 13 | day: genDay(hour), 14 | hour, 15 | value, 16 | }); 17 | }, []); 18 | }; 19 | 20 | export default formatUpcomingReviews; 21 | -------------------------------------------------------------------------------- /app/features/quiz/QuizSession/QuizHeader/ProgressBar.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Motion, spring } from "react-motion"; 4 | 5 | import { Bar, Percentage } from './styles'; 6 | 7 | ProgressBar.propTypes = { 8 | value: PropTypes.number, 9 | }; 10 | 11 | ProgressBar.defaultProps = { 12 | value: 0, 13 | }; 14 | 15 | function ProgressBar({ value, ...props }) { 16 | const percent = Math.min(value, 100); // prevent width going over 100% 17 | return ( 18 | 19 | 20 | {({ percentX }) => } 21 | 22 | 23 | ); 24 | } 25 | 26 | export default ProgressBar; 27 | -------------------------------------------------------------------------------- /internals/scripts/helpers/progress.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const readline = require('readline'); 4 | 5 | /** 6 | * Adds an animated progress indicator 7 | * 8 | * @param {string} message The message to write next to the indicator 9 | * @param {number} amountOfDots The amount of dots you want to animate 10 | */ 11 | function animateProgress(message, amountOfDots) { 12 | if (typeof amountOfDots !== 'number') { 13 | amountOfDots = 3; 14 | } 15 | 16 | let i = 0; 17 | return setInterval(function() { 18 | readline.cursorTo(process.stdout, 0); 19 | i = (i + 1) % (amountOfDots + 1); 20 | const dots = new Array(i + 1).join('.'); 21 | process.stdout.write(message + dots); 22 | }, 500); 23 | } 24 | 25 | module.exports = animateProgress; 26 | -------------------------------------------------------------------------------- /app/common/components/PageWrapper/styles.js: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components"; 2 | 3 | import { centerByPadding, centerByMargin } from "common/styles/layout"; 4 | 5 | const fullWidthStyle = css` 6 | margin: 0; 7 | padding: 0 0 5rem 0; 8 | width: 100%; 9 | height: 100%; 10 | min-height: 100vh; 11 | `; 12 | 13 | const fullWidthBgStyle = css` 14 | position: relative; 15 | ${centerByPadding} 16 | `; 17 | 18 | export const Wrapper = styled.div` 19 | ${({ fullWidth, fullWidthBg }) => { 20 | switch (true) { 21 | case fullWidth: 22 | return fullWidthStyle; 23 | case fullWidthBg: 24 | return fullWidthBgStyle; 25 | default: 26 | return centerByMargin; 27 | } 28 | }} 29 | padding-bottom: 4vh; 30 | `; 31 | -------------------------------------------------------------------------------- /app/common/utils/__tests__/stripTilde.test.js: -------------------------------------------------------------------------------- 1 | import stripTilde from "../stripTilde"; 2 | 3 | describe("stripTilde()", () => { 4 | it("should have a safe default", () => { 5 | expect(stripTilde()).toEqual(""); 6 | expect(stripTilde("剤")).toEqual("剤"); 7 | }); 8 | 9 | it("should strip JA tilde", () => { 10 | expect(stripTilde("〜剤")).toEqual("剤"); 11 | expect(stripTilde("剤〜")).toEqual("剤"); 12 | expect(stripTilde("〜かた")).toEqual("かた"); 13 | expect(stripTilde("かた〜")).toEqual("かた"); 14 | }); 15 | 16 | it("should strip EN tilde", () => { 17 | expect(stripTilde("~剤")).toEqual("剤"); 18 | expect(stripTilde("剤~")).toEqual("剤"); 19 | expect(stripTilde("~かた")).toEqual("かた"); 20 | expect(stripTilde("かた~")).toEqual("かた"); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "Debug File", 8 | "program": "${file}", 9 | "sourceMapPathOverrides": { 10 | "webpack:///./app/*": "${webRoot}/*", 11 | "webpack:///app/*": "${webRoot}/*" 12 | } 13 | }, 14 | { 15 | "type": "chrome", 16 | "request": "launch", 17 | "name": "Launch Chrome", 18 | "url": "http://localhost:3000", 19 | "webRoot": "${workspaceRoot}/app", 20 | "userDataDir": "${workspaceRoot}/.chrome", 21 | "sourceMapPathOverrides": { 22 | "webpack:///./app/*": "${webRoot}/*", 23 | "webpack:///app/*": "${webRoot}/*" 24 | } 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /app/common/components/VocabListToggleButton/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import { ToggleButton } from "./styles"; 5 | 6 | VocabListToggleButton.propTypes = { 7 | cardsExpanded: PropTypes.bool.isRequired, 8 | onToggle: PropTypes.func, 9 | }; 10 | 11 | VocabListToggleButton.defaultProps = { 12 | onToggle: () => {}, 13 | }; 14 | 15 | function VocabListToggleButton({ cardsExpanded, onToggle, ...props }) { 16 | const name = cardsExpanded ? "CONTRACT_ALL" : "EXPAND_ALL"; 17 | const title = `${cardsExpanded ? "Shrink" : "Enlarge"} card size`; 18 | return ( 19 | 20 | ); 21 | } 22 | 23 | export default VocabListToggleButton; 24 | -------------------------------------------------------------------------------- /app/features/dashboard/SrsChart/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | import { gutter } from 'common/styles/layout'; 4 | import { resetList } from 'common/styles/utils'; 5 | 6 | export const LegendList = styled.ul` 7 | ${resetList} 8 | ${gutter({ type: 'outer' })} 9 | display: flex; 10 | flex-direction: column; 11 | flex: 0 1 200px; 12 | `; 13 | 14 | export const LegendListItem = styled.li` 15 | ${gutter({ position: 'vertical', mod: 0.75 })} 16 | display: flex; 17 | font-size: .95rem; 18 | align-items: center; 19 | `; 20 | 21 | export const LegendName = styled.div` 22 | padding: 0 0.6rem; 23 | flex: 1; 24 | `; 25 | 26 | export const LegendValue = styled.div` 27 | margin-left: auto; 28 | margin-right: 0.6rem; 29 | font-weight: 600; 30 | `; 31 | -------------------------------------------------------------------------------- /app/common/components/Container/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { 3 | gutter, 4 | alignContentMixin, 5 | alignItemsMixin, 6 | alignSelfMixin, 7 | flexCenterMixin, 8 | flexMixin, 9 | flexShorthandMixin, 10 | justifyContentMixin, 11 | textAlignMixin, 12 | } from 'common/styles/layout'; 13 | 14 | export const StyledContainer = styled.div` 15 | position: relative; /* catch any absolute children */ 16 | ${({ withPadding }) => withPadding && gutter({ type: 'outer' })} 17 | ${({ marginTop }) => marginTop && `margin-top: ${marginTop};`} 18 | ${flexMixin} 19 | ${flexCenterMixin} 20 | ${flexShorthandMixin} 21 | ${alignContentMixin} 22 | ${alignItemsMixin} 23 | ${alignSelfMixin} 24 | ${justifyContentMixin} 25 | ${textAlignMixin} 26 | `; 27 | -------------------------------------------------------------------------------- /server/middlewares/addProdMiddlewares.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const express = require('express'); 3 | const compression = require('compression'); 4 | 5 | module.exports = function addProdMiddlewares(app, options) { 6 | const publicPath = options.publicPath || '/'; 7 | const outputPath = options.outputPath || path.resolve(process.cwd(), 'build'); 8 | 9 | // compression middleware compresses your server responses which makes them 10 | // smaller (applies also to assets). You can read more about that technique 11 | // and other good practices on official Express.js docs http://mxs.is/googmy 12 | app.use(compression()); 13 | app.use(publicPath, express.static(outputPath)); 14 | 15 | app.get('*', (req, res) => res.sendFile(path.resolve(outputPath, 'index.html'))); 16 | }; 17 | -------------------------------------------------------------------------------- /app/features/synonyms/selectors.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | import { getState, makeSelectItemIds, makeSelectItemById } from 'common/selectors'; 3 | 4 | export const UI_DOMAIN = 'synonyms'; 5 | export const ENTITY_DOMAIN = 'synonyms'; 6 | export const selectSynonymsUiDomain = getState(UI_DOMAIN, {}); 7 | export const selectSynonymsDomain = getState(['entities', ENTITY_DOMAIN], {}); 8 | 9 | export const selectSynonymsSubmitting = createSelector( 10 | selectSynonymsUiDomain, 11 | getState('submitting', false) 12 | ); 13 | 14 | export const selectSynonymIds = makeSelectItemIds(selectSynonymsDomain); 15 | export const selectSynonyms = selectSynonymsDomain; 16 | export const selectSynonymById = makeSelectItemById(selectSynonyms); 17 | 18 | export default selectSynonymsDomain; 19 | -------------------------------------------------------------------------------- /app/features/navigation/NavLink/LogoutLink.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | 5 | import user from 'features/user/actions'; 6 | 7 | import { Li, LinkButton, Text } from './styles'; 8 | 9 | export const LogoutLink = ({ name, isOffCanvas, onLogout }) => ( 10 |
  • 11 | 12 | {name} 13 | 14 |
  • 15 | ); 16 | 17 | LogoutLink.propTypes = { 18 | name: PropTypes.string.isRequired, 19 | isOffCanvas: PropTypes.bool.isRequired, 20 | onLogout: PropTypes.func.isRequired, 21 | }; 22 | 23 | const mapDispatchToProps = { 24 | onLogout: user.logout, 25 | }; 26 | 27 | export default connect(null, mapDispatchToProps)(LogoutLink); 28 | -------------------------------------------------------------------------------- /app/features/quiz/QuizSession/QuizAnswer/reducer.js: -------------------------------------------------------------------------------- 1 | import { handleActions, combineActions } from 'redux-actions'; 2 | import { merge } from 'lodash'; 3 | import { LOCATION_CHANGE } from 'react-router-redux'; 4 | 5 | import quiz from 'features/quiz/actions'; 6 | 7 | const initialState = { 8 | value: '', 9 | type: '', 10 | isFocused: true, 11 | isMarked: false, 12 | isValid: false, 13 | isCorrect: false, 14 | isIncorrect: false, 15 | isDisabled: false, 16 | isIgnored: false, 17 | }; 18 | 19 | export const quizAnswerReducer = handleActions( 20 | { 21 | [quiz.answer.update]: (state, { payload }) => merge({}, state, payload), 22 | [combineActions(quiz.answer.reset, LOCATION_CHANGE)]: () => initialState, 23 | }, 24 | initialState 25 | ); 26 | 27 | export default quizAnswerReducer; 28 | -------------------------------------------------------------------------------- /app/common/utils/createDict.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign, no-console */ 2 | import { get } from 'lodash'; 3 | import { IS_DEV_ENV } from 'common/constants'; 4 | import devLog from 'common/utils/devLog'; 5 | 6 | // creates object by provided key, defaults to index otherwise 7 | const createDict = (list = [], key = '') => { 8 | if (!Array.isArray(list)) { 9 | devLog('createDict should receive an array'); 10 | return {}; 11 | } 12 | return list.reduce((hash, item, index) => { 13 | const target = get(item, key) || index; 14 | if (IS_DEV_ENV && !!hash[target]) { 15 | devLog('duplicate entries identified:'); 16 | devLog(hash[target]); 17 | devLog(item); 18 | } 19 | hash[target] = item; 20 | return hash; 21 | }, {}); 22 | }; 23 | 24 | export default createDict; 25 | -------------------------------------------------------------------------------- /app/common/utils/determineCriticality.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Determines criticality by comparing correct answers against total times answered 4 | * @param {number} correct answered correctly count 5 | * @param {number} incorrect answered incorrectly count 6 | * @param {number} [threshold=0.75] Incorrect ratio above which item is considered critical 7 | * @param {number} [minimum=4] Number of times item must have been answered previously 8 | * @return {boolean} True if item is above critical threshold 9 | */ 10 | function determineCriticality(correct = 0, incorrect = 0, threshold = 0.75, minimum = 4) { 11 | const answered = correct + incorrect; 12 | const incorrectRatio = incorrect / answered; 13 | return (answered >= minimum) && (incorrectRatio >= threshold); 14 | } 15 | 16 | export default determineCriticality; 17 | -------------------------------------------------------------------------------- /app/common/components/Mark/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components'; 4 | import { transparentize } from 'polished'; 5 | import { blue } from 'common/styles/colors'; 6 | 7 | export const StyledMark = styled.mark` 8 | color: ${({ color }) => color}; 9 | background-color: ${({ bgColor }) => bgColor}; 10 | border-radius: 2px; 11 | ${({ pad }) => pad && 'padding: 1px 2px 2px;'}; 12 | `; 13 | 14 | Mark.propTypes = { 15 | color: PropTypes.string, 16 | bgColor: PropTypes.string, 17 | pad: PropTypes.bool, 18 | }; 19 | 20 | Mark.defaultProps = { 21 | color: 'inherit', 22 | bgColor: transparentize(0.2, blue[1]), 23 | pad: true, 24 | }; 25 | 26 | function Mark(props) { 27 | return ; 28 | } 29 | 30 | export default Mark; 31 | -------------------------------------------------------------------------------- /app/features/vocab/Entry/VocabStats/StreakStatus.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components'; 4 | 5 | import StreakIcon from 'common/components/StreakIcon'; 6 | import { gutter } from 'common/styles/layout'; 7 | import H5 from 'common/components/H5'; 8 | 9 | const Wrapper = styled.div` 10 | ${gutter({ type: 'outer', position: 'right' })}; 11 | display: inline-flex; 12 | align-items: center; 13 | `; 14 | 15 | StreakStatus.propTypes = { 16 | streak: PropTypes.number.isRequired, 17 | category: PropTypes.string.isRequired, 18 | }; 19 | 20 | function StreakStatus({ streak, category }) { 21 | return ( 22 | 23 |
    {category}:
    24 | 25 |
    26 | ); 27 | } 28 | 29 | export default StreakStatus; 30 | -------------------------------------------------------------------------------- /app/common/assets/img/apprentice.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /app/common/components/KanjiStroke/styles.js: -------------------------------------------------------------------------------- 1 | 2 | import styled from 'styled-components'; 3 | import IconButton from 'common/components/IconButton'; 4 | 5 | import { gutter } from 'common/styles/layout'; 6 | 7 | export const Wrapper = styled.div` 8 | display: inline-flex; 9 | flex-flow: column nowrap; 10 | align-items: center; 11 | `; 12 | 13 | export const Canvas = styled.div` 14 | ${gutter()}; 15 | display: flex; 16 | flex-flow: row wrap; 17 | justify-content: center; 18 | align-items: center; 19 | min-height: 200px; 20 | `; 21 | 22 | export const Controls = styled.div` 23 | ${gutter({ position: 'bottom' })} 24 | display: flex; 25 | justify-content: center; 26 | `; 27 | 28 | export const ControlButton = styled(IconButton)` 29 | ${gutter({ position: 'horizontal', mod: 3 })} 30 | ${({ disabled }) => disabled && 'opacity: .5;'} 31 | `; 32 | -------------------------------------------------------------------------------- /app/features/search/reducer.js: -------------------------------------------------------------------------------- 1 | import { handleActions, combineActions } from 'redux-actions'; 2 | import { merge } from 'lodash'; 3 | import { LOCATION_CHANGE } from 'react-router-redux'; 4 | 5 | import search from './actions'; 6 | 7 | export const initialSearchState = { 8 | ids: [], 9 | isSearching: false, 10 | isSearchComplete: false, 11 | }; 12 | 13 | const startSearch = () => ({ ...initialSearchState, isSearching: true }); 14 | const mergePayload = (state, { payload }) => merge({}, state, payload); 15 | const resetState = () => initialSearchState; 16 | 17 | export const searchReducer = handleActions( 18 | { 19 | [search.query.request]: startSearch, 20 | [search.query.success]: mergePayload, 21 | [combineActions(search.clear, LOCATION_CHANGE)]: resetState, 22 | }, 23 | initialSearchState 24 | ); 25 | 26 | export default searchReducer; 27 | -------------------------------------------------------------------------------- /app/common/components/ScrollToTop/ScrollTopButton/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import Icon from 'common/components/Icon'; 5 | import { StyledButton } from './styles'; 6 | 7 | ScrollTopButton.propTypes = { 8 | onClick: PropTypes.func.isRequired, 9 | isVisible: PropTypes.bool, 10 | isScrolling: PropTypes.bool, 11 | }; 12 | 13 | ScrollTopButton.defaultProps = { 14 | isVisible: false, 15 | isScrolling: false, 16 | }; 17 | 18 | export default function ScrollTopButton({ onClick, isVisible, isScrolling, ...props }) { 19 | return ( 20 | 27 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /app/features/navigation/NavLink/NavLink.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { Li, Link, Text } from './styles'; 5 | 6 | NavLink.propTypes = { 7 | name: PropTypes.string.isRequired, 8 | route: PropTypes.string.isRequired, 9 | isOffCanvas: PropTypes.bool, 10 | disabled: PropTypes.bool, 11 | }; 12 | 13 | NavLink.defaultProps = { 14 | isOffCanvas: false, 15 | disabled: false, 16 | }; 17 | 18 | export function NavLink({ name, route, disabled, isOffCanvas }) { 19 | return ( 20 |
  • 21 | 22 | 23 |
    {name}
    24 |
    25 | 26 |
  • 27 | ); 28 | } 29 | 30 | export default NavLink; 31 | -------------------------------------------------------------------------------- /app/features/vocab/reducer.js: -------------------------------------------------------------------------------- 1 | import { handleActions, combineActions } from 'redux-actions'; 2 | // import update from "immutability-helper"; 3 | import { merge } from 'lodash'; 4 | import quiz from 'features/quiz/actions'; 5 | import review from 'features/reviews/actions'; 6 | import user from 'features/user/actions'; 7 | import vocab from './actions'; 8 | 9 | export const initialVocabEntitiesState = {}; 10 | 11 | const ingestVocabs = (state, { payload }) => merge({}, state, payload.vocabById); 12 | 13 | export const vocabReducer = handleActions( 14 | { 15 | [combineActions( 16 | vocab.level.load.success, 17 | quiz.session.queue.load.success, 18 | review.load.success, 19 | vocab.batchUpdate 20 | )]: ingestVocabs, 21 | [user.logout]: () => initialVocabEntitiesState, 22 | }, 23 | initialVocabEntitiesState 24 | ); 25 | 26 | export default vocabReducer; 27 | -------------------------------------------------------------------------------- /app/common/utils/splitKeepingDelimiter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Splits string at given delimiter but keeps it at its location(s) in the result array 3 | * @param {String} [text=''] input 4 | * @param {String} [delimiter=','] string to provide to RegExp 5 | * @param {String} [flags='g'] RegExp flags like 'gi' 6 | * @return {Array} text split including delimiter 7 | * @example 8 | * splitKeepingDelimiter('This;is;an;example;here',';') 9 | * // => [ 'This', ';', 'is', ';', 'an', ';', 'example', ';', 'here' ] 10 | * splitKeepingDelimiter('We want to split but keep WORD with this sentence', 'word', 'gi') 11 | * // => [ 'We want to split but keep ', 'WORD', ' with this sentence' ] 12 | */ 13 | export default function splitKeepingDelimiter(text, delimiter = ',', flags = 'g') { 14 | const delim = new RegExp(`(${delimiter})`, flags); 15 | return typeof text === 'string' ? text.split(delim) : []; 16 | } 17 | -------------------------------------------------------------------------------- /app/common/components/IconButton/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | import { gutter } from 'common/styles/layout'; 4 | import { resetButton } from 'common/styles/utils'; 5 | import { fastEaseQuad } from 'common/styles/animation'; 6 | 7 | export const Button = styled.button` 8 | ${resetButton} 9 | ${gutter()} 10 | display: flex; 11 | justify-content: center; 12 | align-self: center; 13 | align-items: center; 14 | vertical-align: middle; 15 | transition: all ${fastEaseQuad}, transform 100ms linear; 16 | cursor: pointer; 17 | opacity: .7; 18 | transform: scale(1); 19 | 20 | &:not(:disabled) { 21 | &:focus, 22 | &:hover { 23 | opacity: .9; 24 | outline: none; 25 | } 26 | 27 | &:active { 28 | opacity: 1; 29 | transform: scale(.9); 30 | } 31 | } 32 | 33 | &:disabled { 34 | cursor: not-allowed; 35 | } 36 | `; 37 | -------------------------------------------------------------------------------- /app/common/components/IconLink/__tests__/index.test.js: -------------------------------------------------------------------------------- 1 | import "jest-styled-components"; 2 | import React from "react"; 3 | import { render } from "enzyme"; 4 | import { MemoryRouter } from "react-router-dom"; 5 | 6 | import IconLink from "../index"; 7 | 8 | describe(" ", () => { 9 | const props = { name: "ADD", title: "Does an action" }; 10 | it('renders an Anchor with "href"', () => { 11 | const renderedComponent = render( 12 | 13 | 14 | 15 | ); 16 | expect(renderedComponent).toMatchSnapshot(); 17 | }); 18 | it('renders a Link with "to"', () => { 19 | const renderedComponent = render( 20 | 21 | 22 | 23 | ); 24 | expect(renderedComponent).toMatchSnapshot(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /app/common/components/AddSynonym/JishoSearchLink.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { createJishoUrl } from 'common/api'; 5 | import IconLink from 'common/components/IconLink'; 6 | 7 | JishoSearchLink.propTypes = { 8 | keyword: PropTypes.string.isRequired, 9 | visuallyHidden: PropTypes.bool, 10 | }; 11 | 12 | JishoSearchLink.defaultProps = { 13 | visuallyHidden: false, 14 | }; 15 | 16 | function JishoSearchLink({ keyword, visuallyHidden, ...props }) { 17 | return ( 18 | 29 | ); 30 | } 31 | 32 | export default JishoSearchLink; 33 | -------------------------------------------------------------------------------- /app/common/utils/__tests__/typeOf.test.js: -------------------------------------------------------------------------------- 1 | import typeOf from "../typeOf"; 2 | 3 | describe("typeOf", () => { 4 | it("should properly return types", () => { 5 | expect(typeOf(0)).toBe("number"); 6 | expect(typeOf(NaN)).toBe("nan"); 7 | expect(typeOf({})).toBe("object"); 8 | expect(typeOf([])).toBe("array"); 9 | expect(typeOf("")).toBe("string"); 10 | expect(typeOf("str")).toBe("string"); 11 | expect(typeOf(new Map())).toBe("map"); 12 | expect(typeOf(new Set())).toBe("set"); 13 | expect(typeOf(/re/gi)).toBe("regexp"); 14 | expect(typeOf(new Date())).toBe("date"); 15 | expect(typeOf(() => {})).toBe("function"); 16 | expect(typeOf(true)).toBe("boolean"); 17 | expect(typeOf(false)).toBe("boolean"); 18 | expect(typeOf(null)).toBe("null"); 19 | expect(typeOf(undefined)).toBe("undefined"); 20 | expect(typeOf()).toBe("undefined"); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /app/features/dashboard/UpcomingReviewsChart/DayTick.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { grey } from 'common/styles/colors'; 5 | 6 | const DayTick = ({ x, y, width, height, payload: { value } }) => 7 | value ? ( 8 | 9 | 21 | {value} 22 | 23 | 24 | ) : null; 25 | 26 | /* eslint-disable react/require-default-props */ 27 | DayTick.propTypes = { 28 | x: PropTypes.number, 29 | y: PropTypes.number, 30 | width: PropTypes.number, 31 | height: PropTypes.number, 32 | payload: PropTypes.object, 33 | }; 34 | 35 | export default DayTick; 36 | -------------------------------------------------------------------------------- /app/common/persistence.js: -------------------------------------------------------------------------------- 1 | import { VERSION, IS_DEV_ENV } from 'common/constants'; 2 | import { persistReducer as persist } from 'redux-persist'; 3 | import localForage from 'localforage'; 4 | 5 | const baseConfig = { 6 | debug: IS_DEV_ENV, 7 | storage: localForage, 8 | debounce: 1000, 9 | // https://github.com/rt2zz/redux-persist/blob/master/docs/migrations.md 10 | version: VERSION, 11 | }; 12 | 13 | export const persistReducer = ( 14 | { key = 'kaniwani', blacklist = [], whitelist = [] } = {}, 15 | reducer 16 | ) => { 17 | const config = { 18 | ...baseConfig, 19 | key, 20 | blacklist, 21 | }; 22 | if (whitelist.length) { 23 | config.whitelist = whitelist; 24 | } 25 | return persist(config, reducer); 26 | }; 27 | 28 | export const persistUiReducer = (key = 'kaniwani', reducer) => persistReducer({ key, whitelist: ['lastLoad'] }, reducer); 29 | 30 | export default persistReducer; 31 | -------------------------------------------------------------------------------- /app/common/components/Spinner/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { purple } from 'common/styles/colors'; 5 | import { Wrapper, Circles, Circle1, Circle2 } from './styles'; 6 | 7 | const Spinner = ({ size, duration, color1, color2, ...props }) => ( 8 | 9 | 10 | 11 | 12 | 13 | 14 | ); 15 | 16 | Spinner.propTypes = { 17 | size: PropTypes.string, 18 | duration: PropTypes.number, 19 | color1: PropTypes.string, 20 | color2: PropTypes.string, 21 | padded: PropTypes.bool, 22 | }; 23 | 24 | Spinner.defaultProps = { 25 | size: '2.5rem', // or em, rem, percent 26 | duration: 1000, // ms 27 | color1: purple[4], 28 | color2: purple[4], 29 | padded: true, 30 | }; 31 | 32 | export default Spinner; 33 | -------------------------------------------------------------------------------- /app/features/quiz/QuizSession/QuizInfo/styles.js: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components'; 2 | 3 | import { white } from 'common/styles/colors'; 4 | import { gutter } from 'common/styles/layout'; 5 | 6 | export const Wrapper = styled.div` 7 | display: flex; 8 | flex-flow: column nowrap; 9 | align-items: center; 10 | flex: 1 1 100%; 11 | background-color: ${white[2]}; 12 | overflow: hidden; 13 | position: relative; /* engage z-index */ 14 | z-index: 2; /* Stay above absolute Quiz Background Image */ 15 | text-align: center; 16 | ${({ isMinimized }) => isMinimized ? 'flex: 0 1 0px' : css` 17 | ${gutter({ prop: 'margin', position: 'horizontal' })} 18 | ${gutter({ prop: 'margin', position: 'bottom' })} 19 | ${gutter({ prop: 'padding', type: 'outer' })} 20 | `}; 21 | `; 22 | 23 | export const ReadingWrapper = styled.div` 24 | &:not(:first-child) { 25 | margin-top: 1rem; 26 | } 27 | `; 28 | -------------------------------------------------------------------------------- /app/common/components/StreakIcon/__tests__/index.test.js: -------------------------------------------------------------------------------- 1 | import 'jest-styled-components'; 2 | import React from 'react'; 3 | import { render } from 'enzyme'; 4 | import StreakIcon from '../index'; 5 | 6 | describe('', () => { 7 | it('should match baseline snapshot', () => { 8 | expect(render()).toMatchSnapshot(); 9 | }); 10 | it('should adapt to streakName', () => { 11 | expect(render()).toMatchSnapshot(); 12 | }); 13 | it('should render appropriate streakName color', () => { 14 | expect(render()).toMatchSnapshot(); 15 | }); 16 | it('should adapt to streak number', () => { 17 | expect(render()).toMatchSnapshot(); 18 | }); 19 | it('should render appropriate streak color', () => { 20 | expect(render()).toMatchSnapshot(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # KaniWani 2 | 3 | Before opening a new issue, please take a moment to review our [**community guidelines**](https://github.com/Kaniwani/kw-frontend/blob/master/.github/CONTRIBUTING.md) to make the contribution process easy and effective for everyone involved. 4 | 5 | **Before opening a new issue, you may find an answer in already closed issues**: 6 | https://github.com/Kaniwani/kw-frontend/issues?q=is%3Aissue+is%3Aclosed 7 | 8 | ## Issue Type 9 | 10 | - [ ] Bug (https://github.com/Kaniwani/kw-frontend/blob/master/.github/CONTRIBUTING.md#bug-reports) 11 | - [ ] Feature (https://github.com/Kaniwani/kw-frontend/blob/master/.github/CONTRIBUTING.md#feature-requests) 12 | 13 | ## Description 14 | 15 | (Add images if possible) 16 | 17 | ## Steps to reproduce 18 | 19 | (Add link to a demo on https://codesandbox.io or similar if possible) 20 | 21 | # Versions 22 | 23 | - Node: 24 | - NPM/Yarn: 25 | - Browser: 26 | -------------------------------------------------------------------------------- /app/common/utils/__tests__/createDict.test.js: -------------------------------------------------------------------------------- 1 | import createDict from '../createDict'; 2 | 3 | describe('createDict', () => { 4 | it('sane default', () => { 5 | expect(createDict()).toEqual({}); 6 | expect(createDict([])).toEqual({}); 7 | expect(createDict({})).toEqual({}); 8 | }); 9 | it('uses array index by default', () => { 10 | const item1 = { val: 1 }; 11 | const item2 = { val: 2 }; 12 | expect(createDict([item1, item2])).toEqual({ 0: item1, 1: item2 }); 13 | }); 14 | it('uses array index if key not present', () => { 15 | const item1 = { val: 1, rightKey: 33 }; 16 | const item2 = { val: 2, rightKey: 44 }; 17 | expect(createDict([item1, item2], 'wrongKey')).toEqual({ 0: item1, 1: item2 }); 18 | }); 19 | it('uses key if provided', () => { 20 | const item = { val: 1, identifier: 'herp' }; 21 | expect(createDict([item], 'identifier')).toEqual({ herp: item }); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /app/pages/VocabLevelsPage/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Helmet } from "react-helmet"; 3 | 4 | import PageWrapper from "common/components/PageWrapper"; 5 | import Container from "common/components/Container"; 6 | import Element from "common/components/Element"; 7 | import H1 from "common/components/H1"; 8 | import Levels from 'features/vocab/Levels/Loadable'; 9 | 10 | function VocabLevelsPage() { 11 | const pageTitle = "Vocabulary: Levels"; 12 | return ( 13 |
    14 | 15 | {pageTitle} 16 | 17 | 18 | 19 | 20 | 21 |

    Vocabulary Levels

    22 |
    23 |
    24 | 25 |
    26 |
    27 | ); 28 | } 29 | 30 | export default VocabLevelsPage; 31 | -------------------------------------------------------------------------------- /app/features/announcements/reducer.js: -------------------------------------------------------------------------------- 1 | import { handleActions } from 'redux-actions'; 2 | 3 | import { 4 | initialUiState, 5 | updateUiLoadRequest, 6 | updateUiLoadSuccess, 7 | updateUiLoadFailure, 8 | } from 'reducers/utils'; 9 | 10 | import user from 'features/user/actions'; 11 | import announcements from './actions'; 12 | 13 | export const initialEntitiesState = {}; 14 | 15 | export const announcementsUiReducer = handleActions( 16 | { 17 | [announcements.load.request]: updateUiLoadRequest, 18 | [announcements.load.success]: updateUiLoadSuccess, 19 | [announcements.load.failure]: updateUiLoadFailure, 20 | [user.logout]: () => initialUiState, 21 | }, 22 | initialUiState 23 | ); 24 | 25 | export const announcementsReducer = handleActions( 26 | { 27 | [announcements.load.success]: (state, { payload }) => payload, 28 | }, 29 | initialEntitiesState 30 | ); 31 | 32 | export default announcementsReducer; 33 | -------------------------------------------------------------------------------- /app/common/selectors.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | import { get, identity, partialRight, flowRight as compose } from 'lodash'; 3 | 4 | export const getProp = (keyPath) => (_, props) => get(props, keyPath); 5 | export const getState = (keyPath, defaultVal) => (state) => { 6 | const ret = get(state, keyPath); 7 | return ret === undefined ? defaultVal : ret; 8 | }; 9 | export const getBy = (val, transform = identity) => (state) => compose( 10 | transform, 11 | partialRight(get, val) 12 | )(state); 13 | 14 | export const selectLocationPath = createSelector( 15 | getState('router', {}), 16 | getState('location.pathname', '/') 17 | ); 18 | 19 | export const makeSelectItemIds = (domainSelector) => createSelector(domainSelector, (domain) => Object.keys(domain).map(Number)); 20 | 21 | export const makeSelectItemById = (itemsSelector) => createSelector([itemsSelector, getProp('id')], (items, id) => items[`${id}`] || {}); 22 | -------------------------------------------------------------------------------- /app/features/quiz/QuizSummary/QuizSummarySections/StripeHeading/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { isNumber } from 'lodash'; 4 | 5 | import { white } from 'common/styles/colors'; 6 | import { Heading, Text, Count } from './styles'; 7 | 8 | StripeHeading.propTypes = { 9 | text: PropTypes.string.isRequired, 10 | bgColor: PropTypes.string, 11 | count: PropTypes.oneOfType([PropTypes.bool, PropTypes.number]), 12 | }; 13 | 14 | StripeHeading.defaultProps = { 15 | count: false, 16 | // must match parent background color otherwise strike line appears behind text 17 | bgColor: white[2], 18 | }; 19 | 20 | function StripeHeading({ text, count, bgColor }) { 21 | return ( 22 | 23 | 24 | {isNumber(count) && {count}} 25 | {text} 26 | 27 | 28 | ); 29 | } 30 | 31 | export default StripeHeading; 32 | -------------------------------------------------------------------------------- /app/pages/AboutPage/PayPalDonate.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const PayPalDonate = () => ( 4 |
    5 | 6 | 7 | 8 | 9 | 17 | 24 |
    25 | ); 26 | 27 | export default PayPalDonate; 28 | -------------------------------------------------------------------------------- /app/features/dashboard/SrsChart/SrsLegend.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import cuid from 'cuid'; 4 | 5 | import StreakIcon from 'common/components/StreakIcon'; 6 | import { LegendList, LegendListItem, LegendName, LegendValue } from './styles'; 7 | 8 | SrsLegend.propTypes = { 9 | data: PropTypes.arrayOf( 10 | PropTypes.shape({ 11 | name: PropTypes.string.isRequired, 12 | value: PropTypes.number.isRequired, 13 | }) 14 | ).isRequired, 15 | }; 16 | 17 | function SrsLegend({ data }) { 18 | return ( 19 | 20 | {data.map(({ name, value }) => ( 21 | 22 | 23 | {name} 24 | {value} 25 | 26 | ))} 27 | 28 | ); 29 | } 30 | 31 | export default SrsLegend; 32 | -------------------------------------------------------------------------------- /app/common/utils/__tests__/groupByRank.test.js: -------------------------------------------------------------------------------- 1 | import { SRS_RANKS } from "common/constants"; 2 | import groupByRank from "../groupByRank"; 3 | 4 | const items = Array.from({ length: 25 }).map((_, i) => ({ 5 | id: i, 6 | streak: Math.floor(i * 0.5), 7 | })); 8 | 9 | describe("groupByRank()", () => { 10 | it("should have a safe default", () => { 11 | expect(groupByRank()).toMatchSnapshot(); 12 | }); 13 | 14 | it("should group ids under named srs ranks", () => { 15 | const grouped = groupByRank(items); 16 | expect(grouped).toMatchSnapshot(); 17 | expect(grouped[SRS_RANKS.ZERO].length).toMatchSnapshot(); 18 | expect(grouped[SRS_RANKS.ONE].length).toMatchSnapshot(); 19 | expect(grouped[SRS_RANKS.TWO].length).toMatchSnapshot(); 20 | expect(grouped[SRS_RANKS.THREE].length).toMatchSnapshot(); 21 | expect(grouped[SRS_RANKS.FOUR].length).toMatchSnapshot(); 22 | expect(grouped[SRS_RANKS.FIVE].length).toMatchSnapshot(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /app/common/components/BackgroundImg/index.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | /** 4 | * Requires a parent with position: relative, and a valid height or min-height 5 | * This component renders a div with position: absolute, height/width 100% 6 | * expecting to fill its parent's container. 7 | * Easy solution is using: 8 | */ 9 | 10 | const BackgroundImg = styled.div` 11 | position: absolute; 12 | bottom: 0; 13 | left: 0; 14 | width: 100%; 15 | height: 100%; 16 | background-image: url(${({ imgSrc }) => imgSrc}); 17 | background-position: ${({ bgPosition }) => bgPosition}; 18 | background-size: ${({ bgSize }) => bgSize}; 19 | background-repeat: no-repeat; 20 | z-index: -1; 21 | /* If parent is a flex container */ 22 | flex: 1 1 100%; 23 | align-self: stretch; 24 | `; 25 | 26 | BackgroundImg.defaultProps = { 27 | bgPosition: 'center center', 28 | bgSize: 'cover', 29 | }; 30 | 31 | export default BackgroundImg; 32 | -------------------------------------------------------------------------------- /app/pages/SettingsPage/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Helmet } from 'react-helmet'; 3 | import PageWrapper from 'common/components/PageWrapper'; 4 | import Container from 'common/components/Container'; 5 | 6 | import LastWkSync from 'features/settings/LastWkSync'; 7 | import SettingsForm from 'features/settings/SettingsForm'; 8 | import AccountForm from 'features/settings/AccountForm'; 9 | 10 | export function SettingsPage() { 11 | return ( 12 |
    13 | 14 | Settings 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
    29 | ); 30 | } 31 | 32 | export default SettingsPage; 33 | -------------------------------------------------------------------------------- /app/common/components/IconLink/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import Icon from "common/components/Icon"; 5 | import { Link } from "./styles"; 6 | 7 | // color and size have defaults in already 8 | /* eslint-disable react/require-default-props */ 9 | IconLink.propTypes = { 10 | name: PropTypes.string.isRequired, 11 | title: PropTypes.string.isRequired, 12 | href: PropTypes.string, 13 | to: PropTypes.string, 14 | color: PropTypes.string, 15 | size: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), 16 | }; 17 | /* eslint-enable */ 18 | 19 | IconLink.defaultProps = { 20 | href: "", 21 | to: "", 22 | }; 23 | 24 | function IconLink({ 25 | name, title, color, size, href, to, ...props 26 | }) { 27 | return ( 28 | 29 | 30 | 31 | ); 32 | } 33 | 34 | export default IconLink; 35 | -------------------------------------------------------------------------------- /app/common/components/Icon/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import ICONS from './constants'; 5 | import { SVGWrapper, SVG } from './styles'; 6 | 7 | Icon.propTypes = { 8 | name: PropTypes.oneOf(Object.keys(ICONS)).isRequired, 9 | inline: PropTypes.bool, 10 | color: PropTypes.string, 11 | size: PropTypes.string, 12 | isRotating: PropTypes.bool, 13 | }; 14 | 15 | Icon.defaultProps = { 16 | inline: true, 17 | color: 'currentColor', 18 | size: '1.5em', 19 | isRotating: false, 20 | }; 21 | 22 | function Icon({ name, ...props }) { 23 | const NAME = ICONS[name] || {}; 24 | return ( 25 | 26 | 33 | 34 | 35 | 36 | ); 37 | } 38 | 39 | export default Icon; 40 | -------------------------------------------------------------------------------- /app/features/quiz/QuizSummary/QuizSummarySections/StripeHeading/styles.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { transparentize } from "polished"; 3 | 4 | import { white, grey } from "common/styles/colors"; 5 | import { delta } from "common/styles/typography"; 6 | 7 | export const Heading = styled.h3` 8 | ${delta} margin: 0 0 1.25em; 9 | color: ${transparentize(0.1, grey[5])}; 10 | font-weight: 400; 11 | line-height: 1.15; 12 | border-bottom: 2px solid ${transparentize(0.75, grey[5])}; 13 | `; 14 | 15 | export const Text = styled.span` 16 | display: inline-block; 17 | position: relative; 18 | top: 0.5em; 19 | margin-left: 0.5em; 20 | padding: 0 0.25em; 21 | background-color: ${({ bgColor }) => bgColor}; 22 | `; 23 | 24 | export const Count = styled.strong` 25 | font-size: 0.9em; 26 | margin-right: 0.3em; 27 | padding: 0.1em 0.4em 0.15em; 28 | color: ${white[5]}; 29 | background-color: ${transparentize(0.1, grey[5])}; 30 | border-radius: 2px; 31 | `; 32 | -------------------------------------------------------------------------------- /app/features/navigation/OffCanvasMenu/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import cuid from "cuid"; 4 | 5 | import NavLink from "features/navigation/NavLink"; 6 | 7 | import { Wrapper, Ul, CloseButton } from "./styles"; 8 | 9 | OffCanvasMenu.propTypes = { 10 | links: PropTypes.array, 11 | isVisible: PropTypes.bool, 12 | onClose: PropTypes.func.isRequired, 13 | }; 14 | 15 | OffCanvasMenu.defaultProps = { 16 | links: [], 17 | isVisible: false, 18 | }; 19 | 20 | function OffCanvasMenu({ 21 | links, isVisible, onClose, 22 | }) { 23 | return ( 24 | 25 | 26 |
      27 | 28 | {links.map((link) => ( 29 | 30 | ))} 31 |
    32 |
    33 | ); 34 | } 35 | 36 | export default OffCanvasMenu; 37 | -------------------------------------------------------------------------------- /app/common/utils/__tests__/splitKeepingDelimiter.test.js: -------------------------------------------------------------------------------- 1 | import splitKeepingDelimiter from "../splitKeepingDelimiter"; 2 | 3 | describe("splitKeepingDelimiter()", () => { 4 | it("should return an empty array if nothing passed", () => { 5 | expect(splitKeepingDelimiter()).toEqual([]); 6 | }); 7 | 8 | it("should return an empty array if argument was not a string", () => { 9 | const items = [null, {}, [], new Map()]; 10 | items.forEach((item) => expect(splitKeepingDelimiter(item)).toEqual([])); 11 | }); 12 | 13 | it("should split words by comma as default", () => { 14 | expect(splitKeepingDelimiter("This,is,a,default example")).toMatchSnapshot(); 15 | }); 16 | 17 | it("should accept a custom delimiter", () => { 18 | expect(splitKeepingDelimiter("This;is;a;custom example", ";")).toMatchSnapshot(); 19 | }); 20 | 21 | it("should accept regex flags", () => { 22 | expect( 23 | splitKeepingDelimiter("Split ignoring WORD case", "word", "gi") 24 | ).toMatchSnapshot(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /app/features/settings/LastWkSync.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | import { distanceInWordsToNow } from 'date-fns'; 5 | 6 | import { selectLastWkSyncDate } from 'features/user/selectors'; 7 | 8 | import Element from 'common/components/Element'; 9 | import H5 from 'common/components/H5'; 10 | import P from 'common/components/P'; 11 | 12 | LastWkSync.propTypes = { 13 | lastWkSync: PropTypes.string.isRequired, 14 | }; 15 | 16 | export function LastWkSync({ lastWkSync }) { 17 | return ( 18 | 19 |
    Last Sync with WaniKani:
    20 |

    {lastWkSync}

    21 |
    22 | ); 23 | } 24 | 25 | const mapStateToProps = (state) => ({ 26 | lastWkSync: `${distanceInWordsToNow(selectLastWkSyncDate(state), { 27 | includeSeconds: true, 28 | suffix: true, 29 | })} ago`, 30 | }); 31 | 32 | export default connect(mapStateToProps)(LastWkSync); 33 | -------------------------------------------------------------------------------- /app/features/vocab/Levels/logic.js: -------------------------------------------------------------------------------- 1 | import { createLogic } from 'redux-logic'; 2 | 3 | import vocab from 'features/vocab/actions'; 4 | import notify from 'features/notifications/actions'; 5 | 6 | export const levelsLoadLogic = createLogic({ 7 | type: vocab.levels.load.request, 8 | warnTimeout: 10000, 9 | process({ api, serializers }, dispatch, done) { 10 | api.vocab.level 11 | .fetchAll() 12 | .then((response) => { 13 | const levels = serializers.serializeLevelsResponse(response); 14 | dispatch(vocab.levels.load.success(levels)); 15 | done(); 16 | }) 17 | .catch(({ status, response, message, ...rest }) => { 18 | dispatch( 19 | notify.error({ 20 | content: 'Unable to load levels. You may be experiencing connection problems.', 21 | }) 22 | ); 23 | dispatch(vocab.levels.load.failure({ status, response, message, ...rest })); 24 | done(); 25 | }); 26 | }, 27 | }); 28 | 29 | export default [levelsLoadLogic]; 30 | -------------------------------------------------------------------------------- /app/common/utils/__tests__/toUniqueStringsArray.test.js: -------------------------------------------------------------------------------- 1 | import toUniqueStringsArray from "../toUniqueStringsArray"; 2 | 3 | describe("toUniqueStringsArray", () => { 4 | it("sane defaults", () => { 5 | expect(toUniqueStringsArray()).toEqual([]); 6 | expect(toUniqueStringsArray(null)).toEqual([]); 7 | expect(toUniqueStringsArray({})).toEqual([]); 8 | expect(toUniqueStringsArray("")).toEqual([]); 9 | expect(toUniqueStringsArray([])).toEqual([]); 10 | }); 11 | 12 | it("handles a single string", () => { 13 | expect(toUniqueStringsArray("red")).toEqual(["red"]); 14 | }); 15 | 16 | it("handles comma separated string", () => { 17 | expect(toUniqueStringsArray("red, blue, red, green, blue")).toEqual([ 18 | "red", 19 | "blue", 20 | "green", 21 | ]); 22 | }); 23 | 24 | it("handles array of strings", () => { 25 | expect(toUniqueStringsArray(["red", "blue", "red", "green", "blue"])).toEqual([ 26 | "red", 27 | "blue", 28 | "green", 29 | ]); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /app/features/settings/RangeField.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { Block, Label, Note } from './styles'; 5 | 6 | const RangeField = ({ input, min, max, step, label, note, display }) => ( 7 | 8 | 13 | {note && {note}} 14 | 15 | ); 16 | 17 | RangeField.propTypes = { 18 | input: PropTypes.object.isRequired, 19 | min: PropTypes.number.isRequired, 20 | max: PropTypes.number.isRequired, 21 | step: PropTypes.number.isRequired, 22 | label: PropTypes.string.isRequired, 23 | note: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), 24 | display: PropTypes.func, 25 | }; 26 | 27 | RangeField.defaultProps = { 28 | display: (x) => x, 29 | note: '', 30 | }; 31 | 32 | export default RangeField; 33 | -------------------------------------------------------------------------------- /app/common/components/StreakIcon/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { titleCase } from 'voca'; 4 | 5 | import { SRS_RANKS } from 'common/constants'; 6 | import { SRS_COLORS } from 'common/styles/colors'; 7 | import getSrsRankName from 'common/utils/getSrsRankName'; 8 | import Icon from 'common/components/Icon'; 9 | 10 | StreakIcon.propTypes = { 11 | streak: PropTypes.number, 12 | streakName: PropTypes.oneOf(Object.values(SRS_RANKS)), 13 | colored: PropTypes.bool, 14 | size: PropTypes.string, 15 | }; 16 | 17 | StreakIcon.defaultProps = { 18 | colored: false, 19 | size: '1.5em', 20 | }; 21 | 22 | function StreakIcon({ streak, streakName, colored, ...props }) { 23 | const name = streakName || getSrsRankName(streak); 24 | const title = streak ? `${streak}: ${titleCase(name)}` : titleCase(name); 25 | const color = colored ? SRS_COLORS[name] : 'currentColor'; 26 | return ; 27 | } 28 | 29 | export default StreakIcon; 30 | -------------------------------------------------------------------------------- /app/common/components/TextAreaControls/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | import { gutter } from 'common/styles/layout'; 4 | import { grey, black, orange, red } from 'common/styles/colors'; 5 | 6 | export const Controls = styled.div` 7 | ${gutter({ position: 'vertical' })} 8 | display: flex; 9 | justify-content: center; 10 | 11 | & > button { 12 | ${gutter({ prop: 'margin', position: 'horizontal' })} 13 | } 14 | `; 15 | 16 | const textColorMixin = ({ maxLength, remaining }) => { 17 | switch (true) { 18 | case remaining < maxLength / 10: return red[5]; 19 | case remaining < maxLength / 8: return orange[5]; 20 | case remaining < maxLength / 3: return black[5]; 21 | case remaining < maxLength / 2: return grey[8]; 22 | case remaining < maxLength / 1.5: return grey[5]; 23 | default: return grey[2]; 24 | } 25 | }; 26 | 27 | export const Count = styled.div` 28 | ${gutter({ prop: 'margin', position: 'left' })} 29 | color: ${textColorMixin}; 30 | align-self: center; 31 | `; 32 | -------------------------------------------------------------------------------- /app/common/components/PitchDiagram/PitchDiagramList.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from 'react-redux'; 4 | import cuid from "cuid"; 5 | 6 | import { selectPitch, selectPrimaryReading } from 'features/vocab/selectors'; 7 | import PitchDiagram from "./PitchDiagram"; 8 | 9 | PitchDiagramList.propTypes = { 10 | pitch: PropTypes.arrayOf(PropTypes.number), 11 | primaryReading: PropTypes.string, 12 | }; 13 | 14 | PitchDiagramList.defaultProps = { 15 | pitch: [], 16 | primaryReading: "", 17 | }; 18 | 19 | export function PitchDiagramList({ pitch, primaryReading }) { 20 | return ( 21 |
    22 | {pitch.map((num) => ( 23 | 24 | ))} 25 |
    26 | ); 27 | } 28 | 29 | const mapStateToProps = (state, props) => ({ 30 | pitch: selectPitch(state, props), 31 | primaryReading: selectPrimaryReading(state, props), 32 | }); 33 | 34 | export default connect(mapStateToProps)(PitchDiagramList); 35 | -------------------------------------------------------------------------------- /app/common/components/VocabList/VocabChip/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { mix } from 'polished'; 3 | 4 | import A from 'common/components/A'; 5 | 6 | import { fluidType } from 'common/styles/utils'; 7 | import { borderRadius } from 'common/styles/sizing'; 8 | 9 | export const Wrapper = styled.li` 10 | display: inline-flex; 11 | background-color: ${({ bgColor }) => mix(0.8, bgColor, '#bbb')}; 12 | color: ${({ textColor }) => textColor}; 13 | border-radius: ${borderRadius}; 14 | box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.1); 15 | flex-basis: auto !important; 16 | 17 | .user-is-tabbing & a:focus { 18 | outline-color: ${({ bgColor }) => bgColor}; 19 | } 20 | `; 21 | 22 | export const Link = styled(A)` 23 | padding: .3rem .6rem; 24 | display: block; 25 | `; 26 | 27 | export const Text = styled.span` 28 | ${fluidType(26, 32, 300, 2000)}; 29 | overflow: hidden; 30 | text-overflow: ellipsis; 31 | white-space: nowrap; 32 | text-shadow: 1px 1px 0 ${({ shadowColor }) => mix(0.8, shadowColor, '#444')}; 33 | `; 34 | -------------------------------------------------------------------------------- /app/features/settings/SelectField.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import cuid from 'cuid'; 4 | import { titleCase } from 'voca'; 5 | 6 | import { Block, Label, Note } from './styles'; 7 | 8 | const SelectField = ({ input, options, label, note }) => ( 9 | 10 | 20 | {note && {note}} 21 | 22 | ); 23 | SelectField.propTypes = { 24 | options: PropTypes.arrayOf(PropTypes.string).isRequired, 25 | input: PropTypes.object.isRequired, 26 | label: PropTypes.string.isRequired, 27 | note: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), 28 | }; 29 | 30 | SelectField.defaultProps = { 31 | note: '', 32 | }; 33 | 34 | export default SelectField; 35 | -------------------------------------------------------------------------------- /app/pages/HomePage/index.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | import { Helmet } from 'react-helmet'; 5 | 6 | import user from 'features/user/actions'; 7 | 8 | import PageWrapper from 'common/components/PageWrapper'; 9 | import Dashboard from 'features/dashboard'; 10 | 11 | class HomePage extends React.PureComponent { 12 | static propTypes = { 13 | loadUser: PropTypes.func.isRequired, 14 | }; 15 | 16 | componentDidMount() { 17 | this.props.loadUser(); 18 | } 19 | 20 | render() { 21 | return ( 22 | 23 | 24 | Dashboard 25 | 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | } 33 | } 34 | 35 | const mapDispatchToProps = { 36 | loadUser: user.load.request, 37 | }; 38 | 39 | export default connect(null, mapDispatchToProps)(HomePage); 40 | -------------------------------------------------------------------------------- /app/common/utils/__tests__/determineCriticality.test.js: -------------------------------------------------------------------------------- 1 | import determineCriticality from "../determineCriticality"; 2 | 3 | describe("determineCriticality", () => { 4 | it("should have sane defaults", () => { 5 | expect(determineCriticality()).toBe(false); 6 | expect(determineCriticality(1)).toBe(false); 7 | expect(determineCriticality(undefined, 1)).toBe(false); 8 | expect(determineCriticality(2, 2)).toBe(false); 9 | expect(determineCriticality(0, 4)).toBe(true); 10 | }); 11 | 12 | it("should respect critical threshold param", () => { 13 | expect(determineCriticality(1, 3)).toBe(true); 14 | expect(determineCriticality(2, 2, 0.5)).toBe(true); 15 | expect(determineCriticality(0, 10, 1)).toBe(true); 16 | expect(determineCriticality(1, 10, 100)).toBe(false); 17 | }); 18 | 19 | it("should respect minimum attempt limit", () => { 20 | expect(determineCriticality(0, 4, 0.75)).toBe(true); 21 | expect(determineCriticality(0, 10, 0.75, 10)).toBe(true); 22 | expect(determineCriticality(0, 10, 0.75, 11)).toBe(false); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /app/features/vocab/logic.js: -------------------------------------------------------------------------------- 1 | import { createLogic } from 'redux-logic'; 2 | 3 | import { app } from 'common/actions'; 4 | import notify from 'features/notifications/actions'; 5 | import vocab from './actions'; 6 | 7 | const reportVocabLogic = createLogic({ 8 | type: vocab.report.request, 9 | process: ({ api, action: { payload, meta } }, dispatch, done) => { 10 | const { form } = meta; 11 | form.startSubmit(); 12 | api.report 13 | .create(payload) 14 | .then(() => { 15 | form.setSubmitSucceeded(); 16 | form.reset(); 17 | dispatch(vocab.report.success()); 18 | done(); 19 | }) 20 | .catch((err) => { 21 | form.setSubmitFailed(); 22 | dispatch( 23 | notify.error({ 24 | content: 'Unable to send report. You may be experiencing connection problems.', 25 | }) 26 | ); 27 | dispatch(app.captureError(err, payload)); 28 | dispatch(vocab.report.failure(err)); 29 | done(); 30 | }); 31 | }, 32 | }); 33 | 34 | export default [reportVocabLogic]; 35 | -------------------------------------------------------------------------------- /app/features/user/reducer.js: -------------------------------------------------------------------------------- 1 | import { handleActions } from 'redux-actions'; 2 | 3 | import { 4 | initialUiState, 5 | updateUiLoadRequest, 6 | updateUiLoadSuccess, 7 | updateUiLoadFailure, 8 | } from 'reducers/utils'; 9 | 10 | import user from 'features/user/actions'; 11 | export const initialUserState = {}; 12 | 13 | export const userUiReducer = handleActions( 14 | { 15 | [user.load.request]: updateUiLoadRequest, 16 | [user.load.success]: updateUiLoadSuccess, 17 | [user.load.failure]: updateUiLoadFailure, 18 | [user.logout]: () => initialUiState, 19 | }, 20 | initialUiState 21 | ); 22 | 23 | export const initialQuizCountsState = {}; 24 | 25 | export const quizCountsReducer = handleActions( 26 | { 27 | [user.quizCounts.success]: (state, { payload }) => payload, 28 | }, 29 | initialQuizCountsState 30 | ); 31 | 32 | export const userReducer = handleActions( 33 | { 34 | [user.load.success]: (state, { payload }) => payload, 35 | [user.logout]: () => initialUserState, 36 | }, 37 | initialUserState 38 | ); 39 | 40 | export default userReducer; 41 | -------------------------------------------------------------------------------- /app/common/components/Icon/styles.js: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components'; 2 | import { transparent } from 'common/styles/colors'; 3 | import { spin } from 'common/styles/animation'; 4 | 5 | export const SVGWrapper = styled.span` 6 | ${({ inline }) => inline ? css` 7 | display: inline-block; 8 | vertical-align: middle; 9 | ` : css` 10 | display: block; 11 | align-self: center; 12 | `} 13 | position: relative; 14 | width: ${({ size }) => size}; /*CSS instead of html width attr to support non-pixel units*/ 15 | height: ${({ size }) => size}; /*Prevents scaling issue in IE*/ 16 | background-repeat: no-repeat; 17 | background-color: ${transparent}; 18 | color: ${({ color }) => color}; 19 | flex-shrink: 0; 20 | ${({ isRotating }) => isRotating && css`animation: ${spin} 1.25s linear infinite;`}; 21 | `; 22 | 23 | export const SVG = styled.svg` 24 | display: block; 25 | pointer-events: none; 26 | transform-origin: 50% 50% 0px; 27 | position: absolute; 28 | top: 0; 29 | right: 0; 30 | bottom: 0; 31 | left: 0; 32 | fill: currentColor; 33 | `; 34 | -------------------------------------------------------------------------------- /app/common/components/TagsList/utils/getTagColors.js: -------------------------------------------------------------------------------- 1 | import * as COLORS from 'common/styles/colors'; 2 | 3 | let palette = { 4 | default: { bgColor: COLORS.purple[0] }, 5 | noun: { bgColor: COLORS.orange[2] }, 6 | adverb: { bgColor: COLORS.pink[0] }, 7 | verb: { bgColor: COLORS.cyan[4] }, 8 | adj: { bgColor: COLORS.yellow[3] }, 9 | }; 10 | 11 | palette = Object.entries(palette).reduce((hash, [key, { bgColor }]) => { 12 | hash[key] = { bgColor, textColor: COLORS.black[2] }; // eslint-disable-line 13 | return hash; 14 | }, {}); 15 | 16 | function getTagColors(text) { 17 | const isNoun = /noun/i.test(text); 18 | const isAdverb = /adverb/i.test(text); 19 | const isVerb = /\bverb/i.test(text); 20 | const isAdj = /\badj/i.test(text); 21 | 22 | switch (true) { 23 | case isNoun: 24 | return palette.noun; 25 | case isAdverb: 26 | return palette.adverb; 27 | case isVerb: 28 | return palette.verb; 29 | case isAdj: 30 | return palette.adj; 31 | default: 32 | return palette.default; 33 | } 34 | } 35 | 36 | export default getTagColors; 37 | -------------------------------------------------------------------------------- /app/common/styles/media.js: -------------------------------------------------------------------------------- 1 | import { css } from 'styled-components'; 2 | 3 | /** 4 | * Sizes for media queries using media tag 5 | * IE: media('max').sm` css: rule; `; 6 | * @type {Object} 7 | */ 8 | export const breakpoints = { 9 | xs: 400, 10 | sm: 600, 11 | md: 900, 12 | lg: 1200, 13 | xl: 1600, 14 | xxl: 2000, 15 | }; 16 | 17 | /** 18 | * Returns css wrapped in a given media query 19 | * usage with styled-components: 20 | * media().sm`color: red;`; 21 | * @param {String} [limit='min'] 'min' or 'max' to apply min-width or max-width 22 | * @return {String} css wrapped in media query 23 | */ 24 | export const media = (limit = 'min') => Object.keys(breakpoints).reduce((accumulator, label) => { 25 | const acc = accumulator; 26 | acc[label] = (...args) => { 27 | let size = breakpoints[label]; 28 | // to ensure (max-width: 599px) versus (min-width: 600px) [the next size up] 29 | if (limit === 'max') size -= 1; 30 | 31 | return css` 32 | @media (${limit}-width: ${size}px) { 33 | ${css(...args)} 34 | }`; 35 | }; 36 | return acc; 37 | }, {}); 38 | -------------------------------------------------------------------------------- /app/features/announcements/logic.js: -------------------------------------------------------------------------------- 1 | import { createLogic } from 'redux-logic'; 2 | 3 | import { app } from 'common/actions'; 4 | import { hasToken } from 'common/utils/auth'; 5 | import { selectAnnouncementsShouldLoad } from './selectors'; 6 | import announcements from './actions'; 7 | 8 | export const loadLogic = createLogic({ 9 | type: announcements.load.request, 10 | warnTimeout: 5000, 11 | validate({ getState, action }, allow, reject) { 12 | hasToken() && (!!action.payload.force || selectAnnouncementsShouldLoad(getState())) 13 | ? allow(action) 14 | : reject(); 15 | }, 16 | process({ api, serializers: { serializeAnnouncementsResponse } }, dispatch, done) { 17 | api.announcements 18 | .fetchAll() 19 | .then((res) => { 20 | dispatch(announcements.load.success(serializeAnnouncementsResponse(res))); 21 | done(); 22 | }) 23 | .catch((err) => { 24 | dispatch(app.captureError(err)); 25 | dispatch(announcements.load.failure(err)); 26 | done(); 27 | }); 28 | }, 29 | }); 30 | 31 | export default [loadLogic]; 32 | -------------------------------------------------------------------------------- /app/features/quiz/QuizSummary/QuizSummarySections/LastActivity.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { distanceInWordsToNow } from 'date-fns'; 4 | 5 | import Container from 'common/components/Container'; 6 | import H2 from 'common/components/H2'; 7 | import P from 'common/components/P'; 8 | 9 | LastActivity.propTypes = { 10 | date: PropTypes.oneOfType([PropTypes.instanceOf(Date), PropTypes.oneOf([false])]), 11 | }; 12 | 13 | LastActivity.defaultProps = { 14 | date: false, 15 | }; 16 | 17 | export function LastActivity({ date }) { 18 | return ( 19 |
    20 | {date !== false ? ( 21 | 22 |

    23 | {'Last session activity: '} 24 | {`${distanceInWordsToNow(date, { 25 | includeSeconds: true, 26 | })} ago.`} 27 |

    28 |
    29 | ) : ( 30 | 31 |

    No recent history.

    32 |
    33 | )} 34 |
    35 | ); 36 | } 37 | 38 | export default LastActivity; 39 | -------------------------------------------------------------------------------- /app/pages/VocabLevelPage/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import PropTypes from 'prop-types'; 4 | import { Helmet } from 'react-helmet'; 5 | 6 | import { getProp } from 'common/selectors'; 7 | 8 | import PageWrapper from 'common/components/PageWrapper'; 9 | import Container from 'common/components/Container'; 10 | import VocabLevel from 'features/vocab/Level'; 11 | 12 | VocabLevelPage.propTypes = { 13 | id: PropTypes.number.isRequired, 14 | }; 15 | 16 | export function VocabLevelPage({ id }) { 17 | const pageTitle = `Level ${id}`; 18 | return ( 19 |
    20 | 21 | {pageTitle} 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
    30 | ); 31 | } 32 | 33 | const mapStateToProps = (_, props) => ({ 34 | id: +getProp('match.params.id')(_, props), 35 | }); 36 | 37 | export default connect(mapStateToProps)(VocabLevelPage); 38 | -------------------------------------------------------------------------------- /app/common/utils/filterRomajiReadings.js: -------------------------------------------------------------------------------- 1 | import { toHiragana } from 'wanakana'; 2 | 3 | const WHITELIST = ['tin can', 'kimono']; 4 | 5 | /** 6 | * Removes meanings that are romaji versions of valid answers 7 | * If no meanings left, returns original meanings 8 | * @param {Array} [meanings=[]] review meanings 9 | * @param {Array} [readings=[]] review readings 10 | * @return {Array} filtered meanings 11 | * @example 12 | * filterRomajiReadings(['Southern Barbarians, Nanban'], ['なんばん']) 13 | * // => ['Southern Barbarians'] 14 | */ 15 | const filterRomajiReadings = (meanings = [], readings = []) => { 16 | if (WHITELIST.some((word) => meanings.includes(word))) { 17 | return meanings; 18 | } 19 | 20 | // TODO: filter out meanings that are modified versions of long o 21 | // IE: jomon / joumon, tohoku / touhoku 22 | 23 | const filteredMeanings = meanings.filter( 24 | (meaning) => !readings.some((reading) => RegExp(`^${reading}`, 'i').test(toHiragana(meaning))) 25 | ); 26 | return filteredMeanings.length ? filteredMeanings : meanings; 27 | }; 28 | 29 | export default filterRomajiReadings; 30 | -------------------------------------------------------------------------------- /app/features/landing/MultiLogin.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import FormSelector from './FormSelector'; 3 | import Form from './Form'; 4 | import { Wrapper } from './styles'; 5 | 6 | class MultiLogin extends React.Component { 7 | state = { 8 | activePanel: 'Login', 9 | panels: ['Register', 'Login', 'Reset'], 10 | }; 11 | 12 | setActivePanel = (activePanel) => { 13 | this.setState({ activePanel }); 14 | }; 15 | 16 | isActivePanel = (panel) => this.state.activePanel === panel; 17 | 18 | render() { 19 | const selections = { 20 | registerSelected: this.isActivePanel('Register'), 21 | loginSelected: this.isActivePanel('Login'), 22 | resetSelected: this.isActivePanel('Reset'), 23 | }; 24 | 25 | return ( 26 | 27 | 33 |
    34 | 35 | ); 36 | } 37 | } 38 | 39 | export default MultiLogin; 40 | -------------------------------------------------------------------------------- /app/pages/VocabEntryPage/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | import { Helmet } from 'react-helmet'; 5 | 6 | import { getProp } from 'common/selectors'; 7 | 8 | import PageWrapper from 'common/components/PageWrapper'; 9 | import Container from 'common/components/Container'; 10 | import VocabEntry from 'features/vocab/Entry'; 11 | 12 | VocabEntryPage.propTypes = { 13 | id: PropTypes.number.isRequired, 14 | }; 15 | 16 | export function VocabEntryPage({ id }) { 17 | const pageTitle = `Vocabulary: Entry ${id}`; 18 | return ( 19 |
    20 | 21 | {pageTitle} 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
    30 | ); 31 | } 32 | 33 | const mapStateToProps = (_, props) => ({ 34 | id: +getProp('match.params.id')(_, props), 35 | }); 36 | 37 | export default connect(mapStateToProps)(VocabEntryPage); 38 | -------------------------------------------------------------------------------- /app/common/components/TagsList/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | import cuid from 'cuid'; 5 | 6 | import { selectTags } from 'features/vocab/selectors'; 7 | 8 | import parseTags from 'common/utils/parseTags'; 9 | import getTagColors from './utils/getTagColors'; 10 | 11 | import { Ul, Li, Text } from './styles'; 12 | 13 | TagsList.propTypes = { 14 | tags: PropTypes.arrayOf(PropTypes.string), 15 | isVisible: PropTypes.bool, 16 | }; 17 | 18 | TagsList.defaultProps = { 19 | tags: [], 20 | isVisible: true, 21 | }; 22 | 23 | export function TagsList({ tags, isVisible, ...props }) { 24 | const longformTags = parseTags(tags); 25 | return ( 26 |
      27 | {longformTags.map((text) => ( 28 |
    • 29 | {text} 30 |
    • 31 | ))} 32 |
    33 | ); 34 | } 35 | 36 | const mapStateToProps = (state, props) => ({ 37 | tags: selectTags(state, props), 38 | }); 39 | 40 | export default connect(mapStateToProps)(TagsList); 41 | -------------------------------------------------------------------------------- /app/common/components/ScrollToTop/ScrollTopButton/styles.js: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components"; 2 | 3 | import { resetButton } from "common/styles/utils"; 4 | import { shadowBox } from "common/styles/shadows"; 5 | import { purple, pink } from "common/styles/colors"; 6 | import { fastEaseQuad } from "common/styles/animation"; 7 | 8 | const visibleMixin = ({ isVisible }) => isVisible && css` 9 | transition: all ${fastEaseQuad}; 10 | transform: scale(1); 11 | 12 | &:hover { 13 | opacity: 1; 14 | } 15 | `; 16 | 17 | const scrollingMixin = ({ isScrolling }) => isScrolling && css` 18 | opacity: 1; 19 | background-color: ${pink[5]}; 20 | `; 21 | 22 | export const StyledButton = styled.button` 23 | ${resetButton} 24 | ${shadowBox} 25 | position: fixed; 26 | bottom: 0.75rem; 27 | right: 0.75rem; 28 | border-radius: 100%; 29 | background-color: ${purple[3]}; 30 | opacity: 0.8; 31 | transform: scale(0); 32 | transition: all ${fastEaseQuad}; 33 | z-index: 10; 34 | &:active { 35 | opacity: 1; 36 | background-color: ${pink[5]}; 37 | } 38 | ${visibleMixin} 39 | ${scrollingMixin}; 40 | `; 41 | -------------------------------------------------------------------------------- /app/common/components/VocabList/VocabCard/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { mix } from 'polished'; 3 | import { gutter } from 'common/styles/layout'; 4 | import { borderRadius } from 'common/styles/sizing'; 5 | 6 | import A from 'common/components/A'; 7 | 8 | export const Wrapper = styled.li` 9 | display: inline-flex; 10 | min-width: 8em; 11 | flex: 1 0 auto; 12 | justify-content: center; 13 | color: ${({ textColor }) => textColor}; 14 | background-color: ${({ bgColor }) => mix(0.8, bgColor, '#bbb')}; 15 | text-shadow: 0 2px 2px ${({ bgColor }) => mix(0.8, bgColor, '#444')}; 16 | border-radius: ${borderRadius}; 17 | box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.1); 18 | 19 | .user-is-tabbing & a:focus { 20 | outline-color: ${({ bgColor }) => bgColor}; 21 | } 22 | `; 23 | 24 | export const Link = styled(A)` 25 | ${gutter({ position: 'vertical', mod: 2 })} 26 | ${gutter({ position: 'horizontal', mod: 2 })} 27 | display: flex; 28 | width: 100%; 29 | flex-flow: column nowrap; 30 | text-align: center; 31 | justify-content: center; 32 | align-content: center; 33 | align-items: center; 34 | `; 35 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Duncan Bay 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/common/components/Divider/styles.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { transparentize } from "polished"; 3 | import * as COLORS from "common/styles/colors"; 4 | import { gutter } from "common/styles/layout"; 5 | 6 | const fullWidthMixin = ({ fullWidth }) => `max-width: ${fullWidth ? "100%" : "70%"};`; 7 | 8 | const borderColorMixin = ({ color, fade }) => { 9 | const dividerColor = COLORS[color] || color; 10 | return fade ? ` 11 | border-image: linear-gradient( 12 | 90deg, 13 | ${transparentize(1, dividerColor)}, 14 | ${transparentize(0, dividerColor)} 50%, 15 | ${transparentize(1, dividerColor)} 100% 16 | ) 0 0 100%; 17 | ` : ` 18 | border-color: ${dividerColor}; 19 | `; 20 | }; 21 | 22 | export const StyledDivider = styled.div` 23 | ${fullWidthMixin} 24 | ${gutter({ prop: "margin", position: "top" })} 25 | margin-left: auto; 26 | margin-right: auto; 27 | color: ${({ color }) => COLORS[color] || color}; 28 | background-color: ${COLORS.transparent}; 29 | background-position: 50%; 30 | border: 0; 31 | border-width: 0 0 1px; 32 | border-style: solid; 33 | ${borderColorMixin}; 34 | `; 35 | -------------------------------------------------------------------------------- /app/common/validations.js: -------------------------------------------------------------------------------- 1 | import { isKanji, isJapanese, isKana } from 'wanakana'; 2 | 3 | export const onlyKanjiKana = (value = '') => isJapanese(value) ? undefined : 'Must be a mix of kanji and okurigana'; 4 | 5 | export const onlyKana = (value = '') => isKana(value) ? undefined : 'Must be hiragana or katakana'; 6 | 7 | export const onlyKanjiOrKana = (value = '') => isKanji(value) || isJapanese(value) || isKana(value) 8 | ? undefined 9 | : 'Must be kana, kanji, or a mix of both'; 10 | 11 | export const doValuesMatch = (value, matcher) => value && value === matcher ? undefined : 'Does not match'; 12 | 13 | export const requiredValid = (value) => (value ? undefined : 'Required'); 14 | 15 | export const minLengthValid = (value) => value.length > 4 ? undefined : 'Length must be greater than 4'; 16 | 17 | export const confirmPasswordValid = (value, allValues) => doValuesMatch(value, allValues.password); 18 | 19 | export const numberValid = (value) => !value || !Number.isNaN(Number(value)) ? undefined : 'Must be a number'; 20 | 21 | export const emailValid = (value) => /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(value) ? undefined : 'Invalid email address'; 22 | -------------------------------------------------------------------------------- /app/features/quiz/QuizSummary/QuizSummarySections/PercentageBar/styles.js: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components'; 2 | import { transparentize, darken } from 'polished'; 3 | 4 | import { white } from 'common/styles/colors'; 5 | import { gutter } from 'common/styles/layout'; 6 | import { beta } from 'common/styles/typography'; 7 | 8 | export const Background = styled.div` 9 | display: flex; 10 | position: relative; 11 | padding: .8rem; 12 | flex: 1 1 auto; 13 | `; 14 | 15 | export const Text = styled.h1` 16 | ${beta} 17 | margin: 0; 18 | line-height: 1; 19 | align-self: center; 20 | color: ${white[2]}; 21 | z-index: 2; 22 | `; 23 | 24 | export const Wrapper = styled.div` 25 | ${gutter()}; 26 | flex: 1 1 auto; 27 | 28 | ${({ color }) => css` 29 | & ${Background} { 30 | background-color: ${transparentize(0.75, color)}; 31 | } 32 | 33 | & ${Text} { 34 | text-shadow: 1px 1px 0.1em ${transparentize(0.5, darken(0.4, color))}; 35 | } 36 | `} 37 | 38 | `; 39 | 40 | 41 | export const Bar = styled.div` 42 | position: absolute; 43 | top: 0; 44 | left: 0; 45 | height: 100%; 46 | z-index: 1; 47 | `; 48 | -------------------------------------------------------------------------------- /app/features/quiz/QuizSummary/QuizSummaryHeader/SessionLink/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { white, blue } from 'common/styles/colors'; 5 | 6 | import { Wrapper, LinkBlock, Left, Right, InboxIcon, Count } from './styles'; 7 | 8 | SessionLink.propTypes = { 9 | text: PropTypes.string.isRequired, 10 | to: PropTypes.string.isRequired, 11 | count: PropTypes.number.isRequired, 12 | isDisabled: PropTypes.bool.isRequired, 13 | onClick: PropTypes.func.isRequired, 14 | color: PropTypes.string, 15 | }; 16 | 17 | SessionLink.defaultProps = { 18 | color: blue[4], 19 | }; 20 | 21 | function SessionLink({ text, to, count, color, isDisabled, onClick }) { 22 | return ( 23 | 24 | 25 | {text} 26 | 27 | 28 | {count} 29 | 30 | 31 | 32 | ); 33 | } 34 | 35 | export default SessionLink; 36 | -------------------------------------------------------------------------------- /server/middlewares/addDevMiddlewares.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const webpackDevMiddleware = require('webpack-dev-middleware'); 4 | const webpackHotMiddleware = require('webpack-hot-middleware'); 5 | 6 | function createWebpackMiddleware(compiler, publicPath) { 7 | return webpackDevMiddleware(compiler, { 8 | publicPath, 9 | stats: 'errors-only', 10 | logLevel: 'warn', 11 | }); 12 | } 13 | 14 | module.exports = function addDevMiddlewares(app, webpackConfig) { 15 | const compiler = webpack(webpackConfig); 16 | const middleware = createWebpackMiddleware(compiler, webpackConfig.output.publicPath); 17 | 18 | app.use(middleware); 19 | app.use(webpackHotMiddleware(compiler)); 20 | 21 | // Since webpackDevMiddleware uses memory-fs internally to store build 22 | // artifacts, we use it instead 23 | const fs = middleware.fileSystem; 24 | 25 | app.get('*', (req, res) => { 26 | fs.readFile(path.join(compiler.outputPath, 'index.html'), (err, file) => { 27 | if (err) { 28 | res.sendStatus(404); 29 | } else { 30 | res.send(file.toString()); 31 | } 32 | }); 33 | }); 34 | }; 35 | -------------------------------------------------------------------------------- /app/common/components/Loadable/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactLoadable from 'react-loadable'; 3 | import PropTypes from 'prop-types'; 4 | 5 | const Loadable = ({ loader, loading: CustomLoadingComponent, ...rest }) => 6 | class InnerLoadable extends React.Component { 7 | static contextTypes = { 8 | store: PropTypes.object, 9 | defaultLoadingComponent: PropTypes.any, 10 | }; 11 | 12 | // loaderWithAsyncInjectors = () => { 13 | // if (loader) { 14 | // return loader(getAsyncInjectors(this.context.store)) 15 | // .then((component) => component.default ? component.default : component); 16 | // } 17 | // return Promise.resolve(null); 18 | // }; 19 | 20 | emptyLoadingComponent = () => null; 21 | 22 | loadableComponent = ReactLoadable({ 23 | delay: 3000, 24 | ...rest, 25 | loader, 26 | loading: 27 | CustomLoadingComponent || 28 | this.context.defaultLoadingComponent || 29 | this.emptyLoadingComponent, 30 | }); 31 | 32 | render() { 33 | return ; 34 | } 35 | }; 36 | 37 | export default Loadable; 38 | -------------------------------------------------------------------------------- /app/common/utils/condenseReadings.js: -------------------------------------------------------------------------------- 1 | import { groupBy } from 'lodash'; 2 | 3 | /** 4 | * Combines kana for each vocab word under a single entry 5 | * @param {Array} [readings=[]] vocabulary readings 6 | * @return {Array} readings with kana combined 7 | */ 8 | export default function condenseReadings(readings = []) { 9 | // nest readings into groups by character entry 10 | const groupedReadings = Object.values(groupBy(readings, 'character')); 11 | // re-order entry with furistring to front 12 | const primaryFirst = groupedReadings.map((group) => 13 | group.sort((entry) => (entry.furigana != null ? -1 : 1)) 14 | ); 15 | 16 | return combineKana(primaryFirst); 17 | } 18 | 19 | function combineKana(list) { 20 | return list.map((entries) => 21 | entries 22 | .map(ensureKanaArray) 23 | .reduce((entry, next) => (!entry.kana ? next : spreadKana(entry, next)), {}) 24 | ); 25 | } 26 | 27 | function spreadKana(entry, next) { 28 | return { 29 | ...entry, 30 | kana: [...entry.kana, ...next.kana], 31 | }; 32 | } 33 | 34 | function ensureKanaArray(obj) { 35 | return { 36 | ...obj, 37 | kana: Array.isArray(obj.kana) ? obj.kana : [obj.kana], 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /app/common/components/TagsList/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | import { resetList, fluidType, ghost } from 'common/styles/utils'; 4 | import { gutter } from 'common/styles/layout'; 5 | import { borderRadius } from 'common/styles/sizing'; 6 | 7 | export const Ul = styled.ul` 8 | ${resetList} 9 | ${gutter({ position: 'horizontal', mod: 1 })} 10 | ${gutter({ position: 'vertical', mod: 0.75 })} 11 | ${({ isHidden }) => isHidden && ghost} 12 | `; 13 | 14 | export const Li = styled.li` 15 | ${fluidType(10, 16, 300, 1800)} 16 | display: inline-flex; 17 | max-width: 100%; 18 | ${gutter({ prop: 'margin', position: 'horizontal', mod: 0.5 })} 19 | ${gutter({ prop: 'margin', position: 'vertical', mod: 0.5 })} 20 | text-decoration: none; 21 | align-items: center; 22 | color: ${({ textColor }) => textColor}; 23 | background-color: ${({ bgColor }) => bgColor}; 24 | border-radius: ${borderRadius}; 25 | `; 26 | 27 | export const Text = styled.span` 28 | overflow: hidden; 29 | padding: .25rem .5rem; 30 | text-transform: lowercase; 31 | text-overflow: ellipsis; 32 | line-height: 1; 33 | white-space: nowrap; 34 | margin-left: .2em; 35 | margin-right: .2em; 36 | `; 37 | -------------------------------------------------------------------------------- /app/features/vocab/Level/Notice.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import Element from 'common/components/Element'; 5 | import H3 from 'common/components/H3'; 6 | import A from 'common/components/A'; 7 | 8 | Notice.propTypes = { 9 | ids: PropTypes.arrayOf(PropTypes.number), 10 | isLocked: PropTypes.bool, 11 | }; 12 | 13 | function Notice({ ids, isLocked }) { 14 | let notice = null; 15 | if (isLocked === true) { 16 | notice = ( 17 | 18 |

    19 | Level is locked! Unlock it in Vocabulary Levels 20 |

    21 |
    22 | ); 23 | } else if (isLocked === false && !ids.length) { 24 | notice = ( 25 | 26 |

    27 | All entries hidden. Ensure you have unlocked{' '} 28 | vocabulary words (not just Kanji) on WaniKani for this level. In{' '} 29 | Settings confirm that Follow Wanikani is enabled 30 | and check your WaniKani SRS filters there as well. 31 |

    32 |
    33 | ); 34 | } 35 | return notice; 36 | } 37 | 38 | export default Notice; 39 | -------------------------------------------------------------------------------- /app/common/components/LockButton/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import IconButton from 'common/components/IconButton'; 5 | 6 | LockButton.propTypes = { 7 | size: PropTypes.string, 8 | isActionable: PropTypes.bool, 9 | isSubmitting: PropTypes.bool, 10 | isLocked: PropTypes.bool, 11 | children: PropTypes.any, 12 | onClick: PropTypes.func.isRequired, 13 | }; 14 | 15 | LockButton.defaultProps = { 16 | size: '1.5em', 17 | isActionable: true, 18 | isSubmitting: false, 19 | isLocked: false, 20 | children: false, 21 | }; 22 | 23 | function LockButton({ 24 | isSubmitting, isActionable, isLocked, children, ...props 25 | }) { 26 | let title = 'Not allowed'; 27 | let icon = 'LOCK_SOLID'; 28 | if (isSubmitting) { 29 | icon = 'SYNC'; 30 | title = 'Syncing'; 31 | } else if (isActionable) { 32 | icon = isLocked ? 'LOCK_CLOSED' : 'LOCK_OPEN'; 33 | title = isLocked ? 'Unlock' : 'Lock'; 34 | } 35 | 36 | return ( 37 | 43 | {children} 44 | 45 | ); 46 | } 47 | 48 | export default LockButton; 49 | -------------------------------------------------------------------------------- /app/common/utils/__tests__/smoothScrollY.test.js: -------------------------------------------------------------------------------- 1 | import * as fakeRaf from 'fake-raf'; // mocked requestAnimationFrame 2 | import smoothScrollY from '../smoothScrollY'; 3 | 4 | describe('smoothScrollY()', () => { 5 | let spy; 6 | 7 | beforeEach(() => { 8 | spy = jest.spyOn(window, 'scrollTo').mockImplementation(() => {}); 9 | fakeRaf.use(); 10 | }); 11 | 12 | afterEach(() => { 13 | spy.mockRestore(); 14 | fakeRaf.restore(); 15 | }); 16 | 17 | it('should call window.scrollTo()', () => { 18 | smoothScrollY(); 19 | expect(spy).toHaveBeenCalled(); 20 | fakeRaf.step(); 21 | expect(spy).toHaveBeenCalledTimes(2); 22 | fakeRaf.step(); 23 | expect(spy).toHaveBeenCalledTimes(3); 24 | }); 25 | 26 | it('should end when timing is diminished', () => { 27 | smoothScrollY(0, 10); 28 | expect(spy).toHaveBeenCalled(); 29 | fakeRaf.step(); 30 | fakeRaf.step(); 31 | fakeRaf.step(); 32 | fakeRaf.step(); 33 | fakeRaf.step(); 34 | fakeRaf.step(); 35 | expect(spy).toHaveBeenCalledTimes(7); 36 | // step some more, but no more scrolls should fire 37 | fakeRaf.step(); 38 | fakeRaf.step(); 39 | expect(spy).toHaveBeenCalledTimes(7); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /app/common/components/ScrollToTop/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { debounce } from 'lodash'; 3 | 4 | import smoothScrollY from 'common/utils/smoothScrollY'; 5 | import ScrollTopButton from './ScrollTopButton'; 6 | 7 | class ScrollToTop extends React.PureComponent { 8 | state = { 9 | isVisible: false, 10 | isScrolling: false, 11 | }; 12 | 13 | componentDidMount() { 14 | window.addEventListener('scroll', this.onScroll); 15 | } 16 | 17 | componentWillUnmount() { 18 | window.removeEventListener('scroll', this.onScroll); 19 | } 20 | 21 | onScroll = debounce(() => { 22 | const belowOneThirdViewport = window.pageYOffset > window.innerHeight / 3; 23 | this.setState((prevState) => ({ 24 | isVisible: belowOneThirdViewport, 25 | isScrolling: prevState.isScrolling && !belowOneThirdViewport ? false : prevState.isScrolling, 26 | })); 27 | }, 100); 28 | 29 | scrollUp = () => { 30 | if (!this.state.isScrolling) { 31 | this.setState(() => ({ isScrolling: true })); 32 | smoothScrollY(0, 2000); 33 | } 34 | }; 35 | 36 | render() { 37 | return ; 38 | } 39 | } 40 | 41 | export default ScrollToTop; 42 | -------------------------------------------------------------------------------- /app/common/components/SentencePair/MarkedSentence/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | import splitSentenceByMatch from 'common/utils/splitSentenceByMatch'; 5 | import A from 'common/components/A'; 6 | 7 | import { Sentence, VocabMark } from './styles'; 8 | 9 | MarkedSentence.propTypes = { 10 | sentence: PropTypes.string.isRequired, 11 | head: PropTypes.string.isRequired, 12 | match: PropTypes.string.isRequired, 13 | tail: PropTypes.string.isRequired, 14 | }; 15 | 16 | function MarkedSentence({ sentence, head, match, tail }) { 17 | return ( 18 | 19 | 25 | {head} 26 | {match} 27 | {tail} 28 | 29 | 30 | ); 31 | } 32 | 33 | const enhance = connect((state, props) => { 34 | const { head, match, tail } = splitSentenceByMatch(props); 35 | return { sentence: props.sentence, head, match, tail }; 36 | }); 37 | 38 | export default enhance(MarkedSentence); 39 | -------------------------------------------------------------------------------- /app/common/components/Loadable/DefaultLoadingComponentProvider.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import warning from 'warning'; 4 | 5 | class DefaultLoadingComponentProvider extends React.Component { 6 | static childContextTypes = { 7 | defaultLoadingComponent: PropTypes.any, 8 | }; 9 | 10 | static propTypes = { 11 | component: PropTypes.any, 12 | children: PropTypes.node, 13 | }; 14 | 15 | getChildContext = () => ({ 16 | // Can't be changed dynamically by design, hence no `setState`; to avoid stale `defaultLoadingComponent` problem 17 | defaultLoadingComponent: this.props.component, 18 | }); 19 | 20 | render() { 21 | const { children } = this.props; 22 | 23 | return React.Children.only(children); 24 | } 25 | } 26 | 27 | if (process.env.NODE_ENV !== 'production') { 28 | DefaultLoadingComponentProvider.prototype.componentWillReceiveProps = function componentWillReceiveProps( 29 | nextProps 30 | ) { 31 | warning( 32 | this.defaultLoadingComponent === nextProps.component, 33 | ' does not support dynamic ' 34 | ); 35 | }; 36 | } 37 | 38 | export default DefaultLoadingComponentProvider; 39 | -------------------------------------------------------------------------------- /app/pages/LandingPage/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | import { lighten, darken, transparentize } from 'polished'; 4 | import { purple } from 'common/styles/colors'; 5 | import { godzilla } from 'common/styles/typography'; 6 | 7 | import H1 from 'common/components/H1'; 8 | import BackgroundImg from 'common/components/BackgroundImg'; 9 | import PageWrapper from 'common/components/PageWrapper'; 10 | 11 | export const bgImgColor = '#e5e5e5'; 12 | 13 | export const Wrapper = styled(PageWrapper)` 14 | background: ${bgImgColor}; /* same as background-image */ 15 | `; 16 | 17 | export const Title = styled(H1)` 18 | ${godzilla} 19 | letter-spacing: -0.03em; 20 | padding-top: .3em; 21 | padding-bottom: .3em; 22 | color: ${lighten(0.1, purple[5])}; 23 | text-shadow: 0 .05rem .1rem ${transparentize(0.2, darken(0.3, purple[5]))}; 24 | text-align: center; 25 | font-weight: 500; 26 | z-index: 2; 27 | `; 28 | 29 | export const LandingBackgroundImg = styled(BackgroundImg)` 30 | left: 50%; 31 | transform: translateX(-50%); 32 | height: 100vh; 33 | min-width: 320px; 34 | max-width: 1200px; 35 | margin: 0 auto; 36 | background-size: 100%; 37 | background-position: bottom center; 38 | z-index: 1; 39 | `; 40 | -------------------------------------------------------------------------------- /app/common/utils/__tests__/pluralize.test.js: -------------------------------------------------------------------------------- 1 | import pluralize from "../pluralize"; 2 | 3 | describe("pluralize", () => { 4 | it("should return the given string if no arguments", () => { 5 | expect(pluralize("fhqwhgads")).toBe("fhqwhgads"); 6 | }); 7 | 8 | it("should have sane defaults", () => { 9 | expect(pluralize("dog", 0)).toBe("dogs"); 10 | expect(pluralize("dog", 1)).toBe("dog"); 11 | expect(pluralize("dog", 2)).toBe("dogs"); 12 | expect(pluralize("dog", 3)).toBe("dogs"); 13 | expect(pluralize("dog", 32)).toBe("dogs"); 14 | }); 15 | 16 | it("should handle negative numbers", () => { 17 | expect(pluralize("dog", -1)).toBe("dog"); 18 | expect(pluralize("dog", -2)).toBe("dogs"); 19 | expect(pluralize("dog", -3)).toBe("dogs"); 20 | expect(pluralize("dog", -23454)).toBe("dogs"); 21 | }); 22 | 23 | it("should handle a custom schema", () => { 24 | const schema = { 25 | single: "person", 26 | plural: "people", 27 | }; 28 | expect(pluralize("person", 0, schema)).toBe("people"); 29 | expect(pluralize("person", 1, schema)).toBe("person"); 30 | expect(pluralize("person", 2, schema)).toBe("people"); 31 | expect(pluralize("person", 3252, schema)).toBe("people"); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /app/common/components/VocabResetButton/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components'; 4 | 5 | import Button from 'common/components/Button'; 6 | import Icon from 'common/components/Icon'; 7 | import { gutter } from 'common/styles/layout'; 8 | import { white, grey } from 'common/styles/colors'; 9 | 10 | // prettier-ignore 11 | export const Text = styled.div` 12 | /* slightly nicer centering otherwise too far left due to button min-width & 2 char text */ 13 | ${gutter({ position: 'left', mod: 1.5 })} 14 | ${gutter({ position: 'right', mod: 2 })} 15 | `; 16 | 17 | function VocabResetButton({ disabled, onClick }) { 18 | return ( 19 | 32 | ); 33 | } 34 | 35 | VocabResetButton.propTypes = { 36 | onClick: PropTypes.func.isRequired, 37 | disabled: PropTypes.bool.isRequired, 38 | }; 39 | 40 | export default VocabResetButton; 41 | -------------------------------------------------------------------------------- /app/features/navigation/OffCanvasMenu/styles.js: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components'; 2 | 3 | import { white, purple } from 'common/styles/colors'; 4 | import { resetList } from 'common/styles/utils'; 5 | import { fastEaseQuad } from 'common/styles/animation'; 6 | 7 | import IconButton from 'common/components/IconButton'; 8 | 9 | export const Wrapper = styled.nav` 10 | position: fixed; 11 | width: 100%; 12 | height: 100%; 13 | top: 0; 14 | right: 0; 15 | background-image: linear-gradient(160deg, ${purple[4]} 10%, ${purple[3]} 100%); 16 | color: ${white[2]}; 17 | z-index: 10; 18 | visibility: hidden; 19 | transform: translateX(100%); 20 | transition: transform ${fastEaseQuad}, visibility 0s 0.5s; 21 | 22 | ${({ isVisible }) => isVisible && css` 23 | visibility: visible; 24 | transform: translateX(0%); 25 | transition: transform ${fastEaseQuad}; 26 | `} 27 | `; 28 | 29 | export const Ul = styled.ul` 30 | ${resetList} 31 | width: 100%; 32 | height: 100vh; 33 | margin-top: 4rem; 34 | display: flex; 35 | flex-direction: column; 36 | `; 37 | 38 | export const CloseButton = styled(IconButton)` 39 | position: absolute; 40 | right: .8rem; 41 | top: .8rem; 42 | overflow: hidden; 43 | z-index: 100; 44 | `; 45 | -------------------------------------------------------------------------------- /app/common/components/A/styles.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { darken } from 'polished'; 3 | import styled, { css } from 'styled-components'; 4 | import { NavLink } from 'react-router-dom'; 5 | 6 | import { link, linkHover } from 'common/styles/colors'; 7 | import { fastEaseQuad } from 'common/styles/animation'; 8 | 9 | const plainStyles = css` 10 | text-decoration: none; 11 | color: inherit; 12 | `; 13 | 14 | const linkStyles = css` 15 | transition: all ${fastEaseQuad}; 16 | color: ${({ color }) => color || link}; 17 | &:hover, 18 | &:focus { 19 | color: ${({ color }) => (color && darken(0.2, color)) || linkHover}; 20 | } 21 | `; 22 | 23 | export const Anchor = styled.a` 24 | ${({ plainLink }) => plainLink ? plainStyles : linkStyles} 25 | cursor: pointer; 26 | `; 27 | 28 | export const ExternalAnchor = styled.a.attrs({ 29 | target: '_blank', 30 | rel: 'external noopener noreferrer', 31 | })` 32 | ${({ plainLink }) => plainLink ? plainStyles : linkStyles} 33 | cursor: pointer; 34 | `; 35 | 36 | export const RouterLink = styled(({ plainLink, colorHover, bgColor, bgColorHover, isOffCanvas, noReviews, ...rest }) => )` 37 | ${({ plainLink }) => plainLink ? plainStyles : linkStyles} 38 | cursor: pointer; 39 | `; 40 | -------------------------------------------------------------------------------- /app/common/components/LogoLink/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { debounce } from 'lodash'; 5 | import { breakpoints } from 'common/styles/media'; 6 | import { StyledLink, Svg } from './styles'; 7 | 8 | export const LOGO_LARGE_REMS = '4.25rem'; 9 | export const LOGO_SMALL_REMS = '3.25rem'; 10 | 11 | const getSize = () => (window.innerWidth > breakpoints.md ? LOGO_LARGE_REMS : LOGO_SMALL_REMS); 12 | 13 | class LogoLink extends React.PureComponent { 14 | static propTypes = { 15 | to: PropTypes.string, 16 | }; 17 | 18 | static defaultProps = { 19 | to: '/', 20 | }; 21 | 22 | state = { 23 | size: getSize(), 24 | }; 25 | 26 | componentDidMount() { 27 | window.addEventListener('resize', this.handleResize); 28 | } 29 | 30 | componentWillUnmount() { 31 | window.removeEventListener('resize', this.handleResize); 32 | } 33 | 34 | handleResize = debounce(() => { 35 | this.setState({ size: getSize() }); 36 | }, 300); 37 | 38 | render() { 39 | return ( 40 | 41 | 42 | 43 | ); 44 | } 45 | } 46 | 47 | export default LogoLink; 48 | -------------------------------------------------------------------------------- /app/common/components/Button/__tests__/__snapshots__/index.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` 40 | )} 41 | 42 | 43 | {remaining} 44 | 45 | 46 | ); 47 | } 48 | 49 | export default TextAreaControls; 50 | -------------------------------------------------------------------------------- /app/features/settings/InputField.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { Block, Label, Note, ValidationMessage } from './styles'; 5 | 6 | const InputField = ({ input, meta, label, icon, placeholder, note, inputStyle, disabled }) => ( 7 | 8 | 20 | {meta.touched && meta.error && {meta.error}} 21 | {note && {note}} 22 | 23 | ); 24 | InputField.propTypes = { 25 | input: PropTypes.object.isRequired, 26 | meta: PropTypes.object.isRequired, 27 | label: PropTypes.string.isRequired, 28 | placeholder: PropTypes.string, 29 | note: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), 30 | icon: PropTypes.object, 31 | inputStyle: PropTypes.object, 32 | disabled: PropTypes.bool, 33 | }; 34 | 35 | InputField.defaultProps = { 36 | note: '', 37 | placeholder: '', 38 | inputStyle: {}, 39 | disabled: false, 40 | icon: undefined, 41 | }; 42 | 43 | export default InputField; 44 | -------------------------------------------------------------------------------- /app/features/landing/Input.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { InputWrapper, Label, InputField, ValidationMessage } from './styles'; 5 | 6 | Input.propTypes = { 7 | label: PropTypes.string.isRequired, 8 | input: PropTypes.object.isRequired, 9 | meta: PropTypes.object.isRequired, 10 | placeholder: PropTypes.string.isRequired, 11 | autoComplete: PropTypes.string, 12 | isHidden: PropTypes.bool, 13 | children: PropTypes.node, 14 | }; 15 | 16 | Input.defaultProps = { 17 | isHidden: false, 18 | }; 19 | 20 | function Input({ label, input, meta, placeholder, autoComplete, isHidden, children, ...props }) { 21 | return ( 22 | 23 | 24 | 36 | {children} 37 | {!isHidden && meta.touched && meta.error && ( 38 | {meta.error} 39 | )} 40 | 41 | ); 42 | } 43 | 44 | export default Input; 45 | -------------------------------------------------------------------------------- /app/features/quiz/QuizSummary/QuizSummarySections/VocabListRanked.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components'; 4 | import { titleCase } from 'voca'; 5 | import cuid from 'cuid'; 6 | 7 | import VocabList, { ITEM_TYPES as VOCABLIST_TYPES } from 'common/components/VocabList'; 8 | import StripeHeading from './StripeHeading'; 9 | 10 | import { gutter } from 'common/styles/layout'; 11 | 12 | // prettier-ignore 13 | const Wrapper = styled.div` 14 | ${gutter({ type: "inner" })} 15 | `; 16 | 17 | const VocabListRanked = ({ rankedIds, sectionType, bgColor, cardsExpanded }) => 18 | Object.entries(rankedIds).map(([rank, ids]) => { 19 | const count = ids.length; 20 | return ( 21 | count > 0 && ( 22 | 23 | 24 | 30 | 31 | ) 32 | ); 33 | }); 34 | 35 | VocabListRanked.propTypes = { 36 | rankedIds: PropTypes.object, 37 | sectionType: PropTypes.string, 38 | bgColor: PropTypes.string, 39 | cardsExpanded: PropTypes.bool, 40 | }; 41 | 42 | export default VocabListRanked; 43 | -------------------------------------------------------------------------------- /app/common/components/Toggle/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | const compose = (...fns) => (...args) => fns.forEach((fn) => fn && fn(...args)); 5 | 6 | class Toggle extends React.Component { 7 | static propTypes = { 8 | on: PropTypes.bool, // eslint-disable-line react/require-default-props 9 | defaultOn: PropTypes.bool, 10 | onToggle: PropTypes.func, 11 | render: PropTypes.func.isRequired, 12 | }; 13 | 14 | static defaultProps = { 15 | defaultOn: false, 16 | onToggle: () => {}, 17 | }; 18 | 19 | state = { 20 | on: this.props.defaultOn, 21 | }; 22 | 23 | getTogglerProps = ({ onClick, ...props } = {}) => ({ 24 | onClick: compose(onClick, this.toggle), 25 | "aria-expanded": this.state.on, 26 | ...props, 27 | }); 28 | 29 | toggle = () => { 30 | if (this.isOnControlled()) { 31 | this.props.onToggle(!this.props.on); 32 | } else { 33 | this.setState(({ on }) => ({ on: !on }), () => this.props.onToggle(this.state.on)); 34 | } 35 | }; 36 | 37 | isOnControlled() { 38 | return this.props.on !== undefined; 39 | } 40 | 41 | render() { 42 | return this.props.render({ 43 | on: this.isOnControlled() ? this.props.on : this.state.on, 44 | toggle: this.toggle, 45 | getTogglerProps: this.getTogglerProps, 46 | }); 47 | } 48 | } 49 | 50 | export default Toggle; 51 | -------------------------------------------------------------------------------- /app/common/components/LogoLink/styles.js: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom'; 2 | import styled, { css } from 'styled-components'; 3 | import { timingFunctions } from 'polished'; 4 | 5 | import Logo from '-!babel-loader!svg-react-loader!common/assets/img/logo.svg'; 6 | import { purple, pink } from 'common/styles/colors'; 7 | 8 | const linkStyle = css` 9 | display: block; 10 | position: relative; 11 | align-self: center; 12 | flex: 0 0 auto; 13 | width: ${({ size }) => size}; /*CSS instead of html width attr to support non-pixel units*/ 14 | height: ${({ size }) => size}; /*Prevents scaling issue in IE*/ 15 | background-repeat: no-repeat; 16 | color: ${purple[3]}; 17 | cursor: pointer; 18 | transition: color .4s ${timingFunctions('easeInOutSine')}; 19 | 20 | &:hover { 21 | transition: color .5s ${timingFunctions('easeOutQuad')}; 22 | color: ${purple[5]}; 23 | } 24 | 25 | &:active { 26 | transition: color .1s ${timingFunctions('easeOutQuad')}; 27 | color: ${pink[5]}; 28 | } 29 | `; 30 | 31 | const logoStyle = css` 32 | display: block; 33 | position: absolute; 34 | transform-origin: 50% 50% 0px; 35 | top: 0; 36 | right: 0; 37 | bottom: 0; 38 | left: 0; 39 | 40 | .bg { 41 | color: inherit; 42 | fill: currentColor; 43 | } 44 | `; 45 | 46 | 47 | export const StyledLink = styled(Link)`${linkStyle}`; 48 | export const Svg = styled(Logo)`${logoStyle}`; 49 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## KaniWani 2 | 3 | Thank you for contributing! Please take a moment to review our [**contributing guidelines**](https://github.com/Kaniwani/kw-frontend/blob/master/.github/CONTRIBUTING.md) 4 | to make the process easy and effective for everyone involved. 5 | 6 | **Please open an issue** before embarking on any significant pull request, especially those that 7 | add a new library or change existing tests, otherwise you risk spending a lot of time working 8 | on something that might not end up being merged into the project. 9 | 10 | Before opening a pull request, please ensure: 11 | 12 | - [ ] You have followed our [**contributing guidelines**](https://github.com/Kaniwani/kw-frontend/blob/master/.github/CONTRIBUTING.md) 13 | - [ ] Double-check your branch is based on `master` and targets `master` 14 | - [ ] Pull request has unit tests (as relevant - visual updates can be manually checked instead) 15 | - [ ] Code is well-commented, linted and follows project conventions 16 | - [ ] Documentation is updated (if necessary) 17 | - [ ] Description explains the issue/use-case resolved 18 | 19 | Be kind to code reviewers, please try to keep pull requests as small and focused as possible :) 20 | 21 | **IMPORTANT**: By submitting a patch, you agree to allow the project 22 | owners to license your work under the terms of the [MIT License](https://github.com/Kaniwani/kw-frontend/blob/master/LICENSE.md). 23 | -------------------------------------------------------------------------------- /app/features/announcements/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | import { gutter } from 'common/styles/layout'; 4 | import { white, grey } from 'common/styles/colors'; 5 | import { ffBody } from 'common/styles/typography'; 6 | 7 | import H4 from 'common/components/H4'; 8 | 9 | export const Article = styled.article` 10 | ${gutter({ position: 'vertical', type: 'margin' })} 11 | 12 | & .ReactCollapse--collapse { 13 | transition: height 500ms; 14 | } 15 | 16 | & .ReactCollapse--content { 17 | ${gutter({ position: 'vertical' })} 18 | display: flex; 19 | justify-content: center; 20 | line-height: 1.3; 21 | & > div { 22 | padding: 4px 0 8px; 23 | max-width: 30em; 24 | ul { 25 | padding-left: 12px; 26 | text-align: left; 27 | max-width: 26em; 28 | li { 29 | padding: 5px 0; 30 | } 31 | } 32 | } 33 | } 34 | `; 35 | 36 | export const Header = styled.header` 37 | display: flex; 38 | flex-flow: row wrap; 39 | align-items: center; 40 | justify-content: center; 41 | ${({ borderActive }) => borderActive && `border-bottom: 2px solid ${white[7]};`} 42 | `; 43 | 44 | export const Title = styled(H4)` 45 | font-weight: 500; 46 | `; 47 | 48 | export const Time = styled.time` 49 | padding: 0 .6rem; 50 | color: ${grey[8]}; 51 | font-family: ${ffBody}; 52 | font-weight: 400; 53 | font-size: 0.9em; 54 | `; 55 | -------------------------------------------------------------------------------- /app/common/components/SentencePair/RevealSentence/styles.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | import P from "common/components/P"; 4 | import Icon from "common/components/Icon"; 5 | 6 | import { gutter } from "common/styles/layout"; 7 | import { borderRadius } from "common/styles/sizing"; 8 | import { grey, transparent } from "common/styles/colors"; 9 | import { fastEaseQuad, midEaseQuad } from "common/styles/animation"; 10 | 11 | export const Wrapper = styled.div` 12 | ${gutter()} 13 | `; 14 | 15 | export const RevealIcon = styled(Icon)` 16 | position: absolute; 17 | top: 50%; 18 | left: 50%; 19 | opacity: 1; 20 | transition: opacity ${fastEaseQuad}; 21 | transform: translate(-50%, -50%); 22 | z-index: 1; 23 | color: ${grey[8]}; 24 | pointer-events: none; 25 | `; 26 | 27 | export const Sentence = styled(P)` 28 | position: relative; 29 | display: inline-flex; 30 | font-size: 1.1em; 31 | font-style: italic; 32 | line-height: 1.2; 33 | transition: all ${fastEaseQuad}; 34 | border-radius: ${borderRadius}; 35 | /* blur effect */ 36 | color: ${transparent}; 37 | text-shadow: 0 0 0.8em rgba(0, 0, 0, 0.4); 38 | &:hover, 39 | &:active, 40 | &:focus { 41 | transition: all ${midEaseQuad}; 42 | outline: none; 43 | color: ${grey[8]}; 44 | text-shadow: none; 45 | & ${RevealIcon} { 46 | transition: opacity ${midEaseQuad}; 47 | opacity: 0; 48 | } 49 | } 50 | `; 51 | -------------------------------------------------------------------------------- /app/features/quiz/QuizSession/styles.js: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components'; 2 | import { lighten, darken } from 'polished'; 3 | 4 | import { white } from 'common/styles/colors'; 5 | import BackgroundImg from 'common/components/BackgroundImg'; 6 | import { Meanings } from 'features/quiz/QuizSession/QuizQuestion/styles'; 7 | // match review background image svg color 8 | export const backgroundImageColor = '#e5e5e5'; 9 | 10 | export const Upper = styled.section` 11 | display: flex; 12 | flex-direction: column; 13 | min-height: 30vh; 14 | color: ${white[3]}; 15 | 16 | ${({ bgColor }) => css` 17 | background-color: ${bgColor}; 18 | background-image: linear-gradient(180deg, ${lighten(0.01, bgColor)}, ${darken(0.02, bgColor)}); 19 | background-repeat: repeat-x; 20 | `} 21 | 22 | & ${Meanings} { 23 | text-shadow: 0 0.1em 1em ${({ bgColor }) => darken(0.1, bgColor)}; 24 | } 25 | `; 26 | 27 | export const Lower = styled.section` 28 | position: relative; 29 | display: flex; 30 | flex-direction: column; 31 | flex: 1 1 auto; 32 | background-color: ${backgroundImageColor}; 33 | `; 34 | 35 | export const Background = styled(BackgroundImg)` 36 | position: absolute; 37 | left: 50%; 38 | transform: translateX(-50%); 39 | height: 100%; 40 | min-height: auto; 41 | max-height: 25vmax; 42 | margin-top: auto; 43 | opacity: .9; 44 | z-index: 0; 45 | background-position: bottom right; 46 | `; 47 | --------------------------------------------------------------------------------