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