diff --git a/README.md b/README.md index 846fa2f..c0373ab 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Alternative front-end for Google Translate, serving as a Free and Open Source tr ## How does it work? -Inspired by projects like [NewPipe](https://github.com/TeamNewPipe/NewPipe), [Nitter](https://github.com/zedeus/nitter), [Invidious](https://github.com/iv-org/invidious) or [Bibliogram](https://git.sr.ht/~cadence/bibliogram), *Lingva* scrappes through GTranslate and retrieves the translation without using any Google-related service, preventing them from tracking. +Inspired by projects like [NewPipe](https://github.com/TeamNewPipe/NewPipe), [Nitter](https://github.com/zedeus/nitter), [Invidious](https://github.com/iv-org/invidious) or [Bibliogram](https://git.sr.ht/~cadence/bibliogram), *Lingva* scrapes through GTranslate and retrieves the translation without using any Google-related service, preventing them from tracking. For this purpose, *Lingva* is built, among others, with the following Open Source resources: diff --git a/components/AutoTranslateButton.tsx b/components/AutoTranslateButton.tsx new file mode 100644 index 0000000..06475c9 --- /dev/null +++ b/components/AutoTranslateButton.tsx @@ -0,0 +1,38 @@ +import { useState, useEffect, FC } from "react"; +import { IconButton } from "@chakra-ui/react"; +import { FaBolt } from "react-icons/fa"; + +type Props = { + onAuto: () => void, + [key: string]: any +}; + +const initLocalStorage = () => { + const initial = typeof window !== "undefined" && localStorage.getItem("isauto"); + return initial ? initial === "true" : false; +}; + +const AutoTranslateButton: FC = ({ onAuto, ...props }) => { + const [isAuto, setIsAuto] = useState(initLocalStorage); + + useEffect(() => { + localStorage.setItem("isauto", isAuto.toString()); + }, [isAuto]); + + useEffect(() => { + isAuto && onAuto(); + }, [isAuto, onAuto]); + + return ( + } + colorScheme="lingva" + variant={isAuto ? "solid" : "outline"} + onClick={() => setIsAuto(current => !current)} + {...props} + /> + ); +}; + +export default AutoTranslateButton; diff --git a/components/LangSelect.tsx b/components/LangSelect.tsx index 9208328..052a500 100644 --- a/components/LangSelect.tsx +++ b/components/LangSelect.tsx @@ -13,7 +13,7 @@ const LangSelect: FC = ({ value, onChange, langs, ...props }) => ( value={value} onChange={onChange} variant="flushed" - px={2} + px={3} textAlign="center" style={{ textAlignLast: "center" }} {...props} diff --git a/components/TranslationArea.tsx b/components/TranslationArea.tsx index e2f817a..8bd5c01 100644 --- a/components/TranslationArea.tsx +++ b/components/TranslationArea.tsx @@ -1,11 +1,12 @@ -import { FC, ChangeEvent } from "react"; -import { Box, HStack, Textarea, IconButton, Tooltip, Spinner, useBreakpointValue, useColorModeValue, useClipboard } from "@chakra-ui/react"; +import { FC } from "react"; +import { Box, HStack, Textarea, IconButton, Tooltip, Spinner, TextareaProps, useBreakpointValue, useColorModeValue, useClipboard } from "@chakra-ui/react"; import { FaCopy, FaCheck, FaPlay, FaStop } from "react-icons/fa"; import { useAudioFromBuffer } from "@hooks"; type Props = { value: string, - onChange?: (e: ChangeEvent) => void, + onChange?: TextareaProps["onChange"], + onSubmit?: () => void, readOnly?: true, audio?: number[], canCopy?: boolean, @@ -13,7 +14,7 @@ type Props = { [key: string]: any }; -const TranslationArea: FC = ({ value, onChange, readOnly, audio, canCopy, isLoading, ...props }) => { +const TranslationArea: FC = ({ value, onChange, onSubmit, readOnly, audio, canCopy, isLoading, ...props }) => { const { hasCopied, onCopy } = useClipboard(value); const { audioExists, isAudioPlaying, onAudioClick } = useAudioFromBuffer(audio); const spinnerProps = { @@ -36,6 +37,7 @@ const TranslationArea: FC = ({ value, onChange, readOnly, audio, canCopy, rows={useBreakpointValue([6, null, 12]) ?? undefined} size="lg" data-gramm_editor={false} + onKeyPress={e => (e.ctrlKey || e.metaKey) && e.key === "Enter" && onSubmit?.()} {...props} /> { cy.visit("/"); + cy.clearLocalStorage(); }); it("switches page on inputs change & goes back correctly", () => { @@ -11,13 +12,16 @@ it("switches page on inputs change & goes back correctly", () => { cy.findByRole("textbox", { name: /translation query/i }) .as("query") .type("palabra"); - cy.findByText(/loading translation/i) - .should("be.visible"); + cy.findByRole("button", { name: /translate/i }) + .click(); cy.findByRole("textbox", { name: /translation result/i }) .as("translation") .should("have.value", "word") .url() .should("include", "/auto/en/palabra"); + cy.findByRole("button", { name: /switch auto/i }) + .click(); + // source change cy.findByRole("combobox", { name: /source language/i }) .as("source") @@ -71,6 +75,9 @@ it("switches first loaded page and back and forth on language change", () => { const query = faker.random.words(); cy.visit(`/auto/en/${query}`); + cy.findByRole("button", { name: /switch auto/i }) + .click(); + cy.findByRole("textbox", { name: /translation query/i }) .as("query") .should("have.value", query); @@ -92,6 +99,9 @@ it("switches first loaded page and back and forth on language change", () => { }); it("language switching button is disabled on 'auto', but enables when other", () => { + cy.findByRole("button", { name: /switch auto/i }) + .click(); + cy.findByRole("button", { name: /switch languages/i }) .as("btnSwitch") .should("be.disabled"); diff --git a/jest.config.js b/jest.config.js index 096cdf9..671f6d9 100644 --- a/jest.config.js +++ b/jest.config.js @@ -18,7 +18,7 @@ module.exports = { ], moduleFileExtensions: ["ts", "tsx", "js", "jsx"], moduleNameMapper: { - "^@(components|hooks|pages|public|tests|utils|theme)(.*)$": "/$1$2" + "^@(components|hooks|mocks|pages|public|tests|utils|theme)(.*)$": "/$1$2" }, testEnvironment: "jsdom" } diff --git a/mocks/localStorage.ts b/mocks/localStorage.ts new file mode 100644 index 0000000..4d14c9e --- /dev/null +++ b/mocks/localStorage.ts @@ -0,0 +1,9 @@ +Object.defineProperty(window, "localStorage", { + value: { + getItem: jest.fn(() => null), + setItem: jest.fn(() => null) + }, + writable: true +}); + +export const localStorageSetMock = window.localStorage.setItem; diff --git a/mocks/next.ts b/mocks/next.ts new file mode 100644 index 0000000..5be6396 --- /dev/null +++ b/mocks/next.ts @@ -0,0 +1,3 @@ +import Router from "next/router"; + +export const routerPushMock = jest.spyOn(Router, "push").mockImplementation(async () => true); diff --git a/pages/[[...slug]].tsx b/pages/[[...slug]].tsx index 242d7d2..210bee6 100644 --- a/pages/[[...slug]].tsx +++ b/pages/[[...slug]].tsx @@ -1,8 +1,10 @@ -import { useEffect, useReducer, FC, ChangeEvent } from "react"; +import { useEffect, useReducer, useCallback, FC, ChangeEvent } from "react"; import { GetStaticPaths, GetStaticProps, InferGetStaticPropsType } from "next"; import Router from "next/router"; +import dynamic from "next/dynamic"; import { Stack, VStack, HStack, IconButton } from "@chakra-ui/react"; import { FaExchangeAlt } from "react-icons/fa"; +import { HiTranslate } from "react-icons/hi"; import { useHotkeys } from "react-hotkeys-hook"; import { CustomHead, LangSelect, TranslationArea } from "@components"; import { useToastOnLoad } from "@hooks"; @@ -10,6 +12,8 @@ import { googleScrape, extractSlug, textToSpeechScrape } from "@utils/translate" import { retrieveFiltered, replaceBoth } from "@utils/language"; import langReducer, { Actions, initialState } from "@utils/reducer"; +const AutoTranslateButton = dynamic(() => import("@components/AutoTranslateButton"), { ssr: false }); + const Page: FC> = ({ home, translationRes, audio, errorMsg, initial }) => { const [{ source, target, query, delayedQuery, translation, isLoading }, dispatch] = useReducer(langReducer, initialState); @@ -23,6 +27,20 @@ const Page: FC> = ({ home, transl }); }; + const changeRoute = useCallback((customQuery: string) => { + if (isLoading) + return; + if (!customQuery || customQuery === initialState.query) + return; + if (!home && !initial) + return; + if (!home && customQuery === initial.query && source === initial.source && target === initial.target) + return; + + dispatch({ type: Actions.SET_FIELD, payload: { key: "isLoading", value: true }}); + Router.push(`/${source}/${target}/${encodeURIComponent(customQuery)}`); + }, [isLoading, source, target, home, initial]); + useEffect(() => { if (home) return dispatch({ type: Actions.SET_ALL, payload: { state: { ...initialState, isLoading: false } } }); @@ -42,20 +60,6 @@ const Page: FC> = ({ home, transl return () => clearTimeout(timeout); }, [query]); - useEffect(() => { - if (isLoading) - return; - if (!delayedQuery || delayedQuery === initialState.query) - return; - if (!home && !initial) - return; - if (!home && delayedQuery === initial.query && source === initial.source && target === initial.target) - return; - - dispatch({ type: Actions.SET_FIELD, payload: { key: "isLoading", value: true }}); - Router.push(`/${source}/${target}/${encodeURIComponent(delayedQuery)}`); - }, [source, target, delayedQuery, initial, home, isLoading]); - useEffect(() => { const handler = (url: string) => url === Router.asPath || dispatch({ type: Actions.SET_FIELD, payload: { key: "isLoading", value: true }}); Router.events.on("beforeHistoryChange", handler); @@ -114,9 +118,26 @@ const Page: FC> = ({ home, transl placeholder="Text" value={query} onChange={e => isLoading || handleChange(e)} + onSubmit={useCallback(() => changeRoute(query), [query, changeRoute])} lang={queryLang} audio={audio?.source} /> + + } + colorScheme="lingva" + variant="outline" + onClick={() => changeRoute(query)} + isDisabled={isLoading} + w={["full", null, "auto"]} + /> + changeRoute(delayedQuery), [delayedQuery, changeRoute])} + isDisabled={isLoading} + w={["full", null, "auto"]} + /> + true); +import { localStorageSetMock } from "@mocks/localStorage"; +import { routerPushMock } from "@mocks/next"; beforeEach(() => { fetchMock.resetMocks(); - mockPush.mockReset(); + routerPushMock.mockReset(); }); describe("getStaticProps", () => { @@ -77,28 +76,125 @@ describe("Page", () => { expect(screen.getByText(/\xA9/)).toBeVisible(); }); - it("switches the page on query change", async () => { - render() + it("switches the page on translate button click", async () => { + render(); + const query = screen.getByRole("textbox", { name: /translation query/i }); + userEvent.type(query, faker.random.words()); + const translate = screen.getByRole("button", { name: /translate/i }); + translate.click(); + + expect(routerPushMock).toHaveBeenCalledTimes(1); + expect(screen.getByText(/loading translation/i)).toBeInTheDocument(); + }); + + it("doesn't switch the page if nothing has changed", async () => { + const initial = { + source: "ca", + target: "es", + query: faker.random.words() + }; + render(); + + const translate = screen.getByRole("button", { name: /translate/i }); + translate.click(); + + expect(routerPushMock).not.toHaveBeenCalled(); + expect(screen.queryByText(/loading translation/i)).not.toBeInTheDocument(); + }); + + it("stores auto state in localStorage", async () => { + render(); + + const switchAuto = screen.getByRole("button", { name: /switch auto/i }); + + switchAuto.click(); + await waitFor(() => expect(localStorageSetMock).toHaveBeenLastCalledWith("isauto", "true")); + switchAuto.click(); + await waitFor(() => expect(localStorageSetMock).toHaveBeenLastCalledWith("isauto", "false")); + }); + + it("switches the page on query change if auto is enabled", async () => { + render(); + + const switchAuto = screen.getByRole("button", { name: /switch auto/i }); + switchAuto.click(); const query = screen.getByRole("textbox", { name: /translation query/i }); userEvent.type(query, faker.random.words()); await waitFor( () => { - expect(Router.push).not.toHaveBeenCalled(); + expect(routerPushMock).not.toHaveBeenCalled(); expect(screen.queryByText(/loading translation/i)).not.toBeInTheDocument(); }, { timeout: 250 } ); await waitFor( () => { - expect(Router.push).toHaveBeenCalledTimes(1); + expect(routerPushMock).toHaveBeenCalledTimes(1); expect(screen.getByText(/loading translation/i)).toBeInTheDocument(); }, { timeout: 2500 } ); }); + it("switches the page on language change if auto is enabled", async () => { + const initial = { + source: "auto", + target: "en", + query: faker.random.words() + }; + render(); + + const switchAuto = screen.getByRole("button", { name: /switch auto/i }); + switchAuto.click(); + + const source = screen.getByRole("combobox", { name: /source language/i }); + + const sourceVal = "eo"; + userEvent.selectOptions(source, sourceVal); + expect(source).toHaveValue(sourceVal); + + await waitFor(() => expect(routerPushMock).toHaveBeenCalledTimes(1)); + }); + + it("doesn't switch the page on language change on the start page", async () => { + render(); + + const switchAuto = screen.getByRole("button", { name: /switch auto/i }); + switchAuto.click(); + + const source = screen.getByRole("combobox", { name: /source language/i }); + + const sourceVal = "eo"; + userEvent.selectOptions(source, sourceVal); + expect(source).toHaveValue(sourceVal); + + await waitFor(() => expect(routerPushMock).not.toHaveBeenCalled()); + }); + + it("switches languages & translations", async () => { + const initial = { + source: "es", + target: "ca", + query: faker.random.words() + }; + render(); + + const switchAuto = screen.getByRole("button", { name: /switch auto/i }); + switchAuto.click(); + + const btnSwitch = screen.getByRole("button", { name: /switch languages/i }); + userEvent.click(btnSwitch); + + expect(screen.getByRole("combobox", { name: /source language/i })).toHaveValue(initial.target); + expect(screen.getByRole("combobox", { name: /target language/i })).toHaveValue(initial.source); + expect(screen.getByRole("textbox", { name: /translation query/i })).toHaveValue(translationRes); + expect(screen.getByRole("textbox", { name: /translation result/i })).toHaveValue(initial.query); + + await waitFor(() => expect(routerPushMock).toHaveBeenCalledTimes(1)); + }); + it("translates & loads initials correctly", async () => { const initial = { source: "ca", @@ -117,54 +213,6 @@ describe("Page", () => { expect(translation).toHaveValue(translationRes); }); - it("switches the page on language change", async () => { - const initial = { - source: "auto", - target: "en", - query: faker.random.words() - }; - render(); - - const source = screen.getByRole("combobox", { name: /source language/i }); - - const sourceVal = "eo"; - userEvent.selectOptions(source, sourceVal); - expect(source).toHaveValue(sourceVal); - - await waitFor(() => expect(Router.push).toHaveBeenCalledTimes(1)); - }); - - it("doesn't switch the page on language change on the start page", async () => { - render(); - - const source = screen.getByRole("combobox", { name: /source language/i }); - - const sourceVal = "eo"; - userEvent.selectOptions(source, sourceVal); - expect(source).toHaveValue(sourceVal); - - await waitFor(() => expect(Router.push).not.toHaveBeenCalled()); - }); - - it("switches languages & translations", async () => { - const initial = { - source: "es", - target: "ca", - query: faker.random.words() - }; - render(); - - const btnSwitch = screen.getByRole("button", { name: /switch languages/i }); - userEvent.click(btnSwitch); - - expect(screen.getByRole("combobox", { name: /source language/i })).toHaveValue(initial.target); - expect(screen.getByRole("combobox", { name: /target language/i })).toHaveValue(initial.source); - expect(screen.getByRole("textbox", { name: /translation query/i })).toHaveValue(translationRes); - expect(screen.getByRole("textbox", { name: /translation result/i })).toHaveValue(""); - - await waitFor(() => expect(Router.push).toHaveBeenCalledTimes(1)); - }); - it("loads audio & clipboard correctly", async () => { const initial = { source: "eo", diff --git a/tests/utils/reducer.test.ts b/tests/utils/reducer.test.ts index b31f8c6..b09903b 100644 --- a/tests/utils/reducer.test.ts +++ b/tests/utils/reducer.test.ts @@ -65,7 +65,7 @@ it("switches the languages & the translations", () => { target: state.source, query: state.translation, delayedQuery: state.translation, - translation: "", + translation: state.query, isLoading: initialState.isLoading }); }); diff --git a/utils/reducer.ts b/utils/reducer.ts index cabc663..c859e36 100644 --- a/utils/reducer.ts +++ b/utils/reducer.ts @@ -57,7 +57,7 @@ export default function reducer(state: State, action: Action) { target, query: state.translation, delayedQuery: state.translation, - translation: "" + translation: state.query }; default: return state;