Compare commits

...

28 Commits

Author SHA1 Message Date
0c3dc037cb ...
All checks were successful
continuous-integration/drone Build is passing
2025-10-28 12:00:22 +08:00
00d7aee32a 优化代码,拆分组件 2025-10-28 11:58:02 +08:00
4529c58aad format everything in zed 2025-10-27 18:20:34 +08:00
99c58217c9 optimize code
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-26 14:15:26 +08:00
e8bc064ad5 ...
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-26 14:13:34 +08:00
54e0eb452b ...
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-25 10:57:59 +08:00
5428c55094 ...
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-25 09:50:38 +08:00
ffc1499232 ...
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-25 09:01:48 +08:00
0900ac26f7 ...
Some checks failed
continuous-integration/drone/push Build is failing
2025-10-25 08:52:23 +08:00
e6d6096636 规范子页面函数命名 2025-10-23 11:17:15 +08:00
89ef27eb57 将Navbar下放到子页面 2025-10-23 11:12:01 +08:00
cb805e2199 ...
Some checks failed
continuous-integration/drone/push Build is failing
2025-10-23 11:04:09 +08:00
dd1d288d0d 以窗口宽高计算单词板宽高 2025-10-21 16:56:10 +08:00
8f2b3eb0cc ...
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-17 11:59:03 +08:00
f45645cc73 ...
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-17 11:55:49 +08:00
d3eac5ccda 修复了删除保存项失败的bug 2025-10-17 11:55:27 +08:00
664dac2f00 change .gitignore 2025-10-16 17:28:54 +08:00
986be675b2 add .drone.yml
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-16 17:24:47 +08:00
aaa484ebee ... 2025-10-12 20:47:37 +08:00
a632e5f249 ... 2025-10-12 20:34:05 +08:00
156b5aad34 ... 2025-10-12 20:21:28 +08:00
75f1e529ac ... 2025-10-12 20:06:10 +08:00
84837de999 ... 2025-10-12 19:59:23 +08:00
a9d0247294 ... 2025-10-12 19:48:40 +08:00
4708828972 添加朗读器本地保存功能 2025-10-12 18:42:04 +08:00
85085ba5ff 逐步添加本地保存功能 2025-10-11 20:43:43 +08:00
2edfb0afb4 fix typo 2025-10-09 12:36:35 +08:00
9d4d2c6299 修复了按键监听的问题 2025-10-09 11:51:43 +08:00
52 changed files with 5684 additions and 3143 deletions

73
.drone.yml Normal file
View File

@@ -0,0 +1,73 @@
---
kind: pipeline
type: docker
name: learn-languages
platform:
os: linux
arch: amd64
volumes:
- name: debian-dist
host:
path: /home/debian/dist
steps:
- name: build
image: node:23-alpine
commands:
- npm ci
- npm run build
- name: package
image: node:23-alpine
environment:
ZHIPU_API_KEY:
from_secret: zhipu_api_key
commands:
- apk add zip
- mkdir -p .next/standalone/.next
- cp -r public .next/standalone
- cp -r .next/static .next/standalone/.next
- cd .next/standalone
- echo "$ZHIPU_API_KEY" > zhipu_api_key.txt
- rm -f /dist/learn-languages.zip
- zip -r /dist/learn-languages.zip .
volumes:
- name: debian-dist
path: /dist
- name: deploy
image: appleboy/drone-ssh
settings:
host:
from_secret: ssh_host
username:
from_secret: ssh_username
password:
from_secret: ssh_password
port: 22
script:
- cd ~/
- rm -rf learn-languages
- mkdir learn-languages
- unzip -d learn-languages dist/learn-languages.zip
- cd learn-languages
- npm i
- |
if pm2 list | grep -q learn-languages; then
echo "进程 learn-languages 已在pm2中运行正在重启..."
ZHIPU_API_KEY=`cat zhipu_api_key.txt` PORT=3030 pm2 restart "learn-languages"
else
echo "进程 learn-languages 未在pm2中运行正在启动..."
ZHIPU_API_KEY=`cat zhipu_api_key.txt` PORT=3030 pm2 start "./server.js" --name "learn-languages"
fi
- pm2 save
- cd ~/
- rm -rf dist
- mkdir dist
debug: true
trigger:
branch:
- main

4
.gitignore vendored
View File

@@ -40,6 +40,4 @@ yarn-error.log*
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
deploy.sh .env
learn-languages.tar.gz
src/app/test

2
css.d.ts vendored
View File

@@ -1 +1 @@
declare module '*.css'; declare module "*.css";

View File

@@ -2,6 +2,8 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
output: "standalone",
allowedDevOrigins: ["192.168.3.65"],
}; };
export default nextConfig; export default nextConfig;

4423
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,11 +10,12 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"clsx": "^2.1.1",
"edge-tts-universal": "^1.3.2", "edge-tts-universal": "^1.3.2",
"motion": "^12.23.24",
"next": "15.5.3", "next": "15.5.3",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0" "react-dom": "19.1.0",
"zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",

View File

@@ -1,8 +1,9 @@
2025.09.25 新增记忆字母表功能 2025.10.12 添加朗读器本地保存功能
2025.10.09 新增记忆字母表功能
2025.10.08 加快了TTS的生成速度将IPA生成设置为可选项 2025.10.08 加快了TTS的生成速度将IPA生成设置为可选项
2025.10.07 新增文本朗读器优化了视频播放器UI 2025.10.07 新增文本朗读器优化了视频播放器UI
2025.10.06 更新了主页面UI移除IPA生成与文本朗读功能新增全语言翻译器 2025.10.06 更新了主页面UI移除IPA生成与文本朗读功能新增全语言翻译器
2025.10.05 新增IPA生成与文本朗读功能 2025.10.05 新增IPA生成与文本朗读功能
2025.09.25 优化了主界面UI 2025.09.25 优化了主界面UI
2025.09.19 更新了单词板,单词不再会重叠 2025.09.19 更新了单词板,单词不再会重叠

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M280-120q-33 0-56.5-23.5T200-200v-520h-40v-80h200v-40h240v40h200v80h-40v520q0 33-23.5 56.5T680-120H280Zm400-600H280v520h400v-520ZM360-280h80v-360h-80v360Zm160 0h80v-360h-80v360ZM280-720v520-520Z"/></svg>

After

Width:  |  Height:  |  Size: 319 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M840-680v480q0 33-23.5 56.5T760-120H200q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h480l160 160Zm-80 34L646-760H200v560h560v-446ZM480-240q50 0 85-35t35-85q0-50-35-85t-85-35q-50 0-85 35t-35 85q0 50 35 85t85 35ZM240-560h360v-160H240v160Zm-40-86v446-560 114Z"/></svg>

