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

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,53 +2,97 @@ 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(
const [index, setIndex] = useState(Math.floor(Math.random() * alphabet.length)); 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
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="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"> <div className="w-full flex justify-end items-center">
<IconClick size={32} alt="close" src={IMAGES.close} onClick={() => setChosenAlphabet(null)}></IconClick> <IconClick
size={32}
alt="close"
src={IMAGES.close}
onClick={() => setChosenAlphabet(null)}
></IconClick>
</div> </div>
<div className="flex flex-col gap-12 justify-center items-center"> <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-7xl md:text-9xl">
<span className="text-5xl md:text-7xl text-gray-400">{ipaDisplay ? letter.letter_sound_ipa : ''}</span> {letterDisplay ? letter.letter : ""}
</span>
<span className="text-5xl md:text-7xl text-gray-400">
{ipaDisplay ? letter.letter_sound_ipa : ""}
</span>
</div> </div>
<div className="flex flex-row mt-32 items-center justify-center gap-2"> <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
<IconClick size={48} alt="more" src={IMAGES.more_horiz} onClick={() => setMore(!more)}></IconClick> size={48}
{ alt="refresh"
more ? (<> src={IMAGES.refresh}
<Button className="w-20" onClick={() => { setLetterDisplay(!letterDisplay) }}>{letterDisplay ? '隐藏字母' : '显示字母'}</Button> onClick={refresh}
<Button className="w-20" onClick={() => { setIPADisplay(!ipaDisplay) }}>{ipaDisplay ? '隐藏IPA' : '显示IPA'}</Button> ></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> </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);
const [alphabetData, setAlphabetData] = useState<
Record<SupportedAlphabets, Letter[] | null>
>({
japanese: null, japanese: null,
english: null, english: null,
esperanto: null, esperanto: null,
uyghur: null uyghur: null,
}); });
const [loadingState, setLoadingState] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); const [loadingState, setLoadingState] = useState<
"idle" | "loading" | "success" | "error"
>("idle");
useEffect(() => { useEffect(() => {
if (chosenAlphabet && !alphabetData[chosenAlphabet]) { if (chosenAlphabet && !alphabetData[chosenAlphabet]) {
setLoadingState('loading'); setLoadingState("loading");
fetch('/alphabets/' + chosenAlphabet + '.json') fetch("/alphabets/" + chosenAlphabet + ".json")
.then(res => { .then((res) => {
if (!res.ok) throw new Error('Network response was not ok'); if (!res.ok) throw new Error("Network response was not ok");
return res.json(); return res.json();
}).then((obj) => { })
setAlphabetData(prev => ({ ...prev, [chosenAlphabet]: obj as Letter[] })); .then((obj) => {
setLoadingState('success'); setAlphabetData((prev) => ({
}).catch(() => { ...prev,
setLoadingState('error'); [chosenAlphabet]: obj as Letter[],
}));
setLoadingState("success");
})
.catch(() => {
setLoadingState("error");
}); });
} }
}, [chosenAlphabet, alphabetData]); }, [chosenAlphabet, alphabetData]);
useEffect(() => { useEffect(() => {
if (loadingState === 'error') { if (loadingState === "error") {
const timer = setTimeout(() => { const timer = setTimeout(() => {
setLoadingState('idle'); setLoadingState("idle");
setChosenAlphabet(null); setChosenAlphabet(null);
}, 2000); }, 2000);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
}, [loadingState]); }, [loadingState]);
if (!chosenAlphabet) return (<> 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> <Navbar></Navbar>
<MemoryCard <MemoryCard
alphabet={alphabetData[chosenAlphabet]} alphabet={alphabetData[chosenAlphabet]}
setChosenAlphabet={setChosenAlphabet}> setChosenAlphabet={setChosenAlphabet}
</MemoryCard> ></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,13 +19,15 @@ async function getIPA(text: string) {
ipa一定要加[] ipa一定要加[]
locale如果可能有多个选取最可能的一个其中使用符号"-" locale如果可能有多个选取最可能的一个其中使用符号"-"
locale如果推断失败就返回{"locale": "en-US"} locale如果推断失败就返回{"locale": "en-US"}
` `,
}]; },
];
try { try {
const response = await callZhipuAPI(messages); const response = await callZhipuAPI(messages);
let to_parse = response.choices[0].message.content.trim() as string; 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.startsWith("`"))
if (to_parse.length === 0) throw Error('ai啥也每说'); to_parse = to_parse.slice(7, to_parse.length - 3);
if (to_parse.length === 0) throw Error("ai啥也每说");
return JSON.parse(to_parse); return JSON.parse(to_parse);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -35,12 +38,12 @@ locale如果推断失败就返回{"locale": "en-US"}
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 },
); );
} }
@@ -48,17 +51,12 @@ export async function GET(request: NextRequest) {
if (!textInfo) { if (!textInfo) {
return NextResponse.json( return NextResponse.json(
{ error: "服务暂时不可用", message: "LLM API 请求失败" }, { error: "服务暂时不可用", message: "LLM API 请求失败" },
{ status: 503 } { status: 503 },
); );
} }
return NextResponse.json(textInfo, { status: 200 }); return NextResponse.json(textInfo, { status: 200 });
} catch (error) { } catch (error) {
console.error('API 错误:', error); handleAPIError(error, "请稍后再试");
return NextResponse.json(
{ error: "服务器内部错误", message: "请稍后重试" },
{ status: 500 }
);
} }
} }

