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:
branch:
- main
- main

2
.gitignore vendored
View File

@@ -40,4 +40,4 @@ yarn-error.log*
*.tsbuildinfo
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 = {
/* config options here */
output: "standalone",
allowedDevOrigins: ["192.168.3.65"]
allowedDevOrigins: ["192.168.3.65"],
};
export default nextConfig;

View File

@@ -2,55 +2,99 @@ import Button from "@/components/Button";
import IconClick from "@/components/IconClick";
import IMAGES from "@/config/images";
import { Letter, SupportedAlphabets } from "@/interfaces";
import { Dispatch, KeyboardEvent, SetStateAction, useEffect, useState } from "react";
import {
Dispatch,
KeyboardEvent,
SetStateAction,
useEffect,
useState,
} from "react";
export default function MemoryCard(
{
alphabet,
setChosenAlphabet
}: {
alphabet: Letter[],
setChosenAlphabet: Dispatch<SetStateAction<SupportedAlphabets | null>>
}
) {
const [index, setIndex] = useState(Math.floor(Math.random() * alphabet.length));
const [more, setMore] = useState(false);
const [ipaDisplay, setIPADisplay] = useState(true);
const [letterDisplay, setLetterDisplay] = useState(true);
export default function MemoryCard({
alphabet,
setChosenAlphabet,
}: {
alphabet: Letter[];
setChosenAlphabet: Dispatch<SetStateAction<SupportedAlphabets | null>>;
}) {
const [index, setIndex] = useState(
Math.floor(Math.random() * alphabet.length),
);
const [more, setMore] = useState(false);
const [ipaDisplay, setIPADisplay] = useState(true);
const [letterDisplay, setLetterDisplay] = useState(true);
useEffect(() => {
const handleKeydown = (e: globalThis.KeyboardEvent) => {
if (e.key === ' ') refresh();
}
document.addEventListener('keydown', handleKeydown);
return () => document.removeEventListener('keydown', handleKeydown);
});
useEffect(() => {
const handleKeydown = (e: globalThis.KeyboardEvent) => {
if (e.key === " ") refresh();
};
document.addEventListener("keydown", handleKeydown);
return () => document.removeEventListener("keydown", handleKeydown);
});
const letter = alphabet[index];
const refresh = () => {
setIndex(Math.floor(Math.random() * alphabet.length));
}
return (
<div className="w-full flex justify-center items-center" onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => e.preventDefault()}>
<div className="m-4 p-4 w-full md:w-[60dvw] flex-col rounded-2xl shadow border-gray-200 border flex justify-center items-center">
<div className="w-full flex justify-end items-center">
<IconClick size={32} alt="close" src={IMAGES.close} onClick={() => setChosenAlphabet(null)}></IconClick>
</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>
const letter = alphabet[index];
const refresh = () => {
setIndex(Math.floor(Math.random() * alphabet.length));
};
return (
<div
className="w-full flex justify-center items-center"
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => e.preventDefault()}
>
<div className="m-4 p-4 w-full md:w-[60dvw] flex-col rounded-2xl shadow border-gray-200 border flex justify-center items-center">
<div className="w-full flex justify-end items-center">
<IconClick
size={32}
alt="close"
src={IMAGES.close}
onClick={() => setChosenAlphabet(null)}
></IconClick>
</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 { Letter, SupportedAlphabets } from "@/interfaces";
@@ -7,78 +7,91 @@ import MemoryCard from "./MemoryCard";
import { Navbar } from "@/components/Navbar";
export default function Alphabet() {
const [chosenAlphabet, setChosenAlphabet] = useState<SupportedAlphabets | null>(null);
const [alphabetData, setAlphabetData] = useState<Record<SupportedAlphabets, Letter[] | null>>({
japanese: null,
english: null,
esperanto: null,
uyghur: null
});
const [loadingState, setLoadingState] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [chosenAlphabet, setChosenAlphabet] =
useState<SupportedAlphabets | null>(null);
const [alphabetData, setAlphabetData] = useState<
Record<SupportedAlphabets, Letter[] | null>
>({
japanese: null,
english: null,
esperanto: null,
uyghur: null,
});
const [loadingState, setLoadingState] = useState<
"idle" | "loading" | "success" | "error"
>("idle");
useEffect(() => {
if (chosenAlphabet && !alphabetData[chosenAlphabet]) {
setLoadingState("loading");
useEffect(() => {
if (chosenAlphabet && !alphabetData[chosenAlphabet]) {
setLoadingState('loading');
fetch("/alphabets/" + chosenAlphabet + ".json")
.then((res) => {
if (!res.ok) throw new Error("Network response was not ok");
return res.json();
})
.then((obj) => {
setAlphabetData((prev) => ({
...prev,
[chosenAlphabet]: obj as Letter[],
}));
setLoadingState("success");
})
.catch(() => {
setLoadingState("error");
});
}
}, [chosenAlphabet, alphabetData]);
fetch('/alphabets/' + chosenAlphabet + '.json')
.then(res => {
if (!res.ok) throw new Error('Network response was not ok');
return res.json();
}).then((obj) => {
setAlphabetData(prev => ({ ...prev, [chosenAlphabet]: obj as Letter[] }));
setLoadingState('success');
}).catch(() => {
setLoadingState('error');
});
}
}, [chosenAlphabet, alphabetData]);
useEffect(() => {
if (loadingState === "error") {
const timer = setTimeout(() => {
setLoadingState("idle");
setChosenAlphabet(null);
}, 2000);
return () => clearTimeout(timer);
}
}, [loadingState]);
useEffect(() => {
if (loadingState === 'error') {
const timer = setTimeout(() => {
setLoadingState('idle');
setChosenAlphabet(null);
}, 2000);
return () => clearTimeout(timer);
}
}, [loadingState]);
if (!chosenAlphabet) return (<>
if (!chosenAlphabet)
return (
<>
<Navbar></Navbar>
<div className="border border-gray-200 m-4 mt-4 flex flex-col justify-center items-center p-4 rounded-2xl gap-2">
<span className="text-2xl md:text-3xl"></span>
<div className="flex gap-1 flex-wrap">
<Button onClick={() => setChosenAlphabet('japanese')}>
</Button>
<Button onClick={() => setChosenAlphabet('english')}>
</Button>
<Button onClick={() => setChosenAlphabet('uyghur')}>
</Button>
<Button onClick={() => setChosenAlphabet('esperanto')}>
</Button>
</div>
<span className="text-2xl md:text-3xl"></span>
<div className="flex gap-1 flex-wrap">
<Button onClick={() => setChosenAlphabet("japanese")}>
</Button>
<Button onClick={() => setChosenAlphabet("english")}>
</Button>
<Button onClick={() => setChosenAlphabet("uyghur")}>
</Button>
<Button onClick={() => setChosenAlphabet("esperanto")}>
</Button>
</div>
</div>
</>
</>
);
if (loadingState === 'loading') {
return '加载中...';
}
if (loadingState === 'error') {
return '加载失败,请重试';
}
if (loadingState === 'success' && alphabetData[chosenAlphabet]) {
return (<>
<Navbar></Navbar>
<MemoryCard
alphabet={alphabetData[chosenAlphabet]}
setChosenAlphabet={setChosenAlphabet}>
</MemoryCard>
</>);
}
return null;
}
if (loadingState === "loading") {
return "加载中...";
}
if (loadingState === "error") {
return "加载失败,请重试";
}
if (loadingState === "success" && alphabetData[chosenAlphabet]) {
return (
<>
<Navbar></Navbar>
<MemoryCard
alphabet={alphabetData[chosenAlphabet]}
setChosenAlphabet={setChosenAlphabet}
></MemoryCard>
</>
);
}
return null;
}

View File

@@ -1,11 +1,12 @@
import { callZhipuAPI } from "@/utils";
import { callZhipuAPI, handleAPIError } from "@/utils";
import { NextRequest, NextResponse } from "next/server";
async function getIPA(text: string) {
console.log(`get ipa of ${text}`);
const messages = [
{
role: 'user', content: `
console.log(`get ipa of ${text}`);
const messages = [
{
role: "user",
content: `
请推断以下文本的语言生成对应的宽式国际音标IPA以及locale以JSON格式返回
[${text}]
结果如:
@@ -18,47 +19,44 @@ async function getIPA(text: string) {
ipa一定要加[]
locale如果可能有多个选取最可能的一个其中使用符号"-"
locale如果推断失败就返回{"locale": "en-US"}
`
}];
try {
const response = await callZhipuAPI(messages);
let to_parse = response.choices[0].message.content.trim() as string;
if (to_parse.startsWith('`')) to_parse = to_parse.slice(7, to_parse.length - 3);
if (to_parse.length === 0) throw Error('ai啥也每说');
return JSON.parse(to_parse);
} catch (error) {
console.error(error);
return null;
}
`,
},
];
try {
const response = await callZhipuAPI(messages);
let to_parse = response.choices[0].message.content.trim() as string;
if (to_parse.startsWith("`"))
to_parse = to_parse.slice(7, to_parse.length - 3);
if (to_parse.length === 0) throw Error("ai啥也每说");
return JSON.parse(to_parse);
} catch (error) {
console.error(error);
return null;
}
}
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const text = searchParams.get('text');
try {
const searchParams = request.nextUrl.searchParams;
const text = searchParams.get("text");
if (!text) {
return NextResponse.json(
{ error: "查询参数错误", message: "text参数是必需的" },
{ status: 400 }
);
}
const textInfo = await getIPA(text);
if (!textInfo) {
return NextResponse.json(
{ error: "服务暂时不可用", message: "LLM API 请求失败" },
{ status: 503 }
);
}
return NextResponse.json(textInfo, { status: 200 });
} catch (error) {
console.error('API 错误:', error);
return NextResponse.json(
{ error: "服务器内部错误", message: "请稍后重试" },
{ status: 500 }
);
if (!text) {
return NextResponse.json(
{ error: "查询参数错误", message: "text参数是必需的" },
{ status: 400 },
);
}
}
const textInfo = await getIPA(text);
if (!textInfo) {
return NextResponse.json(
{ error: "服务暂时不可用", message: "LLM API 请求失败" },
{ status: 503 },
);
}
return NextResponse.json(textInfo, { status: 200 });
} catch (error) {
handleAPIError(error, "请稍后再试");
}
}

View File

@@ -2,10 +2,11 @@ import { callZhipuAPI } from "@/utils";
import { NextRequest, NextResponse } from "next/server";
async function getLocale(text: string) {
console.log(`get locale of ${text}`);
const messages = [
{
role: 'user', content: `
console.log(`get locale of ${text}`);
const messages = [
{
role: "user",
content: `
请推断以下文本的的locale以JSON格式返回
[${text}]
结果如:
@@ -16,47 +17,48 @@ async function getLocale(text: string) {
直接返回json文本
locale如果可能有多个选取最可能的一个其中使用符号"-"
locale如果推断失败就返回{"locale": "en-US"}
`
}];
try {
const response = await callZhipuAPI(messages);
let to_parse = response.choices[0].message.content.trim() as string;
if (to_parse.startsWith('`')) to_parse = to_parse.slice(7, to_parse.length - 3);
if (to_parse.length === 0) throw Error('ai啥也每说');
return JSON.parse(to_parse);
} catch (error) {
console.error(error);
return null;
}
`,
},
];
try {
const response = await callZhipuAPI(messages);
let to_parse = response.choices[0].message.content.trim() as string;
if (to_parse.startsWith("`"))
to_parse = to_parse.slice(7, to_parse.length - 3);
if (to_parse.length === 0) throw Error("ai啥也每说");
return JSON.parse(to_parse);
} catch (error) {
console.error(error);
return null;
}
}
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const text = searchParams.get('text');
try {
const searchParams = request.nextUrl.searchParams;
const text = searchParams.get("text");
if (!text) {
return NextResponse.json(
{ error: "查询参数错误", message: "text参数是必需的" },
{ status: 400 }
);
}
const textInfo = await getLocale(text.slice(0, 30));
if (!textInfo) {
return NextResponse.json(
{ error: "服务暂时不可用", message: "LLM API 请求失败" },
{ status: 503 }
);
}
return NextResponse.json(textInfo, { status: 200 });
} catch (error) {
console.error('API 错误:', error);
return NextResponse.json(
{ error: "服务器内部错误", message: "请稍后重试" },
{ status: 500 }
);
if (!text) {
return NextResponse.json(
{ error: "查询参数错误", message: "text参数是必需的" },
{ status: 400 },
);
}
}
const textInfo = await getLocale(text.slice(0, 30));
if (!textInfo) {
return NextResponse.json(
{ error: "服务暂时不可用", message: "LLM API 请求失败" },
{ status: 503 },
);
}
return NextResponse.json(textInfo, { status: 200 });
} catch (error) {
console.error("API 错误:", error);
return NextResponse.json(
{ error: "服务器内部错误", message: "请稍后重试" },
{ status: 500 },
);
}
}