After

Width:  |  Height:  |  Size: 388 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M418-340q24 24 62 23.5t56-27.5l224-336-336 224q-27 18-28.5 55t22.5 61Zm62-460q59 0 113.5 16.5T696-734l-76 48q-33-17-68.5-25.5T480-720q-133 0-226.5 93.5T160-400q0 42 11.5 83t32.5 77h552q23-38 33.5-79t10.5-85q0-36-8.5-70T766-540l48-76q30 47 47.5 100T880-406q1 57-13 109t-41 99q-11 18-30 28t-40 10H204q-21 0-40-10t-30-28q-26-45-40-95.5T80-400q0-83 31.5-155.5t86-127Q252-737 325-768.5T480-800Zm7 313Z"/></svg>

After

Width:  |  Height:  |  Size: 521 B

View File

@@ -1,46 +1,100 @@
import Button from "@/components/Button"; import LightButton from "@/components/buttons/LightButton";
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, SetStateAction, useRef, useState } from "react"; import {
Dispatch,
KeyboardEvent,
SetStateAction,
useEffect,
useState,
} from "react";
export default function MemoryCard( export default function MemoryCard({
{ alphabet,
alphabet, setChosenAlphabet,
language, }: {
setChosenAlphabet alphabet: Letter[];
}: { setChosenAlphabet: Dispatch<SetStateAction<SupportedAlphabets | null>>;
alphabet: Letter[], }) {
language: string, const [index, setIndex] = useState(
setChosenAlphabet: Dispatch<SetStateAction<SupportedAlphabets | null>> Math.floor(Math.random() * alphabet.length),
} );
) { const [more, setMore] = useState(false);
const [index, setIndex] = useState(Math.floor(Math.random() * alphabet.length)); const [ipaDisplay, setIPADisplay] = useState(true);
const [more, setMore] = useState(false); const [letterDisplay, setLetterDisplay] = useState(true);
const [ipaDisplay, setIPADisplay] = useState(true);
const [letterDisplay, setLetterDisplay] = useState(true); useEffect(() => {
const letter = alphabet[index]; const handleKeydown = (e: globalThis.KeyboardEvent) => {
return ( if (e.key === " ") refresh();
<div className="w-full flex justify-center items-center"> };
<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"> document.addEventListener("keydown", handleKeydown);
<div className="w-full flex justify-end items-center"> return () => document.removeEventListener("keydown", handleKeydown);
<IconClick size={32} alt="close" src={IMAGES.close} onClick={() => setChosenAlphabet(null)}></IconClick> });
</div>
<div className="flex flex-col gap-12 justify-center items-center"> const letter = alphabet[index];
<span className="text-7xl md:text-9xl">{letterDisplay ? letter.letter : ''}</span> const refresh = () => {
<span className="text-5xl md:text-7xl text-gray-400">{ipaDisplay ? letter.letter_sound_ipa : ''}</span> setIndex(Math.floor(Math.random() * alphabet.length));
</div> };
<div className="flex flex-row mt-32 items-center justify-center gap-2"> return (
<IconClick size={48} alt="refresh" src={IMAGES.refresh} onClick={() => setIndex(Math.floor(Math.random() * alphabet.length))}></IconClick> <div
<IconClick size={48} alt="more" src={IMAGES.more_horiz} onClick={() => setMore(!more)}></IconClick> className="w-full flex justify-center items-center"
{ onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => e.preventDefault()}
more ? (<> >
<Button className="w-20" label={letterDisplay ? '隐藏字母' : '显示字母'} onClick={() => { setLetterDisplay(!letterDisplay) }}></Button> <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">
<Button className="w-20" label={ipaDisplay ? '隐藏IPA' : '显示IPA'} onClick={() => { setIPADisplay(!ipaDisplay) }}></Button> <div className="w-full flex justify-end items-center">
</>) : (<></>) <IconClick
} size={32}
</div> alt="close"
</div> src={IMAGES.close}
onClick={() => setChosenAlphabet(null)}
></IconClick>
</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 ? (
<>
<LightButton
className="w-20"
onClick={() => {
setLetterDisplay(!letterDisplay);
}}
>
{letterDisplay ? "隐藏字母" : "显示字母"}
</LightButton>
<LightButton
className="w-20"
onClick={() => {
setIPADisplay(!ipaDisplay);
}}
>
{ipaDisplay ? "隐藏IPA" : "显示IPA"}
</LightButton>
</>
) : (
<></>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,70 +1,97 @@
'use client'; "use client";
import Button from "@/components/Button"; import LightButton from "@/components/buttons/LightButton";
import { Letter, SupportedAlphabets } from "@/interfaces"; import { Letter, SupportedAlphabets } from "@/interfaces";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import MemoryCard from "./MemoryCard"; import MemoryCard from "./MemoryCard";
import { Navbar } from "@/components/Navbar";
export default function Home() { 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'); <Navbar></Navbar>
setChosenAlphabet(null);
}, 2000);
return () => clearTimeout(timer);
}
}, [loadingState]);
if (!chosenAlphabet) return (
<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 label="日语假名" onClick={() => setChosenAlphabet('japanese')}></Button> <LightButton onClick={() => setChosenAlphabet("japanese")}>
<Button label="英文字母" onClick={() => setChosenAlphabet('english')}></Button>
<Button label="维吾尔字母" onClick={() => setChosenAlphabet('uyghur')}></Button> </LightButton>
<Button label="世界语字母" onClick={() => setChosenAlphabet('esperanto')}></Button> <LightButton onClick={() => setChosenAlphabet("english")}>
</div>
</div>); </LightButton>
if (loadingState === 'loading') { <LightButton onClick={() => setChosenAlphabet("uyghur")}>
return '加载中...';
} </LightButton>
if (loadingState === 'error') { <LightButton onClick={() => setChosenAlphabet("esperanto")}>
return '加载失败,请重试';
} </LightButton>
if (loadingState === 'success' && alphabetData[chosenAlphabet]) { </div>
return (<MemoryCard </div>
language={chosenAlphabet} </>
alphabet={alphabetData[chosenAlphabet]} );
setChosenAlphabet={setChosenAlphabet}> if (loadingState === "loading") {
</MemoryCard>); return "加载中...";
} }
return null; if (loadingState === "error") {
} return "加载失败,请重试";
}
if (loadingState === "success" && alphabetData[chosenAlphabet]) {
return (
<>
<Navbar></Navbar>
<MemoryCard
alphabet={alphabetData[chosenAlphabet]}
setChosenAlphabet={setChosenAlphabet}
></MemoryCard>
</>
);
}
return null;
}