View File

@@ -5,7 +5,8 @@ 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,13 +17,15 @@ async function getLocale(text: string) {
直接返回json文本 直接返回json文本
locale如果可能有多个选取最可能的一个其中使用符号"-" locale如果可能有多个选取最可能的一个其中使用符号"-"
locale如果推断失败就返回{"locale": "en-US"} locale如果推断失败就返回{"locale": "en-US"}
` `,
}]; },
];
try { try {
const response = await callZhipuAPI(messages); const response = await callZhipuAPI(messages);
let to_parse = response.choices[0].message.content.trim() as string; 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.startsWith("`"))
if (to_parse.length === 0) throw Error('ai啥也每说'); to_parse = to_parse.slice(7, to_parse.length - 3);
if (to_parse.length === 0) throw Error("ai啥也每说");
return JSON.parse(to_parse); return JSON.parse(to_parse);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -33,12 +36,12 @@ locale如果推断失败就返回{"locale": "en-US"}
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 },
); );
} }
@@ -46,17 +49,16 @@ export async function GET(request: NextRequest) {
if (!textInfo) { if (!textInfo) {
return NextResponse.json( return NextResponse.json(
{ error: "服务暂时不可用", message: "LLM API 请求失败" }, { error: "服务暂时不可用", message: "LLM API 请求失败" },
{ status: 503 } { status: 503 },
); );
} }
return NextResponse.json(textInfo, { status: 200 }); return NextResponse.json(textInfo, { status: 200 });
} catch (error) { } catch (error) {
console.error('API 错误:', error); console.error("API 错误:", error);
return NextResponse.json( return NextResponse.json(
{ error: "服务器内部错误", message: "请稍后重试" }, { error: "服务器内部错误", message: "请稍后重试" },
{ status: 500 } { 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", message: "Hello World",
url: url url: url,
}, { status: 200 }); },
{ status: 200 },
);
} }

View File

@@ -5,7 +5,8 @@ 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,13 +21,15 @@ async function getTextinfo(text: string) {
ipa一定要加[] ipa一定要加[]
lang的值是小写字母的英文的语言名称 lang的值是小写字母的英文的语言名称
locale如果可能有多个选取最可能的一个其中使用符号"-" locale如果可能有多个选取最可能的一个其中使用符号"-"
` `,
}]; },
];
try { try {
const response = await callZhipuAPI(messages); const response = await callZhipuAPI(messages);
let to_parse = response.choices[0].message.content.trim() as string; 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.startsWith("`"))
if (to_parse.length === 0) throw Error('ai啥也每说'); to_parse = to_parse.slice(7, to_parse.length - 3);
if (to_parse.length === 0) throw Error("ai啥也每说");
return JSON.parse(to_parse); return JSON.parse(to_parse);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -37,12 +40,12 @@ locale如果可能有多个选取最可能的一个其中使用符号"-"
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 },
); );
} }
@@ -50,15 +53,15 @@ export async function GET(request: NextRequest) {
if (!textInfo) { if (!textInfo) {
return NextResponse.json( return NextResponse.json(
{ error: "服务暂时不可用", message: "LLM API 请求失败" }, { error: "服务暂时不可用", message: "LLM API 请求失败" },
{ status: 503 } { status: 503 },
); );
} }
return NextResponse.json(textInfo, { status: 200 }); return NextResponse.json(textInfo, { status: 200 });
} catch (error) { } catch (error) {
console.error('API 错误:', error); console.error("API 错误:", error);
return NextResponse.json( return NextResponse.json(
{ error: "服务器内部错误", message: "请稍后重试" }, { error: "服务器内部错误", message: "请稍后重试" },
{ status: 500 } { status: 500 },
); );
} }
} }

View File

@@ -5,7 +5,8 @@ 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,13 +19,15 @@ async function translate(text: string, target_lang: string) {
直接返回json文本 直接返回json文本
locale如果可能有多个选取最可能的一个其中使用符号"-" locale如果可能有多个选取最可能的一个其中使用符号"-"
locale如果推断失败就当作是en-US locale如果推断失败就当作是en-US
` `,
}]; },
];
try { try {
const response = await callZhipuAPI(messages); const response = await callZhipuAPI(messages);
let to_parse = response.choices[0].message.content.trim() as string; 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.startsWith("`"))
if (to_parse.length === 0) throw Error('ai啥也每说'); to_parse = to_parse.slice(7, to_parse.length - 3);
if (to_parse.length === 0) throw Error("ai啥也每说");
return JSON.parse(to_parse); return JSON.parse(to_parse);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -35,13 +38,13 @@ locale如果推断失败就当作是en-US
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 },
); );
} }
@@ -49,15 +52,15 @@ export async function GET(request: NextRequest) {
if (!textInfo) { if (!textInfo) {
return NextResponse.json( return NextResponse.json(
{ error: "服务暂时不可用", message: "LLM API 请求失败" }, { error: "服务暂时不可用", message: "LLM API 请求失败" },
{ status: 503 } { status: 503 },
); );
} }
return NextResponse.json(textInfo, { status: 200 }); return NextResponse.json(textInfo, { status: 200 });
} catch (error) { } catch (error) {
console.error('API 错误:', error); console.error("API 错误:", error);
return NextResponse.json( return NextResponse.json(
{ error: "服务器内部错误", message: "请稍后重试" }, { error: "服务器内部错误", message: "请稍后重试" },
{ status: 500 } { status: 500 },
); );
} }
} }

