This commit is contained in:
@@ -4,4 +4,5 @@ node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
.next
|
||||
.git
|
||||
.git
|
||||
certificates
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -47,3 +47,5 @@ build.sh
|
||||
|
||||
test.ts
|
||||
/generated/prisma
|
||||
|
||||
certificates
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"license": "GPL-3.0-only",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"dev": "next dev --turbopack --experimental-https",
|
||||
"build": "next build --turbopack",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
|
||||
@@ -6,7 +6,7 @@ import Container from "@/components/cards/Container";
|
||||
import { useState } from "react";
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||
import { getTTSAudioUrl } from "@/lib/tts";
|
||||
import { getTTSAudioUrl } from "@/lib/browser/tts";
|
||||
import { VOICES } from "@/config/locales";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@ import { getTranslations } from "next-intl/server";
|
||||
import {
|
||||
getFoldersWithTotalPairsByOwner,
|
||||
getOwnerByFolderId,
|
||||
} from "@/lib/services/folderService";
|
||||
} from "@/lib/actions/services/folderService";
|
||||
import { isNonNegativeInteger } from "@/lib/utils";
|
||||
import FolderSelector from "./FolderSelector";
|
||||
import Memorize from "./Memorize";
|
||||
import { getTextPairsByFolderId } from "@/lib/services/textPairService";
|
||||
import { getTextPairsByFolderId } from "@/lib/actions/services/textPairService";
|
||||
|
||||
export default async function MemorizePage({
|
||||
searchParams,
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { inspect } from "@/lib/utils";
|
||||
|
||||
export default function SubtitleDisplay({ subtitle }: { subtitle: string }) {
|
||||
const words = subtitle.match(/\b[\w']+(?:-[\w']+)*\b/g) || [];
|
||||
let i = 0;
|
||||
return (
|
||||
<div className="w-full subtitle overflow-auto h-16 mt-2 break-words bg-black/50 font-sans text-white text-center text-2xl">
|
||||
<div className="w-full subtitle overflow-auto h-16 mt-2 wrap-break-word bg-black/50 font-sans text-white text-center text-2xl">
|
||||
{words.map((v) => (
|
||||
<span
|
||||
onClick={inspect(v)}
|
||||
onClick={() => {
|
||||
window.open(
|
||||
`https://www.youdao.com/result?word=${v}&lang=en`,
|
||||
"_blank",
|
||||
);
|
||||
}}
|
||||
key={i++}
|
||||
className="hover:bg-gray-700 hover:underline hover:cursor-pointer"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { getLocalStorageOperator } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
import z from "zod";
|
||||
import {
|
||||
@@ -10,6 +9,7 @@ import {
|
||||
import IconClick from "@/components/IconClick";
|
||||
import IMAGES from "@/config/images";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
|
||||
|
||||
interface TextCardProps {
|
||||
item: z.infer<typeof TextSpeakerItemSchema>;
|
||||
|
||||
@@ -8,13 +8,15 @@ import {
|
||||
TextSpeakerArraySchema,
|
||||
TextSpeakerItemSchema,
|
||||
} from "@/lib/interfaces";
|
||||
import { getLocalStorageOperator, getTTSAudioUrl } from "@/lib/utils";
|
||||
import { ChangeEvent, useEffect, useRef, useState } from "react";
|
||||
import z from "zod";
|
||||
import SaveList from "./SaveList";
|
||||
|
||||
import { VOICES } from "@/config/locales";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
|
||||
import { getTTSAudioUrl } from "@/lib/browser/tts";
|
||||
import { genIPA, genLocale } from "@/lib/actions/translatorActions";
|
||||
|
||||
export default function TextSpeakerPage() {
|
||||
const t = useTranslations("text_speaker");
|
||||
@@ -94,14 +96,9 @@ export default function TextSpeakerPage() {
|
||||
let theLocale = locale;
|
||||
if (!theLocale) {
|
||||
console.log("downloading text info");
|
||||
const params = new URLSearchParams({
|
||||
text: textRef.current.slice(0, 30),
|
||||
});
|
||||
const textinfo = await (
|
||||
await fetch(`/api/locale?${params}`)
|
||||
).json();
|
||||
setLocale(textinfo.locale);
|
||||
theLocale = textinfo.locale as string;
|
||||
const tmp_locale = await genLocale(textRef.current.slice(0, 30));
|
||||
setLocale(tmp_locale);
|
||||
theLocale = tmp_locale;
|
||||
}
|
||||
|
||||
const voice = VOICES.find((v) => v.locale.startsWith(theLocale));
|
||||
@@ -184,22 +181,16 @@ export default function TextSpeakerPage() {
|
||||
let theLocale = locale;
|
||||
if (!theLocale) {
|
||||
console.log("downloading text info");
|
||||
const params = new URLSearchParams({
|
||||
text: textRef.current.slice(0, 30),
|
||||
});
|
||||
const textinfo = await (await fetch(`/api/locale?${params}`)).json();
|
||||
setLocale(textinfo.locale);
|
||||
theLocale = textinfo.locale as string;
|
||||
const tmp_locale = await genLocale(textRef.current.slice(0, 30));
|
||||
setLocale(tmp_locale);
|
||||
theLocale = tmp_locale;
|
||||
}
|
||||
|
||||
let theIPA = ipa;
|
||||
if (ipa.length === 0 && ipaEnabled) {
|
||||
const params = new URLSearchParams({
|
||||
text: textRef.current,
|
||||
});
|
||||
const tmp = await (await fetch(`/api/ipa?${params}`)).json();
|
||||
setIPA(tmp.ipa);
|
||||
theIPA = tmp.ipa;
|
||||
const tmp_ipa = await genIPA(textRef.current);
|
||||
setIPA(tmp_ipa);
|
||||
theIPA = tmp_ipa;
|
||||
}
|
||||
|
||||
const save = getFromLocalStorage();
|
||||
|
||||
@@ -5,9 +5,9 @@ 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 { getFoldersByOwner } from "@/lib/actions/services/folderService";
|
||||
import { Folder } from "lucide-react";
|
||||
import { createTextPair } from "@/lib/services/textPairService";
|
||||
import { createTextPair } from "@/lib/actions/services/textPairService";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Container from "@/components/cards/Container";
|
||||
import { useEffect, useState } from "react";
|
||||
import { folder } from "../../../../generated/prisma/browser";
|
||||
import { getFoldersByOwner } from "@/lib/services/folderService";
|
||||
import { getFoldersByOwner } from "@/lib/actions/services/folderService";
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import { Folder } from "lucide-react";
|
||||
|
||||
|
||||
@@ -6,9 +6,8 @@ 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 { shallowEqual } from "@/lib/utils";
|
||||
import { tlsoPush, tlso } from "@/lib/browser/localStorageOperators";
|
||||
import { getTTSAudioUrl } from "@/lib/browser/tts";
|
||||
import { Plus, Trash } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRef, useState } from "react";
|
||||
@@ -22,7 +21,8 @@ import {
|
||||
import { toast } from "sonner";
|
||||
import FolderSelector from "./FolderSelector";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { createTextPair } from "@/lib/services/textPairService";
|
||||
import { createTextPair } from "@/lib/actions/services/textPairService";
|
||||
import { shallowEqual } from "@/lib/utils";
|
||||
|
||||
export default function TranslatorPage() {
|
||||
const t = useTranslations("translator");
|
||||
@@ -64,6 +64,7 @@ export default function TranslatorPage() {
|
||||
lastTTS.current.url = url;
|
||||
} catch (error) {
|
||||
toast.error("Failed to generate audio");
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
await play();
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
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<typeof TranslationHistorySchema>;
|
||||
setShow: Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const AddToFolder: React.FC<AddToFolderProps> = ({ item, setShow }) => {
|
||||
const session = useSession();
|
||||
const [folders, setFolders] = useState<folder[]>([]);
|
||||
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 (
|
||||
<div className="fixed left-0 top-0 z-50 w-screen h-screen bg-black/50 flex justify-center items-center">
|
||||
<Container className="p-6">
|
||||
<div>{t("notAuthenticated")}</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="fixed left-0 top-0 z-50 w-screen h-screen bg-black/50 flex justify-center items-center">
|
||||
<Container className="p-6">
|
||||
<h1>{t("chooseFolder")}</h1>
|
||||
<div className="border border-gray-200 rounded-2xl">
|
||||
{(loading && <span>...</span>) ||
|
||||
(folders.length > 0 &&
|
||||
folders.map((folder) => (
|
||||
<button
|
||||
key={folder.id}
|
||||
className="p-2 flex items-center justify-start hover:bg-gray-50 gap-2 hover:cursor-pointer w-full border-b border-gray-200"
|
||||
onClick={() => {
|
||||
createTextPair({
|
||||
text1: item.text1,
|
||||
text2: item.text2,
|
||||
locale1: item.locale1,
|
||||
locale2: item.locale2,
|
||||
folders: {
|
||||
connect: {
|
||||
id: folder.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(t("success"));
|
||||
setShow(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(t("error"));
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Folder />
|
||||
{t("folderInfo", { id: folder.id, name: folder.name })}
|
||||
</button>
|
||||
))) || <div>{t("noFolders")}</div>}
|
||||
</div>
|
||||
<LightButton onClick={() => setShow(false)}>{t("close")}</LightButton>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddToFolder;
|
||||
@@ -1,307 +0,0 @@
|
||||
"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<HTMLTextAreaElement>(null);
|
||||
const [lang, setLang] = useState<string>("chinese");
|
||||
const [tresult, setTresult] = useState<string>("");
|
||||
const [genIpa, setGenIpa] = useState(true);
|
||||
const [ipaTexts, setIpaTexts] = useState(["", ""]);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const { load, play } = useAudioPlayer();
|
||||
const [history, setHistory] = useState<
|
||||
z.infer<typeof TranslationHistorySchema>[]
|
||||
>(tlso.get());
|
||||
const [showAddToFolder, setShowAddToFolder] = useState(false);
|
||||
const [addToFolderItem, setAddToFolderItem] = useState<z.infer<
|
||||
typeof TranslationHistorySchema
|
||||
> | 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<typeof TranslationHistorySchema>));
|
||||
}
|
||||
};
|
||||
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 */}
|
||||
<div className="w-screen flex flex-col md:flex-row md:justify-between gap-2 p-2">
|
||||
{/* Card Component - Left Side */}
|
||||
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
|
||||
{/* ICard1 Component */}
|
||||
<div className="border border-gray-200 rounded-2xl w-full h-64 p-2">
|
||||
<textarea
|
||||
className="resize-none h-8/12 w-full focus:outline-0"
|
||||
ref={taref}
|
||||
onKeyDown={(e) => {
|
||||
if (e.ctrlKey && e.key === "Enter") translate();
|
||||
}}
|
||||
></textarea>
|
||||
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
|
||||
{ipaTexts[0]}
|
||||
</div>
|
||||
<div className="h-2/12 w-full flex justify-end items-center">
|
||||
<IconClick
|
||||
src={IMAGES.copy_all}
|
||||
alt="copy"
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
taref.current?.value || "",
|
||||
);
|
||||
}}
|
||||
></IconClick>
|
||||
<IconClick
|
||||
src={IMAGES.play_arrow}
|
||||
alt="play"
|
||||
onClick={() => {
|
||||
const t = taref.current?.value;
|
||||
if (!t) return;
|
||||
tts(t, tlso.get().find((v) => v.text1 === t)?.locale1 || "");
|
||||
}}
|
||||
></IconClick>
|
||||
</div>
|
||||
</div>
|
||||
<div className="option1 w-full flex flex-row justify-between items-center">
|
||||
<span>{t("detectLanguage")}</span>
|
||||
<LightButton
|
||||
selected={genIpa}
|
||||
onClick={() => setGenIpa((prev) => !prev)}
|
||||
>
|
||||
{t("generateIPA")}
|
||||
</LightButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card Component - Right Side */}
|
||||
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
|
||||
{/* ICard2 Component */}
|
||||
<div className="bg-gray-100 rounded-2xl w-full h-64 p-2">
|
||||
<div className="h-2/3 w-full overflow-y-auto">{tresult}</div>
|
||||
<div className="ipa w-full h-1/6 overflow-y-auto text-gray-600">
|
||||
{ipaTexts[1]}
|
||||
</div>
|
||||
<div className="h-1/6 w-full flex justify-end items-center">
|
||||
<IconClick
|
||||
src={IMAGES.copy_all}
|
||||
alt="copy"
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(tresult);
|
||||
}}
|
||||
></IconClick>
|
||||
<IconClick
|
||||
src={IMAGES.play_arrow}
|
||||
alt="play"
|
||||
onClick={() => {
|
||||
tts(
|
||||
tresult,
|
||||
tlso.get().find((v) => v.text2 === tresult)?.locale2 || "",
|
||||
);
|
||||
}}
|
||||
></IconClick>
|
||||
</div>
|
||||
</div>
|
||||
<div className="option2 w-full flex gap-1 items-center flex-wrap">
|
||||
<span>{t("translateInto")}</span>
|
||||
<LightButton
|
||||
selected={lang === "chinese"}
|
||||
onClick={() => setLang("chinese")}
|
||||
>
|
||||
{t("chinese")}
|
||||
</LightButton>
|
||||
<LightButton
|
||||
selected={lang === "english"}
|
||||
onClick={() => setLang("english")}
|
||||
>
|
||||
{t("english")}
|
||||
</LightButton>
|
||||
<LightButton
|
||||
selected={lang === "italian"}
|
||||
onClick={() => setLang("italian")}
|
||||
>
|
||||
{t("italian")}
|
||||
</LightButton>
|
||||
<LightButton
|
||||
selected={!["chinese", "english", "italian"].includes(lang)}
|
||||
onClick={() => {
|
||||
const newLang = prompt(t("enterLanguage"));
|
||||
if (newLang) {
|
||||
setLang(newLang);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("other")}
|
||||
</LightButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TranslateButton Component */}
|
||||
<div className="w-screen flex justify-center items-center">
|
||||
<button
|
||||
className={`duration-150 ease-in text-xl font-extrabold border rounded-4xl p-3 border-gray-200 h-16 ${processing ? "bg-gray-200" : "bg-white hover:bg-gray-200 hover:cursor-pointer"}`}
|
||||
onClick={translate}
|
||||
>
|
||||
{t("translate")}
|
||||
</button>
|
||||
</div>
|
||||
{history.length > 0 && (
|
||||
<div className="m-6 flex flex-col items-center">
|
||||
<h1 className="text-2xl font-light">{t("history")}</h1>
|
||||
<div className="border border-gray-200 rounded-2xl m-4">
|
||||
{history.toReversed().map((item, index) => (
|
||||
<div key={index}>
|
||||
<div className="border-b border-gray-200 p-2 group hover:bg-gray-50 flex gap-2 flex-row justify-between items-start">
|
||||
<div className="flex-1 flex flex-col">
|
||||
<p className="text-sm font-light">{item.text1}</p>
|
||||
<p className="text-sm font-light">{item.text2}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAddToFolder(true);
|
||||
setAddToFolderItem(item);
|
||||
}}
|
||||
className="hover:bg-gray-200 hover:cursor-pointer rounded-4xl border border-gray-200 w-8 h-8 flex justify-center items-center"
|
||||
>
|
||||
<Plus />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setHistory(
|
||||
tlso.set(
|
||||
tlso.get().filter((v) => !shallowEqual(v, item)),
|
||||
) || [],
|
||||
);
|
||||
}}
|
||||
className="hover:bg-gray-200 hover:cursor-pointer rounded-4xl border border-gray-200 w-8 h-8 flex justify-center items-center"
|
||||
>
|
||||
<Trash />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{showAddToFolder && (
|
||||
<AddToFolder setShow={setShowAddToFolder} item={addToFolderItem!} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { callZhipuAPI, handleAPIError } from "@/lib/utils";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
async function getIPA(text: string) {
|
||||
console.log(`get ipa of ${text}`);
|
||||
const messages = [
|
||||
{
|
||||
role: "user",
|
||||
content: `
|
||||
请推断以下文本的语言,生成对应的宽式国际音标(IPA)以及locale,以JSON格式返回
|
||||
[${text}]
|
||||
结果如:
|
||||
{
|
||||
"ipa": "[ni˨˩˦ xɑʊ˨˩˦]",
|
||||
"locale": "zh-CN"
|
||||
}
|
||||
注意:
|
||||
直接返回json文本,
|
||||
ipa一定要加[],
|
||||
locale如果可能有多个,选取最可能的一个,其中使用符号"-",
|
||||
locale如果推断失败,就返回{"locale": "en-US"}
|
||||
`,
|
||||
},
|
||||
];
|
||||
try {
|
||||
const response = await callZhipuAPI(messages);
|
||||
let to_parse = response.choices[0].message.content.trim() as string;
|
||||
if (to_parse.startsWith("`"))
|
||||
to_parse = to_parse.slice(7, to_parse.length - 3);
|
||||
if (to_parse.length === 0) throw Error("ai啥也每说");
|
||||
return JSON.parse(to_parse);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const text = searchParams.get("text");
|
||||
|
||||
if (!text) {
|
||||
return NextResponse.json(
|
||||
{ error: "查询参数错误", message: "text参数是必需的" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const textInfo = await getIPA(text);
|
||||
if (!textInfo) {
|
||||
return NextResponse.json(
|
||||
{ error: "服务暂时不可用", message: "LLM API 请求失败" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(textInfo, { status: 200 });
|
||||
} catch (error) {
|
||||
handleAPIError(error, "请稍后再试");
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { callZhipuAPI } from "@/lib/utils";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
async function getLocale(text: string) {
|
||||
console.log(`get locale of ${text}`);
|
||||
const messages = [
|
||||
{
|
||||
role: "user",
|
||||
content: `
|
||||
请推断以下文本的的locale,以JSON格式返回
|
||||
[${text}]
|
||||
结果如:
|
||||
{
|
||||
"locale": "zh-CN"
|
||||
}
|
||||
注意:
|
||||
直接返回json文本,
|
||||
locale如果可能有多个,选取最可能的一个,其中使用符号"-",
|
||||
locale如果推断失败,就返回{"locale": "en-US"}
|
||||
`,
|
||||
},
|
||||
];
|
||||
try {
|
||||
const response = await callZhipuAPI(messages);
|
||||
let to_parse = response.choices[0].message.content.trim() as string;
|
||||
if (to_parse.startsWith("`"))
|
||||
to_parse = to_parse.slice(7, to_parse.length - 3);
|
||||
if (to_parse.length === 0) throw Error("ai啥也每说");
|
||||
return JSON.parse(to_parse);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const text = searchParams.get("text");
|
||||
|
||||
if (!text) {
|
||||
return NextResponse.json(
|
||||
{ error: "查询参数错误", message: "text参数是必需的" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const textInfo = await getLocale(text.slice(0, 30));
|
||||
if (!textInfo) {
|
||||
return NextResponse.json(
|
||||
{ error: "服务暂时不可用", message: "LLM API 请求失败" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(textInfo, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("API 错误:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "服务器内部错误", message: "请稍后重试" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const url = request.url;
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: "Hello World",
|
||||
url: url,
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { callZhipuAPI } from "@/lib/utils";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
async function translate(text: string, target_lang: string) {
|
||||
console.log(`translate "${text}" into ${target_lang}`);
|
||||
const messages = [
|
||||
{
|
||||
role: "user",
|
||||
content: `
|
||||
请推断以下文本的locale,并翻译到目标语言${target_lang},同样需要locale信息,以JSON格式返回
|
||||
[${text}]
|
||||
结果如:
|
||||
{
|
||||
"source_locale": "zh-CN",
|
||||
"target_locale": "de-DE",
|
||||
"target_text": "Halo"
|
||||
}
|
||||
注意:
|
||||
直接返回json文本,
|
||||
locale如果可能有多个,选取最可能的一个,其中使用符号"-",
|
||||
locale如果推断失败,就当作是en-US
|
||||
`,
|
||||
},
|
||||
];
|
||||
try {
|
||||
const response = await callZhipuAPI(messages);
|
||||
let to_parse = response.choices[0].message.content.trim() as string;
|
||||
if (to_parse.startsWith("`"))
|
||||
to_parse = to_parse.slice(7, to_parse.length - 3);
|
||||
if (to_parse.length === 0) throw Error("ai啥也每说");
|
||||
return JSON.parse(to_parse);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const text = searchParams.get("text");
|
||||
const target_lang = searchParams.get("target");
|
||||
|
||||
if (!text || !target_lang) {
|
||||
return NextResponse.json(
|
||||
{ error: "查询参数错误", message: "text参数, target参数是必需的" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const textInfo = await translate(text, target_lang);
|
||||
if (!textInfo) {
|
||||
return NextResponse.json(
|
||||
{ error: "服务暂时不可用", message: "LLM API 请求失败" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json(textInfo, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("API 错误:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "服务器内部错误", message: "请稍后重试" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { simpleGetLLMAnswer } from "@/lib/ai";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return await simpleGetLLMAnswer(
|
||||
`请生成[[[%s]]]的严式国际音标(International Phonetic Alphabet),然后直接发给我,不要附带任何说明。`,
|
||||
req.nextUrl.searchParams,
|
||||
["text"],
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { simpleGetLLMAnswer } from "@/lib/ai";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return await simpleGetLLMAnswer(
|
||||
`请根据文本[[[%s]]]推断地区(locale),形如zh-CN、en-US,然后直接发给我,不要附带任何说明。`,
|
||||
req.nextUrl.searchParams,
|
||||
["text"],
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { simpleGetLLMAnswer } from "@/lib/ai";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return await simpleGetLLMAnswer(
|
||||
`请翻译文本[[[%s]]]到语言[[[%s]]]然后直接发给我,不要附带任何说明,不要新增任何符号。`,
|
||||
req.nextUrl.searchParams,
|
||||
["text", "lang"],
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
createFolder,
|
||||
deleteFolderById,
|
||||
getFoldersWithTotalPairsByOwner,
|
||||
} from "@/lib/services/folderService";
|
||||
} from "@/lib/actions/services/folderService";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface FolderProps {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
createTextPair,
|
||||
deleteTextPairById,
|
||||
getTextPairsByFolderId,
|
||||
} from "@/lib/services/textPairService";
|
||||
} from "@/lib/actions/services/textPairService";
|
||||
import AddTextPairModal from "./AddTextPairModal";
|
||||
import TextPairCard from "./TextPairCard";
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Edit, Trash2 } from "lucide-react";
|
||||
import { TextPair } from "./InFolder";
|
||||
import { updateTextPairById } from "@/lib/services/textPairService";
|
||||
import { updateTextPairById } from "@/lib/actions/services/textPairService";
|
||||
import { useState } from "react";
|
||||
import { text_pairUpdateInput } from "../../../../generated/prisma/models";
|
||||
import UpdateTextPairModal from "./UpdateTextPairModal";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import InFolder from "./InFolder";
|
||||
import { getOwnerByFolderId } from "@/lib/services/folderService";
|
||||
import { getOwnerByFolderId } from "@/lib/actions/services/folderService";
|
||||
export default async function FoldersPage({
|
||||
params,
|
||||
}: {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import type { Viewport } from "next";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import SessionWrapper from "@/lib/SessionWrapper";
|
||||
import SessionWrapper from "@/components/SessionWrapper";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ type AudioPlayerError = Error | null;
|
||||
|
||||
export function useAudioPlayer() {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const [state, setState] = useState({
|
||||
isPlaying: false,
|
||||
isLoading: false,
|
||||
@@ -32,7 +33,10 @@ export function useAudioPlayer() {
|
||||
setState((prev) => ({ ...prev, isPlaying: false, currentTime: 0 }));
|
||||
const handleError = (e: Event) => {
|
||||
const target = e.target as HTMLAudioElement;
|
||||
setError(new Error(target.error?.message || "Audio playback error"));
|
||||
// 忽略中止错误,这些是预期的
|
||||
if (target.error?.code !== MediaError.MEDIA_ERR_ABORTED) {
|
||||
setError(new Error(target.error?.message || "Audio playback error"));
|
||||
}
|
||||
setState((prev) => ({ ...prev, isLoading: false, isPlaying: false }));
|
||||
};
|
||||
|
||||
@@ -44,6 +48,11 @@ export function useAudioPlayer() {
|
||||
audio.addEventListener("error", handleError);
|
||||
|
||||
return () => {
|
||||
// 中止所有进行中的操作
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
audio.removeEventListener("loadstart", handleLoadStart);
|
||||
audio.removeEventListener("canplay", handleCanPlay);
|
||||
audio.removeEventListener("loadedmetadata", handleLoadedMetadata);
|
||||
@@ -67,6 +76,10 @@ export function useAudioPlayer() {
|
||||
await audioRef.current.play();
|
||||
setState((prev) => ({ ...prev, isPlaying: true }));
|
||||
} catch (err) {
|
||||
// 忽略中止错误
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
const error =
|
||||
err instanceof Error ? err : new Error("Failed to play audio");
|
||||
setError(error);
|
||||
@@ -102,7 +115,7 @@ export function useAudioPlayer() {
|
||||
if (audioRef.current) {
|
||||
const clampedTime = Math.max(
|
||||
0,
|
||||
Math.min(audioRef.current.duration, time),
|
||||
Math.min(audioRef.current.duration || 0, time),
|
||||
);
|
||||
audioRef.current.currentTime = clampedTime;
|
||||
setState((prev) => ({ ...prev, currentTime: clampedTime }));
|
||||
@@ -112,44 +125,110 @@ export function useAudioPlayer() {
|
||||
const load = useCallback(async (audioUrl: string) => {
|
||||
if (!audioRef.current) return;
|
||||
|
||||
// 中止之前的加载操作
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
setState((prev) => ({ ...prev, isLoading: true }));
|
||||
|
||||
// Only load if URL is different
|
||||
// 如果信号已经中止,直接返回
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 重置当前播放状态
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = 0;
|
||||
}
|
||||
|
||||
// Only load if URL is different or we need to force reload
|
||||
if (audioRef.current.src !== audioUrl) {
|
||||
audioRef.current.src = audioUrl;
|
||||
await new Promise((resolve, reject) => {
|
||||
if (!audioRef.current)
|
||||
return reject(new Error("Audio element not found"));
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (!audioRef.current) {
|
||||
reject(new Error("Audio element not found"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否已经中止
|
||||
if (abortController.signal.aborted) {
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
return;
|
||||
}
|
||||
|
||||
const handleCanPlay = () => {
|
||||
audioRef.current?.removeEventListener("canplay", handleCanPlay);
|
||||
audioRef.current?.removeEventListener("error", handleError);
|
||||
resolve(void 0);
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
audioRef.current?.removeEventListener("canplay", handleCanPlay);
|
||||
audioRef.current?.removeEventListener("error", handleError);
|
||||
reject(new Error("Failed to load audio"));
|
||||
const handleError = (e: Event) => {
|
||||
cleanup();
|
||||
const target = e.target as HTMLAudioElement;
|
||||
// 如果是中止错误,不视为真正的错误
|
||||
if (target.error?.code === MediaError.MEDIA_ERR_ABORTED) {
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
} else {
|
||||
reject(new Error("Failed to load audio"));
|
||||
}
|
||||
};
|
||||
|
||||
audioRef.current.addEventListener("canplay", handleCanPlay);
|
||||
audioRef.current.addEventListener("error", handleError);
|
||||
const handleAbort = () => {
|
||||
cleanup();
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
audioRef.current?.removeEventListener("canplay", handleCanPlay);
|
||||
audioRef.current?.removeEventListener("error", handleError);
|
||||
abortController.signal.removeEventListener("abort", handleAbort);
|
||||
};
|
||||
|
||||
audioRef.current.addEventListener("canplay", handleCanPlay, { once: true });
|
||||
audioRef.current.addEventListener("error", handleError, { once: true });
|
||||
abortController.signal.addEventListener("abort", handleAbort, { once: true });
|
||||
|
||||
// 如果音频已经可以播放,立即解析
|
||||
if (audioRef.current.readyState >= HTMLMediaElement.HAVE_ENOUGH_DATA) {
|
||||
handleCanPlay();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, isLoading: false }));
|
||||
if (!abortController.signal.aborted) {
|
||||
setState((prev) => ({ ...prev, isLoading: false }));
|
||||
}
|
||||
} catch (err) {
|
||||
// 忽略中止错误
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
const error =
|
||||
err instanceof Error ? err : new Error("Failed to load audio");
|
||||
setError(error);
|
||||
setState((prev) => ({ ...prev, isLoading: false }));
|
||||
throw error;
|
||||
} finally {
|
||||
// 清理中止控制器,如果仍然是当前的话
|
||||
if (abortControllerRef.current === abortController) {
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 新增:同时加载和播放的便捷方法
|
||||
const playAudio = useCallback(async (audioUrl: string) => {
|
||||
await load(audioUrl);
|
||||
await play();
|
||||
}, [load, play]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
play,
|
||||
@@ -158,7 +237,8 @@ export function useAudioPlayer() {
|
||||
setVolume,
|
||||
seek,
|
||||
load,
|
||||
playAudio, // 新增的便捷方法
|
||||
error,
|
||||
audioRef,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
"use server";
|
||||
|
||||
import { format } from "util";
|
||||
|
||||
async function callZhipuAPI(
|
||||
@@ -3,8 +3,8 @@
|
||||
import {
|
||||
folderCreateInput,
|
||||
folderUpdateInput,
|
||||
} from "../../../generated/prisma/models";
|
||||
import prisma from "../db";
|
||||
} from "../../../../generated/prisma/models";
|
||||
import prisma from "../../db";
|
||||
|
||||
export async function getFoldersByOwner(owner: string) {
|
||||
const folders = await prisma.folder.findMany({
|
||||
@@ -3,8 +3,8 @@
|
||||
import {
|
||||
text_pairCreateInput,
|
||||
text_pairUpdateInput,
|
||||
} from "../../../generated/prisma/models";
|
||||
import prisma from "../db";
|
||||
} from "../../../../generated/prisma/models";
|
||||
import prisma from "../../db";
|
||||
|
||||
export async function createTextPair(data: text_pairCreateInput) {
|
||||
await prisma.text_pair.create({
|
||||
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { getLLMAnswer } from "../ai";
|
||||
import { getLLMAnswer } from "./ai";
|
||||
|
||||
export const genIPA = async (text: string) => {
|
||||
return (
|
||||
|
||||
58
src/lib/browser/localStorageOperators.ts
Normal file
58
src/lib/browser/localStorageOperators.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
TranslationHistoryArraySchema,
|
||||
TranslationHistorySchema,
|
||||
} from "@/lib/interfaces";
|
||||
import z from "zod";
|
||||
import { shallowEqual } from "../utils";
|
||||
|
||||
export const getLocalStorageOperator = <T extends z.ZodTypeAny>(
|
||||
key: string,
|
||||
schema: T,
|
||||
) => {
|
||||
return {
|
||||
get: (): z.infer<T> => {
|
||||
try {
|
||||
const item = globalThis.localStorage.getItem(key);
|
||||
|
||||
if (!item) return [];
|
||||
|
||||
const rawData = JSON.parse(item) as z.infer<T>;
|
||||
const result = schema.safeParse(rawData);
|
||||
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
} else {
|
||||
console.error(
|
||||
"Invalid data structure in localStorage:",
|
||||
result.error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to parse ${key} data:`, e);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
set: (data: z.infer<T>) => {
|
||||
if (!globalThis.localStorage) return;
|
||||
globalThis.localStorage.setItem(key, JSON.stringify(data));
|
||||
return data;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
const MAX_HISTORY_LENGTH = 50;
|
||||
export const tlso = getLocalStorageOperator<
|
||||
typeof TranslationHistoryArraySchema
|
||||
>("translator", TranslationHistoryArraySchema);
|
||||
|
||||
export const tlsoPush = (item: z.infer<typeof TranslationHistorySchema>) => {
|
||||
const oldHistory = tlso.get();
|
||||
if (oldHistory.some((v) => shallowEqual(v, item))) return oldHistory;
|
||||
|
||||
const newHistory = [...oldHistory, item].slice(-MAX_HISTORY_LENGTH);
|
||||
tlso.set(newHistory);
|
||||
|
||||
return newHistory;
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ProsodyOptions } from "edge-tts-universal";
|
||||
import { EdgeTTS } from "edge-tts-universal/browser";
|
||||
import { ProsodyOptions, EdgeTTS } from "edge-tts-universal/browser";
|
||||
|
||||
export async function getTTSAudioUrl(
|
||||
text: string,
|
||||
@@ -1,22 +0,0 @@
|
||||
import {
|
||||
TranslationHistoryArraySchema,
|
||||
TranslationHistorySchema,
|
||||
} from "@/lib/interfaces";
|
||||
import { getLocalStorageOperator, shallowEqual } from "@/lib/utils";
|
||||
import z from "zod";
|
||||
|
||||
const MAX_HISTORY_LENGTH = 50;
|
||||
|
||||
export const tlso = getLocalStorageOperator<
|
||||
typeof TranslationHistoryArraySchema
|
||||
>("translator", TranslationHistoryArraySchema);
|
||||
|
||||
export const tlsoPush = (item: z.infer<typeof TranslationHistorySchema>) => {
|
||||
const oldHistory = tlso.get();
|
||||
if (oldHistory.some((v) => shallowEqual(v, item))) return oldHistory;
|
||||
|
||||
const newHistory = [...oldHistory, item].slice(-MAX_HISTORY_LENGTH);
|
||||
tlso.set(newHistory);
|
||||
|
||||
return newHistory;
|
||||
};
|
||||
127
src/lib/utils.ts
127
src/lib/utils.ts
@@ -1,130 +1,3 @@
|
||||
import { EdgeTTS, ProsodyOptions } from "edge-tts-universal/browser";
|
||||
import { env } from "process";
|
||||
import z from "zod";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function inspect(word: string) {
|
||||
const goto = (url: string) => {
|
||||
window.open(url, "_blank");
|
||||
};
|
||||
return () => {
|
||||
word = word.toLowerCase();
|
||||
goto(`https://www.youdao.com/result?word=${word}&lang=en`);
|
||||
};
|
||||
}
|
||||
|
||||
export function urlGoto(url: string) {
|
||||
window.open(url, "_blank");
|
||||
}
|
||||
const API_KEY = env.ZHIPU_API_KEY;
|
||||
export async function callZhipuAPI(
|
||||
messages: { role: string; content: string }[],
|
||||
model = "glm-4.6",
|
||||
) {
|
||||
const url = "https://open.bigmodel.cn/api/paas/v4/chat/completions";
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer " + API_KEY,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: model,
|
||||
messages: messages,
|
||||
temperature: 0.2,
|
||||
thinking: {
|
||||
type: "disabled",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API 调用失败: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export async function getTTSAudioUrl(
|
||||
text: string,
|
||||
short_name: string,
|
||||
options: ProsodyOptions | undefined = undefined,
|
||||
) {
|
||||
const tts = new EdgeTTS(text, short_name, options);
|
||||
try {
|
||||
const result = await tts.synthesize();
|
||||
return URL.createObjectURL(result.audio);
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export const getLocalStorageOperator = <T extends z.ZodTypeAny>(
|
||||
key: string,
|
||||
schema: T,
|
||||
) => {
|
||||
return {
|
||||
get: (): z.infer<T> => {
|
||||
try {
|
||||
const item = globalThis.localStorage.getItem(key);
|
||||
|
||||
if (!item) return [];
|
||||
|
||||
const rawData = JSON.parse(item) as z.infer<T>;
|
||||
const result = schema.safeParse(rawData);
|
||||
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
} else {
|
||||
console.error(
|
||||
"Invalid data structure in localStorage:",
|
||||
result.error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to parse ${key} data:`, e);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
set: (data: z.infer<T>) => {
|
||||
if (!globalThis.localStorage) return;
|
||||
globalThis.localStorage.setItem(key, JSON.stringify(data));
|
||||
return data;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export function handleAPIError(error: unknown, message: string) {
|
||||
console.error(message, error);
|
||||
return NextResponse.json(
|
||||
{ error: "服务器内部错误", message },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export const letsFetch = (
|
||||
url: string,
|
||||
onSuccess: (message: string) => void,
|
||||
onError: (message: string) => void,
|
||||
onFinally: () => void,
|
||||
) => {
|
||||
return fetch(url)
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.status === "success") {
|
||||
onSuccess(data.message);
|
||||
} else if (data.status === "error") {
|
||||
onError(data.message);
|
||||
} else {
|
||||
onError("Unknown error");
|
||||
}
|
||||
})
|
||||
.finally(onFinally);
|
||||
};
|
||||
|
||||
export function isNonNegativeInteger(str: string): boolean {
|
||||
return /^\d+$/.test(str);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user