View File

@@ -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, "请稍后再试");
}
}

View File

@@ -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 },
);
}
}

View File

@@ -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 },
);
} }

View File

@@ -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 },
);
}
}

View File

@@ -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 },
);
}
}

View File

@@ -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,11 +20,11 @@
} */ } */
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;
} }

View File

@@ -1,14 +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";
import Link from "next/link";
import Image from "next/image";
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",
@@ -25,34 +23,6 @@ export const metadata: Metadata = {
description: "A Website to Learn Languages", description: "A Website to Learn Languages",
}; };
function MyLink(
{ href, label }: { href: string, label: string }
) {
return (
<Link className="font-bold" href={href}>{label}</Link>
)
}
function Navbar() {
return (
<div className="flex justify-between items-center w-full h-16 px-8 bg-[#35786f] text-white">
<Link href={'/'} className="text-xl flex">
<Image
src={'/favicon.ico'}
alt="logo"
width="32"
height="32"
className="rounded-4xl">
</Image>
<span className="font-bold"></span>
</Link>
<div className="flex gap-4 text-xl">
<MyLink href="/changelog.txt" label="关于"></MyLink>
<MyLink href="https://github.com/GoddoNebianU/learn-languages" label="源码"></MyLink>
</div>
</div>
);
}
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
@@ -63,7 +33,6 @@ export default function RootLayout({
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
> >
<Navbar></Navbar>
{children} {children}
</body> </body>
</html> </html>

View File

@@ -0,0 +1,65 @@
import LightButton from "@/components/buttons/LightButton";
import ACard from "@/components/cards/ACard";
import BCard from "@/components/cards/BCard";
import Window from "@/components/Window";
import { LOCALES } from "@/config/locales";
import { Dispatch, SetStateAction, useState } from "react";
import { WordData } from "./page";
interface Props {
setEditPage: Dispatch<SetStateAction<"choose" | "edit">>;
wordData: WordData;
setWordData: Dispatch<SetStateAction<WordData>>;
localeKey: 0 | 1;
}
export default function Choose({
setEditPage,
wordData,
setWordData,
localeKey,
}: Props) {
const [chosenLocale, setChosenLocale] = useState<
(typeof LOCALES)[number] | null
>(null);
const handleChooseClick = () => {
if (chosenLocale) {
setWordData({
locales: [
localeKey === 0 ? chosenLocale : wordData.locales[0],
localeKey === 1 ? chosenLocale : wordData.locales[1],
],
wordPairs: wordData.wordPairs,
});
setEditPage("edit");
}
};
return (
<Window>
<ACard className="flex flex-col">
<div className="overflow-y-auto flex-1 border border-gray-200 rounded-2xl p-2 grid grid-cols-6 gap-2">
{LOCALES.map((locale, index) => (
<LightButton
key={index}
className="w-26"
selected={locale === chosenLocale}
onClick={() => setChosenLocale(locale)}
>
{locale}
</LightButton>
))}
</div>
<div className="w-full flex items-center justify-center">
<BCard className="flex gap-2 justify-center items-center w-fit">
<LightButton onClick={handleChooseClick}>choose</LightButton>
<LightButton onClick={() => setEditPage("edit")}>
Back
</LightButton>
</BCard>
</div>
</ACard>
</Window>
);
}

97
src/app/memorize/Edit.tsx Normal file
View File

@@ -0,0 +1,97 @@
import LightButton from "@/components/buttons/LightButton";
import ACard from "@/components/cards/ACard";
import BCard from "@/components/cards/BCard";
import Window from "@/components/Window";
import { ChangeEvent, Dispatch, SetStateAction, useState } from "react";
import DarkButton from "@/components/buttons/DarkButton";
import { WordData } from "./page";
import Choose from "./Choose";
interface Props {
setPage: Dispatch<SetStateAction<"start" | "main" | "edit">>;
wordData: WordData;
setWordData: Dispatch<SetStateAction<WordData>>;
}
export default function Edit({ setPage, wordData, setWordData }: Props) {
const [localeKey, setLocaleKey] = useState<0 | 1>(0);
const [editPage, setEditPage] = useState<"choose" | "edit">("edit");
const convertIntoWordData = (text: string) => {
const t1 = text
.replace("", ",")
.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] as [string, string];
else return null;
})
.filter((v) => v !== null);
const new_data: WordData = {
locales: [...wordData.locales],
wordPairs: t2,
};
setWordData(new_data);
};
const convertFromWordData = () => {
let result = "";
for (const pair of wordData.wordPairs) {
result += `${pair[0]}, ${pair[1]}\n`;
}
return result;
};
let input = convertFromWordData();
const handleSave = () => {
convertIntoWordData(input);
};
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
input = e.target.value;
};
if (editPage === "edit")
return (
<Window>
<ACard className="flex flex-col">
<textarea
className="flex-1 text-gray-800 font-serif text-2xl border-gray-200 border rounded-2xl w-full 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">
<LightButton onClick={() => setPage("main")}>
Back
</LightButton>
<LightButton onClick={handleSave}>Save Text</LightButton>
<DarkButton
onClick={() => {
setLocaleKey(0);
setEditPage("choose");
}}
>
Choose Locale 1
</DarkButton>
<DarkButton
onClick={() => {
setLocaleKey(1);
setEditPage("choose");
}}
>
Choose Locale 2
</DarkButton>
</BCard>
</div>
</ACard>
</Window>
);
if (editPage === "choose")
return (
<Choose
wordData={wordData}
setEditPage={setEditPage}
setWordData={setWordData}
localeKey={localeKey}
></Choose>
);
}

40
src/app/memorize/Main.tsx Normal file
View File

@@ -0,0 +1,40 @@
import LightButton from "@/components/buttons/LightButton";
import ACard from "@/components/cards/ACard";
import BCard from "@/components/cards/BCard";
import Window from "@/components/Window";
import { WordData } from "./page";
import { Dispatch, SetStateAction } from "react";
interface Props {
wordData: WordData;
setPage: Dispatch<SetStateAction<"start" | "main" | "edit">>;
}
export default function Main({ wordData, setPage: setPage }: Props) {
return (
<Window>
<ACard className="flex-col flex">
<h1 className="text-center font-extrabold text-4xl text-gray-800 m-2 mb-4">
Memorize
</h1>
<div className="flex-1 font-serif text-2xl w-full h-full text-gray-800">
<BCard>
<p>locale 1 {wordData.locales[0]}</p>
<p>locale 2 {wordData.locales[1]}</p>
<p>Total Words: {wordData.wordPairs.length}</p>
</BCard>
</div>
<div className="w-full flex items-center justify-center">
<BCard className="flex gap-2 justify-center items-center w-fit">
<LightButton onClick={() => setPage("start")}>
Start
</LightButton>
<LightButton>Load</LightButton>
<LightButton>Save</LightButton>
<LightButton onClick={() => setPage("edit")}>Edit</LightButton>
</BCard>
</div>
</ACard>
</Window>
);
}