View File

@@ -2,8 +2,11 @@ import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const url = request.url;
return NextResponse.json({
message: "Hello World",
url: url
}, { status: 200 });
return NextResponse.json(
{
message: "Hello World",
url: url,
},
{ status: 200 },
);
}

View File

@@ -2,10 +2,11 @@ import { callZhipuAPI } from "@/utils";
import { NextRequest, NextResponse } from "next/server";
async function getTextinfo(text: string) {
console.log(`get textinfo of ${text}`);
const messages = [
{
role: 'user', content: `
console.log(`get textinfo of ${text}`);
const messages = [
{
role: "user",
content: `
请推断以下文本的语言、locale生成宽式国际音标IPA以JSON格式返回
[${text}]
结果如:
@@ -20,45 +21,47 @@ async function getTextinfo(text: string) {
ipa一定要加[]
lang的值是小写字母的英文的语言名称
locale如果可能有多个选取最可能的一个其中使用符号"-"
`
}];
try {
const response = await callZhipuAPI(messages);
let to_parse = response.choices[0].message.content.trim() as string;
if (to_parse.startsWith('`')) to_parse = to_parse.slice(7, to_parse.length - 3);
if (to_parse.length === 0) throw Error('ai啥也每说');
return JSON.parse(to_parse);
} catch (error) {
console.error(error);
return null;
}
`,
},
];
try {
const response = await callZhipuAPI(messages);
let to_parse = response.choices[0].message.content.trim() as string;
if (to_parse.startsWith("`"))
to_parse = to_parse.slice(7, to_parse.length - 3);
if (to_parse.length === 0) throw Error("ai啥也每说");
return JSON.parse(to_parse);
} catch (error) {
console.error(error);
return null;
}
}
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const text = searchParams.get('text');
try {
const searchParams = request.nextUrl.searchParams;
const text = searchParams.get("text");
if (!text) {
return NextResponse.json(
{ error: "查询参数错误", message: "text参数是必需的" },
{ 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 }
);
if (!text) {
return NextResponse.json(
{ error: "查询参数错误", message: "text参数是必需的" },
{ 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 },
);
}
}

View File

@@ -2,10 +2,11 @@ import { callZhipuAPI } from "@/utils";
import { NextRequest, NextResponse } from "next/server";
async function translate(text: string, target_lang: string) {
console.log(`translate "${text}" into ${target_lang}`);
const messages = [
{
role: 'user', content: `
console.log(`translate "${text}" into ${target_lang}`);
const messages = [
{
role: "user",
content: `
请推断以下文本的语言、locale并翻译到目标语言[${target_lang}]同样需要locale信息以JSON格式返回
[${text}]
结果如:
@@ -18,46 +19,48 @@ async function translate(text: string, target_lang: string) {
直接返回json文本
locale如果可能有多个选取最可能的一个其中使用符号"-"
locale如果推断失败就当作是en-US
`
}];
try {
const response = await callZhipuAPI(messages);
let to_parse = response.choices[0].message.content.trim() as string;
if (to_parse.startsWith('`')) to_parse = to_parse.slice(7, to_parse.length - 3);
if (to_parse.length === 0) throw Error('ai啥也每说');
return JSON.parse(to_parse);
} catch (error) {
console.error(error);
return null;
}
`,
},
];
try {
const response = await callZhipuAPI(messages);
let to_parse = response.choices[0].message.content.trim() as string;
if (to_parse.startsWith("`"))
to_parse = to_parse.slice(7, to_parse.length - 3);
if (to_parse.length === 0) throw Error("ai啥也每说");
return JSON.parse(to_parse);
} catch (error) {
console.error(error);
return null;
}
}
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const text = searchParams.get('text');
const target_lang = searchParams.get('target');
try {
const searchParams = request.nextUrl.searchParams;
const text = searchParams.get("text");
const target_lang = searchParams.get("target");
if (!text || !target_lang) {
return NextResponse.json(
{ error: "查询参数错误", message: "text参数, target参数是必需的" },
{ status: 400 }
);
}
const textInfo = await translate(text, target_lang);
if (!textInfo) {
return NextResponse.json(
{ error: "服务暂时不可用", message: "LLM API 请求失败" },
{ status: 503 }
);
}
return NextResponse.json(textInfo, { status: 200 });
} catch (error) {
console.error('API 错误:', error);
return NextResponse.json(
{ error: "服务器内部错误", message: "请稍后重试" },
{ status: 500 }
);
if (!text || !target_lang) {
return NextResponse.json(
{ error: "查询参数错误", message: "text参数, target参数是必需的" },
{ status: 400 },
);
}
}
const textInfo = await translate(text, target_lang);
if (!textInfo) {
return NextResponse.json(
{ error: "服务暂时不可用", message: "LLM API 请求失败" },
{ status: 503 },
);
}
return NextResponse.json(textInfo, { status: 200 });
} catch (error) {
console.error("API 错误:", error);
return NextResponse.json(
{ error: "服务器内部错误", message: "请稍后重试" },
{ status: 500 },
);
}
}

View File

@@ -1,15 +1,15 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
/* @media (prefers-color-scheme: dark) {
@@ -20,13 +20,13 @@
} */
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
background: var(--background);
color: var(--foreground);
font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
}
.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 { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import type { Viewport } from 'next'
import type { Viewport } from "next";
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1.0
}
width: "device-width",
initialScale: 1.0,
};
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -23,8 +23,6 @@ export const metadata: Metadata = {
description: "A Website to Learn Languages",
};
export default function RootLayout({
children,
}: Readonly<{

View File

@@ -1,135 +1,146 @@
'use client';
"use client";
import Button from "@/components/Button";
import { Select, Option } from "@material-tailwind/react";
import { ChangeEvent, useState } from "react";
interface ACardProps {
children?: React.ReactNode,
className?: string
children?: React.ReactNode;
className?: string;
}
function ACard({ children, className }: ACardProps) {
return (
<div className={`w-[61vw] h-96 p-2 shadow-2xl bg-[#00BCD4] rounded-xl ${className}`}>
{children}
</div>);
return (
<div
className={`w-[61vw] h-96 p-2 shadow-2xl bg-[#00BCD4] rounded-xl ${className}`}
>
{children}
</div>
);
}
interface BCard {
children?: React.ReactNode,
className?: string
children?: React.ReactNode;
className?: string;
}
function BCard({ children, className }: BCard) {
return (
<div className={`border border-[#0097A7] rounded-xl p-2 ${className}`}>
{children}
</div>);
return (
<div className={`border border-[#0097A7] rounded-xl p-2 ${className}`}>
{children}
</div>
);
}
interface WordData {
locale1: string,
locale2: string,
data: Record<string, string>
locale1: string;
locale2: string;
data: Record<string, string>;
}
export default function Memorize() {
const [pageState, setPageState] = useState<'choose' | 'start' | 'main' | 'edit'>('edit');
const [wordData, setWordData] = useState<WordData>({
locale1: 'en-US',
locale2: 'zh-CN',
data: { 'hello': '你好' }
});
if (pageState === 'main') {
return (<>
<div className="w-full h-screen flex justify-center items-center">
<ACard>
<h1 className="text-center font-extrabold text-4xl text-white m-2 mb-4">
Memorize
</h1>
<div className="w-full text-white">
<BCard>
<p>Lang1: {wordData.locale1}</p>
<p>Lang2: {wordData.locale2}</p>
<p>Total Words: {Object.keys(wordData.data).length}</p>
</BCard>
</div>
<div className="w-full flex items-center justify-center">
<BCard className="flex gap-2 justify-center items-center w-fit">
<Button>Start</Button>
<Button>Load</Button>
<Button>Save</Button>
<Button onClick={() => setPageState('edit')}>Edit</Button>
</BCard>
</div>
</ACard>
const [pageState, setPageState] = useState<
"choose" | "start" | "main" | "edit"
>("edit");
const [wordData, setWordData] = useState<WordData>({
locale1: "en-US",
locale2: "zh-CN",
data: { hello: "你好" },
});
if (pageState === "main") {
return (
<>
<div className="w-full h-screen flex justify-center items-center">
<ACard>
<h1 className="text-center font-extrabold text-4xl text-white m-2 mb-4">
Memorize
</h1>
<div className="w-full text-white">
<BCard>
<p>Lang1: {wordData.locale1}</p>
<p>Lang2: {wordData.locale2}</p>
<p>Total Words: {Object.keys(wordData.data).length}</p>
</BCard>
</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 className="w-full flex items-center justify-center">
<BCard className="flex gap-2 justify-center items-center w-fit">
<Button>Start</Button>
<Button>Load</Button>
<Button>Save</Button>
<Button onClick={() => setPageState("edit")}>Edit</Button>
</BCard>
</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="选择语言"
placeholder="请选择语言"
onResize={undefined}
@@ -143,6 +154,7 @@ export default function Memorize() {
<Option>Material Tailwind Angular</Option>
<Option>Material Tailwind Svelte</Option>
</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="mb-16 mx-16 md:mx-0 md:max-w-[60dvw]">
<h1 className="text-6xl md:text-9xl mb-8">Learn Languages</h1>
<p className="text-2xl md:text-5xl">Here is a very useful website to help you learn almost every language in the world, including constructed ones.</p>
<p className="text-2xl md:text-5xl">
Here is a very useful website to help you learn almost every language
in the world, including constructed ones.
</p>
</div>
</div>
)
);
}
interface LinkAreaProps {
href: string,
name: string,
description: string,
color: string
href: string;
name: string;
description: string;
color: string;
}
function LinkArea(
{ href, name, description, color }: LinkAreaProps
) {
function LinkArea({ href, name, description, color }: LinkAreaProps) {
return (
<Link href={href}
<Link
href={href}
style={{ backgroundColor: color }}
className={`h-32 md:h-64 flex justify-center items-center`}>
className={`h-32 md:h-64 flex justify-center items-center`}
>
<div className="text-white m-8">
<h1 className="text-4xl">{name}</h1>
<p className="text-xl">{description}</p>
@@ -36,17 +39,18 @@ function LinkArea(
function LinkGrid() {
return (
<div className="w-full grid grid-cols-1 grid-rows-6 md:grid-cols-3">
<LinkArea
href="/translator"
name="翻译器"
description="翻译到任何语言并标注国际音标IPA"
color="#a56068"></LinkArea>
color="#a56068"
></LinkArea>
<LinkArea
href="/text-speaker"
name="朗读器"
description="识别并朗读文本,支持循环朗读、朗读速度调节"
color="#578aad"></LinkArea>
color="#578aad"
></LinkArea>
{/* <LinkArea
href="/word-board"
name="词墙"
@@ -56,19 +60,22 @@ function LinkGrid() {
href="/srt-player"
name="逐句视频播放器"
description="基于SRT字幕文件逐句播放视频以模仿母语者的发音"
color="#3c988d"></LinkArea>
<LinkArea
color="#3c988d"
></LinkArea>
<LinkArea
href="/alphabet"
name="记忆字母表"
description="从字母表开始新语言的学习"
color="#dd7486"></LinkArea>
color="#dd7486"
></LinkArea>
<LinkArea
href="#"
name="更多功能"
description="开发中,敬请期待"
color="#cab48a"></LinkArea>
color="#cab48a"
></LinkArea>
</div>
)
);
}
function Fortune() {
@@ -97,5 +104,6 @@ export default function Home() {
<Fortune></Fortune>
<Explore></Explore>
<LinkGrid></LinkGrid>
</>);
</>
);
}

View File

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

View File

@@ -1,21 +1,19 @@
import { inspect } from "@/utils";
export default function SubtitleDisplay({ subtitle }: { subtitle: string }) {
const words = subtitle.match(/\b[\w']+(?:-[\w']+)*\b/g) || [];
let i = 0;
return (
<div className="w-full subtitle overflow-auto h-16 mt-2 break-words bg-black/50 font-sans text-white text-center text-2xl">
{
words.map((v) => (
<span
onClick={inspect(v)}
key={i++}
className="hover:bg-gray-700 hover:underline hover:cursor-pointer"
>
{v + ' '}
</span>
))
}
</div>
);
}
const words = subtitle.match(/\b[\w']+(?:-[\w']+)*\b/g) || [];
let i = 0;
return (
<div className="w-full subtitle overflow-auto h-16 mt-2 break-words bg-black/50 font-sans text-white text-center text-2xl">
{words.map((v) => (
<span
onClick={inspect(v)}
key={i++}
className="hover:bg-gray-700 hover:underline hover:cursor-pointer"
>
{v + " "}
</span>
))}
</div>
);
}

View File

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

View File

@@ -1,52 +1,74 @@
export function parseSrt(data: string) {
const lines = data.split(/\r?\n/);
const result = [];
const re = new RegExp('(\\d{2}:\\d{2}:\\d{2},\\d{3})\\s*-->\\s*(\\d{2}:\\d{2}:\\d{2},\\d{3})');
let i = 0;
while (i < lines.length) {
if (!lines[i].trim()) { i++; continue; }
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++;
const lines = data.split(/\r?\n/);
const result = [];
const re = new RegExp(
"(\\d{2}:\\d{2}:\\d{2},\\d{3})\\s*-->\\s*(\\d{2}:\\d{2}:\\d{2},\\d{3})",
);
let i = 0;
while (i < lines.length) {
if (!lines[i].trim()) {
i++;
continue;
}
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) {
for (let i = 0; i < srt.length; i++) {
const s = srt[i];
const l = ct - s.start >= 0;
const r = ct - s.end >= 0;
if (!(l || r)) return i - 1;
if (l && (!r)) return i;
}
export function getNearistIndex(
srt: { start: number; end: number; text: string }[],
ct: number,
) {
for (let i = 0; i < srt.length; i++) {
const s = srt[i];
const l = ct - s.start >= 0;
const r = ct - s.end >= 0;
if (!(l || r)) return i - 1;
if (l && !r) return i;
}
}
export function getIndex(srt: { start: number; end: number; text: string; }[], ct: number) {
for (let i = 0; i < srt.length; i++) {
if (ct >= srt[i].start && ct <= srt[i].end) {
return i;
}
export function getIndex(
srt: { start: number; end: number; text: string }[],
ct: number,
) {
for (let i = 0; i < srt.length; i++) {
if (ct >= srt[i].start && ct <= srt[i].end) {
return i;
}
return null;
}
return null;
}
export function getSubtitle(srt: { start: number; end: number; text: string; }[], currentTime: number) {
return srt.find(sub => currentTime >= sub.start && currentTime <= sub.end) || null;
export function getSubtitle(
srt: { start: number; end: number; text: string }[],
currentTime: number,
) {
return (
srt.find((sub) => currentTime >= sub.start && currentTime <= sub.end) ||
null
);
}
function toSeconds(timeStr: string): number {
const [h, m, s] = timeStr.replace(',', '.').split(':');
return parseFloat((parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s)).toFixed(3));
const [h, m, s] = timeStr.replace(",", ".").split(":");
return parseFloat(
(parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s)).toFixed(3),
);
}

View File

@@ -1,4 +1,4 @@
'use client';
"use client";
import { getTextSpeakerData, setTextSpeakerData } from "@/utils";
import { useState } from "react";
@@ -8,88 +8,98 @@ import IconClick from "@/components/IconClick";
import IMAGES from "@/config/images";
interface TextCardProps {
item: z.infer<typeof TextSpeakerItemSchema>;
handleUse: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
handleDel: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
item: z.infer<typeof TextSpeakerItemSchema>;
handleUse: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
handleDel: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
}
function TextCard({
item,
handleUse,
handleDel
}: TextCardProps) {
const onUseClick = () => {
handleUse(item);
}
const onDelClick = () => {
handleDel(item);
}
return (
<div className="p-2 border-b-1 border-gray-200 rounded-2xl bg-gray-100 m-2 grid grid-cols-8">
<div className="col-span-7" onClick={onUseClick}>
<div className="max-h-26 hover:cursor-pointer text-3xl overflow-y-auto">{item.text}</div>
<div className="max-h-16 overflow-y-auto text-xl text-gray-600 whitespace-nowrap overflow-x-auto">{item.ipa}</div>
</div>
<div className="flex justify-center items-center border-gray-300 border-l-2 m-2">
<IconClick
src={IMAGES.delete}
alt="delete"
onClick={onDelClick}
className="place-self-center"
size={42}>
</IconClick>
</div>
function TextCard({ item, handleUse, handleDel }: TextCardProps) {
const onUseClick = () => {
handleUse(item);
};
const onDelClick = () => {
handleDel(item);
};
return (
<div className="p-2 border-b-1 border-gray-200 rounded-2xl bg-gray-100 m-2 grid grid-cols-8">
<div className="col-span-7" onClick={onUseClick}>
<div className="max-h-26 hover:cursor-pointer text-3xl overflow-y-auto">
{item.text}
</div>
);
<div className="max-h-16 overflow-y-auto text-xl text-gray-600 whitespace-nowrap overflow-x-auto">
{item.ipa}
</div>
</div>
<div className="flex justify-center items-center border-gray-300 border-l-2 m-2">
<IconClick
src={IMAGES.delete}
alt="delete"
onClick={onDelClick}
className="place-self-center"
size={42}
></IconClick>
</div>
</div>
);
}
interface SaveListProps {
show?: boolean;
handleUse: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
show?: boolean;
handleUse: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
}
export default function SaveList({
show = false,
handleUse
}: SaveListProps) {
const [data, setData] = useState(getTextSpeakerData());
const handleDel = (item: z.infer<typeof TextSpeakerItemSchema>) => {
const current_data = getTextSpeakerData();
current_data.splice(
current_data.findIndex(v => v.text === item.text), 1
);
setTextSpeakerData(current_data);
refresh();
export default function SaveList({ show = false, handleUse }: SaveListProps) {
const [data, setData] = useState(getTextSpeakerData());
const handleDel = (item: z.infer<typeof TextSpeakerItemSchema>) => {
const current_data = getTextSpeakerData();
current_data.splice(
current_data.findIndex((v) => v.text === item.text),
1,
);
setTextSpeakerData(current_data);
refresh();
};
const refresh = () => {
setData(getTextSpeakerData());
};
const handleDeleteAll = () => {
const yesorno = prompt("确定删光吗?(Y/N)")?.trim();
if (yesorno && (yesorno === "Y" || yesorno === "y")) {
setTextSpeakerData([]);
refresh();
}
const refresh = () => {
setData(getTextSpeakerData());
}
const handleDeleteAll = () => {
const yesorno = prompt('确定删光吗?(Y/N)')?.trim();
if (yesorno && (yesorno === 'Y' || yesorno === 'y')) {
setTextSpeakerData([]);
refresh();
}
}
if (show) return (
<div className="my-4 p-2 mx-4 md:mx-32 border-1 border-gray-200 rounded-2xl" style={{ fontFamily: 'Times New Roman, serif' }}>
<div className="flex flex-row justify-center gap-8 items-center">
<IconClick
src={IMAGES.refresh}
alt="refresh"
onClick={refresh}
size={48}
className=""></IconClick>
<IconClick
src={IMAGES.delete}
alt="delete"
onClick={handleDeleteAll}
size={48}
className=""></IconClick>
</div>
<ul>
{data.map(v =>
<TextCard item={v} key={crypto.randomUUID()} handleUse={handleUse} handleDel={handleDel}></TextCard>
)}
</ul>
};
if (show)
return (
<div
className="my-4 p-2 mx-4 md:mx-32 border-1 border-gray-200 rounded-2xl"
style={{ fontFamily: "Times New Roman, serif" }}
>
<div className="flex flex-row justify-center gap-8 items-center">
<IconClick
src={IMAGES.refresh}
alt="refresh"
onClick={refresh}
size={48}
className=""
></IconClick>
<IconClick
src={IMAGES.delete}
alt="delete"
onClick={handleDeleteAll}
size={48}
className=""
></IconClick>
</div>
); 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 IMAGES from "@/config/images";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { getTextSpeakerData, getTTSAudioUrl, setTextSpeakerData } from "@/utils";
import {
getTextSpeakerData,
getTTSAudioUrl,
setTextSpeakerData,
} from "@/utils";
import { ChangeEvent, useEffect, useRef, useState } from "react";
import SaveList from "./SaveList";
import { TextSpeakerItemSchema } from "@/interfaces";
@@ -13,277 +17,320 @@ import { Navbar } from "@/components/Navbar";
import { VOICES } from "@/config/locales";
export default function TextSpeaker() {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [showSpeedAdjust, setShowSpeedAdjust] = useState(false);
const [showSaveList, setShowSaveList] = useState(false);
const [saving, setSaving] = useState(false);
const [ipaEnabled, setIPAEnabled] = useState(false);
const [speed, setSpeed] = useState(1);
const [pause, setPause] = useState(true);
const [autopause, setAutopause] = useState(true);
const textRef = useRef('');
const [locale, setLocale] = useState<string | null>(null);
const [ipa, setIPA] = useState<string>('');
const objurlRef = useRef<string | null>(null);
const [processing, setProcessing] = useState(false);
const { playAudio, stopAudio, audioRef } = useAudioPlayer();
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [showSpeedAdjust, setShowSpeedAdjust] = useState(false);
const [showSaveList, setShowSaveList] = useState(false);
const [saving, setSaving] = useState(false);
const [ipaEnabled, setIPAEnabled] = useState(false);
const [speed, setSpeed] = useState(1);
const [pause, setPause] = useState(true);
const [autopause, setAutopause] = useState(true);
const textRef = useRef("");
const [locale, setLocale] = useState<string | null>(null);
const [ipa, setIPA] = useState<string>("");
const objurlRef = useRef<string | null>(null);
const [processing, setProcessing] = useState(false);
const { playAudio, stopAudio, audioRef } = useAudioPlayer();
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const handleEnded = () => {
if (autopause) {
setPause(true);
} else {
playAudio(objurlRef.current!);
}
}
audio.addEventListener('ended', handleEnded);
return () => {
audio.removeEventListener('ended', handleEnded);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [audioRef, autopause]);
const handleEnded = () => {
if (autopause) {
setPause(true);
} else {
playAudio(objurlRef.current!);
}
};
audio.addEventListener("ended", handleEnded);
return () => {
audio.removeEventListener("ended", handleEnded);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [audioRef, autopause]);
const speak = async () => {
if (processing) return;
setProcessing(true);
const speak = async () => {
if (processing) return;
setProcessing(true);
if (ipa.length === 0 && ipaEnabled && textRef.current.length !== 0) {
const params = new URLSearchParams({
text: textRef.current
});
fetch(`/api/ipa?${params}`)
.then(res => res.json())
.then(data => {
setIPA(data.ipa);
}).catch(e => {
console.error(e);
setIPA('');
})
}
if (ipa.length === 0 && ipaEnabled && textRef.current.length !== 0) {
const params = new URLSearchParams({
text: textRef.current,
});
fetch(`/api/ipa?${params}`)
.then((res) => res.json())
.then((data) => {
setIPA(data.ipa);
})
.catch((e) => {
console.error(e);
setIPA("");
});
}
if (pause) {
// 如果没在读
if (textRef.current.length === 0) {
// 没文本咋读
} else {
setPause(false);
if (pause) {
// 如果没在读
if (textRef.current.length === 0) {
// 没文本咋读
} else {
setPause(false);
if (objurlRef.current) {
// 之前有播放
playAudio(objurlRef.current);
} else {
// 第一次播放
try {
let theLocale = locale;
if (!theLocale) {
console.log('downloading text info');
const params = new URLSearchParams({
text: textRef.current.slice(0, 30)
});
const textinfo = await (await fetch(`/api/locale?${params}`)).json();
setLocale(textinfo.locale);
theLocale = textinfo.locale as string;
}
const voice = VOICES.find(v => v.locale.startsWith(theLocale));
if (!voice) throw 'Voice not found.';
objurlRef.current = await getTTSAudioUrl(
textRef.current,
voice.short_name,
(() => {
if (speed === 1) return {};
else if (speed < 1) return {
rate: `-${100 - speed * 100}%`
}; else return {
rate: `+${speed * 100 - 100}%`
};
})()
);
playAudio(objurlRef.current);
} catch (e) {
console.error(e);
setPause(true);
setLocale(null);
setProcessing(false);
}
}
}
if (objurlRef.current) {
// 之前有播放
playAudio(objurlRef.current);
} else {
// 如果在读就暂停
setPause(true);
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 {
// 第一次播放
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;
console.log("downloading text info");
const params = new URLSearchParams({
text: textRef.current.slice(0, 30),
});
const textinfo = await (
await fetch(`/api/locale?${params}`)
).json();
setLocale(textinfo.locale);
theLocale = textinfo.locale as string;
}
let theIPA = ipa;
if (ipa.length === 0 && ipaEnabled) {
const params = new URLSearchParams({
text: textRef.current
});
const tmp = await (await fetch(`/api/ipa?${params}`)).json();
setIPA(tmp.ipa);
theIPA = tmp.ipa;
}
const voice = VOICES.find((v) => v.locale.startsWith(theLocale));
if (!voice) throw "Voice not found.";
const save = getTextSpeakerData();
const oldIndex = save.findIndex(v => v.text === textRef.current);
if (oldIndex !== -1) {
const oldItem = save[oldIndex];
if (theIPA) {
if ((!oldItem.ipa || (oldItem.ipa !== theIPA))) {
oldItem.ipa = theIPA;
setTextSpeakerData(save);
}
}
} else if (theIPA.length === 0) {
save.push({
text: textRef.current,
locale: theLocale
});
} else {
save.push({
text: textRef.current,
locale: theLocale,
ipa: theIPA
});
}
setTextSpeakerData(save);
} catch (e) {
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);
} finally {
setSaving(false);
setProcessing(false);
}
}
}
} else {
// 如果在读就暂停
setPause(true);
stopAudio();
}
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>)}
<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>
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;
if (!theLocale) {
console.log("downloading text info");
const params = new URLSearchParams({
text: textRef.current.slice(0, 30),
});
const textinfo = await (await fetch(`/api/locale?${params}`)).json();
setLocale(textinfo.locale);
theLocale = textinfo.locale as string;
}
let theIPA = ipa;
if (ipa.length === 0 && ipaEnabled) {
const params = new URLSearchParams({
text: textRef.current,
});
const tmp = await (await fetch(`/api/ipa?${params}`)).json();
setIPA(tmp.ipa);
theIPA = tmp.ipa;
}
const save = getTextSpeakerData();
const oldIndex = save.findIndex((v) => v.text === textRef.current);
if (oldIndex !== -1) {
const oldItem = save[oldIndex];
if (theIPA) {
if (!oldItem.ipa || oldItem.ipa !== theIPA) {
oldItem.ipa = theIPA;
setTextSpeakerData(save);
}
}
} else if (theIPA.length === 0) {
save.push({
text: textRef.current,
locale: theLocale,
});
} else {
save.push({
text: textRef.current,
locale: theLocale,
ipa: theIPA,
});
}
setTextSpeakerData(save);
} catch (e) {
console.error(e);
setLocale(null);
} finally {
setSaving(false);
}
};
return (
<>
<Navbar></Navbar>
<div
className="my-4 p-4 mx-4 md:mx-32 border-1 border-gray-200 rounded-2xl"
style={{ fontFamily: "Times New Roman, serif" }}
>
<textarea
className="text-2xl resize-none focus:outline-0 min-h-64 w-full border-gray-200 border-b"
onChange={handleInputChange}
ref={textareaRef}
></textarea>
{(ipa.length !== 0 && (
<div className="overflow-auto text-gray-600 h-18 border-gray-200 border-b">
{ipa}
</div>
)) || <div className="h-18"></div>}
<div className="mt-8 relative w-full flex flex-row flex-wrap gap-2 justify-center items-center">
{showSpeedAdjust && (
<div className="bg-white p-6 rounded-2xl border-gray-200 border-2 shadow-2xl absolute left-1/2 -translate-x-1/2 -translate-y-full -top-4 flex flex-row flex-wrap gap-2 justify-center items-center">
<IconClick
size={45}
onClick={letMeSetSpeed(0.5)}
src={IMAGES.speed_0_5x}
alt="0.5x"
className={speed === 0.5 ? "bg-gray-200" : ""}
></IconClick>
<IconClick
size={45}
onClick={letMeSetSpeed(0.7)}
src={IMAGES.speed_0_7x}
alt="0.7x"
className={speed === 0.7 ? "bg-gray-200" : ""}
></IconClick>
<IconClick
size={45}
onClick={letMeSetSpeed(1)}
src={IMAGES.speed_1x}
alt="1x"
className={speed === 1 ? "bg-gray-200" : ""}
></IconClick>
<IconClick
size={45}
onClick={letMeSetSpeed(1.2)}
src={IMAGES.speed_1_2_x}
alt="1.2x"
className={speed === 1.2 ? "bg-gray-200" : ""}
></IconClick>
<IconClick
size={45}
onClick={letMeSetSpeed(1.5)}
src={IMAGES.speed_1_5x}
alt="1.5x"
className={speed === 1.5 ? "bg-gray-200" : ""}
></IconClick>
</div>
)}
<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>
<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() {
const [ipaEnabled, setIPAEnabled] = useState(true);
const [targetLang, setTargetLang] = useState('Chinese');
const [targetLang, setTargetLang] = useState("Chinese");
const [sourceText, setSourceText] = useState('');
const [targetText, setTargetText] = useState('');
const [sourceIPA, setSourceIPA] = useState('');
const [targetIPA, setTargetIPA] = useState('');
const [sourceText, setSourceText] = useState("");
const [targetText, setTargetText] = useState("");
const [sourceIPA, setSourceIPA] = useState("");
const [targetIPA, setTargetIPA] = useState("");
const [sourceLocale, setSourceLocale] = useState<string | null>(null);
const [targetLocale, setTargetLocale] = useState<string | null>(null);
const [translating, setTranslating] = useState(false);
const { playAudio } = useAudioPlayer();
const tl = ['Chinese', 'English', 'Italian'];
const tl = ["Chinese", "English", "Italian"];
const inputLanguage = () => {
const lang = prompt('Input a language.')?.trim();
const lang = prompt("Input a language.")?.trim();
if (lang) {
setTargetLang(lang);
}
}
};
const translate = () => {
if (translating) return;
@@ -37,91 +37,96 @@ export default function Translator() {
setTranslating(true);
setTargetText('');
setTargetText("");
setSourceLocale(null);
setTargetLocale(null);
setSourceIPA('');
setTargetIPA('');
setSourceIPA("");
setTargetIPA("");
const params = new URLSearchParams({
text: sourceText,
target: targetLang
})
target: targetLang,
});
fetch(`/api/translate?${params}`)
.then(res => res.json())
.then(obj => {
.then((res) => res.json())
.then((obj) => {
setSourceLocale(obj.source_locale);
setTargetLocale(obj.target_locale);
setTargetText(obj.target_text);
if (ipaEnabled) {
const params = new URLSearchParams({
text: sourceText
text: sourceText,
});
fetch(`/api/ipa?${params}`)
.then(res => res.json())
.then(data => {
.then((res) => res.json())
.then((data) => {
setSourceIPA(data.ipa);
}).catch(e => {
console.error(e);
setSourceIPA('');
})
.catch((e) => {
console.error(e);
setSourceIPA("");
});
const params2 = new URLSearchParams({
text: obj.target_text
text: obj.target_text,
});
fetch(`/api/ipa?${params2}`)
.then(res => res.json())
.then(data => {
.then((res) => res.json())
.then((data) => {
setTargetIPA(data.ipa);
}).catch(e => {
console.error(e);
setTargetIPA('');
})
.catch((e) => {
console.error(e);
setTargetIPA("");
});
}
}).catch(r => {
})
.catch((r) => {
console.error(r);
setSourceLocale('');
setTargetLocale('');
setTargetText('');
}).finally(() => setTranslating(false));
}
setSourceLocale("");
setTargetLocale("");
setTargetText("");
})
.finally(() => setTranslating(false));
};
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
setSourceText(e.target.value.trim());
setTargetText('');
setTargetText("");
setSourceLocale(null);
setTargetLocale(null);
setSourceIPA('');
setTargetIPA('');
}
setSourceIPA("");
setTargetIPA("");
};
const readSource = async () => {
if (sourceText.length === 0) return;
if (sourceIPA.length === 0 && ipaEnabled) {
const params = new URLSearchParams({
text: sourceText
text: sourceText,
});
fetch(`/api/ipa?${params}`)
.then(res => res.json())
.then(data => {
.then((res) => res.json())
.then((data) => {
setSourceIPA(data.ipa);
}).catch(e => {
console.error(e);
setSourceIPA('');
})
.catch((e) => {
console.error(e);
setSourceIPA("");
});
}
if (!sourceLocale) {
try {
const params = new URLSearchParams({
text: sourceText.slice(0, 30)
text: sourceText.slice(0, 30),
});
const res = await fetch(`/api/locale?${params}`);
const info = await res.json();
setSourceLocale(info.locale);
const voice = VOICES.find(v => v.locale.startsWith(info.locale));
const voice = VOICES.find((v) => v.locale.startsWith(info.locale));
if (!voice) {
return;
}
@@ -135,7 +140,7 @@ export default function Translator() {
return;
}
} else {
const voice = VOICES.find(v => v.locale.startsWith(sourceLocale!));
const voice = VOICES.find((v) => v.locale.startsWith(sourceLocale!));
if (!voice) {
return;
}
@@ -144,32 +149,33 @@ export default function Translator() {
await playAudio(url);
URL.revokeObjectURL(url);
}
}
};
const readTarget = async () => {
if (targetText.length === 0) return;
if (targetIPA.length === 0 && ipaEnabled) {
const params = new URLSearchParams({
text: targetText
text: targetText,
});
fetch(`/api/ipa?${params}`)
.then(res => res.json())
.then(data => {
.then((res) => res.json())
.then((data) => {
setTargetIPA(data.ipa);
}).catch(e => {
console.error(e);
setTargetIPA('');
})
.catch((e) => {
console.error(e);
setTargetIPA("");
});
}
const voice = VOICES.find(v => v.locale.startsWith(targetLocale!));
const voice = VOICES.find((v) => v.locale.startsWith(targetLocale!));
if (!voice) return;
const url = await getTTSAudioUrl(targetText, voice.short_name);
await playAudio(url);
URL.revokeObjectURL(url);
}
};
return (
<>
@@ -177,52 +183,100 @@ export default function Translator() {
<div className="w-screen flex flex-col md:flex-row md:justify-between gap-2 p-2">
<div className="card1 w-full md:w-1/2 flex flex-col-reverse gap-2">
<div className="textarea1 border-1 border-gray-200 rounded-2xl w-full h-64 p-2">
<textarea onChange={handleInputChange} className="resize-none h-8/12 w-full focus:outline-0"></textarea>
<textarea
onChange={handleInputChange}
className="resize-none h-8/12 w-full focus:outline-0"
></textarea>
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
{sourceIPA}
</div>
<div className="h-2/12 w-full flex justify-end items-center">
<IconClick onClick={async () => {
if (sourceText.length !== 0)
await navigator.clipboard.writeText(sourceText);
}} src={IMAGES.copy_all} alt="copy"></IconClick>
<IconClick onClick={readSource} src={IMAGES.play_arrow} alt="play"></IconClick>
<IconClick
onClick={async () => {
if (sourceText.length !== 0)
await navigator.clipboard.writeText(sourceText);
}}
src={IMAGES.copy_all}
alt="copy"
></IconClick>
<IconClick
onClick={readSource}
src={IMAGES.play_arrow}
alt="play"
></IconClick>
</div>
</div>
<div className="option1 w-full flex flex-row justify-between items-center">
<span>detect language</span>
<Button selected={ipaEnabled} onClick={() => setIPAEnabled(!ipaEnabled)}>generate ipa</Button>
<Button
selected={ipaEnabled}
onClick={() => setIPAEnabled(!ipaEnabled)}
>
generate ipa
</Button>
</div>
</div>
<div className="card2 w-full md:w-1/2 flex flex-col-reverse gap-2">
<div className="textarea2 bg-gray-100 rounded-2xl w-full h-64 p-2">
<div className="h-8/12 w-full">{
targetText
}</div>
<div className="h-8/12 w-full">{targetText}</div>
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
{targetIPA}
</div>
<div className="h-2/12 w-full flex justify-end items-center">
<IconClick onClick={async () => {
if (targetText.length !== 0)
await navigator.clipboard.writeText(targetText);
}} src={IMAGES.copy_all} alt="copy"></IconClick>
<IconClick onClick={readTarget} src={IMAGES.play_arrow} alt="play"></IconClick>
<IconClick
onClick={async () => {
if (targetText.length !== 0)
await navigator.clipboard.writeText(targetText);
}}
src={IMAGES.copy_all}
alt="copy"
></IconClick>
<IconClick
onClick={readTarget}
src={IMAGES.play_arrow}
alt="play"
></IconClick>
</div>
</div>
<div className="option2 w-full flex gap-1 items-center flex-wrap">
<span>translate into</span>
<Button onClick={() => { setTargetLang('Chinese') }} selected={targetLang === 'Chinese'}>Chinese</Button>
<Button onClick={() => { setTargetLang('English') }} selected={targetLang === 'English'}>English</Button>
<Button onClick={() => { setTargetLang('Italian') }} selected={targetLang === 'Italian'}>Italian</Button>
<Button onClick={inputLanguage} selected={!(tl.includes(targetLang))}>{'Other' + (tl.includes(targetLang) ? '' : ': ' + targetLang)}</Button>
<Button
onClick={() => {
setTargetLang("Chinese");
}}
selected={targetLang === "Chinese"}
>
Chinese
</Button>
<Button
onClick={() => {
setTargetLang("English");
}}
selected={targetLang === "English"}
>
English
</Button>
<Button
onClick={() => {
setTargetLang("Italian");
}}
selected={targetLang === "Italian"}
>
Italian
</Button>
<Button onClick={inputLanguage} selected={!tl.includes(targetLang)}>
{"Other" + (tl.includes(targetLang) ? "" : ": " + targetLang)}
</Button>
</div>
</div>
</div>
<div className="button-area w-screen flex justify-center items-center">
<button onClick={translate} className={`duration-150 ease-in text-xl font-extrabold border rounded-4xl p-3 border-gray-200 h-16 ${translating ? 'bg-gray-200' : 'bg-white hover:bg-gray-200 hover:cursor-pointer'}`}>
{translating ? 'translating...' : 'translate'}
<button
onClick={translate}
className={`duration-150 ease-in text-xl font-extrabold border rounded-4xl p-3 border-gray-200 h-16 ${translating ? "bg-gray-200" : "bg-white hover:bg-gray-200 hover:cursor-pointer"}`}
>
{translating ? "translating..." : "translate"}
</button>
</div>
</>

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

View File

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

View File

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

View File

@@ -1,23 +1,27 @@
import Image from "next/image";
interface IconClickProps {
src: string;
alt: string;
onClick?: () => void;
className?: string;
size?: number
src: string;
alt: string;
onClick?: () => void;
className?: string;
size?: number;
}
export default function IconClick(
{ src, alt, onClick = () => { }, className = '', size = 32 }: IconClickProps) {
return (<>
<div onClick={onClick} className={`hover:cursor-pointer hover:bg-gray-200 rounded-3xl w-[${size}px] h-[${size}px] flex justify-center items-center ${className}`}>
<Image
src={src}
width={size - 5}
height={size - 5}
alt={alt}
></Image>
</div>
</>);
export default function IconClick({
src,
alt,
onClick = () => {},
className = "",
size = 32,
}: IconClickProps) {
return (
<>
<div
onClick={onClick}
className={`hover:cursor-pointer hover:bg-gray-200 rounded-3xl w-[${size}px] h-[${size}px] flex justify-center items-center ${className}`}
>
<Image src={src} width={size - 5} height={size - 5} alt={alt}></Image>
</div>
</>
);
}

View File

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

View File

@@ -1,20 +1,20 @@
const IMAGES = {
speed_1_5x: '/images/speed_1_5x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
speed_1_2_x: '/images/speed_1_2x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
speed_0_7x: '/images/speed_0_7x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
pause: '/images/pause_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
speed_0_5x: '/images/speed_0_5x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
copy_all: '/images/copy_all_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
autoplay: '/images/autoplay_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
autopause: '/images/autopause_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
speed_1x: '/images/1x_mobiledata_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
play_arrow: '/images/play_arrow_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
close: '/images/close_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
refresh: '/images/refresh_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
more_horiz: '/images/more_horiz_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
save: '/images/save_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
delete: '/images/delete_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
speed: '/images/speed_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg',
}
speed_1_5x: "/images/speed_1_5x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
speed_1_2_x: "/images/speed_1_2x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
speed_0_7x: "/images/speed_0_7x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
pause: "/images/pause_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
speed_0_5x: "/images/speed_0_5x_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
copy_all: "/images/copy_all_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
autoplay: "/images/autoplay_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
autopause: "/images/autopause_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
speed_1x: "/images/1x_mobiledata_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
play_arrow: "/images/play_arrow_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
close: "/images/close_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
refresh: "/images/refresh_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
more_horiz: "/images/more_horiz_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
save: "/images/save_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
delete: "/images/delete_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
speed: "/images/speed_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
};
export default IMAGES;
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_HEIGHT = globalThis.innerHeight * 0.68;
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";
export function useAudioPlayer() {
const audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
audioRef.current = new Audio();
return () => {
audioRef.current!.pause();
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 audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
audioRef.current = new Audio();
return () => {
audioRef.current!.pause();
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,
};
}

View File

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