├── .babelrc.js ├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── components ├── ErrorFallback.tsx ├── FieldLabel.tsx ├── InvalidNotice.tsx ├── MorsePlayer.tsx ├── SelectLanguage.tsx ├── SpeechRecorder.tsx ├── TextField.tsx └── TextPlayer.tsx ├── hooks ├── useDidMount.ts └── useLocalStorage.ts ├── jest.config.js ├── next-env.d.ts ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── _app.tsx ├── _document.tsx └── index.tsx ├── public ├── .well-known │ └── assetlinks.json ├── browserconfig.xml ├── dash.mp3 ├── dot.mp3 ├── favicon.ico ├── flag-id.svg ├── flag-us.svg ├── icons │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── icon-128x128.png │ ├── icon-144x144.png │ ├── icon-152x152.png │ ├── icon-192x192.png │ ├── icon-256x256.png │ ├── icon-384x384.png │ ├── icon-512x512.png │ ├── icon-72x72.png │ ├── icon-96x96.png │ ├── maskable_icon_x512.png │ ├── mstile-150x150.png │ └── safari-pinned-tab.svg ├── manifest.json └── screenshots │ ├── 1.png │ ├── 2.png │ ├── 3.png │ ├── 4.png │ └── 5.png ├── setupTests.js ├── tailwind.config.js ├── tests ├── components │ ├── ErrorFallback.test.tsx │ ├── FieldLabel.test.tsx │ ├── InvalidNotice.test.tsx │ ├── MorsePlayer.test.tsx │ ├── SelectLanguage.test.tsx │ ├── SpeechRecorder.test.tsx │ ├── TextField.test.tsx │ └── TextPlayer.test.tsx ├── hooks │ ├── useDidMount.test.tsx │ └── useLocalStorage.test.tsx ├── pages │ └── index.test.tsx └── utils │ ├── language.test.tsx │ └── translation.test.tsx ├── tsconfig.json ├── twin.d.ts └── utils ├── event.ts ├── language.ts └── translation.ts /.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | 'next/babel', 5 | { 6 | 'preset-react': { 7 | runtime: 'automatic', 8 | importSource: '@emotion/react', 9 | }, 10 | }, 11 | ], 12 | ], 13 | plugins: ['@emotion/babel-plugin', 'babel-plugin-macros'], 14 | } -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: ["https://ko-fi.com/ilhamwahabi", "https://trakteer.id/ilhamwahabi"] 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | .next 7 | 8 | .env 9 | 10 | sw.* 11 | workbox* -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Ilham Wahabi 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Morsible 2 | 3 | Translator for text <-> morse with speech functionality. Have optimized performance, PWA support, and accessibility friendly. 4 | 5 | Built for the sake of `IF4081 - Informatika untuk Komunitas` lectures, also to explore new tech stack and things about audio management in ~~React~~ Preact. 6 | 7 | Tech stack: 8 | 9 | - ~~`React`~~ Preact + `Typescript` with `Next.js` 10 | - `twin.macro` with `emotion` and `Tailwind CSS` for styling 11 | - `@testing-library/react` and `Jest` for testing 12 | - `Splitbee` for analytics 13 | - `Vercel` for deployment 14 | 15 | ## Usage 16 | 17 | 1. Clone this repo 18 | 2. Get Google API Key from Google Cloud Console and enable `Text-to-speech` & `Speech-to-text`, then create `.env` file with variables 19 | 20 | ``` 21 | NEXT_PUBLIC_GOOGLE_API_KEY=xxxxxxxxxx 22 | ``` 23 | 24 | 3. Install package, `npm install` 25 | 4. Run, `npm run dev` 26 | -------------------------------------------------------------------------------- /components/ErrorFallback.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentType } from 'react'; 2 | import { FallbackProps } from 'react-error-boundary'; 3 | import 'twin.macro'; 4 | 5 | const ErrorFallback: ComponentType = (props) => { 6 | const { error, resetErrorBoundary } = props; 7 | 8 | return ( 9 |
10 |

