Add manual translate as default (#33)

* Added translate and switch auto buttons

* Tests updated

* Added hotkey & improved buttons visually
This commit is contained in:
David
2021-07-12 17:06:27 +02:00
committed by GitHub
parent f537dff588
commit 5870e4b096
13 changed files with 214 additions and 82 deletions

View File

@@ -13,7 +13,7 @@ Alternative front-end for Google Translate, serving as a Free and Open Source tr
## How does it work? ## 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: For this purpose, *Lingva* is built, among others, with the following Open Source resources:

View File

@@ -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<Props> = ({ onAuto, ...props }) => {
const [isAuto, setIsAuto] = useState(initLocalStorage);
useEffect(() => {
localStorage.setItem("isauto", isAuto.toString());
}, [isAuto]);
useEffect(() => {
isAuto && onAuto();
}, [isAuto, onAuto]);
return (
<IconButton
aria-label="Switch auto"
icon={<FaBolt />}
colorScheme="lingva"
variant={isAuto ? "solid" : "outline"}
onClick={() => setIsAuto(current => !current)}
{...props}
/>
);
};
export default AutoTranslateButton;

View File

@@ -13,7 +13,7 @@ const LangSelect: FC<Props> = ({ value, onChange, langs, ...props }) => (
value={value} value={value}
onChange={onChange} onChange={onChange}
variant="flushed" variant="flushed"
px={2} px={3}
textAlign="center" textAlign="center"
style={{ textAlignLast: "center" }} style={{ textAlignLast: "center" }}
{...props} {...props}

View File

@@ -1,11 +1,12 @@
import { FC, ChangeEvent } from "react"; import { FC } from "react";
import { Box, HStack, Textarea, IconButton, Tooltip, Spinner, useBreakpointValue, useColorModeValue, useClipboard } from "@chakra-ui/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 { FaCopy, FaCheck, FaPlay, FaStop } from "react-icons/fa";
import { useAudioFromBuffer } from "@hooks"; import { useAudioFromBuffer } from "@hooks";
type Props = { type Props = {
value: string, value: string,
onChange?: (e: ChangeEvent<HTMLTextAreaElement>) => void, onChange?: TextareaProps["onChange"],
onSubmit?: () => void,
readOnly?: true, readOnly?: true,
audio?: number[], audio?: number[],
canCopy?: boolean, canCopy?: boolean,
@@ -13,7 +14,7 @@ type Props = {
[key: string]: any [key: string]: any
}; };
const TranslationArea: FC<Props> = ({ value, onChange, readOnly, audio, canCopy, isLoading, ...props }) => { const TranslationArea: FC<Props> = ({ value, onChange, onSubmit, readOnly, audio, canCopy, isLoading, ...props }) => {
const { hasCopied, onCopy } = useClipboard(value); const { hasCopied, onCopy } = useClipboard(value);
const { audioExists, isAudioPlaying, onAudioClick } = useAudioFromBuffer(audio); const { audioExists, isAudioPlaying, onAudioClick } = useAudioFromBuffer(audio);
const spinnerProps = { const spinnerProps = {
@@ -36,6 +37,7 @@ const TranslationArea: FC<Props> = ({ value, onChange, readOnly, audio, canCopy,
rows={useBreakpointValue([6, null, 12]) ?? undefined} rows={useBreakpointValue([6, null, 12]) ?? undefined}
size="lg" size="lg"
data-gramm_editor={false} data-gramm_editor={false}
onKeyPress={e => (e.ctrlKey || e.metaKey) && e.key === "Enter" && onSubmit?.()}
{...props} {...props}
/> />
<HStack <HStack

View File

@@ -6,3 +6,4 @@ export { default as Footer } from "./Footer";
export { default as ColorModeToggler } from "./ColorModeToggler"; export { default as ColorModeToggler } from "./ColorModeToggler";
export { default as LangSelect } from "./LangSelect"; export { default as LangSelect } from "./LangSelect";
export { default as TranslationArea } from "./TranslationArea"; export { default as TranslationArea } from "./TranslationArea";
export { default as AutoTranslateButton } from "./AutoTranslateButton";

View File

@@ -4,6 +4,7 @@ import faker from "faker";
beforeEach(() => { beforeEach(() => {
cy.visit("/"); cy.visit("/");
cy.clearLocalStorage();
}); });
it("switches page on inputs change & goes back correctly", () => { 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 }) cy.findByRole("textbox", { name: /translation query/i })
.as("query") .as("query")
.type("palabra"); .type("palabra");
cy.findByText(/loading translation/i) cy.findByRole("button", { name: /translate/i })
.should("be.visible"); .click();
cy.findByRole("textbox", { name: /translation result/i }) cy.findByRole("textbox", { name: /translation result/i })
.as("translation") .as("translation")
.should("have.value", "word") .should("have.value", "word")
.url() .url()
.should("include", "/auto/en/palabra"); .should("include", "/auto/en/palabra");
cy.findByRole("button", { name: /switch auto/i })
.click();
// source change // source change
cy.findByRole("combobox", { name: /source language/i }) cy.findByRole("combobox", { name: /source language/i })
.as("source") .as("source")
@@ -71,6 +75,9 @@ it("switches first loaded page and back and forth on language change", () => {
const query = faker.random.words(); const query = faker.random.words();
cy.visit(`/auto/en/${query}`); cy.visit(`/auto/en/${query}`);
cy.findByRole("button", { name: /switch auto/i })
.click();
cy.findByRole("textbox", { name: /translation query/i }) cy.findByRole("textbox", { name: /translation query/i })
.as("query") .as("query")
.should("have.value", 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", () => { 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 }) cy.findByRole("button", { name: /switch languages/i })
.as("btnSwitch") .as("btnSwitch")
.should("be.disabled"); .should("be.disabled");

View File

@@ -18,7 +18,7 @@ module.exports = {
], ],
moduleFileExtensions: ["ts", "tsx", "js", "jsx"], moduleFileExtensions: ["ts", "tsx", "js", "jsx"],
moduleNameMapper: { moduleNameMapper: {
"^@(components|hooks|pages|public|tests|utils|theme)(.*)$": "<rootDir>/$1$2" "^@(components|hooks|mocks|pages|public|tests|utils|theme)(.*)$": "<rootDir>/$1$2"
}, },
testEnvironment: "jsdom" testEnvironment: "jsdom"
} }

9
mocks/localStorage.ts Normal file
View File

@@ -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;

3
mocks/next.ts Normal file
View File

@@ -0,0 +1,3 @@
import Router from "next/router";
export const routerPushMock = jest.spyOn(Router, "push").mockImplementation(async () => true);

View File

@@ -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 { GetStaticPaths, GetStaticProps, InferGetStaticPropsType } from "next";
import Router from "next/router"; import Router from "next/router";
import dynamic from "next/dynamic";
import { Stack, VStack, HStack, IconButton } from "@chakra-ui/react"; import { Stack, VStack, HStack, IconButton } from "@chakra-ui/react";
import { FaExchangeAlt } from "react-icons/fa"; import { FaExchangeAlt } from "react-icons/fa";
import { HiTranslate } from "react-icons/hi";
import { useHotkeys } from "react-hotkeys-hook"; import { useHotkeys } from "react-hotkeys-hook";
import { CustomHead, LangSelect, TranslationArea } from "@components"; import { CustomHead, LangSelect, TranslationArea } from "@components";
import { useToastOnLoad } from "@hooks"; import { useToastOnLoad } from "@hooks";
@@ -10,6 +12,8 @@ import { googleScrape, extractSlug, textToSpeechScrape } from "@utils/translate"
import { retrieveFiltered, replaceBoth } from "@utils/language"; import { retrieveFiltered, replaceBoth } from "@utils/language";
import langReducer, { Actions, initialState } from "@utils/reducer"; import langReducer, { Actions, initialState } from "@utils/reducer";
const AutoTranslateButton = dynamic(() => import("@components/AutoTranslateButton"), { ssr: false });
const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, translationRes, audio, errorMsg, initial }) => { const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, translationRes, audio, errorMsg, initial }) => {
const [{ source, target, query, delayedQuery, translation, isLoading }, dispatch] = useReducer(langReducer, initialState); const [{ source, target, query, delayedQuery, translation, isLoading }, dispatch] = useReducer(langReducer, initialState);
@@ -23,6 +27,20 @@ const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ 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(() => { useEffect(() => {
if (home) if (home)
return dispatch({ type: Actions.SET_ALL, payload: { state: { ...initialState, isLoading: false } } }); return dispatch({ type: Actions.SET_ALL, payload: { state: { ...initialState, isLoading: false } } });
@@ -42,20 +60,6 @@ const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, transl
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
}, [query]); }, [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(() => { useEffect(() => {
const handler = (url: string) => url === Router.asPath || dispatch({ type: Actions.SET_FIELD, payload: { key: "isLoading", value: true }}); const handler = (url: string) => url === Router.asPath || dispatch({ type: Actions.SET_FIELD, payload: { key: "isLoading", value: true }});
Router.events.on("beforeHistoryChange", handler); Router.events.on("beforeHistoryChange", handler);
@@ -114,9 +118,26 @@ const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, transl
placeholder="Text" placeholder="Text"
value={query} value={query}
onChange={e => isLoading || handleChange(e)} onChange={e => isLoading || handleChange(e)}
onSubmit={useCallback(() => changeRoute(query), [query, changeRoute])}
lang={queryLang} lang={queryLang}
audio={audio?.source} audio={audio?.source}
/> />
<Stack direction={["row", null, "column"]} justify="center" spacing={3} px={[2, null, "initial"]}>
<IconButton
aria-label="Translate"
icon={<HiTranslate />}
colorScheme="lingva"
variant="outline"
onClick={() => changeRoute(query)}
isDisabled={isLoading}
w={["full", null, "auto"]}
/>
<AutoTranslateButton
onAuto={useCallback(() => changeRoute(delayedQuery), [delayedQuery, changeRoute])}
isDisabled={isLoading}
w={["full", null, "auto"]}
/>
</Stack>
<TranslationArea <TranslationArea
id="translation" id="translation"
aria-label="Translation result" aria-label="Translation result"

View File

@@ -1,15 +1,14 @@
import { render, screen, waitFor } from "@tests/reactUtils"; import { render, screen, waitFor } from "@tests/reactUtils";
import { htmlRes, resolveFetchWith } from "@tests/commonUtils"; import { htmlRes, resolveFetchWith } from "@tests/commonUtils";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import Router from "next/router";
import faker from "faker"; import faker from "faker";
import Page, { getStaticProps } from "@pages/[[...slug]]"; import Page, { getStaticProps } from "@pages/[[...slug]]";
import { localStorageSetMock } from "@mocks/localStorage";
const mockPush = jest.spyOn(Router, "push").mockImplementation(async () => true); import { routerPushMock } from "@mocks/next";
beforeEach(() => { beforeEach(() => {
fetchMock.resetMocks(); fetchMock.resetMocks();
mockPush.mockReset(); routerPushMock.mockReset();
}); });
describe("getStaticProps", () => { describe("getStaticProps", () => {
@@ -77,28 +76,125 @@ describe("Page", () => {
expect(screen.getByText(/\xA9/)).toBeVisible(); expect(screen.getByText(/\xA9/)).toBeVisible();
}); });
it("switches the page on query change", async () => { it("switches the page on translate button click", async () => {
render(<Page home={true} />) render(<Page home={true} />);
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(<Page translationRes={translationRes} audio={audio} initial={initial} />);
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(<Page home={true} />);
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(<Page home={true} />);
const switchAuto = screen.getByRole("button", { name: /switch auto/i });
switchAuto.click();
const query = screen.getByRole("textbox", { name: /translation query/i }); const query = screen.getByRole("textbox", { name: /translation query/i });
userEvent.type(query, faker.random.words()); userEvent.type(query, faker.random.words());
await waitFor( await waitFor(
() => { () => {
expect(Router.push).not.toHaveBeenCalled(); expect(routerPushMock).not.toHaveBeenCalled();
expect(screen.queryByText(/loading translation/i)).not.toBeInTheDocument(); expect(screen.queryByText(/loading translation/i)).not.toBeInTheDocument();
}, },
{ timeout: 250 } { timeout: 250 }
); );
await waitFor( await waitFor(
() => { () => {
expect(Router.push).toHaveBeenCalledTimes(1); expect(routerPushMock).toHaveBeenCalledTimes(1);
expect(screen.getByText(/loading translation/i)).toBeInTheDocument(); expect(screen.getByText(/loading translation/i)).toBeInTheDocument();
}, },
{ timeout: 2500 } { 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(<Page translationRes={translationRes} audio={audio} initial={initial} />);
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(<Page home={true} />);
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(<Page translationRes={translationRes} audio={audio} initial={initial} />);
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 () => { it("translates & loads initials correctly", async () => {
const initial = { const initial = {
source: "ca", source: "ca",
@@ -117,54 +213,6 @@ describe("Page", () => {
expect(translation).toHaveValue(translationRes); expect(translation).toHaveValue(translationRes);
}); });
it("switches the page on language change", async () => {
const initial = {
source: "auto",
target: "en",
query: faker.random.words()
};
render(<Page translationRes={translationRes} audio={audio} initial={initial} />);
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(<Page home={true} />);
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(<Page translationRes={translationRes} audio={audio} initial={initial} />);
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 () => { it("loads audio & clipboard correctly", async () => {
const initial = { const initial = {
source: "eo", source: "eo",

View File

@@ -65,7 +65,7 @@ it("switches the languages & the translations", () => {
target: state.source, target: state.source,
query: state.translation, query: state.translation,
delayedQuery: state.translation, delayedQuery: state.translation,
translation: "", translation: state.query,
isLoading: initialState.isLoading isLoading: initialState.isLoading
}); });
}); });

View File

@@ -57,7 +57,7 @@ export default function reducer(state: State, action: Action) {
target, target,
query: state.translation, query: state.translation,
delayedQuery: state.translation, delayedQuery: state.translation,
translation: "" translation: state.query
}; };
default: default:
return state; return state;