View File

@@ -0,0 +1,53 @@
import LightButton from "@/components/buttons/LightButton";
import ACard from "@/components/cards/ACard";
import BCard from "@/components/cards/BCard";
import Window from "@/components/Window";
import { WordData } from "./page";
import { Dispatch, SetStateAction, useState } from "react";
interface Props {
wordData: WordData;
setPage: Dispatch<SetStateAction<"start" | "main" | "edit">>;
}
export default function Start({ wordData, setPage }: Props) {
const [display, setDisplay] = useState<"ask" | "show">("ask");
const [wordPair, setWordPair] = useState(
wordData.wordPairs[Math.floor(Math.random() * wordData.wordPairs.length)],
);
const show = () => {
setDisplay("show");
};
const next = () => {
setDisplay("ask");
setWordPair(
wordData.wordPairs[Math.floor(Math.random() * wordData.wordPairs.length)],
);
};
return (
<Window>
<div className="flex-col flex items-center h-96 w-[66dvw]">
<div className="flex-1 w-full p-4 gap-4 flex flex-col text-5xl font-serif">
<div className="p-4 w-full border border-white rounded shadow">
{wordPair[0]}
</div>
{display === "show" && (
<div className="p-4 w-full flex-1 border border-white rounded shadow">
{wordPair[1]}
</div>
)}
</div>
<div className="w-full flex items-center justify-center">
<div className="flex gap-2 justify-center items-center w-fit">
{display === "ask" ? (
<LightButton onClick={show}>Show</LightButton>
) : (
<LightButton onClick={next}>Next</LightButton>
)}
<LightButton onClick={() => setPage("main")}>Exit</LightButton>
</div>
</div>
</div>
</Window>
);
}

41
src/app/memorize/page.tsx Normal file
View File

@@ -0,0 +1,41 @@
"use client";
import { useState } from "react";
import Main from "./Main";
import Edit from "./Edit";
import Start from "./Start";
export interface WordData {
locales: [string, string];
wordPairs: [string, string][];
}
export default function Memorize() {
const [page, setPage] = useState<"start" | "main" | "edit">(
"start",
);
const [wordData, setWordData] = useState<WordData>({
locales: ["en-US", "zh-CN"],
wordPairs: [
['hello', '你好'],
['world', '世界'],
['brutal', '残酷的'],
['apple', '苹果'],
['banana', '香蕉'],
['orange', '橙子'],
['grape', '葡萄'],
]
});
if (page === "main")
return <Main wordData={wordData} setPage={setPage}></Main>;
if (page === "edit")
return (
<Edit
setPage={setPage}
wordData={wordData}
setWordData={setWordData}
></Edit>
);
if (page === "start")
return <Start setPage={setPage} wordData={wordData}></Start>;
}

View File

@@ -1,3 +1,4 @@
import { Navbar } from "@/components/Navbar";
import Link from "next/link"; import Link from "next/link";
function TopArea() { function TopArea() {
@@ -5,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>
@@ -35,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="词墙"
@@ -55,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() {
@@ -91,9 +99,11 @@ function Explore() {
export default function Home() { export default function Home() {
return ( return (
<> <>
<Navbar></Navbar>
<TopArea></TopArea> <TopArea></TopArea>
<Fortune></Fortune> <Fortune></Fortune>
<Explore></Explore> <Explore></Explore>
<LinkGrid></LinkGrid> <LinkGrid></LinkGrid>
</>); </>
);
} }

View File

@@ -1,53 +1,46 @@
import Button from "@/components/Button"; import LightButton from "@/components/buttons/LightButton";
import { useRef, useState } 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 [videoFile, setVideoFile] = useState<File | null>(null);
const [SrtFile, setSrtFile] = useState<File | null>(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];
if (file) { if (file) {
setVideoFile(file);
setVideoUrl(URL.createObjectURL(file)); setVideoUrl(URL.createObjectURL(file));
} }
}; };
} }
} };
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];
if (file) { if (file) {
setSrtFile(file);
setSrtUrl(URL.createObjectURL(file)); setSrtUrl(URL.createObjectURL(file));
} }
}; };
} }
} };
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 label="上传视频" onClick={uploadVideo} /> <LightButton onClick={uploadVideo}></LightButton>
<Button label="上传字幕" onClick={uploadSRT} /> <LightButton onClick={uploadSRT}></LightButton>
<input type="file" className="hidden" ref={inputRef} /> <input type="file" className="hidden" ref={inputRef} />
</div > </div>
) );
} }

View File

@@ -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> }
);
}

View File