View File

@@ -29,4 +29,4 @@ body {
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,47 +1,54 @@
'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
className={`w-[61vw] h-96 p-2 shadow-2xl bg-[#00BCD4] rounded-xl ${className}`}
>
{children} {children}
</div>); </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<
"choose" | "start" | "main" | "edit"
>("edit");
const [wordData, setWordData] = useState<WordData>({ const [wordData, setWordData] = useState<WordData>({
locale1: 'en-US', locale1: "en-US",
locale2: 'zh-CN', locale2: "zh-CN",
data: { 'hello': '你好' } data: { hello: "你好" },
}); });
if (pageState === 'main') { if (pageState === "main") {
return (<> return (
<>
<div className="w-full h-screen flex justify-center items-center"> <div className="w-full h-screen flex justify-center items-center">
<ACard> <ACard>
<h1 className="text-center font-extrabold text-4xl text-white m-2 mb-4"> <h1 className="text-center font-extrabold text-4xl text-white m-2 mb-4">
@@ -59,73 +66,77 @@ export default function Memorize() {
<Button>Start</Button> <Button>Start</Button>
<Button>Load</Button> <Button>Load</Button>
<Button>Save</Button> <Button>Save</Button>
<Button onClick={() => setPageState('edit')}>Edit</Button> <Button onClick={() => setPageState("edit")}>Edit</Button>
</BCard> </BCard>
</div> </div>
</ACard> </ACard>
</div> </div>
</>); </>
);
} }
if (pageState === 'choose') { if (pageState === "choose") {
return (<> return <></>;
</>);
} }
if (pageState === 'start') { if (pageState === "start") {
return (<> return <></>;
</>);
} }
if (pageState === 'edit') { if (pageState === "edit") {
const convertIntoWordData = (text: string) => { const convertIntoWordData = (text: string) => {
const t1 = text.split('\n').map(v => v.trim()).filter(v => v.includes(',')); const t1 = text
const t2 = t1.map(v => { .split("\n")
const [left, right] = v.split(',', 2).map(v => v.trim()); .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) if (left && right)
return { return {
[left]: right [left]: right,
}; };
else return {}; else return {};
}); });
const new_data = { const new_data = {
locale1: wordData.locale1, locale1: wordData.locale1,
locale2: wordData.locale2, locale2: wordData.locale2,
data: Object.assign({}, ...t2) data: Object.assign({}, ...t2),
}; };
setWordData(new_data); setWordData(new_data);
} };
const convertFromWordData = () => { const convertFromWordData = () => {
let result = ''; let result = "";
for (const k in wordData.data) { for (const k in wordData.data) {
result += `${k}, ${wordData.data[k]}\n`; result += `${k}, ${wordData.data[k]}\n`;
} }
return result; return result;
} };
let input = convertFromWordData(); let input = convertFromWordData();
const handleSave = () => { const handleSave = () => {
convertIntoWordData(input); convertIntoWordData(input);
setPageState('main'); setPageState("main");
} };
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => { const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
input = e.target.value; input = e.target.value;
} };
return (<> return (
<>
<div className="w-full h-screen flex flex-col justify-center items-center"> <div className="w-full h-screen flex flex-col justify-center items-center">
<ACard className=""> <ACard className="">
<textarea className="text-white border-gray-200 border rounded-2xl w-full h-50 resize-none outline-0 p-2" <textarea
className="text-white border-gray-200 border rounded-2xl w-full h-50 resize-none outline-0 p-2"
defaultValue={input} defaultValue={input}
onChange={handleChange}></textarea> onChange={handleChange}
></textarea>
<div className="w-full flex items-center justify-center"> <div className="w-full flex items-center justify-center">
<BCard className="flex gap-2 justify-center items-center w-fit"> <BCard className="flex gap-2 justify-center items-center w-fit">
<Button>choose locale1</Button> <Button>choose locale1</Button>
<Button>choose locale2</Button> <Button>choose locale2</Button>
<Button onClick={() => setPageState('main')}>Cancel</Button> <Button onClick={() => setPageState("main")}>Cancel</Button>
<Button onClick={handleSave}>Save</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 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
</button> </button>
</BCard> </BCard>
</div> </div>
<div className="w-48"> <div className="w-48"></div>
</div>
</ACard> </ACard>
</div> </div>
@@ -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

@@ -5,17 +5,15 @@ export default function SubtitleDisplay({ subtitle }: { subtitle: string }) {
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

@@ -8,22 +8,25 @@ type VideoPanelProps = {
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(() => {
@@ -41,51 +44,53 @@ const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>((
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); document.addEventListener("keydown", handleKeyDownEvent);
return () => document.removeEventListener('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 (autoPause && ct >= (srt[index].end - 0.05) && ct < srt[index].end) { if (
autoPause &&
ct >= srt[index].end - 0.05 &&
ct < srt[index].end
) {
videoRef.current!.currentTime = srt[index].start; videoRef.current!.currentTime = srt[index].start;
togglePlayPause(); togglePlayPause();
} }
} else { } else {
setSubtitle(''); setSubtitle("");
} }
} else { } else {
;
} }
} }
rafldRef.current = requestAnimationFrame(cb); rafldRef.current = requestAnimationFrame(cb);
} };
rafldRef.current = requestAnimationFrame(cb); rafldRef.current = requestAnimationFrame(cb);
return () => { return () => {
cancelAnimationFrame(rafldRef.current); cancelAnimationFrame(rafldRef.current);
} };
}, [autoPause, isPlaying, togglePlayPause, videoRef]); }, [autoPause, isPlaying, togglePlayPause, videoRef]);
useEffect(() => { useEffect(() => {
@@ -93,32 +98,36 @@ const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>((
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(
parsedSrtRef.current,
videoRef.current.currentTime,
);
if (!index) return; if (!index) return;
setSpanText(`${index + 1}/${parsedSrtRef.current.length}`) 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 =
parsedSrtRef.current[newProgress]?.start || 0;
setProgress(newProgress); setProgress(newProgress);
} }
}; };
@@ -129,51 +138,77 @@ const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>((
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(
parsedSrtRef.current,
videoRef.current.currentTime,
);
if (i != null && i + 1 < parsedSrtRef.current.length) { if (i != null && i + 1 < parsedSrtRef.current.length) {
videoRef.current.currentTime = parsedSrtRef.current[i + 1].start; videoRef.current.currentTime = parsedSrtRef.current[i + 1].start;
videoRef.current.play(); videoRef.current.play();
setIsPlaying(true); 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(
parsedSrtRef.current,
videoRef.current.currentTime,
);
if (i != null && i - 1 >= 0) { if (i != null && i - 1 >= 0) {
videoRef.current.currentTime = parsedSrtRef.current[i - 1].start; videoRef.current.currentTime = parsedSrtRef.current[i - 1].start;
videoRef.current.play(); videoRef.current.play();
setIsPlaying(true); 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(
parsedSrtRef.current,
videoRef.current.currentTime,
);
if (i != null && i >= 0) { if (i != null && i >= 0) {
videoRef.current.currentTime = parsedSrtRef.current[i].start; videoRef.current.currentTime = parsedSrtRef.current[i].start;
videoRef.current.play(); videoRef.current.play();
setIsPlaying(true); 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
className="bg-gray-200"
ref={videoRef}
onTimeUpdate={timeUpdate}
></video>
<SubtitleDisplay subtitle={subtitle}></SubtitleDisplay> <SubtitleDisplay subtitle={subtitle}></SubtitleDisplay>
<div className="buttons flex mt-2 gap-2 flex-wrap"> <div className="buttons flex mt-2 gap-2 flex-wrap">
<Button onClick={togglePlayPause}>{isPlaying ? '暂停' : '播放'}</Button> <Button onClick={togglePlayPause}>
{isPlaying ? "暂停" : "播放"}
</Button>
<Button onClick={previous}></Button> <Button onClick={previous}></Button>
<Button onClick={next}></Button> <Button onClick={next}></Button>
<Button onClick={restart}></Button> <Button onClick={restart}></Button>
<Button onClick={handleAutoPauseToggle}>{`自动暂停(${autoPause ? '是' : '否'})`}</Button> <Button
onClick={handleAutoPauseToggle}
>{`自动暂停(${autoPause ? "是" : "否"})`}</Button>
</div> </div>
<input className="seekbar" type="range" min={0} max={srtLength} onChange={handleSeek} step={1} value={progress}></input> <input
className="seekbar"
type="range"
min={0}
max={srtLength}
onChange={handleSeek}
step={1}
value={progress}
></input>
<span>{spanText}</span> <span>{spanText}</span>
</div> </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,20 +1,28 @@
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(
"(\\d{2}:\\d{2}:\\d{2},\\d{3})\\s*-->\\s*(\\d{2}:\\d{2}:\\d{2},\\d{3})",
);
let i = 0; let i = 0;
while (i < lines.length) { while (i < lines.length) {
if (!lines[i].trim()) { i++; continue; } if (!lines[i].trim()) {
i++;
continue;
}
i++; i++;
if (i >= lines.length) break; if (i >= lines.length) break;
const timeMatch = lines[i].match(re); const timeMatch = lines[i].match(re);
if (!timeMatch) { i++; continue; } if (!timeMatch) {
i++;
continue;
}
const start = toSeconds(timeMatch[1]); const start = toSeconds(timeMatch[1]);
const end = toSeconds(timeMatch[2]); const end = toSeconds(timeMatch[2]);
i++; i++;
let text = ''; let text = "";
while (i < lines.length && lines[i].trim()) { while (i < lines.length && lines[i].trim()) {
text += lines[i] + '\n'; text += lines[i] + "\n";
i++; i++;
} }
result.push({ start, end, text: text.trim() }); result.push({ start, end, text: text.trim() });
@@ -23,17 +31,23 @@ export function parseSrt(data: string) {
return result; return result;
} }
export function getNearistIndex(srt: { start: number; end: number; text: string; }[], ct: number) { export function getNearistIndex(
srt: { start: number; end: number; text: string }[],
ct: number,
) {
for (let i = 0; i < srt.length; i++) { for (let i = 0; i < srt.length; i++) {
const s = srt[i]; const s = srt[i];
const l = ct - s.start >= 0; const l = ct - s.start >= 0;
const r = ct - s.end >= 0; const r = ct - s.end >= 0;
if (!(l || r)) return i - 1; if (!(l || r)) return i - 1;
if (l && (!r)) return i; if (l && !r) return i;
} }
} }
export function getIndex(srt: { start: number; end: number; text: string; }[], ct: number) { export function getIndex(
srt: { start: number; end: number; text: string }[],
ct: number,
) {
for (let i = 0; i < srt.length; i++) { for (let i = 0; i < srt.length; i++) {
if (ct >= srt[i].start && ct <= srt[i].end) { if (ct >= srt[i].start && ct <= srt[i].end) {
return i; return i;
@@ -42,11 +56,19 @@ export function getIndex(srt: { start: number; end: number; text: string; }[], c
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";
@@ -12,22 +12,22 @@ interface TextCardProps {
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,
handleUse,
handleDel
}: TextCardProps) {
const onUseClick = () => { const onUseClick = () => {
handleUse(item); handleUse(item);
} };
const onDelClick = () => { const onDelClick = () => {
handleDel(item); handleDel(item);
} };
return ( return (
<div className="p-2 border-b-1 border-gray-200 rounded-2xl bg-gray-100 m-2 grid grid-cols-8"> <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="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-26 hover:cursor-pointer text-3xl overflow-y-auto">
<div className="max-h-16 overflow-y-auto text-xl text-gray-600 whitespace-nowrap overflow-x-auto">{item.ipa}</div> {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>
<div className="flex justify-center items-center border-gray-300 border-l-2 m-2"> <div className="flex justify-center items-center border-gray-300 border-l-2 m-2">
<IconClick <IconClick
@@ -35,8 +35,8 @@ function TextCard({
alt="delete" alt="delete"
onClick={onDelClick} onClick={onDelClick}
className="place-self-center" className="place-self-center"
size={42}> size={42}
</IconClick> ></IconClick>
</div> </div>
</div> </div>
); );
@@ -46,50 +46,60 @@ 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,
handleUse
}: SaveListProps) {
const [data, setData] = useState(getTextSpeakerData()); const [data, setData] = useState(getTextSpeakerData());
const handleDel = (item: z.infer<typeof TextSpeakerItemSchema>) => { const handleDel = (item: z.infer<typeof TextSpeakerItemSchema>) => {
const current_data = getTextSpeakerData(); const current_data = getTextSpeakerData();
current_data.splice( current_data.splice(
current_data.findIndex(v => v.text === item.text), 1 current_data.findIndex((v) => v.text === item.text),
1,
); );
setTextSpeakerData(current_data); setTextSpeakerData(current_data);
refresh(); refresh();
} };
const refresh = () => { const refresh = () => {
setData(getTextSpeakerData()); setData(getTextSpeakerData());
} };
const handleDeleteAll = () => { const handleDeleteAll = () => {
const yesorno = prompt('确定删光吗?(Y/N)')?.trim(); const yesorno = prompt("确定删光吗?(Y/N)")?.trim();
if (yesorno && (yesorno === 'Y' || yesorno === 'y')) { if (yesorno && (yesorno === "Y" || yesorno === "y")) {
setTextSpeakerData([]); setTextSpeakerData([]);
refresh(); refresh();
} }
} };
if (show) return ( if (show)
<div className="my-4 p-2 mx-4 md:mx-32 border-1 border-gray-200 rounded-2xl" style={{ fontFamily: 'Times New Roman, serif' }}> 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"> <div className="flex flex-row justify-center gap-8 items-center">
<IconClick <IconClick
src={IMAGES.refresh} src={IMAGES.refresh}
alt="refresh" alt="refresh"
onClick={refresh} onClick={refresh}
size={48} size={48}
className=""></IconClick> className=""
></IconClick>
<IconClick <IconClick
src={IMAGES.delete} src={IMAGES.delete}
alt="delete" alt="delete"
onClick={handleDeleteAll} onClick={handleDeleteAll}
size={48} size={48}
className=""></IconClick> className=""
></IconClick>
</div> </div>
<ul> <ul>
{data.map(v => {data.map((v) => (
<TextCard item={v} key={crypto.randomUUID()} handleUse={handleUse} handleDel={handleDel}></TextCard> <TextCard
)} item={v}
key={crypto.randomUUID()}
handleUse={handleUse}
handleDel={handleDel}
></TextCard>
))}
</ul> </ul>
</div> </div>
); else return (<></>); );
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";
@@ -21,9 +25,9 @@ export default function TextSpeaker() {
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();
@@ -37,10 +41,10 @@ export default function TextSpeaker() {
} 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]);
@@ -51,16 +55,17 @@ export default function TextSpeaker() {
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);
setIPA('');
}) })
.catch((e) => {
console.error(e);
setIPA("");
});
} }
if (pause) { if (pause) {
@@ -78,29 +83,34 @@ export default function TextSpeaker() {
try { 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 (
await fetch(`/api/locale?${params}`)
).json();
setLocale(textinfo.locale); setLocale(textinfo.locale);
theLocale = textinfo.locale as string; theLocale = textinfo.locale as string;
} }
const voice = VOICES.find(v => v.locale.startsWith(theLocale)); const voice = VOICES.find((v) => v.locale.startsWith(theLocale));
if (!voice) throw 'Voice not found.'; if (!voice) throw "Voice not found.";
objurlRef.current = await getTTSAudioUrl( objurlRef.current = await getTTSAudioUrl(
textRef.current, textRef.current,
voice.short_name, voice.short_name,
(() => { (() => {
if (speed === 1) return {}; if (speed === 1) return {};
else if (speed < 1) return { else if (speed < 1)
rate: `-${100 - speed * 100}%` return {
}; else return { rate: `-${100 - speed * 100}%`,
rate: `+${speed * 100 - 100}%`
}; };
})() else
return {
rate: `+${speed * 100 - 100}%`,
};
})(),
); );
playAudio(objurlRef.current); playAudio(objurlRef.current);
} catch (e) { } catch (e) {
@@ -120,17 +130,17 @@ export default function TextSpeaker() {
} }
setProcessing(false); setProcessing(false);
} };
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => { const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
textRef.current = e.target.value.trim(); textRef.current = e.target.value.trim();
setLocale(null); setLocale(null);
setIPA(''); setIPA("");
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current); if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
objurlRef.current = null; objurlRef.current = null;
stopAudio(); stopAudio();
setPause(true); setPause(true);
} };
const letMeSetSpeed = (new_speed: number) => { const letMeSetSpeed = (new_speed: number) => {
return () => { return () => {
@@ -139,19 +149,19 @@ export default function TextSpeaker() {
objurlRef.current = null; objurlRef.current = null;
stopAudio(); stopAudio();
setPause(true); setPause(true);
} };
} };
const handleUseItem = (item: z.infer<typeof TextSpeakerItemSchema>) => { const handleUseItem = (item: z.infer<typeof TextSpeakerItemSchema>) => {
if (textareaRef.current) textareaRef.current.value = item.text; if (textareaRef.current) textareaRef.current.value = item.text;
textRef.current = item.text; textRef.current = item.text;
setLocale(item.locale); setLocale(item.locale);
setIPA(item.ipa || ''); setIPA(item.ipa || "");
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current); if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
objurlRef.current = null; objurlRef.current = null;
stopAudio(); stopAudio();
setPause(true); setPause(true);
} };
const save = async () => { const save = async () => {
if (saving) return; if (saving) return;
@@ -162,9 +172,9 @@ export default function TextSpeaker() {
try { 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 (await fetch(`/api/locale?${params}`)).json();
setLocale(textinfo.locale); setLocale(textinfo.locale);
@@ -174,7 +184,7 @@ export default function TextSpeaker() {
let theIPA = ipa; let theIPA = ipa;
if (ipa.length === 0 && ipaEnabled) { if (ipa.length === 0 && ipaEnabled) {
const params = new URLSearchParams({ const params = new URLSearchParams({
text: textRef.current text: textRef.current,
}); });
const tmp = await (await fetch(`/api/ipa?${params}`)).json(); const tmp = await (await fetch(`/api/ipa?${params}`)).json();
setIPA(tmp.ipa); setIPA(tmp.ipa);
@@ -182,11 +192,11 @@ export default function TextSpeaker() {
} }
const save = getTextSpeakerData(); const save = getTextSpeakerData();
const oldIndex = save.findIndex(v => v.text === textRef.current); const oldIndex = save.findIndex((v) => v.text === textRef.current);
if (oldIndex !== -1) { if (oldIndex !== -1) {
const oldItem = save[oldIndex]; const oldItem = save[oldIndex];
if (theIPA) { if (theIPA) {
if ((!oldItem.ipa || (oldItem.ipa !== theIPA))) { if (!oldItem.ipa || oldItem.ipa !== theIPA) {
oldItem.ipa = theIPA; oldItem.ipa = theIPA;
setTextSpeakerData(save); setTextSpeakerData(save);
} }
@@ -194,13 +204,13 @@ export default function TextSpeaker() {
} else if (theIPA.length === 0) { } else if (theIPA.length === 0) {
save.push({ save.push({
text: textRef.current, text: textRef.current,
locale: theLocale locale: theLocale,
}); });
} else { } else {
save.push({ save.push({
text: textRef.current, text: textRef.current,
locale: theLocale, locale: theLocale,
ipa: theIPA ipa: theIPA,
}); });
} }
setTextSpeakerData(save); setTextSpeakerData(save);
@@ -210,80 +220,117 @@ export default function TextSpeaker() {
} finally { } finally {
setSaving(false); setSaving(false);
} }
} };
return (<> return (
<>
<Navbar></Navbar> <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' }}> <div
<textarea className="text-2xl resize-none focus:outline-0 min-h-64 w-full border-gray-200 border-b" 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} onChange={handleInputChange}
ref={textareaRef}> ref={textareaRef}
</textarea> ></textarea>
{ {(ipa.length !== 0 && (
ipa.length !== 0 && (<div className="overflow-auto text-gray-600 h-18 border-gray-200 border-b"> <div className="overflow-auto text-gray-600 h-18 border-gray-200 border-b">
{ipa} {ipa}
</div>) || (<div className="h-18"></div>) </div>
} )) || <div className="h-18"></div>}
<div className="mt-8 relative w-full flex flex-row flex-wrap gap-2 justify-center items-center"> <div className="mt-8 relative w-full flex flex-row flex-wrap gap-2 justify-center items-center">
{showSpeedAdjust && ( {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"> <div className="bg-white p-6 rounded-2xl border-gray-200 border-2 shadow-2xl absolute left-1/2 -translate-x-1/2 -translate-y-full -top-4 flex flex-row flex-wrap gap-2 justify-center items-center">
<IconClick size={45} onClick={letMeSetSpeed(0.5)} <IconClick
size={45}
onClick={letMeSetSpeed(0.5)}
src={IMAGES.speed_0_5x} src={IMAGES.speed_0_5x}
alt="0.5x" alt="0.5x"
className={speed === 0.5 ? 'bg-gray-200' : ''} className={speed === 0.5 ? "bg-gray-200" : ""}
></IconClick> ></IconClick>
<IconClick size={45} onClick={letMeSetSpeed(0.7)} <IconClick
size={45}
onClick={letMeSetSpeed(0.7)}
src={IMAGES.speed_0_7x} src={IMAGES.speed_0_7x}
alt="0.7x" alt="0.7x"
className={speed === 0.7 ? 'bg-gray-200' : ''} className={speed === 0.7 ? "bg-gray-200" : ""}
></IconClick> ></IconClick>
<IconClick size={45} onClick={letMeSetSpeed(1)} <IconClick
size={45}
onClick={letMeSetSpeed(1)}
src={IMAGES.speed_1x} src={IMAGES.speed_1x}
alt="1x" alt="1x"
className={speed === 1 ? 'bg-gray-200' : ''} className={speed === 1 ? "bg-gray-200" : ""}
></IconClick> ></IconClick>
<IconClick size={45} onClick={letMeSetSpeed(1.2)} <IconClick
size={45}
onClick={letMeSetSpeed(1.2)}
src={IMAGES.speed_1_2_x} src={IMAGES.speed_1_2_x}
alt="1.2x" alt="1.2x"
className={speed === 1.2 ? 'bg-gray-200' : ''} className={speed === 1.2 ? "bg-gray-200" : ""}
></IconClick> ></IconClick>
<IconClick size={45} onClick={letMeSetSpeed(1.5)} <IconClick
size={45}
onClick={letMeSetSpeed(1.5)}
src={IMAGES.speed_1_5x} src={IMAGES.speed_1_5x}
alt="1.5x" alt="1.5x"
className={speed === 1.5 ? 'bg-gray-200' : ''} className={speed === 1.5 ? "bg-gray-200" : ""}
></IconClick> ></IconClick>
</div>)} </div>
<IconClick size={45} onClick={speak} src={ )}
pause ? IMAGES.play_arrow : IMAGES.pause <IconClick
} alt="playorpause" className={`${processing ? 'bg-gray-200' : ''}`}></IconClick> size={45}
<IconClick size={45} onClick={() => { onClick={speak}
setAutopause(!autopause); if (objurlRef) { stopAudio(); } setPause(true); src={pause ? IMAGES.play_arrow : IMAGES.pause}
}} src={ alt="playorpause"
autopause ? IMAGES.autoplay : IMAGES.autopause className={`${processing ? "bg-gray-200" : ""}`}
} alt="autoplayorpause"
></IconClick> ></IconClick>
<IconClick size={45} onClick={() => setShowSpeedAdjust(!showSpeedAdjust)} <IconClick
size={45}
onClick={() => {
setAutopause(!autopause);
if (objurlRef) {
stopAudio();
}
setPause(true);
}}
src={autopause ? IMAGES.autoplay : IMAGES.autopause}
alt="autoplayorpause"
></IconClick>
<IconClick
size={45}
onClick={() => setShowSpeedAdjust(!showSpeedAdjust)}
src={IMAGES.speed} src={IMAGES.speed}
alt="speed" alt="speed"
className={`${showSpeedAdjust ? 'bg-gray-200' : ''}`}></IconClick> className={`${showSpeedAdjust ? "bg-gray-200" : ""}`}
<IconClick size={45} onClick={save} ></IconClick>
<IconClick
size={45}
onClick={save}
src={IMAGES.save} src={IMAGES.save}
alt="save" alt="save"
className={`${saving ? 'bg-gray-200' : ''}`}></IconClick> className={`${saving ? "bg-gray-200" : ""}`}
></IconClick>
<div className="w-full flex flex-row flex-wrap gap-2 justify-center items-center"> <div className="w-full flex flex-row flex-wrap gap-2 justify-center items-center">
<Button <Button
selected={ipaEnabled} selected={ipaEnabled}
onClick={() => setIPAEnabled(!ipaEnabled)}> onClick={() => setIPAEnabled(!ipaEnabled)}
>
IPA IPA
</Button> </Button>
<Button <Button
onClick={() => { setShowSaveList(!showSaveList) }} onClick={() => {
selected={showSaveList}> setShowSaveList(!showSaveList);
}}
selected={showSaveList}
>
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
<SaveList show={showSaveList} handleUse={handleUseItem}></SaveList> <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
onClick={async () => {
if (sourceText.length !== 0) if (sourceText.length !== 0)
await navigator.clipboard.writeText(sourceText); await navigator.clipboard.writeText(sourceText);
}} src={IMAGES.copy_all} alt="copy"></IconClick> }}
<IconClick onClick={readSource} src={IMAGES.play_arrow} alt="play"></IconClick> src={IMAGES.copy_all}
alt="copy"
></IconClick>
<IconClick
onClick={readSource}
src={IMAGES.play_arrow}
alt="play"
></IconClick>
</div> </div>
</div> </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
onClick={async () => {
if (targetText.length !== 0) if (targetText.length !== 0)
await navigator.clipboard.writeText(targetText); await navigator.clipboard.writeText(targetText);
}} src={IMAGES.copy_all} alt="copy"></IconClick> }}
<IconClick onClick={readTarget} src={IMAGES.play_arrow} alt="play"></IconClick> src={IMAGES.copy_all}
alt="copy"
></IconClick>
<IconClick
onClick={readTarget}
src={IMAGES.play_arrow}
alt="play"
></IconClick>
</div> </div>
</div> </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,
selectWord,
}: {
words: [ words: [
{ {
word: string, word: string;
x: number, x: number;
y: number y: number;
} },
], ];
setWords: Dispatch<SetStateAction<Word[]>>, setWords: Dispatch<SetStateAction<Word[]>>;
selectWord: (word: string) => void selectWord: (word: string) => void;
} }) {
) {
function DraggableWord({ word }: { word: Word }) { function DraggableWord({ word }: { word: Word }) {
return (<span return (
<span
style={{ style={{
left: `${Math.floor(word.x * (BOARD_WIDTH - TEXT_WIDTH * word.word.length))}px`, left: `${Math.floor(word.x * (BOARD_WIDTH - TEXT_WIDTH * word.word.length))}px`,
top: `${Math.floor(word.y * (BOARD_HEIGHT - TEXT_SIZE))}px`, top: `${Math.floor(word.y * (BOARD_HEIGHT - TEXT_SIZE))}px`,
fontSize: `${TEXT_SIZE}px` fontSize: `${TEXT_SIZE}px`,
}} }}
className="select-none cursor-pointer absolute code-block border-amber-100 border-1" className="select-none cursor-pointer absolute code-block border-amber-100 border-1"
// onClick={inspect(word.word)}>{word.word}</span>)) // onClick={inspect(word.word)}>{word.word}</span>))
onClick={() => { selectWord(word.word); }}>{word.word}</span>); onClick={() => {
selectWord(word.word);
}}
>
{word.word}
</span>
);
} }
return ( return (
<div style={{ <div
style={{
width: `${BOARD_WIDTH}px`, width: `${BOARD_WIDTH}px`,
height: `${BOARD_HEIGHT}px` height: `${BOARD_HEIGHT}px`,
}} className="relative rounded bg-white"> }}
className="relative rounded bg-white"
>
{words.map( {words.map(
(v: { (
word: string, v: {
x: number, word: string;
y: number x: number;
}, i: number) => { y: number;
return (<DraggableWord word={v} key={i}></DraggableWord>) },
})} i: number,
) => {
return <DraggableWord word={v} key={i}></DraggableWord>;
},
)}
</div> </div>
) );
} }

View File

@@ -1,17 +1,21 @@
'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',
@@ -23,113 +27,115 @@ export default function WordBoard() {
] 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, word: ww.word,
x: Math.floor(ww.x * (BOARD_WIDTH - TEXT_WIDTH * ww.word.length)), x: Math.floor(ww.x * (BOARD_WIDTH - TEXT_WIDTH * ww.word.length)),
y: Math.floor(ww.y * (BOARD_HEIGHT - TEXT_SIZE)) y: Math.floor(ww.y * (BOARD_HEIGHT - TEXT_SIZE)),
} as Word); }) 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,17 +2,17 @@ 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
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> </div>
</>); </>
);
} }

