format everything in zed

This commit is contained in:
2025-10-27 18:20:34 +08:00
parent 99c58217c9
commit 4529c58aad
34 changed files with 1927 additions and 1609 deletions

View File

@@ -70,4 +70,4 @@ steps:
trigger: trigger:
branch: branch:
- main - main

2
.gitignore vendored
View File

@@ -40,4 +40,4 @@ yarn-error.log*
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
.env .env

2
css.d.ts vendored
View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
'use client'; "use client";
import Button from "@/components/Button"; import Button from "@/components/Button";
import { Letter, SupportedAlphabets } from "@/interfaces"; import { Letter, SupportedAlphabets } from "@/interfaces";
@@ -7,78 +7,91 @@ import MemoryCard from "./MemoryCard";
import { Navbar } from "@/components/Navbar"; import { Navbar } from "@/components/Navbar";
export default function Alphabet() { export default function Alphabet() {
const [chosenAlphabet, setChosenAlphabet] = useState<SupportedAlphabets | null>(null); const [chosenAlphabet, setChosenAlphabet] =
const [alphabetData, setAlphabetData] = useState<Record<SupportedAlphabets, Letter[] | null>>({ useState<SupportedAlphabets | null>(null);
japanese: null, const [alphabetData, setAlphabetData] = useState<
english: null, Record<SupportedAlphabets, Letter[] | null>
esperanto: null, >({
uyghur: null japanese: null,
}); english: null,
const [loadingState, setLoadingState] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); esperanto: null,
uyghur: null,
});
const [loadingState, setLoadingState] = useState<
"idle" | "loading" | "success" | "error"
>("idle");
useEffect(() => {
if (chosenAlphabet && !alphabetData[chosenAlphabet]) {
setLoadingState("loading");
useEffect(() => { fetch("/alphabets/" + chosenAlphabet + ".json")
if (chosenAlphabet && !alphabetData[chosenAlphabet]) { .then((res) => {
setLoadingState('loading'); if (!res.ok) throw new Error("Network response was not ok");
return res.json();
})
.then((obj) => {
setAlphabetData((prev) => ({
...prev,
[chosenAlphabet]: obj as Letter[],
}));
setLoadingState("success");
})
.catch(() => {
setLoadingState("error");
});
}
}, [chosenAlphabet, alphabetData]);
fetch('/alphabets/' + chosenAlphabet + '.json') useEffect(() => {
.then(res => { if (loadingState === "error") {
if (!res.ok) throw new Error('Network response was not ok'); const timer = setTimeout(() => {
return res.json(); setLoadingState("idle");
}).then((obj) => { setChosenAlphabet(null);
setAlphabetData(prev => ({ ...prev, [chosenAlphabet]: obj as Letter[] })); }, 2000);
setLoadingState('success'); return () => clearTimeout(timer);
}).catch(() => { }
setLoadingState('error'); }, [loadingState]);
});
}
}, [chosenAlphabet, alphabetData]);
useEffect(() => { if (!chosenAlphabet)
if (loadingState === 'error') { return (
const timer = setTimeout(() => { <>
setLoadingState('idle');
setChosenAlphabet(null);
}, 2000);
return () => clearTimeout(timer);
}
}, [loadingState]);
if (!chosenAlphabet) return (<>
<Navbar></Navbar> <Navbar></Navbar>
<div className="border border-gray-200 m-4 mt-4 flex flex-col justify-center items-center p-4 rounded-2xl gap-2"> <div className="border border-gray-200 m-4 mt-4 flex flex-col justify-center items-center p-4 rounded-2xl gap-2">
<span className="text-2xl md:text-3xl"></span> <span className="text-2xl md:text-3xl"></span>
<div className="flex gap-1 flex-wrap"> <div className="flex gap-1 flex-wrap">
<Button onClick={() => setChosenAlphabet('japanese')}> <Button onClick={() => setChosenAlphabet("japanese")}>
</Button> </Button>
<Button onClick={() => setChosenAlphabet('english')}> <Button onClick={() => setChosenAlphabet("english")}>
</Button> </Button>
<Button onClick={() => setChosenAlphabet('uyghur')}> <Button onClick={() => setChosenAlphabet("uyghur")}>
</Button> </Button>
<Button onClick={() => setChosenAlphabet('esperanto')}> <Button onClick={() => setChosenAlphabet("esperanto")}>
</Button> </Button>
</div> </div>
</div> </div>
</> </>
); );
if (loadingState === 'loading') { if (loadingState === "loading") {
return '加载中...'; return "加载中...";
} }
if (loadingState === 'error') { if (loadingState === "error") {
return '加载失败,请重试'; return "加载失败,请重试";
} }
if (loadingState === 'success' && alphabetData[chosenAlphabet]) { if (loadingState === "success" && alphabetData[chosenAlphabet]) {
return (<> return (
<Navbar></Navbar> <>
<MemoryCard <Navbar></Navbar>
alphabet={alphabetData[chosenAlphabet]} <MemoryCard
setChosenAlphabet={setChosenAlphabet}> alphabet={alphabetData[chosenAlphabet]}
</MemoryCard> setChosenAlphabet={setChosenAlphabet}
</>); ></MemoryCard>
} </>
return null; );
} }
return null;
}

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,13 +20,13 @@
} */ } */
body { body {
background: var(--background); background: var(--background);
color: var(--foreground); color: var(--foreground);
font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif; font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
} }
.code-block { .code-block {
font-family: var(--font-geist-mono), monospace; font-family: var(--font-geist-mono), monospace;
} }
@source '../../node_modules/rc-modal-sheet/**/*.js' @source '../../node_modules/rc-modal-sheet/**/*.js';

View File

@@ -1,12 +1,12 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import type { Viewport } from 'next' import type { Viewport } from "next";
export const viewport: Viewport = { export const viewport: Viewport = {
width: 'device-width', width: "device-width",
initialScale: 1.0 initialScale: 1.0,
} };
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -23,8 +23,6 @@ export const metadata: Metadata = {
description: "A Website to Learn Languages", description: "A Website to Learn Languages",
}; };
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{

View File

@@ -1,135 +1,146 @@
'use client'; "use client";
import Button from "@/components/Button"; import Button from "@/components/Button";
import { Select, Option } from "@material-tailwind/react"; import { Select, Option } from "@material-tailwind/react";
import { ChangeEvent, useState } from "react"; import { ChangeEvent, useState } from "react";
interface ACardProps { interface ACardProps {
children?: React.ReactNode, children?: React.ReactNode;
className?: string className?: string;
} }
function ACard({ children, className }: ACardProps) { function ACard({ children, className }: ACardProps) {
return ( return (
<div className={`w-[61vw] h-96 p-2 shadow-2xl bg-[#00BCD4] rounded-xl ${className}`}> <div
{children} className={`w-[61vw] h-96 p-2 shadow-2xl bg-[#00BCD4] rounded-xl ${className}`}
</div>); >
{children}
</div>
);
} }
interface BCard { interface BCard {
children?: React.ReactNode, children?: React.ReactNode;
className?: string className?: string;
} }
function BCard({ children, className }: BCard) { function BCard({ children, className }: BCard) {
return ( return (
<div className={`border border-[#0097A7] rounded-xl p-2 ${className}`}> <div className={`border border-[#0097A7] rounded-xl p-2 ${className}`}>
{children} {children}
</div>); </div>
);
} }
interface WordData { interface WordData {
locale1: string, locale1: string;
locale2: string, locale2: string;
data: Record<string, string> data: Record<string, string>;
} }
export default function Memorize() { export default function Memorize() {
const [pageState, setPageState] = useState<'choose' | 'start' | 'main' | 'edit'>('edit'); const [pageState, setPageState] = useState<
const [wordData, setWordData] = useState<WordData>({ "choose" | "start" | "main" | "edit"
locale1: 'en-US', >("edit");
locale2: 'zh-CN', const [wordData, setWordData] = useState<WordData>({
data: { 'hello': '你好' } locale1: "en-US",
}); locale2: "zh-CN",
if (pageState === 'main') { data: { hello: "你好" },
return (<> });
<div className="w-full h-screen flex justify-center items-center"> if (pageState === "main") {
<ACard> return (
<h1 className="text-center font-extrabold text-4xl text-white m-2 mb-4"> <>
Memorize <div className="w-full h-screen flex justify-center items-center">
</h1> <ACard>
<div className="w-full text-white"> <h1 className="text-center font-extrabold text-4xl text-white m-2 mb-4">
<BCard> Memorize
<p>Lang1: {wordData.locale1}</p> </h1>
<p>Lang2: {wordData.locale2}</p> <div className="w-full text-white">
<p>Total Words: {Object.keys(wordData.data).length}</p> <BCard>
</BCard> <p>Lang1: {wordData.locale1}</p>
</div> <p>Lang2: {wordData.locale2}</p>
<div className="w-full flex items-center justify-center"> <p>Total Words: {Object.keys(wordData.data).length}</p>
<BCard className="flex gap-2 justify-center items-center w-fit"> </BCard>
<Button>Start</Button>
<Button>Load</Button>
<Button>Save</Button>
<Button onClick={() => setPageState('edit')}>Edit</Button>
</BCard>
</div>
</ACard>
</div> </div>
</>); <div className="w-full flex items-center justify-center">
} <BCard className="flex gap-2 justify-center items-center w-fit">
if (pageState === 'choose') { <Button>Start</Button>
return (<> <Button>Load</Button>
</>); <Button>Save</Button>
} <Button onClick={() => setPageState("edit")}>Edit</Button>
if (pageState === 'start') { </BCard>
return (<>
</>);
}
if (pageState === 'edit') {
const convertIntoWordData = (text: string) => {
const t1 = text.split('\n').map(v => v.trim()).filter(v => v.includes(','));
const t2 = t1.map(v => {
const [left, right] = v.split(',', 2).map(v => v.trim());
if (left && right)
return {
[left]: right
};
else return {};
});
const new_data = {
locale1: wordData.locale1,
locale2: wordData.locale2,
data: Object.assign({}, ...t2)
};
setWordData(new_data);
}
const convertFromWordData = () => {
let result = '';
for (const k in wordData.data) {
result += `${k}, ${wordData.data[k]}\n`;
}
return result;
}
let input = convertFromWordData();
const handleSave = () => {
convertIntoWordData(input);
setPageState('main');
}
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
input = e.target.value;
}
return (<>
<div className="w-full h-screen flex flex-col justify-center items-center">
<ACard className="">
<textarea className="text-white border-gray-200 border rounded-2xl w-full h-50 resize-none outline-0 p-2"
defaultValue={input}
onChange={handleChange}></textarea>
<div className="w-full flex items-center justify-center">
<BCard className="flex gap-2 justify-center items-center w-fit">
<Button>choose locale1</Button>
<Button>choose locale2</Button>
<Button onClick={() => setPageState('main')}>Cancel</Button>
<Button onClick={handleSave}>Save</Button>
<button className="inline-flex items-center justify-center border align-middle select-none font-sans font-medium text-center transition-all duration-300 ease-in disabled:opacity-50 disabled:shadow-none disabled:cursor-not-allowed data-[shape=pill]:rounded-full data-[width=full]:w-full focus:shadow-none text-sm rounded-md py-2 px-4 shadow-sm hover:shadow-md bg-slate-800 border-slate-800 text-slate-50 hover:bg-slate-700 hover:border-slate-700">
Button
</button>
</BCard>
</div>
<div className="w-48">
</div>
</ACard>
</div> </div>
</ACard>
</div>
</>
);
}
if (pageState === "choose") {
return <></>;
}
if (pageState === "start") {
return <></>;
}
if (pageState === "edit") {
const convertIntoWordData = (text: string) => {
const t1 = text
.split("\n")
.map((v) => v.trim())
.filter((v) => v.includes(","));
const t2 = t1.map((v) => {
const [left, right] = v.split(",", 2).map((v) => v.trim());
if (left && right)
return {
[left]: right,
};
else return {};
});
const new_data = {
locale1: wordData.locale1,
locale2: wordData.locale2,
data: Object.assign({}, ...t2),
};
setWordData(new_data);
};
const convertFromWordData = () => {
let result = "";
for (const k in wordData.data) {
result += `${k}, ${wordData.data[k]}\n`;
}
return result;
};
let input = convertFromWordData();
const handleSave = () => {
convertIntoWordData(input);
setPageState("main");
};
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
input = e.target.value;
};
return (
<>
<div className="w-full h-screen flex flex-col justify-center items-center">
<ACard className="">
<textarea
className="text-white border-gray-200 border rounded-2xl w-full h-50 resize-none outline-0 p-2"
defaultValue={input}
onChange={handleChange}
></textarea>
<div className="w-full flex items-center justify-center">
<BCard className="flex gap-2 justify-center items-center w-fit">
<Button>choose locale1</Button>
<Button>choose locale2</Button>
<Button onClick={() => setPageState("main")}>Cancel</Button>
<Button onClick={handleSave}>Save</Button>
<button className="inline-flex items-center justify-center border align-middle select-none font-sans font-medium text-center transition-all duration-300 ease-in disabled:opacity-50 disabled:shadow-none disabled:cursor-not-allowed data-[shape=pill]:rounded-full data-[width=full]:w-full focus:shadow-none text-sm rounded-md py-2 px-4 shadow-sm hover:shadow-md bg-slate-800 border-slate-800 text-slate-50 hover:bg-slate-700 hover:border-slate-700">
Button
</button>
</BCard>
</div>
<div className="w-48"></div>
</ACard>
</div>
{/* <Select {/* <Select
label="选择语言" label="选择语言"
placeholder="请选择语言" placeholder="请选择语言"
onResize={undefined} onResize={undefined}
@@ -143,6 +154,7 @@ export default function Memorize() {
<Option>Material Tailwind Angular</Option> <Option>Material Tailwind Angular</Option>
<Option>Material Tailwind Svelte</Option> <Option>Material Tailwind Svelte</Option>
</Select> */} </Select> */}
</>); </>
} );
}
} }