@@ -1,175 +1,214 @@
import { useState, useRef, forwardRef, useEffect, KeyboardEvent, useCallback } from "react"; import { useState, useRef, forwardRef, useEffect, useCallback } from "react";
import SubtitleDisplay from "./SubtitleDisplay"; import SubtitleDisplay from "./SubtitleDisplay";
import Button from "@/components/Button"; import LightButton from "@/components/buttons/LightButton";
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 cb = () => { const handleKeyDownEvent = (e: globalThis.KeyboardEvent) => {
if (ready.current.all()) { if (e.key === "n") {
if (!parsedSrtRef.current) { next();
; } else if (e.key === "p") {
} else if (isPlaying) { previous();
// 这里负责显示当前时间的字幕与自动暂停 } else if (e.key === " ") {
const srt = parsedSrtRef.current; togglePlayPause();
const ct = videoRef.current?.currentTime as number; } else if (e.key === "r") {
const index = getIndex(srt, ct); restart();
if (index !== null) { } else if (e.key === "a") {
setSubtitle(srt[index].text) handleAutoPauseToggle();
if (autoPause && ct >= (srt[index].end - 0.05) && ct < srt[index].end) { }
videoRef.current!.currentTime = srt[index].start; };
togglePlayPause(); document.addEventListener("keydown", handleKeyDownEvent);
} return () => document.removeEventListener("keydown", handleKeyDownEvent);
} else { });
setSubtitle('');
} useEffect(() => {
} else { const cb = () => {
; if (ready.current.all()) {
} if (!parsedSrtRef.current) {
} else if (isPlaying) {
// 这里负责显示当前时间的字幕与自动暂停
const srt = parsedSrtRef.current;
const ct = videoRef.current?.currentTime as number;
const index = getIndex(srt, ct);
if (index !== null) {
setSubtitle(srt[index].text);
if (
autoPause &&
ct >= srt[index].end - 0.05 &&
ct < srt[index].end
) {
videoRef.current!.currentTime = srt[index].start;
togglePlayPause();
}
} 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);
const handleKeyDownEvent = (e: KeyboardEvent<HTMLDivElement>) => { }
if (e.key === 'n') { };
next();
} else if (e.key === 'p') {
previous();
} else if (e.key === ' ') {
togglePlayPause();
} else if (e.key === 'r') {
restart();
} else if (e.key === 'a') {
; handleAutoPauseToggle();
}
}
return ( return (
<div className="w-full flex flex-col" onKeyDown={handleKeyDownEvent}> <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 label={isPlaying ? '暂停' : '播放'} onClick={togglePlayPause}></Button> onTimeUpdate={timeUpdate}
<Button label="上句" onClick={previous}></Button> ></video>
<Button label="下句" onClick={next}></Button> <SubtitleDisplay subtitle={subtitle}></SubtitleDisplay>
<Button label="句首" onClick={restart}></Button> <div className="buttons flex mt-2 gap-2 flex-wrap">
<Button label={`自动暂停(${autoPause ? '是' : '否'})`} onClick={handleAutoPauseToggle}></Button> <LightButton onClick={togglePlayPause}>
</div> {isPlaying ? "暂停" : "播放"}
<input className="seekbar" type="range" min={0} max={srtLength} onChange={handleSeek} step={1} value={progress}></input> </LightButton>
<span>{spanText}</span> <LightButton onClick={previous}></LightButton>
<LightButton onClick={next}></LightButton>
<LightButton onClick={restart}></LightButton>
<LightButton
onClick={handleAutoPauseToggle}
>{`自动暂停(${autoPause ? "是" : "否"})`}</LightButton>
</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;

View File

@@ -1,25 +1,27 @@
'use client'; "use client";
import { useRef, useState } from "react"; import { KeyboardEvent, useRef, useState } from "react";
import UploadArea from "./UploadArea"; import UploadArea from "./UploadArea";
import VideoPanel from "./VideoPlayer/VideoPanel"; import VideoPanel from "./VideoPlayer/VideoPanel";
import { Navbar } from "@/components/Navbar";
export default function Home() { export default function SrtPlayer() {
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
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 (
<div className="flex w-screen pt-8 items-center justify-center"> <>
<div className="w-[80vw] md:w-[45vw] flex items-center flex-col"> <Navbar></Navbar>
<VideoPanel <div
videoUrl={videoUrl} className="flex w-screen pt-8 items-center justify-center"
srtUrl={srtUrl} onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => e.preventDefault()}
ref={videoRef} /> >
<UploadArea <div className="w-[80vw] md:w-[45vw] flex items-center flex-col">
setVideoUrl={setVideoUrl} <VideoPanel videoUrl={videoUrl} srtUrl={srtUrl} ref={videoRef} />
setSrtUrl={setSrtUrl} /> <UploadArea setVideoUrl={setVideoUrl} setSrtUrl={setSrtUrl} />
</div>
</div> </div>
</div> </>
); );
} }

View File

@@ -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),
);
} }

View File

@@ -0,0 +1,105 @@
"use client";
import { getTextSpeakerData, setTextSpeakerData } from "@/utils";
import { useState } from "react";
import z from "zod";
import { TextSpeakerItemSchema } from "@/interfaces";
import IconClick from "@/components/IconClick";
import IMAGES from "@/config/images";
interface TextCardProps {
item: z.infer<typeof TextSpeakerItemSchema>;
handleUse: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
handleDel: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
}
function TextCard({ item, handleUse, handleDel }: TextCardProps) {
const onUseClick = () => {
handleUse(item);
};
const onDelClick = () => {
handleDel(item);
};
return (
<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>
);
}
interface SaveListProps {
show?: boolean;
handleUse: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
}
export default function SaveList({ show = false, handleUse }: SaveListProps) {
const [data, setData] = useState(getTextSpeakerData());
const handleDel = (item: z.infer<typeof TextSpeakerItemSchema>) => {
const current_data = getTextSpeakerData();
current_data.splice(
current_data.findIndex((v) => v.text === item.text),
1,
);
setTextSpeakerData(current_data);
refresh();
};
const refresh = () => {
setData(getTextSpeakerData());
};
const handleDeleteAll = () => {
const yesorno = prompt("确定删光吗?(Y/N)")?.trim();
if (yesorno && (yesorno === "Y" || yesorno === "y")) {
setTextSpeakerData([]);
refresh();
}
};
if (show)
return (
<div
className="my-4 p-2 mx-4 md:mx-32 border-1 border-gray-200 rounded-2xl"
style={{ fontFamily: "Times New Roman, serif" }}
>
<div className="flex flex-row justify-center gap-8 items-center">
<IconClick
src={IMAGES.refresh}
alt="refresh"
onClick={refresh}
size={48}
className=""
></IconClick>
<IconClick
src={IMAGES.delete}
alt="delete"
onClick={handleDeleteAll}
size={48}
className=""
></IconClick>
</div>
<ul>
{data.map((v) => (
<TextCard
item={v}
key={crypto.randomUUID()}
handleUse={handleUse}
handleDel={handleDel}
></TextCard>
))}
</ul>
</div>
);
else return <></>;
}

View File