11 | Something wrong happened :( 12 |

13 |

14 | We have reported this to developer 15 |

16 |

17 | { error.toString() } 18 |

19 | 25 |
26 | ) 27 | } 28 | 29 | export default ErrorFallback 30 | -------------------------------------------------------------------------------- /components/FieldLabel.tsx: -------------------------------------------------------------------------------- 1 | import 'twin.macro' 2 | 3 | interface IProps { 4 | targetId: string; 5 | text: string; 6 | } 7 | 8 | function FieldLabel(props: IProps) { 9 | const { targetId, text } = props; 10 | 11 | return ( 12 | 15 | ) 16 | } 17 | 18 | export default FieldLabel 19 | -------------------------------------------------------------------------------- /components/InvalidNotice.tsx: -------------------------------------------------------------------------------- 1 | import 'twin.macro'; 2 | 3 | interface IProps { 4 | pre: string; 5 | invalidItems: string[]; 6 | } 7 | 8 | function InvalidNotice(props: IProps) { 9 | const { pre, invalidItems } = props; 10 | 11 | if (invalidItems.length === 0) return null; 12 | 13 | return ( 14 | 15 | { pre } 16 | { 17 | invalidItems.map((item, index) => ( 18 | <> 19 | { index > 0 && , } 20 | {item} 21 | 22 | )) 23 | } 24 | 25 | ) 26 | } 27 | 28 | export default InvalidNotice 29 | -------------------------------------------------------------------------------- /components/MorsePlayer.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import tw from 'twin.macro' 3 | import useSound from 'use-sound' 4 | import { FaPlay, FaStop } from "react-icons/fa"; 5 | import toast from 'react-hot-toast'; 6 | 7 | import { TEvent } from '../utils/event'; 8 | 9 | function timeout(ms: number) { 10 | return new Promise(resolve => setTimeout(resolve, ms)); 11 | } 12 | 13 | interface IProps { 14 | morse: string 15 | setIsHold: (isHold: { status: boolean, event?: TEvent }) => void 16 | } 17 | 18 | function MorsePlayer(props: IProps) { 19 | const { morse, setIsHold } = props; 20 | 21 | const [isPlaying, setIsPlaying] = useState({ status: false }) 22 | const [playDot, dotData] = useSound('/dot.mp3'); 23 | const [playDash, dashData] = useSound('/dash.mp3'); 24 | 25 | const play = async () => { 26 | let charIndex = 0 27 | while (charIndex < morse.length && isPlaying.status) { 28 | const char = morse[charIndex] 29 | 30 | if (char === ".") { 31 | playDot() 32 | await timeout(dotData.duration); 33 | } else if (char === '-') { 34 | playDash() 35 | await timeout(dashData.duration); 36 | } else if (char === ' ') { 37 | await timeout(250) 38 | } else if (char === "/") { 39 | await timeout(150) 40 | } 41 | 42 | charIndex++ 43 | } 44 | 45 | setIsHold({ status: false }) 46 | setIsPlaying({ status: false }) 47 | } 48 | 49 | const stop = () => { 50 | dotData.stop() 51 | dashData.stop() 52 | } 53 | 54 | useEffect(() => { 55 | if (isPlaying.status) play() 56 | else stop() 57 | }, [isPlaying.status]) 58 | 59 | useEffect(() => { 60 | if (!isPlaying.status && (dotData.isPlaying || dashData.isPlaying)) stop() 61 | }, [dotData.isPlaying, dashData.isPlaying]) 62 | 63 | const actionClickPlayButton = async () => { 64 | if (morse === "") return toast.error("Please input morse code") 65 | 66 | if (isPlaying.status) { 67 | // HACK: so state inside play function is mutated 68 | isPlaying.status = false 69 | setIsPlaying({ status: false }) 70 | setIsHold({ status: false }) 71 | 72 | stop() 73 | } else { 74 | isPlaying.status = true 75 | setIsPlaying({ status: true}) 76 | setIsHold({ status: true, event: 'play-morse' }) 77 | } 78 | } 79 | 80 | return ( 81 | 93 | ) 94 | } 95 | 96 | export default MorsePlayer 97 | -------------------------------------------------------------------------------- /components/SelectLanguage.tsx: -------------------------------------------------------------------------------- 1 | import dynamic from 'next/dynamic' 2 | // handle issue: https://github.com/JedWatson/react-select/issues/3590 3 | const Select = dynamic(() => import("react-select"), { ssr: false }); 4 | import 'twin.macro' 5 | 6 | import { TCountryCode } from '../utils/language' 7 | 8 | interface IOptionProps { 9 | countryCode: TCountryCode 10 | label: string 11 | } 12 | 13 | function LocaleOption(props: IOptionProps) { 14 | const { countryCode, label } = props; 15 | 16 | return ( 17 |
18 | 19 | { label } 20 |
21 | ) 22 | } 23 | 24 | interface IOption { 25 | value: TCountryCode 26 | label: React.ReactElement 27 | } 28 | 29 | const options: IOption[] = [ 30 | { value: 'us', label: }, 31 | { value: 'id', label: }, 32 | ] 33 | 34 | interface ISelectProps { 35 | language: TCountryCode 36 | setLanguage: (language: TCountryCode) => void 37 | } 38 | 39 | function SelectLanguage(props: ISelectProps) { 40 | const { language, setLanguage } = props; 41 | 42 | return ( 43 |