Add manual translate as default (#33)
* Added translate and switch auto buttons * Tests updated * Added hotkey & improved buttons visually
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
38
components/AutoTranslateButton.tsx
Normal file
38
components/AutoTranslateButton.tsx
Normal 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;
|
||||
@@ -13,7 +13,7 @@ const LangSelect: FC<Props> = ({ value, onChange, langs, ...props }) => (
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
variant="flushed"
|
||||
px={2}
|
||||
px={3}
|
||||
textAlign="center"
|
||||
style={{ textAlignLast: "center" }}
|
||||
{...props}
|
||||
|
||||
@@ -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<HTMLTextAreaElement>) => void,
|
||||
onChange?: TextareaProps["onChange"],
|
||||
onSubmit?: () => void,
|
||||
readOnly?: true,
|
||||
audio?: number[],
|
||||
canCopy?: boolean,
|
||||
@@ -13,7 +14,7 @@ type Props = {
|
||||
[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 { audioExists, isAudioPlaying, onAudioClick } = useAudioFromBuffer(audio);
|
||||
const spinnerProps = {
|
||||
@@ -36,6 +37,7 @@ const TranslationArea: FC<Props> = ({ 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}
|
||||
/>
|
||||
<HStack
|
||||
|
||||
@@ -6,3 +6,4 @@ export { default as Footer } from "./Footer";
|
||||
export { default as ColorModeToggler } from "./ColorModeToggler";
|
||||
export { default as LangSelect } from "./LangSelect";
|
||||
export { default as TranslationArea } from "./TranslationArea";
|
||||
export { default as AutoTranslateButton } from "./AutoTranslateButton";
|
||||
|
||||
@@ -4,6 +4,7 @@ import faker from "faker";
|
||||
|
||||
beforeEach(() => {
|
||||
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");
|
||||
|
||||
@@ -18,7 +18,7 @@ module.exports = {
|
||||
],
|
||||
moduleFileExtensions: ["ts", "tsx", "js", "jsx"],
|
||||
moduleNameMapper: {
|
||||
"^@(components|hooks|pages|public|tests|utils|theme)(.*)$": "<rootDir>/$1$2"
|
||||
"^@(components|hooks|mocks|pages|public|tests|utils|theme)(.*)$": "<rootDir>/$1$2"
|
||||
},
|
||||
testEnvironment: "jsdom"
|
||||
}
|
||||
|
||||
9
mocks/localStorage.ts
Normal file
9
mocks/localStorage.ts
Normal 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
3
mocks/next.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import Router from "next/router";
|
||||
|
||||
export const routerPushMock = jest.spyOn(Router, "push").mockImplementation(async () => true);
|
||||
@@ -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<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, translationRes, audio, errorMsg, initial }) => {
|
||||
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(() => {
|
||||
if (home)
|
||||
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);
|
||||
}, [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<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, transl
|
||||
placeholder="Text"
|
||||
value={query}
|
||||
onChange={e => isLoading || handleChange(e)}
|
||||
onSubmit={useCallback(() => changeRoute(query), [query, changeRoute])}
|
||||
lang={queryLang}
|
||||
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
|
||||
id="translation"
|
||||
aria-label="Translation result"
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { render, screen, waitFor } from "@tests/reactUtils";
|
||||
import { htmlRes, resolveFetchWith } from "@tests/commonUtils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import Router from "next/router";
|
||||
import faker from "faker";
|
||||
import Page, { getStaticProps } from "@pages/[[...slug]]";
|
||||
|
||||
const mockPush = jest.spyOn(Router, "push").mockImplementation(async () => 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(<Page home={true} />)
|
||||
it("switches the page on translate button click", async () => {
|
||||
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 });
|
||||
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(<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 () => {
|
||||
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(<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 () => {
|
||||
const initial = {
|
||||
source: "eo",
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user