@@ -1,197 +1,336 @@
"use client"; "use client";
import Button from "@/components/Button"; import LightButton from "@/components/buttons/LightButton";
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 { getTTSAudioUrl } 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 { TextSpeakerItemSchema } from "@/interfaces";
import z from "zod";
import { Navbar } from "@/components/Navbar";
import { VOICES } from "@/config/locales";
export default function Home() { export default function TextSpeaker() {
const [ipaEnabled, setIPAEnabled] = useState(false); const textareaRef = useRef<HTMLTextAreaElement>(null);
const [speed, setSpeed] = useState(1); const [showSpeedAdjust, setShowSpeedAdjust] = useState(false);
const [pause, setPause] = useState(true); const [showSaveList, setShowSaveList] = useState(false);
const [autopause, setAutopause] = useState(true); const [saving, setSaving] = useState(false);
const textRef = useRef(''); const [ipaEnabled, setIPAEnabled] = useState(false);
const localeRef = useRef<string | null>(null); const [speed, setSpeed] = useState(1);
const [ipa, setIPA] = useState<string>(''); const [pause, setPause] = useState(true);
const objurlRef = useRef<string | null>(null); const [autopause, setAutopause] = useState(true);
const [processing, setProcessing] = useState(false); const textRef = useRef("");
const [locale, setLocale] = useState<string | null>(null);
const [ipa, setIPA] = useState<string>("");
const objurlRef = useRef<string | null>(null);
const [processing, setProcessing] = useState(false);
const { playAudio, stopAudio, audioRef } = useAudioPlayer();
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const [voicesData, setVoicesData] = useState<{ const handleEnded = () => {
locale: string, if (autopause) {
short_name: string
}[] | null>(null);
const [loading, setLoading] = useState(true);
const { playAudio, stopAudio, audioRef } = useAudioPlayer();
useEffect(() => {
fetch('/list_of_voices.json')
.then(res => res.json())
.then(setVoicesData)
.catch(() => setVoicesData(null))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const handleEnded = () => {
if (autopause) {
setPause(true);
} else {
playAudio(objurlRef.current!);
}
}
audio.addEventListener('ended', handleEnded);
return () => {
audio.removeEventListener('ended', handleEnded);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [audioRef, autopause]);
if (loading) return <div>...</div>;
if (!voicesData) return <div></div>;
const speak = async () => {
if (processing) return;
setProcessing(true);
if (ipa.length === 0 && ipaEnabled && textRef.current.length !== 0) {
const params = new URLSearchParams({
text: textRef.current
});
fetch(`/api/ipa?${params}`)
.then(res => res.json())
.then(data => {
setIPA(data.ipa);
}).catch(e => {
console.error(e);
setIPA('');
})
}
if (pause) {
// 如果没在读
if (textRef.current.length === 0) {
// 没文本咋读
} else {
setPause(false);
if (objurlRef.current) {
// 之前有播放
playAudio(objurlRef.current);
} else {
// 第一次播放
console.log('downloading text info');
const params = new URLSearchParams({
text: textRef.current.slice(0, 30)
});
try {
const textinfo = await (await fetch(`/api/locale?${params}`)).json();
localeRef.current = textinfo.locale;
const voice = voicesData.find(v => v.locale.startsWith(localeRef.current!));
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);
localeRef.current = null;
setProcessing(false);
}
}
}
} else {
// 如果在读就暂停
setPause(true);
stopAudio();
}
setProcessing(false);
}
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
textRef.current = e.target.value.trim();
localeRef.current = null;
setIPA('');
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
objurlRef.current = null;
stopAudio();
setPause(true); setPause(true);
} else {
playAudio(objurlRef.current!);
}
};
audio.addEventListener("ended", handleEnded);
return () => {
audio.removeEventListener("ended", handleEnded);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [audioRef, autopause]);
const speak = async () => {
if (processing) return;
setProcessing(true);
if (ipa.length === 0 && ipaEnabled && textRef.current.length !== 0) {
const params = new URLSearchParams({
text: textRef.current,
});
fetch(`/api/ipa?${params}`)
.then((res) => res.json())
.then((data) => {
setIPA(data.ipa);
})
.catch((e) => {
console.error(e);
setIPA("");
});
} }
const letMeSetSpeed = (new_speed: number) => { if (pause) {
return () => { // 如果没在读
setSpeed(new_speed); if (textRef.current.length === 0) {
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current); // 没文本咋读
objurlRef.current = null; } else {
stopAudio(); setPause(false);
if (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); setPause(true);
setLocale(null);
setProcessing(false);
}
} }
}
} else {
// 如果在读就暂停
setPause(true);
stopAudio();
} }
return (<> setProcessing(false);
<div className="my-4 p-4 mx-4 md:mx-32 border-1 border-gray-200 rounded-2xl"> };
<textarea className="text-2xl resize-none focus:outline-0 min-h-64 w-full"
onChange={handleInputChange}> const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
</textarea> textRef.current = e.target.value.trim();
<div className="overflow-auto text-gray-600 h-18"> setLocale(null);
{ipa} 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;
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;
}
let theIPA = ipa;
if (ipa.length === 0 && ipaEnabled) {
const params = new URLSearchParams({
text: textRef.current,
});
const tmp = await (await fetch(`/api/ipa?${params}`)).json();
setIPA(tmp.ipa);
theIPA = tmp.ipa;
}
const save = getTextSpeakerData();
const oldIndex = save.findIndex((v) => v.text === textRef.current);
if (oldIndex !== -1) {
const oldItem = save[oldIndex];
if (theIPA) {
if (!oldItem.ipa || oldItem.ipa !== theIPA) {
oldItem.ipa = theIPA;
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>
<div className="w-full flex flex-row gap-2 justify-center items-center"> )}
<Button label="generate ipa" selected={ipaEnabled} onClick={() => setIPAEnabled(!ipaEnabled)}></Button> <IconClick
<IconClick size={45} onClick={() => { size={45}
setAutopause(!autopause); if (objurlRef) { stopAudio(); } setPause(true); onClick={speak}
}} src={ src={pause ? IMAGES.play_arrow : IMAGES.pause}
autopause ? IMAGES.autoplay : IMAGES.autopause alt="playorpause"
} alt="autoplayorpause" className={`${processing ? "bg-gray-200" : ""}`}
></IconClick> ></IconClick>
<IconClick size={45} onClick={speak} src={ <IconClick
pause ? IMAGES.play_arrow : IMAGES.pause size={45}
} alt="playorpause" className={`${processing ? 'bg-gray-200' : ''}`}></IconClick> onClick={() => {
<IconClick size={45} onClick={letMeSetSpeed(0.5)} setAutopause(!autopause);
src={IMAGES.speed_0_5x} if (objurlRef) {
alt="0.5x" stopAudio();
className={speed === 0.5 ? 'bg-gray-200' : ''} }
></IconClick> setPause(true);
<IconClick size={45} onClick={letMeSetSpeed(0.7)} }}
src={IMAGES.speed_0_7x} src={autopause ? IMAGES.autoplay : IMAGES.autopause}
alt="0.7x" alt="autoplayorpause"
className={speed === 0.7 ? 'bg-gray-200' : ''} ></IconClick>
></IconClick> <IconClick
<IconClick size={45} onClick={letMeSetSpeed(1)} size={45}
src={IMAGES.speed_1x} onClick={() => setShowSpeedAdjust(!showSpeedAdjust)}
alt="1x" src={IMAGES.speed}
className={speed === 1 ? 'bg-gray-200' : ''} alt="speed"
></IconClick> className={`${showSpeedAdjust ? "bg-gray-200" : ""}`}
<IconClick size={45} onClick={letMeSetSpeed(1.2)} ></IconClick>
src={IMAGES.speed_1_2_x} <IconClick
alt="1.2x" size={45}
className={speed === 1.2 ? 'bg-gray-200' : ''} onClick={save}
></IconClick> src={IMAGES.save}
<IconClick size={45} onClick={letMeSetSpeed(1.5)} alt="save"
src={IMAGES.speed_1_5x} className={`${saving ? "bg-gray-200" : ""}`}
alt="1.5x" ></IconClick>
className={speed === 1.5 ? 'bg-gray-200' : ''} <div className="w-full flex flex-row flex-wrap gap-2 justify-center items-center">
></IconClick> <LightButton
</div> selected={ipaEnabled}
</div > onClick={() => setIPAEnabled(!ipaEnabled)}
</>); >
} IPA
</LightButton>
<LightButton
onClick={() => {
setShowSaveList(!showSaveList);
}}
selected={showSaveList}
>
</LightButton>
</div>
</div>
</div>
<SaveList show={showSaveList} handleUse={handleUseItem}></SaveList>
</>
);
}

