format everything in zed
This commit is contained in:
@@ -70,4 +70,4 @@ steps:
|
|||||||
|
|
||||||
trigger:
|
trigger:
|
||||||
branch:
|
branch:
|
||||||
- main
|
- main
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -40,4 +40,4 @@ yarn-error.log*
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { NextConfig } from "next";
|
|||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
/* config options here */
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
allowedDevOrigins: ["192.168.3.65"]
|
allowedDevOrigins: ["192.168.3.65"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -2,55 +2,99 @@ import Button from "@/components/Button";
|
|||||||
import IconClick from "@/components/IconClick";
|
import IconClick from "@/components/IconClick";
|
||||||
import IMAGES from "@/config/images";
|
import IMAGES from "@/config/images";
|
||||||
import { Letter, SupportedAlphabets } from "@/interfaces";
|
import { Letter, SupportedAlphabets } from "@/interfaces";
|
||||||
import { Dispatch, KeyboardEvent, SetStateAction, useEffect, useState } from "react";
|
import {
|
||||||
|
Dispatch,
|
||||||
|
KeyboardEvent,
|
||||||
|
SetStateAction,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
export default function MemoryCard(
|
export default function MemoryCard({
|
||||||
{
|
alphabet,
|
||||||
alphabet,
|
setChosenAlphabet,
|
||||||
setChosenAlphabet
|
}: {
|
||||||
}: {
|
alphabet: Letter[];
|
||||||
alphabet: Letter[],
|
setChosenAlphabet: Dispatch<SetStateAction<SupportedAlphabets | null>>;
|
||||||
setChosenAlphabet: Dispatch<SetStateAction<SupportedAlphabets | null>>
|
}) {
|
||||||
}
|
const [index, setIndex] = useState(
|
||||||
) {
|
Math.floor(Math.random() * alphabet.length),
|
||||||
const [index, setIndex] = useState(Math.floor(Math.random() * alphabet.length));
|
);
|
||||||
const [more, setMore] = useState(false);
|
const [more, setMore] = useState(false);
|
||||||
const [ipaDisplay, setIPADisplay] = useState(true);
|
const [ipaDisplay, setIPADisplay] = useState(true);
|
||||||
const [letterDisplay, setLetterDisplay] = useState(true);
|
const [letterDisplay, setLetterDisplay] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeydown = (e: globalThis.KeyboardEvent) => {
|
const handleKeydown = (e: globalThis.KeyboardEvent) => {
|
||||||
if (e.key === ' ') refresh();
|
if (e.key === " ") refresh();
|
||||||
}
|
};
|
||||||
document.addEventListener('keydown', handleKeydown);
|
document.addEventListener("keydown", handleKeydown);
|
||||||
return () => document.removeEventListener('keydown', handleKeydown);
|
return () => document.removeEventListener("keydown", handleKeydown);
|
||||||
});
|
});
|
||||||
|
|
||||||
const letter = alphabet[index];
|
const letter = alphabet[index];
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
setIndex(Math.floor(Math.random() * alphabet.length));
|
setIndex(Math.floor(Math.random() * alphabet.length));
|
||||||
}
|
};
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex justify-center items-center" onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => e.preventDefault()}>
|
<div
|
||||||
<div className="m-4 p-4 w-full md:w-[60dvw] flex-col rounded-2xl shadow border-gray-200 border flex justify-center items-center">
|
className="w-full flex justify-center items-center"
|
||||||
<div className="w-full flex justify-end items-center">
|
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => e.preventDefault()}
|
||||||
<IconClick size={32} alt="close" src={IMAGES.close} onClick={() => setChosenAlphabet(null)}></IconClick>
|
>
|
||||||
</div>
|
<div className="m-4 p-4 w-full md:w-[60dvw] flex-col rounded-2xl shadow border-gray-200 border flex justify-center items-center">
|
||||||
<div className="flex flex-col gap-12 justify-center items-center">
|
<div className="w-full flex justify-end items-center">
|
||||||
<span className="text-7xl md:text-9xl">{letterDisplay ? letter.letter : ''}</span>
|
<IconClick
|
||||||
<span className="text-5xl md:text-7xl text-gray-400">{ipaDisplay ? letter.letter_sound_ipa : ''}</span>
|
size={32}
|
||||||
</div>
|
alt="close"
|
||||||
<div className="flex flex-row mt-32 items-center justify-center gap-2">
|
src={IMAGES.close}
|
||||||
<IconClick size={48} alt="refresh" src={IMAGES.refresh} onClick={refresh}></IconClick>
|
onClick={() => setChosenAlphabet(null)}
|
||||||
<IconClick size={48} alt="more" src={IMAGES.more_horiz} onClick={() => setMore(!more)}></IconClick>
|
></IconClick>
|
||||||
{
|
|
||||||
more ? (<>
|
|
||||||
<Button className="w-20" onClick={() => { setLetterDisplay(!letterDisplay) }}>{letterDisplay ? '隐藏字母' : '显示字母'}</Button>
|
|
||||||
<Button className="w-20" onClick={() => { setIPADisplay(!ipaDisplay) }}>{ipaDisplay ? '隐藏IPA' : '显示IPA'}</Button>
|
|
||||||
</>) : (<></>)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
<div className="flex flex-col gap-12 justify-center items-center">
|
||||||
}
|
<span className="text-7xl md:text-9xl">
|
||||||
|
{letterDisplay ? letter.letter : ""}
|
||||||
|
</span>
|
||||||
|
<span className="text-5xl md:text-7xl text-gray-400">
|
||||||
|
{ipaDisplay ? letter.letter_sound_ipa : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row mt-32 items-center justify-center gap-2">
|
||||||
|
<IconClick
|
||||||
|
size={48}
|
||||||
|
alt="refresh"
|
||||||
|
src={IMAGES.refresh}
|
||||||
|
onClick={refresh}
|
||||||
|
></IconClick>
|
||||||
|
<IconClick
|
||||||
|
size={48}
|
||||||
|
alt="more"
|
||||||
|
src={IMAGES.more_horiz}
|
||||||
|
onClick={() => setMore(!more)}
|
||||||
|
></IconClick>
|
||||||
|
{more ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
className="w-20"
|
||||||
|
onClick={() => {
|
||||||
|
setLetterDisplay(!letterDisplay);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{letterDisplay ? "隐藏字母" : "显示字母"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="w-20"
|
||||||
|
onClick={() => {
|
||||||
|
setIPADisplay(!ipaDisplay);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ipaDisplay ? "隐藏IPA" : "显示IPA"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import Button from "@/components/Button";
|
import Button from "@/components/Button";
|
||||||
import { Letter, SupportedAlphabets } from "@/interfaces";
|
import { Letter, SupportedAlphabets } from "@/interfaces";
|
||||||
@@ -7,78 +7,91 @@ import MemoryCard from "./MemoryCard";
|
|||||||
import { Navbar } from "@/components/Navbar";
|
import { Navbar } from "@/components/Navbar";
|
||||||
|
|
||||||
export default function Alphabet() {
|
export default function Alphabet() {
|
||||||
const [chosenAlphabet, setChosenAlphabet] = useState<SupportedAlphabets | null>(null);
|
const [chosenAlphabet, setChosenAlphabet] =
|
||||||
const [alphabetData, setAlphabetData] = useState<Record<SupportedAlphabets, Letter[] | null>>({
|
useState<SupportedAlphabets | null>(null);
|
||||||
japanese: null,
|
const [alphabetData, setAlphabetData] = useState<
|
||||||
english: null,
|
Record<SupportedAlphabets, Letter[] | null>
|
||||||
esperanto: null,
|
>({
|
||||||
uyghur: null
|
japanese: null,
|
||||||
});
|
english: null,
|
||||||
const [loadingState, setLoadingState] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
esperanto: null,
|
||||||
|
uyghur: null,
|
||||||
|
});
|
||||||
|
const [loadingState, setLoadingState] = useState<
|
||||||
|
"idle" | "loading" | "success" | "error"
|
||||||
|
>("idle");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (chosenAlphabet && !alphabetData[chosenAlphabet]) {
|
||||||
|
setLoadingState("loading");
|
||||||
|
|
||||||
useEffect(() => {
|
fetch("/alphabets/" + chosenAlphabet + ".json")
|
||||||
if (chosenAlphabet && !alphabetData[chosenAlphabet]) {
|
.then((res) => {
|
||||||
setLoadingState('loading');
|
if (!res.ok) throw new Error("Network response was not ok");
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((obj) => {
|
||||||
|
setAlphabetData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[chosenAlphabet]: obj as Letter[],
|
||||||
|
}));
|
||||||
|
setLoadingState("success");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setLoadingState("error");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [chosenAlphabet, alphabetData]);
|
||||||
|
|
||||||
fetch('/alphabets/' + chosenAlphabet + '.json')
|
useEffect(() => {
|
||||||
.then(res => {
|
if (loadingState === "error") {
|
||||||
if (!res.ok) throw new Error('Network response was not ok');
|
const timer = setTimeout(() => {
|
||||||
return res.json();
|
setLoadingState("idle");
|
||||||
}).then((obj) => {
|
setChosenAlphabet(null);
|
||||||
setAlphabetData(prev => ({ ...prev, [chosenAlphabet]: obj as Letter[] }));
|
}, 2000);
|
||||||
setLoadingState('success');
|
return () => clearTimeout(timer);
|
||||||
}).catch(() => {
|
}
|
||||||
setLoadingState('error');
|
}, [loadingState]);
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [chosenAlphabet, alphabetData]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (!chosenAlphabet)
|
||||||
if (loadingState === 'error') {
|
return (
|
||||||
const timer = setTimeout(() => {
|
<>
|
||||||
setLoadingState('idle');
|
|
||||||
setChosenAlphabet(null);
|
|
||||||
}, 2000);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [loadingState]);
|
|
||||||
|
|
||||||
if (!chosenAlphabet) return (<>
|
|
||||||
<Navbar></Navbar>
|
<Navbar></Navbar>
|
||||||
<div className="border border-gray-200 m-4 mt-4 flex flex-col justify-center items-center p-4 rounded-2xl gap-2">
|
<div className="border border-gray-200 m-4 mt-4 flex flex-col justify-center items-center p-4 rounded-2xl gap-2">
|
||||||
<span className="text-2xl md:text-3xl">请选择您想学习的字符</span>
|
<span className="text-2xl md:text-3xl">请选择您想学习的字符</span>
|
||||||
<div className="flex gap-1 flex-wrap">
|
<div className="flex gap-1 flex-wrap">
|
||||||
<Button onClick={() => setChosenAlphabet('japanese')}>
|
<Button onClick={() => setChosenAlphabet("japanese")}>
|
||||||
日语假名
|
日语假名
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setChosenAlphabet('english')}>
|
<Button onClick={() => setChosenAlphabet("english")}>
|
||||||
英文字母
|
英文字母
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setChosenAlphabet('uyghur')}>
|
<Button onClick={() => setChosenAlphabet("uyghur")}>
|
||||||
维吾尔字母
|
维吾尔字母
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setChosenAlphabet('esperanto')}>
|
<Button onClick={() => setChosenAlphabet("esperanto")}>
|
||||||
世界语字母
|
世界语字母
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
if (loadingState === 'loading') {
|
if (loadingState === "loading") {
|
||||||
return '加载中...';
|
return "加载中...";
|
||||||
}
|
}
|
||||||
if (loadingState === 'error') {
|
if (loadingState === "error") {
|
||||||
return '加载失败,请重试';
|
return "加载失败,请重试";
|
||||||
}
|
}
|
||||||
if (loadingState === 'success' && alphabetData[chosenAlphabet]) {
|
if (loadingState === "success" && alphabetData[chosenAlphabet]) {
|
||||||
return (<>
|
return (
|
||||||
<Navbar></Navbar>
|
<>
|
||||||
<MemoryCard
|
<Navbar></Navbar>
|
||||||
alphabet={alphabetData[chosenAlphabet]}
|
<MemoryCard
|
||||||
setChosenAlphabet={setChosenAlphabet}>
|
alphabet={alphabetData[chosenAlphabet]}
|
||||||
</MemoryCard>
|
setChosenAlphabet={setChosenAlphabet}
|
||||||
</>);
|
></MemoryCard>
|
||||||
}
|
</>
|
||||||
return null;
|
);
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { callZhipuAPI } from "@/utils";
|
import { callZhipuAPI, handleAPIError } from "@/utils";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
async function getIPA(text: string) {
|
async function getIPA(text: string) {
|
||||||
console.log(`get ipa of ${text}`);
|
console.log(`get ipa of ${text}`);
|
||||||
const messages = [
|
const messages = [
|
||||||
{
|
{
|
||||||
role: 'user', content: `
|
role: "user",
|
||||||
|
content: `
|
||||||
请推断以下文本的语言,生成对应的宽式国际音标(IPA)以及locale,以JSON格式返回
|
请推断以下文本的语言,生成对应的宽式国际音标(IPA)以及locale,以JSON格式返回
|
||||||
[${text}]
|
[${text}]
|
||||||
结果如:
|
结果如:
|
||||||
@@ -18,47 +19,44 @@ async function getIPA(text: string) {
|
|||||||
ipa一定要加[],
|
ipa一定要加[],
|
||||||
locale如果可能有多个,选取最可能的一个,其中使用符号"-",
|
locale如果可能有多个,选取最可能的一个,其中使用符号"-",
|
||||||
locale如果推断失败,就返回{"locale": "en-US"}
|
locale如果推断失败,就返回{"locale": "en-US"}
|
||||||
`
|
`,
|
||||||
}];
|
},
|
||||||
try {
|
];
|
||||||
const response = await callZhipuAPI(messages);
|
try {
|
||||||
let to_parse = response.choices[0].message.content.trim() as string;
|
const response = await callZhipuAPI(messages);
|
||||||
if (to_parse.startsWith('`')) to_parse = to_parse.slice(7, to_parse.length - 3);
|
let to_parse = response.choices[0].message.content.trim() as string;
|
||||||
if (to_parse.length === 0) throw Error('ai啥也每说');
|
if (to_parse.startsWith("`"))
|
||||||
return JSON.parse(to_parse);
|
to_parse = to_parse.slice(7, to_parse.length - 3);
|
||||||
} catch (error) {
|
if (to_parse.length === 0) throw Error("ai啥也每说");
|
||||||
console.error(error);
|
return JSON.parse(to_parse);
|
||||||
return null;
|
} catch (error) {
|
||||||
}
|
console.error(error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
const text = searchParams.get('text');
|
const text = searchParams.get("text");
|
||||||
|
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "查询参数错误", message: "text参数是必需的" },
|
{ error: "查询参数错误", message: "text参数是必需的" },
|
||||||
{ status: 400 }
|
{ 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) {
|
|
||||||
console.error('API 错误:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "服务器内部错误", message: "请稍后重试" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
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, "请稍后再试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import { callZhipuAPI } from "@/utils";
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
async function getLocale(text: string) {
|
async function getLocale(text: string) {
|
||||||
console.log(`get locale of ${text}`);
|
console.log(`get locale of ${text}`);
|
||||||
const messages = [
|
const messages = [
|
||||||
{
|
{
|
||||||
role: 'user', content: `
|
role: "user",
|
||||||
|
content: `
|
||||||
请推断以下文本的的locale,以JSON格式返回
|
请推断以下文本的的locale,以JSON格式返回
|
||||||
[${text}]
|
[${text}]
|
||||||
结果如:
|
结果如:
|
||||||
@@ -16,47 +17,48 @@ async function getLocale(text: string) {
|
|||||||
直接返回json文本,
|
直接返回json文本,
|
||||||
locale如果可能有多个,选取最可能的一个,其中使用符号"-",
|
locale如果可能有多个,选取最可能的一个,其中使用符号"-",
|
||||||
locale如果推断失败,就返回{"locale": "en-US"}
|
locale如果推断失败,就返回{"locale": "en-US"}
|
||||||
`
|
`,
|
||||||
}];
|
},
|
||||||
try {
|
];
|
||||||
const response = await callZhipuAPI(messages);
|
try {
|
||||||
let to_parse = response.choices[0].message.content.trim() as string;
|
const response = await callZhipuAPI(messages);
|
||||||
if (to_parse.startsWith('`')) to_parse = to_parse.slice(7, to_parse.length - 3);
|
let to_parse = response.choices[0].message.content.trim() as string;
|
||||||
if (to_parse.length === 0) throw Error('ai啥也每说');
|
if (to_parse.startsWith("`"))
|
||||||
return JSON.parse(to_parse);
|
to_parse = to_parse.slice(7, to_parse.length - 3);
|
||||||
} catch (error) {
|
if (to_parse.length === 0) throw Error("ai啥也每说");
|
||||||
console.error(error);
|
return JSON.parse(to_parse);
|
||||||
return null;
|
} catch (error) {
|
||||||
}
|
console.error(error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
const text = searchParams.get('text');
|
const text = searchParams.get("text");
|
||||||
|
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "查询参数错误", message: "text参数是必需的" },
|
{ error: "查询参数错误", message: "text参数是必需的" },
|
||||||
{ status: 400 }
|
{ 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 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const url = request.url;
|
const url = request.url;
|
||||||
return NextResponse.json({
|
return NextResponse.json(
|
||||||
message: "Hello World",
|
{
|
||||||
url: url
|
message: "Hello World",
|
||||||
}, { status: 200 });
|
url: url,
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import { callZhipuAPI } from "@/utils";
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
async function getTextinfo(text: string) {
|
async function getTextinfo(text: string) {
|
||||||
console.log(`get textinfo of ${text}`);
|
console.log(`get textinfo of ${text}`);
|
||||||
const messages = [
|
const messages = [
|
||||||
{
|
{
|
||||||
role: 'user', content: `
|
role: "user",
|
||||||
|
content: `
|
||||||
请推断以下文本的语言、locale,生成宽式国际音标(IPA),以JSON格式返回
|
请推断以下文本的语言、locale,生成宽式国际音标(IPA),以JSON格式返回
|
||||||
[${text}]
|
[${text}]
|
||||||
结果如:
|
结果如:
|
||||||
@@ -20,45 +21,47 @@ async function getTextinfo(text: string) {
|
|||||||
ipa一定要加[],
|
ipa一定要加[],
|
||||||
lang的值是小写字母的英文的语言名称,
|
lang的值是小写字母的英文的语言名称,
|
||||||
locale如果可能有多个,选取最可能的一个,其中使用符号"-"
|
locale如果可能有多个,选取最可能的一个,其中使用符号"-"
|
||||||
`
|
`,
|
||||||
}];
|
},
|
||||||
try {
|
];
|
||||||
const response = await callZhipuAPI(messages);
|
try {
|
||||||
let to_parse = response.choices[0].message.content.trim() as string;
|
const response = await callZhipuAPI(messages);
|
||||||
if (to_parse.startsWith('`')) to_parse = to_parse.slice(7, to_parse.length - 3);
|
let to_parse = response.choices[0].message.content.trim() as string;
|
||||||
if (to_parse.length === 0) throw Error('ai啥也每说');
|
if (to_parse.startsWith("`"))
|
||||||
return JSON.parse(to_parse);
|
to_parse = to_parse.slice(7, to_parse.length - 3);
|
||||||
} catch (error) {
|
if (to_parse.length === 0) throw Error("ai啥也每说");
|
||||||
console.error(error);
|
return JSON.parse(to_parse);
|
||||||
return null;
|
} catch (error) {
|
||||||
}
|
console.error(error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
const text = searchParams.get('text');
|
const text = searchParams.get("text");
|
||||||
|
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "查询参数错误", message: "text参数是必需的" },
|
{ error: "查询参数错误", message: "text参数是必需的" },
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
const textInfo = await getTextinfo(text);
|
|
||||||
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 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
const textInfo = await getTextinfo(text);
|
||||||
|
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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import { callZhipuAPI } from "@/utils";
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
async function translate(text: string, target_lang: string) {
|
async function translate(text: string, target_lang: string) {
|
||||||
console.log(`translate "${text}" into ${target_lang}`);
|
console.log(`translate "${text}" into ${target_lang}`);
|
||||||
const messages = [
|
const messages = [
|
||||||
{
|
{
|
||||||
role: 'user', content: `
|
role: "user",
|
||||||
|
content: `
|
||||||
请推断以下文本的语言、locale,并翻译到目标语言[${target_lang}],同样需要locale信息,以JSON格式返回
|
请推断以下文本的语言、locale,并翻译到目标语言[${target_lang}],同样需要locale信息,以JSON格式返回
|
||||||
[${text}]
|
[${text}]
|
||||||
结果如:
|
结果如:
|
||||||
@@ -18,46 +19,48 @@ async function translate(text: string, target_lang: string) {
|
|||||||
直接返回json文本,
|
直接返回json文本,
|
||||||
locale如果可能有多个,选取最可能的一个,其中使用符号"-",
|
locale如果可能有多个,选取最可能的一个,其中使用符号"-",
|
||||||
locale如果推断失败,就当作是en-US
|
locale如果推断失败,就当作是en-US
|
||||||
`
|
`,
|
||||||
}];
|
},
|
||||||
try {
|
];
|
||||||
const response = await callZhipuAPI(messages);
|
try {
|
||||||
let to_parse = response.choices[0].message.content.trim() as string;
|
const response = await callZhipuAPI(messages);
|
||||||
if (to_parse.startsWith('`')) to_parse = to_parse.slice(7, to_parse.length - 3);
|
let to_parse = response.choices[0].message.content.trim() as string;
|
||||||
if (to_parse.length === 0) throw Error('ai啥也每说');
|
if (to_parse.startsWith("`"))
|
||||||
return JSON.parse(to_parse);
|
to_parse = to_parse.slice(7, to_parse.length - 3);
|
||||||
} catch (error) {
|
if (to_parse.length === 0) throw Error("ai啥也每说");
|
||||||
console.error(error);
|
return JSON.parse(to_parse);
|
||||||
return null;
|
} catch (error) {
|
||||||
}
|
console.error(error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
const text = searchParams.get('text');
|
const text = searchParams.get("text");
|
||||||
const target_lang = searchParams.get('target');
|
const target_lang = searchParams.get("target");
|
||||||
|
|
||||||
if (!text || !target_lang) {
|
if (!text || !target_lang) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "查询参数错误", message: "text参数, target参数是必需的" },
|
{ error: "查询参数错误", message: "text参数, target参数是必需的" },
|
||||||
{ status: 400 }
|
{ 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 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
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,15 +1,15 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--background: #ffffff;
|
||||||
--foreground: #171717;
|
--foreground: #171717;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* @media (prefers-color-scheme: dark) {
|
/* @media (prefers-color-scheme: dark) {
|
||||||
@@ -20,13 +20,13 @@
|
|||||||
} */
|
} */
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
|
font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.code-block {
|
.code-block {
|
||||||
font-family: var(--font-geist-mono), monospace;
|
font-family: var(--font-geist-mono), monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
@source '../../node_modules/rc-modal-sheet/**/*.js'
|
@source '../../node_modules/rc-modal-sheet/**/*.js';
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import type { Viewport } from 'next'
|
import type { Viewport } from "next";
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
width: 'device-width',
|
width: "device-width",
|
||||||
initialScale: 1.0
|
initialScale: 1.0,
|
||||||
}
|
};
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -23,8 +23,6 @@ export const metadata: Metadata = {
|
|||||||
description: "A Website to Learn Languages",
|
description: "A Website to Learn Languages",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
|
|||||||
@@ -1,135 +1,146 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import Button from "@/components/Button";
|
import Button from "@/components/Button";
|
||||||
import { Select, Option } from "@material-tailwind/react";
|
import { Select, Option } from "@material-tailwind/react";
|
||||||
import { ChangeEvent, useState } from "react";
|
import { ChangeEvent, useState } from "react";
|
||||||
|
|
||||||
interface ACardProps {
|
interface ACardProps {
|
||||||
children?: React.ReactNode,
|
children?: React.ReactNode;
|
||||||
className?: string
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ACard({ children, className }: ACardProps) {
|
function ACard({ children, className }: ACardProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`w-[61vw] h-96 p-2 shadow-2xl bg-[#00BCD4] rounded-xl ${className}`}>
|
<div
|
||||||
{children}
|
className={`w-[61vw] h-96 p-2 shadow-2xl bg-[#00BCD4] rounded-xl ${className}`}
|
||||||
</div>);
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BCard {
|
interface BCard {
|
||||||
children?: React.ReactNode,
|
children?: React.ReactNode;
|
||||||
className?: string
|
className?: string;
|
||||||
}
|
}
|
||||||
function BCard({ children, className }: BCard) {
|
function BCard({ children, className }: BCard) {
|
||||||
return (
|
return (
|
||||||
<div className={`border border-[#0097A7] rounded-xl p-2 ${className}`}>
|
<div className={`border border-[#0097A7] rounded-xl p-2 ${className}`}>
|
||||||
{children}
|
{children}
|
||||||
</div>);
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WordData {
|
interface WordData {
|
||||||
locale1: string,
|
locale1: string;
|
||||||
locale2: string,
|
locale2: string;
|
||||||
data: Record<string, string>
|
data: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Memorize() {
|
export default function Memorize() {
|
||||||
const [pageState, setPageState] = useState<'choose' | 'start' | 'main' | 'edit'>('edit');
|
const [pageState, setPageState] = useState<
|
||||||
const [wordData, setWordData] = useState<WordData>({
|
"choose" | "start" | "main" | "edit"
|
||||||
locale1: 'en-US',
|
>("edit");
|
||||||
locale2: 'zh-CN',
|
const [wordData, setWordData] = useState<WordData>({
|
||||||
data: { 'hello': '你好' }
|
locale1: "en-US",
|
||||||
});
|
locale2: "zh-CN",
|
||||||
if (pageState === 'main') {
|
data: { hello: "你好" },
|
||||||
return (<>
|
});
|
||||||
<div className="w-full h-screen flex justify-center items-center">
|
if (pageState === "main") {
|
||||||
<ACard>
|
return (
|
||||||
<h1 className="text-center font-extrabold text-4xl text-white m-2 mb-4">
|
<>
|
||||||
Memorize
|
<div className="w-full h-screen flex justify-center items-center">
|
||||||
</h1>
|
<ACard>
|
||||||
<div className="w-full text-white">
|
<h1 className="text-center font-extrabold text-4xl text-white m-2 mb-4">
|
||||||
<BCard>
|
Memorize
|
||||||
<p>Lang1: {wordData.locale1}</p>
|
</h1>
|
||||||
<p>Lang2: {wordData.locale2}</p>
|
<div className="w-full text-white">
|
||||||
<p>Total Words: {Object.keys(wordData.data).length}</p>
|
<BCard>
|
||||||
</BCard>
|
<p>Lang1: {wordData.locale1}</p>
|
||||||
</div>
|
<p>Lang2: {wordData.locale2}</p>
|
||||||
<div className="w-full flex items-center justify-center">
|
<p>Total Words: {Object.keys(wordData.data).length}</p>
|
||||||
<BCard className="flex gap-2 justify-center items-center w-fit">
|
</BCard>
|
||||||
<Button>Start</Button>
|
|
||||||
<Button>Load</Button>
|
|
||||||
<Button>Save</Button>
|
|
||||||
<Button onClick={() => setPageState('edit')}>Edit</Button>
|
|
||||||
</BCard>
|
|
||||||
</div>
|
|
||||||
</ACard>
|
|
||||||
</div>
|
</div>
|
||||||
</>);
|
<div className="w-full flex items-center justify-center">
|
||||||
}
|
<BCard className="flex gap-2 justify-center items-center w-fit">
|
||||||
if (pageState === 'choose') {
|
<Button>Start</Button>
|
||||||
return (<>
|
<Button>Load</Button>
|
||||||
</>);
|
<Button>Save</Button>
|
||||||
}
|
<Button onClick={() => setPageState("edit")}>Edit</Button>
|
||||||
if (pageState === 'start') {
|
</BCard>
|
||||||
return (<>
|
|
||||||
</>);
|
|
||||||
}
|
|
||||||
if (pageState === 'edit') {
|
|
||||||
const convertIntoWordData = (text: string) => {
|
|
||||||
const t1 = text.split('\n').map(v => v.trim()).filter(v => v.includes(','));
|
|
||||||
const t2 = t1.map(v => {
|
|
||||||
const [left, right] = v.split(',', 2).map(v => v.trim());
|
|
||||||
if (left && right)
|
|
||||||
return {
|
|
||||||
[left]: right
|
|
||||||
};
|
|
||||||
else return {};
|
|
||||||
});
|
|
||||||
const new_data = {
|
|
||||||
locale1: wordData.locale1,
|
|
||||||
locale2: wordData.locale2,
|
|
||||||
data: Object.assign({}, ...t2)
|
|
||||||
};
|
|
||||||
setWordData(new_data);
|
|
||||||
}
|
|
||||||
const convertFromWordData = () => {
|
|
||||||
let result = '';
|
|
||||||
for (const k in wordData.data) {
|
|
||||||
result += `${k}, ${wordData.data[k]}\n`;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
let input = convertFromWordData();
|
|
||||||
const handleSave = () => {
|
|
||||||
convertIntoWordData(input);
|
|
||||||
setPageState('main');
|
|
||||||
}
|
|
||||||
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
|
||||||
input = e.target.value;
|
|
||||||
}
|
|
||||||
return (<>
|
|
||||||
<div className="w-full h-screen flex flex-col justify-center items-center">
|
|
||||||
<ACard className="">
|
|
||||||
<textarea className="text-white border-gray-200 border rounded-2xl w-full h-50 resize-none outline-0 p-2"
|
|
||||||
defaultValue={input}
|
|
||||||
onChange={handleChange}></textarea>
|
|
||||||
<div className="w-full flex items-center justify-center">
|
|
||||||
<BCard className="flex gap-2 justify-center items-center w-fit">
|
|
||||||
<Button>choose locale1</Button>
|
|
||||||
<Button>choose locale2</Button>
|
|
||||||
<Button onClick={() => setPageState('main')}>Cancel</Button>
|
|
||||||
<Button onClick={handleSave}>Save</Button>
|
|
||||||
<button className="inline-flex items-center justify-center border align-middle select-none font-sans font-medium text-center transition-all duration-300 ease-in disabled:opacity-50 disabled:shadow-none disabled:cursor-not-allowed data-[shape=pill]:rounded-full data-[width=full]:w-full focus:shadow-none text-sm rounded-md py-2 px-4 shadow-sm hover:shadow-md bg-slate-800 border-slate-800 text-slate-50 hover:bg-slate-700 hover:border-slate-700">
|
|
||||||
Button
|
|
||||||
</button>
|
|
||||||
</BCard>
|
|
||||||
</div>
|
|
||||||
<div className="w-48">
|
|
||||||
</div>
|
|
||||||
</ACard>
|
|
||||||
</div>
|
</div>
|
||||||
|
</ACard>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (pageState === "choose") {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
if (pageState === "start") {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
if (pageState === "edit") {
|
||||||
|
const convertIntoWordData = (text: string) => {
|
||||||
|
const t1 = text
|
||||||
|
.split("\n")
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter((v) => v.includes(","));
|
||||||
|
const t2 = t1.map((v) => {
|
||||||
|
const [left, right] = v.split(",", 2).map((v) => v.trim());
|
||||||
|
if (left && right)
|
||||||
|
return {
|
||||||
|
[left]: right,
|
||||||
|
};
|
||||||
|
else return {};
|
||||||
|
});
|
||||||
|
const new_data = {
|
||||||
|
locale1: wordData.locale1,
|
||||||
|
locale2: wordData.locale2,
|
||||||
|
data: Object.assign({}, ...t2),
|
||||||
|
};
|
||||||
|
setWordData(new_data);
|
||||||
|
};
|
||||||
|
const convertFromWordData = () => {
|
||||||
|
let result = "";
|
||||||
|
for (const k in wordData.data) {
|
||||||
|
result += `${k}, ${wordData.data[k]}\n`;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
let input = convertFromWordData();
|
||||||
|
const handleSave = () => {
|
||||||
|
convertIntoWordData(input);
|
||||||
|
setPageState("main");
|
||||||
|
};
|
||||||
|
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
input = e.target.value;
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="w-full h-screen flex flex-col justify-center items-center">
|
||||||
|
<ACard className="">
|
||||||
|
<textarea
|
||||||
|
className="text-white border-gray-200 border rounded-2xl w-full h-50 resize-none outline-0 p-2"
|
||||||
|
defaultValue={input}
|
||||||
|
onChange={handleChange}
|
||||||
|
></textarea>
|
||||||
|
<div className="w-full flex items-center justify-center">
|
||||||
|
<BCard className="flex gap-2 justify-center items-center w-fit">
|
||||||
|
<Button>choose locale1</Button>
|
||||||
|
<Button>choose locale2</Button>
|
||||||
|
<Button onClick={() => setPageState("main")}>Cancel</Button>
|
||||||
|
<Button onClick={handleSave}>Save</Button>
|
||||||
|
<button className="inline-flex items-center justify-center border align-middle select-none font-sans font-medium text-center transition-all duration-300 ease-in disabled:opacity-50 disabled:shadow-none disabled:cursor-not-allowed data-[shape=pill]:rounded-full data-[width=full]:w-full focus:shadow-none text-sm rounded-md py-2 px-4 shadow-sm hover:shadow-md bg-slate-800 border-slate-800 text-slate-50 hover:bg-slate-700 hover:border-slate-700">
|
||||||
|
Button
|
||||||
|
</button>
|
||||||
|
</BCard>
|
||||||
|
</div>
|
||||||
|
<div className="w-48"></div>
|
||||||
|
</ACard>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* <Select
|
{/* <Select
|
||||||
label="选择语言"
|
label="选择语言"
|
||||||
placeholder="请选择语言"
|
placeholder="请选择语言"
|
||||||
onResize={undefined}
|
onResize={undefined}
|
||||||
@@ -143,6 +154,7 @@ export default function Memorize() {
|
|||||||
<Option>Material Tailwind Angular</Option>
|
<Option>Material Tailwind Angular</Option>
|
||||||
<Option>Material Tailwind Svelte</Option>
|
<Option>Material Tailwind Svelte</Option>
|
||||||
</Select> */}
|
</Select> */}
|
||||||
</>);
|
</>
|
||||||
}
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,25 +6,28 @@ function TopArea() {
|
|||||||
<div className="bg-[#35786f] text-white w-full min-h-[75dvh] flex justify-center items-center">
|
<div className="bg-[#35786f] text-white w-full min-h-[75dvh] flex justify-center items-center">
|
||||||
<div className="mb-16 mx-16 md:mx-0 md:max-w-[60dvw]">
|
<div className="mb-16 mx-16 md:mx-0 md:max-w-[60dvw]">
|
||||||
<h1 className="text-6xl md:text-9xl mb-8">Learn Languages</h1>
|
<h1 className="text-6xl md:text-9xl mb-8">Learn Languages</h1>
|
||||||
<p className="text-2xl md:text-5xl">Here is a very useful website to help you learn almost every language in the world, including constructed ones.</p>
|
<p className="text-2xl md:text-5xl">
|
||||||
|
Here is a very useful website to help you learn almost every language
|
||||||
|
in the world, including constructed ones.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LinkAreaProps {
|
interface LinkAreaProps {
|
||||||
href: string,
|
href: string;
|
||||||
name: string,
|
name: string;
|
||||||
description: string,
|
description: string;
|
||||||
color: string
|
color: string;
|
||||||
}
|
}
|
||||||
function LinkArea(
|
function LinkArea({ href, name, description, color }: LinkAreaProps) {
|
||||||
{ href, name, description, color }: LinkAreaProps
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
<Link href={href}
|
<Link
|
||||||
|
href={href}
|
||||||
style={{ backgroundColor: color }}
|
style={{ backgroundColor: color }}
|
||||||
className={`h-32 md:h-64 flex justify-center items-center`}>
|
className={`h-32 md:h-64 flex justify-center items-center`}
|
||||||
|
>
|
||||||
<div className="text-white m-8">
|
<div className="text-white m-8">
|
||||||
<h1 className="text-4xl">{name}</h1>
|
<h1 className="text-4xl">{name}</h1>
|
||||||
<p className="text-xl">{description}</p>
|
<p className="text-xl">{description}</p>
|
||||||
@@ -36,17 +39,18 @@ function LinkArea(
|
|||||||
function LinkGrid() {
|
function LinkGrid() {
|
||||||
return (
|
return (
|
||||||
<div className="w-full grid grid-cols-1 grid-rows-6 md:grid-cols-3">
|
<div className="w-full grid grid-cols-1 grid-rows-6 md:grid-cols-3">
|
||||||
|
|
||||||
<LinkArea
|
<LinkArea
|
||||||
href="/translator"
|
href="/translator"
|
||||||
name="翻译器"
|
name="翻译器"
|
||||||
description="翻译到任何语言,并标注国际音标(IPA)"
|
description="翻译到任何语言,并标注国际音标(IPA)"
|
||||||
color="#a56068"></LinkArea>
|
color="#a56068"
|
||||||
|
></LinkArea>
|
||||||
<LinkArea
|
<LinkArea
|
||||||
href="/text-speaker"
|
href="/text-speaker"
|
||||||
name="朗读器"
|
name="朗读器"
|
||||||
description="识别并朗读文本,支持循环朗读、朗读速度调节"
|
description="识别并朗读文本,支持循环朗读、朗读速度调节"
|
||||||
color="#578aad"></LinkArea>
|
color="#578aad"
|
||||||
|
></LinkArea>
|
||||||
{/* <LinkArea
|
{/* <LinkArea
|
||||||
href="/word-board"
|
href="/word-board"
|
||||||
name="词墙"
|
name="词墙"
|
||||||
@@ -56,19 +60,22 @@ function LinkGrid() {
|
|||||||
href="/srt-player"
|
href="/srt-player"
|
||||||
name="逐句视频播放器"
|
name="逐句视频播放器"
|
||||||
description="基于SRT字幕文件,逐句播放视频以模仿母语者的发音"
|
description="基于SRT字幕文件,逐句播放视频以模仿母语者的发音"
|
||||||
color="#3c988d"></LinkArea>
|
color="#3c988d"
|
||||||
<LinkArea
|
></LinkArea>
|
||||||
|
<LinkArea
|
||||||
href="/alphabet"
|
href="/alphabet"
|
||||||
name="记忆字母表"
|
name="记忆字母表"
|
||||||
description="从字母表开始新语言的学习"
|
description="从字母表开始新语言的学习"
|
||||||
color="#dd7486"></LinkArea>
|
color="#dd7486"
|
||||||
|
></LinkArea>
|
||||||
<LinkArea
|
<LinkArea
|
||||||
href="#"
|
href="#"
|
||||||
name="更多功能"
|
name="更多功能"
|
||||||
description="开发中,敬请期待"
|
description="开发中,敬请期待"
|
||||||
color="#cab48a"></LinkArea>
|
color="#cab48a"
|
||||||
|
></LinkArea>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Fortune() {
|
function Fortune() {
|
||||||
@@ -97,5 +104,6 @@ export default function Home() {
|
|||||||
<Fortune></Fortune>
|
<Fortune></Fortune>
|
||||||
<Explore></Explore>
|
<Explore></Explore>
|
||||||
<LinkGrid></LinkGrid>
|
<LinkGrid></LinkGrid>
|
||||||
</>);
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
import Button from "@/components/Button";
|
import Button from "@/components/Button";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
|
|
||||||
export default function UploadArea(
|
export default function UploadArea({
|
||||||
{
|
setVideoUrl,
|
||||||
setVideoUrl,
|
setSrtUrl,
|
||||||
setSrtUrl
|
}: {
|
||||||
}: {
|
setVideoUrl: (url: string | null) => void;
|
||||||
setVideoUrl: (url: string | null) => void;
|
setSrtUrl: (url: string | null) => void;
|
||||||
setSrtUrl: (url: string | null) => void;
|
}) {
|
||||||
}
|
|
||||||
) {
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const uploadVideo = () => {
|
const uploadVideo = () => {
|
||||||
const input = inputRef.current;
|
const input = inputRef.current;
|
||||||
if (input) {
|
if (input) {
|
||||||
input.setAttribute('accept', 'video/*');
|
input.setAttribute("accept", "video/*");
|
||||||
input.click();
|
input.click();
|
||||||
input.onchange = () => {
|
input.onchange = () => {
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
@@ -24,11 +22,11 @@ export default function UploadArea(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
const uploadSRT = () => {
|
const uploadSRT = () => {
|
||||||
const input = inputRef.current;
|
const input = inputRef.current;
|
||||||
if (input) {
|
if (input) {
|
||||||
input.setAttribute('accept', '.srt');
|
input.setAttribute("accept", ".srt");
|
||||||
input.click();
|
input.click();
|
||||||
input.onchange = () => {
|
input.onchange = () => {
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
@@ -37,12 +35,12 @@ export default function UploadArea(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-col gap-2 m-2">
|
<div className="w-full flex flex-col gap-2 m-2">
|
||||||
<Button onClick={uploadVideo}>上传视频</Button>
|
<Button onClick={uploadVideo}>上传视频</Button>
|
||||||
<Button onClick={uploadSRT}>上传字幕</Button>
|
<Button onClick={uploadSRT}>上传字幕</Button>
|
||||||
<input type="file" className="hidden" ref={inputRef} />
|
<input type="file" className="hidden" ref={inputRef} />
|
||||||
</div >
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
import { inspect } from "@/utils";
|
import { inspect } from "@/utils";
|
||||||
|
|
||||||
export default function SubtitleDisplay({ subtitle }: { subtitle: string }) {
|
export default function SubtitleDisplay({ subtitle }: { subtitle: string }) {
|
||||||
const words = subtitle.match(/\b[\w']+(?:-[\w']+)*\b/g) || [];
|
const words = subtitle.match(/\b[\w']+(?:-[\w']+)*\b/g) || [];
|
||||||
let i = 0;
|
let i = 0;
|
||||||
return (
|
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 break-words bg-black/50 font-sans text-white text-center text-2xl">
|
||||||
{
|
{words.map((v) => (
|
||||||
words.map((v) => (
|
<span
|
||||||
<span
|
onClick={inspect(v)}
|
||||||
onClick={inspect(v)}
|
key={i++}
|
||||||
key={i++}
|
className="hover:bg-gray-700 hover:underline hover:cursor-pointer"
|
||||||
className="hover:bg-gray-700 hover:underline hover:cursor-pointer"
|
>
|
||||||
>
|
{v + " "}
|
||||||
{v + ' '}
|
</span>
|
||||||
</span>
|
))}
|
||||||
))
|
</div>
|
||||||
}
|
);
|
||||||
</div>
|
}
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,176 +4,211 @@ import Button from "@/components/Button";
|
|||||||
import { getIndex, parseSrt, getNearistIndex } from "../subtitle";
|
import { getIndex, parseSrt, getNearistIndex } from "../subtitle";
|
||||||
|
|
||||||
type VideoPanelProps = {
|
type VideoPanelProps = {
|
||||||
videoUrl: string | null;
|
videoUrl: string | null;
|
||||||
srtUrl: string | null;
|
srtUrl: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>((
|
const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>(
|
||||||
{ videoUrl, srtUrl }, videoRef
|
({ videoUrl, srtUrl }, videoRef) => {
|
||||||
) => {
|
|
||||||
videoRef = videoRef as React.RefObject<HTMLVideoElement>;
|
videoRef = videoRef as React.RefObject<HTMLVideoElement>;
|
||||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||||
const [srtLength, setSrtLength] = useState<number>(0);
|
const [srtLength, setSrtLength] = useState<number>(0);
|
||||||
const [progress, setProgress] = useState<number>(-1);
|
const [progress, setProgress] = useState<number>(-1);
|
||||||
const [autoPause, setAutoPause] = useState<boolean>(true);
|
const [autoPause, setAutoPause] = useState<boolean>(true);
|
||||||
const [spanText, setSpanText] = useState<string>('');
|
const [spanText, setSpanText] = useState<string>("");
|
||||||
const [subtitle, setSubtitle] = useState<string>('');
|
const [subtitle, setSubtitle] = useState<string>("");
|
||||||
const parsedSrtRef = useRef<{ start: number; end: number; text: string; }[] | null>(null);
|
const parsedSrtRef = useRef<
|
||||||
|
{ start: number; end: number; text: string }[] | null
|
||||||
|
>(null);
|
||||||
const rafldRef = useRef<number>(0);
|
const rafldRef = useRef<number>(0);
|
||||||
const ready = useRef({
|
const ready = useRef({
|
||||||
'vid': false,
|
vid: false,
|
||||||
'sub': false,
|
sub: false,
|
||||||
'all': function () { return this.vid && this.sub }
|
all: function () {
|
||||||
|
return this.vid && this.sub;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const togglePlayPause = useCallback(() => {
|
const togglePlayPause = useCallback(() => {
|
||||||
if (!videoUrl) return;
|
if (!videoUrl) return;
|
||||||
|
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
if (video.paused || video.currentTime === 0) {
|
if (video.paused || video.currentTime === 0) {
|
||||||
video.play();
|
video.play();
|
||||||
} else {
|
} else {
|
||||||
video.pause();
|
video.pause();
|
||||||
}
|
}
|
||||||
setIsPlaying(!video.paused);
|
setIsPlaying(!video.paused);
|
||||||
}, [videoRef, videoUrl]);
|
}, [videoRef, videoUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDownEvent = (e: globalThis.KeyboardEvent) => {
|
const handleKeyDownEvent = (e: globalThis.KeyboardEvent) => {
|
||||||
if (e.key === 'n') {
|
if (e.key === "n") {
|
||||||
next();
|
next();
|
||||||
} else if (e.key === 'p') {
|
} else if (e.key === "p") {
|
||||||
previous();
|
previous();
|
||||||
} else if (e.key === ' ') {
|
} else if (e.key === " ") {
|
||||||
togglePlayPause();
|
togglePlayPause();
|
||||||
} else if (e.key === 'r') {
|
} else if (e.key === "r") {
|
||||||
restart();
|
restart();
|
||||||
} else if (e.key === 'a') {
|
} else if (e.key === "a") {
|
||||||
; handleAutoPauseToggle();
|
handleAutoPauseToggle();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', handleKeyDownEvent);
|
};
|
||||||
return () => document.removeEventListener('keydown', handleKeyDownEvent)
|
document.addEventListener("keydown", handleKeyDownEvent);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDownEvent);
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cb = () => {
|
const cb = () => {
|
||||||
if (ready.current.all()) {
|
if (ready.current.all()) {
|
||||||
if (!parsedSrtRef.current) {
|
if (!parsedSrtRef.current) {
|
||||||
;
|
} else if (isPlaying) {
|
||||||
} else if (isPlaying) {
|
// 这里负责显示当前时间的字幕与自动暂停
|
||||||
// 这里负责显示当前时间的字幕与自动暂停
|
const srt = parsedSrtRef.current;
|
||||||
const srt = parsedSrtRef.current;
|
const ct = videoRef.current?.currentTime as number;
|
||||||
const ct = videoRef.current?.currentTime as number;
|
const index = getIndex(srt, ct);
|
||||||
const index = getIndex(srt, ct);
|
if (index !== null) {
|
||||||
if (index !== null) {
|
setSubtitle(srt[index].text);
|
||||||
setSubtitle(srt[index].text)
|
if (
|
||||||
if (autoPause && ct >= (srt[index].end - 0.05) && ct < srt[index].end) {
|
autoPause &&
|
||||||
videoRef.current!.currentTime = srt[index].start;
|
ct >= srt[index].end - 0.05 &&
|
||||||
togglePlayPause();
|
ct < srt[index].end
|
||||||
}
|
) {
|
||||||
} else {
|
videoRef.current!.currentTime = srt[index].start;
|
||||||
setSubtitle('');
|
togglePlayPause();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
;
|
setSubtitle("");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
rafldRef.current = requestAnimationFrame(cb);
|
} else {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
rafldRef.current = requestAnimationFrame(cb);
|
rafldRef.current = requestAnimationFrame(cb);
|
||||||
return () => {
|
};
|
||||||
cancelAnimationFrame(rafldRef.current);
|
rafldRef.current = requestAnimationFrame(cb);
|
||||||
}
|
return () => {
|
||||||
|
cancelAnimationFrame(rafldRef.current);
|
||||||
|
};
|
||||||
}, [autoPause, isPlaying, togglePlayPause, videoRef]);
|
}, [autoPause, isPlaying, togglePlayPause, videoRef]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (videoUrl && videoRef.current) {
|
if (videoUrl && videoRef.current) {
|
||||||
videoRef.current.src = videoUrl;
|
videoRef.current.src = videoUrl;
|
||||||
videoRef.current.load();
|
videoRef.current.load();
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
ready.current['vid'] = true;
|
ready.current["vid"] = true;
|
||||||
}
|
}
|
||||||
}, [videoRef, videoUrl]);
|
}, [videoRef, videoUrl]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (srtUrl) {
|
if (srtUrl) {
|
||||||
fetch(srtUrl)
|
fetch(srtUrl)
|
||||||
.then(response => response.text())
|
.then((response) => response.text())
|
||||||
.then(data => {
|
.then((data) => {
|
||||||
parsedSrtRef.current = parseSrt(data);
|
parsedSrtRef.current = parseSrt(data);
|
||||||
setSrtLength(parsedSrtRef.current.length);
|
setSrtLength(parsedSrtRef.current.length);
|
||||||
ready.current['sub'] = true;
|
ready.current["sub"] = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [srtUrl]);
|
}, [srtUrl]);
|
||||||
|
|
||||||
const timeUpdate = () => {
|
const timeUpdate = () => {
|
||||||
if (!parsedSrtRef.current || !videoRef.current) return;
|
if (!parsedSrtRef.current || !videoRef.current) return;
|
||||||
const index = getIndex(parsedSrtRef.current, videoRef.current.currentTime);
|
const index = getIndex(
|
||||||
if (!index) return;
|
parsedSrtRef.current,
|
||||||
setSpanText(`${index + 1}/${parsedSrtRef.current.length}`)
|
videoRef.current.currentTime,
|
||||||
}
|
);
|
||||||
|
if (!index) return;
|
||||||
|
setSpanText(`${index + 1}/${parsedSrtRef.current.length}`);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (videoRef.current && parsedSrtRef.current) {
|
if (videoRef.current && parsedSrtRef.current) {
|
||||||
const newProgress = parseInt(e.target.value);
|
const newProgress = parseInt(e.target.value);
|
||||||
videoRef.current.currentTime = parsedSrtRef.current[newProgress]?.start || 0;
|
videoRef.current.currentTime =
|
||||||
setProgress(newProgress);
|
parsedSrtRef.current[newProgress]?.start || 0;
|
||||||
}
|
setProgress(newProgress);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAutoPauseToggle = () => {
|
const handleAutoPauseToggle = () => {
|
||||||
setAutoPause(!autoPause);
|
setAutoPause(!autoPause);
|
||||||
};
|
};
|
||||||
|
|
||||||
const next = () => {
|
const next = () => {
|
||||||
if (!parsedSrtRef.current || !videoRef.current) return;
|
if (!parsedSrtRef.current || !videoRef.current) return;
|
||||||
const i = getNearistIndex(parsedSrtRef.current, videoRef.current.currentTime);
|
const i = getNearistIndex(
|
||||||
if (i != null && i + 1 < parsedSrtRef.current.length) {
|
parsedSrtRef.current,
|
||||||
videoRef.current.currentTime = parsedSrtRef.current[i + 1].start;
|
videoRef.current.currentTime,
|
||||||
videoRef.current.play();
|
);
|
||||||
setIsPlaying(true);
|
if (i != null && i + 1 < parsedSrtRef.current.length) {
|
||||||
}
|
videoRef.current.currentTime = parsedSrtRef.current[i + 1].start;
|
||||||
}
|
videoRef.current.play();
|
||||||
|
setIsPlaying(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const previous = () => {
|
const previous = () => {
|
||||||
if (!parsedSrtRef.current || !videoRef.current) return;
|
if (!parsedSrtRef.current || !videoRef.current) return;
|
||||||
const i = getNearistIndex(parsedSrtRef.current, videoRef.current.currentTime);
|
const i = getNearistIndex(
|
||||||
if (i != null && i - 1 >= 0) {
|
parsedSrtRef.current,
|
||||||
videoRef.current.currentTime = parsedSrtRef.current[i - 1].start;
|
videoRef.current.currentTime,
|
||||||
videoRef.current.play();
|
);
|
||||||
setIsPlaying(true);
|
if (i != null && i - 1 >= 0) {
|
||||||
}
|
videoRef.current.currentTime = parsedSrtRef.current[i - 1].start;
|
||||||
}
|
videoRef.current.play();
|
||||||
|
setIsPlaying(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const restart = () => {
|
const restart = () => {
|
||||||
if (!parsedSrtRef.current || !videoRef.current) return;
|
if (!parsedSrtRef.current || !videoRef.current) return;
|
||||||
const i = getNearistIndex(parsedSrtRef.current, videoRef.current.currentTime);
|
const i = getNearistIndex(
|
||||||
if (i != null && i >= 0) {
|
parsedSrtRef.current,
|
||||||
videoRef.current.currentTime = parsedSrtRef.current[i].start;
|
videoRef.current.currentTime,
|
||||||
videoRef.current.play();
|
);
|
||||||
setIsPlaying(true);
|
if (i != null && i >= 0) {
|
||||||
}
|
videoRef.current.currentTime = parsedSrtRef.current[i].start;
|
||||||
}
|
videoRef.current.play();
|
||||||
|
setIsPlaying(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-col">
|
<div className="w-full flex flex-col">
|
||||||
<video className="bg-gray-200" ref={videoRef} onTimeUpdate={timeUpdate}></video>
|
<video
|
||||||
<SubtitleDisplay subtitle={subtitle}></SubtitleDisplay>
|
className="bg-gray-200"
|
||||||
<div className="buttons flex mt-2 gap-2 flex-wrap">
|
ref={videoRef}
|
||||||
<Button onClick={togglePlayPause}>{isPlaying ? '暂停' : '播放'}</Button>
|
onTimeUpdate={timeUpdate}
|
||||||
<Button onClick={previous}>上句</Button>
|
></video>
|
||||||
<Button onClick={next}>下句</Button>
|
<SubtitleDisplay subtitle={subtitle}></SubtitleDisplay>
|
||||||
<Button onClick={restart}>句首</Button>
|
<div className="buttons flex mt-2 gap-2 flex-wrap">
|
||||||
<Button onClick={handleAutoPauseToggle}>{`自动暂停(${autoPause ? '是' : '否'})`}</Button>
|
<Button onClick={togglePlayPause}>
|
||||||
</div>
|
{isPlaying ? "暂停" : "播放"}
|
||||||
<input className="seekbar" type="range" min={0} max={srtLength} onChange={handleSeek} step={1} value={progress}></input>
|
</Button>
|
||||||
<span>{spanText}</span>
|
<Button onClick={previous}>上句</Button>
|
||||||
|
<Button onClick={next}>下句</Button>
|
||||||
|
<Button onClick={restart}>句首</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleAutoPauseToggle}
|
||||||
|
>{`自动暂停(${autoPause ? "是" : "否"})`}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
className="seekbar"
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={srtLength}
|
||||||
|
onChange={handleSeek}
|
||||||
|
step={1}
|
||||||
|
value={progress}
|
||||||
|
></input>
|
||||||
|
<span>{spanText}</span>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
VideoPanel.displayName = 'VideoPanel';
|
VideoPanel.displayName = "VideoPanel";
|
||||||
|
|
||||||
export default VideoPanel;
|
export default VideoPanel;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { KeyboardEvent, useRef, useState } from "react";
|
import { KeyboardEvent, useRef, useState } from "react";
|
||||||
import UploadArea from "./UploadArea";
|
import UploadArea from "./UploadArea";
|
||||||
@@ -10,18 +10,18 @@ export default function SrtPlayer() {
|
|||||||
|
|
||||||
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
||||||
const [srtUrl, setSrtUrl] = useState<string | null>(null);
|
const [srtUrl, setSrtUrl] = useState<string | null>(null);
|
||||||
return (<>
|
return (
|
||||||
|
<>
|
||||||
<Navbar></Navbar>
|
<Navbar></Navbar>
|
||||||
<div className="flex w-screen pt-8 items-center justify-center" onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => e.preventDefault()}>
|
<div
|
||||||
|
className="flex w-screen pt-8 items-center justify-center"
|
||||||
|
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => e.preventDefault()}
|
||||||
|
>
|
||||||
<div className="w-[80vw] md:w-[45vw] flex items-center flex-col">
|
<div className="w-[80vw] md:w-[45vw] flex items-center flex-col">
|
||||||
<VideoPanel
|
<VideoPanel videoUrl={videoUrl} srtUrl={srtUrl} ref={videoRef} />
|
||||||
videoUrl={videoUrl}
|
<UploadArea setVideoUrl={setVideoUrl} setSrtUrl={setSrtUrl} />
|
||||||
srtUrl={srtUrl}
|
|
||||||
ref={videoRef} />
|
|
||||||
<UploadArea
|
|
||||||
setVideoUrl={setVideoUrl}
|
|
||||||
setSrtUrl={setSrtUrl} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>);
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +1,74 @@
|
|||||||
export function parseSrt(data: string) {
|
export function parseSrt(data: string) {
|
||||||
const lines = data.split(/\r?\n/);
|
const lines = data.split(/\r?\n/);
|
||||||
const result = [];
|
const result = [];
|
||||||
const re = new RegExp('(\\d{2}:\\d{2}:\\d{2},\\d{3})\\s*-->\\s*(\\d{2}:\\d{2}:\\d{2},\\d{3})');
|
const re = new RegExp(
|
||||||
let i = 0;
|
"(\\d{2}:\\d{2}:\\d{2},\\d{3})\\s*-->\\s*(\\d{2}:\\d{2}:\\d{2},\\d{3})",
|
||||||
while (i < lines.length) {
|
);
|
||||||
if (!lines[i].trim()) { i++; continue; }
|
let i = 0;
|
||||||
i++;
|
while (i < lines.length) {
|
||||||
if (i >= lines.length) break;
|
if (!lines[i].trim()) {
|
||||||
const timeMatch = lines[i].match(re);
|
i++;
|
||||||
if (!timeMatch) { i++; continue; }
|
continue;
|
||||||
const start = toSeconds(timeMatch[1]);
|
|
||||||
const end = toSeconds(timeMatch[2]);
|
|
||||||
i++;
|
|
||||||
let text = '';
|
|
||||||
while (i < lines.length && lines[i].trim()) {
|
|
||||||
text += lines[i] + '\n';
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
result.push({ start, end, text: text.trim() });
|
|
||||||
i++;
|
|
||||||
}
|
}
|
||||||
return result;
|
i++;
|
||||||
|
if (i >= lines.length) break;
|
||||||
|
const timeMatch = lines[i].match(re);
|
||||||
|
if (!timeMatch) {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const start = toSeconds(timeMatch[1]);
|
||||||
|
const end = toSeconds(timeMatch[2]);
|
||||||
|
i++;
|
||||||
|
let text = "";
|
||||||
|
while (i < lines.length && lines[i].trim()) {
|
||||||
|
text += lines[i] + "\n";
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
result.push({ start, end, text: text.trim() });
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNearistIndex(srt: { start: number; end: number; text: string; }[], ct: number) {
|
export function getNearistIndex(
|
||||||
for (let i = 0; i < srt.length; i++) {
|
srt: { start: number; end: number; text: string }[],
|
||||||
const s = srt[i];
|
ct: number,
|
||||||
const l = ct - s.start >= 0;
|
) {
|
||||||
const r = ct - s.end >= 0;
|
for (let i = 0; i < srt.length; i++) {
|
||||||
if (!(l || r)) return i - 1;
|
const s = srt[i];
|
||||||
if (l && (!r)) return i;
|
const l = ct - s.start >= 0;
|
||||||
}
|
const r = ct - s.end >= 0;
|
||||||
|
if (!(l || r)) return i - 1;
|
||||||
|
if (l && !r) return i;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getIndex(srt: { start: number; end: number; text: string; }[], ct: number) {
|
export function getIndex(
|
||||||
for (let i = 0; i < srt.length; i++) {
|
srt: { start: number; end: number; text: string }[],
|
||||||
if (ct >= srt[i].start && ct <= srt[i].end) {
|
ct: number,
|
||||||
return i;
|
) {
|
||||||
}
|
for (let i = 0; i < srt.length; i++) {
|
||||||
|
if (ct >= srt[i].start && ct <= srt[i].end) {
|
||||||
|
return i;
|
||||||
}
|
}
|
||||||
return null;
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSubtitle(srt: { start: number; end: number; text: string; }[], currentTime: number) {
|
export function getSubtitle(
|
||||||
return srt.find(sub => currentTime >= sub.start && currentTime <= sub.end) || null;
|
srt: { start: number; end: number; text: string }[],
|
||||||
|
currentTime: number,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
srt.find((sub) => currentTime >= sub.start && currentTime <= sub.end) ||
|
||||||
|
null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toSeconds(timeStr: string): number {
|
function toSeconds(timeStr: string): number {
|
||||||
const [h, m, s] = timeStr.replace(',', '.').split(':');
|
const [h, m, s] = timeStr.replace(",", ".").split(":");
|
||||||
return parseFloat((parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s)).toFixed(3));
|
return parseFloat(
|
||||||
|
(parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s)).toFixed(3),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { getTextSpeakerData, setTextSpeakerData } from "@/utils";
|
import { getTextSpeakerData, setTextSpeakerData } from "@/utils";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -8,88 +8,98 @@ import IconClick from "@/components/IconClick";
|
|||||||
import IMAGES from "@/config/images";
|
import IMAGES from "@/config/images";
|
||||||
|
|
||||||
interface TextCardProps {
|
interface TextCardProps {
|
||||||
item: z.infer<typeof TextSpeakerItemSchema>;
|
item: z.infer<typeof TextSpeakerItemSchema>;
|
||||||
handleUse: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
|
handleUse: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
|
||||||
handleDel: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
|
handleDel: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
|
||||||
}
|
}
|
||||||
function TextCard({
|
function TextCard({ item, handleUse, handleDel }: TextCardProps) {
|
||||||
item,
|
const onUseClick = () => {
|
||||||
handleUse,
|
handleUse(item);
|
||||||
handleDel
|
};
|
||||||
}: TextCardProps) {
|
const onDelClick = () => {
|
||||||
const onUseClick = () => {
|
handleDel(item);
|
||||||
handleUse(item);
|
};
|
||||||
}
|
return (
|
||||||
const onDelClick = () => {
|
<div className="p-2 border-b-1 border-gray-200 rounded-2xl bg-gray-100 m-2 grid grid-cols-8">
|
||||||
handleDel(item);
|
<div className="col-span-7" onClick={onUseClick}>
|
||||||
}
|
<div className="max-h-26 hover:cursor-pointer text-3xl overflow-y-auto">
|
||||||
return (
|
{item.text}
|
||||||
<div className="p-2 border-b-1 border-gray-200 rounded-2xl bg-gray-100 m-2 grid grid-cols-8">
|
|
||||||
<div className="col-span-7" onClick={onUseClick}>
|
|
||||||
<div className="max-h-26 hover:cursor-pointer text-3xl overflow-y-auto">{item.text}</div>
|
|
||||||
<div className="max-h-16 overflow-y-auto text-xl text-gray-600 whitespace-nowrap overflow-x-auto">{item.ipa}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-center items-center border-gray-300 border-l-2 m-2">
|
|
||||||
<IconClick
|
|
||||||
src={IMAGES.delete}
|
|
||||||
alt="delete"
|
|
||||||
onClick={onDelClick}
|
|
||||||
className="place-self-center"
|
|
||||||
size={42}>
|
|
||||||
</IconClick>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
<div className="max-h-16 overflow-y-auto text-xl text-gray-600 whitespace-nowrap overflow-x-auto">
|
||||||
|
{item.ipa}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center items-center border-gray-300 border-l-2 m-2">
|
||||||
|
<IconClick
|
||||||
|
src={IMAGES.delete}
|
||||||
|
alt="delete"
|
||||||
|
onClick={onDelClick}
|
||||||
|
className="place-self-center"
|
||||||
|
size={42}
|
||||||
|
></IconClick>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SaveListProps {
|
interface SaveListProps {
|
||||||
show?: boolean;
|
show?: boolean;
|
||||||
handleUse: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
|
handleUse: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
|
||||||
}
|
}
|
||||||
export default function SaveList({
|
export default function SaveList({ show = false, handleUse }: SaveListProps) {
|
||||||
show = false,
|
const [data, setData] = useState(getTextSpeakerData());
|
||||||
handleUse
|
const handleDel = (item: z.infer<typeof TextSpeakerItemSchema>) => {
|
||||||
}: SaveListProps) {
|
const current_data = getTextSpeakerData();
|
||||||
const [data, setData] = useState(getTextSpeakerData());
|
current_data.splice(
|
||||||
const handleDel = (item: z.infer<typeof TextSpeakerItemSchema>) => {
|
current_data.findIndex((v) => v.text === item.text),
|
||||||
const current_data = getTextSpeakerData();
|
1,
|
||||||
current_data.splice(
|
);
|
||||||
current_data.findIndex(v => v.text === item.text), 1
|
setTextSpeakerData(current_data);
|
||||||
);
|
refresh();
|
||||||
setTextSpeakerData(current_data);
|
};
|
||||||
refresh();
|
const refresh = () => {
|
||||||
|
setData(getTextSpeakerData());
|
||||||
|
};
|
||||||
|
const handleDeleteAll = () => {
|
||||||
|
const yesorno = prompt("确定删光吗?(Y/N)")?.trim();
|
||||||
|
if (yesorno && (yesorno === "Y" || yesorno === "y")) {
|
||||||
|
setTextSpeakerData([]);
|
||||||
|
refresh();
|
||||||
}
|
}
|
||||||
const refresh = () => {
|
};
|
||||||
setData(getTextSpeakerData());
|
if (show)
|
||||||
}
|
return (
|
||||||
const handleDeleteAll = () => {
|
<div
|
||||||
const yesorno = prompt('确定删光吗?(Y/N)')?.trim();
|
className="my-4 p-2 mx-4 md:mx-32 border-1 border-gray-200 rounded-2xl"
|
||||||
if (yesorno && (yesorno === 'Y' || yesorno === 'y')) {
|
style={{ fontFamily: "Times New Roman, serif" }}
|
||||||
setTextSpeakerData([]);
|
>
|
||||||
refresh();
|
<div className="flex flex-row justify-center gap-8 items-center">
|
||||||
}
|
<IconClick
|
||||||
}
|
src={IMAGES.refresh}
|
||||||
if (show) return (
|
alt="refresh"
|
||||||
<div className="my-4 p-2 mx-4 md:mx-32 border-1 border-gray-200 rounded-2xl" style={{ fontFamily: 'Times New Roman, serif' }}>
|
onClick={refresh}
|
||||||
<div className="flex flex-row justify-center gap-8 items-center">
|
size={48}
|
||||||
<IconClick
|
className=""
|
||||||
src={IMAGES.refresh}
|
></IconClick>
|
||||||
alt="refresh"
|
<IconClick
|
||||||
onClick={refresh}
|
src={IMAGES.delete}
|
||||||
size={48}
|
alt="delete"
|
||||||
className=""></IconClick>
|
onClick={handleDeleteAll}
|
||||||
<IconClick
|
size={48}
|
||||||
src={IMAGES.delete}
|
className=""
|
||||||
alt="delete"
|
></IconClick>
|
||||||
onClick={handleDeleteAll}
|
|
||||||
size={48}
|
|
||||||
className=""></IconClick>
|
|
||||||
</div>
|
|
||||||
<ul>
|
|
||||||
{data.map(v =>
|
|
||||||
<TextCard item={v} key={crypto.randomUUID()} handleUse={handleUse} handleDel={handleDel}></TextCard>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
); else return (<></>);
|
<ul>
|
||||||
}
|
{data.map((v) => (
|
||||||
|
<TextCard
|
||||||
|
item={v}
|
||||||
|
key={crypto.randomUUID()}
|
||||||
|
handleUse={handleUse}
|
||||||
|
handleDel={handleDel}
|
||||||
|
></TextCard>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
else return <></>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import Button from "@/components/Button";
|
|||||||
import IconClick from "@/components/IconClick";
|
import IconClick from "@/components/IconClick";
|
||||||
import IMAGES from "@/config/images";
|
import IMAGES from "@/config/images";
|
||||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||||
import { getTextSpeakerData, getTTSAudioUrl, setTextSpeakerData } from "@/utils";
|
import {
|
||||||
|
getTextSpeakerData,
|
||||||
|
getTTSAudioUrl,
|
||||||
|
setTextSpeakerData,
|
||||||
|
} from "@/utils";
|
||||||
import { ChangeEvent, useEffect, useRef, useState } from "react";
|
import { ChangeEvent, useEffect, useRef, useState } from "react";
|
||||||
import SaveList from "./SaveList";
|
import SaveList from "./SaveList";
|
||||||
import { TextSpeakerItemSchema } from "@/interfaces";
|
import { TextSpeakerItemSchema } from "@/interfaces";
|
||||||
@@ -13,277 +17,320 @@ import { Navbar } from "@/components/Navbar";
|
|||||||
import { VOICES } from "@/config/locales";
|
import { VOICES } from "@/config/locales";
|
||||||
|
|
||||||
export default function TextSpeaker() {
|
export default function TextSpeaker() {
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const [showSpeedAdjust, setShowSpeedAdjust] = useState(false);
|
const [showSpeedAdjust, setShowSpeedAdjust] = useState(false);
|
||||||
const [showSaveList, setShowSaveList] = useState(false);
|
const [showSaveList, setShowSaveList] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [ipaEnabled, setIPAEnabled] = useState(false);
|
const [ipaEnabled, setIPAEnabled] = useState(false);
|
||||||
const [speed, setSpeed] = useState(1);
|
const [speed, setSpeed] = useState(1);
|
||||||
const [pause, setPause] = useState(true);
|
const [pause, setPause] = useState(true);
|
||||||
const [autopause, setAutopause] = useState(true);
|
const [autopause, setAutopause] = useState(true);
|
||||||
const textRef = useRef('');
|
const textRef = useRef("");
|
||||||
const [locale, setLocale] = useState<string | null>(null);
|
const [locale, setLocale] = useState<string | null>(null);
|
||||||
const [ipa, setIPA] = useState<string>('');
|
const [ipa, setIPA] = useState<string>("");
|
||||||
const objurlRef = useRef<string | null>(null);
|
const objurlRef = useRef<string | null>(null);
|
||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
const { playAudio, stopAudio, audioRef } = useAudioPlayer();
|
const { playAudio, stopAudio, audioRef } = useAudioPlayer();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
if (!audio) return;
|
if (!audio) return;
|
||||||
|
|
||||||
const handleEnded = () => {
|
const handleEnded = () => {
|
||||||
if (autopause) {
|
if (autopause) {
|
||||||
setPause(true);
|
setPause(true);
|
||||||
} else {
|
} else {
|
||||||
playAudio(objurlRef.current!);
|
playAudio(objurlRef.current!);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
audio.addEventListener('ended', handleEnded);
|
audio.addEventListener("ended", handleEnded);
|
||||||
return () => {
|
return () => {
|
||||||
audio.removeEventListener('ended', handleEnded);
|
audio.removeEventListener("ended", handleEnded);
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [audioRef, autopause]);
|
}, [audioRef, autopause]);
|
||||||
|
|
||||||
const speak = async () => {
|
const speak = async () => {
|
||||||
if (processing) return;
|
if (processing) return;
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
|
|
||||||
if (ipa.length === 0 && ipaEnabled && textRef.current.length !== 0) {
|
if (ipa.length === 0 && ipaEnabled && textRef.current.length !== 0) {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
text: textRef.current
|
text: textRef.current,
|
||||||
});
|
});
|
||||||
fetch(`/api/ipa?${params}`)
|
fetch(`/api/ipa?${params}`)
|
||||||
.then(res => res.json())
|
.then((res) => res.json())
|
||||||
.then(data => {
|
.then((data) => {
|
||||||
setIPA(data.ipa);
|
setIPA(data.ipa);
|
||||||
}).catch(e => {
|
})
|
||||||
console.error(e);
|
.catch((e) => {
|
||||||
setIPA('');
|
console.error(e);
|
||||||
})
|
setIPA("");
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (pause) {
|
if (pause) {
|
||||||
// 如果没在读
|
// 如果没在读
|
||||||
if (textRef.current.length === 0) {
|
if (textRef.current.length === 0) {
|
||||||
// 没文本咋读
|
// 没文本咋读
|
||||||
} else {
|
} else {
|
||||||
setPause(false);
|
setPause(false);
|
||||||
|
|
||||||
if (objurlRef.current) {
|
if (objurlRef.current) {
|
||||||
// 之前有播放
|
// 之前有播放
|
||||||
playAudio(objurlRef.current);
|
playAudio(objurlRef.current);
|
||||||
} else {
|
|
||||||
// 第一次播放
|
|
||||||
try {
|
|
||||||
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 voice = VOICES.find(v => v.locale.startsWith(theLocale));
|
|
||||||
if (!voice) throw 'Voice not found.';
|
|
||||||
|
|
||||||
objurlRef.current = await getTTSAudioUrl(
|
|
||||||
textRef.current,
|
|
||||||
voice.short_name,
|
|
||||||
(() => {
|
|
||||||
if (speed === 1) return {};
|
|
||||||
else if (speed < 1) return {
|
|
||||||
rate: `-${100 - speed * 100}%`
|
|
||||||
}; else return {
|
|
||||||
rate: `+${speed * 100 - 100}%`
|
|
||||||
};
|
|
||||||
})()
|
|
||||||
);
|
|
||||||
playAudio(objurlRef.current);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
|
|
||||||
setPause(true);
|
|
||||||
setLocale(null);
|
|
||||||
|
|
||||||
setProcessing(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// 如果在读就暂停
|
// 第一次播放
|
||||||
setPause(true);
|
try {
|
||||||
stopAudio();
|
|
||||||
}
|
|
||||||
|
|
||||||
setProcessing(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
|
||||||
textRef.current = e.target.value.trim();
|
|
||||||
setLocale(null);
|
|
||||||
setIPA('');
|
|
||||||
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
|
||||||
objurlRef.current = null;
|
|
||||||
stopAudio();
|
|
||||||
setPause(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const letMeSetSpeed = (new_speed: number) => {
|
|
||||||
return () => {
|
|
||||||
setSpeed(new_speed);
|
|
||||||
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
|
||||||
objurlRef.current = null;
|
|
||||||
stopAudio();
|
|
||||||
setPause(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUseItem = (item: z.infer<typeof TextSpeakerItemSchema>) => {
|
|
||||||
if (textareaRef.current) textareaRef.current.value = item.text;
|
|
||||||
textRef.current = item.text;
|
|
||||||
setLocale(item.locale);
|
|
||||||
setIPA(item.ipa || '');
|
|
||||||
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
|
||||||
objurlRef.current = null;
|
|
||||||
stopAudio();
|
|
||||||
setPause(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const save = async () => {
|
|
||||||
if (saving) return;
|
|
||||||
if (textRef.current.length === 0) return;
|
|
||||||
|
|
||||||
setSaving(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
let theLocale = locale;
|
let theLocale = locale;
|
||||||
if (!theLocale) {
|
if (!theLocale) {
|
||||||
console.log('downloading text info');
|
console.log("downloading text info");
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
text: textRef.current.slice(0, 30)
|
text: textRef.current.slice(0, 30),
|
||||||
});
|
});
|
||||||
const textinfo = await (await fetch(`/api/locale?${params}`)).json();
|
const textinfo = await (
|
||||||
setLocale(textinfo.locale);
|
await fetch(`/api/locale?${params}`)
|
||||||
theLocale = textinfo.locale as string;
|
).json();
|
||||||
|
setLocale(textinfo.locale);
|
||||||
|
theLocale = textinfo.locale as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let theIPA = ipa;
|
const voice = VOICES.find((v) => v.locale.startsWith(theLocale));
|
||||||
if (ipa.length === 0 && ipaEnabled) {
|
if (!voice) throw "Voice not found.";
|
||||||
const params = new URLSearchParams({
|
|
||||||
text: textRef.current
|
|
||||||
});
|
|
||||||
const tmp = await (await fetch(`/api/ipa?${params}`)).json();
|
|
||||||
setIPA(tmp.ipa);
|
|
||||||
theIPA = tmp.ipa;
|
|
||||||
}
|
|
||||||
|
|
||||||
const save = getTextSpeakerData();
|
objurlRef.current = await getTTSAudioUrl(
|
||||||
const oldIndex = save.findIndex(v => v.text === textRef.current);
|
textRef.current,
|
||||||
if (oldIndex !== -1) {
|
voice.short_name,
|
||||||
const oldItem = save[oldIndex];
|
(() => {
|
||||||
if (theIPA) {
|
if (speed === 1) return {};
|
||||||
if ((!oldItem.ipa || (oldItem.ipa !== theIPA))) {
|
else if (speed < 1)
|
||||||
oldItem.ipa = theIPA;
|
return {
|
||||||
setTextSpeakerData(save);
|
rate: `-${100 - speed * 100}%`,
|
||||||
}
|
};
|
||||||
}
|
else
|
||||||
} else if (theIPA.length === 0) {
|
return {
|
||||||
save.push({
|
rate: `+${speed * 100 - 100}%`,
|
||||||
text: textRef.current,
|
};
|
||||||
locale: theLocale
|
})(),
|
||||||
});
|
);
|
||||||
} else {
|
playAudio(objurlRef.current);
|
||||||
save.push({
|
} catch (e) {
|
||||||
text: textRef.current,
|
|
||||||
locale: theLocale,
|
|
||||||
ipa: theIPA
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setTextSpeakerData(save);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
|
||||||
|
setPause(true);
|
||||||
setLocale(null);
|
setLocale(null);
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
setProcessing(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果在读就暂停
|
||||||
|
setPause(true);
|
||||||
|
stopAudio();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (<>
|
setProcessing(false);
|
||||||
<Navbar></Navbar>
|
};
|
||||||
<div className="my-4 p-4 mx-4 md:mx-32 border-1 border-gray-200 rounded-2xl" style={{ fontFamily: 'Times New Roman, serif' }}>
|
|
||||||
<textarea className="text-2xl resize-none focus:outline-0 min-h-64 w-full border-gray-200 border-b"
|
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
onChange={handleInputChange}
|
textRef.current = e.target.value.trim();
|
||||||
ref={textareaRef}>
|
setLocale(null);
|
||||||
</textarea>
|
setIPA("");
|
||||||
{
|
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
||||||
ipa.length !== 0 && (<div className="overflow-auto text-gray-600 h-18 border-gray-200 border-b">
|
objurlRef.current = null;
|
||||||
{ipa}
|
stopAudio();
|
||||||
</div>) || (<div className="h-18"></div>)
|
setPause(true);
|
||||||
}
|
};
|
||||||
<div className="mt-8 relative w-full flex flex-row flex-wrap gap-2 justify-center items-center">
|
|
||||||
{showSpeedAdjust && (
|
const letMeSetSpeed = (new_speed: number) => {
|
||||||
<div className="bg-white p-6 rounded-2xl border-gray-200 border-2 shadow-2xl absolute left-1/2 -translate-x-1/2 -translate-y-full -top-4 flex flex-row flex-wrap gap-2 justify-center items-center">
|
return () => {
|
||||||
<IconClick size={45} onClick={letMeSetSpeed(0.5)}
|
setSpeed(new_speed);
|
||||||
src={IMAGES.speed_0_5x}
|
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
||||||
alt="0.5x"
|
objurlRef.current = null;
|
||||||
className={speed === 0.5 ? 'bg-gray-200' : ''}
|
stopAudio();
|
||||||
></IconClick>
|
setPause(true);
|
||||||
<IconClick size={45} onClick={letMeSetSpeed(0.7)}
|
};
|
||||||
src={IMAGES.speed_0_7x}
|
};
|
||||||
alt="0.7x"
|
|
||||||
className={speed === 0.7 ? 'bg-gray-200' : ''}
|
const handleUseItem = (item: z.infer<typeof TextSpeakerItemSchema>) => {
|
||||||
></IconClick>
|
if (textareaRef.current) textareaRef.current.value = item.text;
|
||||||
<IconClick size={45} onClick={letMeSetSpeed(1)}
|
textRef.current = item.text;
|
||||||
src={IMAGES.speed_1x}
|
setLocale(item.locale);
|
||||||
alt="1x"
|
setIPA(item.ipa || "");
|
||||||
className={speed === 1 ? 'bg-gray-200' : ''}
|
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
||||||
></IconClick>
|
objurlRef.current = null;
|
||||||
<IconClick size={45} onClick={letMeSetSpeed(1.2)}
|
stopAudio();
|
||||||
src={IMAGES.speed_1_2_x}
|
setPause(true);
|
||||||
alt="1.2x"
|
};
|
||||||
className={speed === 1.2 ? 'bg-gray-200' : ''}
|
|
||||||
></IconClick>
|
const save = async () => {
|
||||||
<IconClick size={45} onClick={letMeSetSpeed(1.5)}
|
if (saving) return;
|
||||||
src={IMAGES.speed_1_5x}
|
if (textRef.current.length === 0) return;
|
||||||
alt="1.5x"
|
|
||||||
className={speed === 1.5 ? 'bg-gray-200' : ''}
|
setSaving(true);
|
||||||
></IconClick>
|
|
||||||
</div>)}
|
try {
|
||||||
<IconClick size={45} onClick={speak} src={
|
let theLocale = locale;
|
||||||
pause ? IMAGES.play_arrow : IMAGES.pause
|
if (!theLocale) {
|
||||||
} alt="playorpause" className={`${processing ? 'bg-gray-200' : ''}`}></IconClick>
|
console.log("downloading text info");
|
||||||
<IconClick size={45} onClick={() => {
|
const params = new URLSearchParams({
|
||||||
setAutopause(!autopause); if (objurlRef) { stopAudio(); } setPause(true);
|
text: textRef.current.slice(0, 30),
|
||||||
}} src={
|
});
|
||||||
autopause ? IMAGES.autoplay : IMAGES.autopause
|
const textinfo = await (await fetch(`/api/locale?${params}`)).json();
|
||||||
} alt="autoplayorpause"
|
setLocale(textinfo.locale);
|
||||||
></IconClick>
|
theLocale = textinfo.locale as string;
|
||||||
<IconClick size={45} onClick={() => setShowSpeedAdjust(!showSpeedAdjust)}
|
}
|
||||||
src={IMAGES.speed}
|
|
||||||
alt="speed"
|
let theIPA = ipa;
|
||||||
className={`${showSpeedAdjust ? 'bg-gray-200' : ''}`}></IconClick>
|
if (ipa.length === 0 && ipaEnabled) {
|
||||||
<IconClick size={45} onClick={save}
|
const params = new URLSearchParams({
|
||||||
src={IMAGES.save}
|
text: textRef.current,
|
||||||
alt="save"
|
});
|
||||||
className={`${saving ? 'bg-gray-200' : ''}`}></IconClick>
|
const tmp = await (await fetch(`/api/ipa?${params}`)).json();
|
||||||
<div className="w-full flex flex-row flex-wrap gap-2 justify-center items-center">
|
setIPA(tmp.ipa);
|
||||||
<Button
|
theIPA = tmp.ipa;
|
||||||
selected={ipaEnabled}
|
}
|
||||||
onClick={() => setIPAEnabled(!ipaEnabled)}>
|
|
||||||
生成IPA
|
const save = getTextSpeakerData();
|
||||||
</Button>
|
const oldIndex = save.findIndex((v) => v.text === textRef.current);
|
||||||
<Button
|
if (oldIndex !== -1) {
|
||||||
onClick={() => { setShowSaveList(!showSaveList) }}
|
const oldItem = save[oldIndex];
|
||||||
selected={showSaveList}>
|
if (theIPA) {
|
||||||
查看保存项
|
if (!oldItem.ipa || oldItem.ipa !== theIPA) {
|
||||||
</Button>
|
oldItem.ipa = theIPA;
|
||||||
</div>
|
setTextSpeakerData(save);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (theIPA.length === 0) {
|
||||||
|
save.push({
|
||||||
|
text: textRef.current,
|
||||||
|
locale: theLocale,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
save.push({
|
||||||
|
text: textRef.current,
|
||||||
|
locale: theLocale,
|
||||||
|
ipa: theIPA,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setTextSpeakerData(save);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setLocale(null);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Navbar></Navbar>
|
||||||
|
<div
|
||||||
|
className="my-4 p-4 mx-4 md:mx-32 border-1 border-gray-200 rounded-2xl"
|
||||||
|
style={{ fontFamily: "Times New Roman, serif" }}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
className="text-2xl resize-none focus:outline-0 min-h-64 w-full border-gray-200 border-b"
|
||||||
|
onChange={handleInputChange}
|
||||||
|
ref={textareaRef}
|
||||||
|
></textarea>
|
||||||
|
{(ipa.length !== 0 && (
|
||||||
|
<div className="overflow-auto text-gray-600 h-18 border-gray-200 border-b">
|
||||||
|
{ipa}
|
||||||
|
</div>
|
||||||
|
)) || <div className="h-18"></div>}
|
||||||
|
<div className="mt-8 relative w-full flex flex-row flex-wrap gap-2 justify-center items-center">
|
||||||
|
{showSpeedAdjust && (
|
||||||
|
<div className="bg-white p-6 rounded-2xl border-gray-200 border-2 shadow-2xl absolute left-1/2 -translate-x-1/2 -translate-y-full -top-4 flex flex-row flex-wrap gap-2 justify-center items-center">
|
||||||
|
<IconClick
|
||||||
|
size={45}
|
||||||
|
onClick={letMeSetSpeed(0.5)}
|
||||||
|
src={IMAGES.speed_0_5x}
|
||||||
|
alt="0.5x"
|
||||||
|
className={speed === 0.5 ? "bg-gray-200" : ""}
|
||||||
|
></IconClick>
|
||||||
|
<IconClick
|
||||||
|
size={45}
|
||||||
|
onClick={letMeSetSpeed(0.7)}
|
||||||
|
src={IMAGES.speed_0_7x}
|
||||||
|
alt="0.7x"
|
||||||
|
className={speed === 0.7 ? "bg-gray-200" : ""}
|
||||||
|
></IconClick>
|
||||||
|
<IconClick
|
||||||
|
size={45}
|
||||||
|
onClick={letMeSetSpeed(1)}
|
||||||
|
src={IMAGES.speed_1x}
|
||||||
|
alt="1x"
|
||||||
|
className={speed === 1 ? "bg-gray-200" : ""}
|
||||||
|
></IconClick>
|
||||||
|
<IconClick
|
||||||
|
size={45}
|
||||||
|
onClick={letMeSetSpeed(1.2)}
|
||||||
|
src={IMAGES.speed_1_2_x}
|
||||||
|
alt="1.2x"
|
||||||
|
className={speed === 1.2 ? "bg-gray-200" : ""}
|
||||||
|
></IconClick>
|
||||||
|
<IconClick
|
||||||
|
size={45}
|
||||||
|
onClick={letMeSetSpeed(1.5)}
|
||||||
|
src={IMAGES.speed_1_5x}
|
||||||
|
alt="1.5x"
|
||||||
|
className={speed === 1.5 ? "bg-gray-200" : ""}
|
||||||
|
></IconClick>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
<IconClick
|
||||||
|
size={45}
|
||||||
|
onClick={speak}
|
||||||
|
src={pause ? IMAGES.play_arrow : IMAGES.pause}
|
||||||
|
alt="playorpause"
|
||||||
|
className={`${processing ? "bg-gray-200" : ""}`}
|
||||||
|
></IconClick>
|
||||||
|
<IconClick
|
||||||
|
size={45}
|
||||||
|
onClick={() => {
|
||||||
|
setAutopause(!autopause);
|
||||||
|
if (objurlRef) {
|
||||||
|
stopAudio();
|
||||||
|
}
|
||||||
|
setPause(true);
|
||||||
|
}}
|
||||||
|
src={autopause ? IMAGES.autoplay : IMAGES.autopause}
|
||||||
|
alt="autoplayorpause"
|
||||||
|
></IconClick>
|
||||||
|
<IconClick
|
||||||
|
size={45}
|
||||||
|
onClick={() => setShowSpeedAdjust(!showSpeedAdjust)}
|
||||||
|
src={IMAGES.speed}
|
||||||
|
alt="speed"
|
||||||
|
className={`${showSpeedAdjust ? "bg-gray-200" : ""}`}
|
||||||
|
></IconClick>
|
||||||
|
<IconClick
|
||||||
|
size={45}
|
||||||
|
onClick={save}
|
||||||
|
src={IMAGES.save}
|
||||||
|
alt="save"
|
||||||
|
className={`${saving ? "bg-gray-200" : ""}`}
|
||||||
|
></IconClick>
|
||||||
|
<div className="w-full flex flex-row flex-wrap gap-2 justify-center items-center">
|
||||||
|
<Button
|
||||||
|
selected={ipaEnabled}
|
||||||
|
onClick={() => setIPAEnabled(!ipaEnabled)}
|
||||||
|
>
|
||||||
|
生成IPA
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setShowSaveList(!showSaveList);
|
||||||
|
}}
|
||||||
|
selected={showSaveList}
|
||||||
|
>
|
||||||
|
查看保存项
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<SaveList show={showSaveList} handleUse={handleUseItem}></SaveList>
|
</div>
|
||||||
</>);
|
<SaveList show={showSaveList} handleUse={handleUseItem}></SaveList>
|
||||||
}
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,25 +11,25 @@ import { VOICES } from "@/config/locales";
|
|||||||
|
|
||||||
export default function Translator() {
|
export default function Translator() {
|
||||||
const [ipaEnabled, setIPAEnabled] = useState(true);
|
const [ipaEnabled, setIPAEnabled] = useState(true);
|
||||||
const [targetLang, setTargetLang] = useState('Chinese');
|
const [targetLang, setTargetLang] = useState("Chinese");
|
||||||
|
|
||||||
const [sourceText, setSourceText] = useState('');
|
const [sourceText, setSourceText] = useState("");
|
||||||
const [targetText, setTargetText] = useState('');
|
const [targetText, setTargetText] = useState("");
|
||||||
const [sourceIPA, setSourceIPA] = useState('');
|
const [sourceIPA, setSourceIPA] = useState("");
|
||||||
const [targetIPA, setTargetIPA] = useState('');
|
const [targetIPA, setTargetIPA] = useState("");
|
||||||
const [sourceLocale, setSourceLocale] = useState<string | null>(null);
|
const [sourceLocale, setSourceLocale] = useState<string | null>(null);
|
||||||
const [targetLocale, setTargetLocale] = useState<string | null>(null);
|
const [targetLocale, setTargetLocale] = useState<string | null>(null);
|
||||||
const [translating, setTranslating] = useState(false);
|
const [translating, setTranslating] = useState(false);
|
||||||
const { playAudio } = useAudioPlayer();
|
const { playAudio } = useAudioPlayer();
|
||||||
|
|
||||||
const tl = ['Chinese', 'English', 'Italian'];
|
const tl = ["Chinese", "English", "Italian"];
|
||||||
|
|
||||||
const inputLanguage = () => {
|
const inputLanguage = () => {
|
||||||
const lang = prompt('Input a language.')?.trim();
|
const lang = prompt("Input a language.")?.trim();
|
||||||
if (lang) {
|
if (lang) {
|
||||||
setTargetLang(lang);
|
setTargetLang(lang);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const translate = () => {
|
const translate = () => {
|
||||||
if (translating) return;
|
if (translating) return;
|
||||||
@@ -37,91 +37,96 @@ export default function Translator() {
|
|||||||
|
|
||||||
setTranslating(true);
|
setTranslating(true);
|
||||||
|
|
||||||
setTargetText('');
|
setTargetText("");
|
||||||
setSourceLocale(null);
|
setSourceLocale(null);
|
||||||
setTargetLocale(null);
|
setTargetLocale(null);
|
||||||
setSourceIPA('');
|
setSourceIPA("");
|
||||||
setTargetIPA('');
|
setTargetIPA("");
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
text: sourceText,
|
text: sourceText,
|
||||||
target: targetLang
|
target: targetLang,
|
||||||
})
|
});
|
||||||
fetch(`/api/translate?${params}`)
|
fetch(`/api/translate?${params}`)
|
||||||
.then(res => res.json())
|
.then((res) => res.json())
|
||||||
.then(obj => {
|
.then((obj) => {
|
||||||
setSourceLocale(obj.source_locale);
|
setSourceLocale(obj.source_locale);
|
||||||
setTargetLocale(obj.target_locale);
|
setTargetLocale(obj.target_locale);
|
||||||
setTargetText(obj.target_text);
|
setTargetText(obj.target_text);
|
||||||
|
|
||||||
if (ipaEnabled) {
|
if (ipaEnabled) {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
text: sourceText
|
text: sourceText,
|
||||||
});
|
});
|
||||||
fetch(`/api/ipa?${params}`)
|
fetch(`/api/ipa?${params}`)
|
||||||
.then(res => res.json())
|
.then((res) => res.json())
|
||||||
.then(data => {
|
.then((data) => {
|
||||||
setSourceIPA(data.ipa);
|
setSourceIPA(data.ipa);
|
||||||
}).catch(e => {
|
|
||||||
console.error(e);
|
|
||||||
setSourceIPA('');
|
|
||||||
})
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
setSourceIPA("");
|
||||||
|
});
|
||||||
const params2 = new URLSearchParams({
|
const params2 = new URLSearchParams({
|
||||||
text: obj.target_text
|
text: obj.target_text,
|
||||||
});
|
});
|
||||||
fetch(`/api/ipa?${params2}`)
|
fetch(`/api/ipa?${params2}`)
|
||||||
.then(res => res.json())
|
.then((res) => res.json())
|
||||||
.then(data => {
|
.then((data) => {
|
||||||
setTargetIPA(data.ipa);
|
setTargetIPA(data.ipa);
|
||||||
}).catch(e => {
|
|
||||||
console.error(e);
|
|
||||||
setTargetIPA('');
|
|
||||||
})
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
setTargetIPA("");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}).catch(r => {
|
})
|
||||||
|
.catch((r) => {
|
||||||
console.error(r);
|
console.error(r);
|
||||||
setSourceLocale('');
|
setSourceLocale("");
|
||||||
setTargetLocale('');
|
setTargetLocale("");
|
||||||
setTargetText('');
|
setTargetText("");
|
||||||
}).finally(() => setTranslating(false));
|
})
|
||||||
}
|
.finally(() => setTranslating(false));
|
||||||
|
};
|
||||||
|
|
||||||
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
setSourceText(e.target.value.trim());
|
setSourceText(e.target.value.trim());
|
||||||
setTargetText('');
|
setTargetText("");
|
||||||
setSourceLocale(null);
|
setSourceLocale(null);
|
||||||
setTargetLocale(null);
|
setTargetLocale(null);
|
||||||
setSourceIPA('');
|
setSourceIPA("");
|
||||||
setTargetIPA('');
|
setTargetIPA("");
|
||||||
}
|
};
|
||||||
|
|
||||||
const readSource = async () => {
|
const readSource = async () => {
|
||||||
if (sourceText.length === 0) return;
|
if (sourceText.length === 0) return;
|
||||||
|
|
||||||
if (sourceIPA.length === 0 && ipaEnabled) {
|
if (sourceIPA.length === 0 && ipaEnabled) {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
text: sourceText
|
text: sourceText,
|
||||||
});
|
});
|
||||||
fetch(`/api/ipa?${params}`)
|
fetch(`/api/ipa?${params}`)
|
||||||
.then(res => res.json())
|
.then((res) => res.json())
|
||||||
.then(data => {
|
.then((data) => {
|
||||||
setSourceIPA(data.ipa);
|
setSourceIPA(data.ipa);
|
||||||
}).catch(e => {
|
|
||||||
console.error(e);
|
|
||||||
setSourceIPA('');
|
|
||||||
})
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
setSourceIPA("");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sourceLocale) {
|
if (!sourceLocale) {
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
text: sourceText.slice(0, 30)
|
text: sourceText.slice(0, 30),
|
||||||
});
|
});
|
||||||
const res = await fetch(`/api/locale?${params}`);
|
const res = await fetch(`/api/locale?${params}`);
|
||||||
const info = await res.json();
|
const info = await res.json();
|
||||||
setSourceLocale(info.locale);
|
setSourceLocale(info.locale);
|
||||||
|
|
||||||
const voice = VOICES.find(v => v.locale.startsWith(info.locale));
|
const voice = VOICES.find((v) => v.locale.startsWith(info.locale));
|
||||||
if (!voice) {
|
if (!voice) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -135,7 +140,7 @@ export default function Translator() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const voice = VOICES.find(v => v.locale.startsWith(sourceLocale!));
|
const voice = VOICES.find((v) => v.locale.startsWith(sourceLocale!));
|
||||||
if (!voice) {
|
if (!voice) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -144,32 +149,33 @@ export default function Translator() {
|
|||||||
await playAudio(url);
|
await playAudio(url);
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const readTarget = async () => {
|
const readTarget = async () => {
|
||||||
if (targetText.length === 0) return;
|
if (targetText.length === 0) return;
|
||||||
|
|
||||||
if (targetIPA.length === 0 && ipaEnabled) {
|
if (targetIPA.length === 0 && ipaEnabled) {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
text: targetText
|
text: targetText,
|
||||||
});
|
});
|
||||||
fetch(`/api/ipa?${params}`)
|
fetch(`/api/ipa?${params}`)
|
||||||
.then(res => res.json())
|
.then((res) => res.json())
|
||||||
.then(data => {
|
.then((data) => {
|
||||||
setTargetIPA(data.ipa);
|
setTargetIPA(data.ipa);
|
||||||
}).catch(e => {
|
|
||||||
console.error(e);
|
|
||||||
setTargetIPA('');
|
|
||||||
})
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
setTargetIPA("");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const voice = VOICES.find(v => v.locale.startsWith(targetLocale!));
|
const voice = VOICES.find((v) => v.locale.startsWith(targetLocale!));
|
||||||
if (!voice) return;
|
if (!voice) return;
|
||||||
|
|
||||||
const url = await getTTSAudioUrl(targetText, voice.short_name);
|
const url = await getTTSAudioUrl(targetText, voice.short_name);
|
||||||
await playAudio(url);
|
await playAudio(url);
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -177,52 +183,100 @@ export default function Translator() {
|
|||||||
<div className="w-screen flex flex-col md:flex-row md:justify-between gap-2 p-2">
|
<div className="w-screen flex flex-col md:flex-row md:justify-between gap-2 p-2">
|
||||||
<div className="card1 w-full md:w-1/2 flex flex-col-reverse gap-2">
|
<div className="card1 w-full md:w-1/2 flex flex-col-reverse gap-2">
|
||||||
<div className="textarea1 border-1 border-gray-200 rounded-2xl w-full h-64 p-2">
|
<div className="textarea1 border-1 border-gray-200 rounded-2xl w-full h-64 p-2">
|
||||||
<textarea onChange={handleInputChange} className="resize-none h-8/12 w-full focus:outline-0"></textarea>
|
<textarea
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="resize-none h-8/12 w-full focus:outline-0"
|
||||||
|
></textarea>
|
||||||
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
|
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
|
||||||
{sourceIPA}
|
{sourceIPA}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2/12 w-full flex justify-end items-center">
|
<div className="h-2/12 w-full flex justify-end items-center">
|
||||||
<IconClick onClick={async () => {
|
<IconClick
|
||||||
if (sourceText.length !== 0)
|
onClick={async () => {
|
||||||
await navigator.clipboard.writeText(sourceText);
|
if (sourceText.length !== 0)
|
||||||
}} src={IMAGES.copy_all} alt="copy"></IconClick>
|
await navigator.clipboard.writeText(sourceText);
|
||||||
<IconClick onClick={readSource} src={IMAGES.play_arrow} alt="play"></IconClick>
|
}}
|
||||||
|
src={IMAGES.copy_all}
|
||||||
|
alt="copy"
|
||||||
|
></IconClick>
|
||||||
|
<IconClick
|
||||||
|
onClick={readSource}
|
||||||
|
src={IMAGES.play_arrow}
|
||||||
|
alt="play"
|
||||||
|
></IconClick>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="option1 w-full flex flex-row justify-between items-center">
|
<div className="option1 w-full flex flex-row justify-between items-center">
|
||||||
<span>detect language</span>
|
<span>detect language</span>
|
||||||
<Button selected={ipaEnabled} onClick={() => setIPAEnabled(!ipaEnabled)}>generate ipa</Button>
|
<Button
|
||||||
|
selected={ipaEnabled}
|
||||||
|
onClick={() => setIPAEnabled(!ipaEnabled)}
|
||||||
|
>
|
||||||
|
generate ipa
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card2 w-full md:w-1/2 flex flex-col-reverse gap-2">
|
<div className="card2 w-full md:w-1/2 flex flex-col-reverse gap-2">
|
||||||
<div className="textarea2 bg-gray-100 rounded-2xl w-full h-64 p-2">
|
<div className="textarea2 bg-gray-100 rounded-2xl w-full h-64 p-2">
|
||||||
<div className="h-8/12 w-full">{
|
<div className="h-8/12 w-full">{targetText}</div>
|
||||||
targetText
|
|
||||||
}</div>
|
|
||||||
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
|
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
|
||||||
{targetIPA}
|
{targetIPA}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2/12 w-full flex justify-end items-center">
|
<div className="h-2/12 w-full flex justify-end items-center">
|
||||||
<IconClick onClick={async () => {
|
<IconClick
|
||||||
if (targetText.length !== 0)
|
onClick={async () => {
|
||||||
await navigator.clipboard.writeText(targetText);
|
if (targetText.length !== 0)
|
||||||
}} src={IMAGES.copy_all} alt="copy"></IconClick>
|
await navigator.clipboard.writeText(targetText);
|
||||||
<IconClick onClick={readTarget} src={IMAGES.play_arrow} alt="play"></IconClick>
|
}}
|
||||||
|
src={IMAGES.copy_all}
|
||||||
|
alt="copy"
|
||||||
|
></IconClick>
|
||||||
|
<IconClick
|
||||||
|
onClick={readTarget}
|
||||||
|
src={IMAGES.play_arrow}
|
||||||
|
alt="play"
|
||||||
|
></IconClick>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="option2 w-full flex gap-1 items-center flex-wrap">
|
<div className="option2 w-full flex gap-1 items-center flex-wrap">
|
||||||
<span>translate into</span>
|
<span>translate into</span>
|
||||||
<Button onClick={() => { setTargetLang('Chinese') }} selected={targetLang === 'Chinese'}>Chinese</Button>
|
<Button
|
||||||
<Button onClick={() => { setTargetLang('English') }} selected={targetLang === 'English'}>English</Button>
|
onClick={() => {
|
||||||
<Button onClick={() => { setTargetLang('Italian') }} selected={targetLang === 'Italian'}>Italian</Button>
|
setTargetLang("Chinese");
|
||||||
<Button onClick={inputLanguage} selected={!(tl.includes(targetLang))}>{'Other' + (tl.includes(targetLang) ? '' : ': ' + targetLang)}</Button>
|
}}
|
||||||
|
selected={targetLang === "Chinese"}
|
||||||
|
>
|
||||||
|
Chinese
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setTargetLang("English");
|
||||||
|
}}
|
||||||
|
selected={targetLang === "English"}
|
||||||
|
>
|
||||||
|
English
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setTargetLang("Italian");
|
||||||
|
}}
|
||||||
|
selected={targetLang === "Italian"}
|
||||||
|
>
|
||||||
|
Italian
|
||||||
|
</Button>
|
||||||
|
<Button onClick={inputLanguage} selected={!tl.includes(targetLang)}>
|
||||||
|
{"Other" + (tl.includes(targetLang) ? "" : ": " + targetLang)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="button-area w-screen flex justify-center items-center">
|
<div className="button-area w-screen flex justify-center items-center">
|
||||||
<button onClick={translate} className={`duration-150 ease-in text-xl font-extrabold border rounded-4xl p-3 border-gray-200 h-16 ${translating ? 'bg-gray-200' : 'bg-white hover:bg-gray-200 hover:cursor-pointer'}`}>
|
<button
|
||||||
{translating ? 'translating...' : 'translate'}
|
onClick={translate}
|
||||||
|
className={`duration-150 ease-in text-xl font-extrabold border rounded-4xl p-3 border-gray-200 h-16 ${translating ? "bg-gray-200" : "bg-white hover:bg-gray-200 hover:cursor-pointer"}`}
|
||||||
|
>
|
||||||
|
{translating ? "translating..." : "translate"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,46 +1,66 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { BOARD_WIDTH, TEXT_WIDTH, BOARD_HEIGHT, TEXT_SIZE } from "@/config/word-board-config";
|
import {
|
||||||
|
BOARD_WIDTH,
|
||||||
|
TEXT_WIDTH,
|
||||||
|
BOARD_HEIGHT,
|
||||||
|
TEXT_SIZE,
|
||||||
|
} from "@/config/word-board-config";
|
||||||
import { Word } from "@/interfaces";
|
import { Word } from "@/interfaces";
|
||||||
import { Dispatch, SetStateAction } from "react";
|
import { Dispatch, SetStateAction } from "react";
|
||||||
|
|
||||||
export default function TheBoard(
|
export default function TheBoard({
|
||||||
{ words, selectWord }: {
|
words,
|
||||||
words: [
|
selectWord,
|
||||||
{
|
}: {
|
||||||
word: string,
|
words: [
|
||||||
x: number,
|
{
|
||||||
y: number
|
word: string;
|
||||||
}
|
x: number;
|
||||||
],
|
y: number;
|
||||||
setWords: Dispatch<SetStateAction<Word[]>>,
|
},
|
||||||
selectWord: (word: string) => void
|
];
|
||||||
}
|
setWords: Dispatch<SetStateAction<Word[]>>;
|
||||||
) {
|
selectWord: (word: string) => void;
|
||||||
function DraggableWord({ word }: { word: Word }) {
|
}) {
|
||||||
return (<span
|
function DraggableWord({ word }: { word: Word }) {
|
||||||
style={{
|
|
||||||
left: `${Math.floor(word.x * (BOARD_WIDTH - TEXT_WIDTH * word.word.length))}px`,
|
|
||||||
top: `${Math.floor(word.y * (BOARD_HEIGHT - TEXT_SIZE))}px`,
|
|
||||||
fontSize: `${TEXT_SIZE}px`
|
|
||||||
}}
|
|
||||||
className="select-none cursor-pointer absolute code-block border-amber-100 border-1"
|
|
||||||
// onClick={inspect(word.word)}>{word.word}</span>))
|
|
||||||
onClick={() => { selectWord(word.word); }}>{word.word}</span>);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<span
|
||||||
width: `${BOARD_WIDTH}px`,
|
style={{
|
||||||
height: `${BOARD_HEIGHT}px`
|
left: `${Math.floor(word.x * (BOARD_WIDTH - TEXT_WIDTH * word.word.length))}px`,
|
||||||
}} className="relative rounded bg-white">
|
top: `${Math.floor(word.y * (BOARD_HEIGHT - TEXT_SIZE))}px`,
|
||||||
{words.map(
|
fontSize: `${TEXT_SIZE}px`,
|
||||||
(v: {
|
}}
|
||||||
word: string,
|
className="select-none cursor-pointer absolute code-block border-amber-100 border-1"
|
||||||
x: number,
|
// onClick={inspect(word.word)}>{word.word}</span>))
|
||||||
y: number
|
onClick={() => {
|
||||||
}, i: number) => {
|
selectWord(word.word);
|
||||||
return (<DraggableWord word={v} key={i}></DraggableWord>)
|
}}
|
||||||
})}
|
>
|
||||||
</div>
|
{word.word}
|
||||||
)
|
</span>
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${BOARD_WIDTH}px`,
|
||||||
|
height: `${BOARD_HEIGHT}px`,
|
||||||
|
}}
|
||||||
|
className="relative rounded bg-white"
|
||||||
|
>
|
||||||
|
{words.map(
|
||||||
|
(
|
||||||
|
v: {
|
||||||
|
word: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
},
|
||||||
|
i: number,
|
||||||
|
) => {
|
||||||
|
return <DraggableWord word={v} key={i}></DraggableWord>;
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,135 +1,141 @@
|
|||||||
'use client';
|
"use client";
|
||||||
import TheBoard from "@/app/word-board/TheBoard";
|
import TheBoard from "@/app/word-board/TheBoard";
|
||||||
import Button from "../../components/Button";
|
import Button from "../../components/Button";
|
||||||
import { KeyboardEvent, useRef, useState } from "react";
|
import { KeyboardEvent, useRef, useState } from "react";
|
||||||
import { Word } from "@/interfaces";
|
import { Word } from "@/interfaces";
|
||||||
import { BOARD_WIDTH, TEXT_WIDTH, BOARD_HEIGHT, TEXT_SIZE } from "@/config/word-board-config";
|
import {
|
||||||
|
BOARD_WIDTH,
|
||||||
|
TEXT_WIDTH,
|
||||||
|
BOARD_HEIGHT,
|
||||||
|
TEXT_SIZE,
|
||||||
|
} from "@/config/word-board-config";
|
||||||
import { inspect } from "@/utils";
|
import { inspect } from "@/utils";
|
||||||
import { Navbar } from "@/components/Navbar";
|
import { Navbar } from "@/components/Navbar";
|
||||||
|
|
||||||
export default function WordBoard() {
|
export default function WordBoard() {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const inputFileRef = useRef<HTMLInputElement>(null);
|
const inputFileRef = useRef<HTMLInputElement>(null);
|
||||||
const initialWords =
|
const initialWords = [
|
||||||
[
|
// 'apple',
|
||||||
// 'apple',
|
// 'banana',
|
||||||
// 'banana',
|
// 'cannon',
|
||||||
// 'cannon',
|
// 'desktop',
|
||||||
// 'desktop',
|
// 'kernel',
|
||||||
// 'kernel',
|
// 'system',
|
||||||
// 'system',
|
// 'programming',
|
||||||
// 'programming',
|
// 'owe'
|
||||||
// 'owe'
|
] as Array<string>;
|
||||||
] as Array<string>;
|
|
||||||
const [words, setWords] = useState(
|
const [words, setWords] = useState(
|
||||||
initialWords.map((v: string) => ({
|
initialWords.map((v: string) => ({
|
||||||
'word': v,
|
word: v,
|
||||||
'x': Math.random(),
|
x: Math.random(),
|
||||||
'y': Math.random()
|
y: Math.random(),
|
||||||
}))
|
})),
|
||||||
);
|
);
|
||||||
const generateNewWord = (word: string) => {
|
const generateNewWord = (word: string) => {
|
||||||
const isOK = (w: Word) => {
|
const isOK = (w: Word) => {
|
||||||
if (words.length === 0) return true;
|
if (words.length === 0) return true;
|
||||||
const tf = (ww: Word) => ({
|
const tf = (ww: Word) =>
|
||||||
word: ww.word,
|
({
|
||||||
x: Math.floor(ww.x * (BOARD_WIDTH - TEXT_WIDTH * ww.word.length)),
|
word: ww.word,
|
||||||
y: Math.floor(ww.y * (BOARD_HEIGHT - TEXT_SIZE))
|
x: Math.floor(ww.x * (BOARD_WIDTH - TEXT_WIDTH * ww.word.length)),
|
||||||
} as Word);
|
y: Math.floor(ww.y * (BOARD_HEIGHT - TEXT_SIZE)),
|
||||||
|
}) as Word;
|
||||||
const tfd_words = words.map(tf);
|
const tfd_words = words.map(tf);
|
||||||
const tfd_w = tf(w);
|
const tfd_w = tf(w);
|
||||||
for (const www of tfd_words) {
|
for (const www of tfd_words) {
|
||||||
const p1 = {
|
const p1 = {
|
||||||
x: (www.x + www.x + TEXT_WIDTH * www.word.length) / 2,
|
x: (www.x + www.x + TEXT_WIDTH * www.word.length) / 2,
|
||||||
y: (www.y + www.y + TEXT_SIZE) / 2
|
y: (www.y + www.y + TEXT_SIZE) / 2,
|
||||||
}
|
};
|
||||||
const p2 = {
|
const p2 = {
|
||||||
x: (tfd_w.x + tfd_w.x + TEXT_WIDTH * tfd_w.word.length) / 2,
|
x: (tfd_w.x + tfd_w.x + TEXT_WIDTH * tfd_w.word.length) / 2,
|
||||||
y: (tfd_w.y + tfd_w.y + TEXT_SIZE) / 2
|
y: (tfd_w.y + tfd_w.y + TEXT_SIZE) / 2,
|
||||||
}
|
};
|
||||||
if (
|
if (
|
||||||
Math.abs(p1.x - p2.x) < (TEXT_WIDTH * (www.word.length + tfd_w.word.length)) / 2 &&
|
Math.abs(p1.x - p2.x) <
|
||||||
|
(TEXT_WIDTH * (www.word.length + tfd_w.word.length)) / 2 &&
|
||||||
Math.abs(p1.y - p2.y) < TEXT_SIZE
|
Math.abs(p1.y - p2.y) < TEXT_SIZE
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
};
|
||||||
let new_word;
|
let new_word;
|
||||||
let count = 0;
|
let count = 0;
|
||||||
do {
|
do {
|
||||||
new_word = {
|
new_word = {
|
||||||
word: word,
|
word: word,
|
||||||
x: Math.random(),
|
x: Math.random(),
|
||||||
y: Math.random()
|
y: Math.random(),
|
||||||
};
|
};
|
||||||
if (++count > 1000) return null;
|
if (++count > 1000) return null;
|
||||||
} while (!isOK(new_word));
|
} while (!isOK(new_word));
|
||||||
return new_word as Word;
|
return new_word as Word;
|
||||||
}
|
};
|
||||||
const insertWord = () => {
|
const insertWord = () => {
|
||||||
if (!inputRef.current) return;
|
if (!inputRef.current) return;
|
||||||
const word = inputRef.current.value.trim();
|
const word = inputRef.current.value.trim();
|
||||||
if (word === '') return;
|
if (word === "") return;
|
||||||
const new_word = generateNewWord(word);
|
const new_word = generateNewWord(word);
|
||||||
if (!new_word) return;
|
if (!new_word) return;
|
||||||
setWords([...words, new_word]);
|
setWords([...words, new_word]);
|
||||||
inputRef.current.value = '';
|
inputRef.current.value = "";
|
||||||
}
|
};
|
||||||
const deleteWord = () => {
|
const deleteWord = () => {
|
||||||
if (!inputRef.current) return;
|
if (!inputRef.current) return;
|
||||||
const word = inputRef.current.value.trim();
|
const word = inputRef.current.value.trim();
|
||||||
if (word === '') return;
|
if (word === "") return;
|
||||||
setWords(words.filter((v) => v.word !== word));
|
setWords(words.filter((v) => v.word !== word));
|
||||||
inputRef.current.value = '';
|
inputRef.current.value = "";
|
||||||
};
|
};
|
||||||
const importWords = () => {
|
const importWords = () => {
|
||||||
inputFileRef.current?.click();
|
inputFileRef.current?.click();
|
||||||
}
|
};
|
||||||
const exportWords = () => {
|
const exportWords = () => {
|
||||||
const blob = new Blob([JSON.stringify(words)], {
|
const blob = new Blob([JSON.stringify(words)], {
|
||||||
type: 'application/json'
|
type: "application/json",
|
||||||
});
|
});
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement("a");
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `${Date.now()}.json`;
|
a.download = `${Date.now()}.json`;
|
||||||
a.style.display = 'none';
|
a.style.display = "none";
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
};
|
||||||
const handleFileChange = () => {
|
const handleFileChange = () => {
|
||||||
const files = inputFileRef.current?.files;
|
const files = inputFileRef.current?.files;
|
||||||
if (files && files.length > 0) {
|
if (files && files.length > 0) {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
if (reader.result && typeof reader.result === 'string')
|
if (reader.result && typeof reader.result === "string")
|
||||||
setWords(JSON.parse(reader.result) as [Word]);
|
setWords(JSON.parse(reader.result) as [Word]);
|
||||||
}
|
};
|
||||||
reader.readAsText(files[0]);
|
reader.readAsText(files[0]);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
const deleteAll = () => {
|
const deleteAll = () => {
|
||||||
setWords([] as Array<Word>);
|
setWords([] as Array<Word>);
|
||||||
}
|
};
|
||||||
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||||
// e.preventDefault();
|
// e.preventDefault();
|
||||||
if (e.key === 'Enter') {
|
if (e.key === "Enter") {
|
||||||
insertWord();
|
insertWord();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
const selectWord = (word: string) => {
|
const selectWord = (word: string) => {
|
||||||
if (!inputRef.current) return;
|
if (!inputRef.current) return;
|
||||||
inputRef.current.value = word;
|
inputRef.current.value = word;
|
||||||
}
|
};
|
||||||
const searchWord = () => {
|
const searchWord = () => {
|
||||||
if (!inputRef.current) return;
|
if (!inputRef.current) return;
|
||||||
const word = inputRef.current.value.trim();
|
const word = inputRef.current.value.trim();
|
||||||
if (word === '') return;
|
if (word === "") return;
|
||||||
inspect(word)();
|
inspect(word)();
|
||||||
inputRef.current.value = '';
|
inputRef.current.value = "";
|
||||||
}
|
};
|
||||||
// const readWordAloud = () => {
|
// const readWordAloud = () => {
|
||||||
// playFromUrl('https://fanyi.baidu.com/gettts?lan=uk&text=disclose&spd=3')
|
// playFromUrl('https://fanyi.baidu.com/gettts?lan=uk&text=disclose&spd=3')
|
||||||
// return;
|
// return;
|
||||||
@@ -143,10 +149,22 @@ export default function WordBoard() {
|
|||||||
<>
|
<>
|
||||||
<Navbar></Navbar>
|
<Navbar></Navbar>
|
||||||
<div className="flex w-screen h-screen justify-center items-center">
|
<div className="flex w-screen h-screen justify-center items-center">
|
||||||
<div onKeyDown={handleKeyDown} className="p-5 bg-gray-200 rounded shadow-2xl">
|
<div
|
||||||
<TheBoard selectWord={selectWord} words={words as [Word]} setWords={setWords} />
|
onKeyDown={handleKeyDown}
|
||||||
|
className="p-5 bg-gray-200 rounded shadow-2xl"
|
||||||
|
>
|
||||||
|
<TheBoard
|
||||||
|
selectWord={selectWord}
|
||||||
|
words={words as [Word]}
|
||||||
|
setWords={setWords}
|
||||||
|
/>
|
||||||
<div className="flex justify-center rounded mt-3 gap-1">
|
<div className="flex justify-center rounded mt-3 gap-1">
|
||||||
<input ref={inputRef} placeholder="word to operate" type="text" className="focus:outline-none border-b-2 border-black" />
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
placeholder="word to operate"
|
||||||
|
type="text"
|
||||||
|
className="focus:outline-none border-b-2 border-black"
|
||||||
|
/>
|
||||||
<Button onClick={insertWord}>插入</Button>
|
<Button onClick={insertWord}>插入</Button>
|
||||||
<Button onClick={deleteWord}>删除</Button>
|
<Button onClick={deleteWord}>删除</Button>
|
||||||
<Button onClick={searchWord}>搜索</Button>
|
<Button onClick={searchWord}>搜索</Button>
|
||||||
@@ -155,10 +173,15 @@ export default function WordBoard() {
|
|||||||
<Button onClick={deleteAll}>删光</Button>
|
<Button onClick={deleteAll}>删光</Button>
|
||||||
{/* <Button label="朗读" onClick={readWordAloud}></Button> */}
|
{/* <Button label="朗读" onClick={readWordAloud}></Button> */}
|
||||||
</div>
|
</div>
|
||||||
<input type="file" ref={inputFileRef} className="hidden" accept="application/json" onChange={handleFileChange}></input>
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={inputFileRef}
|
||||||
|
className="hidden"
|
||||||
|
accept="application/json"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
></input>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,19 +2,19 @@ export default function Button({
|
|||||||
onClick,
|
onClick,
|
||||||
className,
|
className,
|
||||||
selected,
|
selected,
|
||||||
children
|
children,
|
||||||
}: {
|
}: {
|
||||||
onClick?: () => void,
|
onClick?: () => void;
|
||||||
className?: string,
|
className?: string;
|
||||||
selected?: boolean,
|
selected?: boolean;
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`px-2 py-1 rounded shadow-2xs font-bold hover:bg-gray-300 hover:cursor-pointer ${selected ? 'bg-gray-300' : "bg-white"} ${className}`}
|
className={`px-2 py-1 rounded shadow-2xs font-bold hover:bg-gray-300 hover:cursor-pointer ${selected ? "bg-gray-300" : "bg-white"} ${className}`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,27 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
||||||
|
|
||||||
interface IconClickProps {
|
interface IconClickProps {
|
||||||
src: string;
|
src: string;
|
||||||
alt: string;
|
alt: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
size?: number
|
size?: number;
|
||||||
}
|
}
|
||||||
export default function IconClick(
|
export default function IconClick({
|
||||||
{ src, alt, onClick = () => { }, className = '', size = 32 }: IconClickProps) {
|
src,
|
||||||
return (<>
|
alt,
|
||||||
<div onClick={onClick} className={`hover:cursor-pointer hover:bg-gray-200 rounded-3xl w-[${size}px] h-[${size}px] flex justify-center items-center ${className}`}>
|
onClick = () => {},
|
||||||
<Image
|
className = "",
|
||||||
src={src}
|
size = 32,
|
||||||
width={size - 5}
|
}: IconClickProps) {
|
||||||
height={size - 5}
|
return (
|
||||||
alt={alt}
|
<>
|
||||||
></Image>
|
<div
|
||||||
</div>
|
onClick={onClick}
|
||||||
</>);
|
className={`hover:cursor-pointer hover:bg-gray-200 rounded-3xl w-[${size}px] h-[${size}px] flex justify-center items-center ${className}`}
|
||||||
|
>
|
||||||
|
<Image src={src} width={size - 5} height={size - 5} alt={alt}></Image>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,33 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
||||||
function MyLink(
|
function MyLink({ href, label }: { href: string; label: string }) {
|
||||||
{ href, label }: { href: string, label: string }
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
<Link className="font-bold" href={href}>{label}</Link>
|
<Link className="font-bold" href={href}>
|
||||||
)
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between items-center w-full h-16 px-8 bg-[#35786f] text-white">
|
<div className="flex justify-between items-center w-full h-16 px-8 bg-[#35786f] text-white">
|
||||||
<Link href={'/'} className="text-xl flex">
|
<Link href={"/"} className="text-xl flex">
|
||||||
<Image
|
<Image
|
||||||
src={'/favicon.ico'}
|
src={"/favicon.ico"}
|
||||||
alt="logo"
|
alt="logo"
|
||||||
width="32"
|
width="32"
|
||||||
height="32"
|
height="32"
|
||||||
className="rounded-4xl">
|
className="rounded-4xl"
|
||||||
</Image>
|
></Image>
|
||||||
<span className="font-bold">学语言</span>
|
<span className="font-bold">学语言</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex gap-4 text-xl">
|
<div className="flex gap-4 text-xl">
|
||||||
<MyLink href="/changelog.txt" label="关于"></MyLink>
|
<MyLink href="/changelog.txt" label="关于"></MyLink>
|
||||||
<MyLink href="https://github.com/GoddoNebianU/learn-languages" label="源码"></MyLink>
|
<MyLink
|
||||||
|
href="https://github.com/GoddoNebianU/learn-languages"
|
||||||
|
label="源码"
|
||||||
|
></MyLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
const IMAGES = {
|
const IMAGES = {
|
||||||
speed_1_5x: '/images/speed_1_5x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
|
speed_1_5x: "/images/speed_1_5x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
|
||||||
speed_1_2_x: '/images/speed_1_2x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
|
speed_1_2_x: "/images/speed_1_2x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
|
||||||
speed_0_7x: '/images/speed_0_7x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
|
speed_0_7x: "/images/speed_0_7x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
|
||||||
pause: '/images/pause_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
|
pause: "/images/pause_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
|
||||||
speed_0_5x: '/images/speed_0_5x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
|
speed_0_5x: "/images/speed_0_5x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
|
||||||
copy_all: '/images/copy_all_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
|
copy_all: "/images/copy_all_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
|
||||||
autoplay: '/images/autoplay_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
|
autoplay: "/images/autoplay_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
|
||||||
autopause: '/images/autopause_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
|
autopause: "/images/autopause_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
|
||||||
speed_1x: '/images/1x_mobiledata_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
|
speed_1x: "/images/1x_mobiledata_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
|
||||||
play_arrow: '/images/play_arrow_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
|
play_arrow: "/images/play_arrow_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
|
||||||
close: '/images/close_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
|
close: "/images/close_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
|
||||||
refresh: '/images/refresh_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
|
refresh: "/images/refresh_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
|
||||||
more_horiz: '/images/more_horiz_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
|
more_horiz: "/images/more_horiz_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
|
||||||
save: '/images/save_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
|
save: "/images/save_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
|
||||||
delete: '/images/delete_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
|
delete: "/images/delete_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
|
||||||
speed: '/images/speed_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
|
speed: "/images/speed_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
|
||||||
}
|
};
|
||||||
|
|
||||||
export default IMAGES;
|
export default IMAGES;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
export const BOARD_WIDTH = globalThis.innerWidth * 0.68;
|
export const BOARD_WIDTH = globalThis.innerWidth * 0.68;
|
||||||
export const BOARD_HEIGHT = globalThis.innerHeight * 0.68;
|
export const BOARD_HEIGHT = globalThis.innerHeight * 0.68;
|
||||||
export const TEXT_SIZE = 30;
|
export const TEXT_SIZE = 30;
|
||||||
export const TEXT_WIDTH = TEXT_SIZE * 0.6;
|
export const TEXT_WIDTH = TEXT_SIZE * 0.6;
|
||||||
|
|||||||
@@ -1,34 +1,33 @@
|
|||||||
import { useRef, useEffect } from "react";
|
import { useRef, useEffect } from "react";
|
||||||
|
|
||||||
|
|
||||||
export function useAudioPlayer() {
|
export function useAudioPlayer() {
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
audioRef.current = new Audio();
|
audioRef.current = new Audio();
|
||||||
return () => {
|
return () => {
|
||||||
audioRef.current!.pause();
|
audioRef.current!.pause();
|
||||||
audioRef.current = null;
|
audioRef.current = null;
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
const playAudio = async (audioUrl: string) => {
|
|
||||||
audioRef.current!.src = audioUrl;
|
|
||||||
try {
|
|
||||||
await audioRef.current!.play();
|
|
||||||
} catch (e) {
|
|
||||||
return e;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const pauseAudio = () => {
|
|
||||||
audioRef.current!.pause();
|
|
||||||
};
|
|
||||||
const stopAudio = () => {
|
|
||||||
audioRef.current!.pause();
|
|
||||||
audioRef.current!.currentTime = 0;
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
playAudio,
|
|
||||||
pauseAudio,
|
|
||||||
stopAudio,
|
|
||||||
audioRef
|
|
||||||
};
|
};
|
||||||
|
}, []);
|
||||||
|
const playAudio = async (audioUrl: string) => {
|
||||||
|
audioRef.current!.src = audioUrl;
|
||||||
|
try {
|
||||||
|
await audioRef.current!.play();
|
||||||
|
} catch (e) {
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const pauseAudio = () => {
|
||||||
|
audioRef.current!.pause();
|
||||||
|
};
|
||||||
|
const stopAudio = () => {
|
||||||
|
audioRef.current!.pause();
|
||||||
|
audioRef.current!.currentTime = 0;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
playAudio,
|
||||||
|
pauseAudio,
|
||||||
|
stopAudio,
|
||||||
|
audioRef,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,17 +4,21 @@ export interface Word {
|
|||||||
word: string;
|
word: string;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
}export interface Letter {
|
|
||||||
letter: string;
|
|
||||||
letter_name_ipa: string;
|
|
||||||
letter_sound_ipa: string;
|
|
||||||
roman_letter?: string;
|
|
||||||
}
|
}
|
||||||
export type SupportedAlphabets = 'japanese' | 'english' | 'esperanto' | 'uyghur';
|
export interface Letter {
|
||||||
|
letter: string;
|
||||||
|
letter_name_ipa: string;
|
||||||
|
letter_sound_ipa: string;
|
||||||
|
roman_letter?: string;
|
||||||
|
}
|
||||||
|
export type SupportedAlphabets =
|
||||||
|
| "japanese"
|
||||||
|
| "english"
|
||||||
|
| "esperanto"
|
||||||
|
| "uyghur";
|
||||||
export const TextSpeakerItemSchema = z.object({
|
export const TextSpeakerItemSchema = z.object({
|
||||||
text: z.string(),
|
text: z.string(),
|
||||||
ipa: z.string().optional(),
|
ipa: z.string().optional(),
|
||||||
locale: z.string()
|
locale: z.string(),
|
||||||
});
|
});
|
||||||
export const TextSpeakerArraySchema = z.array(TextSpeakerItemSchema);
|
export const TextSpeakerArraySchema = z.array(TextSpeakerItemSchema);
|
||||||
|
|
||||||
|
|||||||
127
src/utils.ts
127
src/utils.ts
@@ -2,77 +2,94 @@ import { EdgeTTS, ProsodyOptions } from "edge-tts-universal/browser";
|
|||||||
import { env } from "process";
|
import { env } from "process";
|
||||||
import { TextSpeakerArraySchema } from "./interfaces";
|
import { TextSpeakerArraySchema } from "./interfaces";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
export function inspect(word: string) {
|
export function inspect(word: string) {
|
||||||
const goto = (url: string) => {
|
const goto = (url: string) => {
|
||||||
window.open(url, '_blank');
|
window.open(url, "_blank");
|
||||||
}
|
};
|
||||||
return () => {
|
return () => {
|
||||||
word = word.toLowerCase();
|
word = word.toLowerCase();
|
||||||
goto(`https://www.youdao.com/result?word=${word}&lang=en`);
|
goto(`https://www.youdao.com/result?word=${word}&lang=en`);
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function urlGoto(url: string) {
|
export function urlGoto(url: string) {
|
||||||
window.open(url, '_blank');
|
window.open(url, "_blank");
|
||||||
}
|
}
|
||||||
const API_KEY = env.ZHIPU_API_KEY;
|
const API_KEY = env.ZHIPU_API_KEY;
|
||||||
export async function callZhipuAPI(messages: { role: string; content: string; }[], model = 'glm-4.5-flash') {
|
export async function callZhipuAPI(
|
||||||
const url = 'https://open.bigmodel.cn/api/paas/v4/chat/completions';
|
messages: { role: string; content: string }[],
|
||||||
|
model = "glm-4.5-flash",
|
||||||
|
) {
|
||||||
|
const url = "https://open.bigmodel.cn/api/paas/v4/chat/completions";
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': 'Bearer ' + API_KEY,
|
Authorization: "Bearer " + API_KEY,
|
||||||
'Content-Type': 'application/json'
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: model,
|
model: model,
|
||||||
messages: messages,
|
messages: messages,
|
||||||
temperature: 0.2,
|
temperature: 0.2,
|
||||||
thinking: {
|
thinking: {
|
||||||
type: 'disabled'
|
type: "disabled",
|
||||||
}
|
},
|
||||||
})
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`API 调用失败: ${response.status}`);
|
throw new Error(`API 调用失败: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await response.json();
|
return await response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTTSAudioUrl(text: string, short_name: string, options: ProsodyOptions | undefined = undefined) {
|
export async function getTTSAudioUrl(
|
||||||
const tts = new EdgeTTS(text, short_name, options);
|
text: string,
|
||||||
try {
|
short_name: string,
|
||||||
const result = await tts.synthesize();
|
options: ProsodyOptions | undefined = undefined,
|
||||||
return URL.createObjectURL(result.audio);
|
) {
|
||||||
} catch (e) {
|
const tts = new EdgeTTS(text, short_name, options);
|
||||||
throw e;
|
try {
|
||||||
}
|
const result = await tts.synthesize();
|
||||||
|
return URL.createObjectURL(result.audio);
|
||||||
|
} catch (e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
export const getTextSpeakerData = () => {
|
export const getTextSpeakerData = () => {
|
||||||
try {
|
try {
|
||||||
const item = localStorage.getItem('text-speaker');
|
const item = localStorage.getItem("text-speaker");
|
||||||
|
|
||||||
if (!item) return [];
|
if (!item) return [];
|
||||||
|
|
||||||
const rawData = JSON.parse(item);
|
const rawData = JSON.parse(item);
|
||||||
const result = TextSpeakerArraySchema.safeParse(rawData);
|
const result = TextSpeakerArraySchema.safeParse(rawData);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
return result.data;
|
return result.data;
|
||||||
} else {
|
} else {
|
||||||
console.error('Invalid data structure in localStorage:', result.error);
|
console.error("Invalid data structure in localStorage:", result.error);
|
||||||
return [];
|
return [];
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to parse text-speaker data:', e);
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to parse text-speaker data:", e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
};
|
};
|
||||||
export const setTextSpeakerData = (data: z.infer<typeof TextSpeakerArraySchema>) => {
|
export const setTextSpeakerData = (
|
||||||
if (!localStorage) return;
|
data: z.infer<typeof TextSpeakerArraySchema>,
|
||||||
localStorage.setItem('text-speaker', JSON.stringify(data));
|
) => {
|
||||||
};
|
if (!localStorage) return;
|
||||||
|
localStorage.setItem("text-speaker", JSON.stringify(data));
|
||||||
|
};
|
||||||
|
export function handleAPIError(error: unknown, message: string) {
|
||||||
|
console.error(message, error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "服务器内部错误", message },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user