View File

@@ -6,25 +6,28 @@ function TopArea() {
<div className="bg-[#35786f] text-white w-full min-h-[75dvh] flex justify-center items-center"> <div className="bg-[#35786f] text-white w-full min-h-[75dvh] flex justify-center items-center">
<div className="mb-16 mx-16 md:mx-0 md:max-w-[60dvw]"> <div className="mb-16 mx-16 md:mx-0 md:max-w-[60dvw]">
<h1 className="text-6xl md:text-9xl mb-8">Learn Languages</h1> <h1 className="text-6xl md:text-9xl mb-8">Learn Languages</h1>
<p className="text-2xl md:text-5xl">Here is a very useful website to help you learn almost every language in the world, including constructed ones.</p> <p className="text-2xl md:text-5xl">
Here is a very useful website to help you learn almost every language
in the world, including constructed ones.
</p>
</div> </div>
</div> </div>
) );
} }
interface LinkAreaProps { interface LinkAreaProps {
href: string, href: string;
name: string, name: string;
description: string, description: string;
color: string color: string;
} }
function LinkArea( function LinkArea({ href, name, description, color }: LinkAreaProps) {
{ href, name, description, color }: LinkAreaProps
) {
return ( return (
<Link href={href} <Link
href={href}
style={{ backgroundColor: color }} style={{ backgroundColor: color }}
className={`h-32 md:h-64 flex justify-center items-center`}> className={`h-32 md:h-64 flex justify-center items-center`}
>
<div className="text-white m-8"> <div className="text-white m-8">
<h1 className="text-4xl">{name}</h1> <h1 className="text-4xl">{name}</h1>
<p className="text-xl">{description}</p> <p className="text-xl">{description}</p>
@@ -36,17 +39,18 @@ function LinkArea(
function LinkGrid() { function LinkGrid() {
return ( return (
<div className="w-full grid grid-cols-1 grid-rows-6 md:grid-cols-3"> <div className="w-full grid grid-cols-1 grid-rows-6 md:grid-cols-3">
<LinkArea <LinkArea
href="/translator" href="/translator"
name="翻译器" name="翻译器"
description="翻译到任何语言并标注国际音标IPA" description="翻译到任何语言并标注国际音标IPA"
color="#a56068"></LinkArea> color="#a56068"
></LinkArea>
<LinkArea <LinkArea
href="/text-speaker" href="/text-speaker"
name="朗读器" name="朗读器"
description="识别并朗读文本,支持循环朗读、朗读速度调节" description="识别并朗读文本,支持循环朗读、朗读速度调节"
color="#578aad"></LinkArea> color="#578aad"
></LinkArea>
{/* <LinkArea {/* <LinkArea
href="/word-board" href="/word-board"
name="词墙" name="词墙"
@@ -56,19 +60,22 @@ function LinkGrid() {
href="/srt-player" href="/srt-player"
name="逐句视频播放器" name="逐句视频播放器"
description="基于SRT字幕文件逐句播放视频以模仿母语者的发音" description="基于SRT字幕文件逐句播放视频以模仿母语者的发音"
color="#3c988d"></LinkArea> color="#3c988d"
<LinkArea ></LinkArea>
<LinkArea
href="/alphabet" href="/alphabet"
name="记忆字母表" name="记忆字母表"
description="从字母表开始新语言的学习" description="从字母表开始新语言的学习"
color="#dd7486"></LinkArea> color="#dd7486"
></LinkArea>
<LinkArea <LinkArea
href="#" href="#"
name="更多功能" name="更多功能"
description="开发中,敬请期待" description="开发中,敬请期待"
color="#cab48a"></LinkArea> color="#cab48a"
></LinkArea>
</div> </div>
) );
} }
function Fortune() { function Fortune() {
@@ -97,5 +104,6 @@ export default function Home() {
<Fortune></Fortune> <Fortune></Fortune>
<Explore></Explore> <Explore></Explore>
<LinkGrid></LinkGrid> <LinkGrid></LinkGrid>
</>); </>
);
} }

View File

@@ -1,21 +1,19 @@
import Button from "@/components/Button"; import Button from "@/components/Button";
import { useRef } from "react"; import { useRef } from "react";
export default function UploadArea( export default function UploadArea({
{ setVideoUrl,
setVideoUrl, setSrtUrl,
setSrtUrl }: {
}: { setVideoUrl: (url: string | null) => void;
setVideoUrl: (url: string | null) => void; setSrtUrl: (url: string | null) => void;
setSrtUrl: (url: string | null) => void; }) {
}
) {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const uploadVideo = () => { const uploadVideo = () => {
const input = inputRef.current; const input = inputRef.current;
if (input) { if (input) {
input.setAttribute('accept', 'video/*'); input.setAttribute("accept", "video/*");
input.click(); input.click();
input.onchange = () => { input.onchange = () => {
const file = input.files?.[0]; const file = input.files?.[0];
@@ -24,11 +22,11 @@ export default function UploadArea(
} }
}; };
} }
} };
const uploadSRT = () => { const uploadSRT = () => {
const input = inputRef.current; const input = inputRef.current;
if (input) { if (input) {
input.setAttribute('accept', '.srt'); input.setAttribute("accept", ".srt");
input.click(); input.click();
input.onchange = () => { input.onchange = () => {
const file = input.files?.[0]; const file = input.files?.[0];
@@ -37,12 +35,12 @@ export default function UploadArea(
} }
}; };
} }
} };
return ( return (
<div className="w-full flex flex-col gap-2 m-2"> <div className="w-full flex flex-col gap-2 m-2">
<Button onClick={uploadVideo}></Button> <Button onClick={uploadVideo}></Button>
<Button onClick={uploadSRT}></Button> <Button onClick={uploadSRT}></Button>
<input type="file" className="hidden" ref={inputRef} /> <input type="file" className="hidden" ref={inputRef} />
</div > </div>
) );
} }

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

@@ -4,176 +4,211 @@ import Button from "@/components/Button";
import { getIndex, parseSrt, getNearistIndex } from "../subtitle"; import { getIndex, parseSrt, getNearistIndex } from "../subtitle";
type VideoPanelProps = { type VideoPanelProps = {
videoUrl: string | null; videoUrl: string | null;
srtUrl: string | null; srtUrl: string | null;
}; };
const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>(( const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>(
{ videoUrl, srtUrl }, videoRef ({ videoUrl, srtUrl }, videoRef) => {
) => {
videoRef = videoRef as React.RefObject<HTMLVideoElement>; videoRef = videoRef as React.RefObject<HTMLVideoElement>;
const [isPlaying, setIsPlaying] = useState<boolean>(false); const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [srtLength, setSrtLength] = useState<number>(0); const [srtLength, setSrtLength] = useState<number>(0);
const [progress, setProgress] = useState<number>(-1); const [progress, setProgress] = useState<number>(-1);
const [autoPause, setAutoPause] = useState<boolean>(true); const [autoPause, setAutoPause] = useState<boolean>(true);
const [spanText, setSpanText] = useState<string>(''); const [spanText, setSpanText] = useState<string>("");
const [subtitle, setSubtitle] = useState<string>(''); const [subtitle, setSubtitle] = useState<string>("");
const parsedSrtRef = useRef<{ start: number; end: number; text: string; }[] | null>(null); const parsedSrtRef = useRef<
{ start: number; end: number; text: string }[] | null
>(null);
const rafldRef = useRef<number>(0); const rafldRef = useRef<number>(0);
const ready = useRef({ const ready = useRef({
'vid': false, vid: false,
'sub': false, sub: false,
'all': function () { return this.vid && this.sub } all: function () {
return this.vid && this.sub;
},
}); });
const togglePlayPause = useCallback(() => { const togglePlayPause = useCallback(() => {
if (!videoUrl) return; if (!videoUrl) return;
const video = videoRef.current; const video = videoRef.current;
if (!video) return; if (!video) return;
if (video.paused || video.currentTime === 0) { if (video.paused || video.currentTime === 0) {
video.play(); video.play();
} else { } else {
video.pause(); video.pause();
} }
setIsPlaying(!video.paused); setIsPlaying(!video.paused);
}, [videoRef, videoUrl]); }, [videoRef, videoUrl]);
useEffect(() => { useEffect(() => {
const handleKeyDownEvent = (e: globalThis.KeyboardEvent) => { const handleKeyDownEvent = (e: globalThis.KeyboardEvent) => {
if (e.key === 'n') { if (e.key === "n") {
next(); next();
} else if (e.key === 'p') { } else if (e.key === "p") {
previous(); previous();
} else if (e.key === ' ') { } else if (e.key === " ") {
togglePlayPause(); togglePlayPause();
} else if (e.key === 'r') { } else if (e.key === "r") {
restart(); restart();
} else if (e.key === 'a') { } else if (e.key === "a") {
; handleAutoPauseToggle(); handleAutoPauseToggle();
}
} }
document.addEventListener('keydown', handleKeyDownEvent); };
return () => document.removeEventListener('keydown', handleKeyDownEvent) document.addEventListener("keydown", handleKeyDownEvent);
return () => document.removeEventListener("keydown", handleKeyDownEvent);
}); });
useEffect(() => { useEffect(() => {
const cb = () => { const cb = () => {
if (ready.current.all()) { if (ready.current.all()) {
if (!parsedSrtRef.current) { if (!parsedSrtRef.current) {
; } else if (isPlaying) {
} else if (isPlaying) { // 这里负责显示当前时间的字幕与自动暂停
// 这里负责显示当前时间的字幕与自动暂停 const srt = parsedSrtRef.current;
const srt = parsedSrtRef.current; const ct = videoRef.current?.currentTime as number;
const ct = videoRef.current?.currentTime as number; const index = getIndex(srt, ct);
const index = getIndex(srt, ct); if (index !== null) {
if (index !== null) { setSubtitle(srt[index].text);
setSubtitle(srt[index].text) if (
if (autoPause && ct >= (srt[index].end - 0.05) && ct < srt[index].end) { autoPause &&
videoRef.current!.currentTime = srt[index].start; ct >= srt[index].end - 0.05 &&
togglePlayPause(); ct < srt[index].end
} ) {
} else { videoRef.current!.currentTime = srt[index].start;
setSubtitle(''); togglePlayPause();
} }
} else { } else {
; setSubtitle("");
}
} }
rafldRef.current = requestAnimationFrame(cb); } else {
}
} }
rafldRef.current = requestAnimationFrame(cb); rafldRef.current = requestAnimationFrame(cb);
return () => { };
cancelAnimationFrame(rafldRef.current); rafldRef.current = requestAnimationFrame(cb);
} return () => {
cancelAnimationFrame(rafldRef.current);
};
}, [autoPause, isPlaying, togglePlayPause, videoRef]); }, [autoPause, isPlaying, togglePlayPause, videoRef]);
useEffect(() => { useEffect(() => {
if (videoUrl && videoRef.current) { if (videoUrl && videoRef.current) {
videoRef.current.src = videoUrl; videoRef.current.src = videoUrl;
videoRef.current.load(); videoRef.current.load();
setIsPlaying(false); setIsPlaying(false);
ready.current['vid'] = true; ready.current["vid"] = true;
} }
}, [videoRef, videoUrl]); }, [videoRef, videoUrl]);
useEffect(() => { useEffect(() => {
if (srtUrl) { if (srtUrl) {
fetch(srtUrl) fetch(srtUrl)
.then(response => response.text()) .then((response) => response.text())
.then(data => { .then((data) => {
parsedSrtRef.current = parseSrt(data); parsedSrtRef.current = parseSrt(data);
setSrtLength(parsedSrtRef.current.length); setSrtLength(parsedSrtRef.current.length);
ready.current['sub'] = true; ready.current["sub"] = true;
}); });
} }
}, [srtUrl]); }, [srtUrl]);
const timeUpdate = () => { const timeUpdate = () => {
if (!parsedSrtRef.current || !videoRef.current) return; if (!parsedSrtRef.current || !videoRef.current) return;
const index = getIndex(parsedSrtRef.current, videoRef.current.currentTime); const index = getIndex(
if (!index) return; parsedSrtRef.current,
setSpanText(`${index + 1}/${parsedSrtRef.current.length}`) videoRef.current.currentTime,
} );
if (!index) return;
setSpanText(`${index + 1}/${parsedSrtRef.current.length}`);
};
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => { const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
if (videoRef.current && parsedSrtRef.current) { if (videoRef.current && parsedSrtRef.current) {
const newProgress = parseInt(e.target.value); const newProgress = parseInt(e.target.value);
videoRef.current.currentTime = parsedSrtRef.current[newProgress]?.start || 0; videoRef.current.currentTime =
setProgress(newProgress); parsedSrtRef.current[newProgress]?.start || 0;
} setProgress(newProgress);
}
}; };
const handleAutoPauseToggle = () => { const handleAutoPauseToggle = () => {
setAutoPause(!autoPause); setAutoPause(!autoPause);
}; };
const next = () => { const next = () => {
if (!parsedSrtRef.current || !videoRef.current) return; if (!parsedSrtRef.current || !videoRef.current) return;
const i = getNearistIndex(parsedSrtRef.current, videoRef.current.currentTime); const i = getNearistIndex(
if (i != null && i + 1 < parsedSrtRef.current.length) { parsedSrtRef.current,
videoRef.current.currentTime = parsedSrtRef.current[i + 1].start; videoRef.current.currentTime,
videoRef.current.play(); );
setIsPlaying(true); if (i != null && i + 1 < parsedSrtRef.current.length) {
} videoRef.current.currentTime = parsedSrtRef.current[i + 1].start;
} videoRef.current.play();
setIsPlaying(true);
}
};
const previous = () => { const previous = () => {
if (!parsedSrtRef.current || !videoRef.current) return; if (!parsedSrtRef.current || !videoRef.current) return;
const i = getNearistIndex(parsedSrtRef.current, videoRef.current.currentTime); const i = getNearistIndex(
if (i != null && i - 1 >= 0) { parsedSrtRef.current,
videoRef.current.currentTime = parsedSrtRef.current[i - 1].start; videoRef.current.currentTime,
videoRef.current.play(); );
setIsPlaying(true); if (i != null && i - 1 >= 0) {
} videoRef.current.currentTime = parsedSrtRef.current[i - 1].start;
} videoRef.current.play();
setIsPlaying(true);
}
};
const restart = () => { const restart = () => {
if (!parsedSrtRef.current || !videoRef.current) return; if (!parsedSrtRef.current || !videoRef.current) return;
const i = getNearistIndex(parsedSrtRef.current, videoRef.current.currentTime); const i = getNearistIndex(
if (i != null && i >= 0) { parsedSrtRef.current,
videoRef.current.currentTime = parsedSrtRef.current[i].start; videoRef.current.currentTime,
videoRef.current.play(); );
setIsPlaying(true); if (i != null && i >= 0) {
} videoRef.current.currentTime = parsedSrtRef.current[i].start;
} videoRef.current.play();
setIsPlaying(true);
}
};
return ( return (
<div className="w-full flex flex-col"> <div className="w-full flex flex-col">
<video className="bg-gray-200" ref={videoRef} onTimeUpdate={timeUpdate}></video> <video
<SubtitleDisplay subtitle={subtitle}></SubtitleDisplay> className="bg-gray-200"
<div className="buttons flex mt-2 gap-2 flex-wrap"> ref={videoRef}
<Button onClick={togglePlayPause}>{isPlaying ? '暂停' : '播放'}</Button> onTimeUpdate={timeUpdate}
<Button onClick={previous}></Button> ></video>
<Button onClick={next}></Button> <SubtitleDisplay subtitle={subtitle}></SubtitleDisplay>
<Button onClick={restart}></Button> <div className="buttons flex mt-2 gap-2 flex-wrap">
<Button onClick={handleAutoPauseToggle}>{`自动暂停(${autoPause ? '是' : '否'})`}</Button> <Button onClick={togglePlayPause}>
</div> {isPlaying ? "暂停" : "播放"}
<input className="seekbar" type="range" min={0} max={srtLength} onChange={handleSeek} step={1} value={progress}></input> </Button>
<span>{spanText}</span> <Button onClick={previous}></Button>
<Button onClick={next}></Button>
<Button onClick={restart}></Button>
<Button
onClick={handleAutoPauseToggle}
>{`自动暂停(${autoPause ? "是" : "否"})`}</Button>
</div> </div>
<input
className="seekbar"
type="range"
min={0}
max={srtLength}
onChange={handleSeek}
step={1}
value={progress}
></input>
<span>{spanText}</span>
</div>
); );
}); },
);
VideoPanel.displayName = 'VideoPanel'; VideoPanel.displayName = "VideoPanel";
export default VideoPanel; export default VideoPanel;

View File

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

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

@@ -1,4 +1,4 @@
'use client'; "use client";
import { getTextSpeakerData, setTextSpeakerData } from "@/utils"; import { getTextSpeakerData, setTextSpeakerData } from "@/utils";
import { useState } from "react"; import { useState } from "react";
@@ -8,88 +8,98 @@ import IconClick from "@/components/IconClick";
import IMAGES from "@/config/images"; import IMAGES from "@/config/images";
interface TextCardProps { interface TextCardProps {
item: z.infer<typeof TextSpeakerItemSchema>; item: z.infer<typeof TextSpeakerItemSchema>;
handleUse: (item: z.infer<typeof TextSpeakerItemSchema>) => void; handleUse: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
handleDel: (item: z.infer<typeof TextSpeakerItemSchema>) => void; handleDel: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
} }
function TextCard({ function TextCard({ item, handleUse, handleDel }: TextCardProps) {
item, const onUseClick = () => {
handleUse, handleUse(item);
handleDel };
}: TextCardProps) { const onDelClick = () => {
const onUseClick = () => { handleDel(item);
handleUse(item); };
} return (
const onDelClick = () => { <div className="p-2 border-b-1 border-gray-200 rounded-2xl bg-gray-100 m-2 grid grid-cols-8">
handleDel(item); <div className="col-span-7" onClick={onUseClick}>
} <div className="max-h-26 hover:cursor-pointer text-3xl overflow-y-auto">
return ( {item.text}
<div className="p-2 border-b-1 border-gray-200 rounded-2xl bg-gray-100 m-2 grid grid-cols-8">
<div className="col-span-7" onClick={onUseClick}>
<div className="max-h-26 hover:cursor-pointer text-3xl overflow-y-auto">{item.text}</div>
<div className="max-h-16 overflow-y-auto text-xl text-gray-600 whitespace-nowrap overflow-x-auto">{item.ipa}</div>
</div>
<div className="flex justify-center items-center border-gray-300 border-l-2 m-2">
<IconClick
src={IMAGES.delete}
alt="delete"
onClick={onDelClick}
className="place-self-center"
size={42}>
</IconClick>
</div>
</div> </div>
); <div className="max-h-16 overflow-y-auto text-xl text-gray-600 whitespace-nowrap overflow-x-auto">
{item.ipa}
</div>
</div>
<div className="flex justify-center items-center border-gray-300 border-l-2 m-2">
<IconClick
src={IMAGES.delete}
alt="delete"
onClick={onDelClick}
className="place-self-center"
size={42}
></IconClick>
</div>
</div>
);
} }
interface SaveListProps { interface SaveListProps {
show?: boolean; show?: boolean;
handleUse: (item: z.infer<typeof TextSpeakerItemSchema>) => void; handleUse: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
} }
export default function SaveList({ export default function SaveList({ show = false, handleUse }: SaveListProps) {
show = false, const [data, setData] = useState(getTextSpeakerData());
handleUse const handleDel = (item: z.infer<typeof TextSpeakerItemSchema>) => {
}: SaveListProps) { const current_data = getTextSpeakerData();
const [data, setData] = useState(getTextSpeakerData()); current_data.splice(
const handleDel = (item: z.infer<typeof TextSpeakerItemSchema>) => { current_data.findIndex((v) => v.text === item.text),
const current_data = getTextSpeakerData(); 1,
current_data.splice( );
current_data.findIndex(v => v.text === item.text), 1 setTextSpeakerData(current_data);
); refresh();
setTextSpeakerData(current_data); };
refresh(); const refresh = () => {
setData(getTextSpeakerData());
};
const handleDeleteAll = () => {
const yesorno = prompt("确定删光吗?(Y/N)")?.trim();
if (yesorno && (yesorno === "Y" || yesorno === "y")) {
setTextSpeakerData([]);
refresh();
} }
const refresh = () => { };
setData(getTextSpeakerData()); if (show)
} return (
const handleDeleteAll = () => { <div
const yesorno = prompt('确定删光吗?(Y/N)')?.trim(); className="my-4 p-2 mx-4 md:mx-32 border-1 border-gray-200 rounded-2xl"
if (yesorno && (yesorno === 'Y' || yesorno === 'y')) { style={{ fontFamily: "Times New Roman, serif" }}
setTextSpeakerData([]); >
refresh(); <div className="flex flex-row justify-center gap-8 items-center">
} <IconClick
} src={IMAGES.refresh}
if (show) return ( alt="refresh"
<div className="my-4 p-2 mx-4 md:mx-32 border-1 border-gray-200 rounded-2xl" style={{ fontFamily: 'Times New Roman, serif' }}> onClick={refresh}
<div className="flex flex-row justify-center gap-8 items-center"> size={48}
<IconClick className=""
src={IMAGES.refresh} ></IconClick>
alt="refresh" <IconClick
onClick={refresh} src={IMAGES.delete}
size={48} alt="delete"
className=""></IconClick> onClick={handleDeleteAll}
<IconClick size={48}
src={IMAGES.delete} className=""
alt="delete" ></IconClick>
onClick={handleDeleteAll}
size={48}
className=""></IconClick>
</div>
<ul>
{data.map(v =>
<TextCard item={v} key={crypto.randomUUID()} handleUse={handleUse} handleDel={handleDel}></TextCard>
)}
</ul>
</div> </div>
); else return (<></>); <ul>
} {data.map((v) => (
<TextCard
item={v}
key={crypto.randomUUID()}
handleUse={handleUse}
handleDel={handleDel}
></TextCard>
))}
</ul>
</div>
);
else return <></>;
}

View File

@@ -4,7 +4,11 @@ import Button from "@/components/Button";
import IconClick from "@/components/IconClick"; import IconClick from "@/components/IconClick";
import IMAGES from "@/config/images"; import IMAGES from "@/config/images";
import { useAudioPlayer } from "@/hooks/useAudioPlayer"; import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { getTextSpeakerData, getTTSAudioUrl, setTextSpeakerData } from "@/utils"; import {
getTextSpeakerData,
getTTSAudioUrl,
setTextSpeakerData,
} from "@/utils";
import { ChangeEvent, useEffect, useRef, useState } from "react"; import { ChangeEvent, useEffect, useRef, useState } from "react";
import SaveList from "./SaveList"; import SaveList from "./SaveList";
import { TextSpeakerItemSchema } from "@/interfaces"; import { TextSpeakerItemSchema } from "@/interfaces";
@@ -13,277 +17,320 @@ import { Navbar } from "@/components/Navbar";
import { VOICES } from "@/config/locales"; import { VOICES } from "@/config/locales";
export default function TextSpeaker() { export default function TextSpeaker() {
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const [showSpeedAdjust, setShowSpeedAdjust] = useState(false); const [showSpeedAdjust, setShowSpeedAdjust] = useState(false);
const [showSaveList, setShowSaveList] = useState(false); const [showSaveList, setShowSaveList] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [ipaEnabled, setIPAEnabled] = useState(false); const [ipaEnabled, setIPAEnabled] = useState(false);
const [speed, setSpeed] = useState(1); const [speed, setSpeed] = useState(1);
const [pause, setPause] = useState(true); const [pause, setPause] = useState(true);
const [autopause, setAutopause] = useState(true); const [autopause, setAutopause] = useState(true);
const textRef = useRef(''); const textRef = useRef("");
const [locale, setLocale] = useState<string | null>(null); const [locale, setLocale] = useState<string | null>(null);
const [ipa, setIPA] = useState<string>(''); const [ipa, setIPA] = useState<string>("");
const objurlRef = useRef<string | null>(null); const objurlRef = useRef<string | null>(null);
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
const { playAudio, stopAudio, audioRef } = useAudioPlayer(); const { playAudio, stopAudio, audioRef } = useAudioPlayer();
useEffect(() => { useEffect(() => {
const audio = audioRef.current; const audio = audioRef.current;
if (!audio) return; if (!audio) return;
const handleEnded = () => { const handleEnded = () => {
if (autopause) { if (autopause) {
setPause(true); setPause(true);
} else { } else {
playAudio(objurlRef.current!); playAudio(objurlRef.current!);
} }
} };
audio.addEventListener('ended', handleEnded); audio.addEventListener("ended", handleEnded);
return () => { return () => {
audio.removeEventListener('ended', handleEnded); audio.removeEventListener("ended", handleEnded);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [audioRef, autopause]); }, [audioRef, autopause]);
const speak = async () => { const speak = async () => {
if (processing) return; if (processing) return;
setProcessing(true); setProcessing(true);
if (ipa.length === 0 && ipaEnabled && textRef.current.length !== 0) { if (ipa.length === 0 && ipaEnabled && textRef.current.length !== 0) {
const params = new URLSearchParams({ const params = new URLSearchParams({
text: textRef.current text: textRef.current,
}); });
fetch(`/api/ipa?${params}`) fetch(`/api/ipa?${params}`)
.then(res => res.json()) .then((res) => res.json())
.then(data => { .then((data) => {
setIPA(data.ipa); setIPA(data.ipa);
}).catch(e => { })
console.error(e); .catch((e) => {
setIPA(''); console.error(e);
}) setIPA("");
} });
}
if (pause) { if (pause) {
// 如果没在读 // 如果没在读
if (textRef.current.length === 0) { if (textRef.current.length === 0) {
// 没文本咋读 // 没文本咋读
} else { } else {
setPause(false); setPause(false);
if (objurlRef.current) { if (objurlRef.current) {
// 之前有播放 // 之前有播放
playAudio(objurlRef.current); playAudio(objurlRef.current);
} else {
// 第一次播放
try {
let theLocale = locale;
if (!theLocale) {
console.log('downloading text info');
const params = new URLSearchParams({
text: textRef.current.slice(0, 30)
});
const textinfo = await (await fetch(`/api/locale?${params}`)).json();
setLocale(textinfo.locale);
theLocale = textinfo.locale as string;
}
const voice = VOICES.find(v => v.locale.startsWith(theLocale));
if (!voice) throw 'Voice not found.';
objurlRef.current = await getTTSAudioUrl(
textRef.current,
voice.short_name,
(() => {
if (speed === 1) return {};
else if (speed < 1) return {
rate: `-${100 - speed * 100}%`
}; else return {
rate: `+${speed * 100 - 100}%`
};
})()
);
playAudio(objurlRef.current);
} catch (e) {
console.error(e);
setPause(true);
setLocale(null);
setProcessing(false);
}
}
}
} else { } else {
// 如果在读就暂停 // 第一次播放
setPause(true); try {
stopAudio();
}
setProcessing(false);
}
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
textRef.current = e.target.value.trim();
setLocale(null);
setIPA('');
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
objurlRef.current = null;
stopAudio();
setPause(true);
}
const letMeSetSpeed = (new_speed: number) => {
return () => {
setSpeed(new_speed);
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
objurlRef.current = null;
stopAudio();
setPause(true);
}
}
const handleUseItem = (item: z.infer<typeof TextSpeakerItemSchema>) => {
if (textareaRef.current) textareaRef.current.value = item.text;
textRef.current = item.text;
setLocale(item.locale);
setIPA(item.ipa || '');
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
objurlRef.current = null;
stopAudio();
setPause(true);
}
const save = async () => {
if (saving) return;
if (textRef.current.length === 0) return;
setSaving(true);
try {
let theLocale = locale; let theLocale = locale;
if (!theLocale) { if (!theLocale) {
console.log('downloading text info'); console.log("downloading text info");
const params = new URLSearchParams({ const params = new URLSearchParams({
text: textRef.current.slice(0, 30) text: textRef.current.slice(0, 30),
}); });
const textinfo = await (await fetch(`/api/locale?${params}`)).json(); const textinfo = await (
setLocale(textinfo.locale); await fetch(`/api/locale?${params}`)
theLocale = textinfo.locale as string; ).json();
setLocale(textinfo.locale);
theLocale = textinfo.locale as string;
} }
let theIPA = ipa; const voice = VOICES.find((v) => v.locale.startsWith(theLocale));
if (ipa.length === 0 && ipaEnabled) { if (!voice) throw "Voice not found.";
const params = new URLSearchParams({
text: textRef.current
});
const tmp = await (await fetch(`/api/ipa?${params}`)).json();
setIPA(tmp.ipa);
theIPA = tmp.ipa;
}
const save = getTextSpeakerData(); objurlRef.current = await getTTSAudioUrl(
const oldIndex = save.findIndex(v => v.text === textRef.current); textRef.current,
if (oldIndex !== -1) { voice.short_name,
const oldItem = save[oldIndex]; (() => {
if (theIPA) { if (speed === 1) return {};
if ((!oldItem.ipa || (oldItem.ipa !== theIPA))) { else if (speed < 1)
oldItem.ipa = theIPA; return {
setTextSpeakerData(save); rate: `-${100 - speed * 100}%`,
} };
} else
} else if (theIPA.length === 0) { return {
save.push({ rate: `+${speed * 100 - 100}%`,
text: textRef.current, };
locale: theLocale })(),
}); );
} else { playAudio(objurlRef.current);
save.push({ } catch (e) {
text: textRef.current,
locale: theLocale,
ipa: theIPA
});
}
setTextSpeakerData(save);
} catch (e) {
console.error(e); console.error(e);
setPause(true);
setLocale(null); setLocale(null);
} finally {
setSaving(false); setProcessing(false);
}
} }
}
} else {
// 如果在读就暂停
setPause(true);
stopAudio();
} }
return (<> setProcessing(false);
<Navbar></Navbar> };
<div className="my-4 p-4 mx-4 md:mx-32 border-1 border-gray-200 rounded-2xl" style={{ fontFamily: 'Times New Roman, serif' }}>
<textarea className="text-2xl resize-none focus:outline-0 min-h-64 w-full border-gray-200 border-b" const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
onChange={handleInputChange} textRef.current = e.target.value.trim();
ref={textareaRef}> setLocale(null);
</textarea> setIPA("");
{ if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
ipa.length !== 0 && (<div className="overflow-auto text-gray-600 h-18 border-gray-200 border-b"> objurlRef.current = null;
{ipa} stopAudio();
</div>) || (<div className="h-18"></div>) setPause(true);
} };
<div className="mt-8 relative w-full flex flex-row flex-wrap gap-2 justify-center items-center">
{showSpeedAdjust && ( const letMeSetSpeed = (new_speed: number) => {
<div className="bg-white p-6 rounded-2xl border-gray-200 border-2 shadow-2xl absolute left-1/2 -translate-x-1/2 -translate-y-full -top-4 flex flex-row flex-wrap gap-2 justify-center items-center"> return () => {
<IconClick size={45} onClick={letMeSetSpeed(0.5)} setSpeed(new_speed);
src={IMAGES.speed_0_5x} if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
alt="0.5x" objurlRef.current = null;
className={speed === 0.5 ? 'bg-gray-200' : ''} stopAudio();
></IconClick> setPause(true);
<IconClick size={45} onClick={letMeSetSpeed(0.7)} };
src={IMAGES.speed_0_7x} };
alt="0.7x"
className={speed === 0.7 ? 'bg-gray-200' : ''} const handleUseItem = (item: z.infer<typeof TextSpeakerItemSchema>) => {
></IconClick> if (textareaRef.current) textareaRef.current.value = item.text;
<IconClick size={45} onClick={letMeSetSpeed(1)} textRef.current = item.text;
src={IMAGES.speed_1x} setLocale(item.locale);
alt="1x" setIPA(item.ipa || "");
className={speed === 1 ? 'bg-gray-200' : ''} if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
></IconClick> objurlRef.current = null;
<IconClick size={45} onClick={letMeSetSpeed(1.2)} stopAudio();
src={IMAGES.speed_1_2_x} setPause(true);
alt="1.2x" };
className={speed === 1.2 ? 'bg-gray-200' : ''}
></IconClick> const save = async () => {
<IconClick size={45} onClick={letMeSetSpeed(1.5)} if (saving) return;
src={IMAGES.speed_1_5x} if (textRef.current.length === 0) return;
alt="1.5x"
className={speed === 1.5 ? 'bg-gray-200' : ''} setSaving(true);
></IconClick>
</div>)} try {
<IconClick size={45} onClick={speak} src={ let theLocale = locale;
pause ? IMAGES.play_arrow : IMAGES.pause if (!theLocale) {
} alt="playorpause" className={`${processing ? 'bg-gray-200' : ''}`}></IconClick> console.log("downloading text info");
<IconClick size={45} onClick={() => { const params = new URLSearchParams({
setAutopause(!autopause); if (objurlRef) { stopAudio(); } setPause(true); text: textRef.current.slice(0, 30),
}} src={ });
autopause ? IMAGES.autoplay : IMAGES.autopause const textinfo = await (await fetch(`/api/locale?${params}`)).json();
} alt="autoplayorpause" setLocale(textinfo.locale);
></IconClick> theLocale = textinfo.locale as string;
<IconClick size={45} onClick={() => setShowSpeedAdjust(!showSpeedAdjust)} }
src={IMAGES.speed}
alt="speed" let theIPA = ipa;
className={`${showSpeedAdjust ? 'bg-gray-200' : ''}`}></IconClick> if (ipa.length === 0 && ipaEnabled) {
<IconClick size={45} onClick={save} const params = new URLSearchParams({
src={IMAGES.save} text: textRef.current,
alt="save" });
className={`${saving ? 'bg-gray-200' : ''}`}></IconClick> const tmp = await (await fetch(`/api/ipa?${params}`)).json();
<div className="w-full flex flex-row flex-wrap gap-2 justify-center items-center"> setIPA(tmp.ipa);
<Button theIPA = tmp.ipa;
selected={ipaEnabled} }
onClick={() => setIPAEnabled(!ipaEnabled)}>
IPA const save = getTextSpeakerData();
</Button> const oldIndex = save.findIndex((v) => v.text === textRef.current);
<Button if (oldIndex !== -1) {
onClick={() => { setShowSaveList(!showSaveList) }} const oldItem = save[oldIndex];
selected={showSaveList}> if (theIPA) {
if (!oldItem.ipa || oldItem.ipa !== theIPA) {
</Button> oldItem.ipa = theIPA;
</div> setTextSpeakerData(save);
}
}
} else if (theIPA.length === 0) {
save.push({
text: textRef.current,
locale: theLocale,
});
} else {
save.push({
text: textRef.current,
locale: theLocale,
ipa: theIPA,
});
}
setTextSpeakerData(save);
} catch (e) {
console.error(e);
setLocale(null);
} finally {
setSaving(false);
}
};
return (
<>
<Navbar></Navbar>
<div
className="my-4 p-4 mx-4 md:mx-32 border-1 border-gray-200 rounded-2xl"
style={{ fontFamily: "Times New Roman, serif" }}
>
<textarea
className="text-2xl resize-none focus:outline-0 min-h-64 w-full border-gray-200 border-b"
onChange={handleInputChange}
ref={textareaRef}
></textarea>
{(ipa.length !== 0 && (
<div className="overflow-auto text-gray-600 h-18 border-gray-200 border-b">
{ipa}
</div>
)) || <div className="h-18"></div>}
<div className="mt-8 relative w-full flex flex-row flex-wrap gap-2 justify-center items-center">
{showSpeedAdjust && (
<div className="bg-white p-6 rounded-2xl border-gray-200 border-2 shadow-2xl absolute left-1/2 -translate-x-1/2 -translate-y-full -top-4 flex flex-row flex-wrap gap-2 justify-center items-center">
<IconClick
size={45}
onClick={letMeSetSpeed(0.5)}
src={IMAGES.speed_0_5x}
alt="0.5x"
className={speed === 0.5 ? "bg-gray-200" : ""}
></IconClick>
<IconClick
size={45}
onClick={letMeSetSpeed(0.7)}
src={IMAGES.speed_0_7x}
alt="0.7x"
className={speed === 0.7 ? "bg-gray-200" : ""}
></IconClick>
<IconClick
size={45}
onClick={letMeSetSpeed(1)}
src={IMAGES.speed_1x}
alt="1x"
className={speed === 1 ? "bg-gray-200" : ""}
></IconClick>
<IconClick
size={45}
onClick={letMeSetSpeed(1.2)}
src={IMAGES.speed_1_2_x}
alt="1.2x"
className={speed === 1.2 ? "bg-gray-200" : ""}
></IconClick>
<IconClick
size={45}
onClick={letMeSetSpeed(1.5)}
src={IMAGES.speed_1_5x}
alt="1.5x"
className={speed === 1.5 ? "bg-gray-200" : ""}
></IconClick>
</div> </div>
)}
<IconClick
size={45}
onClick={speak}
src={pause ? IMAGES.play_arrow : IMAGES.pause}
alt="playorpause"
className={`${processing ? "bg-gray-200" : ""}`}
></IconClick>
<IconClick
size={45}
onClick={() => {
setAutopause(!autopause);
if (objurlRef) {
stopAudio();
}
setPause(true);
}}
src={autopause ? IMAGES.autoplay : IMAGES.autopause}
alt="autoplayorpause"
></IconClick>
<IconClick
size={45}
onClick={() => setShowSpeedAdjust(!showSpeedAdjust)}
src={IMAGES.speed}
alt="speed"
className={`${showSpeedAdjust ? "bg-gray-200" : ""}`}
></IconClick>
<IconClick
size={45}
onClick={save}
src={IMAGES.save}
alt="save"
className={`${saving ? "bg-gray-200" : ""}`}
></IconClick>
<div className="w-full flex flex-row flex-wrap gap-2 justify-center items-center">
<Button
selected={ipaEnabled}
onClick={() => setIPAEnabled(!ipaEnabled)}
>
IPA
</Button>
<Button
onClick={() => {
setShowSaveList(!showSaveList);
}}
selected={showSaveList}
>
</Button>
</div>
</div> </div>
<SaveList show={showSaveList} handleUse={handleUseItem}></SaveList> </div>
</>); <SaveList show={showSaveList} handleUse={handleUseItem}></SaveList>
} </>
);
}

View File

@@ -11,25 +11,25 @@ import { VOICES } from "@/config/locales";
export default function Translator() { export default function Translator() {
const [ipaEnabled, setIPAEnabled] = useState(true); const [ipaEnabled, setIPAEnabled] = useState(true);
const [targetLang, setTargetLang] = useState('Chinese'); const [targetLang, setTargetLang] = useState("Chinese");
const [sourceText, setSourceText] = useState(''); const [sourceText, setSourceText] = useState("");
const [targetText, setTargetText] = useState(''); const [targetText, setTargetText] = useState("");
const [sourceIPA, setSourceIPA] = useState(''); const [sourceIPA, setSourceIPA] = useState("");
const [targetIPA, setTargetIPA] = useState(''); const [targetIPA, setTargetIPA] = useState("");
const [sourceLocale, setSourceLocale] = useState<string | null>(null); const [sourceLocale, setSourceLocale] = useState<string | null>(null);
const [targetLocale, setTargetLocale] = useState<string | null>(null); const [targetLocale, setTargetLocale] = useState<string | null>(null);
const [translating, setTranslating] = useState(false); const [translating, setTranslating] = useState(false);
const { playAudio } = useAudioPlayer(); const { playAudio } = useAudioPlayer();
const tl = ['Chinese', 'English', 'Italian']; const tl = ["Chinese", "English", "Italian"];
const inputLanguage = () => { const inputLanguage = () => {
const lang = prompt('Input a language.')?.trim(); const lang = prompt("Input a language.")?.trim();
if (lang) { if (lang) {
setTargetLang(lang); setTargetLang(lang);
} }
} };
const translate = () => { const translate = () => {
if (translating) return; if (translating) return;
@@ -37,91 +37,96 @@ export default function Translator() {
setTranslating(true); setTranslating(true);
setTargetText(''); setTargetText("");
setSourceLocale(null); setSourceLocale(null);
setTargetLocale(null); setTargetLocale(null);
setSourceIPA(''); setSourceIPA("");
setTargetIPA(''); setTargetIPA("");
const params = new URLSearchParams({ const params = new URLSearchParams({
text: sourceText, text: sourceText,
target: targetLang target: targetLang,
}) });
fetch(`/api/translate?${params}`) fetch(`/api/translate?${params}`)
.then(res => res.json()) .then((res) => res.json())
.then(obj => { .then((obj) => {
setSourceLocale(obj.source_locale); setSourceLocale(obj.source_locale);
setTargetLocale(obj.target_locale); setTargetLocale(obj.target_locale);
setTargetText(obj.target_text); setTargetText(obj.target_text);
if (ipaEnabled) { if (ipaEnabled) {
const params = new URLSearchParams({ const params = new URLSearchParams({
text: sourceText text: sourceText,
}); });
fetch(`/api/ipa?${params}`) fetch(`/api/ipa?${params}`)
.then(res => res.json()) .then((res) => res.json())
.then(data => { .then((data) => {
setSourceIPA(data.ipa); setSourceIPA(data.ipa);
}).catch(e => {
console.error(e);
setSourceIPA('');
}) })
.catch((e) => {
console.error(e);
setSourceIPA("");
});
const params2 = new URLSearchParams({ const params2 = new URLSearchParams({
text: obj.target_text text: obj.target_text,
}); });
fetch(`/api/ipa?${params2}`) fetch(`/api/ipa?${params2}`)
.then(res => res.json()) .then((res) => res.json())
.then(data => { .then((data) => {
setTargetIPA(data.ipa); setTargetIPA(data.ipa);
}).catch(e => {
console.error(e);
setTargetIPA('');
}) })
.catch((e) => {
console.error(e);
setTargetIPA("");
});
} }
}).catch(r => { })
.catch((r) => {
console.error(r); console.error(r);
setSourceLocale(''); setSourceLocale("");
setTargetLocale(''); setTargetLocale("");
setTargetText(''); setTargetText("");
}).finally(() => setTranslating(false)); })
} .finally(() => setTranslating(false));
};
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => { const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
setSourceText(e.target.value.trim()); setSourceText(e.target.value.trim());
setTargetText(''); setTargetText("");
setSourceLocale(null); setSourceLocale(null);
setTargetLocale(null); setTargetLocale(null);
setSourceIPA(''); setSourceIPA("");
setTargetIPA(''); setTargetIPA("");
} };
const readSource = async () => { const readSource = async () => {
if (sourceText.length === 0) return; if (sourceText.length === 0) return;
if (sourceIPA.length === 0 && ipaEnabled) { if (sourceIPA.length === 0 && ipaEnabled) {
const params = new URLSearchParams({ const params = new URLSearchParams({
text: sourceText text: sourceText,
}); });
fetch(`/api/ipa?${params}`) fetch(`/api/ipa?${params}`)
.then(res => res.json()) .then((res) => res.json())
.then(data => { .then((data) => {
setSourceIPA(data.ipa); setSourceIPA(data.ipa);
}).catch(e => {
console.error(e);
setSourceIPA('');
}) })
.catch((e) => {
console.error(e);
setSourceIPA("");
});
} }
if (!sourceLocale) { if (!sourceLocale) {
try { try {
const params = new URLSearchParams({ const params = new URLSearchParams({
text: sourceText.slice(0, 30) text: sourceText.slice(0, 30),
}); });
const res = await fetch(`/api/locale?${params}`); const res = await fetch(`/api/locale?${params}`);
const info = await res.json(); const info = await res.json();
setSourceLocale(info.locale); setSourceLocale(info.locale);
const voice = VOICES.find(v => v.locale.startsWith(info.locale)); const voice = VOICES.find((v) => v.locale.startsWith(info.locale));
if (!voice) { if (!voice) {
return; return;
} }
@@ -135,7 +140,7 @@ export default function Translator() {
return; return;
} }
} else { } else {
const voice = VOICES.find(v => v.locale.startsWith(sourceLocale!)); const voice = VOICES.find((v) => v.locale.startsWith(sourceLocale!));
if (!voice) { if (!voice) {
return; return;
} }
@@ -144,32 +149,33 @@ export default function Translator() {
await playAudio(url); await playAudio(url);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
} };
const readTarget = async () => { const readTarget = async () => {
if (targetText.length === 0) return; if (targetText.length === 0) return;
if (targetIPA.length === 0 && ipaEnabled) { if (targetIPA.length === 0 && ipaEnabled) {
const params = new URLSearchParams({ const params = new URLSearchParams({
text: targetText text: targetText,
}); });
fetch(`/api/ipa?${params}`) fetch(`/api/ipa?${params}`)
.then(res => res.json()) .then((res) => res.json())
.then(data => { .then((data) => {
setTargetIPA(data.ipa); setTargetIPA(data.ipa);
}).catch(e => {
console.error(e);
setTargetIPA('');
}) })
.catch((e) => {
console.error(e);
setTargetIPA("");
});
} }
const voice = VOICES.find(v => v.locale.startsWith(targetLocale!)); const voice = VOICES.find((v) => v.locale.startsWith(targetLocale!));
if (!voice) return; if (!voice) return;
const url = await getTTSAudioUrl(targetText, voice.short_name); const url = await getTTSAudioUrl(targetText, voice.short_name);
await playAudio(url); await playAudio(url);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} };
return ( return (
<> <>
@@ -177,52 +183,100 @@ export default function Translator() {
<div className="w-screen flex flex-col md:flex-row md:justify-between gap-2 p-2"> <div className="w-screen flex flex-col md:flex-row md:justify-between gap-2 p-2">
<div className="card1 w-full md:w-1/2 flex flex-col-reverse gap-2"> <div className="card1 w-full md:w-1/2 flex flex-col-reverse gap-2">
<div className="textarea1 border-1 border-gray-200 rounded-2xl w-full h-64 p-2"> <div className="textarea1 border-1 border-gray-200 rounded-2xl w-full h-64 p-2">
<textarea onChange={handleInputChange} className="resize-none h-8/12 w-full focus:outline-0"></textarea> <textarea
onChange={handleInputChange}
className="resize-none h-8/12 w-full focus:outline-0"
></textarea>
<div className="ipa w-full h-2/12 overflow-auto text-gray-600"> <div className="ipa w-full h-2/12 overflow-auto text-gray-600">
{sourceIPA} {sourceIPA}
</div> </div>
<div className="h-2/12 w-full flex justify-end items-center"> <div className="h-2/12 w-full flex justify-end items-center">
<IconClick onClick={async () => { <IconClick
if (sourceText.length !== 0) onClick={async () => {
await navigator.clipboard.writeText(sourceText); if (sourceText.length !== 0)
}} src={IMAGES.copy_all} alt="copy"></IconClick> await navigator.clipboard.writeText(sourceText);
<IconClick onClick={readSource} src={IMAGES.play_arrow} alt="play"></IconClick> }}
src={IMAGES.copy_all}
alt="copy"
></IconClick>
<IconClick
onClick={readSource}
src={IMAGES.play_arrow}
alt="play"
></IconClick>
</div> </div>
</div> </div>
<div className="option1 w-full flex flex-row justify-between items-center"> <div className="option1 w-full flex flex-row justify-between items-center">
<span>detect language</span> <span>detect language</span>
<Button selected={ipaEnabled} onClick={() => setIPAEnabled(!ipaEnabled)}>generate ipa</Button> <Button
selected={ipaEnabled}
onClick={() => setIPAEnabled(!ipaEnabled)}
>
generate ipa
</Button>
</div> </div>
</div> </div>
<div className="card2 w-full md:w-1/2 flex flex-col-reverse gap-2"> <div className="card2 w-full md:w-1/2 flex flex-col-reverse gap-2">
<div className="textarea2 bg-gray-100 rounded-2xl w-full h-64 p-2"> <div className="textarea2 bg-gray-100 rounded-2xl w-full h-64 p-2">
<div className="h-8/12 w-full">{ <div className="h-8/12 w-full">{targetText}</div>
targetText
}</div>
<div className="ipa w-full h-2/12 overflow-auto text-gray-600"> <div className="ipa w-full h-2/12 overflow-auto text-gray-600">
{targetIPA} {targetIPA}
</div> </div>
<div className="h-2/12 w-full flex justify-end items-center"> <div className="h-2/12 w-full flex justify-end items-center">
<IconClick onClick={async () => { <IconClick
if (targetText.length !== 0) onClick={async () => {
await navigator.clipboard.writeText(targetText); if (targetText.length !== 0)
}} src={IMAGES.copy_all} alt="copy"></IconClick> await navigator.clipboard.writeText(targetText);
<IconClick onClick={readTarget} src={IMAGES.play_arrow} alt="play"></IconClick> }}
src={IMAGES.copy_all}
alt="copy"
></IconClick>
<IconClick
onClick={readTarget}
src={IMAGES.play_arrow}
alt="play"
></IconClick>
</div> </div>
</div> </div>
<div className="option2 w-full flex gap-1 items-center flex-wrap"> <div className="option2 w-full flex gap-1 items-center flex-wrap">
<span>translate into</span> <span>translate into</span>
<Button onClick={() => { setTargetLang('Chinese') }} selected={targetLang === 'Chinese'}>Chinese</Button> <Button
<Button onClick={() => { setTargetLang('English') }} selected={targetLang === 'English'}>English</Button> onClick={() => {
<Button onClick={() => { setTargetLang('Italian') }} selected={targetLang === 'Italian'}>Italian</Button> setTargetLang("Chinese");
<Button onClick={inputLanguage} selected={!(tl.includes(targetLang))}>{'Other' + (tl.includes(targetLang) ? '' : ': ' + targetLang)}</Button> }}
selected={targetLang === "Chinese"}
>
Chinese
</Button>
<Button
onClick={() => {
setTargetLang("English");
}}
selected={targetLang === "English"}
>
English
</Button>
<Button
onClick={() => {
setTargetLang("Italian");
}}
selected={targetLang === "Italian"}
>
Italian
</Button>
<Button onClick={inputLanguage} selected={!tl.includes(targetLang)}>
{"Other" + (tl.includes(targetLang) ? "" : ": " + targetLang)}
</Button>
</div> </div>
</div> </div>
</div> </div>
<div className="button-area w-screen flex justify-center items-center"> <div className="button-area w-screen flex justify-center items-center">
<button onClick={translate} className={`duration-150 ease-in text-xl font-extrabold border rounded-4xl p-3 border-gray-200 h-16 ${translating ? 'bg-gray-200' : 'bg-white hover:bg-gray-200 hover:cursor-pointer'}`}> <button
{translating ? 'translating...' : 'translate'} onClick={translate}
className={`duration-150 ease-in text-xl font-extrabold border rounded-4xl p-3 border-gray-200 h-16 ${translating ? "bg-gray-200" : "bg-white hover:bg-gray-200 hover:cursor-pointer"}`}
>
{translating ? "translating..." : "translate"}
</button> </button>
</div> </div>
</> </>

View File

@@ -1,46 +1,66 @@
'use client'; "use client";
import { BOARD_WIDTH, TEXT_WIDTH, BOARD_HEIGHT, TEXT_SIZE } from "@/config/word-board-config"; import {
BOARD_WIDTH,
TEXT_WIDTH,
BOARD_HEIGHT,
TEXT_SIZE,
} from "@/config/word-board-config";
import { Word } from "@/interfaces"; import { Word } from "@/interfaces";
import { Dispatch, SetStateAction } from "react"; import { Dispatch, SetStateAction } from "react";
export default function TheBoard( export default function TheBoard({
{ words, selectWord }: { words,
words: [ selectWord,
{ }: {
word: string, words: [
x: number, {
y: number word: string;
} x: number;
], y: number;
setWords: Dispatch<SetStateAction<Word[]>>, },
selectWord: (word: string) => void ];
} setWords: Dispatch<SetStateAction<Word[]>>;
) { selectWord: (word: string) => void;
function DraggableWord({ word }: { word: Word }) { }) {
return (<span function DraggableWord({ word }: { word: Word }) {
style={{
left: `${Math.floor(word.x * (BOARD_WIDTH - TEXT_WIDTH * word.word.length))}px`,
top: `${Math.floor(word.y * (BOARD_HEIGHT - TEXT_SIZE))}px`,
fontSize: `${TEXT_SIZE}px`
}}
className="select-none cursor-pointer absolute code-block border-amber-100 border-1"
// onClick={inspect(word.word)}>{word.word}</span>))
onClick={() => { selectWord(word.word); }}>{word.word}</span>);
}
return ( return (
<div style={{ <span
width: `${BOARD_WIDTH}px`, style={{
height: `${BOARD_HEIGHT}px` left: `${Math.floor(word.x * (BOARD_WIDTH - TEXT_WIDTH * word.word.length))}px`,
}} className="relative rounded bg-white"> top: `${Math.floor(word.y * (BOARD_HEIGHT - TEXT_SIZE))}px`,
{words.map( fontSize: `${TEXT_SIZE}px`,
(v: { }}
word: string, className="select-none cursor-pointer absolute code-block border-amber-100 border-1"
x: number, // onClick={inspect(word.word)}>{word.word}</span>))
y: number onClick={() => {
}, i: number) => { selectWord(word.word);
return (<DraggableWord word={v} key={i}></DraggableWord>) }}
})} >
</div> {word.word}
) </span>
} );
}
return (
<div
style={{
width: `${BOARD_WIDTH}px`,
height: `${BOARD_HEIGHT}px`,
}}
className="relative rounded bg-white"
>
{words.map(
(
v: {
word: string;
x: number;
y: number;
},
i: number,
) => {
return <DraggableWord word={v} key={i}></DraggableWord>;
},
)}
</div>
);
}

View File

@@ -1,135 +1,141 @@
'use client'; "use client";
import TheBoard from "@/app/word-board/TheBoard"; import TheBoard from "@/app/word-board/TheBoard";
import Button from "../../components/Button"; import Button from "../../components/Button";
import { KeyboardEvent, useRef, useState } from "react"; import { KeyboardEvent, useRef, useState } from "react";
import { Word } from "@/interfaces"; import { Word } from "@/interfaces";
import { BOARD_WIDTH, TEXT_WIDTH, BOARD_HEIGHT, TEXT_SIZE } from "@/config/word-board-config"; import {
BOARD_WIDTH,
TEXT_WIDTH,
BOARD_HEIGHT,
TEXT_SIZE,
} from "@/config/word-board-config";
import { inspect } from "@/utils"; import { inspect } from "@/utils";
import { Navbar } from "@/components/Navbar"; import { Navbar } from "@/components/Navbar";
export default function WordBoard() { export default function WordBoard() {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const inputFileRef = useRef<HTMLInputElement>(null); const inputFileRef = useRef<HTMLInputElement>(null);
const initialWords = const initialWords = [
[ // 'apple',
// 'apple', // 'banana',
// 'banana', // 'cannon',
// 'cannon', // 'desktop',
// 'desktop', // 'kernel',
// 'kernel', // 'system',
// 'system', // 'programming',
// 'programming', // 'owe'
// 'owe' ] as Array<string>;
] as Array<string>;
const [words, setWords] = useState( const [words, setWords] = useState(
initialWords.map((v: string) => ({ initialWords.map((v: string) => ({
'word': v, word: v,
'x': Math.random(), x: Math.random(),
'y': Math.random() y: Math.random(),
})) })),
); );
const generateNewWord = (word: string) => { const generateNewWord = (word: string) => {
const isOK = (w: Word) => { const isOK = (w: Word) => {
if (words.length === 0) return true; if (words.length === 0) return true;
const tf = (ww: Word) => ({ const tf = (ww: Word) =>
word: ww.word, ({
x: Math.floor(ww.x * (BOARD_WIDTH - TEXT_WIDTH * ww.word.length)), word: ww.word,
y: Math.floor(ww.y * (BOARD_HEIGHT - TEXT_SIZE)) x: Math.floor(ww.x * (BOARD_WIDTH - TEXT_WIDTH * ww.word.length)),
} as Word); y: Math.floor(ww.y * (BOARD_HEIGHT - TEXT_SIZE)),
}) as Word;
const tfd_words = words.map(tf); const tfd_words = words.map(tf);
const tfd_w = tf(w); const tfd_w = tf(w);
for (const www of tfd_words) { for (const www of tfd_words) {
const p1 = { const p1 = {
x: (www.x + www.x + TEXT_WIDTH * www.word.length) / 2, x: (www.x + www.x + TEXT_WIDTH * www.word.length) / 2,
y: (www.y + www.y + TEXT_SIZE) / 2 y: (www.y + www.y + TEXT_SIZE) / 2,
} };
const p2 = { const p2 = {
x: (tfd_w.x + tfd_w.x + TEXT_WIDTH * tfd_w.word.length) / 2, x: (tfd_w.x + tfd_w.x + TEXT_WIDTH * tfd_w.word.length) / 2,
y: (tfd_w.y + tfd_w.y + TEXT_SIZE) / 2 y: (tfd_w.y + tfd_w.y + TEXT_SIZE) / 2,
} };
if ( if (
Math.abs(p1.x - p2.x) < (TEXT_WIDTH * (www.word.length + tfd_w.word.length)) / 2 && Math.abs(p1.x - p2.x) <
(TEXT_WIDTH * (www.word.length + tfd_w.word.length)) / 2 &&
Math.abs(p1.y - p2.y) < TEXT_SIZE Math.abs(p1.y - p2.y) < TEXT_SIZE
) { ) {
return false; return false;
} }
} }
return true; return true;
} };
let new_word; let new_word;
let count = 0; let count = 0;
do { do {
new_word = { new_word = {
word: word, word: word,
x: Math.random(), x: Math.random(),
y: Math.random() y: Math.random(),
}; };
if (++count > 1000) return null; if (++count > 1000) return null;
} while (!isOK(new_word)); } while (!isOK(new_word));
return new_word as Word; return new_word as Word;
} };
const insertWord = () => { const insertWord = () => {
if (!inputRef.current) return; if (!inputRef.current) return;
const word = inputRef.current.value.trim(); const word = inputRef.current.value.trim();
if (word === '') return; if (word === "") return;
const new_word = generateNewWord(word); const new_word = generateNewWord(word);
if (!new_word) return; if (!new_word) return;
setWords([...words, new_word]); setWords([...words, new_word]);
inputRef.current.value = ''; inputRef.current.value = "";
} };
const deleteWord = () => { const deleteWord = () => {
if (!inputRef.current) return; if (!inputRef.current) return;
const word = inputRef.current.value.trim(); const word = inputRef.current.value.trim();
if (word === '') return; if (word === "") return;
setWords(words.filter((v) => v.word !== word)); setWords(words.filter((v) => v.word !== word));
inputRef.current.value = ''; inputRef.current.value = "";
}; };
const importWords = () => { const importWords = () => {
inputFileRef.current?.click(); inputFileRef.current?.click();
} };
const exportWords = () => { const exportWords = () => {
const blob = new Blob([JSON.stringify(words)], { const blob = new Blob([JSON.stringify(words)], {
type: 'application/json' type: "application/json",
}); });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = `${Date.now()}.json`; a.download = `${Date.now()}.json`;
a.style.display = 'none'; a.style.display = "none";
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} };
const handleFileChange = () => { const handleFileChange = () => {
const files = inputFileRef.current?.files; const files = inputFileRef.current?.files;
if (files && files.length > 0) { if (files && files.length > 0) {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
if (reader.result && typeof reader.result === 'string') if (reader.result && typeof reader.result === "string")
setWords(JSON.parse(reader.result) as [Word]); setWords(JSON.parse(reader.result) as [Word]);
} };
reader.readAsText(files[0]); reader.readAsText(files[0]);
} }
} };
const deleteAll = () => { const deleteAll = () => {
setWords([] as Array<Word>); setWords([] as Array<Word>);
} };
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => { const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
// e.preventDefault(); // e.preventDefault();
if (e.key === 'Enter') { if (e.key === "Enter") {
insertWord(); insertWord();
} }
} };
const selectWord = (word: string) => { const selectWord = (word: string) => {
if (!inputRef.current) return; if (!inputRef.current) return;
inputRef.current.value = word; inputRef.current.value = word;
} };
const searchWord = () => { const searchWord = () => {
if (!inputRef.current) return; if (!inputRef.current) return;
const word = inputRef.current.value.trim(); const word = inputRef.current.value.trim();
if (word === '') return; if (word === "") return;
inspect(word)(); inspect(word)();
inputRef.current.value = ''; inputRef.current.value = "";
} };
// const readWordAloud = () => { // const readWordAloud = () => {
// playFromUrl('https://fanyi.baidu.com/gettts?lan=uk&text=disclose&spd=3') // playFromUrl('https://fanyi.baidu.com/gettts?lan=uk&text=disclose&spd=3')
// return; // return;
@@ -143,10 +149,22 @@ export default function WordBoard() {
<> <>
<Navbar></Navbar> <Navbar></Navbar>
<div className="flex w-screen h-screen justify-center items-center"> <div className="flex w-screen h-screen justify-center items-center">
<div onKeyDown={handleKeyDown} className="p-5 bg-gray-200 rounded shadow-2xl"> <div
<TheBoard selectWord={selectWord} words={words as [Word]} setWords={setWords} /> onKeyDown={handleKeyDown}
className="p-5 bg-gray-200 rounded shadow-2xl"
>
<TheBoard
selectWord={selectWord}
words={words as [Word]}
setWords={setWords}
/>
<div className="flex justify-center rounded mt-3 gap-1"> <div className="flex justify-center rounded mt-3 gap-1">
<input ref={inputRef} placeholder="word to operate" type="text" className="focus:outline-none border-b-2 border-black" /> <input
ref={inputRef}
placeholder="word to operate"
type="text"
className="focus:outline-none border-b-2 border-black"
/>
<Button onClick={insertWord}></Button> <Button onClick={insertWord}></Button>
<Button onClick={deleteWord}></Button> <Button onClick={deleteWord}></Button>
<Button onClick={searchWord}></Button> <Button onClick={searchWord}></Button>
@@ -155,10 +173,15 @@ export default function WordBoard() {
<Button onClick={deleteAll}></Button> <Button onClick={deleteAll}></Button>
{/* <Button label="朗读" onClick={readWordAloud}></Button> */} {/* <Button label="朗读" onClick={readWordAloud}></Button> */}
</div> </div>
<input type="file" ref={inputFileRef} className="hidden" accept="application/json" onChange={handleFileChange}></input> <input
type="file"
ref={inputFileRef}
className="hidden"
accept="application/json"
onChange={handleFileChange}
></input>
</div> </div>
</div> </div>
</> </>
); );
} }

View File

@@ -2,19 +2,19 @@ export default function Button({
onClick, onClick,
className, className,
selected, selected,
children children,
}: { }: {
onClick?: () => void, onClick?: () => void;
className?: string, className?: string;
selected?: boolean, selected?: boolean;
children?: React.ReactNode children?: React.ReactNode;
}) { }) {
return ( return (
<button <button
onClick={onClick} onClick={onClick}
className={`px-2 py-1 rounded shadow-2xs font-bold hover:bg-gray-300 hover:cursor-pointer ${selected ? 'bg-gray-300' : "bg-white"} ${className}`} className={`px-2 py-1 rounded shadow-2xs font-bold hover:bg-gray-300 hover:cursor-pointer ${selected ? "bg-gray-300" : "bg-white"} ${className}`}
> >
{children} {children}
</button> </button>
); );
} }

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

View File

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

View File

@@ -1,20 +1,20 @@
const IMAGES = { const IMAGES = {
speed_1_5x: '/images/speed_1_5x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg', speed_1_5x: "/images/speed_1_5x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
speed_1_2_x: '/images/speed_1_2x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg', speed_1_2_x: "/images/speed_1_2x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
speed_0_7x: '/images/speed_0_7x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg', speed_0_7x: "/images/speed_0_7x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
pause: '/images/pause_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg', pause: "/images/pause_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
speed_0_5x: '/images/speed_0_5x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg', speed_0_5x: "/images/speed_0_5x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
copy_all: '/images/copy_all_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg', copy_all: "/images/copy_all_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
autoplay: '/images/autoplay_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg', autoplay: "/images/autoplay_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
autopause: '/images/autopause_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg', autopause: "/images/autopause_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
speed_1x: '/images/1x_mobiledata_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg', speed_1x: "/images/1x_mobiledata_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
play_arrow: '/images/play_arrow_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg', play_arrow: "/images/play_arrow_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
close: '/images/close_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg', close: "/images/close_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
refresh: '/images/refresh_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg', refresh: "/images/refresh_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
more_horiz: '/images/more_horiz_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg', more_horiz: "/images/more_horiz_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
save: '/images/save_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg', save: "/images/save_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
delete: '/images/delete_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg', delete: "/images/delete_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
speed: '/images/speed_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg', speed: "/images/speed_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
} };
export default IMAGES; export default IMAGES;

File diff suppressed because it is too large Load Diff

View File

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

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

@@ -4,17 +4,21 @@ export interface Word {
word: string; word: string;
x: number; x: number;
y: number; y: number;
}export interface Letter {
letter: string;
letter_name_ipa: string;
letter_sound_ipa: string;
roman_letter?: string;
} }
export type SupportedAlphabets = 'japanese' | 'english' | 'esperanto' | 'uyghur'; export interface Letter {
letter: string;
letter_name_ipa: string;
letter_sound_ipa: string;
roman_letter?: string;
}
export type SupportedAlphabets =
| "japanese"
| "english"
| "esperanto"
| "uyghur";
export const TextSpeakerItemSchema = z.object({ export const TextSpeakerItemSchema = z.object({
text: z.string(), text: z.string(),
ipa: z.string().optional(), ipa: z.string().optional(),
locale: z.string() locale: z.string(),
}); });
export const TextSpeakerArraySchema = z.array(TextSpeakerItemSchema); export const TextSpeakerArraySchema = z.array(TextSpeakerItemSchema);

View File

@@ -2,77 +2,94 @@ import { EdgeTTS, ProsodyOptions } from "edge-tts-universal/browser";
import { env } from "process"; import { env } from "process";
import { TextSpeakerArraySchema } from "./interfaces"; import { TextSpeakerArraySchema } from "./interfaces";
import z from "zod"; import z from "zod";
import { NextResponse } from "next/server";
export function inspect(word: string) { export function inspect(word: string) {
const goto = (url: string) => { const goto = (url: string) => {
window.open(url, '_blank'); window.open(url, "_blank");
} };
return () => { return () => {
word = word.toLowerCase(); word = word.toLowerCase();
goto(`https://www.youdao.com/result?word=${word}&lang=en`); goto(`https://www.youdao.com/result?word=${word}&lang=en`);
} };
} }
export function urlGoto(url: string) { export function urlGoto(url: string) {
window.open(url, '_blank'); window.open(url, "_blank");
} }
const API_KEY = env.ZHIPU_API_KEY; const API_KEY = env.ZHIPU_API_KEY;
export async function callZhipuAPI(messages: { role: string; content: string; }[], model = 'glm-4.5-flash') { export async function callZhipuAPI(
const url = 'https://open.bigmodel.cn/api/paas/v4/chat/completions'; messages: { role: string; content: string }[],
model = "glm-4.5-flash",
) {
const url = "https://open.bigmodel.cn/api/paas/v4/chat/completions";
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: "POST",
headers: { headers: {
'Authorization': 'Bearer ' + API_KEY, Authorization: "Bearer " + API_KEY,
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
model: model, model: model,
messages: messages, messages: messages,
temperature: 0.2, temperature: 0.2,
thinking: { thinking: {
type: 'disabled' type: "disabled",
} },
}) }),
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`API 调用失败: ${response.status}`); throw new Error(`API 调用失败: ${response.status}`);
} }
return await response.json(); return await response.json();
} }
export async function getTTSAudioUrl(text: string, short_name: string, options: ProsodyOptions | undefined = undefined) { export async function getTTSAudioUrl(
const tts = new EdgeTTS(text, short_name, options); text: string,
try { short_name: string,
const result = await tts.synthesize(); options: ProsodyOptions | undefined = undefined,
return URL.createObjectURL(result.audio); ) {
} catch (e) { const tts = new EdgeTTS(text, short_name, options);
throw e; try {
} const result = await tts.synthesize();
return URL.createObjectURL(result.audio);
} catch (e) {
throw e;
}
} }
export const getTextSpeakerData = () => { export const getTextSpeakerData = () => {
try { try {
const item = localStorage.getItem('text-speaker'); const item = localStorage.getItem("text-speaker");
if (!item) return []; if (!item) return [];
const rawData = JSON.parse(item); const rawData = JSON.parse(item);
const result = TextSpeakerArraySchema.safeParse(rawData); const result = TextSpeakerArraySchema.safeParse(rawData);
if (result.success) { if (result.success) {
return result.data; return result.data;
} else { } else {
console.error('Invalid data structure in localStorage:', result.error); console.error("Invalid data structure in localStorage:", result.error);
return []; return [];
}
} catch (e) {
console.error('Failed to parse text-speaker data:', e);
return [];
} }
} catch (e) {
console.error("Failed to parse text-speaker data:", e);
return [];
}
}; };
export const setTextSpeakerData = (data: z.infer<typeof TextSpeakerArraySchema>) => { export const setTextSpeakerData = (
if (!localStorage) return; data: z.infer<typeof TextSpeakerArraySchema>,
localStorage.setItem('text-speaker', JSON.stringify(data)); ) => {
}; if (!localStorage) return;
localStorage.setItem("text-speaker", JSON.stringify(data));
};
export function handleAPIError(error: unknown, message: string) {
console.error(message, error);
return NextResponse.json(
{ error: "服务器内部错误", message },
{ status: 500 },
);
}