View File

@@ -1,49 +1,35 @@
"use client"; "use client";
import { ChangeEvent, useEffect, useState } from "react"; import { ChangeEvent, useState } from "react";
import Button from "@/components/Button"; import LightButton from "@/components/buttons/LightButton";
import IconClick from "@/components/IconClick"; import IconClick from "@/components/IconClick";
import { useAudioPlayer } from "@/hooks/useAudioPlayer"; import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import IMAGES from "@/config/images"; import IMAGES from "@/config/images";
import { getTTSAudioUrl } from "@/utils"; import { getTTSAudioUrl } from "@/utils";
import { Navbar } from "@/components/Navbar";
import { VOICES } from "@/config/locales";
export default function Home() { export default function Translator() {
const [ipaEnabled, setIPAEnabled] = useState(true); const [ipaEnabled, setIPAEnabled] = useState(true);
const [voicesData, setVoicesData] = useState<{ const [targetLang, setTargetLang] = useState("Chinese");
locale: string,
short_name: string
}[] | null>(null);
const [loading, setLoading] = useState(true);
const [targetLang, setTargetLang] = useState('Italian');
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();
useEffect(() => { const tl = ["Chinese", "English", "Italian"];
fetch('/list_of_voices.json')
.then(res => res.json())
.then(setVoicesData)
.catch(() => setVoicesData(null))
.finally(() => setLoading(false));
}, []);
if (loading) return <div>...</div>;
if (!voicesData) return <div></div>;
const tl = ['English', 'Italian', 'Japanese'];
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;
@@ -51,91 +37,96 @@ export default function Home() {
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 = voicesData.find(v => v.locale.startsWith(info.locale)); const voice = VOICES.find((v) => v.locale.startsWith(info.locale));
if (!voice) { if (!voice) {
return; return;
} }
@@ -149,7 +140,7 @@ export default function Home() {
return; return;
} }
} else { } else {
const voice = voicesData.find(v => v.locale.startsWith(sourceLocale!)); const voice = VOICES.find((v) => v.locale.startsWith(sourceLocale!));
if (!voice) { if (!voice) {
return; return;
} }
@@ -158,84 +149,134 @@ export default function Home() {
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 = voicesData.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 (
<> <>
<Navbar></Navbar>
<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 label="generate ipa" selected={ipaEnabled} onClick={() => setIPAEnabled(!ipaEnabled)}></Button> <LightButton
selected={ipaEnabled}
onClick={() => setIPAEnabled(!ipaEnabled)}
>
generate ipa
</LightButton>
</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('English') }} label="English" selected={targetLang === 'English'}></Button> <LightButton
<Button onClick={() => { setTargetLang('Italian') }} label="Italian" selected={targetLang === 'Italian'}></Button> onClick={() => {
<Button onClick={() => { setTargetLang('Japanese') }} label="Japanese" selected={targetLang === 'Japanese'}></Button> setTargetLang("Chinese");
<Button onClick={inputLanguage} label={'Other' + (tl.includes(targetLang) ? '' : ': ' + targetLang)} selected={!(tl.includes(targetLang))}></Button> }}
selected={targetLang === "Chinese"}
>
Chinese
</LightButton>
<LightButton
onClick={() => {
setTargetLang("English");
}}
selected={targetLang === "English"}
>
English
</LightButton>
<LightButton
onClick={() => {
setTargetLang("Italian");
}}
selected={targetLang === "Italian"}
>
Italian
</LightButton>
<LightButton onClick={inputLanguage} selected={!tl.includes(targetLang)}>
{"Other" + (tl.includes(targetLang) ? "" : ": " + targetLang)}
</LightButton>
</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={`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>
</> </>

View File

@@ -0,0 +1,66 @@
"use client";
import {
BOARD_WIDTH,
TEXT_WIDTH,
BOARD_HEIGHT,
TEXT_SIZE,
} from "@/config/word-board-config";
import { Word } from "@/interfaces";
import { Dispatch, SetStateAction } from "react";
export default function TheBoard({
words,
selectWord,
}: {
words: [
{
word: string;
x: number;
y: number;
},
];
setWords: Dispatch<SetStateAction<Word[]>>;
selectWord: (word: string) => void;
}) {
function DraggableWord({ word }: { word: Word }) {
return (
<span
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 (
<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>
);
}

View File

@@ -1,46 +0,0 @@
'use client';
import { BOARD_WIDTH, TEXT_WIDTH, BOARD_HEIGHT, TEXT_SIZE } from "@/constants";
import { Word } from "@/interfaces";
import { Dispatch, SetStateAction, useEffect } from "react";
export default function WordBoard(
{ words, setWords, selectWord }: {
words: [
{
word: string,
x: number,
y: number
}
],
setWords: Dispatch<SetStateAction<Word[]>>,
selectWord: (word: string) => void
}
) {
function DraggableWord({ word }: { word: Word }) {
return (<span
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 (
<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>
)
}

View File

@@ -1,134 +1,141 @@
'use client'; "use client";
import WordBoard from "@/app/word-board/WordBoard"; import TheBoard from "@/app/word-board/TheBoard";
import Button from "../../components/Button"; import LightButton from "../../components/buttons/LightButton";
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 "@/constants"; 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";
export default function Home() { 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;
@@ -139,21 +146,42 @@ export default function Home() {
// inputRef.current.value = ''; // inputRef.current.value = '';
// } // }
return ( return (
<div className="flex w-screen h-screen justify-center items-center"> <>
<div onKeyDown={handleKeyDown} className="p-5 bg-gray-200 rounded shadow-2xl"> <Navbar></Navbar>
<WordBoard selectWord={selectWord} words={words as [Word]} setWords={setWords} /> <div className="flex w-screen h-screen justify-center items-center">
<div className="flex justify-center rounded mt-3 gap-1"> <div
<input ref={inputRef} placeholder="word to operate" type="text" className="focus:outline-none border-b-2 border-black" /> onKeyDown={handleKeyDown}
<Button label="插入" onClick={insertWord}></Button> className="p-5 bg-gray-200 rounded shadow-2xl"
<Button label="删除" onClick={deleteWord}></Button> >
<Button label="搜索" onClick={searchWord}></Button> <TheBoard
<Button label="导入" onClick={importWords}></Button> selectWord={selectWord}
<Button label="导出" onClick={exportWords}></Button> words={words as [Word]}
<Button label="删光" onClick={deleteAll}></Button> setWords={setWords}
{/* <Button label="朗读" onClick={readWordAloud}></Button> */} />
<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"
/>
<LightButton onClick={insertWord}></LightButton>
<LightButton onClick={deleteWord}></LightButton>
<LightButton onClick={searchWord}></LightButton>
<LightButton onClick={importWords}></LightButton>
<LightButton onClick={exportWords}></LightButton>
<LightButton onClick={deleteAll}></LightButton>
{/* <Button label="朗读" onClick={readWordAloud}></Button> */}
</div>
<input
type="file"
ref={inputFileRef}
className="hidden"
accept="application/json"
onChange={handleFileChange}
></input>
</div> </div>
<input type="file" ref={inputFileRef} className="hidden" accept="application/json" onChange={handleFileChange}></input>
</div> </div>
</div> </>
); );
} }

View File

@@ -1,21 +0,0 @@
export default function Button({
label,
onClick,
className = '',
selected = false
}: {
label:
string,
onClick?: () => void,
className?: string,
selected?: boolean
}) {
return (
<button
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}`}
>
{label}
</button>
);
}

