diff --git a/.dockerignore b/.dockerignore
index 72e9aa4..8f25043 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -4,4 +4,5 @@ node_modules
npm-debug.log
README.md
.next
-.git
\ No newline at end of file
+.git
+certificates
diff --git a/.gitignore b/.gitignore
index 2c62c23..7761e57 100644
--- a/.gitignore
+++ b/.gitignore
@@ -47,3 +47,5 @@ build.sh
test.ts
/generated/prisma
+
+certificates
\ No newline at end of file
diff --git a/package.json b/package.json
index 5d37634..d64ea29 100644
--- a/package.json
+++ b/package.json
@@ -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"
diff --git a/src/app/(features)/memorize/Memorize.tsx b/src/app/(features)/memorize/Memorize.tsx
index 8957cbd..52a805e 100644
--- a/src/app/(features)/memorize/Memorize.tsx
+++ b/src/app/(features)/memorize/Memorize.tsx
@@ -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";
diff --git a/src/app/(features)/memorize/page.tsx b/src/app/(features)/memorize/page.tsx
index dd92bab..c0aa297 100644
--- a/src/app/(features)/memorize/page.tsx
+++ b/src/app/(features)/memorize/page.tsx
@@ -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,
diff --git a/src/app/(features)/srt-player/VideoPlayer/SubtitleDisplay.tsx b/src/app/(features)/srt-player/VideoPlayer/SubtitleDisplay.tsx
index 1aa7b7f..566f1d2 100644
--- a/src/app/(features)/srt-player/VideoPlayer/SubtitleDisplay.tsx
+++ b/src/app/(features)/srt-player/VideoPlayer/SubtitleDisplay.tsx
@@ -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 (
-
+
{words.map((v) => (
{
+ window.open(
+ `https://www.youdao.com/result?word=${v}&lang=en`,
+ "_blank",
+ );
+ }}
key={i++}
className="hover:bg-gray-700 hover:underline hover:cursor-pointer"
>
diff --git a/src/app/(features)/text-speaker/SaveList.tsx b/src/app/(features)/text-speaker/SaveList.tsx
index 86f283b..582f356 100644
--- a/src/app/(features)/text-speaker/SaveList.tsx
+++ b/src/app/(features)/text-speaker/SaveList.tsx
@@ -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;
diff --git a/src/app/(features)/text-speaker/page.tsx b/src/app/(features)/text-speaker/page.tsx
index e7d6671..c606733 100644
--- a/src/app/(features)/text-speaker/page.tsx
+++ b/src/app/(features)/text-speaker/page.tsx
@@ -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();
diff --git a/src/app/(features)/translator/AddToFolder.tsx b/src/app/(features)/translator/AddToFolder.tsx
index 3ce4454..5a270dd 100644
--- a/src/app/(features)/translator/AddToFolder.tsx
+++ b/src/app/(features)/translator/AddToFolder.tsx
@@ -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";
diff --git a/src/app/(features)/translator/FolderSelector.tsx b/src/app/(features)/translator/FolderSelector.tsx
index 3d7f125..94bf1c2 100644
--- a/src/app/(features)/translator/FolderSelector.tsx
+++ b/src/app/(features)/translator/FolderSelector.tsx
@@ -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";
diff --git a/src/app/(features)/translator/page.tsx b/src/app/(features)/translator/page.tsx
index 9d337ed..1c59f89 100644
--- a/src/app/(features)/translator/page.tsx
+++ b/src/app/(features)/translator/page.tsx
@@ -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();
diff --git a/src/app/(features)/translator_old/AddToFolder.tsx b/src/app/(features)/translator_old/AddToFolder.tsx
deleted file mode 100644
index 3ce4454..0000000
--- a/src/app/(features)/translator_old/AddToFolder.tsx
+++ /dev/null
@@ -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;
- 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
deleted file mode 100644
index e3c1199..0000000
--- a/src/app/(features)/translator_old/page.tsx
+++ /dev/null
@@ -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(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/app/api/ipa/route.ts b/src/app/api/ipa/route.ts
deleted file mode 100644
index 4506748..0000000
--- a/src/app/api/ipa/route.ts
+++ /dev/null
@@ -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, "请稍后再试");
- }
-}
diff --git a/src/app/api/locale/route.ts b/src/app/api/locale/route.ts
deleted file mode 100644
index 8aba5b2..0000000
--- a/src/app/api/locale/route.ts
+++ /dev/null
@@ -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 },
- );
- }
-}
diff --git a/src/app/api/route.ts b/src/app/api/route.ts
deleted file mode 100644
index 8e79113..0000000
--- a/src/app/api/route.ts
+++ /dev/null
@@ -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 },
- );
-}
diff --git a/src/app/api/translate/route.ts b/src/app/api/translate/route.ts
deleted file mode 100644
index 17175f1..0000000
--- a/src/app/api/translate/route.ts
+++ /dev/null
@@ -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 },
- );
- }
-}
diff --git a/src/app/api/v1/ipa/route.ts b/src/app/api/v1/ipa/route.ts
deleted file mode 100644
index 07a8373..0000000
--- a/src/app/api/v1/ipa/route.ts
+++ /dev/null
@@ -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"],
- );
-}
diff --git a/src/app/api/v1/locale/route.ts b/src/app/api/v1/locale/route.ts
deleted file mode 100644
index ba2e77a..0000000
--- a/src/app/api/v1/locale/route.ts
+++ /dev/null
@@ -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"],
- );
-}
diff --git a/src/app/api/v1/translate/route.ts b/src/app/api/v1/translate/route.ts
deleted file mode 100644
index ad5c9f9..0000000
--- a/src/app/api/v1/translate/route.ts
+++ /dev/null
@@ -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"],
- );
-}
diff --git a/src/app/folders/FoldersClient.tsx b/src/app/folders/FoldersClient.tsx
index c0a31ff..ddc6d29 100644
--- a/src/app/folders/FoldersClient.tsx
+++ b/src/app/folders/FoldersClient.tsx
@@ -9,7 +9,7 @@ import {
createFolder,
deleteFolderById,
getFoldersWithTotalPairsByOwner,
-} from "@/lib/services/folderService";
+} from "@/lib/actions/services/folderService";
import { useTranslations } from "next-intl";
interface FolderProps {
diff --git a/src/app/folders/[folder_id]/InFolder.tsx b/src/app/folders/[folder_id]/InFolder.tsx
index f2f7e7c..6e6f202 100644
--- a/src/app/folders/[folder_id]/InFolder.tsx
+++ b/src/app/folders/[folder_id]/InFolder.tsx
@@ -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";
diff --git a/src/app/folders/[folder_id]/TextPairCard.tsx b/src/app/folders/[folder_id]/TextPairCard.tsx
index d03d73f..0c9fe2c 100644
--- a/src/app/folders/[folder_id]/TextPairCard.tsx
+++ b/src/app/folders/[folder_id]/TextPairCard.tsx
@@ -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";
diff --git a/src/app/folders/[folder_id]/page.tsx b/src/app/folders/[folder_id]/page.tsx
index b6aefd2..1faaee3 100644
--- a/src/app/folders/[folder_id]/page.tsx
+++ b/src/app/folders/[folder_id]/page.tsx
@@ -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,
}: {
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 27dc430..3af3dd1 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -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";
diff --git a/src/lib/SessionWrapper.tsx b/src/components/SessionWrapper.tsx
similarity index 100%
rename from src/lib/SessionWrapper.tsx
rename to src/components/SessionWrapper.tsx
diff --git a/src/hooks/useAudioPlayer.ts b/src/hooks/useAudioPlayer.ts
index 87695f9..d949633 100644
--- a/src/hooks/useAudioPlayer.ts
+++ b/src/hooks/useAudioPlayer.ts
@@ -4,6 +4,7 @@ type AudioPlayerError = Error | null;
export function useAudioPlayer() {
const audioRef = useRef(null);
+ const abortControllerRef = useRef(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((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,
};
-}
+}
\ No newline at end of file
diff --git a/src/lib/ai.ts b/src/lib/actions/ai.ts
similarity index 98%
rename from src/lib/ai.ts
rename to src/lib/actions/ai.ts
index 10f8a95..1db47ee 100644
--- a/src/lib/ai.ts
+++ b/src/lib/actions/ai.ts
@@ -1,3 +1,5 @@
+"use server";
+
import { format } from "util";
async function callZhipuAPI(
diff --git a/src/lib/services/folderService.ts b/src/lib/actions/services/folderService.ts
similarity index 93%
rename from src/lib/services/folderService.ts
rename to src/lib/actions/services/folderService.ts
index bb77992..7d51d6d 100644
--- a/src/lib/services/folderService.ts
+++ b/src/lib/actions/services/folderService.ts
@@ -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({
diff --git a/src/lib/services/textPairService.ts b/src/lib/actions/services/textPairService.ts
similarity index 92%
rename from src/lib/services/textPairService.ts
rename to src/lib/actions/services/textPairService.ts
index 66d92c5..e728cc7 100644
--- a/src/lib/services/textPairService.ts
+++ b/src/lib/actions/services/textPairService.ts
@@ -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({
diff --git a/src/lib/actions/translatorActions.ts b/src/lib/actions/translatorActions.ts
index 21194fb..e3067fc 100644
--- a/src/lib/actions/translatorActions.ts
+++ b/src/lib/actions/translatorActions.ts
@@ -1,6 +1,6 @@
"use server";
-import { getLLMAnswer } from "../ai";
+import { getLLMAnswer } from "./ai";
export const genIPA = async (text: string) => {
return (
diff --git a/src/lib/browser/localStorageOperators.ts b/src/lib/browser/localStorageOperators.ts
new file mode 100644
index 0000000..5505792
--- /dev/null
+++ b/src/lib/browser/localStorageOperators.ts
@@ -0,0 +1,58 @@
+import {
+ TranslationHistoryArraySchema,
+ TranslationHistorySchema,
+} from "@/lib/interfaces";
+import z from "zod";
+import { shallowEqual } from "../utils";
+
+export const getLocalStorageOperator = (
+ key: string,
+ schema: T,
+) => {
+ return {
+ get: (): z.infer => {
+ try {
+ const item = globalThis.localStorage.getItem(key);
+
+ if (!item) return [];
+
+ const rawData = JSON.parse(item) as z.infer;
+ 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) => {
+ 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) => {
+ 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;
+};
diff --git a/src/lib/tts.ts b/src/lib/browser/tts.ts
similarity index 74%
rename from src/lib/tts.ts
rename to src/lib/browser/tts.ts
index 9101093..6245761 100644
--- a/src/lib/tts.ts
+++ b/src/lib/browser/tts.ts
@@ -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,
diff --git a/src/lib/localStorageOperators.ts b/src/lib/localStorageOperators.ts
deleted file mode 100644
index 6a2bba7..0000000
--- a/src/lib/localStorageOperators.ts
+++ /dev/null
@@ -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) => {
- 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;
-};
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index c93cbd1..c41a87d 100644
--- a/src/lib/utils.ts
+++ b/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 = (
- key: string,
- schema: T,
-) => {
- return {
- get: (): z.infer => {
- try {
- const item = globalThis.localStorage.getItem(key);
-
- if (!item) return [];
-
- const rawData = JSON.parse(item) as z.infer;
- 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) => {
- 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);
}