├── .eslintignore
├── public
├── config.js
├── favicon.ico
├── logo192.png
├── logo512.png
├── robots.txt
├── manifest.json
├── index.html
├── logo.svg
└── default-themes
│ ├── dark.css
│ └── light.css
├── src
├── Components
│ ├── Nav
│ │ ├── UpdateAvailable.scss
│ │ ├── NavButton.jsx
│ │ ├── UpdateAvailable.jsx
│ │ ├── Nav.scss
│ │ ├── NavButton.scss
│ │ └── MobileTopNav.scss
│ ├── Flag
│ │ ├── flags.png
│ │ ├── Flag.scss
│ │ └── Flag.jsx
│ ├── Congratulation
│ │ ├── Congratulation.scss
│ │ └── Congratulation.jsx
│ ├── VersionTable
│ │ └── VersionTable.scss
│ ├── sections.scss
│ ├── Timer
│ │ ├── CountdownTimer.scss
│ │ └── CountdownTimer.jsx
│ ├── Layout
│ │ ├── CleanLayout
│ │ │ ├── CleanLayout.scss
│ │ │ └── CleanLayout.jsx
│ │ ├── UnauthenticatedLayout
│ │ │ ├── UnauthenticatedLayout.scss
│ │ │ └── UnauthenticatedLayout.jsx
│ │ └── AuthenticatedLayout
│ │ │ └── AuthenticatedLayout.scss
│ ├── Counter
│ │ ├── Counter.jsx
│ │ └── Counter.scss
│ ├── ConfirmDialog
│ │ ├── ConfirmDialog.scss
│ │ └── ConfirmDialog.jsx
│ ├── Form
│ │ ├── ArrayTextInput
│ │ │ └── ArrayTextInput.scss
│ │ ├── Switch
│ │ │ ├── Switch.jsx
│ │ │ └── Switch.scss
│ │ ├── Textarea
│ │ │ ├── Textarea.jsx
│ │ │ └── Textarea.scss
│ │ └── TextInput
│ │ │ └── TextInput.scss
│ ├── Indicators
│ │ ├── LoadingIndicator
│ │ │ ├── LoadingIndicator.jsx
│ │ │ └── LoadingIndicator.scss
│ │ ├── InviteCodeValidIndicator
│ │ │ ├── InviteCodeValidIndicator.scss
│ │ │ └── InviteCodeValidIndicator.jsx
│ │ ├── PageIndicator
│ │ │ ├── PageIndicator.scss
│ │ │ └── PageIndicator.jsx
│ │ ├── ServerValidIndicator
│ │ │ └── ServerValidIndicator.scss
│ │ └── PasswordComplexityIndicator
│ │ │ ├── PasswordComplexityIndicator.scss
│ │ │ └── PasswordComplexityIndicator.jsx
│ ├── Tooltip
│ │ ├── Tooltip.jsx
│ │ └── Tooltip.scss
│ ├── Snackbar
│ │ ├── Snackbar.jsx
│ │ └── Snackbar.scss
│ ├── Details
│ │ ├── Details.jsx
│ │ └── Details.scss
│ ├── SlideShow
│ │ ├── SlideShow.scss
│ │ └── SlideShow.jsx
│ ├── ContributorCard
│ │ ├── ContributorCard.jsx
│ │ └── ContributorCard.scss
│ ├── Charts
│ │ └── ProgressBar
│ │ │ ├── ProgressBar.scss
│ │ │ └── ProgressBar.jsx
│ ├── CookieConsentBanner
│ │ ├── CookieConsentBanner.scss
│ │ └── CookieConsentBanner.jsx
│ ├── ThemeSelector
│ │ └── ThemeSelector.jsx
│ ├── LanguageSelector
│ │ └── LanguageSelector.jsx
│ ├── DirectionBox
│ │ ├── DirectionBox.scss
│ │ └── DirectionBox.jsx
│ ├── InviteCode
│ │ ├── InviteCode.scss
│ │ └── InviteCode.jsx
│ ├── Modal
│ │ ├── Modal.scss
│ │ └── Modal.jsx
│ ├── PackageOverview
│ │ └── PackageOverview.scss
│ ├── Button
│ │ └── Button.jsx
│ ├── Table
│ │ └── Table.scss
│ ├── SelectionBox
│ │ ├── SelectionBox.scss
│ │ └── SelectionBox.jsx
│ └── StatsTable
│ │ └── StatsTable.jsx
├── Forms
│ ├── ImportPreviewForm
│ │ ├── ImportPreviewForm.scss
│ │ ├── GroupPreview
│ │ │ └── GroupPreview.scss
│ │ ├── ImportPreviewForm.jsx
│ │ └── PackagePreview
│ │ │ └── PackagePreview.scss
│ ├── InviteCodeForm
│ │ └── InviteCodeForm.scss
│ ├── GroupForm
│ │ └── GroupForm.scss
│ ├── PackageForm
│ │ └── PackageForm.scss
│ └── VocabForm
│ │ └── VocabForm.scss
├── constants.scss
├── images
│ ├── icons
│ │ ├── check.png
│ │ ├── addBtn-blue.png
│ │ ├── addBtn-grey.png
│ │ ├── addBtn-grey-30px.png
│ │ └── hamburger.svg
│ ├── logo
│ │ ├── vocascan-logo.png
│ │ ├── vocascan-round-mac.png
│ │ ├── vocascan-round-win.ico
│ │ ├── vocascan-round-linux.png
│ │ ├── vocascan-github-cover.png
│ │ ├── transparent-round.svg
│ │ ├── transparent-rect.svg
│ │ ├── color-round.svg
│ │ └── color-rect.svg
│ └── preview
│ │ ├── dashboard.png
│ │ ├── query-card-back.png
│ │ ├── add-vocab-screen.png
│ │ ├── query-card-front.png
│ │ └── language-selection.png
├── screens
│ ├── Learn
│ │ ├── Learn.scss
│ │ ├── End
│ │ │ ├── End.scss
│ │ │ └── End.jsx
│ │ ├── Learn.jsx
│ │ ├── Dashboard
│ │ │ ├── Dashboard.scss
│ │ │ └── Dashboard.jsx
│ │ ├── Query
│ │ │ └── Query.scss
│ │ └── Direction
│ │ │ ├── Direction.scss
│ │ │ └── Direction.jsx
│ ├── AddVocab
│ │ ├── AddVocab.scss
│ │ └── AddVocab.jsx
│ ├── Guide
│ │ ├── Pages
│ │ │ ├── VocabDescription
│ │ │ │ ├── addVocab.png
│ │ │ │ ├── VocabDescription.jsx
│ │ │ │ └── VocabDescription.scss
│ │ │ ├── GroupDescription
│ │ │ │ ├── addGroup1.png
│ │ │ │ ├── addGroup2.png
│ │ │ │ ├── GroupDescription.jsx
│ │ │ │ └── GroupDescription.scss
│ │ │ ├── PackageDescription
│ │ │ │ ├── addPackage1.png
│ │ │ │ ├── addPackage2.png
│ │ │ │ ├── PackageDescription.jsx
│ │ │ │ └── PackageDescription.scss
│ │ │ ├── End
│ │ │ │ ├── End.scss
│ │ │ │ └── End.jsx
│ │ │ └── Start
│ │ │ │ ├── Start.scss
│ │ │ │ └── Start.jsx
│ │ └── Guide.scss
│ ├── Library
│ │ ├── Library.scss
│ │ ├── Library.jsx
│ │ ├── AllVocabs
│ │ │ └── AllVocabs.scss
│ │ ├── AllPackages
│ │ │ └── AllPackages.scss
│ │ └── AllGroups
│ │ │ └── AllGroups.scss
│ ├── Custom
│ │ ├── Custom.scss
│ │ └── Custom.jsx
│ ├── Progress
│ │ ├── Progress.scss
│ │ └── Progress.jsx
│ ├── SelectionScreen
│ │ └── SelectionScreen.scss
│ ├── Auth
│ │ ├── EmailVerify
│ │ │ └── EmailVerify.scss
│ │ ├── Login
│ │ │ └── Login.scss
│ │ └── Register
│ │ │ └── Register.scss
│ ├── About
│ │ ├── About.scss
│ │ └── About.jsx
│ ├── Settings
│ │ ├── Settings.scss
│ │ └── Settings.jsx
│ ├── Admin
│ │ └── Admin.scss
│ └── Profile
│ │ └── Profile.scss
├── redux
│ ├── Actions
│ │ ├── language.js
│ │ ├── table.js
│ │ ├── setting.js
│ │ ├── form.js
│ │ ├── index.js
│ │ ├── query.js
│ │ └── login.js
│ ├── Store
│ │ └── index.js
│ └── Reducers
│ │ ├── index.js
│ │ ├── table.js
│ │ ├── language.js
│ │ ├── setting.js
│ │ ├── form.js
│ │ └── query.js
├── setupTests.js
├── modules
│ ├── about.js
│ ├── update.js
│ ├── clipboard.js
│ └── fileOperations.js
├── App.test.js
├── hooks
│ ├── useSnack.js
│ ├── useLanguage.js
│ ├── useTheme.js
│ ├── useHumanizer.js
│ ├── useFeature.js
│ ├── useDebounce.js
│ ├── useLinkCreator.js
│ └── useScrollBlock.js
├── reportWebVitals.js
├── i18n
│ ├── locales
│ │ └── locales.json
│ └── I18nProvider.js
├── index.scss
├── config.js
├── index.js
├── context
│ └── SnackbarContext.jsx
├── utils
│ └── index.js
├── logo.svg
└── colors.scss
├── .github
├── ISSUE_TEMPLATE
│ ├── feature_request.md
│ ├── config.yml
│ └── bug_report.md
├── pull_request_template.md
└── workflows
│ ├── pullrequest.yml
│ └── translations.yml
├── docker
├── nginx.conf
├── docker-compose.yml
└── generate-config.js
├── .eslintrc.json
├── Dockerfile.ci
├── .stylelintrc.json
├── SECURITY.md
├── localazy.json
├── Dockerfile
├── .prettierrc
├── .gitignore
└── package.json
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | build/
3 |
--------------------------------------------------------------------------------
/public/config.js:
--------------------------------------------------------------------------------
1 | window.VOCASCAN_CONFIG = JSON.parse("{}");
2 |
--------------------------------------------------------------------------------
/src/Components/Nav/UpdateAvailable.scss:
--------------------------------------------------------------------------------
1 | .update-button {
2 | padding: 20px;
3 | }
4 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vocascan/vocascan-frontend/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vocascan/vocascan-frontend/HEAD/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vocascan/vocascan-frontend/HEAD/public/logo512.png
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/src/Forms/ImportPreviewForm/ImportPreviewForm.scss:
--------------------------------------------------------------------------------
1 | .import-preview {
2 | width: 100%;
3 | height: 100%;
4 | }
5 |
--------------------------------------------------------------------------------
/src/constants.scss:
--------------------------------------------------------------------------------
1 | $bp-sm: 640px;
2 | $bp-md: 768px;
3 | $bp-lg: 1024px;
4 | $bp-xl: 1280px;
5 | $bp-2xl: 1536px;
6 |
--------------------------------------------------------------------------------
/src/images/icons/check.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vocascan/vocascan-frontend/HEAD/src/images/icons/check.png
--------------------------------------------------------------------------------
/src/screens/Learn/Learn.scss:
--------------------------------------------------------------------------------
1 | .learn-wrapper {
2 | grid-area: main;
3 | width: 100%;
4 | height: 100%;
5 | }
6 |
--------------------------------------------------------------------------------
/src/Components/Flag/flags.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vocascan/vocascan-frontend/HEAD/src/Components/Flag/flags.png
--------------------------------------------------------------------------------
/src/images/icons/addBtn-blue.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vocascan/vocascan-frontend/HEAD/src/images/icons/addBtn-blue.png
--------------------------------------------------------------------------------
/src/images/icons/addBtn-grey.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vocascan/vocascan-frontend/HEAD/src/images/icons/addBtn-grey.png
--------------------------------------------------------------------------------
/src/images/logo/vocascan-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vocascan/vocascan-frontend/HEAD/src/images/logo/vocascan-logo.png
--------------------------------------------------------------------------------
/src/images/preview/dashboard.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vocascan/vocascan-frontend/HEAD/src/images/preview/dashboard.png
--------------------------------------------------------------------------------
/src/Components/Congratulation/Congratulation.scss:
--------------------------------------------------------------------------------
1 | .congratulation {
2 | font-size: 30px;
3 | text-transform: uppercase;
4 | }
5 |
--------------------------------------------------------------------------------
/src/images/icons/addBtn-grey-30px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vocascan/vocascan-frontend/HEAD/src/images/icons/addBtn-grey-30px.png
--------------------------------------------------------------------------------
/src/images/logo/vocascan-round-mac.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vocascan/vocascan-frontend/HEAD/src/images/logo/vocascan-round-mac.png
--------------------------------------------------------------------------------
/src/images/logo/vocascan-round-win.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vocascan/vocascan-frontend/HEAD/src/images/logo/vocascan-round-win.ico
--------------------------------------------------------------------------------
/src/images/preview/query-card-back.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vocascan/vocascan-frontend/HEAD/src/images/preview/query-card-back.png
--------------------------------------------------------------------------------
/src/images/logo/vocascan-round-linux.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vocascan/vocascan-frontend/HEAD/src/images/logo/vocascan-round-linux.png
--------------------------------------------------------------------------------
/src/images/preview/add-vocab-screen.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vocascan/vocascan-frontend/HEAD/src/images/preview/add-vocab-screen.png
--------------------------------------------------------------------------------
/src/images/preview/query-card-front.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vocascan/vocascan-frontend/HEAD/src/images/preview/query-card-front.png
--------------------------------------------------------------------------------
/src/screens/AddVocab/AddVocab.scss:
--------------------------------------------------------------------------------
1 | .add-vocab-form {
2 | display: flex;
3 | align-items: center;
4 | justify-content: center;
5 | }
6 |
--------------------------------------------------------------------------------
/src/images/logo/vocascan-github-cover.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vocascan/vocascan-frontend/HEAD/src/images/logo/vocascan-github-cover.png
--------------------------------------------------------------------------------
/src/images/preview/language-selection.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vocascan/vocascan-frontend/HEAD/src/images/preview/language-selection.png
--------------------------------------------------------------------------------
/src/Components/VersionTable/VersionTable.scss:
--------------------------------------------------------------------------------
1 | .version-table {
2 | .table-cell {
3 | text-align: start;
4 | white-space: pre-wrap;
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/src/screens/Guide/Pages/VocabDescription/addVocab.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vocascan/vocascan-frontend/HEAD/src/screens/Guide/Pages/VocabDescription/addVocab.png
--------------------------------------------------------------------------------
/src/screens/Guide/Pages/GroupDescription/addGroup1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vocascan/vocascan-frontend/HEAD/src/screens/Guide/Pages/GroupDescription/addGroup1.png
--------------------------------------------------------------------------------
/src/screens/Guide/Pages/GroupDescription/addGroup2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vocascan/vocascan-frontend/HEAD/src/screens/Guide/Pages/GroupDescription/addGroup2.png
--------------------------------------------------------------------------------
/src/Components/sections.scss:
--------------------------------------------------------------------------------
1 | .routed-section {
2 | display: flex;
3 | flex-direction: column;
4 | width: 100%;
5 | height: 100vh;
6 | background: #f1f3fa;
7 | }
8 |
--------------------------------------------------------------------------------
/src/screens/Guide/Pages/PackageDescription/addPackage1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vocascan/vocascan-frontend/HEAD/src/screens/Guide/Pages/PackageDescription/addPackage1.png
--------------------------------------------------------------------------------
/src/screens/Guide/Pages/PackageDescription/addPackage2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vocascan/vocascan-frontend/HEAD/src/screens/Guide/Pages/PackageDescription/addPackage2.png
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an feature for vocascan
4 |
5 | ---
6 |
7 |
8 |
--------------------------------------------------------------------------------
/docker/nginx.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 80;
3 |
4 | location / {
5 | root /usr/share/nginx/html/;
6 | include /etc/nginx/mime.types;
7 | try_files $uri $uri/ /index.html;
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/src/Components/Timer/CountdownTimer.scss:
--------------------------------------------------------------------------------
1 | @import "../../colors";
2 |
3 | .countdown-timer-expired {
4 | color: $color-red;
5 | }
6 |
7 | .countdown-timer-running {
8 | color: $color-green;
9 | }
10 |
--------------------------------------------------------------------------------
/src/images/icons/hamburger.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/screens/Guide/Guide.scss:
--------------------------------------------------------------------------------
1 | @import "../../constants";
2 |
3 | .skip-button {
4 | position: absolute;
5 | top: 20px;
6 | right: 20px;
7 |
8 | @media screen and (min-width: $bp-md) {
9 | right: 60px;
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["react-app", "react-app/jest", "plugin:prettier/recommended"],
3 | "rules": {
4 | "prettier/prettier": "warn"
5 | },
6 | "overrides": [
7 | {
8 | "files": "*.jsx"
9 | }
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/src/redux/Actions/language.js:
--------------------------------------------------------------------------------
1 | import { SET_LANGUAGES } from "./index.js";
2 |
3 | export const setLanguages = ({ languages }) => {
4 | return {
5 | type: SET_LANGUAGES,
6 | payload: {
7 | languages,
8 | },
9 | };
10 | };
11 |
--------------------------------------------------------------------------------
/src/Components/Layout/CleanLayout/CleanLayout.scss:
--------------------------------------------------------------------------------
1 | @import "../../../constants";
2 |
3 | .clean-container {
4 | display: flex;
5 | width: 100vw;
6 | height: 100vh;
7 |
8 | @media (min-width: $bp-md) {
9 | width: 100%;
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/Components/Counter/Counter.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import "./Counter.scss";
4 |
5 | const Counter = ({ count, color }) => {
6 | return {count};
7 | };
8 |
9 | export default Counter;
10 |
--------------------------------------------------------------------------------
/src/redux/Actions/table.js:
--------------------------------------------------------------------------------
1 | import { SET_TABLE_PAGE_SIZE } from "./index.js";
2 |
3 | export const setTablePageSize = ({ pageSize }) => {
4 | return {
5 | type: SET_TABLE_PAGE_SIZE,
6 | payload: {
7 | pageSize,
8 | },
9 | };
10 | };
11 |
--------------------------------------------------------------------------------
/src/setupTests.js:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import "@testing-library/jest-dom";
6 |
--------------------------------------------------------------------------------
/docker/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.8"
2 |
3 | services:
4 | frontend:
5 | image: vocascan/frontend:latest
6 | restart: always
7 | tty: true
8 | environment:
9 | VOCASCAN_BASE_URL: "https://mydomain.com"
10 | ports:
11 | - "80:80"
12 |
--------------------------------------------------------------------------------
/src/Components/Layout/CleanLayout/CleanLayout.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import "./CleanLayout.scss";
4 |
5 | const CleanLayout = ({ children }) => {
6 | return
{children}
;
7 | };
8 |
9 | export default CleanLayout;
10 |
--------------------------------------------------------------------------------
/src/modules/about.js:
--------------------------------------------------------------------------------
1 | let getDesktopVersions = () => Promise.resolve();
2 |
3 | if (window.VOCASCAN_CONFIG.ENV === "electron") {
4 | getDesktopVersions = () => {
5 | return window.electron.invoke("getVersions");
6 | };
7 | }
8 |
9 | export { getDesktopVersions };
10 |
--------------------------------------------------------------------------------
/Dockerfile.ci:
--------------------------------------------------------------------------------
1 | FROM nginx:1-alpine
2 |
3 | RUN apk add nodejs
4 |
5 | COPY ./build /usr/share/nginx/html
6 | COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
7 | COPY docker/generate-config.js /
8 |
9 | EXPOSE 80
10 |
11 | CMD node generate-config.js && nginx -g "daemon off;"
12 |
--------------------------------------------------------------------------------
/.stylelintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["stylelint-config-sass-guidelines", "stylelint-config-recess-order"],
3 | "rules": {
4 | "order/properties-alphabetical-order": null,
5 | "max-nesting-depth": 4,
6 | "string-quotes": "double",
7 | "selector-max-compound-selectors": null
8 | }
9 | }
--------------------------------------------------------------------------------
/src/screens/Library/Library.scss:
--------------------------------------------------------------------------------
1 | @import "../../colors";
2 | @import "../../constants";
3 |
4 | .library-wrapper {
5 | padding: 50px 12px;
6 |
7 | @media screen and (min-width: $bp-md) {
8 | padding: 50px;
9 | }
10 |
11 | .title {
12 | margin-bottom: 20px;
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/App.test.js:
--------------------------------------------------------------------------------
1 | import { render, screen } from "@testing-library/react";
2 |
3 | import App from "./App.jsx";
4 |
5 | test("renders learn react link", () => {
6 | render();
7 | const linkElement = screen.getByText(/learn react/i);
8 | expect(linkElement).toBeInTheDocument();
9 | });
10 |
--------------------------------------------------------------------------------
/src/screens/Guide/Pages/End/End.scss:
--------------------------------------------------------------------------------
1 | .end {
2 | display: flex;
3 | justify-content: center;
4 | width: 100%;
5 | height: 100%;
6 |
7 | .end-heading {
8 | margin: auto;
9 | text-transform: uppercase;
10 |
11 | h1 {
12 | margin-bottom: 10px;
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/screens/Guide/Pages/Start/Start.scss:
--------------------------------------------------------------------------------
1 | .start {
2 | display: flex;
3 | justify-content: center;
4 | width: 100%;
5 | height: 100%;
6 |
7 | .start-heading {
8 | margin: auto;
9 | text-transform: uppercase;
10 |
11 | h1 {
12 | margin-bottom: 10px;
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Supported Versions
4 |
5 | There is no released version so far
6 |
7 | | Version | Supported |
8 | | ------- | ------------------ |
9 | | 1.0.0 | :x: |
10 |
11 |
12 | ## Reporting a Vulnerability
13 |
14 | Go to issues tab and create a new bug report
15 |
--------------------------------------------------------------------------------
/src/hooks/useSnack.js:
--------------------------------------------------------------------------------
1 | import { useContext } from "react";
2 |
3 | import SnackbarContext from "../context/SnackbarContext.jsx";
4 |
5 | const useSnack = () => {
6 | const { showSnack } = useContext(SnackbarContext);
7 |
8 | return {
9 | showSnack,
10 | };
11 | };
12 |
13 | export default useSnack;
14 |
--------------------------------------------------------------------------------
/src/screens/Custom/Custom.scss:
--------------------------------------------------------------------------------
1 | @import "../../constants";
2 |
3 | .custom {
4 | display: flex;
5 | grid-area: main;
6 | align-items: center;
7 | justify-content: center;
8 | width: 100%;
9 | height: 100vh;
10 | text-align: center;
11 |
12 | @media screen and (min-width: $bp-md) {
13 | height: 100%;
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/screens/Progress/Progress.scss:
--------------------------------------------------------------------------------
1 | @import "../../constants";
2 |
3 | .progress {
4 | display: flex;
5 | grid-area: main;
6 | align-items: center;
7 | justify-content: center;
8 | width: 100%;
9 | height: 100vh;
10 | text-align: center;
11 |
12 | @media screen and (min-width: $bp-md) {
13 | height: 100%;
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/modules/update.js:
--------------------------------------------------------------------------------
1 | let updateNotifier = null;
2 | let startUpdate = null;
3 | const available = window.VOCASCAN_CONFIG.ENV === "electron";
4 |
5 | if (available) {
6 | updateNotifier = window.electron;
7 |
8 | startUpdate = () => {
9 | window.electron.send("start-update");
10 | };
11 | }
12 |
13 | export { updateNotifier, startUpdate, available };
14 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: false
2 | contact_links:
3 | - name: GitHub Discussions
4 | url: https://github.com/vocascan/vocascan-frontend/discussions
5 | about: Please ask and answer questions here.
6 | - name: Discord community server
7 | url: https://discord.gg/Q3Qp72sKaQ
8 | about: If you have discord, you can also ask questions here.
9 |
--------------------------------------------------------------------------------
/src/Components/ConfirmDialog/ConfirmDialog.scss:
--------------------------------------------------------------------------------
1 | @import "../../colors";
2 |
3 | .submit-dialog {
4 | display: flex;
5 | flex-direction: column;
6 | width: 80%;
7 | max-width: 400px;
8 |
9 | .description {
10 | padding: 20px 0;
11 | }
12 |
13 | .actions {
14 | display: flex;
15 | align-items: center;
16 | justify-content: space-between;
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/Components/Form/ArrayTextInput/ArrayTextInput.scss:
--------------------------------------------------------------------------------
1 | @import "../../../colors";
2 |
3 | .array-input-wrapper {
4 | display: flex;
5 | align-items: center;
6 | height: 53px;
7 | margin: 5px 0;
8 | }
9 |
10 | .add-input-wrapper {
11 | display: flex;
12 | justify-content: center;
13 | margin-top: 10px;
14 |
15 | .add-text {
16 | margin-left: 5px;
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/redux/Store/index.js:
--------------------------------------------------------------------------------
1 | import { createStore } from "redux";
2 | import storeSynchronize from "redux-localstore";
3 |
4 | import allReducers from "../Reducers/index.js";
5 |
6 | const store = createStore(allReducers, window.__REDUX_DEVTOOLS_EXTENSION__?.());
7 |
8 | export default store;
9 |
10 | storeSynchronize(store, {
11 | whitelist: ["login", "setting", "form", "table", "language"],
12 | });
13 |
--------------------------------------------------------------------------------
/src/redux/Actions/setting.js:
--------------------------------------------------------------------------------
1 | import { SET_LANGUAGE, SET_THEME } from "./index.js";
2 |
3 | export const setLanguage = ({ language }) => {
4 | return {
5 | type: SET_LANGUAGE,
6 | payload: {
7 | language,
8 | },
9 | };
10 | };
11 |
12 | export const setTheme = ({ theme }) => {
13 | return {
14 | type: SET_THEME,
15 | payload: {
16 | theme,
17 | },
18 | };
19 | };
20 |
--------------------------------------------------------------------------------
/src/Components/Indicators/LoadingIndicator/LoadingIndicator.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import "./LoadingIndicator.scss";
4 |
5 | const LoadingIndicator = ({ size, position }) => {
6 | return (
7 |
10 | );
11 | };
12 |
13 | export default LoadingIndicator;
14 |
--------------------------------------------------------------------------------
/src/Components/Indicators/InviteCodeValidIndicator/InviteCodeValidIndicator.scss:
--------------------------------------------------------------------------------
1 | @import "../../../colors";
2 |
3 | .invite-code-valid-indicator {
4 | margin-top: -5px;
5 | font-size: 11px;
6 |
7 | .valid {
8 | height: 29px;
9 | color: $color-green-dark;
10 | text-align: right;
11 | }
12 |
13 | .error {
14 | height: 29px;
15 | color: $color-red-dark;
16 | text-align: right;
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/hooks/useLanguage.js:
--------------------------------------------------------------------------------
1 | import { useMemo } from "react";
2 | import { useSelector } from "react-redux";
3 |
4 | const useLanguage = (code) => {
5 | const languages = useSelector((state) => state.language.languages);
6 |
7 | const language = useMemo(
8 | () => languages?.find((language) => language.code === code),
9 | [code, languages]
10 | );
11 |
12 | return language;
13 | };
14 |
15 | export default useLanguage;
16 |
--------------------------------------------------------------------------------
/src/reportWebVitals.js:
--------------------------------------------------------------------------------
1 | const reportWebVitals = (onPerfEntry) => {
2 | if (onPerfEntry && onPerfEntry instanceof Function) {
3 | import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
4 | getCLS(onPerfEntry);
5 | getFID(onPerfEntry);
6 | getFCP(onPerfEntry);
7 | getLCP(onPerfEntry);
8 | getTTFB(onPerfEntry);
9 | });
10 | }
11 | };
12 |
13 | export default reportWebVitals;
14 |
--------------------------------------------------------------------------------
/src/Components/Indicators/PageIndicator/PageIndicator.scss:
--------------------------------------------------------------------------------
1 | @import "../../../colors";
2 |
3 | .indicator {
4 | display: flex;
5 | flex-direction: row;
6 | justify-content: space-between;
7 | width: auto;
8 |
9 | .indicator-dot {
10 | width: 10px;
11 | height: 10px;
12 | margin: 0 3px;
13 | background: $color-grey;
14 | border-radius: 100%;
15 |
16 | &.active {
17 | background: $color-primary;
18 | }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/screens/Custom/Custom.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useTranslation } from "react-i18next";
3 |
4 | import "./Custom.scss";
5 |
6 | const Custom = () => {
7 | const { t } = useTranslation();
8 |
9 | return (
10 |
11 |
12 |
{t("screens.custom.title")}
13 | {t("global.comingSoon")}
14 |
15 |
16 | );
17 | };
18 |
19 | export default Custom;
20 |
--------------------------------------------------------------------------------
/localazy.json:
--------------------------------------------------------------------------------
1 | {
2 | "upload": {
3 | "type": "json",
4 | "files": "src/i18n/locales/en/default.json",
5 | "deprecate": "file",
6 | "features": ["array", "filter_untranslated", "plural_postfix_us"]
7 | },
8 | "download": {
9 | "metadataFileJson": "src/i18n/locales/locales.json",
10 | "files": {
11 | "conditions": ["equals: ${file}, default.json"],
12 | "output": "src/i18n/locales/${lang}/default.json"
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/Components/Indicators/PageIndicator/PageIndicator.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import "./PageIndicator.scss";
4 |
5 | const PageIndicator = ({ max, pageNumber }) => {
6 | const dots = [...Array(max)].map((_, i) => (
7 |
11 | ));
12 |
13 | return {dots}
;
14 | };
15 |
16 | export default PageIndicator;
17 |
--------------------------------------------------------------------------------
/src/screens/Progress/Progress.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useTranslation } from "react-i18next";
3 |
4 | import "./Progress.scss";
5 |
6 | const Progress = () => {
7 | const { t } = useTranslation();
8 |
9 | return (
10 |
11 |
12 |
{t("screens.progress.title")}
13 | {t("global.comingSoon")}
14 |
15 |
16 | );
17 | };
18 |
19 | export default Progress;
20 |
--------------------------------------------------------------------------------
/src/modules/clipboard.js:
--------------------------------------------------------------------------------
1 | let copyToClip = () => Promise.reject();
2 |
3 | if (window.VOCASCAN_CONFIG.ENV === "electron") {
4 | copyToClip = ({ text }) => {
5 | return window.electron.invoke("copy-to-clip", { text });
6 | };
7 | }
8 |
9 | if (window.VOCASCAN_CONFIG.ENV === "web") {
10 | if (navigator.clipboard) {
11 | copyToClip = ({ text }) => {
12 | return navigator.clipboard.writeText(text);
13 | };
14 | }
15 | }
16 |
17 | export { copyToClip };
18 |
--------------------------------------------------------------------------------
/src/screens/AddVocab/AddVocab.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useTranslation } from "react-i18next";
3 |
4 | import VocabForm from "../../Forms/VocabForm/VocabForm.jsx";
5 |
6 | import "./AddVocab.scss";
7 |
8 | const AddVocab = () => {
9 | const { t } = useTranslation();
10 |
11 | return (
12 |
13 |
14 |
15 | );
16 | };
17 |
18 | export default AddVocab;
19 |
--------------------------------------------------------------------------------
/src/Components/Flag/Flag.scss:
--------------------------------------------------------------------------------
1 | .flag {
2 | display: flex;
3 | justify-content: center;
4 | border-radius: 10px;
5 | transition: all 0.2s ease-in-out;
6 |
7 | &:hover {
8 | cursor: pointer;
9 | transition: all 0.2s ease-in-out;
10 | transform: scale(1.1);
11 | }
12 |
13 | span {
14 | display: inline-block;
15 |
16 | // box shadow color
17 | color: #eee;
18 | background-image: url("flags.png");
19 | background-repeat: no-repeat;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:14-alpine AS builder
2 |
3 | WORKDIR /app
4 |
5 | COPY package.json .
6 | COPY package-lock.json .
7 |
8 | RUN npm install
9 |
10 | COPY . .
11 |
12 | RUN npm run build
13 |
14 | FROM nginx:1-alpine as production
15 |
16 | RUN apk add nodejs
17 |
18 | COPY --from=builder /app/build /usr/share/nginx/html
19 | COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
20 | COPY docker/generate-config.js /
21 |
22 | EXPOSE 80
23 |
24 | CMD node generate-config.js && nginx -g "daemon off;"
25 |
--------------------------------------------------------------------------------
/src/Forms/InviteCodeForm/InviteCodeForm.scss:
--------------------------------------------------------------------------------
1 | .invite-code-form {
2 | display: flex;
3 | flex-direction: column;
4 | align-items: center;
5 | justify-content: center;
6 | width: 100%;
7 | height: 100%;
8 |
9 | .form-wrapper {
10 | display: flex;
11 | flex-direction: column;
12 | align-items: center;
13 | width: 70%;
14 | height: 50%;
15 |
16 | .select-wrapper {
17 | width: 100%;
18 | height: auto;
19 | }
20 |
21 | .submit-btn {
22 | margin-top: 20px;
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/Components/Tooltip/Tooltip.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactTooltip from "react-tooltip";
3 |
4 | import "./Tooltip.scss";
5 |
6 | const Tooltip = ({
7 | place = "bottom",
8 | effect = "solid",
9 | type = "default",
10 | id = null,
11 | children,
12 | }) => {
13 | return (
14 |
20 | {children}
21 |
22 | );
23 | };
24 |
25 | export default Tooltip;
26 |
--------------------------------------------------------------------------------
/src/Components/Indicators/ServerValidIndicator/ServerValidIndicator.scss:
--------------------------------------------------------------------------------
1 | @import "../../../colors";
2 |
3 | .server-valid-indicator {
4 | margin-top: -5px;
5 | font-size: 11px;
6 |
7 | .error {
8 | height: 29px;
9 | color: $color-red-dark;
10 | text-align: right;
11 | }
12 |
13 | .success {
14 | display: flex;
15 | align-items: center;
16 | justify-content: flex-end;
17 | height: 29px;
18 | color: $color-green-dark;
19 | text-align: right;
20 |
21 | svg {
22 | margin-left: 5px;
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/Components/Counter/Counter.scss:
--------------------------------------------------------------------------------
1 | @import "../../colors";
2 |
3 | .counter {
4 | display: inline-block;
5 | min-width: 7px;
6 | height: 18px;
7 | padding: 0 6px;
8 | font-size: 12px;
9 | line-height: 18px;
10 | color: #fff;
11 | text-align: center;
12 | vertical-align: middle;
13 | border-radius: 9px;
14 |
15 | &.primary {
16 | background-color: $color-primary;
17 | }
18 |
19 | &.primary-dark {
20 | background-color: $color-primary-dark;
21 | }
22 |
23 | &.background-inverse {
24 | background-color: $color-background-inverse;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/screens/SelectionScreen/SelectionScreen.scss:
--------------------------------------------------------------------------------
1 | .select-srn-wrapper {
2 | display: flex;
3 | flex-direction: column;
4 | margin: auto;
5 |
6 | .select-srn-header-wrapper {
7 | display: flex;
8 | flex-direction: column;
9 | justify-content: center;
10 |
11 | margin: 20px auto;
12 | }
13 |
14 | .boxes {
15 | display: flex;
16 | justify-content: space-around;
17 | margin: auto;
18 | }
19 |
20 | .select-srn-title,
21 | .select-srn-heading {
22 | text-transform: uppercase;
23 | }
24 | }
25 |
26 | .swiper {
27 | margin: 64px 0 32px;
28 | }
29 |
--------------------------------------------------------------------------------
/src/screens/Learn/End/End.scss:
--------------------------------------------------------------------------------
1 | @import "../../../constants";
2 |
3 | .end-screen {
4 | display: flex;
5 | flex-direction: column;
6 | justify-content: space-around;
7 | width: 100%;
8 | max-width: 600px;
9 | height: 100vh;
10 | margin: 0 auto;
11 |
12 | @media screen and (min-width: $bp-md) {
13 | height: 100%;
14 | }
15 |
16 | .end-screen-inner {
17 | display: flex;
18 | flex-direction: column;
19 | justify-content: space-around;
20 | height: 50%;
21 | }
22 |
23 | .percentage-text {
24 | margin: 15px 0;
25 | font-size: 16px;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/screens/Guide/Pages/Start/Start.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react";
2 | import { useTranslation } from "react-i18next";
3 |
4 | import "./Start.scss";
5 |
6 | const Start = ({ setCanContinue }) => {
7 | const { t } = useTranslation();
8 |
9 | useEffect(() => {
10 | setCanContinue(true);
11 | }, [setCanContinue]);
12 |
13 | return (
14 |
15 |
16 |
Vocascan
17 |
{t("screens.guide.start.slogan")}
18 |
19 |
20 | );
21 | };
22 |
23 | export default Start;
24 |
--------------------------------------------------------------------------------
/src/redux/Reducers/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from "redux";
2 |
3 | import formReducer from "./form.js";
4 | import languageReducer from "./language.js";
5 | import loginReducer from "./login.js";
6 | import queryReducer from "./query.js";
7 | import settingReducer from "./setting.js";
8 | import tableReducer from "./table.js";
9 |
10 | const allReducers = combineReducers({
11 | login: loginReducer,
12 | setting: settingReducer,
13 | form: formReducer,
14 | table: tableReducer,
15 | query: queryReducer,
16 | language: languageReducer,
17 | });
18 |
19 | export default allReducers;
20 |
--------------------------------------------------------------------------------
/src/redux/Reducers/table.js:
--------------------------------------------------------------------------------
1 | import { defineState } from "redux-localstore";
2 |
3 | import { SET_TABLE_PAGE_SIZE } from "../Actions/index.js";
4 |
5 | const defaultState = {
6 | pageSize: 10,
7 | };
8 |
9 | const initialState = defineState(defaultState)("table");
10 |
11 | const tableReducer = (state = initialState, action) => {
12 | switch (action.type) {
13 | case SET_TABLE_PAGE_SIZE:
14 | return {
15 | ...state,
16 | pageSize: action.payload.pageSize,
17 | };
18 |
19 | default:
20 | return state;
21 | }
22 | };
23 |
24 | export default tableReducer;
25 |
--------------------------------------------------------------------------------
/src/redux/Reducers/language.js:
--------------------------------------------------------------------------------
1 | import { defineState } from "redux-localstore";
2 |
3 | import { SET_LANGUAGES } from "../Actions/index.js";
4 |
5 | const defaultState = {
6 | languages: [],
7 | };
8 |
9 | const initialState = defineState(defaultState)("language");
10 |
11 | const languageReducer = (state = initialState, action) => {
12 | switch (action.type) {
13 | case SET_LANGUAGES:
14 | return {
15 | ...state,
16 | languages: action.payload.languages,
17 | };
18 |
19 | default:
20 | return state;
21 | }
22 | };
23 |
24 | export default languageReducer;
25 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "tabWidth": 2,
3 | "endOfLine": "auto",
4 | "trailingComma": "es5",
5 | "printWidth": 80,
6 | "singleQuote": false,
7 | "semi": true,
8 | "useTabs": false,
9 | "importOrder": [
10 | "^./config.js$",
11 | "^(react|uniqid|i18next|redux|axios|clsx|semver|@testing-library)(.*)$",
12 | "^@material-ui/core(.*)$",
13 | "^@material-ui/icons(.*)$",
14 | "^(.*).jsx$",
15 | "^(.*).js$",
16 | "^(.*).(png|jpg|svg|json)$",
17 | "^(.*).(scss)$",
18 | ".*"
19 | ],
20 | "importOrderSeparation": true,
21 | "experimentalBabelParserPluginsList": ["jsx"]
22 | }
23 |
--------------------------------------------------------------------------------
/src/Components/Snackbar/Snackbar.jsx:
--------------------------------------------------------------------------------
1 | import clsx from "clsx";
2 | import React, { useContext } from "react";
3 |
4 | import SnackbarContext from "../../context/SnackbarContext.jsx";
5 |
6 | import "./Snackbar.scss";
7 |
8 | const Snackbar = () => {
9 | const { snack } = useContext(SnackbarContext);
10 |
11 | const classes = clsx(
12 | "snackbar",
13 | `snackbar-${snack.variant}`,
14 | snack.show && "show"
15 | );
16 |
17 | return (
18 |
19 | {snack.text}
20 |
21 | );
22 | };
23 |
24 | export default Snackbar;
25 |
--------------------------------------------------------------------------------
/src/i18n/locales/locales.json:
--------------------------------------------------------------------------------
1 | {
2 | "projectUrl": "https://localazy.com/p/vocascan",
3 | "baseLocale": "en",
4 | "languages": [
5 | {
6 | "language": "en",
7 | "region": "",
8 | "script": "",
9 | "isRtl": false,
10 | "name": "English",
11 | "localizedName": "English"
12 | },
13 | {
14 | "language": "de",
15 | "region": "",
16 | "script": "",
17 | "isRtl": false,
18 | "name": "German",
19 | "localizedName": "Deutsch"
20 | }
21 | ]
22 | }
--------------------------------------------------------------------------------
/src/screens/Guide/Pages/End/End.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react";
2 | import { useTranslation } from "react-i18next";
3 |
4 | import "./End.scss";
5 |
6 | const End = ({ setCanContinue }) => {
7 | const { t } = useTranslation();
8 |
9 | useEffect(() => {
10 | setCanContinue(true);
11 | }, [setCanContinue]);
12 |
13 | return (
14 |
15 |
16 |
{t("screens.guide.end.heading")}
17 |
{t("screens.guide.end.description")}
18 |
19 |
20 | );
21 | };
22 |
23 | export default End;
24 |
--------------------------------------------------------------------------------
/src/Components/Layout/UnauthenticatedLayout/UnauthenticatedLayout.scss:
--------------------------------------------------------------------------------
1 | .unauthenticated-container {
2 | position: relative;
3 | display: flex;
4 | justify-content: center;
5 | width: 100%;
6 | height: auto;
7 | min-height: 100vh;
8 | background-image: linear-gradient(to top right, #60a5fa, #f9a8d4);
9 |
10 | .language-selector-wrapper {
11 | position: absolute;
12 | top: 10px;
13 | right: 10px;
14 | }
15 |
16 | .select-language-modal-inner {
17 | align-self: center;
18 | width: 250px;
19 | margin: 20px 0 30px;
20 | }
21 |
22 | .select-language-modal-close {
23 | align-self: center;
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/hooks/useTheme.js:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 | import { useSelector } from "react-redux";
3 |
4 | import { defaultTheme } from "../utils/constants";
5 |
6 | const themeStyleComponent = document.createElement("link");
7 | themeStyleComponent.rel = "stylesheet";
8 | document.head.appendChild(themeStyleComponent);
9 |
10 | const useTheme = () => {
11 | const theme = useSelector((state) => state.setting.theme);
12 | useEffect(() => {
13 | const key = theme in window.VOCASCAN_CONFIG.themes ? theme : defaultTheme;
14 | themeStyleComponent.href = window.VOCASCAN_CONFIG.themes[key];
15 | }, [theme]);
16 | };
17 |
18 | export default useTheme;
19 |
--------------------------------------------------------------------------------
/src/redux/Actions/form.js:
--------------------------------------------------------------------------------
1 | import {
2 | SET_VOCAB_ACTIVE,
3 | SET_VOCAB_ACTIVATE,
4 | SET_GROUP_ACTIVATE,
5 | } from "./index.js";
6 |
7 | export const setVocabActive = ({ active }) => {
8 | return {
9 | type: SET_VOCAB_ACTIVE,
10 | payload: {
11 | active,
12 | },
13 | };
14 | };
15 |
16 | export const setVocabActivate = ({ activate }) => {
17 | return {
18 | type: SET_VOCAB_ACTIVATE,
19 | payload: {
20 | activate,
21 | },
22 | };
23 | };
24 |
25 | export const setGroupActive = ({ active }) => {
26 | return {
27 | type: SET_GROUP_ACTIVATE,
28 | payload: {
29 | active,
30 | },
31 | };
32 | };
33 |
--------------------------------------------------------------------------------
/src/Components/Layout/AuthenticatedLayout/AuthenticatedLayout.scss:
--------------------------------------------------------------------------------
1 | @import "../../../colors";
2 | @import "../../../constants";
3 |
4 | .root {
5 | width: 90%;
6 | height: 100vh;
7 | padding: 0;
8 | margin: 0 auto;
9 |
10 | @media (min-width: $bp-md) {
11 | display: grid;
12 | grid-template-areas:
13 | "topnav topnav"
14 | "sidenav main";
15 | grid-template-rows: 50px auto;
16 | grid-template-columns: 65px auto;
17 | width: 100%;
18 | margin: 0;
19 | overflow-x: hidden;
20 | }
21 |
22 | .server-error-modal-inner {
23 | padding: 30px 0;
24 | }
25 |
26 | .content {
27 | overflow-y: auto;
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/Components/Nav/NavButton.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { NavLink } from "react-router-dom";
3 |
4 | import "./NavButton.scss";
5 |
6 | const NavButton = ({ name, link, icon = null, exact = false }) => {
7 | return (
8 |
15 |
19 |
20 | );
21 | };
22 |
23 | export default NavButton;
24 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Vocascan",
3 | "name": "Vocascan Web",
4 | "description": "A highly configurable vocabulary trainer",
5 | "categories": ["education"],
6 | "icons": [
7 | {
8 | "src": "favicon.ico",
9 | "sizes": "64x64 32x32 24x24 16x16",
10 | "type": "image/x-icon"
11 | },
12 | {
13 | "src": "logo192.png",
14 | "type": "image/png",
15 | "sizes": "192x192"
16 | },
17 | {
18 | "src": "logo512.png",
19 | "type": "image/png",
20 | "sizes": "512x512"
21 | }
22 | ],
23 | "start_url": ".",
24 | "display": "standalone",
25 | "theme_color": "#2a2e36",
26 | "background_color": "#2a2e36"
27 | }
28 |
--------------------------------------------------------------------------------
/src/Components/Details/Details.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Collapsible from "react-collapsible";
3 |
4 | import Counter from "../Counter/Counter.jsx";
5 |
6 | import "./Details.scss";
7 |
8 | const Details = ({ summary, count, children, ...props }) => {
9 | return (
10 |
13 | {summary}
14 | {count && }
15 |
16 | }
17 | transitionTime={200}
18 | easing="ease-in-out"
19 | {...props}
20 | >
21 | {children}
22 |
23 | );
24 | };
25 |
26 | export default Details;
27 |
--------------------------------------------------------------------------------
/src/Components/Indicators/PasswordComplexityIndicator/PasswordComplexityIndicator.scss:
--------------------------------------------------------------------------------
1 | @import "../../../colors";
2 |
3 | .password-complexity-indicator {
4 | width: 100%;
5 | height: 6px;
6 | margin-top: 12px;
7 | background-color: #eee;
8 | border-radius: 5px;
9 |
10 | .bar {
11 | width: 0%;
12 | height: 6px;
13 | border-radius: 3px;
14 | transition: all 0.25s ease-in;
15 |
16 | &.complexity-0,
17 | &.complexity-1,
18 | &.complexity-2 {
19 | background-color: $color-red;
20 | }
21 |
22 | &.complexity-3 {
23 | background-color: $color-yellow;
24 | }
25 |
26 | &.complexity-4,
27 | &.complexity-5 {
28 | background-color: $color-green;
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/Components/Indicators/LoadingIndicator/LoadingIndicator.scss:
--------------------------------------------------------------------------------
1 | @import "../../../colors";
2 |
3 | .loading-indicator {
4 | display: flex;
5 | width: 100%;
6 |
7 | .circle {
8 | width: 20px;
9 | height: 20px;
10 | border: 2px solid $color-primary-light;
11 | border-top: 2px solid $color-primary-dark;
12 | border-radius: 50%;
13 | animation: spin 1.5s ease-in-out infinite;
14 |
15 | &.large {
16 | width: 120px;
17 | height: 120px;
18 | }
19 | }
20 |
21 | &.center {
22 | align-items: center;
23 | justify-content: center;
24 | }
25 | }
26 |
27 | @keyframes spin {
28 | 0% {
29 | transform: rotate(0deg);
30 | }
31 |
32 | 100% {
33 | transform: rotate(360deg);
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Vocascan Web
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/src/Components/SlideShow/SlideShow.scss:
--------------------------------------------------------------------------------
1 | .slideshow {
2 | display: flex;
3 | flex-direction: column;
4 | justify-content: space-between;
5 | width: 100%;
6 | height: 100%;
7 |
8 | .slideshow-content {
9 | display: flex;
10 | align-items: center;
11 | justify-content: center;
12 | width: 100%;
13 | height: 90%;
14 | overflow: auto;
15 |
16 | }
17 |
18 | .slideshow-bar {
19 | display: flex;
20 | flex-direction: row;
21 | justify-content: space-between;
22 | height: 10%;
23 | max-height: 50px;
24 |
25 | .bar-property {
26 | &.invisible {
27 | pointer-events: none;
28 | visibility: hidden;
29 | }
30 |
31 | &.indicator {
32 | margin: auto 0;
33 | }
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/screens/Auth/EmailVerify/EmailVerify.scss:
--------------------------------------------------------------------------------
1 | @import "../../../colors";
2 | @import "../../../constants";
3 |
4 | .email-verify {
5 | width: 80%;
6 | margin: 0 auto;
7 |
8 | @media screen and (min-width: $bp-md) {
9 | max-width: 250px;
10 | }
11 |
12 | h2 {
13 | margin-bottom: 10px;
14 | }
15 |
16 | p {
17 | margin-bottom: 10px;
18 | }
19 |
20 | .check-mark-icon {
21 | font-size: 75px;
22 | color: $color-green;
23 | }
24 |
25 | .login-footer button {
26 | margin-bottom: 10px;
27 | }
28 |
29 | .dont-received {
30 | margin-top: 10px;
31 |
32 | span {
33 | color: $color-primary;
34 | cursor: pointer;
35 | }
36 |
37 | span:hover {
38 | text-decoration: underline;
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/redux/Reducers/setting.js:
--------------------------------------------------------------------------------
1 | import { defineState } from "redux-localstore";
2 |
3 | import { defaultTheme } from "../../utils/constants.js";
4 | import { SET_LANGUAGE, SET_THEME } from "../Actions/index.js";
5 |
6 | const defaultState = {
7 | language: "en",
8 | theme: defaultTheme,
9 | };
10 |
11 | const initialState = defineState(defaultState)("setting");
12 |
13 | const loginReducer = (state = initialState, action) => {
14 | switch (action.type) {
15 | case SET_LANGUAGE:
16 | return {
17 | ...state,
18 | language: action.payload.language,
19 | };
20 |
21 | case SET_THEME:
22 | return {
23 | ...state,
24 | theme: action.payload.theme,
25 | };
26 |
27 | default:
28 | return state;
29 | }
30 | };
31 |
32 | export default loginReducer;
33 |
--------------------------------------------------------------------------------
/src/Forms/ImportPreviewForm/GroupPreview/GroupPreview.scss:
--------------------------------------------------------------------------------
1 | @import "../../../constants";
2 |
3 | .group-preview {
4 | display: flex;
5 | flex-direction: column;
6 | align-items: center;
7 | justify-content: space-between;
8 | width: 100%;
9 | height: 100%;
10 | overflow-y: auto;
11 |
12 | .update-fields {
13 | width: 100%;
14 |
15 | .table-wrapper {
16 | overflow-x: scroll;
17 | @media screen and (min-width: $bp-md) {
18 | overflow-x: hidden;
19 | }
20 | }
21 |
22 | .customizables {
23 | margin-bottom: 20px;
24 |
25 | .update-fields {
26 | height: 80%;
27 | overflow-y: auto;
28 | }
29 | }
30 |
31 | .submit-btn {
32 | display: flex;
33 | justify-content: center;
34 | margin-top: 40px;
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/screens/Learn/Learn.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Route, Switch, useRouteMatch } from "react-router";
3 |
4 | import Dashboard from "./Dashboard/Dashboard.jsx";
5 | import Direction from "./Direction/Direction.jsx";
6 | import End from "./End/End.jsx";
7 | import Query from "./Query/Query.jsx";
8 |
9 | import "./Learn.scss";
10 |
11 | const Learn = () => {
12 | const { path } = useRouteMatch();
13 |
14 | return (
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | );
24 | };
25 |
26 | export default Learn;
27 |
--------------------------------------------------------------------------------
/src/modules/fileOperations.js:
--------------------------------------------------------------------------------
1 | const parseFile = async (event) => {
2 | return new Promise((resolve, reject) => {
3 | const reader = new FileReader();
4 | reader.onerror = reject;
5 | reader.onload = (event) => {
6 | resolve(JSON.parse(event.target.result));
7 | };
8 | reader.readAsText(event.target.files[0]);
9 | });
10 | };
11 |
12 | const saveFile = ({ input, name, type }) => {
13 | //create temp a tag to download file
14 | const element = document.createElement("a");
15 | const file = new Blob([JSON.stringify(input)], {
16 | type,
17 | });
18 | element.href = URL.createObjectURL(file);
19 | element.href = URL.createObjectURL(file);
20 | element.download = `${name}.json`;
21 | document.body.appendChild(element); // Required for this to work in FireFox
22 | element.click();
23 | };
24 |
25 | export { parseFile, saveFile };
26 |
--------------------------------------------------------------------------------
/src/Components/ContributorCard/ContributorCard.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import PersonIcon from "@material-ui/icons/Person";
4 |
5 | import "./ContributorCard.scss";
6 |
7 | const Card = ({ name, url, imageUrl, description }) => {
8 | return (
9 |
10 |
11 |
12 |
{name}
13 |
{description}
14 |
15 | {imageUrl ? (
16 |

17 | ) : (
18 |
21 | )}
22 |
23 |
24 | );
25 | };
26 |
27 | export default Card;
28 |
--------------------------------------------------------------------------------
/src/Forms/GroupForm/GroupForm.scss:
--------------------------------------------------------------------------------
1 | @import "../../colors";
2 | @import "../../constants";
3 |
4 | .group-form {
5 | display: flex;
6 | flex-direction: column;
7 | justify-content: space-between;
8 | width: 100%;
9 | max-width: 700px;
10 | height: 75%;
11 |
12 | label {
13 | text-align: left;
14 | }
15 |
16 | h3 {
17 | margin: 0;
18 | font-size: 15px;
19 | text-align: left;
20 | }
21 |
22 | .dropdown {
23 | z-index: 4;
24 | display: flex;
25 | flex-direction: column;
26 |
27 | @media screen and (min-width: $bp-md) {
28 | flex-direction: row;
29 | justify-content: space-between;
30 | height: 50px;
31 | margin: 0 -10px;
32 | }
33 |
34 | .select-wrapper {
35 | width: 100%;
36 |
37 | @media screen and (min-width: $bp-md) {
38 | margin: 0 10px;
39 | }
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/Forms/PackageForm/PackageForm.scss:
--------------------------------------------------------------------------------
1 | @import "../../colors";
2 | @import "../../constants";
3 |
4 | .language-package-form {
5 | display: flex;
6 | flex-direction: column;
7 | justify-content: space-between;
8 | width: 100%;
9 | max-width: 700px;
10 | height: 75%;
11 |
12 | label {
13 | text-align: left;
14 | }
15 |
16 | h3 {
17 | margin: 0;
18 | font-size: 15px;
19 | text-align: left;
20 | }
21 |
22 | .dropdown {
23 | z-index: 4;
24 | display: flex;
25 | flex-direction: column;
26 |
27 | @media screen and (min-width: $bp-md) {
28 | flex-direction: row;
29 | justify-content: space-between;
30 | height: 50px;
31 | margin: 0 -10px;
32 | }
33 |
34 | .select-wrapper {
35 | width: 100%;
36 |
37 | @media screen and (min-width: $bp-md) {
38 | margin: 0 10px;
39 | }
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/screens/About/About.scss:
--------------------------------------------------------------------------------
1 | @import "../../colors";
2 | @import "../../constants";
3 |
4 | .about {
5 | display: flex;
6 | grid-area: main;
7 | justify-content: center;
8 | width: 100%;
9 | margin: 0 auto;
10 |
11 | .wrapper {
12 | display: flex;
13 | flex-direction: column;
14 | width: 100%;
15 | padding: 0;
16 | margin: 80px 0;
17 |
18 | @media screen and (min-width: $bp-md) {
19 | width: 90%;
20 | }
21 |
22 | h1 {
23 | margin-bottom: 30px;
24 | }
25 |
26 | .dependency-card {
27 | padding: 10px;
28 | margin: 6px;
29 | color: #fff;
30 | text-decoration: none;
31 | background-color: $color-background-inverse;
32 | border-radius: 8px;
33 | transition: all 0.125s ease-in-out;
34 |
35 | &:hover {
36 | background-color: #{$color-background-inverse + "99"};
37 | }
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/screens/Settings/Settings.scss:
--------------------------------------------------------------------------------
1 | @import "../../colors";
2 | @import "../../constants";
3 |
4 | .settings-wrapper {
5 | display: flex;
6 | flex-direction: column;
7 | grid-area: main;
8 | justify-content: center;
9 | width: 100%;
10 |
11 | margin: 80px auto;
12 |
13 | @media screen and (min-width: $bp-md) {
14 | max-width: 900px;
15 | padding: 50px;
16 | margin: 0 auto;
17 | }
18 |
19 | .heading {
20 | margin-bottom: 20px;
21 | }
22 |
23 | .settings-guide {
24 | width: 100%;
25 | width: 50%;
26 | margin: 40px auto;
27 |
28 | @media screen and (min-width: $bp-md) {
29 | margin: 40px auto;
30 | }
31 |
32 | h3 {
33 | margin-bottom: 5px;
34 | }
35 | }
36 |
37 | .table-wrapper {
38 | width: 100%;
39 | overflow-x: scroll;
40 |
41 | @media screen and (min-width: $bp-md) {
42 | overflow-x: hidden;
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/Forms/ImportPreviewForm/ImportPreviewForm.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import GroupPreview from "./GroupPreview/GroupPreview.jsx";
4 | import PackagePreview from "./PackagePreview/PackagePreview.jsx";
5 |
6 | import "./ImportPreviewForm.scss";
7 |
8 | const ImportPreviewForm = ({
9 | defaultPackage = null,
10 | onSubmitCallback = null,
11 | importedData,
12 | }) => {
13 | return (
14 |
15 | {importedData.type === "vocascan/package" ? (
16 |
20 | ) : (
21 |
26 | )}
27 |
28 | );
29 | };
30 |
31 | export default ImportPreviewForm;
32 |
--------------------------------------------------------------------------------
/src/Components/Congratulation/Congratulation.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { useTranslation } from "react-i18next";
3 |
4 | import { scaleValue } from "../../utils/index.js";
5 |
6 | import "./Congratulation.scss";
7 |
8 | const Congratulation = ({ percentage }) => {
9 | const { t } = useTranslation();
10 | const [congratulation, setCongratulations] = useState();
11 |
12 | const translations = t("screens.endScreen.congratulations", {
13 | returnObjects: true,
14 | });
15 |
16 | useEffect(() => {
17 | //get size of congratulation array and calculate the message with the given percentage
18 | setCongratulations(
19 | translations[
20 | scaleValue(percentage, [0, 100], [1, translations.length - 1])
21 | ]
22 | );
23 | }, [percentage, t, translations]);
24 | return {congratulation}
;
25 | };
26 |
27 | export default Congratulation;
28 |
--------------------------------------------------------------------------------
/src/index.scss:
--------------------------------------------------------------------------------
1 | @import "./colors";
2 |
3 | * {
4 | padding: 0;
5 | margin: 0;
6 | font-family: "Roboto", sans-serif;
7 | font-weight: 300;
8 | text-align: center;
9 | }
10 |
11 | body {
12 | color: $color-main-text;
13 | background-color: $color-background;
14 | }
15 |
16 | .text {
17 | &-success {
18 | color: $color-green;
19 | }
20 |
21 | &-error {
22 | color: $color-red;
23 | }
24 |
25 | &-light {
26 | font-weight: lighter !important;
27 | }
28 |
29 | &-normal {
30 | font-weight: normal !important;
31 | }
32 |
33 | &-bold {
34 | font-weight: bold !important;
35 | }
36 |
37 | &-wrap {
38 | white-space: pre-wrap;
39 | }
40 |
41 | &-left {
42 | text-align: left;
43 | }
44 | }
45 |
46 | .w {
47 | &-25 {
48 | width: 25%;
49 | }
50 |
51 | &-50 {
52 | width: 50%;
53 | }
54 |
55 | &-75 {
56 | width: 75%;
57 | }
58 |
59 | &-100 {
60 | width: 100%;
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/screens/Learn/Dashboard/Dashboard.scss:
--------------------------------------------------------------------------------
1 | @import "../../../colors";
2 | @import "../../../constants";
3 |
4 | .dashboard {
5 | display: flex;
6 | grid-area: main;
7 | justify-content: center;
8 | width: 100%;
9 | height: 100vh;
10 | padding: 0;
11 |
12 | @media screen and (min-width: $bp-md) {
13 | max-width: 850px;
14 | height: 100%;
15 | padding: 50px;
16 | margin: auto;
17 | }
18 |
19 | &.empty {
20 | display: flex;
21 | align-items: center;
22 | justify-content: center;
23 | width: 100%;
24 | height: 100vh;
25 | padding: 0;
26 |
27 | @media screen and (min-width: $bp-md) {
28 | height: 100%;
29 | }
30 |
31 | h1 {
32 | font-size: 25px;
33 | text-transform: uppercase;
34 | }
35 | }
36 |
37 | .dashboard-inner {
38 | width: 100%;
39 | margin-top: 80px;
40 |
41 | @media screen and (min-width: $bp-md) {
42 | margin-top: 0;
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/Components/Snackbar/Snackbar.scss:
--------------------------------------------------------------------------------
1 | @import "../../colors";
2 |
3 | .snackbar {
4 | position: absolute;
5 | right: 15px;
6 | bottom: 15px;
7 | z-index: 10000;
8 | display: flex;
9 | align-items: center;
10 | justify-content: center;
11 | width: 300px;
12 | padding: 10px 20px;
13 | visibility: hidden;
14 | border-radius: 10px;
15 | opacity: 0;
16 | transition: all 0.4s ease-in;
17 |
18 | &.snackbar-success {
19 | color: $color-green-dark;
20 | background-color: $color-green-light;
21 | }
22 |
23 | &.snackbar-error {
24 | color: $color-red-dark;
25 | background-color: $color-red-light;
26 | }
27 |
28 | &.snackbar-info {
29 | color: $color-primary-dark;
30 | background-color: $color-primary-light;
31 | }
32 |
33 | .snackbar-text {
34 | font-weight: 400;
35 | }
36 |
37 | &.show {
38 | visibility: visible;
39 | user-select: none;
40 | opacity: 1;
41 | transition: all 0.4s ease-in;
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/Components/Charts/ProgressBar/ProgressBar.scss:
--------------------------------------------------------------------------------
1 | @import "../../../colors";
2 |
3 | .progress-bar {
4 | position: relative;
5 | width: 100%;
6 | max-width: 500px;
7 | height: 20px;
8 | margin: 50px 0;
9 | background-color: $color-background-muted;
10 |
11 | .wrapper {
12 | display: flex;
13 | width: 0%;
14 | max-width: 500px;
15 | height: 100%;
16 | background-color: $color-green;
17 | transition: width 1s ease-in-out;
18 |
19 | .percentage {
20 | position: absolute;
21 | left: 50%;
22 | align-self: center;
23 | font-size: 13px;
24 | font-weight: bold;
25 | line-height: 13px;
26 | color: $color-main-text;
27 | transform: translateX(-50%);
28 | }
29 | }
30 |
31 | .bottom-text {
32 | display: flex;
33 | justify-content: flex-end;
34 | padding: 5px 0;
35 | font-size: 13px;
36 | line-height: 13px;
37 | color: $color-main-text;
38 | letter-spacing: 1px;
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/screens/Library/Library.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useTranslation } from "react-i18next";
3 | import { Route, Switch, useRouteMatch } from "react-router";
4 |
5 | import AllGroups from "./AllGroups/AllGroups.jsx";
6 | import AllPackages from "./AllPackages/AllPackages.jsx";
7 | import AllVocabs from "./AllVocabs/AllVocabs.jsx";
8 |
9 | import "./Library.scss";
10 |
11 | const Library = () => {
12 | const { t } = useTranslation();
13 | const { path } = useRouteMatch();
14 |
15 | return (
16 |
17 |
{t("global.library")}
18 |
19 |
20 |
21 |
25 |
26 |
27 | );
28 | };
29 |
30 | export default Library;
31 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: kind/bug
6 | assignees: noctera
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Please complete the following information:**
27 |
28 |
29 |
30 | ```
31 | Version:
32 | Commit:
33 | Date:
34 | Electron:
35 | Chrome:
36 | Node.js:
37 | V8:
38 | OS:
39 | ```
40 |
41 | **Additional context**
42 | Add any other context about the problem here.
43 |
--------------------------------------------------------------------------------
/src/screens/Learn/Query/Query.scss:
--------------------------------------------------------------------------------
1 | @import "../../../colors";
2 | @import "../../../constants";
3 |
4 | .query-wrapper {
5 | display: grid;
6 | grid-template-areas:
7 | "progress"
8 | "content";
9 | grid-template-rows: 15% 85%;
10 | width: 100%;
11 | height: 100vh;
12 |
13 | @media screen and (min-width: $bp-md) {
14 | grid-template-areas:
15 | "progress"
16 | "content"
17 | "footer";
18 | grid-template-rows: 15% 70% 15%;
19 | height: 100%;
20 | }
21 |
22 | .progress {
23 | display: flex;
24 | grid-area: progress;
25 | align-self: center;
26 | justify-content: center;
27 | justify-self: center;
28 | width: 100%;
29 | max-width: 600px;
30 | margin-top: 80px;
31 |
32 | @media screen and (min-width: $bp-md) {
33 | min-width: 450px;
34 | }
35 | }
36 |
37 | .content {
38 | display: flex;
39 | grid-area: content;
40 | align-items: center;
41 | justify-content: center;
42 | width: 100%;
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/config.js:
--------------------------------------------------------------------------------
1 | import deepmerge from "deepmerge";
2 | import isElectron from "is-electron";
3 |
4 | const envVars = Object.entries(process.env).reduce((acc, [key, value]) => {
5 | if (key.startsWith("REACT_APP_VOCASCAN_")) {
6 | acc[key.replace(/^REACT_APP_VOCASCAN_/, "")] = value;
7 | }
8 | return acc;
9 | }, {});
10 |
11 | window.VOCASCAN_CONFIG = deepmerge(window.VOCASCAN_CONFIG, {
12 | ...envVars,
13 | ENV: undefined,
14 | BASE_URL: "",
15 | SHOW_PLANS: undefined,
16 | THEME_SELECT: "auto",
17 | themes: {
18 | dark: "default-themes/dark.css",
19 | light: "default-themes/light.css",
20 | },
21 | });
22 |
23 | // detect env automatically if not set
24 | if (window.VOCASCAN_CONFIG.ENV === undefined) {
25 | window.VOCASCAN_CONFIG.ENV = isElectron() ? "electron" : "web";
26 | }
27 |
28 | // if SHOW_PLANS is not configured but a BASE_URL is configured -> dont show the plans
29 | if (window.VOCASCAN_CONFIG.SHOW_PLANS === undefined) {
30 | window.VOCASCAN_CONFIG.SHOW_PLANS = !window.VOCASCAN_CONFIG.BASE_URL;
31 | }
32 |
--------------------------------------------------------------------------------
/src/Components/Nav/UpdateAvailable.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from "react";
2 |
3 | import GetAppIcon from "@material-ui/icons/GetApp";
4 |
5 | import { updateNotifier, startUpdate } from "../../modules/update.js";
6 |
7 | import "./UpdateAvailable.scss";
8 |
9 | const UpdateAvailable = () => {
10 | const [show, setShow] = useState(false);
11 |
12 | useEffect(() => {
13 | const handleUpdateNotification = (_event, isDarwin, info) => {
14 | console.log(isDarwin, info);
15 | setShow(true);
16 | };
17 |
18 | updateNotifier.on("update-available", handleUpdateNotification);
19 |
20 | return () => {
21 | updateNotifier.removeListener(
22 | "update-available",
23 | handleUpdateNotification
24 | );
25 | };
26 | });
27 |
28 | const handleUpdate = () => {
29 | startUpdate();
30 | };
31 |
32 | if (!show) {
33 | return null;
34 | }
35 |
36 | return ;
37 | };
38 |
39 | export default UpdateAvailable;
40 |
--------------------------------------------------------------------------------
/src/screens/Learn/Direction/Direction.scss:
--------------------------------------------------------------------------------
1 | @import "../../../constants";
2 |
3 | .direction {
4 | display: grid;
5 | grid-template-areas:
6 | "title"
7 | "boxes";
8 | grid-template-rows: 0.5fr 4fr;
9 | width: 100%;
10 | height: 100%;
11 | margin: 80px 0;
12 |
13 | @media screen and (min-width: $bp-md) {
14 | grid-template-areas:
15 | "title"
16 | "boxes"
17 | "footer";
18 | grid-template-rows: 1fr 3fr 1fr;
19 | margin: 0;
20 | }
21 |
22 | .box-title {
23 | display: flex;
24 | grid-area: title;
25 | align-self: center;
26 | justify-self: center;
27 | font-size: 18px;
28 |
29 | @media screen and (min-width: $bp-md) {
30 | margin: 50px 0;
31 | font-size: 24px;
32 | }
33 | }
34 |
35 | .box-wrapper {
36 | display: flex;
37 | flex-direction: column;
38 | grid-area: boxes;
39 | justify-content: space-around;
40 | margin: auto;
41 |
42 | @media screen and (min-width: $bp-md) {
43 | flex-direction: row;
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/Components/CookieConsentBanner/CookieConsentBanner.scss:
--------------------------------------------------------------------------------
1 | @import "../../colors";
2 |
3 | /* stylelint-disable selector-class-pattern */
4 | .CookieConsent {
5 | position: absolute;
6 | right: 0;
7 | left: 0;
8 | z-index: 999;
9 | display: flex;
10 | flex-wrap: wrap;
11 | justify-content: space-between;
12 | width: 40%;
13 | padding: 15px;
14 | margin-right: auto;
15 | margin-left: auto;
16 | color: $color-main-text-inverse;
17 | background: $color-background-inverse;
18 |
19 | div {
20 | margin: auto 0;
21 | }
22 |
23 | button {
24 | flex: 0 0 auto;
25 | padding: 5px 10px;
26 | margin-left: 15px;
27 | color: $color-main-text-inverse;
28 | cursor: pointer;
29 | background: $color-primary;
30 | border: 0;
31 | border-radius: 0;
32 | box-shadow: none;
33 | }
34 |
35 | .cookie-consent-link {
36 | color: $color-primary;
37 | text-decoration: none;
38 |
39 | &:hover {
40 | text-decoration: underline;
41 | }
42 | }
43 | }
44 | /* stylelint-enable selector-class-pattern */
45 |
--------------------------------------------------------------------------------
/src/Components/Timer/CountdownTimer.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Countdown from "react-countdown";
3 | import { useTranslation } from "react-i18next";
4 |
5 | import "./CountdownTimer.scss";
6 |
7 | const Expired = () => {
8 | const { t } = useTranslation();
9 | return (
10 |
11 | {t("components.inviteCode.expired")}
12 |
13 | );
14 | };
15 |
16 | const renderer = ({ days, hours, minutes, seconds, completed }) => {
17 | if (completed) {
18 | // Render a completed state
19 | return ;
20 | } else {
21 | // Render a countdown
22 | return (
23 |
24 | {("00" + days).substr(-2)}:{("00" + hours).substr(-2)}:
25 | {("00" + minutes).substr(-2)}:{("00" + seconds).substr(-2)}
26 |
27 | );
28 | }
29 | };
30 |
31 | const CountdownTimer = ({ callQueuedTime }) => {
32 | return ;
33 | };
34 |
35 | export default CountdownTimer;
36 |
--------------------------------------------------------------------------------
/src/redux/Actions/index.js:
--------------------------------------------------------------------------------
1 | export const REGISTER = "REGISTER";
2 | export const SIGN_IN = "SIGN_IN";
3 | export const SIGN_OUT = "SIGN_OUT";
4 | export const SET_SERVER_URL = "SET_SERVER_URL";
5 | export const SET_SELF_HOSTED = "SET_SELF_HOSTED";
6 | export const SET_SERVER_INFO = "SET_SERVER_INFO";
7 | export const OPEN_GUIDE = "OPEN_GUIDE";
8 | export const CLOSE_GUIDE = "CLOSE_GUIDE";
9 |
10 | export const SET_LANGUAGE = "SET_LANGUAGE";
11 | export const SET_LANGUAGES = "SET_LANGUAGES";
12 | export const SET_THEME = "SET_THEME";
13 |
14 | export const SET_VOCAB_ACTIVE = "SET_VOCAB_ACTIVE";
15 | export const SET_VOCAB_ACTIVATE = "SET_VOCAB_ACTIVATE";
16 | export const SET_GROUP_ACTIVATE = "SET_GROUP_ACTIVATE";
17 |
18 | export const SET_TABLE_PAGE_SIZE = "SET_TABLE_PAGE_SIZE";
19 |
20 | export const SET_LEARNED_PACKAGE = "SET_LEARNED_PACKAGE";
21 |
22 | export const SET_QUERY_CORRECT = "SET_QUERY_CORRECT";
23 | export const SET_QUERY_WRONG = "SET_QUERY_WRONG";
24 | export const SET_ACTUAL_PROGRESS = "SET_ACTUAL_PROGRESS";
25 | export const CLEAR_QUERY = "CLEAR_QUERY";
26 |
--------------------------------------------------------------------------------
/src/redux/Actions/query.js:
--------------------------------------------------------------------------------
1 | import {
2 | SET_LEARNED_PACKAGE,
3 | SET_QUERY_CORRECT,
4 | SET_QUERY_WRONG,
5 | CLEAR_QUERY,
6 | SET_ACTUAL_PROGRESS,
7 | } from "./index.js";
8 |
9 | export const setLearnedPackage = ({
10 | foreignWordLanguage,
11 | translatedWordLanguage,
12 | languagePackageId,
13 | vocabsToday,
14 | staged,
15 | }) => {
16 | return {
17 | type: SET_LEARNED_PACKAGE,
18 | payload: {
19 | foreignWordLanguage,
20 | translatedWordLanguage,
21 | languagePackageId,
22 | vocabsToday,
23 | staged,
24 | },
25 | };
26 | };
27 |
28 | export const setQueryCorrect = () => {
29 | return {
30 | type: SET_QUERY_CORRECT,
31 | payload: {},
32 | };
33 | };
34 |
35 | export const setQueryWrong = () => {
36 | return {
37 | type: SET_QUERY_WRONG,
38 | payload: {},
39 | };
40 | };
41 |
42 | export const setActualProgress = () => {
43 | return {
44 | type: SET_ACTUAL_PROGRESS,
45 | payload: {},
46 | };
47 | };
48 |
49 | export const clearQuery = () => {
50 | return {
51 | type: CLEAR_QUERY,
52 | payload: {},
53 | };
54 | };
55 |
--------------------------------------------------------------------------------
/src/images/logo/transparent-round.svg:
--------------------------------------------------------------------------------
1 |
2 |
17 |
--------------------------------------------------------------------------------
/src/Forms/ImportPreviewForm/PackagePreview/PackagePreview.scss:
--------------------------------------------------------------------------------
1 | @import "../../../constants";
2 |
3 | .package-preview {
4 | display: flex;
5 | flex-direction: column;
6 | align-items: center;
7 | justify-content: space-between;
8 | width: 100%;
9 | height: 100%;
10 | overflow-y: auto;
11 |
12 | .update-fields {
13 | width: 100%;
14 | height: 80%;
15 | overflow-y: auto;
16 |
17 | .table-wrapper {
18 | width: 100%;
19 | overflow-x: scroll;
20 | @media screen and (min-width: $bp-md) {
21 | overflow-x: hidden;
22 | }
23 | }
24 |
25 | .dropdown {
26 | z-index: 4;
27 | display: flex;
28 | justify-content: space-between;
29 | height: 50px;
30 |
31 | .select-wrapper {
32 | display: flex;
33 | width: 49%;
34 | }
35 | }
36 |
37 | .group-detail {
38 | width: 100%;
39 | margin: 0 auto;
40 |
41 | @media screen and (min-width: $bp-md) {
42 | width: 90%;
43 | }
44 | }
45 | }
46 |
47 | .submit-btn {
48 | display: flex;
49 | justify-content: center;
50 | margin-top: 40px;
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/Components/Nav/Nav.scss:
--------------------------------------------------------------------------------
1 | @import "../../colors";
2 | @import "../../constants";
3 |
4 | .nav {
5 | z-index: 100;
6 | display: none;
7 | flex-direction: column;
8 | grid-area: sidenav;
9 | justify-content: space-between;
10 | width: 65px;
11 | height: calc(100vh - 50px);
12 | background: $color-background-inverse;
13 | transition: width 0.2s ease-in;
14 |
15 | @media screen and (min-width: $bp-md) {
16 | display: flex;
17 | }
18 |
19 | &:hover {
20 | width: 200px;
21 | transition: width 0.2s ease-in;
22 |
23 | .nav-legal {
24 | opacity: 100;
25 | transition: all 0.6s ease-in;
26 | }
27 | }
28 |
29 | .button-list {
30 | grid-row: 2;
31 | grid-column: 1;
32 | margin-top: 40px;
33 | }
34 |
35 | .nav-legal {
36 | margin: 10px auto;
37 | font-size: 15px;
38 | color: $color-white;
39 | opacity: 0;
40 |
41 | .nav-legal-wrapper {
42 | display: flex;
43 | align-items: center;
44 | margin: 5px 0;
45 |
46 | a {
47 | margin-left: 5px;
48 | color: $color-white;
49 | text-decoration: none;
50 | }
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/screens/Library/AllVocabs/AllVocabs.scss:
--------------------------------------------------------------------------------
1 | @import "../../../colors";
2 | @import "../../../constants";
3 |
4 | .all-vocabs-wrapper {
5 | .header-wrapper {
6 | position: relative;
7 | display: flex;
8 | align-items: center;
9 | justify-content: center;
10 | margin-bottom: 20px;
11 |
12 | .back {
13 | position: absolute;
14 | left: 0;
15 | align-self: center;
16 | padding: 5px;
17 | border-radius: 100%;
18 |
19 | &:hover {
20 | color: $color-primary-dark;
21 | cursor: pointer;
22 | }
23 | }
24 |
25 | .add {
26 | position: absolute;
27 | right: 0;
28 | align-self: center;
29 | padding: 5px;
30 | border-radius: 100%;
31 |
32 | &:hover {
33 | color: $color-primary-dark;
34 | cursor: pointer;
35 | }
36 | }
37 | }
38 |
39 | .table-wrapper {
40 | overflow: scroll;
41 |
42 | @media screen and (min-width: $bp-md) {
43 | overflow: hidden;
44 | }
45 | }
46 |
47 | .filters {
48 | display: flex;
49 | justify-content: flex-end;
50 |
51 | .search {
52 | width: 300px;
53 | }
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/Components/ThemeSelector/ThemeSelector.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useTranslation } from "react-i18next";
3 | import { useDispatch, useSelector } from "react-redux";
4 |
5 | import Select from "../Form/Select/Select.jsx";
6 |
7 | import { setTheme } from "../../redux/Actions/setting.js";
8 |
9 | const ThemeSelector = () => {
10 | const { t, i18n } = useTranslation();
11 |
12 | const theme = useSelector((state) => state.setting.theme);
13 |
14 | const dispatch = useDispatch();
15 |
16 | const mapTheme = (key) => ({
17 | label: i18n.exists(`themes.${key}`) ? t(`themes.${key}`) : key,
18 | value: key,
19 | });
20 |
21 | return (
22 |
23 |
34 | );
35 | };
36 |
37 | export default ThemeSelector;
38 |
--------------------------------------------------------------------------------
/src/hooks/useHumanizer.js:
--------------------------------------------------------------------------------
1 | import { useCallback } from "react";
2 |
3 | const useHumanizer = (t) => {
4 | const durationHumanizer = useCallback(
5 | ({ duration }) => {
6 | // inspired from https://stackoverflow.com/questions/8211744/convert-time-interval-given-in-seconds-into-more-human-readable-form
7 | const durations = [
8 | ["years", Math.floor(duration / 31536000)],
9 | ["weeks", Math.floor((duration % 31536000) / 86400 / 7)],
10 | ["days", Math.floor(((duration % 31536000) % 604800) / 86400)],
11 | ["hours", Math.floor(((duration % 31536000) % 86400) / 3600)],
12 | ["minutes", Math.floor((((duration % 31536000) % 86400) % 3600) / 60)],
13 | ["seconds", (((duration % 31536000) % 86400) % 3600) % 60],
14 | ];
15 |
16 | const outputString = durations.reduce((str, [unit, value]) => {
17 | if (value > 0) {
18 | return `${str} ${t(`units.time.${unit}`, { count: value })}`;
19 | }
20 |
21 | return str;
22 | }, "");
23 |
24 | return outputString;
25 | },
26 | [t]
27 | );
28 |
29 | return {
30 | durationHumanizer,
31 | };
32 | };
33 |
34 | export default useHumanizer;
35 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import "./config.js";
2 |
3 | import React from "react";
4 | import ReactDOM from "react-dom";
5 | import { Provider } from "react-redux";
6 |
7 | import App from "./App.jsx";
8 | import Snackbar from "./Components/Snackbar/Snackbar.jsx";
9 | import { SnackbarProvider } from "./context/SnackbarContext.jsx";
10 |
11 | import I18nProvider from "./i18n/I18nProvider.js";
12 | import store from "./redux/Store/index.js";
13 | import reportWebVitals from "./reportWebVitals.js";
14 |
15 | import "@fontsource/roboto/300.css";
16 | import "@fontsource/roboto/400.css";
17 | import "@fontsource/roboto/700.css";
18 |
19 | ReactDOM.render(
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | ,
30 | document.getElementById("root")
31 | );
32 |
33 | // If you want to start measuring performance in your app, pass a function
34 | // to log results (for example: reportWebVitals(console.log))
35 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
36 | reportWebVitals();
37 |
--------------------------------------------------------------------------------
/src/redux/Reducers/form.js:
--------------------------------------------------------------------------------
1 | import { defineState } from "redux-localstore";
2 |
3 | import {
4 | SET_VOCAB_ACTIVE,
5 | SET_VOCAB_ACTIVATE,
6 | SET_GROUP_ACTIVATE,
7 | } from "../Actions/index.js";
8 |
9 | const defaultState = {
10 | vocab: {
11 | active: true,
12 | activate: false,
13 | },
14 | group: {
15 | active: true,
16 | },
17 | };
18 |
19 | const initialState = defineState(defaultState)("form");
20 |
21 | const formReducer = (state = initialState, action) => {
22 | switch (action.type) {
23 | case SET_VOCAB_ACTIVE:
24 | return {
25 | ...state,
26 | vocab: {
27 | ...state.vocab,
28 | active: action.payload.active,
29 | },
30 | };
31 |
32 | case SET_VOCAB_ACTIVATE:
33 | return {
34 | ...state,
35 | vocab: {
36 | ...state.vocab,
37 | activate: action.payload.activate,
38 | },
39 | };
40 |
41 | case SET_GROUP_ACTIVATE:
42 | return {
43 | ...state,
44 | group: {
45 | active: action.payload.active,
46 | },
47 | };
48 |
49 | default:
50 | return state;
51 | }
52 | };
53 |
54 | export default formReducer;
55 |
--------------------------------------------------------------------------------
/src/screens/Guide/Pages/VocabDescription/VocabDescription.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react";
2 | import { useTranslation } from "react-i18next";
3 |
4 | import image1 from "./addVocab.png";
5 |
6 | import "./VocabDescription.scss";
7 |
8 | const VocabDescription = ({ setCanContinue }) => {
9 | const { t } = useTranslation();
10 |
11 | const bulletPoints = t("screens.guide.vocabDescription.bulletPoints", {
12 | returnObjects: true,
13 | });
14 |
15 | useEffect(() => {
16 | setCanContinue(true);
17 | }, [setCanContinue]);
18 |
19 | return (
20 |
21 |
22 |

23 |
24 |
25 |
{t("global.vocabs")}
26 |
{t("screens.guide.vocabDescription.heading")}
27 |
28 | {bulletPoints.map((bulletPoint, index) => (
29 | - {bulletPoint}
30 | ))}
31 |
32 |
33 | {t("screens.guide.vocabDescription.endText")}
34 |
35 |
36 |
37 | );
38 | };
39 |
40 | export default VocabDescription;
41 |
--------------------------------------------------------------------------------
/src/screens/Library/AllPackages/AllPackages.scss:
--------------------------------------------------------------------------------
1 | @import "../../../colors";
2 | @import "../../../constants";
3 |
4 | .all-packages-wrapper {
5 | .action-col-btn {
6 | margin-right: 8px;
7 | }
8 |
9 | .header-wrapper {
10 | position: relative;
11 | display: flex;
12 | align-items: center;
13 | justify-content: center;
14 | margin-bottom: 20px;
15 |
16 | .back {
17 | position: absolute;
18 | top: 0;
19 | left: 0;
20 |
21 | &:hover {
22 | color: $color-primary-dark;
23 | cursor: pointer;
24 | }
25 | }
26 |
27 | .add {
28 | position: absolute;
29 | right: 0;
30 | align-self: center;
31 | padding: 5px;
32 | border-radius: 100%;
33 | }
34 |
35 | .import {
36 | position: absolute;
37 | right: 40px;
38 | align-self: center;
39 | padding: 5px;
40 | border-radius: 100%;
41 | }
42 | }
43 |
44 | .table-wrapper {
45 | overflow: scroll;
46 |
47 | @media screen and (min-width: $bp-md) {
48 | overflow: hidden;
49 | }
50 | }
51 |
52 | .flag-cell-wrapper {
53 | display: flex;
54 | align-items: center;
55 | }
56 |
57 | .flag {
58 | margin-right: 10px;
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/Components/Charts/ProgressBar/ProgressBar.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from "react";
2 |
3 | import "./ProgressBar.scss";
4 |
5 | const calcProgress = (value, max, round) => {
6 | if (value > max) {
7 | return 100;
8 | }
9 |
10 | if (round) {
11 | return ((value / max) * 100).toFixed(0);
12 | }
13 |
14 | return (value / max) * 100;
15 | };
16 |
17 | const ProgressBar = ({
18 | showPercentage = false,
19 | value = 0,
20 | min = 0,
21 | max = 100,
22 | round = true,
23 | bottomText = false,
24 | }) => {
25 | const [progress, setProgress] = useState(calcProgress(value, max, round));
26 |
27 | useEffect(() => {
28 | setProgress(calcProgress(value, max, round));
29 | }, [value, max, round]);
30 |
31 | return (
32 |
33 |
39 | {showPercentage && {`${progress}%`}}
40 |
41 | {bottomText && (
42 |
43 | {`${value} / ${max}`}
44 |
45 | )}
46 |
47 | );
48 | };
49 |
50 | export default ProgressBar;
51 |
--------------------------------------------------------------------------------
/src/screens/Guide/Pages/GroupDescription/GroupDescription.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react";
2 | import { useTranslation } from "react-i18next";
3 |
4 | import image1 from "./addGroup1.png";
5 | import image2 from "./addGroup2.png";
6 |
7 | import "./GroupDescription.scss";
8 |
9 | const GroupDescription = ({ setCanContinue }) => {
10 | const { t } = useTranslation();
11 |
12 | const bulletPoints = t("screens.guide.groupDescription.bulletPoints", {
13 | returnObjects: true,
14 | });
15 |
16 | useEffect(() => {
17 | setCanContinue(true);
18 | }, [setCanContinue]);
19 |
20 | return (
21 |
22 |
23 |

24 |

25 |
26 |
27 |
{t("global.groups")}
28 |
29 | {bulletPoints.map((bulletPoint, index) => (
30 | - {bulletPoint}
31 | ))}
32 |
33 |
34 | {t("screens.guide.groupDescription.endText")}
35 |
36 |
37 |
38 | );
39 | };
40 |
41 | export default GroupDescription;
42 |
--------------------------------------------------------------------------------
/src/screens/Guide/Pages/PackageDescription/PackageDescription.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react";
2 | import { useTranslation } from "react-i18next";
3 |
4 | import image1 from "./addPackage1.png";
5 | import image2 from "./addPackage2.png";
6 |
7 | import "./PackageDescription.scss";
8 |
9 | const PackageDescription = ({ setCanContinue }) => {
10 | const { t } = useTranslation();
11 |
12 | const bulletPoints = t("screens.guide.packageDescription.bulletPoints", {
13 | returnObjects: true,
14 | });
15 |
16 | useEffect(() => {
17 | setCanContinue(true);
18 | }, [setCanContinue]);
19 |
20 | return (
21 |
22 |
23 |

24 |

25 |
26 |
27 |
{t("global.packages")}
28 |
29 | {bulletPoints.map((bulletPoint, index) => (
30 | - {bulletPoint}
31 | ))}
32 |
33 |
34 | {t("screens.guide.packageDescription.endText")}
35 |
36 |
37 |
38 | );
39 | };
40 |
41 | export default PackageDescription;
42 |
--------------------------------------------------------------------------------
/src/Components/CookieConsentBanner/CookieConsentBanner.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import CookieConsent from "react-cookie-consent";
3 | import { useTranslation } from "react-i18next";
4 |
5 | import { pages } from "../../utils/constants.js";
6 |
7 | import "./CookieConsentBanner.scss";
8 |
9 | import useLinkCreator from "../../hooks/useLinkCreator";
10 |
11 | const CookieConsentBanner = () => {
12 | const { t } = useTranslation();
13 |
14 | const { isValid: isPrivacyPolicyValid, url: privacyPolicyUrl } =
15 | useLinkCreator({ path: pages.privacyPolicy });
16 |
17 | return (
18 | <>
19 | {isPrivacyPolicyValid && window.VOCASCAN_CONFIG.ENV === "web" && (
20 |
25 | {t("components.cookieConsent.text")}
26 |
32 | {t("components.cookieConsent.learnMore")}
33 |
34 |
35 | )}
36 | >
37 | );
38 | };
39 |
40 | export default CookieConsentBanner;
41 |
--------------------------------------------------------------------------------
/src/context/SnackbarContext.jsx:
--------------------------------------------------------------------------------
1 | import React, { createContext, useCallback, useEffect, useState } from "react";
2 | import { useTranslation } from "react-i18next";
3 |
4 | const SnackbarContext = createContext();
5 |
6 | export const SnackbarProvider = ({ children }) => {
7 | const { t } = useTranslation();
8 |
9 | const [snack, setSnack] = useState({
10 | show: false,
11 | varian: "",
12 | text: "",
13 | });
14 |
15 | const showSnack = useCallback(
16 | (variant = "success", text = t("global.successMessage")) => {
17 | setSnack({
18 | show: true,
19 | variant,
20 | text,
21 | });
22 | },
23 | [t]
24 | );
25 |
26 | useEffect(() => {
27 | if (!snack.show) {
28 | return;
29 | }
30 |
31 | const timer = setTimeout(() => {
32 | setSnack((s) => {
33 | return {
34 | ...s,
35 | show: false,
36 | };
37 | });
38 | }, 2000);
39 |
40 | return () => {
41 | clearTimeout(timer);
42 | };
43 | }, [snack]);
44 |
45 | const providerValue = {
46 | snack,
47 | showSnack,
48 | };
49 |
50 | return (
51 |
52 | {children}
53 |
54 | );
55 | };
56 |
57 | export default SnackbarContext;
58 |
--------------------------------------------------------------------------------
/src/Components/Indicators/PasswordComplexityIndicator/PasswordComplexityIndicator.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react";
2 |
3 | import { bytesLength } from "../../../utils/index.js";
4 |
5 | import "./PasswordComplexityIndicator.scss";
6 |
7 | const PasswordComplexityIndicator = ({
8 | password,
9 | complexity,
10 | setComplexity,
11 | }) => {
12 | useEffect(() => {
13 | const passwordLength = bytesLength(password);
14 |
15 | const length = passwordLength >= 8 && passwordLength <= 72;
16 | const hasUpperCase = /[A-Z]/.test(password);
17 | const hasLowerCase = /[a-z]/.test(password);
18 | const hasNumbers = /\d/.test(password);
19 | const hasNonAlphas = /\W/.test(password);
20 |
21 | if (length) {
22 | setComplexity(
23 | length + hasUpperCase + hasLowerCase + hasNumbers + hasNonAlphas
24 | );
25 | } else if (passwordLength !== 0) {
26 | setComplexity(1);
27 | } else {
28 | setComplexity(0);
29 | }
30 | }, [password, setComplexity]);
31 |
32 | return (
33 |
39 | );
40 | };
41 |
42 | export default PasswordComplexityIndicator;
43 |
--------------------------------------------------------------------------------
/src/Components/Tooltip/Tooltip.scss:
--------------------------------------------------------------------------------
1 | @import "../../colors";
2 |
3 | .tooltip {
4 | max-width: 30%;
5 | text-align: left;
6 | word-wrap: normal;
7 | overflow-wrap: break-word;
8 | white-space: break-spaces;
9 |
10 | &.tooltip-default {
11 | color: $color-main-text-inverse !important;
12 | background-color: $color-background-inverse !important;
13 |
14 | &::after {
15 | border-top-color: $color-background-inverse !important;
16 | border-bottom-color: $color-background-inverse !important;
17 | }
18 | }
19 |
20 | &.tooltip-info {
21 | background-color: $color-primary-light;
22 |
23 | &::after {
24 | border-top-color: $color-primary-light !important;
25 | border-bottom-color: $color-primary-light !important;
26 | }
27 | }
28 |
29 | &.tooltip-green {
30 | color: $color-main-text-inverse !important;
31 | background-color: $color-green;
32 |
33 | &::after {
34 | border-top-color: $color-green !important;
35 | border-bottom-color: $color-green !important;
36 | }
37 | }
38 |
39 | &.tooltip-red {
40 | color: $color-main-text-inverse !important;
41 | background-color: $color-red;
42 |
43 | &::after {
44 | border-top-color: $color-red !important;
45 | border-bottom-color: $color-red !important;
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/images/logo/transparent-rect.svg:
--------------------------------------------------------------------------------
1 |
2 |
17 |
--------------------------------------------------------------------------------
/src/Components/LanguageSelector/LanguageSelector.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useTranslation } from "react-i18next";
3 | import { useDispatch, useSelector } from "react-redux";
4 |
5 | import Select, { SelectOptionWithFlag } from "../Form/Select/Select.jsx";
6 |
7 | import { languages } from "../../i18n/I18nProvider.js";
8 | import { setLanguage } from "../../redux/Actions/setting.js";
9 |
10 | const mapLanguages = ({ code, name }) => ({
11 | value: code,
12 | label: ,
13 | });
14 |
15 | const LanguageSelector = () => {
16 | const language = useSelector((state) => state.setting.language);
17 | const { t } = useTranslation();
18 | const dispatch = useDispatch();
19 |
20 | return (
21 |
22 |
35 | );
36 | };
37 |
38 | export default LanguageSelector;
39 |
--------------------------------------------------------------------------------
/src/hooks/useFeature.js:
--------------------------------------------------------------------------------
1 | import { useMemo } from "react";
2 | import { useSelector } from "react-redux";
3 | import { gte } from "semver";
4 |
5 | export const FEATURES = {
6 | IMPORT_EXPORT: { minVersion: "1.1.0" },
7 | };
8 |
9 | const useFeature = (feature) => {
10 | const serverInfo = useSelector((state) => state.login.serverInfo);
11 | const isSupported = useMemo(() => {
12 | return Object.entries(feature).every(([key, value]) => {
13 | if (key === "minVersion") {
14 | // in case that serverVersion is not yet defined
15 | if (!serverInfo?.version) {
16 | return false;
17 | }
18 |
19 | // compare semver versions
20 | if (gte(serverInfo?.version, value)) {
21 | return true;
22 | }
23 |
24 | // make the feature always in development versions available to allow testing
25 | if (process.env.NODE_ENV === "development") {
26 | return true;
27 | }
28 |
29 | // feature not supported
30 | return false;
31 | } else if (key === "env") {
32 | return window.VOCASCAN_CONFIG.ENV === value;
33 | }
34 |
35 | return false;
36 | });
37 | }, [feature, serverInfo?.version]);
38 |
39 | return {
40 | version: serverInfo?.version,
41 | isSupported,
42 | };
43 | };
44 |
45 | export default useFeature;
46 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | | Status | Type |
5 | | :---: | :---: |
6 | | :white_check_mark: Ready / :x: Hold | Feature/Bug/Tooling/Refactor/Hotfix |
7 |
8 | ## Description
9 |
10 |
11 |
12 | ## Motivation and Context
13 |
14 |
15 |
16 |
17 | ## Screenshots / GIFs (if appropriate):
18 |
19 |
20 | ## Checklist
21 |
22 |
23 |
24 |
25 | - [ ] I have read the **CONTRIBUTING** document.
26 | - [ ] I have considered the accessibility of my changes (i.e. did I add proper content descriptions to images, or run my changes with talkback enabled?)
27 | - [ ] I have documented my code if needed
28 |
29 | ## Resolves
30 |
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/src/Components/Details/Details.scss:
--------------------------------------------------------------------------------
1 | @import "../../colors";
2 |
3 | /* stylelint-disable selector-class-pattern */
4 |
5 | .Collapsible {
6 | margin-bottom: 30px;
7 |
8 | .Collapsible__trigger {
9 | position: relative;
10 | display: block;
11 | padding: 5px 0 5px 20px;
12 | cursor: pointer;
13 | user-select: none;
14 | border-bottom: 1px solid $color-background-inverse;
15 |
16 | &::before {
17 | position: absolute;
18 | bottom: 11px;
19 | left: 0;
20 | width: 5px;
21 | height: 5px;
22 | content: " ";
23 | border-color: $color-background-inverse;
24 | border-style: solid;
25 | border-width: 0 2px 2px 0;
26 | transition: all 0.125s ease-in-out;
27 | transform: rotate(-45deg);
28 | }
29 |
30 | &.is-open {
31 | &::before {
32 | transform: rotate(45deg);
33 | }
34 | }
35 |
36 | .trigger-wrapper {
37 | display: flex;
38 | justify-content: space-between;
39 |
40 | .summary {
41 | font-size: 14px;
42 | font-weight: 400;
43 | text-transform: uppercase;
44 | letter-spacing: 2px;
45 | }
46 | }
47 | }
48 |
49 | .Collapsible__contentInner {
50 | display: flex;
51 | flex-wrap: wrap;
52 | margin-top: 10px;
53 | }
54 | }
55 |
56 | /* stylelint-enable selector-class-pattern */
57 |
--------------------------------------------------------------------------------
/src/i18n/I18nProvider.js:
--------------------------------------------------------------------------------
1 | import i18n from "i18next";
2 | import React, { useEffect } from "react";
3 | import { initReactI18next, I18nextProvider } from "react-i18next";
4 | import { useSelector } from "react-redux";
5 |
6 | import de from "./locales/de/default.json";
7 | import en from "./locales/en/default.json";
8 |
9 | // import pl from "./locales/pl/default.json";
10 | // import ru from "./locales/ru/default.json";
11 |
12 | i18n.use(initReactI18next).init({
13 | debug: false,
14 | fallbackLng: ["en"],
15 | interpolation: { escapeValue: false },
16 | lng: "en",
17 | load: "all",
18 | defaultNS: "default",
19 | react: { useSuspense: true },
20 | resources: {
21 | de: { default: de },
22 | en: { default: en },
23 | // pl: { default: pl },
24 | // ru: { default: ru },
25 | },
26 | });
27 |
28 | function I18nProvider({ children }) {
29 | const language = useSelector((state) => state.setting.language);
30 |
31 | useEffect(() => {
32 | i18n.changeLanguage(language);
33 | }, [language]);
34 |
35 | return {children};
36 | }
37 |
38 | export default I18nProvider;
39 |
40 | export const languages = [
41 | { code: "en", name: "English" },
42 | { code: "de", name: "Deutsch (German)" },
43 | // { code: "pl", name: "Polski (Polish)" },
44 | // { code: "ru", name: "Pусский (Russian)" },
45 | ];
46 |
--------------------------------------------------------------------------------
/src/screens/Learn/Direction/Direction.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useTranslation } from "react-i18next";
3 | import { useSelector } from "react-redux";
4 |
5 | import DirectionBox from "../../../Components/DirectionBox/DirectionBox.jsx";
6 |
7 | import "./Direction.scss";
8 |
9 | const Direction = () => {
10 | const { t } = useTranslation();
11 |
12 | const foreignWordLanguage = useSelector(
13 | (state) => state.query.foreignWordLanguage
14 | );
15 | const translatedWordLanguage = useSelector(
16 | (state) => state.query.translatedWordLanguage
17 | );
18 | return (
19 |
20 |
{t("screens.direction.title")}
21 |
22 |
26 |
31 |
36 |
37 |
38 | );
39 | };
40 |
41 | export default Direction;
42 |
--------------------------------------------------------------------------------
/src/screens/Guide/Pages/VocabDescription/VocabDescription.scss:
--------------------------------------------------------------------------------
1 | @import "../../../../constants";
2 |
3 | .vocab-description {
4 | display: flex;
5 | flex-direction: column;
6 | width: 100%;
7 | height: 100%;
8 |
9 | @media screen and (min-width: $bp-md) {
10 | flex-direction: row;
11 | }
12 |
13 | .images {
14 | display: flex;
15 | flex-direction: column;
16 | width: 100%;
17 | margin-top: 30px;
18 |
19 | @media screen and (min-width: $bp-md) {
20 | width: 30%;
21 | margin: auto 0;
22 | }
23 | }
24 |
25 | .description {
26 | display: flex;
27 | flex-direction: column;
28 | justify-content: space-between;
29 |
30 | @media screen and (min-width: $bp-md) {
31 | margin: 10% 0;
32 | }
33 |
34 | .heading {
35 | font-size: 25px;
36 | font-weight: bold;
37 | }
38 |
39 | ul {
40 | padding: 30px 0;
41 | @media screen and (min-width: $bp-md) {
42 | padding: 0 5%;
43 | }
44 |
45 | li {
46 | margin: 0 30px;
47 | font-size: 20px;
48 |
49 | text-align: justify;
50 | text-justify: inter-word;
51 |
52 | @media screen and (min-width: $bp-md) {
53 | margin: 10px 100px;
54 | text-align: left;
55 | }
56 | }
57 | }
58 |
59 | .end-text {
60 | font-size: 20px;
61 | }
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/Components/ConfirmDialog/ConfirmDialog.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useTranslation } from "react-i18next";
3 |
4 | import Button from "../Button/Button.jsx";
5 | import Modal from "../Modal/Modal.jsx";
6 |
7 | import "./ConfirmDialog.scss";
8 |
9 | const ConfirmDialog = ({
10 | title = null,
11 | description = null,
12 | onSubmit = null,
13 | submitText,
14 | cancelText,
15 | onClose = null,
16 | show = false,
17 | canSubmit = true,
18 | showAbortButton = true,
19 | children,
20 | }) => {
21 | const [t] = useTranslation();
22 |
23 | return (
24 |
25 |
26 |
27 | {description}
28 | {children}
29 |
30 |
31 | {showAbortButton && (
32 |
35 | )}
36 |
44 |
45 |
46 |
47 | );
48 | };
49 |
50 | export default ConfirmDialog;
51 |
--------------------------------------------------------------------------------
/src/Components/DirectionBox/DirectionBox.scss:
--------------------------------------------------------------------------------
1 | @import "../../colors";
2 | @import "../../constants";
3 |
4 | .direction-box {
5 | display: flex;
6 | justify-content: center;
7 | width: 100%;
8 | min-height: 250px;
9 | margin: 20px 0;
10 | color: $color-main-text-inverse;
11 | background: $color-background-inverse;
12 | border-radius: 10px;
13 |
14 | @media screen and (min-width: $bp-md) {
15 | min-width: 350px;
16 | max-width: 600px;
17 | max-height: 600px;
18 | margin: 0 20px;
19 | }
20 |
21 | &:hover {
22 | cursor: pointer;
23 | box-shadow: 0 0 0 10px $color-primary;
24 | transition: all 0.2s ease-in-out;
25 | transition: all 0.2s ease-in-out;
26 | }
27 |
28 | .direction-box-inner {
29 | display: flex;
30 | flex-direction: column;
31 | align-items: center;
32 | justify-content: space-around;
33 | width: 90%;
34 |
35 | .flags {
36 | display: flex;
37 | align-items: center;
38 | justify-content: space-between;
39 | margin-top: 50px;
40 |
41 | * {
42 | margin: 0 5px;
43 | }
44 |
45 | .direction-arrow {
46 | font-size: 32px;
47 |
48 | &.invert {
49 | transform: rotate(180deg);
50 | }
51 | }
52 | }
53 |
54 | .languages {
55 | margin-top: 50px;
56 | font-size: 13px;
57 | text-transform: uppercase;
58 | letter-spacing: 2px;
59 | }
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/Components/ContributorCard/ContributorCard.scss:
--------------------------------------------------------------------------------
1 | @import "../../colors";
2 |
3 | .card {
4 | position: relative;
5 | display: inline-block;
6 | width: 130px;
7 | height: 170px;
8 | margin: 8px;
9 | overflow: hidden;
10 | border-radius: 8px;
11 |
12 | img {
13 | width: 130px;
14 | }
15 |
16 | .backdrop {
17 | position: absolute;
18 | top: 130px;
19 | bottom: 0;
20 | display: flex;
21 | flex-direction: column;
22 | align-items: center;
23 | justify-content: center;
24 | width: 100%;
25 | color: #fff;
26 | background-color: $color-background-inverse;
27 | transition: border-radius 0.125s ease-in-out, top 0.125s ease-in-out;
28 |
29 | .name {
30 | font-size: 14px;
31 | }
32 |
33 | .description {
34 | display: none;
35 | margin-top: 5px;
36 | font-size: 12px;
37 | color: #bbb;
38 | white-space: pre-line;
39 | }
40 | }
41 |
42 | .card-image {
43 | width: 128px;
44 | }
45 |
46 | .card-image-bp {
47 | display: flex;
48 | align-items: center;
49 | justify-content: center;
50 | height: 65%;
51 |
52 | .card-image-bp-icon {
53 | font-size: 100px;
54 | color: $color-dark;
55 | }
56 | }
57 |
58 | &:hover {
59 | .backdrop {
60 | top: 0;
61 |
62 | background: $color-background-inverse;
63 |
64 | .description {
65 | display: block;
66 | }
67 | }
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/hooks/useDebounce.js:
--------------------------------------------------------------------------------
1 | /**
2 | * See: https://dev.to/gabe_ragland/debouncing-with-react-hooks-jci
3 | */
4 | import { useState, useEffect } from "react";
5 |
6 | const useDebounce = (value, delay) => {
7 | const [debouncedValue, setDebouncedValue] = useState(value);
8 |
9 | useEffect(
10 | () => {
11 | // Set debouncedValue to value (passed in) after the specified delay
12 | const handler = setTimeout(() => {
13 | setDebouncedValue(value);
14 | }, delay);
15 |
16 | // Return a cleanup function that will be called every time ...
17 | // ... useEffect is re-called. useEffect will only be re-called ...
18 | // ... if value changes (see the inputs array below).
19 | // This is how we prevent debouncedValue from changing if value is ...
20 | // ... changed within the delay period. Timeout gets cleared and restarted.
21 | // To put it in context, if the user is typing within our app's ...
22 | // ... search box, we don't want the debouncedValue to update until ...
23 | // ... they've stopped typing for more than 500ms.
24 | return () => {
25 | clearTimeout(handler);
26 | };
27 | },
28 | // Only re-call effect if value changes
29 | // You could also add the "delay" var to inputs array if you ...
30 | // ... need to be able to change that dynamically.
31 | [value, delay]
32 | );
33 |
34 | return debouncedValue;
35 | };
36 |
37 | export default useDebounce;
38 |
--------------------------------------------------------------------------------
/src/screens/Settings/Settings.jsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from "react";
2 | import { useTranslation } from "react-i18next";
3 | import { useDispatch } from "react-redux";
4 |
5 | import Button from "../../Components/Button/Button.jsx";
6 | import LanguageSelector from "../../Components/LanguageSelector/LanguageSelector.jsx";
7 | import ThemeSelector from "../../Components/ThemeSelector/ThemeSelector.jsx";
8 |
9 | import VersionTable from "../../Components/VersionTable/VersionTable.js";
10 | import { openGuide } from "../../redux/Actions/login.js";
11 |
12 | import "./Settings.scss";
13 |
14 | const Settings = () => {
15 | const { t } = useTranslation();
16 |
17 | const dispatch = useDispatch();
18 |
19 | const reopenGuide = useCallback(() => {
20 | dispatch(openGuide());
21 | }, [dispatch]);
22 |
23 | return (
24 |
25 |
{t("screens.settings.title")}
26 |
27 |
28 |
29 |
30 |
31 |
32 |
{t("screens.settings.guide.title")}
33 |
36 |
37 |
38 |
{t("screens.settings.versions")}
39 |
40 |
41 |
42 |
43 | );
44 | };
45 |
46 | export default Settings;
47 |
--------------------------------------------------------------------------------
/src/Forms/VocabForm/VocabForm.scss:
--------------------------------------------------------------------------------
1 | @import "../../colors";
2 | @import "../../constants";
3 |
4 | .vocab-form {
5 | grid-area: main;
6 | width: 100%;
7 | height: 75%;
8 | margin: 80px 0;
9 | overflow-y: auto;
10 |
11 | @media screen and (min-width: $bp-md) {
12 | max-width: 700px;
13 | padding: 50px;
14 | margin: 0;
15 | }
16 |
17 | .heading {
18 | display: none;
19 | grid-row: 2;
20 | grid-column: 2;
21 |
22 | @media screen and (min-width: $bp-md) {
23 | display: inline;
24 | }
25 | }
26 |
27 | .dropdowns {
28 | z-index: 4;
29 | display: flex;
30 | flex-direction: column;
31 |
32 | @media screen and (min-width: $bp-md) {
33 | flex-direction: row;
34 | justify-content: space-between;
35 | margin: 20px -10px;
36 | }
37 |
38 | .select-wrapper {
39 | width: 100%;
40 |
41 | @media screen and (min-width: $bp-md) {
42 | margin: 0 10px;
43 | }
44 | }
45 | }
46 |
47 | .input-fields {
48 | display: flex;
49 | flex-direction: column;
50 | grid-row: 4;
51 | grid-column: 2;
52 |
53 | .submit-btn {
54 | grid-row: 5;
55 | grid-column: 2;
56 | width: 150px;
57 | height: 35px;
58 | margin: 0 auto;
59 | background: $color-primary;
60 | box-shadow: -1px 3px 5px $color-alternative;
61 | }
62 | }
63 |
64 | .form-submit {
65 | display: flex;
66 | justify-content: center;
67 | margin-top: 40px;
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/redux/Reducers/query.js:
--------------------------------------------------------------------------------
1 | import {
2 | SET_LEARNED_PACKAGE,
3 | SET_QUERY_CORRECT,
4 | SET_QUERY_WRONG,
5 | CLEAR_QUERY,
6 | SET_ACTUAL_PROGRESS,
7 | } from "../Actions/index.js";
8 |
9 | const initialState = {
10 | foreignWordLanguage: "",
11 | translatedWordLanguage: "",
12 | languagePackageId: "",
13 | vocabsToday: 0,
14 | staged: false,
15 | correct: 0,
16 | wrong: 0,
17 | actualProgress: 0,
18 | };
19 |
20 | const queryReducer = (state = initialState, action) => {
21 | switch (action.type) {
22 | case SET_LEARNED_PACKAGE:
23 | return {
24 | ...state,
25 | foreignWordLanguage: action.payload.foreignWordLanguage,
26 | translatedWordLanguage: action.payload.translatedWordLanguage,
27 | languagePackageId: action.payload.languagePackageId,
28 | vocabsToday: action.payload.vocabsToday,
29 | staged: action.payload.staged,
30 | };
31 |
32 | case SET_QUERY_CORRECT:
33 | return {
34 | ...state,
35 | correct: state.correct + 1,
36 | };
37 |
38 | case SET_QUERY_WRONG:
39 | return {
40 | ...state,
41 | wrong: state.wrong + 1,
42 | };
43 | case SET_ACTUAL_PROGRESS:
44 | return {
45 | ...state,
46 | actualProgress: state.actualProgress + 1,
47 | };
48 |
49 | case CLEAR_QUERY:
50 | return {
51 | ...initialState,
52 | };
53 |
54 | default:
55 | return state;
56 | }
57 | };
58 |
59 | export default queryReducer;
60 |
--------------------------------------------------------------------------------
/src/Components/InviteCode/InviteCode.scss:
--------------------------------------------------------------------------------
1 | @import "../../colors";
2 |
3 | .invite-code {
4 | position: relative;
5 | display: flex;
6 | flex-direction: column;
7 | justify-content: space-around;
8 | width: 100%;
9 | height: 100%;
10 | min-height: 130px;
11 | max-height: 100px;
12 | background: $color-background-inverse;
13 | border-radius: 15px;
14 |
15 | &.expired {
16 | opacity: 0.9;
17 | }
18 |
19 | .delete-btn {
20 | position: absolute;
21 | top: 10px;
22 | right: 10px;
23 | color: $color-red;
24 | cursor: pointer;
25 | opacity: 0;
26 | transition: 100ms ease-in-out opacity;
27 | }
28 |
29 | &:hover > .delete-btn {
30 | opacity: 1;
31 | }
32 |
33 | .delete-btn-hide {
34 | display: none;
35 | }
36 |
37 | hr {
38 | width: 60%;
39 | margin: 0 auto;
40 | border: 1px solid #bbb;
41 | }
42 |
43 | .heading {
44 | p {
45 | width: fit-content;
46 | margin-right: auto;
47 | margin-left: auto;
48 | color: $color-main-text-inverse;
49 | cursor: pointer;
50 | transition: 250ms ease-out transform;
51 |
52 | &:hover {
53 | transform: scale(1.2);
54 | }
55 | }
56 | }
57 |
58 | .information {
59 | p {
60 | margin-top: 5px;
61 | font-size: 15px;
62 | color: #bbb;
63 | }
64 |
65 | .uses-valid {
66 | color: $color-green;
67 | }
68 |
69 | .uses-invalid {
70 | color: $color-red;
71 | }
72 |
73 | .expiration-code-valid {
74 | color: $color-green;
75 | }
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/public/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
24 |
--------------------------------------------------------------------------------
/src/redux/Actions/login.js:
--------------------------------------------------------------------------------
1 | import {
2 | REGISTER,
3 | SIGN_IN,
4 | SIGN_OUT,
5 | SET_SELF_HOSTED,
6 | SET_SERVER_URL,
7 | OPEN_GUIDE,
8 | CLOSE_GUIDE,
9 | SET_SERVER_INFO,
10 | } from "./index.js";
11 |
12 | export const register = ({ username, email, token, isAdmin }) => {
13 | return {
14 | type: REGISTER,
15 | payload: {
16 | username,
17 | email,
18 | token,
19 | isAdmin,
20 | },
21 | };
22 | };
23 |
24 | export const signIn = ({ username, email, token, isAdmin }) => {
25 | return {
26 | type: SIGN_IN,
27 | payload: {
28 | username,
29 | email,
30 | token,
31 | isAdmin,
32 | },
33 | };
34 | };
35 |
36 | export const setServerUrl = ({ serverAddress }) => {
37 | return {
38 | type: SET_SERVER_URL,
39 | payload: {
40 | serverAddress,
41 | },
42 | };
43 | };
44 |
45 | export const setSelfHosted = ({ selfHosted }) => {
46 | return {
47 | type: SET_SELF_HOSTED,
48 | payload: {
49 | selfHosted,
50 | },
51 | };
52 | };
53 |
54 | export const setServerInfo = ({ serverInfo }) => {
55 | return {
56 | type: SET_SERVER_INFO,
57 | payload: {
58 | serverInfo,
59 | },
60 | };
61 | };
62 |
63 | export const signOut = () => {
64 | return {
65 | type: SIGN_OUT,
66 | payload: {},
67 | };
68 | };
69 |
70 | export const openGuide = () => {
71 | return {
72 | type: OPEN_GUIDE,
73 | payload: {},
74 | };
75 | };
76 |
77 | export const closeGuide = () => {
78 | return {
79 | type: CLOSE_GUIDE,
80 | payload: {},
81 | };
82 | };
83 |
--------------------------------------------------------------------------------
/src/images/logo/color-round.svg:
--------------------------------------------------------------------------------
1 |
2 |
24 |
--------------------------------------------------------------------------------
/src/Components/Layout/UnauthenticatedLayout/UnauthenticatedLayout.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { useTranslation } from "react-i18next";
3 | import { useSelector } from "react-redux";
4 |
5 | import Button from "../../Button/Button.jsx";
6 | import Flag from "../../Flag/Flag.jsx";
7 | import LanguageSelector from "../../LanguageSelector/LanguageSelector.jsx";
8 | import Modal from "../../Modal/Modal.jsx";
9 |
10 | import "./UnauthenticatedLayout.scss";
11 |
12 | const UnauthenticatedLayout = ({ children }) => {
13 | const { t } = useTranslation();
14 | const language = useSelector((state) => state.setting.language);
15 | const [showLanguageModal, setShowLanguageModal] = useState(false);
16 |
17 | return (
18 |
19 |
setShowLanguageModal(true)}
21 | className="language-selector-wrapper"
22 | >
23 |
24 |
25 | {children}
26 |
setShowLanguageModal(false)}
31 | >
32 |
33 |
34 |
35 |
36 |
37 |
40 |
41 |
42 |
43 | );
44 | };
45 |
46 | export default UnauthenticatedLayout;
47 |
--------------------------------------------------------------------------------
/src/screens/Guide/Pages/GroupDescription/GroupDescription.scss:
--------------------------------------------------------------------------------
1 | @import "../../../../constants";
2 |
3 | .group-description {
4 | display: flex;
5 | flex-direction: column;
6 | width: 100%;
7 | height: 100%;
8 |
9 | @media screen and (min-width: $bp-md) {
10 | flex-direction: row;
11 | }
12 |
13 | .images {
14 | display: flex;
15 | flex-direction: row;
16 | width: 100%;
17 |
18 | @media screen and (min-width: $bp-md) {
19 | flex-direction: column;
20 | width: 30%;
21 | margin: 5% 0;
22 | }
23 |
24 | img {
25 | width: 50%;
26 | padding: 5px 0;
27 | object-fit: cover;
28 |
29 | @media screen and (min-width: $bp-md) {
30 | width: auto;
31 | height: 50%;
32 | }
33 | }
34 | }
35 |
36 | .description {
37 | display: flex;
38 | flex-direction: column;
39 | justify-content: space-between;
40 |
41 | @media screen and (min-width: $bp-md) {
42 | margin: 10% 0;
43 | }
44 |
45 | .heading {
46 | font-size: 25px;
47 | font-weight: bold;
48 | }
49 |
50 | ul {
51 | padding: 30px 0;
52 | @media screen and (min-width: $bp-md) {
53 | padding: 0 5%;
54 | }
55 |
56 | li {
57 | margin: 0 30px;
58 | font-size: 20px;
59 |
60 | text-align: justify;
61 | text-justify: inter-word;
62 |
63 | @media screen and (min-width: $bp-md) {
64 | margin: 10px 100px;
65 | text-align: left;
66 | }
67 | }
68 | }
69 |
70 | .end-text {
71 | margin-bottom: 30px;
72 | font-size: 20px;
73 | }
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/screens/Guide/Pages/PackageDescription/PackageDescription.scss:
--------------------------------------------------------------------------------
1 | @import "../../../../constants";
2 |
3 | .package-description {
4 | display: flex;
5 | flex-direction: column;
6 | width: 100%;
7 | height: 100%;
8 |
9 | @media screen and (min-width: $bp-md) {
10 | flex-direction: row;
11 | }
12 |
13 | .images {
14 | display: flex;
15 | flex-direction: row;
16 | width: 100%;
17 |
18 | @media screen and (min-width: $bp-md) {
19 | flex-direction: column;
20 | width: 30%;
21 | margin: 5% 0;
22 | }
23 |
24 | img {
25 | width: 50%;
26 | padding: 5px 0;
27 | object-fit: cover;
28 |
29 | @media screen and (min-width: $bp-md) {
30 | width: auto;
31 | height: 50%;
32 | }
33 | }
34 | }
35 |
36 | .description {
37 | display: flex;
38 | flex-direction: column;
39 | justify-content: space-between;
40 |
41 | @media screen and (min-width: $bp-md) {
42 | margin: 10% 0;
43 | }
44 |
45 | .heading {
46 | font-size: 25px;
47 | font-weight: bold;
48 | }
49 |
50 | ul {
51 | padding: 30px 0;
52 | @media screen and (min-width: $bp-md) {
53 | padding: 0 5%;
54 | }
55 |
56 | li {
57 | margin: 0 30px;
58 | font-size: 20px;
59 |
60 | text-align: justify;
61 | text-justify: inter-word;
62 |
63 | @media screen and (min-width: $bp-md) {
64 | margin: 10px 100px;
65 | text-align: left;
66 | }
67 | }
68 | }
69 |
70 | .end-text {
71 | margin-bottom: 30px;
72 | font-size: 20px;
73 | }
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/Components/Flag/Flag.jsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo, useState } from "react";
2 | import { useEffect } from "react";
3 |
4 | import {
5 | languageCountryMap,
6 | spriteSheetPositions,
7 | } from "./language-country-map.js";
8 |
9 | import "./Flag.scss";
10 |
11 | const sizeMap = {
12 | small: 0.13,
13 | medium: 0.3,
14 | large: 0.6,
15 | };
16 |
17 | const Flag = ({ languageCode, border = false, size = "small", scale }) => {
18 | const [computedScale, setComputedScale] = useState(0);
19 |
20 | useEffect(() => {
21 | if (scale) {
22 | setComputedScale(scale);
23 | } else if (size && sizeMap[size]) {
24 | setComputedScale(sizeMap[size]);
25 | } else {
26 | setComputedScale(sizeMap.small);
27 | }
28 | }, [scale, size]);
29 |
30 | const style = useMemo(() => {
31 | let code = "unknown";
32 |
33 | if (Object.keys(spriteSheetPositions).includes(languageCode)) {
34 | code = languageCode;
35 | } else if (languageCountryMap[languageCode]) {
36 | code = languageCountryMap[languageCode];
37 | }
38 |
39 | return {
40 | width: 200 * computedScale,
41 | height: 150 * computedScale,
42 | backgroundSize: `${3300 * computedScale}px ${3060 * computedScale}px`,
43 | backgroundPosition: spriteSheetPositions[code]
44 | .map((x) => `${x * computedScale}px`)
45 | .join(" "),
46 | borderRadius: `${30 * computedScale}px`,
47 | boxShadow: border ? `0 0 0 ${10 * computedScale}px` : "none",
48 | };
49 | }, [border, computedScale, languageCode]);
50 |
51 | return (
52 |
53 |
54 |
55 | );
56 | };
57 |
58 | export default Flag;
59 |
--------------------------------------------------------------------------------
/src/Components/Nav/NavButton.scss:
--------------------------------------------------------------------------------
1 | @import "../../colors";
2 | @import "../../constants";
3 |
4 | .nav-button-wrapper {
5 | text-decoration: none;
6 | outline: none;
7 | }
8 |
9 | .nav-button {
10 | position: relative;
11 | display: flex;
12 | flex-wrap: nowrap;
13 | align-items: center;
14 | justify-content: center;
15 | width: 100%;
16 | height: 60px;
17 | color: $color-main-text-inverse;
18 | text-transform: uppercase;
19 | background-color: $color-background;
20 | border: unset;
21 | transition: all 150ms linear;
22 |
23 | @media screen and (min-width: $bp-md) {
24 | background-color: $color-background-inverse;
25 | }
26 |
27 | .button-name {
28 | position: absolute;
29 | left: 60px;
30 | color: $color-main-text;
31 |
32 | @media screen and (min-width: $bp-md) {
33 | color: $color-main-text-inverse;
34 | visibility: hidden;
35 | opacity: 0;
36 | }
37 | }
38 |
39 | .button-icon {
40 | position: absolute;
41 | left: 20px;
42 | color: $color-main-text;
43 | transition: all 150ms linear;
44 |
45 | @media screen and (min-width: $bp-md) {
46 | color: $color-main-text-inverse;
47 | }
48 | }
49 |
50 | &:hover {
51 | cursor: pointer;
52 | background-color: $color-primary;
53 | }
54 |
55 | &:focus {
56 | outline: none;
57 | }
58 | }
59 |
60 | .nav-button-active {
61 | .nav-button {
62 | background-color: $color-primary;
63 |
64 | .button-icon {
65 | color: #fff;
66 | }
67 |
68 | .button-name {
69 | color: #fff;
70 | }
71 | }
72 | }
73 |
74 | .nav:hover .button-name {
75 | visibility: visible;
76 | opacity: 1;
77 | transition: all 150ms linear;
78 | transition-delay: 150ms;
79 | }
80 |
--------------------------------------------------------------------------------
/src/hooks/useLinkCreator.js:
--------------------------------------------------------------------------------
1 | import { CancelToken } from "axios";
2 | import { useEffect, useState } from "react";
3 | import { useSelector } from "react-redux";
4 |
5 | import { checkUrlAvailable } from "../utils/api.js";
6 | import { vocascanServer } from "../utils/constants.js";
7 |
8 | const useLinkCreator = ({ path, electronFix = false }) => {
9 | const selfHosted = useSelector((state) => state.login.selfHosted);
10 | const serverAddress = useSelector((state) => state.login.serverAddress);
11 | const appLanguage = useSelector((state) => state.setting.language);
12 |
13 | const [isValid, setIsValid] = useState(false);
14 | const [url, setUrl] = useState("");
15 |
16 | const { BASE_URL: baseURL, ENV: env } = window.VOCASCAN_CONFIG;
17 |
18 | // initialize url with the needed url for the servers
19 | useEffect(() => {
20 | if (baseURL || selfHosted) {
21 | if (electronFix && env === "electron") {
22 | setUrl(vocascanServer + `${path}?lang=` + appLanguage);
23 | } else {
24 | setUrl((serverAddress || baseURL) + `${path}?lang=` + appLanguage);
25 | }
26 | } else {
27 | setUrl(vocascanServer + `${path}?lang=` + appLanguage);
28 | }
29 | }, [appLanguage, baseURL, electronFix, env, path, selfHosted, serverAddress]);
30 |
31 | // check if url is available
32 | useEffect(() => {
33 | const cancelToken = CancelToken.source();
34 |
35 | checkUrlAvailable(url, cancelToken.token)
36 | .then((response) => {
37 | setIsValid(true);
38 | })
39 | .catch((error) => {
40 | setIsValid(false);
41 | });
42 |
43 | return () => cancelToken.cancel();
44 | }, [url]);
45 |
46 | return { isValid, url };
47 | };
48 |
49 | export default useLinkCreator;
50 |
--------------------------------------------------------------------------------
/src/Components/Modal/Modal.scss:
--------------------------------------------------------------------------------
1 | @import "../../colors";
2 | @import "../../constants";
3 |
4 | .modal {
5 | position: absolute;
6 | top: 0;
7 | left: 0;
8 | z-index: 100;
9 | display: flex;
10 | justify-content: center;
11 | width: 100%;
12 | height: 100vh;
13 | background: hsl(0, 0%, 0%, 0.4);
14 |
15 | .inner {
16 | position: relative;
17 | z-index: 11;
18 | display: flex;
19 | flex-direction: column;
20 | align-items: center;
21 | justify-content: space-between;
22 | width: 60vw;
23 | height: 60vh;
24 | padding: 40px 24px;
25 | margin: auto;
26 | overflow-y: auto;
27 | background-color: $color-background;
28 | border-radius: 5px;
29 |
30 | @media screen and (min-width: $bp-md) {
31 | padding: 40px 60px;
32 | }
33 |
34 | &.small {
35 | width: 90%;
36 | @media screen and (min-width: $bp-md) {
37 | width: 40vw;
38 | max-width: 500px;
39 | height: auto;
40 | }
41 | }
42 |
43 | &.large {
44 | width: 90%;
45 | height: 90%;
46 |
47 | @media screen and (min-width: $bp-md) {
48 | width: 80vw;
49 | max-width: 1200px;
50 | height: 80vh;
51 | }
52 | }
53 |
54 | &.maxed {
55 | width: 100%;
56 | height: 100vh;
57 | }
58 |
59 | .close {
60 | position: absolute;
61 | top: 20px;
62 | right: 20px;
63 | padding: 10px;
64 | color: $color-primary;
65 | border-radius: 100%;
66 |
67 | &:hover {
68 | color: $color-primary-dark;
69 | cursor: pointer;
70 | }
71 | }
72 |
73 | .heading {
74 | margin: 20px 0 0;
75 | font-size: 25px;
76 | text-transform: uppercase;
77 | }
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/screens/Library/AllGroups/AllGroups.scss:
--------------------------------------------------------------------------------
1 | @import "../../../colors";
2 | @import "../../../constants";
3 |
4 | .all-groups-wrapper {
5 | .action-col-btn {
6 | margin-right: 8px;
7 | }
8 |
9 | .header-wrapper {
10 | position: relative;
11 | display: flex;
12 | align-items: center;
13 | justify-content: center;
14 | margin-bottom: 20px;
15 |
16 | .back {
17 | position: absolute;
18 | left: 0;
19 | align-self: center;
20 | padding: 5px;
21 | border-radius: 100%;
22 |
23 | &:hover {
24 | color: $color-primary-dark;
25 | cursor: pointer;
26 | }
27 | }
28 |
29 | .add {
30 | position: absolute;
31 | right: 0;
32 | align-self: center;
33 | padding: 5px;
34 | border-radius: 100%;
35 |
36 | &:hover {
37 | color: $color-primary-dark;
38 | cursor: pointer;
39 | }
40 | }
41 |
42 | .import {
43 | position: absolute;
44 | right: 40px;
45 | align-self: center;
46 | padding: 5px;
47 | border-radius: 100%;
48 | }
49 | }
50 |
51 | .table-wrapper {
52 | overflow: scroll;
53 |
54 | @media screen and (min-width: $bp-md) {
55 | overflow: hidden;
56 | }
57 | }
58 |
59 | .group-description-cell {
60 | overflow: hidden;
61 | text-align: left;
62 | text-overflow: ellipsis;
63 | white-space: nowrap;
64 |
65 | @media screen and (min-width: $bp-md) {
66 | max-width: 300px;
67 | }
68 |
69 | @media screen and (min-width: $bp-lg) {
70 | max-width: 450px;
71 | }
72 |
73 | @media screen and (min-width: $bp-xl) {
74 | max-width: 600px;
75 | }
76 |
77 | @media screen and (min-width: $bp-2xl) {
78 | max-width: 1000px;
79 | }
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/src/Components/PackageOverview/PackageOverview.scss:
--------------------------------------------------------------------------------
1 | @import "../../colors";
2 | @import "../../constants";
3 |
4 | .package-overview-wrapper {
5 | display: flex;
6 | align-items: center;
7 | justify-content: center;
8 | width: 100%;
9 | height: 128px;
10 | margin: 0 0 40px;
11 | overflow-x: hidden;
12 | background: $color-background-inverse;
13 | border-radius: 15px;
14 |
15 | .package-overview {
16 | display: grid;
17 | grid-template-areas:
18 | "heading today learnbtn"
19 | "unresolved unactivated activatebtn";
20 | grid-template-rows: 50% 50%;
21 | grid-template-columns: 46% 27% 27%;
22 | width: 90%;
23 | height: 80%;
24 |
25 | .package-inner {
26 | width: 100%;
27 | margin: auto;
28 | font-size: 15px;
29 | color: $color-main-text-inverse;
30 | text-align: left;
31 |
32 | &.package-heading {
33 | grid-area: heading;
34 | overflow: hidden;
35 | font-size: 25px;
36 | text-align: left;
37 | text-overflow: ellipsis;
38 | white-space: nowrap;
39 | }
40 |
41 | &.package-unresolved {
42 | grid-area: unresolved;
43 | }
44 |
45 | &.package-today {
46 | grid-area: today;
47 | }
48 |
49 | &.package-unactivated {
50 | grid-area: unactivated;
51 | }
52 | }
53 |
54 | .package-btn-wrapper {
55 | display: flex;
56 | align-items: center;
57 | justify-content: center;
58 | width: 100%;
59 | height: 100%;
60 | @media screen and (min-width: $bp-md) {
61 | justify-content: flex-end;
62 | }
63 |
64 | .package-btn-inner {
65 | width: 100%;
66 |
67 | @media screen and (min-width: $bp-md) {
68 | width: 70%;
69 | }
70 | }
71 | }
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src/hooks/useScrollBlock.js:
--------------------------------------------------------------------------------
1 | // https://gist.github.com/reecelucas/2f510e6b8504008deaaa52732202d2da
2 | import { useRef } from "react";
3 |
4 | const safeDocument = document;
5 |
6 | /**
7 | * Usage:
8 | * const [blockScroll, allowScroll] = useScrollBlock();
9 | */
10 | export const useScrollBlock = () => {
11 | const scrollBlocked = useRef(false);
12 | const html = safeDocument.documentElement;
13 | const { body } = safeDocument;
14 |
15 | const blockScroll = () => {
16 | if (!body || !body.style || scrollBlocked.current) return;
17 | if (document === undefined) return;
18 |
19 | const scrollBarWidth = window.innerWidth - html.clientWidth;
20 | const bodyPaddingRight =
21 | Number(window.getComputedStyle(body).getPropertyValue("padding-right")) ||
22 | 0;
23 |
24 | /**
25 | * 1. Fixes a bug in iOS and desktop Safari whereby setting
26 | * `overflow: hidden` on the html/body does not prevent scrolling.
27 | * 2. Fixes a bug in desktop Safari where `overflowY` does not prevent
28 | * scroll if an `overflow-x` style is also applied to the body.
29 | */
30 | html.style.position = "relative"; /* [1] */
31 | html.style.overflow = "hidden"; /* [2] */
32 | body.style.position = "relative"; /* [1] */
33 | body.style.overflow = "hidden"; /* [2] */
34 | body.style.paddingRight = `${bodyPaddingRight + scrollBarWidth}px`;
35 |
36 | scrollBlocked.current = true;
37 | };
38 |
39 | const allowScroll = () => {
40 | if (!body || !body.style || !scrollBlocked.current) return;
41 |
42 | html.style.position = "";
43 | html.style.overflow = "";
44 | body.style.position = "";
45 | body.style.overflow = "";
46 | body.style.paddingRight = "";
47 |
48 | scrollBlocked.current = false;
49 | };
50 |
51 | return [blockScroll, allowScroll];
52 | };
53 |
--------------------------------------------------------------------------------
/src/Components/SlideShow/SlideShow.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react";
2 | import { useTranslation } from "react-i18next";
3 |
4 | import Button from "../../Components/Button/Button.jsx";
5 | import Indicator from "../../Components/Indicators/PageIndicator/PageIndicator.jsx";
6 |
7 | import "./SlideShow.scss";
8 |
9 | const SlideShow = ({ pages, onEnd, canContinue }) => {
10 | const { t } = useTranslation();
11 |
12 | const [index, setIndex] = useState(0);
13 |
14 | useEffect(() => {
15 | //close first startup when index is higher than pages length. WARNING: index starts at 0, length at 1, so === is used
16 | if (index === pages.length) {
17 | onEnd();
18 | }
19 | }, [index, onEnd, pages.length]);
20 |
21 | return (
22 |
23 |
{pages[index]}
24 |
25 |
26 |
34 |
35 |
36 |
37 |
38 |
39 |
49 |
50 |
51 |
52 | );
53 | };
54 |
55 | export default SlideShow;
56 |
--------------------------------------------------------------------------------
/src/images/logo/color-rect.svg:
--------------------------------------------------------------------------------
1 |
2 |
25 |
--------------------------------------------------------------------------------
/public/default-themes/dark.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --color-background-inverse: #333340;
3 | --color-background-muted: #333340;
4 | --color-background: #282833;
5 | --color-alternative: #333340;
6 | --color-form: #333340;
7 | --color-box-shadow: #000;
8 | --color-box-shadow-light: #000;
9 | --color-main-text-light: #fff;
10 |
11 | --color-main-text: #fff;
12 | --color-main-text-90: #fff;
13 | --color-main-text-80: #fff;
14 | --color-main-text-70: #fff;
15 | --color-main-text-60: #fff;
16 | --color-main-text-50: #fff;
17 | --color-main-text-40: #fff;
18 | --color-main-text-30: #fff;
19 | --color-main-text-20: #fff;
20 | --color-main-text-10: #fff;
21 |
22 | --color-main-text-inverse: #fff;
23 |
24 | --color-primary-light: #03dac5;
25 |
26 | --color-primary: #bb86fc;
27 | --color-primary-90: #bb86fc;
28 | --color-primary-80: #bb86fc;
29 | --color-primary-70: #bb86fc;
30 | --color-primary-60: #bb86fc;
31 | --color-primary-50: #bb86fc;
32 | --color-primary-40: #bb86fc;
33 | --color-primary-30: #bb86fc;
34 | --color-primary-20: #bb86fc;
35 | --color-primary-10: #bb86fc;
36 |
37 | --color-primary-dark: #bb86fc;
38 | --color-primary-dark-90: #bb86fc;
39 | --color-primary-dark-80: #bb86fc;
40 | --color-primary-dark-70: #bb86fc;
41 | --color-primary-dark-60: #bb86fc;
42 | --color-primary-dark-50: #bb86fc;
43 | --color-primary-dark-40: #bb86fc;
44 | --color-primary-dark-30: #bb86fc;
45 | --color-primary-dark-20: #bb86fc;
46 | --color-primary-dark-10: #bb86fc;
47 |
48 | --color-white: #fff;
49 | --color-dark: #000;
50 | --color-grey: #cbcbcb;
51 | --color-red-light: #f9c5cc;
52 | --color-red: #e53935;
53 | --color-red-dark: #e53935;
54 | --color-yellow: #ffeb3b;
55 | --color-yellow-dark: #c8b900;
56 | --color-green-light: #c8f1e5;
57 | --color-green: #24aa65;
58 | --color-green-dark: #24aa65;
59 | }
60 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 |
9 | # Diagnostic reports (https://nodejs.org/api/report.html)
10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
11 |
12 | # Runtime data
13 | pids
14 | *.pid
15 | *.seed
16 | *.pid.lock
17 | .DS_Store
18 |
19 | # Directory for instrumented libs generated by jscoverage/JSCover
20 | lib-cov
21 |
22 | # Coverage directory used by tools like istanbul
23 | coverage
24 | *.lcov
25 |
26 | # nyc test coverage
27 | .nyc_output
28 |
29 | # node-waf configuration
30 | .lock-wscript
31 |
32 | # Compiled binary addons (https://nodejs.org/api/addons.html)
33 | build/Release
34 |
35 | # Dependency directories
36 | node_modules/
37 | jspm_packages/
38 |
39 | # TypeScript v1 declaration files
40 | typings/
41 |
42 | # TypeScript cache
43 | *.tsbuildinfo
44 |
45 | # Optional npm cache directory
46 | .npm
47 |
48 | # Optional eslint cache
49 | .eslintcache
50 |
51 | # Optional REPL history
52 | .node_repl_history
53 |
54 | # Output of 'npm pack'
55 | *.tgz
56 |
57 | # Yarn Integrity file
58 | .yarn-integrity
59 |
60 | # dotenv environment variables file
61 | .env
62 | .env.test
63 |
64 | # parcel-bundler cache (https://parceljs.org/)
65 | .cache
66 |
67 | # next.js build output
68 | .next
69 |
70 | # nuxt.js build output
71 | .nuxt
72 |
73 | # vuepress build output
74 | .vuepress/dist
75 |
76 | # Serverless directories
77 | .serverless/
78 |
79 | # FuseBox cache
80 | .fusebox/
81 |
82 | # DynamoDB Local files
83 | .dynamodb/
84 |
85 | # Webpack
86 | .webpack/
87 |
88 | # vscode
89 | .vscode
90 |
91 | dist/
92 |
93 | # react
94 | build/
95 |
96 | # electron-builder
97 | builds/
98 | dev-app-update.yml
99 |
100 | # localazy
101 | localazy.keys.json
102 |
103 | # jetbrains
104 | .idea/
105 |
--------------------------------------------------------------------------------
/public/default-themes/light.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --color-background-inverse: #2a2e36;
3 | --color-background-muted: #eceeee;
4 | --color-background: #fff;
5 | --color-alternative: #1c1c1a;
6 | --color-form: #252421;
7 | --color-box-shadow: #212121;
8 | --color-box-shadow-light: #b4bed6;
9 | --color-main-text-light: #383838;
10 |
11 | --color-main-text: #212121;
12 | --color-main-text-90: #363746;
13 | --color-main-text-80: #4d4d5b;
14 | --color-main-text-70: #63636f;
15 | --color-main-text-60: #797984;
16 | --color-main-text-50: #8f8f98;
17 | --color-main-text-40: #a6a6ad;
18 | --color-main-text-30: #bbbbc1;
19 | --color-main-text-20: #d2d2d6;
20 | --color-main-text-10: #e8e8ea;
21 |
22 | --color-main-text-inverse: #fff;
23 |
24 | --color-primary-light: #bec2f3;
25 |
26 | --color-primary: #727cf5;
27 | --color-primary-90: #7f88f5;
28 | --color-primary-80: #8e96f7;
29 | --color-primary-70: #9ca3f7;
30 | --color-primary-60: #aab0f9;
31 | --color-primary-50: #b8bdf9;
32 | --color-primary-40: #c6cafb;
33 | --color-primary-30: #d4d7fb;
34 | --color-primary-20: #e2e4fd;
35 | --color-primary-10: #f0f1fd;
36 |
37 | --color-primary-dark: #4c51ec;
38 | --color-primary-dark-90: #6469ee;
39 | --color-primary-dark-80: #767af0;
40 | --color-primary-dark-70: #868af2;
41 | --color-primary-dark-60: #989bf4;
42 | --color-primary-dark-50: #a9abf5;
43 | --color-primary-dark-40: #babcf7;
44 | --color-primary-dark-30: #cbccf9;
45 | --color-primary-dark-20: #dcddfb;
46 | --color-primary-dark-10: #edeefd;
47 |
48 | --color-white: #fff;
49 | --color-dark: #000;
50 | --color-grey: #cbcbcb;
51 | --color-red-light: #f9c5cc;
52 | --color-red: #ff586e;
53 | --color-red-dark: #fd334e;
54 | --color-yellow: #ffeb3b;
55 | --color-yellow-dark: #c8b900;
56 | --color-green-light: #c8f1e5;
57 | --color-green: #0acf97;
58 | --color-green-dark: #06b483;
59 | }
60 |
--------------------------------------------------------------------------------
/src/screens/Admin/Admin.scss:
--------------------------------------------------------------------------------
1 | @import "../../colors";
2 | @import "../../constants";
3 |
4 | .admin {
5 | grid-area: main;
6 | width: 100%;
7 |
8 | .invite-codes {
9 | display: flex;
10 | justify-content: center;
11 | margin: 80px 0;
12 | @media screen and (min-width: $bp-md) {
13 | padding: 50px;
14 | }
15 |
16 | .invite-codes-wrapper {
17 | width: 100%;
18 | max-width: 400px;
19 |
20 | @media screen and (min-width: $bp-md) {
21 | max-width: 600px;
22 | }
23 |
24 | @media screen and (min-width: $bp-lg) {
25 | max-width: 1700px;
26 | }
27 |
28 | .invite-code-controls {
29 | display: flex;
30 | justify-content: space-between;
31 | margin-bottom: 30px;
32 |
33 | .add-btn {
34 | align-self: center;
35 | padding: 5px;
36 | cursor: pointer;
37 | border-radius: 100%;
38 | }
39 | }
40 |
41 | .invite-code-container {
42 | display: grid;
43 | grid-template-columns: repeat(6, minmax(0, 1fr));
44 | gap: 10px;
45 | width: 100%;
46 |
47 | @media (max-width: 1600px) {
48 | grid-template-columns: repeat(5, minmax(0, 1fr));
49 | }
50 |
51 | @media (max-width: 1400px) {
52 | grid-template-columns: repeat(4, minmax(0, 1fr));
53 | }
54 |
55 | @media (max-width: 1200px) {
56 | grid-template-columns: repeat(3, minmax(0, 1fr));
57 | }
58 |
59 | @media (max-width: 1000px) {
60 | grid-template-columns: repeat(2, minmax(0, 1fr));
61 | }
62 |
63 | @media (max-width: 800px) {
64 | grid-template-columns: repeat(1, minmax(0, 1fr));
65 | }
66 | }
67 | }
68 | }
69 |
70 | .center-wrapper {
71 | display: flex;
72 | align-items: center;
73 | justify-content: center;
74 | width: 100%;
75 | height: 100%;
76 | text-align: center;
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/screens/Profile/Profile.scss:
--------------------------------------------------------------------------------
1 | @import "../../colors";
2 | @import "../../constants";
3 |
4 | .profile-screen {
5 | width: 100%;
6 | margin: 80px auto;
7 |
8 | @media screen and (min-width: $bp-md) {
9 | max-width: 600px;
10 | padding: 50px;
11 | margin: 0 auto;
12 | }
13 |
14 | .profile-avatar-wrapper {
15 | display: flex;
16 | align-items: center;
17 | justify-content: center;
18 | width: 150px;
19 | height: 150px;
20 | margin: 0 auto;
21 | border: 1px solid $color-alternative;
22 | border-radius: 100%;
23 |
24 | .profile-avatar {
25 | width: 70px;
26 | height: 70px;
27 | }
28 | }
29 |
30 | .profile-username {
31 | margin: 20px 0;
32 | }
33 |
34 | .table-wrapper {
35 | width: 100%;
36 | overflow-x: scroll;
37 |
38 | @media screen and (min-width: $bp-md) {
39 | overflow-x: hidden;
40 | }
41 | }
42 |
43 | .account-settings-header {
44 | margin: 15px 0;
45 | font-size: 15px;
46 | font-weight: bold;
47 | text-align: left;
48 | text-transform: uppercase;
49 | }
50 |
51 | .account-settings {
52 | display: flex;
53 | flex-direction: column;
54 | border: 1px solid $color-red;
55 | border-radius: 15px;
56 |
57 | .account-settings-fields {
58 | display: flex;
59 | padding: 15px;
60 |
61 | &.border-bottom {
62 | border-bottom: 1px solid $color-background-muted;
63 | }
64 |
65 | .description-wrapper {
66 | display: flex;
67 | flex-direction: column;
68 | justify-content: space-around;
69 | width: 65%;
70 | height: 60%;
71 | margin: auto 0;
72 | margin-left: 10px;
73 |
74 | h3 {
75 | font-size: 15px;
76 | text-align: left;
77 | }
78 |
79 | p {
80 | font-size: 11px;
81 | text-align: left;
82 | }
83 | }
84 | }
85 |
86 | .button-wrapper {
87 | width: 35%;
88 | }
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/.github/workflows/pullrequest.yml:
--------------------------------------------------------------------------------
1 | name: check pull requests
2 | on:
3 | pull_request:
4 |
5 | jobs:
6 | check:
7 | name: 🔍 Check pull request
8 | runs-on: ubuntu-latest
9 | if: ${{ !contains(github.event.pull_request.labels.*.name, 'reviewed/skip-check') }}
10 | steps:
11 | - name: Checkout
12 | uses: actions/checkout@v2
13 |
14 | - name: check translations
15 | uses: dorny/paths-filter@v2
16 | id: changes
17 | with:
18 | list-files: json
19 | filters: |
20 | translations:
21 | - "src/i18n/locales/!(en)/*.json"
22 | - "src/i18n/locales/locales.json"
23 |
24 | - name: format files output
25 | if: steps.changes.outputs.translations == 'true'
26 | id: files-output
27 | run: |
28 | files=$(node -e 'console.log("- " + JSON.parse(`${{ steps.changes.outputs.translations_files }}`).join("\n- "))')
29 | files="${files//$'\n'/'%0A'}"
30 | echo "::set-output name=files::${files}"
31 |
32 | - name: comment on pull request
33 | if: steps.changes.outputs.translations == 'true'
34 | uses: unsplash/comment-on-pr@master
35 | env:
36 | GITHUB_TOKEN: ${{ secrets.VOCASCAN_BOT_TOKEN }}
37 | with:
38 | msg: |
39 | Thank you for your pull request.
40 | Please remove your translations. Change only the `en/default.json` if necessary.
41 | If this pull request is merged into the [`experimental`](https://github.com/vocascan/vocascan-frontend/tree/experimental) branch, it can be translated via [localazy](https://localazy.com/p/vocascan).
42 |
43 | Please remove following files from pull request changes.
44 | ```
45 | ${{ steps.files-output.outputs.files }}
46 | ```
47 |
48 | - name: check if failed
49 | if: steps.changes.outputs.translations == 'true'
50 | run: |
51 | echo "only change en.json"
52 | exit 1
53 |
--------------------------------------------------------------------------------
/src/Components/Button/Button.jsx:
--------------------------------------------------------------------------------
1 | import clsx from "clsx";
2 | import React, { useCallback, useEffect, useRef, useState } from "react";
3 |
4 | import "./Button.scss";
5 |
6 | import LoadingIndicator from "../Indicators/LoadingIndicator/LoadingIndicator";
7 |
8 | const Button = ({
9 | uppercase = false,
10 | variant = "default",
11 | disabled = false,
12 | appearance = "primary",
13 | block = false,
14 | onClick = () => null,
15 | children,
16 | loading = false,
17 | promiseButton = false,
18 | promiseButtonMinTime = 500,
19 | className = "",
20 | ...props
21 | }) => {
22 | const classes = clsx(
23 | "btn",
24 | `btn-${appearance}`,
25 | `btn-${variant}`,
26 | uppercase && "btn-uppercase",
27 | block && "btn-block",
28 | disabled && "btn-disabled",
29 | className
30 | );
31 |
32 | const [promiseLoading, setPromiseLoading] = useState(false);
33 | const timeoutId = useRef(null);
34 |
35 | useEffect(() => {
36 | return () => clearTimeout(timeoutId.current);
37 | }, []);
38 |
39 | const handleClick = useCallback(
40 | async (event) => {
41 | const timeStart = Date.now();
42 | setPromiseLoading(true);
43 |
44 | await Promise.resolve(onClick(event));
45 |
46 | const timeLeft = -Date.now() + timeStart + promiseButtonMinTime; // calculate time left, if negative time already passed
47 |
48 | if (timeLeft <= 0) {
49 | setPromiseLoading(false);
50 | } else {
51 | timeoutId.current = setTimeout(() => {
52 | setPromiseLoading(false);
53 | timeoutId.current = null;
54 | }, timeLeft);
55 | }
56 | },
57 | [onClick, promiseButtonMinTime]
58 | );
59 |
60 | return (
61 |
70 | );
71 | };
72 |
73 | export default Button;
74 |
--------------------------------------------------------------------------------
/src/Components/Table/Table.scss:
--------------------------------------------------------------------------------
1 | @import "../../colors";
2 | @import "../../constants";
3 |
4 | .table {
5 | width: 100%;
6 | padding-top: 40px;
7 | margin: 20px 0;
8 | border-spacing: 0;
9 |
10 | @media screen and (min-width: $bp-md) {
11 | padding-top: 0;
12 | }
13 |
14 | .table-striped {
15 | tr:nth-child(even) {
16 | background-color: $color-background-muted;
17 | }
18 | }
19 |
20 | .th {
21 | padding: 5px;
22 | text-align: left;
23 | border-bottom: 1px solid $color-main-text;
24 |
25 | .header-text {
26 | display: flex;
27 | align-items: center;
28 | justify-content: space-between;
29 | font-weight: bold;
30 | line-height: 24px;
31 | user-select: none;
32 | }
33 |
34 | .sort-placeholder {
35 | width: 24px;
36 | }
37 | }
38 |
39 | .td {
40 | height: 30px;
41 | padding: 5px;
42 | text-align: left;
43 |
44 | a {
45 | font-weight: bold;
46 | color: $color-primary;
47 | text-decoration: none;
48 |
49 | &:hover {
50 | color: $color-primary-dark;
51 | }
52 | }
53 |
54 | .action-col {
55 | display: flex;
56 | justify-content: flex-end;
57 | }
58 | }
59 | }
60 |
61 | .pagination {
62 | position: absolute;
63 | right: 0;
64 | left: 0;
65 | display: flex;
66 | align-items: center;
67 | justify-content: space-between;
68 | width: 90%;
69 | margin-right: auto;
70 | margin-left: auto;
71 | @media screen and (min-width: $bp-md) {
72 | position: relative;
73 | display: flex;
74 | align-items: center;
75 | justify-content: space-between;
76 | width: 100%;
77 | margin: 10px 0;
78 | }
79 |
80 | .pagination-options {
81 | display: flex;
82 | align-items: center;
83 | justify-content: space-between;
84 | }
85 |
86 | .pagination-button {
87 | padding: 0;
88 | margin-right: 5px;
89 | }
90 |
91 | .pagination-text {
92 | padding: 0;
93 | margin-right: 5px;
94 | font-size: 13px;
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/src/Components/Form/Switch/Switch.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import InfoIcon from "@material-ui/icons/Info";
4 |
5 | import Tooltip from "../../Tooltip/Tooltip.jsx";
6 |
7 | import "./Switch.scss";
8 |
9 | const Switch = ({
10 | disabled = false,
11 | onChange = () => null,
12 | optionRight = "insert a label",
13 | infoRight = null,
14 | switcher = false,
15 | optionLeft = null,
16 | infoLeft = null,
17 | checked = false,
18 | label = null,
19 | appearance = "default",
20 | }) => {
21 | return (
22 |
23 | {label &&
}
24 |
29 | {optionLeft && (
30 |
31 |
32 | {infoLeft && (
33 |
39 | )}
40 |
41 | )}
42 |
51 | {switcher && optionRight && (
52 |
53 | {infoRight && (
54 |
59 | )}
60 |
61 |
62 | )}
63 |
64 |
65 |
66 | );
67 | };
68 |
69 | export default Switch;
70 |
--------------------------------------------------------------------------------
/src/Components/SelectionBox/SelectionBox.scss:
--------------------------------------------------------------------------------
1 | @import "../../colors";
2 |
3 | .select-box {
4 | display: flex;
5 | display: flex;
6 | flex-direction: column;
7 | flex-direction: column;
8 | justify-content: space-between;
9 | justify-content: space-between;
10 | min-width: 300px;
11 | max-width: 400px;
12 | height: 100%;
13 | margin: 0 20px;
14 | background: $color-background;
15 | border-radius: 10px;
16 | box-shadow: -1px 3px 5px $color-box-shadow-light;
17 |
18 | .select-box-header {
19 | display: flex;
20 | flex-direction: column;
21 |
22 | .select-box-header-heading {
23 | display: flex;
24 | justify-content: center;
25 | padding: 20px;
26 | background: $color-background-inverse;
27 | border-top-left-radius: 10px;
28 | border-top-right-radius: 10px;
29 |
30 | &.important {
31 | background: $color-red;
32 | }
33 |
34 | .select-box-header-heading-text {
35 | margin: auto;
36 | font-size: 20px;
37 | color: $color-white;
38 | }
39 | }
40 |
41 | .select-box-header-logo {
42 | display: flex;
43 | justify-content: center;
44 | width: 100%;
45 | padding: 20px 0;
46 |
47 | .select-box-header-logo-img {
48 | width: 40%;
49 | margin: auto;
50 | }
51 | }
52 | }
53 |
54 | .select-box-description {
55 | width: 100%;
56 | min-height: 200px;
57 | padding: 20px 0;
58 |
59 | .select-box-description-ul {
60 | width: 100%;
61 | height: 100%;
62 | padding: 0 0 0 10%;
63 |
64 | .description-list-item {
65 | display: flex;
66 | margin: 5px 0;
67 |
68 | .select-box-header-logo-img {
69 | width: 5%;
70 | margin: 0 10px 0 0;
71 | object-fit: scale-down;
72 | }
73 |
74 | .description-list-item-item {
75 | width: 80%;
76 | text-align: left;
77 | }
78 | }
79 | }
80 | }
81 |
82 | .select-box-footer {
83 | display: flex;
84 | align-items: stretch;
85 | justify-content: center;
86 | padding: 20px;
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/.github/workflows/translations.yml:
--------------------------------------------------------------------------------
1 | name: Translations
2 | on:
3 | push:
4 | branches:
5 | - "experimental"
6 | schedule:
7 | - cron: "0 12 * * 0"
8 | workflow_dispatch:
9 |
10 | jobs:
11 | update-translations:
12 | name: 📥 Update translations
13 | runs-on: ubuntu-latest
14 | steps:
15 | - name: Checkout
16 | uses: actions/checkout@v2
17 | with:
18 | ref: experimental
19 | token: ${{ secrets.VOCASCAN_BOT_TOKEN }}
20 |
21 | - name: Download
22 | uses: localazy/download@v1
23 | with:
24 | read_key: ${{ secrets.LOCALAZY_READ_KEY }}
25 | write_key: ${{ secrets.LOCALAZY_WRITE_KEY }}
26 |
27 | - name: Push
28 | uses: stefanzweifel/git-auto-commit-action@v4
29 | with:
30 | commit_message: "[skip ci] updated translations via Localazy"
31 | commit_user_name: VocascanBot
32 | commit_user_email: vocascan@gmail.com
33 | commit_author: VocascanBot
34 | file_pattern: src/i18n/locales/**/default.json
35 | branch: experimental
36 |
37 | - name: Install dependency
38 | run: |
39 | mv package.json package.json.bak
40 | npm install --no-package-lock --no-save semver
41 | mv package.json.bak package.json
42 |
43 | - name: Extract version
44 | uses: actions/github-script@v5
45 | id: prepare
46 | with:
47 | script: |
48 | const Semver = require('semver');
49 | const package = require('./package.json');
50 | const semver = Semver.parse(package.version);
51 | let version = 0;
52 | if (semver) {
53 | version = 100000000 * semver.major + 10000 * semver.minor + semver.patch;
54 | }
55 | core.setOutput("version", version.toString(10));
56 |
57 | - name: Upload
58 | uses: localazy/upload@v1
59 | with:
60 | read_key: ${{ secrets.LOCALAZY_READ_KEY }}
61 | write_key: ${{ secrets.LOCALAZY_WRITE_KEY }}
62 | app_version: ${{ steps.prepare.outputs.version }}
63 |
--------------------------------------------------------------------------------
/src/Components/Form/Textarea/Textarea.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useCallback, useEffect } from "react";
2 |
3 | import "./Textarea.scss";
4 |
5 | const Textarea = ({
6 | type = "textarea",
7 | placeholder = null,
8 | onChange = () => null,
9 | error = false,
10 | errorText = null,
11 | required = false,
12 | value = "",
13 | autoFocus = false,
14 | rows = 5,
15 | maxLength,
16 | ...props
17 | }) => {
18 | const [flow, setFlow] = useState(false);
19 | const [focused, setFocused] = useState(false);
20 | const [indicator, setIndicator] = useState(false);
21 |
22 | const handleFocus = useCallback(() => {
23 | setFocused(true);
24 | setFlow(true);
25 | }, []);
26 |
27 | const onBlur = useCallback(() => {
28 | setFocused(false);
29 | setFlow(!!value);
30 | }, [value]);
31 |
32 | useEffect(() => {
33 | setFlow(!!value || autoFocus);
34 | setFocused(autoFocus);
35 | // only trigger once the component renders
36 | // eslint-disable-next-line react-hooks/exhaustive-deps
37 | }, []);
38 |
39 | useEffect(() => {
40 | setFlow(!!value || focused);
41 | }, [value, focused]);
42 |
43 | useEffect(() => {
44 | setIndicator(maxLength - value.length);
45 | }, [maxLength, value]);
46 |
47 | return (
48 |
49 |
{`${placeholder}${required ? " *" : ""}`}
52 |
71 | );
72 | };
73 |
74 | export default Textarea;
75 |
--------------------------------------------------------------------------------
/src/screens/About/About.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useTranslation } from "react-i18next";
3 |
4 | import Card from "../../Components/ContributorCard/ContributorCard.jsx";
5 | import Details from "../../Components/Details/Details.jsx";
6 |
7 | import { contributors, additionalDependencies } from "../../utils/constants.js";
8 |
9 | import { dependencies, devDependencies } from "../../../package.json";
10 |
11 | import "./About.scss";
12 |
13 | const About = () => {
14 | const { t } = useTranslation();
15 |
16 | return (
17 |
18 |
19 |
{t("screens.about.title")}
20 |
21 | {Object.entries(contributors).map(([key, value], i) => (
22 |
28 | {value.map((contributor, j) => (
29 |
36 | ))}
37 |
38 | ))}
39 |
40 |
48 | {Object.entries({
49 | ...dependencies,
50 | ...devDependencies,
51 | ...additionalDependencies,
52 | }).map(([name, version], i) => (
53 |
60 | {name}: {version}
61 |
62 | ))}
63 |
64 |
65 |
66 | );
67 | };
68 |
69 | export default About;
70 |
--------------------------------------------------------------------------------
/docker/generate-config.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs");
2 | const path = require("path");
3 |
4 | const readConfig = () => {
5 | if (!fs.existsSync(configPath)) {
6 | console.warn(
7 | `Pre-start: ⚠️ No existing config file found under "${configPath}".`
8 | );
9 | process.exit();
10 | }
11 | const content = fs.readFileSync(configPath, "utf-8");
12 | const existing = content.match(
13 | /window\.VOCASCAN_CONFIG\s*=\s*JSON.parse\(`([\s\S]*)`\)/
14 | );
15 | if (!existing || existing.length < 2) {
16 | console.warn("Pre-start: ⚠️ Could not find existing config in config.js");
17 | process.exit();
18 | }
19 | return JSON.parse(existing[1]);
20 | };
21 |
22 | const writeConfig = (config) => {
23 | const json = JSON.stringify(config, null, 4);
24 | const newContent = `window.VOCASCAN_CONFIG = JSON.parse(\`${json}\`);\n`;
25 | fs.writeFileSync(configPath, newContent, "utf-8");
26 | };
27 |
28 | const convertToNativeType = (input) => {
29 | if (["true", "false"].includes(input)) {
30 | return input === "true";
31 | }
32 |
33 | if (!Number.isNaN(parseInt(input))) {
34 | return parseInt(input);
35 | }
36 |
37 | return input;
38 | };
39 |
40 | const htmlPath =
41 | process.env.NODE_ENV === "development"
42 | ? path.resolve(__dirname, "..", "public")
43 | : "/usr/share/nginx/html";
44 | const configPath = path.resolve(htmlPath, "config.js");
45 | const themesPath = path.resolve(htmlPath, "themes");
46 |
47 | console.log("Pre-start: Generating config file…");
48 |
49 | const config = readConfig();
50 |
51 | Object.entries(process.env).forEach(([key, value]) => {
52 | if (key.startsWith("VOCASCAN_")) {
53 | config[key.replace(/^VOCASCAN_/, "")] = convertToNativeType(value);
54 | }
55 | });
56 |
57 | if (fs.existsSync(themesPath) && fs.lstatSync(themesPath).isDirectory()) {
58 | const themes = Object.fromEntries(
59 | fs
60 | .readdirSync(themesPath)
61 | .map((theme) => [
62 | theme.replace(/(themes\/|.css)/g, ""),
63 | `themes/${theme}`,
64 | ])
65 | );
66 | config.themes = { ...config.themes, ...themes };
67 | }
68 |
69 | writeConfig(config);
70 |
71 | console.log(
72 | `Pre-start: ✓ Successfully written config file to "${configPath}".`
73 | );
74 |
--------------------------------------------------------------------------------
/src/screens/Learn/Dashboard/Dashboard.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useCallback, useState } from "react";
2 | import { useTranslation } from "react-i18next";
3 |
4 | import LoadingIndicator from "../../../Components/Indicators/LoadingIndicator/LoadingIndicator.jsx";
5 | import PackageOverview from "../../../Components/PackageOverview/PackageOverview.jsx";
6 |
7 | import useSnack from "../../../hooks/useSnack.js";
8 | import { getPackages } from "../../../utils/api.js";
9 |
10 | import "./Dashboard.scss";
11 |
12 | const Dashboard = () => {
13 | const { showSnack } = useSnack();
14 | const { t } = useTranslation();
15 |
16 | const [languagePackages, setLanguagePackages] = useState([]);
17 | const [isLoading, setIsLoading] = useState(true);
18 |
19 | useEffect(() => {
20 | getLanguagePackages();
21 | // eslint-disable-next-line react-hooks/exhaustive-deps
22 | }, []);
23 |
24 | const getLanguagePackages = useCallback(() => {
25 | setIsLoading(true);
26 | getPackages(false, true)
27 | .then((response) => {
28 | //store stats
29 | setLanguagePackages(response.data);
30 | setIsLoading(false);
31 | })
32 | .catch((event) => {
33 | setIsLoading(false);
34 |
35 | if (event.response?.status === 401 || event.response?.status === 404) {
36 | showSnack("error", "Error fetching stats");
37 | return;
38 | }
39 |
40 | showSnack("error", "Internal Server Error");
41 | });
42 | }, [showSnack]);
43 |
44 | if (isLoading) {
45 | return (
46 |
47 |
48 |
49 | );
50 | }
51 | //if language package array is empty, show empty screen
52 | else if (languagePackages.length === 0) {
53 | return (
54 |
55 |
{t("screens.dashboard.empty")}
56 |
57 | );
58 | } else {
59 | return (
60 |
61 |
62 | {languagePackages.map((languagePackage, index) => (
63 |
64 | ))}
65 |
66 |
67 | );
68 | }
69 | };
70 |
71 | export default Dashboard;
72 |
--------------------------------------------------------------------------------
/src/screens/Learn/End/End.jsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo, useState } from "react";
2 | import { useTranslation } from "react-i18next";
3 | import { useSelector } from "react-redux";
4 | import { useHistory } from "react-router-dom";
5 |
6 | import Button from "../../../Components/Button/Button.jsx";
7 | import Congratulation from "../../../Components/Congratulation/Congratulation.jsx";
8 | import Table from "../../../Components/Table/Table.jsx";
9 |
10 | import "./End.scss";
11 |
12 | const End = () => {
13 | const correctVocabs = useSelector((state) => state.query.correct);
14 | const wrongVocabs = useSelector((state) => state.query.wrong);
15 | const [percentage] = useState(
16 | ((correctVocabs / (correctVocabs + wrongVocabs)) * 100).toFixed(0)
17 | );
18 | const history = useHistory();
19 | const { t } = useTranslation();
20 |
21 | const submitEndQuery = () => {
22 | history.push(`/learn/`);
23 | };
24 |
25 | const columns = useMemo(
26 | () => [
27 | {
28 | Header: "",
29 | accessor: "query", // accessor is the "key" in the data
30 | },
31 | {
32 | Header: t("global.total"),
33 | accessor: "total",
34 | },
35 | {
36 | Header: t("global.correct"),
37 | accessor: "correct",
38 | },
39 | {
40 | Header: t("global.wrong"),
41 | accessor: "wrong",
42 | },
43 | ],
44 | [t]
45 | );
46 |
47 | const data = useMemo(
48 | () => [
49 | {
50 | query: t("global.query"),
51 | total: correctVocabs + wrongVocabs,
52 | correct: correctVocabs,
53 | wrong: wrongVocabs,
54 | },
55 | ],
56 | [correctVocabs, t, wrongVocabs]
57 | );
58 | return (
59 |
60 |
61 |
62 |
63 | {t("screens.endScreen.percentageText", {
64 | percentage: percentage,
65 | })}
66 |
67 |
68 |
69 |
72 |
73 |
74 | );
75 | };
76 |
77 | export default End;
78 |
--------------------------------------------------------------------------------
/src/Components/SelectionBox/SelectionBox.jsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from "react";
2 | import { useHistory } from "react-router-dom";
3 |
4 | import CheckIcon from "@material-ui/icons/Check";
5 | import CloseIcon from "@material-ui/icons/Close";
6 |
7 | import Button from "../Button/Button.jsx";
8 |
9 | import "./SelectionBox.scss";
10 |
11 | const Item = ({ Icon, text }) => (
12 |
13 |
14 | {text}
15 |
16 | );
17 |
18 | const SelectionBox = ({
19 | onSubmit,
20 | pro,
21 | contra,
22 | heading,
23 | image,
24 | buttonText,
25 | disabled = false,
26 | important = false,
27 | }) => {
28 | const history = useHistory();
29 |
30 | const handleClick = useCallback(() => {
31 | onSubmit();
32 | history.push("/login");
33 | }, [history, onSubmit]);
34 |
35 | return (
36 |
37 |
38 |
43 |
{heading}
44 |
45 |
46 |

51 |
52 |
53 |
54 |
55 | {(typeof pro === "string" ? [pro] : pro).map((text, i) => (
56 |
57 | ))}
58 | {(typeof contra === "string" ? [contra] : contra).map((text, i) => (
59 |
60 | ))}
61 |
62 |
63 |
64 |
72 |
73 |
74 | );
75 | };
76 |
77 | export default SelectionBox;
78 |
--------------------------------------------------------------------------------
/src/Components/Form/Textarea/Textarea.scss:
--------------------------------------------------------------------------------
1 | @import "../../../colors";
2 |
3 | .text-area-wrapper {
4 | position: relative;
5 | display: flex;
6 | flex-direction: column;
7 | align-items: flex-end;
8 | justify-content: flex-end;
9 | width: 100%;
10 | margin: 20px 0;
11 |
12 | .text-area {
13 | display: block;
14 | width: 100%;
15 | padding: 0;
16 | color: $color-main-text;
17 |
18 | text-align: left;
19 | text-align: left;
20 | resize: vertical;
21 | resize: vertical;
22 | background: linear-gradient(to bottom, rgba(255, 255, 255, 0) calc(100% - 2px), $color-primary 2px);
23 | background-repeat: no-repeat;
24 | background-repeat: no-repeat;
25 | background-position: -9999px 0;
26 | background-size: 100% 100%;
27 | border: unset;
28 | border-bottom: solid 1px $color-alternative;
29 |
30 | &:focus {
31 | background-position: 0 0;
32 | border-bottom-color: $color-primary;
33 | outline: none;
34 | box-shadow: none;
35 | }
36 |
37 | &::placeholder {
38 | transition: all 0.2s cubic-bezier(0.64, 0.09, 0.08, 1);
39 | }
40 |
41 | &:focus::placeholder,
42 | &:valid::placeholder {
43 | font-size: 11px;
44 | color: $color-primary;
45 | visibility: visible !important;
46 | transform: translateY(-20px);
47 | }
48 |
49 | &:hover {
50 | background-position: 0 0;
51 | border-bottom-color: $color-primary;
52 | outline: none;
53 | box-shadow: none;
54 | }
55 | }
56 |
57 | .text-area-label {
58 | position: absolute;
59 | top: 24px;
60 | left: 0;
61 | font-size: 13px;
62 | color: $color-main-text-light;
63 | pointer-events: none;
64 | transition: all 0.2s cubic-bezier(0.64, 0.09, 0.08, 1);
65 |
66 | &.flow {
67 | top: -12px;
68 | font-size: 11px;
69 | color: $color-primary;
70 | transition: all 0.2s cubic-bezier(0.64, 0.09, 0.08, 1);
71 | }
72 | }
73 |
74 | .text-area-error {
75 | position: absolute;
76 | bottom: -13px;
77 | font-size: 11px;
78 | color: $color-red;
79 | text-align: left;
80 | }
81 |
82 | .text-area-indicator {
83 | position: absolute;
84 | bottom: -13px;
85 | bottom: -13px;
86 | font-size: 11px;
87 | color: $color-primary;
88 | text-align: left;
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/src/Components/InviteCode/InviteCode.jsx:
--------------------------------------------------------------------------------
1 | import clsx from "clsx";
2 | import React, { useCallback, useMemo } from "react";
3 | import { useTranslation } from "react-i18next";
4 |
5 | import RemoveCircleOutlineIcon from "@material-ui/icons/RemoveCircleOutline";
6 |
7 | import CountdownTimer from "../Timer/CountdownTimer.jsx";
8 |
9 | import useSnack from "../../hooks/useSnack.js";
10 | import { copyToClip } from "../../modules/clipboard.js";
11 |
12 | import "./InviteCode.scss";
13 |
14 | const InviteCode = ({ data, onDelete }) => {
15 | const { t } = useTranslation();
16 |
17 | const { showSnack } = useSnack();
18 |
19 | const isUsesValid = useMemo(
20 | () => data.uses < (data.maxUses || Infinity),
21 | [data]
22 | );
23 | const isDateValid = useMemo(
24 | () => (new Date(data.expirationDate).getTime() || Infinity) > new Date(),
25 | [data]
26 | );
27 |
28 | const copyToClipCb = useCallback(() => {
29 | try {
30 | copyToClip({ text: data.code }).then(() => {
31 | showSnack("success", t("components.inviteCode.copyToClip"));
32 | });
33 | } catch {
34 | showSnack("error", t("components.inviteCode.copyToClipFailed"));
35 | }
36 | }, [data.code, showSnack, t]);
37 |
38 | return (
39 |
44 |
45 |
46 |
47 |
50 |
51 |
52 |
53 | {t("components.inviteCode.uses")}
54 | {`${
55 | data.uses
56 | } / ${data.maxUses ? data.maxUses : "∞"}`}
57 |
58 |
59 | {t("components.inviteCode.expirationDate")}
60 | {data.expirationDate ? (
61 |
62 | ) : (
63 |
64 | {t("components.inviteCode.never")}
65 |
66 | )}
67 |
68 |
69 |
70 | );
71 | };
72 |
73 | export default InviteCode;
74 |
--------------------------------------------------------------------------------
/src/screens/Auth/Login/Login.scss:
--------------------------------------------------------------------------------
1 | @import "../../../colors";
2 | @import "../../../constants";
3 |
4 | .login-form {
5 | position: relative;
6 | display: flex;
7 | flex-direction: column;
8 | justify-content: space-around;
9 | width: 90%;
10 | margin: auto;
11 | background: $color-background;
12 | border-radius: 10px;
13 | box-shadow: -1px 3px 5px $color-box-shadow-light;
14 |
15 | @media screen and (min-width: $bp-md) {
16 | max-width: 300px;
17 | max-height: 550px;
18 | padding: 40px 100px;
19 | }
20 |
21 | .back-icon {
22 | position: absolute;
23 | top: 20px;
24 | left: 20px;
25 | color: $color-primary;
26 |
27 | &:hover {
28 | color: $color-primary-dark;
29 | cursor: pointer;
30 | }
31 | }
32 |
33 | .header {
34 | width: 90%;
35 | height: 33%;
36 | margin: 40px auto 0;
37 |
38 | @media screen and (min-width: $bp-md) {
39 | width: 100%;
40 | margin: 0 auto;
41 | }
42 |
43 | .header-logo {
44 | width: 100px;
45 | height: auto;
46 | margin-bottom: 10px;
47 | }
48 |
49 | .login-form-header-heading {
50 | text-transform: uppercase;
51 | }
52 | }
53 |
54 | .form-input {
55 | display: flex;
56 | flex-direction: column;
57 | justify-content: space-around;
58 | width: 90%;
59 | min-width: 250px;
60 | margin: 30px auto;
61 |
62 | @media screen and (min-width: $bp-md) {
63 | width: 80%;
64 | }
65 |
66 | .form-error {
67 | height: 27px;
68 | margin-top: -3px;
69 | font-size: 11px;
70 | color: $color-red;
71 | text-align: right;
72 | }
73 | }
74 |
75 | .login-footer {
76 | display: flex;
77 | flex-direction: column;
78 | justify-content: center;
79 | width: 90%;
80 | margin: 20px 0;
81 | margin: 20px auto;
82 |
83 | @media screen and (min-width: $bp-md) {
84 | width: 80%;
85 | }
86 |
87 | .submit-register {
88 | display: flex;
89 | flex-direction: row;
90 | justify-content: center;
91 | margin-top: 12px;
92 | font-size: 14px;
93 |
94 | .submit-register-link {
95 | margin-left: 5px;
96 | color: $color-primary;
97 | cursor: pointer;
98 | }
99 | }
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/src/Components/Modal/Modal.jsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useEffect, useRef } from "react";
2 |
3 | import CloseIcon from "@material-ui/icons/Close";
4 |
5 | import Button from "../Button/Button.jsx";
6 |
7 | import "./Modal.scss";
8 |
9 | const Modal = ({
10 | title,
11 | onClose,
12 | size = "", // small, large, maxed, ""
13 | open = false,
14 | renderClose = true,
15 | closeOnEscape = true,
16 | closeOnClickOutside = false,
17 | children,
18 | }) => {
19 | const ref = useRef(null);
20 |
21 | const escapeListener = useCallback(
22 | (e) => {
23 | if (e.key === "Escape") {
24 | closeOnEscape && onClose?.();
25 | }
26 | },
27 | // Modal specific dependency
28 | // eslint-disable-next-line react-hooks/exhaustive-deps
29 | []
30 | );
31 |
32 | const clickListener = useCallback(
33 | (e) => {
34 | if (!ref.current?.contains(e.target)) {
35 | onClose?.();
36 | }
37 | },
38 | // Modal specific dependency
39 | // eslint-disable-next-line react-hooks/exhaustive-deps
40 | []
41 | );
42 |
43 | useEffect(() => {
44 | if (closeOnClickOutside) {
45 | document.addEventListener("click", clickListener);
46 |
47 | return () => {
48 | document.removeEventListener("click", clickListener);
49 | };
50 | }
51 | // Modal specific dependency
52 | // eslint-disable-next-line react-hooks/exhaustive-deps
53 | }, [closeOnClickOutside]);
54 |
55 | useEffect(() => {
56 | document.addEventListener("keyup", escapeListener);
57 | return () => {
58 | document.removeEventListener("keyup", escapeListener);
59 | };
60 | // Modal specific dependency
61 | // eslint-disable-next-line react-hooks/exhaustive-deps
62 | }, []);
63 |
64 | if (!open) {
65 | return null;
66 | }
67 |
68 | return (
69 |
70 |
71 | {renderClose && (
72 |
81 | )}
82 | {title &&
{title}
}
83 | {children}
84 |
85 |
86 | );
87 | };
88 |
89 | export default Modal;
90 |
--------------------------------------------------------------------------------
/src/utils/index.js:
--------------------------------------------------------------------------------
1 | /* Scale a value from one range to another
2 | * Example of use:
3 | *
4 | * // Convert 33 from a 0-100 range to a 0-65535 range
5 | * var n = scaleValue(33, [0,100], [0,65535]);
6 | *
7 | * // Ranges don't have to be positive
8 | * var n = scaleValue(0, [-50,+50], [0,65535]);
9 | *
10 | * Ranges are defined as arrays of two values, inclusive
11 | *
12 | * The ~~ trick on return value does the equivalent of Math.floor, just faster.
13 | *
14 | * See: https://gist.github.com/fpillet/993002
15 | */
16 | export const scaleValue = (value, from, to) => {
17 | var scale = (to[1] - to[0]) / (from[1] - from[0]);
18 | var capped = Math.min(from[1], Math.max(from[0], value)) - from[0];
19 | return ~~(capped * scale + to[0]);
20 | };
21 | /**
22 | * Format a language object
23 | * @param {Object} language Language object
24 | * @returns {String} Language string
25 | */
26 | export const getLanguageString = (language, nativeNames = true) =>
27 | nativeNames
28 | ? `${language?.nativeNames?.[0]} (${language?.name})`
29 | : `${language?.name}`;
30 |
31 | /**
32 | *
33 | * @param {String} language language code
34 | * @param {Array} languages Array of all languages from server
35 | * @returns {Object} language object
36 | */
37 | export const findLanguageByCode = (language, languages) =>
38 | languages.find((lang) => language === lang.code);
39 |
40 | /**
41 | *
42 | * @param {Integer} ms milliseconds to wait
43 | * @returns {Promise} Promise object
44 | */
45 | export const delay = (ms) => new Promise((res) => setTimeout(res, ms));
46 |
47 | /**
48 | * Calculate day difference between two dates
49 | * See: https://www.geeksforgeeks.org/how-to-calculate-the-number-of-days-between-two-dates-in-javascript/
50 | * @param {Date} date1 first date
51 | * @param {Date} date2 second date
52 | * @returns {Number}
53 | */
54 | export const dayDateDiff = (date1, date2) => {
55 | const timeDiff = date2.getTime() - date1.getTime();
56 |
57 | return Math.floor(timeDiff / (1000 * 60 * 60 * 24));
58 | };
59 |
60 | /**
61 | * Return the length of a string in bytes
62 | * See: https://stackoverflow.com/questions/5515869/string-length-in-bytes-in-javascript
63 | * @param {String} string string
64 | * @returns bytesLength
65 | */
66 | export const bytesLength = (string) => new TextEncoder().encode(string).length;
67 |
68 | export const prefersDarkTheme = () =>
69 | window.matchMedia?.("(prefers-color-scheme: dark)").matches;
70 |
--------------------------------------------------------------------------------
/src/Components/Form/Switch/Switch.scss:
--------------------------------------------------------------------------------
1 | @import "../../../colors";
2 |
3 | .switch-wrapper {
4 | display: flex;
5 | flex-direction: column;
6 | align-items: flex-start;
7 | justify-content: flex-start;
8 | margin: 15px 0;
9 |
10 | .label-top {
11 | margin-bottom: 10px;
12 | font-weight: 400;
13 | }
14 |
15 | .info-sign {
16 | position: relative;
17 | display: flex;
18 | align-items: center;
19 | margin: 0 -15px;
20 | color: $color-primary-light;
21 |
22 | &:hover {
23 | cursor: help;
24 | }
25 | }
26 | }
27 |
28 | .switch-wrapper-inner {
29 | display: flex;
30 | align-items: center;
31 | justify-content: space-between;
32 | width: 100%;
33 |
34 | .label-wrapper {
35 | display: flex;
36 | align-items: center;
37 | justify-content: center;
38 | }
39 |
40 | &.switcher-left {
41 | justify-content: flex-start;
42 | }
43 |
44 | .switch {
45 | position: relative;
46 | display: inline-block;
47 | width: 50px;
48 | height: 28px;
49 |
50 | &.disabled {
51 | pointer-events: none;
52 | opacity: 0.3;
53 | }
54 | }
55 |
56 | .switch input {
57 | width: 0;
58 | height: 0;
59 | opacity: 0;
60 | }
61 |
62 | .slider {
63 | position: absolute;
64 | top: 0;
65 | right: 0;
66 | bottom: 0;
67 | left: 0;
68 | cursor: pointer;
69 | background-color: $color-background-inverse;
70 | border-radius: 34px;
71 | transition: 0.4s;
72 |
73 | &::before {
74 | border-radius: 50%;
75 | }
76 |
77 | &.slider-on-off {
78 | background-color: $color-red;
79 | }
80 | }
81 |
82 | .slider::before {
83 | position: absolute;
84 | bottom: 4px;
85 | left: 4px;
86 | width: 20px;
87 | height: 20px;
88 | content: "";
89 | background-color: $color-white;
90 | transition: 0.4s;
91 | }
92 |
93 | input {
94 | &:checked + .slider-default {
95 | background-color: $color-primary;
96 | }
97 |
98 | &:checked + .slider-on-off {
99 | background-color: $color-green;
100 | }
101 |
102 | &:checked + .slider-default::before,
103 | &:checked + .slider-on-off::before {
104 | transform: translateX(22px);
105 | }
106 | }
107 |
108 | .label-left {
109 | margin-right: 20px;
110 | }
111 |
112 | .label-right {
113 | margin-left: 20px;
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/src/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/Components/Nav/MobileTopNav.scss:
--------------------------------------------------------------------------------
1 | @import "../../colors";
2 | @import "../../constants";
3 |
4 | .mtn {
5 | position: fixed;
6 | top: 0;
7 | left: 0;
8 | z-index: 50;
9 | display: flex;
10 | width: 100%;
11 | height: 64px;
12 | overflow: hidden;
13 | opacity: 100;
14 | transition: all 0.7s ease-in-out;
15 |
16 | &.transparent {
17 | background: transparent;
18 | }
19 |
20 | &.hidden {
21 | display: none;
22 | }
23 |
24 | @media screen and (min-width: $bp-md) {
25 | display: none;
26 | }
27 |
28 | .mtn-inner {
29 | display: flex;
30 | flex-direction: row;
31 | align-items: center;
32 | justify-content: space-between;
33 | width: 90%;
34 | height: 100%;
35 | margin: auto;
36 | background: transparent;
37 |
38 | .mtn-link {
39 | text-decoration: none;
40 |
41 | .mtn-link-inner {
42 | display: flex;
43 | align-items: center;
44 | text-decoration: none;
45 |
46 | img {
47 | width: 32px;
48 | }
49 |
50 | p {
51 | margin-left: 4px;
52 | font-size: 16px;
53 | color: $color-main-text;
54 | }
55 | }
56 | }
57 |
58 | .hamburger-menu {
59 | font-size: 40px;
60 | fill: $color-main-text;
61 | }
62 | }
63 | }
64 |
65 | .mtn-menu {
66 | position: relative;
67 | top: 0;
68 | left: 0;
69 | z-index: 10;
70 | display: flex;
71 | flex-direction: column;
72 | align-items: center;
73 | justify-content: center;
74 | width: 100%;
75 | height: 0;
76 | overflow: hidden;
77 | transition: all 0.7s ease-in-out;
78 | transition: height 0.2s ease-out;
79 |
80 | @media screen and (min-width: $bp-md) {
81 | display: none;
82 | }
83 |
84 | &.open {
85 | height: 100%;
86 | }
87 |
88 | &.closed {
89 | z-index: -10;
90 | }
91 |
92 | .mtn-menu-list {
93 | display: flex;
94 | flex-direction: column;
95 | width: 100%;
96 | height: 90%;
97 | padding-top: 64px;
98 | }
99 |
100 | .mtn-external-link-wrapper {
101 | margin: 8px auto;
102 | font-size: 16px;
103 | color: $color-main-text-inverse;
104 | opacity: 0;
105 |
106 | .mtn-external-link {
107 | display: flex;
108 | align-items: center;
109 | margin: 4px 0;
110 |
111 | a {
112 | margin-left: 4px;
113 | color: $color-main-text-inverse;
114 | text-decoration: none;
115 | }
116 | }
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/src/colors.scss:
--------------------------------------------------------------------------------
1 | $color-background-inverse: var(--color-background-inverse);
2 | $color-background-muted: var(--color-background-muted);
3 | $color-background: var(--color-background);
4 | $color-alternative: var(--color-alternative);
5 | $color-form: var(--color-form);
6 | $color-box-shadow: var(--color-box-shadow);
7 | $color-box-shadow-light: var(--color-box-shadow-light);
8 | $color-main-text-light: var(--color-main-text-light);
9 |
10 | $color-main-text: var(--color-main-text);
11 | $color-main-text-90: var(--color-main-text-90);
12 | $color-main-text-80: var(--color-main-text-80);
13 | $color-main-text-70: var(--color-main-text-70);
14 | $color-main-text-60: var(--color-main-text-60);
15 | $color-main-text-50: var(--color-main-text-50);
16 | $color-main-text-40: var(--color-main-text-40);
17 | $color-main-text-30: var(--color-main-text-30);
18 | $color-main-text-20: var(--color-main-text-20);
19 | $color-main-text-10: var(--color-main-text-10);
20 |
21 | $color-main-text-inverse: var(--color-main-text-inverse);
22 |
23 | $color-primary-light: var(--color-primary-light);
24 |
25 | $color-primary: var(--color-primary);
26 | $color-primary-90: var(--color-primary-90);
27 | $color-primary-80: var(--color-primary-80);
28 | $color-primary-70: var(--color-primary-70);
29 | $color-primary-60: var(--color-primary-60);
30 | $color-primary-50: var(--color-primary-50);
31 | $color-primary-40: var(--color-primary-40);
32 | $color-primary-30: var(--color-primary-30);
33 | $color-primary-20: var(--color-primary-20);
34 | $color-primary-10: var(--color-primary-10);
35 |
36 | $color-primary-dark: var(--color-primary-dark);
37 | $color-primary-dark-90: var(--color-primary-dark-90);
38 | $color-primary-dark-80: var(--color-primary-dark-80);
39 | $color-primary-dark-70: var(--color-primary-dark-70);
40 | $color-primary-dark-60: var(--color-primary-dark-60);
41 | $color-primary-dark-50: var(--color-primary-dark-50);
42 | $color-primary-dark-40: var(--color-primary-dark-40);
43 | $color-primary-dark-30: var(--color-primary-dark-30);
44 | $color-primary-dark-20: var(--color-primary-dark-20);
45 | $color-primary-dark-10: var(--color-primary-dark-10);
46 |
47 | $color-white: var(--color-white);
48 | $color-dark: var(--color-dark);
49 | $color-grey: var(--color-grey);
50 | $color-red-light: var(--color-red-light);
51 | $color-red: var(--color-red);
52 | $color-red-dark: var(--color-red-dark);
53 | $color-yellow: var(--color-yellow);
54 | $color-yellow-dark: var(--color-yellow-dark);
55 | $color-green-light: var(--color-green-light);
56 | $color-green: var(--color-green);
57 | $color-green-dark: var(--color-green-dark);
58 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vocascan-frontend",
3 | "version": "1.3.0",
4 | "private": true,
5 | "description": "A highly configurable vocabulary trainer",
6 | "author": "vocascan ",
7 | "repository": {
8 | "type": "git",
9 | "url": "https://github.com/vocascan/vocascan-frontend.git"
10 | },
11 | "homepage": "./",
12 | "scripts": {
13 | "start": "react-scripts start",
14 | "build": "react-scripts build",
15 | "eject": "react-scripts eject",
16 | "test": "npm run lint",
17 | "lint": "npm run lint:js && npm run lint:style",
18 | "lint:fix": "npm run lint:js:fix && npm run lint:style:fix",
19 | "lint:js": "eslint .",
20 | "lint:js:fix": "eslint . --fix",
21 | "lint:style": "stylelint \"src/**/*.scss\"",
22 | "lint:style:fix": "stylelint --fix \"src/**/*.scss\""
23 | },
24 | "browserslist": {
25 | "production": [
26 | ">0.2%",
27 | "not dead",
28 | "not op_mini all"
29 | ],
30 | "development": [
31 | "last 1 chrome version",
32 | "last 1 firefox version",
33 | "last 1 safari version"
34 | ]
35 | },
36 | "dependencies": {
37 | "@fontsource/roboto": "^4.5.1",
38 | "@material-ui/core": "^4.11.2",
39 | "@material-ui/icons": "^4.11.2",
40 | "@testing-library/jest-dom": "^5.11.9",
41 | "@testing-library/react": "^11.2.5",
42 | "@testing-library/user-event": "^12.8.3",
43 | "axios": "^0.21.1",
44 | "clsx": "^1.1.1",
45 | "deepmerge": "^4.2.2",
46 | "i18next": "^21.3.3",
47 | "is-electron": "^2.2.1",
48 | "react": "^17.0.1",
49 | "react-collapsible": "^2.8.3",
50 | "react-cookie-consent": "^7.2.1",
51 | "react-countdown": "^2.3.2",
52 | "react-dom": "^17.0.1",
53 | "react-i18next": "^11.12.0",
54 | "react-redux": "^7.2.2",
55 | "react-router-dom": "^5.2.0",
56 | "react-scripts": "4.0.3",
57 | "react-select": "^4.3.0",
58 | "react-table": "^7.6.3",
59 | "react-tooltip": "^4.2.17",
60 | "redux": "^4.0.5",
61 | "redux-localstore": "^1.0.0",
62 | "sass": "^1.29.0",
63 | "sass-loader": "^10.1.0",
64 | "semver": "^6.3.0",
65 | "swiper": "^8.2.2",
66 | "uniqid": "^5.3.0",
67 | "web-vitals": "^1.1.0"
68 | },
69 | "devDependencies": {
70 | "@trivago/prettier-plugin-sort-imports": "^3.1.1",
71 | "eslint": "^7.27.0",
72 | "eslint-config-prettier": "^8.3.0",
73 | "eslint-plugin-prettier": "^3.4.0",
74 | "prettier": "^2.5.1",
75 | "stylelint": "^14.7.1",
76 | "stylelint-config-recess-order": "^3.0.0",
77 | "stylelint-config-sass-guidelines": "^9.0.1",
78 | "stylelint-order": "^5.0.0"
79 | },
80 | "metadata": {}
81 | }
82 |
--------------------------------------------------------------------------------
/src/Components/Form/TextInput/TextInput.scss:
--------------------------------------------------------------------------------
1 | @import "../../../colors";
2 |
3 | .text-input-wrapper {
4 | position: relative;
5 | display: flex;
6 | flex-direction: column;
7 | align-items: flex-end;
8 | justify-content: flex-end;
9 | width: 100%;
10 | height: 50px;
11 | margin: 5px 0;
12 |
13 | .text-input {
14 | display: block;
15 | width: 100%;
16 | height: 32px;
17 | padding-top: 10px;
18 | color: $color-main-text;
19 |
20 | text-align: left;
21 | background: linear-gradient(to bottom, rgba(255, 255, 255, 0) calc(100% - 2px), $color-primary 2px);
22 | background-repeat: no-repeat;
23 | background-position: -9999px 0;
24 | background-size: 100% 100%;
25 | border: unset;
26 | border-bottom: solid 1px $color-alternative;
27 | transition: all 0.2s cubic-bezier(0.64, 0.09, 0.08, 1);
28 |
29 | &:focus {
30 | background-position: 0 0;
31 | border-bottom-color: $color-primary;
32 | outline: none;
33 | box-shadow: none;
34 | }
35 |
36 | &::placeholder {
37 | transition: all 0.2s cubic-bezier(0.64, 0.09, 0.08, 1);
38 | }
39 |
40 | &:focus::placeholder,
41 | &:valid::placeholder {
42 | font-size: 11px;
43 | color: $color-primary;
44 | visibility: visible !important;
45 | transform: translateY(-20px);
46 | }
47 |
48 | &:hover {
49 | background-position: 0 0;
50 | border-bottom-color: $color-primary;
51 | outline: none;
52 | box-shadow: none;
53 | }
54 | }
55 |
56 | .text-input-label {
57 | position: absolute;
58 | top: 24px;
59 | left: 0;
60 | font-size: 13px;
61 | color: $color-main-text-light;
62 | pointer-events: none;
63 | transition: all 0.2s cubic-bezier(0.64, 0.09, 0.08, 1);
64 |
65 | &.flow {
66 | top: 6px;
67 | font-size: 11px;
68 | color: $color-primary;
69 | transition: all 0.2s cubic-bezier(0.64, 0.09, 0.08, 1);
70 | }
71 | }
72 |
73 | .text-input-error {
74 | position: absolute;
75 | bottom: -13px;
76 | font-size: 11px;
77 | color: $color-red;
78 | text-align: left;
79 | }
80 |
81 | .text-input-indicator {
82 | position: absolute;
83 | bottom: -13px;
84 | font-size: 11px;
85 | color: $color-primary;
86 | }
87 |
88 | .show-password {
89 | position: absolute;
90 | color: $color-primary-light;
91 | transition: color 0.2s ease-in-out;
92 |
93 | &:hover {
94 | color: $color-primary;
95 | cursor: pointer;
96 | transition: color 0.2s ease-in-out;
97 | }
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/src/Components/DirectionBox/DirectionBox.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useTranslation } from "react-i18next";
3 | import { useSelector } from "react-redux";
4 | import { useHistory } from "react-router-dom";
5 |
6 | import ArrowRightAltIcon from "@material-ui/icons/ArrowRightAlt";
7 | import SyncAltIcon from "@material-ui/icons/SyncAlt";
8 |
9 | import Flag from "../Flag/Flag.jsx";
10 |
11 | import { findLanguageByCode, getLanguageString } from "../../utils/index.js";
12 |
13 | import "./DirectionBox.scss";
14 |
15 | const DirectionBox = ({
16 | direction = "default",
17 | foreignWordLanguage,
18 | translatedWordLanguage,
19 | }) => {
20 | const { t } = useTranslation();
21 | const history = useHistory();
22 |
23 | const languages = useSelector((state) => state.language.languages);
24 |
25 | const submitDirection = () => {
26 | history.push(`/learn/query/${direction}`);
27 | };
28 |
29 | return (
30 |
31 |
32 |
33 | {direction === "random" ? (
34 | <>
35 |
36 |
37 |
38 | >
39 | ) : direction === "backwards" ? (
40 | <>
41 |
42 |
43 |
44 | >
45 | ) : (
46 | <>
47 |
48 |
49 |
50 | >
51 | )}
52 |
53 |
54 | {direction === "random"
55 | ? t("global.random")
56 | : direction === "backwards"
57 | ? `${getLanguageString(
58 | findLanguageByCode(translatedWordLanguage, languages)
59 | )} - ${getLanguageString(
60 | findLanguageByCode(foreignWordLanguage, languages)
61 | )}`
62 | : `${getLanguageString(
63 | findLanguageByCode(foreignWordLanguage, languages)
64 | )} - ${getLanguageString(
65 | findLanguageByCode(translatedWordLanguage, languages)
66 | )}`}
67 |
68 |
69 |
70 | );
71 | };
72 |
73 | export default DirectionBox;
74 |
--------------------------------------------------------------------------------
/src/Components/StatsTable/StatsTable.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState, useCallback, useMemo } from "react";
2 | import { useTranslation } from "react-i18next";
3 |
4 | import Table from "../Table/Table.jsx";
5 |
6 | import useSnack from "../../hooks/useSnack.js";
7 | import { getStats } from "../../utils/api.js";
8 |
9 | const StatsTable = () => {
10 | const { t } = useTranslation();
11 | const { showSnack } = useSnack();
12 |
13 | const [stats, setStats] = useState({});
14 |
15 | useEffect(() => {
16 | getProfileStats();
17 | // eslint-disable-next-line react-hooks/exhaustive-deps
18 | }, []);
19 |
20 | //make api call to login
21 | const getProfileStats = useCallback(() => {
22 | getStats()
23 | .then((response) => {
24 | //store stats in variables
25 | setStats(response.data);
26 | })
27 | .catch((event) => {
28 | if (event.response?.status === 401 || event.response?.status === 404) {
29 | showSnack("error", "Error fetching stats");
30 | return;
31 | }
32 |
33 | showSnack("error", "Internal Server Error");
34 | });
35 | }, [showSnack]);
36 |
37 | const columns = useMemo(
38 | () => [
39 | {
40 | Header: t("screens.profile.stats.stats"),
41 | accessor: "stats", // accessor is the "key" in the data
42 | },
43 | {
44 | Header: t("global.packages"),
45 | accessor: "packages",
46 | },
47 | {
48 | Header: t("global.groups"),
49 | accessor: "groups",
50 | },
51 | {
52 | Header: t("global.vocabs"),
53 | accessor: "vocabs",
54 | },
55 | ],
56 | [t]
57 | );
58 |
59 | const data = useMemo(
60 | () => [
61 | {
62 | stats: t("screens.profile.stats.total"),
63 | packages: stats?.languagePackages?.all || "-",
64 | groups: stats?.groups?.all || "-",
65 | vocabs: stats?.vocabularies?.all || "-",
66 | },
67 | {
68 | stats: t("screens.profile.stats.active"),
69 | packages: "-",
70 | groups: stats?.groups?.active || "-",
71 | vocabs: stats?.vocabularies?.active || "-",
72 | },
73 | {
74 | stats: t("screens.profile.stats.inactive"),
75 | packages: "-",
76 | groups: stats?.groups?.inactive || "-",
77 | vocabs: stats?.vocabularies?.inactive || "-",
78 | },
79 | ],
80 | [
81 | stats?.groups?.active,
82 | stats?.groups?.all,
83 | stats?.groups?.inactive,
84 | stats?.languagePackages?.all,
85 | stats?.vocabularies?.active,
86 | stats?.vocabularies?.all,
87 | stats?.vocabularies?.inactive,
88 | t,
89 | ]
90 | );
91 |
92 | return ;
93 | };
94 |
95 | export default StatsTable;
96 |
--------------------------------------------------------------------------------
/src/screens/Auth/Register/Register.scss:
--------------------------------------------------------------------------------
1 | @import "../../../colors";
2 | @import "../../../constants";
3 |
4 | .register-form {
5 | position: relative;
6 | display: flex;
7 | flex-direction: column;
8 | justify-content: space-around;
9 | width: 90%;
10 | margin: 80px auto;
11 | background: $color-background;
12 | border-radius: 10px;
13 | box-shadow: -1px 3px 5px $color-box-shadow-light;
14 |
15 | @media screen and (min-width: $bp-md) {
16 | max-width: 300px;
17 | max-height: 750px;
18 | padding: 40px 100px;
19 | margin: auto;
20 | }
21 |
22 | .back-icon {
23 | position: absolute;
24 | top: 20px;
25 | left: 20px;
26 | color: $color-primary;
27 |
28 | &:hover {
29 | color: $color-primary-dark;
30 | cursor: pointer;
31 | }
32 | }
33 |
34 | .register-form-header {
35 | width: 90%;
36 | height: 33%;
37 | margin: 40px auto 0;
38 |
39 | @media screen and (min-width: $bp-md) {
40 | width: 100%;
41 | margin: 0 auto;
42 | }
43 |
44 | .register-form-header-logo {
45 | width: 100px;
46 | height: auto;
47 | margin-bottom: 10px;
48 | }
49 |
50 | .register-form-header-heading {
51 | text-transform: uppercase;
52 | }
53 | }
54 |
55 | .register-form-input {
56 | display: flex;
57 | flex-direction: column;
58 | justify-content: space-around;
59 | width: 90%;
60 | min-width: 250px;
61 | margin: 30px auto;
62 |
63 | @media screen and (min-width: $bp-md) {
64 | width: 100%;
65 | }
66 |
67 | .form-error {
68 | padding-top: 10px;
69 | font-size: 11px;
70 | color: $color-red;
71 | text-align: left;
72 | }
73 |
74 | .checkbox-wrapper {
75 | margin: 3px 0;
76 | font-size: 14px;
77 | text-align: left;
78 |
79 | .label {
80 | margin-left: 5px;
81 |
82 | a {
83 | color: $color-primary;
84 | text-decoration: none;
85 | }
86 |
87 | a:hover {
88 | text-decoration: underline;
89 | }
90 | }
91 | }
92 | }
93 |
94 | .register-form-submit {
95 | display: flex;
96 | flex-direction: column;
97 | justify-content: center;
98 | width: 90%;
99 | margin: 0 auto 20px;
100 |
101 | @media screen and (min-width: $bp-md) {
102 | width: 100%;
103 | }
104 |
105 | .register-form-submit-register {
106 | display: flex;
107 | flex-direction: row;
108 | justify-content: center;
109 | margin-top: 12px;
110 | font-size: 14px;
111 |
112 | .register-form-submit-register-link {
113 | margin-left: 5px;
114 | color: $color-primary;
115 | cursor: pointer;
116 | }
117 | }
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/src/Components/Indicators/InviteCodeValidIndicator/InviteCodeValidIndicator.jsx:
--------------------------------------------------------------------------------
1 | import { CancelToken } from "axios";
2 | import React, { useEffect, useState, useRef } from "react";
3 | import { useTranslation } from "react-i18next";
4 |
5 | import LoadingIndicator from "../LoadingIndicator/LoadingIndicator.jsx";
6 |
7 | import useDebounce from "../../../hooks/useDebounce.js";
8 | import { checkInviteCode } from "../../../utils/api.js";
9 |
10 | import "./InviteCodeValidIndicator.scss";
11 |
12 | const ServerValidIndicator = ({ inviteCode, setValid }) => {
13 | const [isLoading, setIsLoading] = useState(true);
14 | const [inviteCodeState, setInviteCodeState] = useState(null);
15 |
16 | const debouncedInviteCode = useDebounce(inviteCode, 500);
17 | const timer = useRef(null);
18 |
19 | const { t } = useTranslation();
20 |
21 | useEffect(() => {
22 | setIsLoading(true);
23 | setInviteCodeState(null);
24 | }, [inviteCode]);
25 |
26 | useEffect(() => {
27 | setInviteCodeState(null);
28 |
29 | if (debouncedInviteCode === "") {
30 | setIsLoading(false);
31 | return;
32 | }
33 |
34 | setIsLoading(true);
35 |
36 | const cancelToken = CancelToken.source();
37 |
38 | checkInviteCode(debouncedInviteCode, cancelToken.token)
39 | .then(() => {
40 | setInviteCodeState("valid");
41 | })
42 | .catch((err) => {
43 | const field = err.response.data?.fields?.[0]?.field;
44 |
45 | if (["notExisting", "used", "expired"].includes(field)) {
46 | setInviteCodeState(field);
47 | } else {
48 | setInviteCodeState("error");
49 | }
50 | })
51 | .finally(() => {
52 | timer.current = setTimeout(() => setIsLoading(false), 500);
53 | });
54 |
55 | return () => {
56 | cancelToken.cancel();
57 | setIsLoading(false);
58 | };
59 | }, [debouncedInviteCode]);
60 |
61 | useEffect(() => {
62 | setValid(inviteCodeState === "valid" && debouncedInviteCode !== "");
63 | }, [debouncedInviteCode, inviteCodeState, setValid]);
64 |
65 | useEffect(() => {
66 | return () => {
67 | clearTimeout(timer.current);
68 | };
69 | }, []);
70 |
71 | if (isLoading && inviteCode !== "") {
72 | return ;
73 | }
74 |
75 | return (
76 |
77 | {inviteCodeState === "valid" && (
78 |
79 | {t("components.inviteCodeValidIndicator.valid")}
80 |
81 | )}
82 |
83 | {["notExisting", "used", "expired", "error"].includes(
84 | inviteCodeState
85 | ) && (
86 |
87 | {t(`components.inviteCodeValidIndicator.${inviteCodeState}`)}
88 |
89 | )}
90 |
91 | );
92 | };
93 |
94 | export default ServerValidIndicator;
95 |
--------------------------------------------------------------------------------