View File

@@ -1,29 +1,32 @@
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,6 +1,5 @@
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(() => {
@@ -29,6 +28,6 @@ export function useAudioPlayer() {
playAudio, playAudio,
pauseAudio, pauseAudio,
stopAudio, stopAudio,
audioRef 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 { }
export interface Letter {
letter: string; letter: string;
letter_name_ipa: string; letter_name_ipa: string;
letter_sound_ipa: string; letter_sound_ipa: string;
roman_letter?: string; roman_letter?: string;
} }
export type SupportedAlphabets = 'japanese' | 'english' | 'esperanto' | 'uyghur'; 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,38 +2,42 @@ 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) {
@@ -43,7 +47,11 @@ export async function callZhipuAPI(messages: { role: string; content: string; }[
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(
text: string,
short_name: string,
options: ProsodyOptions | undefined = undefined,
) {
const tts = new EdgeTTS(text, short_name, options); const tts = new EdgeTTS(text, short_name, options);
try { try {
const result = await tts.synthesize(); const result = await tts.synthesize();
@@ -54,7 +62,7 @@ export async function getTTSAudioUrl(text: string, short_name: string, options:
} }
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 [];
@@ -64,15 +72,24 @@ export const getTextSpeakerData = () => {
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) { } catch (e) {
console.error('Failed to parse text-speaker data:', e); console.error("Failed to parse text-speaker data:", e);
return []; return [];
} }
}; };
export const setTextSpeakerData = (data: z.infer<typeof TextSpeakerArraySchema>) => { export const setTextSpeakerData = (
data: z.infer<typeof TextSpeakerArraySchema>,
) => {
if (!localStorage) return; if (!localStorage) return;
localStorage.setItem('text-speaker', JSON.stringify(data)); localStorage.setItem("text-speaker", JSON.stringify(data));
}; };
export function handleAPIError(error: unknown, message: string) {
console.error(message, error);
return NextResponse.json(
{ error: "服务器内部错误", message },
{ status: 500 },
);
}