View File

@@ -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>
</>
);
} }

33
src/components/Navbar.tsx Normal file
View File

@@ -0,0 +1,33 @@
import Link from "next/link";
import Image from "next/image";
function MyLink({ href, label }: { href: string; label: string }) {
return (
<Link className="font-bold" href={href}>
{label}
</Link>
);
}
export function Navbar() {
return (
<div className="flex justify-between items-center w-full h-16 px-8 bg-[#35786f] text-white">
<Link href={"/"} className="text-xl flex">
<Image
src={"/favicon.ico"}
alt="logo"
width="32"
height="32"
className="rounded-4xl"
></Image>
<span className="font-bold"></span>
</Link>
<div className="flex gap-4 text-xl">
<MyLink href="/changelog.txt" label="关于"></MyLink>
<MyLink
href="https://github.com/GoddoNebianU/learn-languages"
label="源码"
></MyLink>
</div>
</div>
);
}

12
src/components/Window.tsx Normal file
View File

@@ -0,0 +1,12 @@
"use client";
interface WindowProps {
children?: React.ReactNode;
className?: string;
}
export default function Window({ children }: WindowProps) {
return (
<div className="w-full bg-gray-200 h-screen flex justify-center items-center">
{children}
</div>
);
}

View File

@@ -0,0 +1,22 @@
import PlainButton from "./PlainButton";
export default function DarkButton({
onClick,
className,
selected,
children,
}: {
onClick?: () => void;
className?: string;
selected?: boolean;
children?: React.ReactNode;
}) {
return (
<PlainButton
onClick={onClick}
className={`hover:bg-gray-600 text-white ${selected ? "bg-gray-600" : "bg-gray-800"} ${className}`}
>
{children}
</PlainButton>
);
}

View File

@@ -0,0 +1,22 @@
import PlainButton from "./PlainButton";
export default function LightButton({
onClick,
className,
selected,
children,
}: {
onClick?: () => void;
className?: string;
selected?: boolean;
children?: React.ReactNode;
}) {
return (
<PlainButton
onClick={onClick}
className={`hover:bg-gray-200 text-gray-800 ${selected ? "bg-gray-200" : "bg-white"} ${className}`}
>
{children}
</PlainButton>
);
}

View File

@@ -0,0 +1,18 @@
export default function PlainButton({
onClick,
className,
children,
}: {
onClick?: () => void;
className?: string;
children?: React.ReactNode;
}) {
return (
<button
onClick={onClick}
className={`px-2 py-1 rounded shadow font-bold hover:cursor-pointer ${className}`}
>
{children}
</button>
);
}

View File

@@ -0,0 +1,16 @@
"use client";
interface ACardProps {
children?: React.ReactNode;
className?: string;
}
export default function ACard({ children, className }: ACardProps) {
return (
<div
className={`${className} w-[61vw] h-96 p-2 shadow-2xl bg-white rounded-xl`}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,12 @@
"use client";
interface BCardProps {
children?: React.ReactNode;
className?: string;
}
export default function BCard({ children, className }: BCardProps) {
return (
<div className={`${className} rounded-xl p-2 shadow-xl`}>{children}</div>
);
}

View File

@@ -1,17 +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",
delete: "/images/delete_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
speed: "/images/speed_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
};
export default IMAGES; export default IMAGES;

1220
src/config/locales.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
export const BOARD_WIDTH = globalThis.innerWidth * 0.68;
export const BOARD_HEIGHT = globalThis.innerHeight * 0.68;
export const TEXT_SIZE = 30;
export const TEXT_WIDTH = TEXT_SIZE * 0.6;

View File

@@ -1,4 +0,0 @@
export const BOARD_WIDTH = 800;
export const BOARD_HEIGHT = 500;
export const TEXT_SIZE = 30;
export const TEXT_WIDTH = TEXT_SIZE * 0.6;

View File

@@ -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,
};
} }

View File

@@ -1,13 +1,24 @@
import z from "zod";
export interface Word { 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({
text: z.string(),
ipa: z.string().optional(),
locale: z.string(),
});
export const TextSpeakerArraySchema = z.array(TextSpeakerItemSchema);

View File

@@ -1,52 +1,95 @@
import { EdgeTTS, ProsodyOptions } from "edge-tts-universal/browser"; import { EdgeTTS, ProsodyOptions } from "edge-tts-universal/browser";
import { env } from "process"; import { env } from "process";
import { TextSpeakerArraySchema } from "./interfaces";
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 = () => {
try {
const item = localStorage.getItem("text-speaker");
if (!item) return [];
const rawData = JSON.parse(item);
const result = TextSpeakerArraySchema.safeParse(rawData);
if (result.success) {
return result.data;
} else {
console.error("Invalid data structure in localStorage:", result.error);
return [];
} }
} } catch (e) {
console.error("Failed to parse text-speaker data:", e);
return [];
}
};
export const setTextSpeakerData = (
data: z.infer<typeof TextSpeakerArraySchema>,
) => {
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 },
);
}