diff --git a/messages/en-US.json b/messages/en-US.json index ac61544..2043f58 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -176,6 +176,7 @@ "close": "Close", "success": "Text pair added to folder", "error": "Failed to add text pair to folder" - } + }, + "autoSave": "Auto Save" } } diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 180b576..a79130d 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -176,6 +176,7 @@ "close": "关闭", "success": "文本对已添加到文件夹", "error": "添加文本对到文件夹失败" - } + }, + "autoSave": "自动保存" } } diff --git a/src/app/(features)/translator/FolderSelector.tsx b/src/app/(features)/translator/FolderSelector.tsx new file mode 100644 index 0000000..6309a10 --- /dev/null +++ b/src/app/(features)/translator/FolderSelector.tsx @@ -0,0 +1,46 @@ +import Container from "@/components/cards/Container"; +import { useEffect, useState } from "react"; +import { folder } from "../../../../generated/prisma/browser"; +import { getFoldersByOwner } from "@/lib/services/folderService"; +import LightButton from "@/components/buttons/LightButton"; + +interface FolderSelectorProps { + setSelectedFolderId: (id: number) => void; + username: string; + cancel: () => void; +} + +const FolderSelector: React.FC = ({ + setSelectedFolderId, + username, + cancel, +}) => { + const [loading, setLoading] = useState(false); + const [folders, setFolders] = useState([]); + + useEffect(() => { + getFoldersByOwner(username) + .then(setFolders) + .then(() => setLoading(false)); + }, []); + + return ( +
+ + {(loading &&

Loading...

) || + (folders.length > 0 && ( +
    + {folders.map((folder) => ( +
  • {folder.name}
  • + ))} +
+ )) ||

No folders found

} + Cancel +
+
+ ); +}; + +export default FolderSelector; diff --git a/src/app/(features)/translator/page.tsx b/src/app/(features)/translator/page.tsx index e3c1199..bb2cb75 100644 --- a/src/app/(features)/translator/page.tsx +++ b/src/app/(features)/translator/page.tsx @@ -8,16 +8,26 @@ import { useAudioPlayer } from "@/hooks/useAudioPlayer"; import { TranslationHistorySchema } from "@/lib/interfaces"; import { tlsoPush, tlso } from "@/lib/localStorageOperators"; import { getTTSAudioUrl } from "@/lib/tts"; -import { letsFetch, shallowEqual } from "@/lib/utils"; +import { shallowEqual } from "@/lib/utils"; import { Plus, Trash } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRef, useState } from "react"; import z from "zod"; import AddToFolder from "./AddToFolder"; +import { + genIPA, + genLocale, + genTranslation, +} from "@/lib/actions/translatorActions"; +import { toast } from "sonner"; +import FolderSelector from "./FolderSelector"; +import { useSession } from "next-auth/react"; export default function TranslatorPage() { const t = useTranslations("translator"); + const session = useSession(); + const taref = useRef(null); const [lang, setLang] = useState("chinese"); const [tresult, setTresult] = useState(""); @@ -32,109 +42,108 @@ export default function TranslatorPage() { const [addToFolderItem, setAddToFolderItem] = useState | null>(null); - const lastTTS = useRef({ text: "", url: "", }); + const [autoSave, setAutoSave] = useState(false); + const [autoSaveFolderId, setAutoSaveFolderId] = useState(null); const tts = async (text: string, locale: string) => { if (lastTTS.current.text !== text) { - const url = await getTTSAudioUrl( - text, - VOICES.find((v) => v.locale === locale)!.short_name, - ); - await load(url); - lastTTS.current.text = text; - lastTTS.current.url = url; + const shortName = VOICES.find((v) => v.locale === locale)?.short_name; + if (!shortName) { + toast.error("Voice not found"); + return; + } + try { + const url = await getTTSAudioUrl(text, shortName); + await load(url); + lastTTS.current.text = text; + lastTTS.current.url = url; + } catch (error) { + toast.error("Failed to generate audio"); + } } - play(); + await play(); }; const translate = async () => { + if (!taref.current) return; if (processing) return; + setProcessing(true); - if (!taref.current) return; - const text = taref.current.value; + const text1 = taref.current.value; - const newItem: { + const llmres: { text1: string | null; text2: string | null; locale1: string | null; locale2: string | null; + ipa1: string | null; + ipa2: string | null; } = { - text1: text, + text1: text1, text2: null, locale1: null, locale2: null, + ipa1: null, + ipa2: null, }; - const checkUpdateLocalStorage = (item: typeof newItem) => { - if (item.text1 && item.text2 && item.locale1 && item.locale2) { - setHistory(tlsoPush(item as z.infer)); + let historyUpdated = false; + + // 检查更新历史记录 + const checkUpdateLocalStorage = () => { + if (historyUpdated) return; + if (llmres.text1 && llmres.text2 && llmres.locale1 && llmres.locale2) { + setHistory( + tlsoPush({ + text1: llmres.text1, + text2: llmres.text2, + locale1: llmres.locale1, + locale2: llmres.locale2, + }), + ); + historyUpdated = true; } }; - const innerStates = { - text2: false, - ipa1: !genIpa, - ipa2: !genIpa, - }; - const checkUpdateProcessStates = () => { - if (innerStates.ipa1 && innerStates.ipa2 && innerStates.text2) - setProcessing(false); - }; - const updateState = (stateName: keyof typeof innerStates) => () => { - innerStates[stateName] = true; - checkUpdateLocalStorage(newItem); - checkUpdateProcessStates(); + // 更新局部翻译状态 + const updateState = (stateName: keyof typeof llmres, value: string) => { + llmres[stateName] = value; + checkUpdateLocalStorage(); }; - // Fetch locale for text1 - letsFetch( - `/api/v1/locale?text=${encodeURIComponent(text)}`, - (locale: string) => { - newItem.locale1 = locale; - }, - console.log, - () => {}, - ); - - if (genIpa) - // Fetch IPA for text1 - letsFetch( - `/api/v1/ipa?text=${encodeURIComponent(text)}`, - (ipa: string) => setIpaTexts((prev) => [ipa, prev[1]]), - console.log, - updateState("ipa1"), - ); - // Fetch translation for text2 - letsFetch( - `/api/v1/translate?text=${encodeURIComponent(text)}&lang=${encodeURIComponent(lang)}`, - (text2) => { + genTranslation(text1, lang) + .then(async (text2) => { + updateState("text2", text2); setTresult(text2); - newItem.text2 = text2; - if (genIpa) - // Fetch IPA for text2 - letsFetch( - `/api/v1/ipa?text=${encodeURIComponent(text2)}`, - (ipa: string) => setIpaTexts((prev) => [prev[0], ipa]), - console.log, - updateState("ipa2"), - ); - // Fetch locale for text2 - letsFetch( - `/api/v1/locale?text=${encodeURIComponent(text2)}`, - (locale: string) => { - newItem.locale2 = locale; - }, - console.log, - () => {}, - ); - }, - console.log, - updateState("text2"), - ); + // 生成两个locale + genLocale(text1).then((locale) => { + updateState("locale1", locale); + }); + genLocale(text2).then((locale) => { + updateState("locale2", locale); + }); + // 生成俩IPA + if (genIpa) { + genIPA(text1).then((ipa1) => { + setIpaTexts((prev) => [ipa1, prev[1]]); + updateState("ipa1", ipa1); + }); + genIPA(text2).then((ipa2) => { + setIpaTexts((prev) => [prev[0], ipa2]); + updateState("ipa2", ipa2); + }); + } + }) + .catch(() => { + toast.error("Translation failed"); + }) + .finally(() => { + setProcessing(false); + }); }; return ( @@ -259,6 +268,27 @@ export default function TranslatorPage() { {t("translate")} + + {/* AutoSave Component */} +
+ +
+ {history.length > 0 && (

{t("history")}

@@ -300,6 +330,13 @@ export default function TranslatorPage() { {showAddToFolder && ( )} + {autoSave && !autoSaveFolderId && ( + setAutoSave(false)} + setSelectedFolderId={(id) => setAutoSaveFolderId(id)} + /> + )}
)} diff --git a/src/app/(features)/translator_old/AddToFolder.tsx b/src/app/(features)/translator_old/AddToFolder.tsx new file mode 100644 index 0000000..3ce4454 --- /dev/null +++ b/src/app/(features)/translator_old/AddToFolder.tsx @@ -0,0 +1,84 @@ +import LightButton from "@/components/buttons/LightButton"; +import Container from "@/components/cards/Container"; +import { TranslationHistorySchema } from "@/lib/interfaces"; +import { useSession } from "next-auth/react"; +import { Dispatch, useEffect, useState } from "react"; +import z from "zod"; +import { folder } from "../../../../generated/prisma/browser"; +import { getFoldersByOwner } from "@/lib/services/folderService"; +import { Folder } from "lucide-react"; +import { createTextPair } from "@/lib/services/textPairService"; +import { toast } from "sonner"; +import { useTranslations } from "next-intl"; + +interface AddToFolderProps { + item: z.infer; + setShow: Dispatch>; +} + +const AddToFolder: React.FC = ({ item, setShow }) => { + const session = useSession(); + const [folders, setFolders] = useState([]); + const t = useTranslations("translator.add_to_folder"); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const username = session.data!.user!.name as string; + getFoldersByOwner(username) + .then(setFolders) + .then(() => setLoading(false)); + }, [session.data]); + + if (session.status !== "authenticated") { + return ( +
+ +
{t("notAuthenticated")}
+
+
+ ); + } + return ( +
+ +

{t("chooseFolder")}

+
+ {(loading && ...) || + (folders.length > 0 && + folders.map((folder) => ( + + ))) ||
{t("noFolders")}
} +
+ setShow(false)}>{t("close")} +
+
+ ); +}; + +export default AddToFolder; diff --git a/src/app/(features)/translator_old/page.tsx b/src/app/(features)/translator_old/page.tsx new file mode 100644 index 0000000..e3c1199 --- /dev/null +++ b/src/app/(features)/translator_old/page.tsx @@ -0,0 +1,307 @@ +"use client"; + +import LightButton from "@/components/buttons/LightButton"; +import IconClick from "@/components/IconClick"; +import IMAGES from "@/config/images"; +import { VOICES } from "@/config/locales"; +import { useAudioPlayer } from "@/hooks/useAudioPlayer"; +import { TranslationHistorySchema } from "@/lib/interfaces"; +import { tlsoPush, tlso } from "@/lib/localStorageOperators"; +import { getTTSAudioUrl } from "@/lib/tts"; +import { letsFetch, shallowEqual } from "@/lib/utils"; +import { Plus, Trash } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useRef, useState } from "react"; +import z from "zod"; +import AddToFolder from "./AddToFolder"; + +export default function TranslatorPage() { + const t = useTranslations("translator"); + + const taref = useRef(null); + const [lang, setLang] = useState("chinese"); + const [tresult, setTresult] = useState(""); + const [genIpa, setGenIpa] = useState(true); + const [ipaTexts, setIpaTexts] = useState(["", ""]); + const [processing, setProcessing] = useState(false); + const { load, play } = useAudioPlayer(); + const [history, setHistory] = useState< + z.infer[] + >(tlso.get()); + const [showAddToFolder, setShowAddToFolder] = useState(false); + const [addToFolderItem, setAddToFolderItem] = useState | null>(null); + + const lastTTS = useRef({ + text: "", + url: "", + }); + + const tts = async (text: string, locale: string) => { + if (lastTTS.current.text !== text) { + const url = await getTTSAudioUrl( + text, + VOICES.find((v) => v.locale === locale)!.short_name, + ); + await load(url); + lastTTS.current.text = text; + lastTTS.current.url = url; + } + play(); + }; + + const translate = async () => { + if (processing) return; + setProcessing(true); + + if (!taref.current) return; + const text = taref.current.value; + + const newItem: { + text1: string | null; + text2: string | null; + locale1: string | null; + locale2: string | null; + } = { + text1: text, + text2: null, + locale1: null, + locale2: null, + }; + + const checkUpdateLocalStorage = (item: typeof newItem) => { + if (item.text1 && item.text2 && item.locale1 && item.locale2) { + setHistory(tlsoPush(item as z.infer)); + } + }; + const innerStates = { + text2: false, + ipa1: !genIpa, + ipa2: !genIpa, + }; + const checkUpdateProcessStates = () => { + if (innerStates.ipa1 && innerStates.ipa2 && innerStates.text2) + setProcessing(false); + }; + const updateState = (stateName: keyof typeof innerStates) => () => { + innerStates[stateName] = true; + checkUpdateLocalStorage(newItem); + checkUpdateProcessStates(); + }; + + // Fetch locale for text1 + letsFetch( + `/api/v1/locale?text=${encodeURIComponent(text)}`, + (locale: string) => { + newItem.locale1 = locale; + }, + console.log, + () => {}, + ); + + if (genIpa) + // Fetch IPA for text1 + letsFetch( + `/api/v1/ipa?text=${encodeURIComponent(text)}`, + (ipa: string) => setIpaTexts((prev) => [ipa, prev[1]]), + console.log, + updateState("ipa1"), + ); + // Fetch translation for text2 + letsFetch( + `/api/v1/translate?text=${encodeURIComponent(text)}&lang=${encodeURIComponent(lang)}`, + (text2) => { + setTresult(text2); + newItem.text2 = text2; + if (genIpa) + // Fetch IPA for text2 + letsFetch( + `/api/v1/ipa?text=${encodeURIComponent(text2)}`, + (ipa: string) => setIpaTexts((prev) => [prev[0], ipa]), + console.log, + updateState("ipa2"), + ); + // Fetch locale for text2 + letsFetch( + `/api/v1/locale?text=${encodeURIComponent(text2)}`, + (locale: string) => { + newItem.locale2 = locale; + }, + console.log, + () => {}, + ); + }, + console.log, + updateState("text2"), + ); + }; + + return ( + <> + {/* TCard Component */} +
+ {/* Card Component - Left Side */} +
+ {/* ICard1 Component */} +
+ +
+ {ipaTexts[0]} +
+
+ { + await navigator.clipboard.writeText( + taref.current?.value || "", + ); + }} + > + { + const t = taref.current?.value; + if (!t) return; + tts(t, tlso.get().find((v) => v.text1 === t)?.locale1 || ""); + }} + > +
+
+
+ {t("detectLanguage")} + setGenIpa((prev) => !prev)} + > + {t("generateIPA")} + +
+
+ + {/* Card Component - Right Side */} +
+ {/* ICard2 Component */} +
+
{tresult}
+
+ {ipaTexts[1]} +
+
+ { + await navigator.clipboard.writeText(tresult); + }} + > + { + tts( + tresult, + tlso.get().find((v) => v.text2 === tresult)?.locale2 || "", + ); + }} + > +
+
+
+ {t("translateInto")} + setLang("chinese")} + > + {t("chinese")} + + setLang("english")} + > + {t("english")} + + setLang("italian")} + > + {t("italian")} + + { + const newLang = prompt(t("enterLanguage")); + if (newLang) { + setLang(newLang); + } + }} + > + {t("other")} + +
+
+
+ + {/* TranslateButton Component */} +
+ +
+ {history.length > 0 && ( +
+

{t("history")}

+
+ {history.toReversed().map((item, index) => ( +
+
+
+

{item.text1}

+

{item.text2}

+
+
+ + +
+
+
+ ))} +
+ {showAddToFolder && ( + + )} +
+ )} + + ); +} diff --git a/src/lib/actions/translatorActions.ts b/src/lib/actions/translatorActions.ts new file mode 100644 index 0000000..21194fb --- /dev/null +++ b/src/lib/actions/translatorActions.ts @@ -0,0 +1,29 @@ +"use server"; + +import { getLLMAnswer } from "../ai"; + +export const genIPA = async (text: string) => { + return ( + "[" + + ( + await getLLMAnswer( + `${text}\n请生成以上文本的严式国际音标,然后直接发给我,不要附带任何说明,不要擅自增减符号。`, + ) + ) + .replaceAll("[", "") + .replaceAll("]", "") + + "]" + ); +}; + +export const genLocale = async (text: string) => { + return await getLLMAnswer( + `${text}\n推断以上文本的地区(locale),然后直接发给我,形如如zh-CN,不要附带任何说明,不要擅自增减符号。`, + ); +}; + +export const genTranslation = async (text: string, targetLanguage: string) => { + return await getLLMAnswer( + `${text}\n请将以上文本翻译到${targetLanguage},然后直接发给我,不要附带任何说明,不要擅自增减符号。`, + ); +}; diff --git a/src/lib/localStorageOperators.ts b/src/lib/localStorageOperators.ts index eefb967..6a2bba7 100644 --- a/src/lib/localStorageOperators.ts +++ b/src/lib/localStorageOperators.ts @@ -10,6 +10,7 @@ const MAX_HISTORY_LENGTH = 50; export const tlso = getLocalStorageOperator< typeof TranslationHistoryArraySchema >("translator", TranslationHistoryArraySchema); + export const tlsoPush = (item: z.infer) => { const oldHistory = tlso.get(); if (oldHistory.some((v) => shallowEqual(v, item))) return oldHistory;