重构了translator,写了点数据库、后端api路由
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
2025-11-10 21:42:44 +08:00
parent b30f9fb0c3
commit d4f786c990
53 changed files with 1037 additions and 432 deletions

View File

@@ -4,7 +4,7 @@ import LightButton from "@/components/buttons/LightButton";
import { Letter, SupportedAlphabets } from "@/interfaces";
import { useEffect, useState } from "react";
import MemoryCard from "./MemoryCard";
import { Navbar } from "@/components/Navbar";
import { useTranslations } from "next-intl";
export default function Alphabet() {
@@ -58,7 +58,6 @@ export default function Alphabet() {
if (!chosenAlphabet)
return (
<>
<Navbar></Navbar>
<div className="border border-gray-200 m-4 mt-4 flex flex-col justify-center items-center p-4 rounded-2xl gap-2">
<span className="text-2xl md:text-3xl">{t("chooseCharacters")}</span>
<div className="flex gap-1 flex-wrap">
@@ -87,7 +86,6 @@ export default function Alphabet() {
if (loadingState === "success" && alphabetData[chosenAlphabet]) {
return (
<>
<Navbar></Navbar>
<MemoryCard
alphabet={alphabetData[chosenAlphabet]}
setChosenAlphabet={setChosenAlphabet}

View File

@@ -1,53 +1,15 @@
import { pool } from "@/lib/db";
import NextAuth, { SessionStrategy } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import NextAuth, { AuthOptions } from "next-auth";
import GithubProvider from "next-auth/providers/github";
export const authOptions = {
export const authOptions: AuthOptions = {
providers: [
CredentialsProvider({
name: "Credentials",
credentials: {
username: { label: "Username", type: "text", placeholder: "jsmith" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.username || !credentials?.password) {
return null;
}
try {
const result = await pool.query(
"SELECT * FROM users WHERE username = $1",
[credentials.username],
);
const user = result.rows[0];
if (!user) {
return null;
}
const isValidPassword = await bcrypt.compare(
credentials.password,
user.password,
);
if (!isValidPassword) return null;
return {
id: user.id,
username: user.username,
};
} catch (error) {
console.error("Auth error:", error);
return null;
}
},
GithubProvider({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
],
session: { strategy: "jwt" as SessionStrategy },
pages: { signIn: "/login" },
};
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View File

@@ -0,0 +1,18 @@
import { getServerSession } from "next-auth";
import { NextResponse } from "next/server";
import { authOptions } from "../../auth/[...nextauth]/route";
import { WordPairController } from "@/lib/db";
export async function GET({ params }: { params: { slug: number } }) {
const session = await getServerSession(authOptions);
if (session) {
const id = params.slug;
return new NextResponse(
JSON.stringify(
await WordPairController.getWordPairsByFolderId(id),
),
);
} else {
return new NextResponse("Unauthorized");
}
}

View File

@@ -0,0 +1,35 @@
import { getServerSession } from "next-auth";
import { NextRequest, NextResponse } from "next/server";
import { authOptions } from "../auth/[...nextauth]/route";
import { FolderController } from "@/lib/db";
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (session) {
return new NextResponse(
JSON.stringify(
await FolderController.getFoldersByOwner(session.user!.name as string),
),
);
} else {
return new NextResponse("Unauthorized");
}
}
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (session) {
const body = await req.json();
return new NextResponse(
JSON.stringify(
await FolderController.createFolder(
body.name,
session.user!.name as string,
),
),
);
} else {
return new NextResponse("Unauthorized");
}
}

View File

@@ -1,22 +0,0 @@
import { UserController } from "@/lib/db";
import { NextRequest } from "next/server";
async function handler(
req: NextRequest,
{ params }: { params: { slug: string[] } },
) {
const { slug } = params;
if (slug.length !== 1) {
return new Response("Invalid slug", { status: 400 });
}
if (req.method === "GET") {
return UserController.getUsers();
} else if (req.method === "POST") {
return UserController.createUser(await req.json());
} else {
return new Response("Method not allowed", { status: 405 });
}
}
export { handler as GET, handler as POST };

View File

@@ -1,7 +0,0 @@
import { UserController } from "@/lib/db";
import { NextRequest, NextResponse } from "next/server";
export async function GET() {
const users = await UserController.getUsers();
return NextResponse.json(users, { status: 200 });
}

View File

@@ -0,0 +1,10 @@
import { simpleGetLLMAnswer } from "@/lib/ai";
import { NextRequest } from "next/server";
export async function GET(req: NextRequest) {
return await simpleGetLLMAnswer(
`请生成%s的严式国际音标(International Phonetic Alphabet),然后直接发给我。`,
req.nextUrl.searchParams,
["text"],
);
}

View File

@@ -0,0 +1,10 @@
import { simpleGetLLMAnswer } from "@/lib/ai";
import { NextRequest } from "next/server";
export async function GET(req: NextRequest) {
return await simpleGetLLMAnswer(
`请根据文本“%s”推断地区(locale)形如zh-CN、en-US然后直接发给我。`,
req.nextUrl.searchParams,
["text"],
);
}

View File

@@ -0,0 +1,10 @@
import { simpleGetLLMAnswer } from "@/lib/ai";
import { NextRequest } from "next/server";
export async function GET(req: NextRequest) {
return await simpleGetLLMAnswer(
`请翻译%s到%s然后直接发给我。`,
req.nextUrl.searchParams,
["text", "lang"],
);
}

View File

@@ -2,6 +2,8 @@ import type { Metadata } from "next";
import "./globals.css";
import type { Viewport } from "next";
import { NextIntlClientProvider } from "next-intl";
import SessionWrapper from "@/lib/SessionWrapper";
import { Navbar } from "@/components/Navbar";
export const viewport: Viewport = {
width: "device-width",
@@ -19,12 +21,15 @@ export default async function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`antialiased`}
>
<NextIntlClientProvider>{children}</NextIntlClientProvider>
</body>
</html>
<SessionWrapper>
<html lang="en">
<body className={`antialiased`}>
<NextIntlClientProvider>
<Navbar></Navbar>
{children}
</NextIntlClientProvider>
</body>
</html>
</SessionWrapper>
);
}

View File

@@ -1,25 +1,42 @@
"use client";
import LightButton from "@/components/buttons/LightButton";
import ACard from "@/components/cards/ACard";
import Input from "@/components/Input";
import NavbarCenterWrapper from "@/components/NavbarCenterWrapper";
import { useRef } from "react";
import { Center } from "@/components/Center";
import IMAGES from "@/config/images";
import { signIn, useSession } from "next-auth/react";
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
export default function Login() {
const usernameRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
export default function LoginPage() {
const session = useSession();
const router = useRouter();
const searchParams = useSearchParams();
useEffect(() => {
if (session.status === "authenticated") {
router.push(searchParams.get("redirect") || "/");
}
}, [session.status, router, searchParams]);
return (
<NavbarCenterWrapper>
<ACard className="md:border-2 border-gray-200 flex items-center justify-center flex-col gap-8">
<h1 className="text-2xl md:text-4xl font-bold">Login</h1>
<form className="flex flex-col gap-2 md:text-xl">
<Input ref={usernameRef} placeholder="username" type="text" />
<Input ref={passwordRef} placeholder="password" type="password" />
<LightButton>Submit</LightButton>
</form>
</ACard>
</NavbarCenterWrapper>
<Center>
{session.status === "loading" ? (
<div>Loading...</div>
) : (
<LightButton
className="flex flex-row p-2 gap-2"
onClick={() => signIn("github")}
>
<Image
src={IMAGES.github_mark}
alt="GitHub Logo"
width={32}
height={32}
/>
<span>GitHub Login</span>
</LightButton>
)}
</Center>
);
}

View File

@@ -4,7 +4,7 @@ import BCard from "@/components/cards/BCard";
import { LOCALES } from "@/config/locales";
import { Dispatch, SetStateAction, useState } from "react";
import { WordData } from "@/interfaces";
import NavbarCenterWrapper from "@/components/NavbarCenterWrapper";
import { useTranslations } from "next-intl";
interface Props {
@@ -39,7 +39,7 @@ export default function Choose({
};
return (
<NavbarCenterWrapper className="bg-gray-100">
<div className="w-screen flex justify-center items-center">
<ACard className="flex flex-col">
<div className="overflow-y-auto flex-1 border border-gray-200 rounded-2xl p-2 grid grid-cols-4 md:grid-cols-6 md:gap-2">
{LOCALES.map((locale, index) => (
@@ -62,6 +62,6 @@ export default function Choose({
</BCard>
</div>
</ACard>
</NavbarCenterWrapper>
</div>
);
}

View File

@@ -5,7 +5,7 @@ import { ChangeEvent, Dispatch, SetStateAction, useRef, useState } from "react";
import DarkButton from "@/components/buttons/DarkButton";
import { WordData } from "@/interfaces";
import Choose from "./Choose";
import NavbarCenterWrapper from "@/components/NavbarCenterWrapper";
import { useTranslations } from "next-intl";
interface Props {
@@ -51,7 +51,7 @@ export default function Edit({ setPage, wordData, setWordData }: Props) {
setWordData(newWordData);
if (textareaRef.current)
textareaRef.current.value = convertFromWordData(newWordData);
if(localStorage) {
if (localStorage) {
localStorage.setItem("wordData", JSON.stringify(newWordData));
}
};
@@ -60,7 +60,7 @@ export default function Edit({ setPage, wordData, setWordData }: Props) {
};
if (editPage === "edit")
return (
<NavbarCenterWrapper className="bg-gray-100">
<div className="w-screen flex justify-center items-center">
<ACard className="flex flex-col">
<textarea
onKeyDown={(e) => {
@@ -96,7 +96,7 @@ export default function Edit({ setPage, wordData, setWordData }: Props) {
</BCard>
</div>
</ACard>
</NavbarCenterWrapper>
</div>
);
if (editPage === "choose")
return (

View File

@@ -4,7 +4,6 @@ import BCard from "@/components/cards/BCard";
import { WordData, WordDataSchema } from "@/interfaces";
import { Dispatch, SetStateAction } from "react";
import useFileUpload from "@/hooks/useFileUpload";
import NavbarCenterWrapper from "@/components/NavbarCenterWrapper";
import { useTranslations } from "next-intl";
interface Props {
@@ -43,7 +42,7 @@ export default function Main({
URL.revokeObjectURL(url);
};
return (
<NavbarCenterWrapper className="bg-gray-100">
<div className="w-screen flex justify-center items-center">
<ACard className="flex-col flex">
<h1 className="text-center font-extrabold text-4xl text-gray-800 m-2 mb-4">
{t("title")}
@@ -69,6 +68,6 @@ export default function Main({
</div>
</ACard>
<input type="file" hidden ref={inputRef}></input>
</NavbarCenterWrapper>
</div>
);
}

View File

@@ -4,7 +4,7 @@ import { Dispatch, SetStateAction, useState } from "react";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { getTTSAudioUrl } from "@/utils";
import { VOICES } from "@/config/locales";
import NavbarCenterWrapper from "@/components/NavbarCenterWrapper";
import { useTranslations } from "next-intl";
interface WordBoardProps {
@@ -49,7 +49,7 @@ export default function Start({ wordData, setPage }: Props) {
).then(play);
};
return (
<NavbarCenterWrapper className="bg-gray-100">
<div className="w-screen flex justify-center items-center">
<div className="flex-col flex items-center h-96">
<div className="flex-1 w-[95dvw] md:w-fit p-4 gap-4 flex flex-col overflow-x-auto">
{dictation ? (
@@ -95,6 +95,6 @@ export default function Start({ wordData, setPage }: Props) {
</div>
</div>
</div>
</NavbarCenterWrapper>
</div>
);
}

View File

@@ -25,7 +25,7 @@ const getLocalWordData = (): WordData => {
}
}
export default function Memorize() {
export default function MemorizePage() {
const [page, setPage] = useState<"start" | "main" | "edit">("main");
const [wordData, setWordData] = useState<WordData>(getLocalWordData());
if (page === "main")

View File

@@ -1,8 +1,7 @@
import { Navbar } from "@/components/Navbar";
import { useTranslations } from "next-intl";
import Link from "next/link";
export default function Home() {
export default function HomePage() {
const t = useTranslations("home");
function TopArea() {
return (
@@ -101,7 +100,6 @@ export default function Home() {
}
return (
<>
<Navbar></Navbar>
<TopArea></TopArea>
<Fortune></Fortune>
<Explore></Explore>

55
src/app/profile/page.tsx Normal file
View File

@@ -0,0 +1,55 @@
"use client";
import { signOut, useSession } from "next-auth/react";
import { usePathname, useRouter } from "next/navigation";
import Image from "next/image";
import DarkButton from "@/components/buttons/DarkButton";
import { useEffect } from "react";
import ACard from "@/components/cards/ACard";
import { Center } from "@/components/Center";
import LightButton from "@/components/buttons/LightButton";
export default function MePage() {
const session = useSession();
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (session.status !== "authenticated") {
router.push(`/login?redirect=${encodeURIComponent(pathname)}`);
}
}, [session.status, router, pathname]);
return (
<Center>
<ACard>
<h1>My Profile</h1>
{(session.data?.user?.image as string) && (
<Image
width={64}
height={64}
alt="User Avatar"
src={session.data?.user?.image as string}
className="rounded-4xl"
></Image>
)}
<p>{session.data?.user?.name}</p>
<p>Email: {session.data?.user?.email}</p>
<DarkButton onClick={signOut}>Logout</DarkButton>
<LightButton
onClick={() => {
fetch("/api/folders", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ name: "New Folder" }),
}).then(async (res) => console.log(await res.json()));
}}
>
POST
</LightButton>
</ACard>
</Center>
);
}

View File

@@ -3,16 +3,14 @@
import { KeyboardEvent, useRef, useState } from "react";
import UploadArea from "./UploadArea";
import VideoPanel from "./VideoPlayer/VideoPanel";
import { Navbar } from "@/components/Navbar";
export default function SrtPlayer() {
export default function SrtPlayerPage() {
const videoRef = useRef<HTMLVideoElement>(null);
const [videoUrl, setVideoUrl] = useState<string | null>(null);
const [srtUrl, setSrtUrl] = useState<string | null>(null);
return (
<>
<Navbar></Navbar>
<div
className="flex w-screen pt-8 items-center justify-center"
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => e.preventDefault()}

View File

@@ -1,9 +1,9 @@
"use client";
import { getTextSpeakerData, setTextSpeakerData } from "@/utils";
import { getLocalStorageOperator } from "@/utils";
import { useState } from "react";
import z from "zod";
import { TextSpeakerItemSchema } from "@/interfaces";
import { TextSpeakerArraySchema, TextSpeakerItemSchema } from "@/interfaces";
import IconClick from "@/components/IconClick";
import IMAGES from "@/config/images";
import { useTranslations } from "next-intl";
@@ -49,23 +49,27 @@ interface SaveListProps {
}
export default function SaveList({ show = false, handleUse }: SaveListProps) {
const t = useTranslations("text-speaker");
const [data, setData] = useState(getTextSpeakerData());
const { get: getFromLocalStorage, set: setIntoLocalStorage } = getLocalStorageOperator<
typeof TextSpeakerArraySchema
>("text-speaker", TextSpeakerArraySchema);
const [data, setData] = useState(getFromLocalStorage());
const handleDel = (item: z.infer<typeof TextSpeakerItemSchema>) => {
const current_data = getTextSpeakerData();
const current_data = getFromLocalStorage();
current_data.splice(
current_data.findIndex((v) => v.text === item.text),
1,
);
setTextSpeakerData(current_data);
setIntoLocalStorage(current_data);
refresh();
};
const refresh = () => {
setData(getTextSpeakerData());
setData(getFromLocalStorage());
};
const handleDeleteAll = () => {
const yesorno = prompt(t("confirmDeleteAll"))?.trim();
if (yesorno && (yesorno === "Y" || yesorno === "y")) {
setTextSpeakerData([]);
setIntoLocalStorage([]);
refresh();
}
};

View File

@@ -4,20 +4,16 @@ import LightButton from "@/components/buttons/LightButton";
import IconClick from "@/components/IconClick";
import IMAGES from "@/config/images";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import {
getTextSpeakerData,
getTTSAudioUrl,
setTextSpeakerData,
} from "@/utils";
import { TextSpeakerArraySchema, TextSpeakerItemSchema } from "@/interfaces";
import { getLocalStorageOperator, getTTSAudioUrl } from "@/utils";
import { ChangeEvent, useEffect, useRef, useState } from "react";
import SaveList from "./SaveList";
import { TextSpeakerItemSchema } from "@/interfaces";
import z from "zod";
import { Navbar } from "@/components/Navbar";
import SaveList from "./SaveList";
import { VOICES } from "@/config/locales";
import { useTranslations } from "next-intl";
export default function TextSpeaker() {
export default function TextSpeakerPage() {
const t = useTranslations("text-speaker");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [showSpeedAdjust, setShowSpeedAdjust] = useState(false);
@@ -33,6 +29,11 @@ export default function TextSpeaker() {
const objurlRef = useRef<string | null>(null);
const [processing, setProcessing] = useState(false);
const { play, stop, load, audioRef } = useAudioPlayer();
const { get: getFromLocalStorage, set: setIntoLocalStorage } = getLocalStorageOperator<
typeof TextSpeakerArraySchema
>("text-speaker", TextSpeakerArraySchema);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
@@ -196,14 +197,14 @@ export default function TextSpeaker() {
theIPA = tmp.ipa;
}
const save = getTextSpeakerData();
const save = getFromLocalStorage();
const oldIndex = save.findIndex((v) => v.text === textRef.current);
if (oldIndex !== -1) {
const oldItem = save[oldIndex];
if (theIPA) {
if (!oldItem.ipa || oldItem.ipa !== theIPA) {
oldItem.ipa = theIPA;
setTextSpeakerData(save);
setIntoLocalStorage(save);
}
}
} else if (theIPA.length === 0) {
@@ -218,7 +219,7 @@ export default function TextSpeaker() {
ipa: theIPA,
});
}
setTextSpeakerData(save);
setIntoLocalStorage(save);
} catch (e) {
console.error(e);
setLocale(null);
@@ -229,9 +230,8 @@ export default function TextSpeaker() {
return (
<>
<Navbar></Navbar>
<div
className="my-4 p-4 mx-4 md:mx-32 border-1 border-gray-200 rounded-2xl"
className="my-4 p-4 mx-4 md:mx-32 border border-gray-200 rounded-2xl"
style={{ fontFamily: "Times New Roman, serif" }}
>
<textarea

View File

@@ -1,293 +1,253 @@
"use client";
import { ChangeEvent, useState } from "react";
import LightButton from "@/components/buttons/LightButton";
import IconClick from "@/components/IconClick";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import IMAGES from "@/config/images";
import { getTTSAudioUrl } from "@/utils";
import { Navbar } from "@/components/Navbar";
import { VOICES } from "@/config/locales";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { TranslationHistorySchema } from "@/interfaces";
import { tlsoPush, tlso } from "@/lib/localStorageOperators";
import { getTTSAudioUrl } from "@/lib/tts";
import { letsFetch } from "@/utils";
import { useTranslations } from "next-intl";
import { useRef, useState } from "react";
import z from "zod";
export default function Translator() {
export default function TranslatorPage() {
const t = useTranslations("translator");
const [ipaEnabled, setIPAEnabled] = useState(true);
const [targetLang, setTargetLang] = useState("Chinese");
const taref = useRef<HTMLTextAreaElement>(null);
const [lang, setLang] = useState<string>("chinese");
const [tresult, setTresult] = useState<string>("");
const [genIpa, setGenIpa] = useState(true);
const [ipaTexts, setIpaTexts] = useState(["", ""]);
const [processing, setProcessing] = useState(false);
const { load, play } = useAudioPlayer();
const [sourceText, setSourceText] = useState("");
const [targetText, setTargetText] = useState("");
const [sourceIPA, setSourceIPA] = useState("");
const [targetIPA, setTargetIPA] = useState("");
const [sourceLocale, setSourceLocale] = useState<string | null>(null);
const [targetLocale, setTargetLocale] = useState<string | null>(null);
const [translating, setTranslating] = useState(false);
const { play, load } = useAudioPlayer();
const lastTTS = useRef({
text: "",
url: "",
});
const tl = ["Chinese", "English", "Italian"];
const inputLanguage = () => {
const lang = prompt(t("inputLanguage"))?.trim();
if (lang) {
setTargetLang(lang);
}
};
const translate = () => {
if (translating) return;
if (sourceText.length === 0) return;
setTranslating(true);
setTargetText("");
setSourceLocale(null);
setTargetLocale(null);
setSourceIPA("");
setTargetIPA("");
const params = new URLSearchParams({
text: sourceText,
target: targetLang,
});
fetch(`/api/translate?${params}`)
.then((res) => res.json())
.then((obj) => {
setSourceLocale(obj.source_locale);
setTargetLocale(obj.target_locale);
setTargetText(obj.target_text);
if (ipaEnabled) {
const params = new URLSearchParams({
text: sourceText,
});
fetch(`/api/ipa?${params}`)
.then((res) => res.json())
.then((data) => {
setSourceIPA(data.ipa);
})
.catch((e) => {
console.error(e);
setSourceIPA("");
});
const params2 = new URLSearchParams({
text: obj.target_text,
});
fetch(`/api/ipa?${params2}`)
.then((res) => res.json())
.then((data) => {
setTargetIPA(data.ipa);
})
.catch((e) => {
console.error(e);
setTargetIPA("");
});
}
})
.catch((r) => {
console.error(r);
setSourceLocale("");
setTargetLocale("");
setTargetText("");
})
.finally(() => setTranslating(false));
};
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
setSourceText(e.target.value.trim());
setTargetText("");
setSourceLocale(null);
setTargetLocale(null);
setSourceIPA("");
setTargetIPA("");
};
const readSource = async () => {
if (sourceText.length === 0) return;
if (sourceIPA.length === 0 && ipaEnabled) {
const params = new URLSearchParams({
text: sourceText,
});
fetch(`/api/ipa?${params}`)
.then((res) => res.json())
.then((data) => {
setSourceIPA(data.ipa);
})
.catch((e) => {
console.error(e);
setSourceIPA("");
});
}
if (!sourceLocale) {
try {
const params = new URLSearchParams({
text: sourceText.slice(0, 30),
});
const res = await fetch(`/api/locale?${params}`);
const info = await res.json();
setSourceLocale(info.locale);
const voice = VOICES.find((v) => v.locale.startsWith(info.locale));
if (!voice) {
return;
}
const url = await getTTSAudioUrl(sourceText, voice.short_name);
await load(url);
await play();
URL.revokeObjectURL(url);
} catch (e) {
console.error(e);
setSourceLocale(null);
return;
}
} else {
const voice = VOICES.find((v) => v.locale.startsWith(sourceLocale!));
if (!voice) {
return;
}
const url = await getTTSAudioUrl(sourceText, voice.short_name);
const tts = async (text: string, locale: string) => {
if (lastTTS.current.text !== text) {
const url = await getTTSAudioUrl(
text,
VOICES.find((v) => v.locale === locale)!.short_name,
);
await load(url);
await play();
URL.revokeObjectURL(url);
lastTTS.current.text = text;
lastTTS.current.url = url;
}
play();
};
const readTarget = async () => {
if (targetText.length === 0) return;
const translate = async () => {
if (processing) return;
setProcessing(true);
if (targetIPA.length === 0 && ipaEnabled) {
const params = new URLSearchParams({
text: targetText,
});
fetch(`/api/ipa?${params}`)
.then((res) => res.json())
.then((data) => {
setTargetIPA(data.ipa);
})
.catch((e) => {
console.error(e);
setTargetIPA("");
});
}
if (!taref.current) return;
const text = taref.current.value;
const voice = VOICES.find((v) => v.locale.startsWith(targetLocale!));
if (!voice) return;
const newItem: {
text1: string | null;
text2: string | null;
locale1: string | null;
locale2: string | null;
} = {
text1: text,
text2: null,
locale1: null,
locale2: null,
};
const url = await getTTSAudioUrl(targetText, voice.short_name);
await load(url);
await play();
URL.revokeObjectURL(url);
const checkUpdateLocalStorage = (item: typeof newItem) => {
if (item.text1 && item.text2 && item.locale1 && item.locale2) {
tlsoPush(item as z.infer<typeof TranslationHistorySchema>);
}
};
const innerStates = {
text2: false,
ipa1: !genIpa,
ipa2: !genIpa,
};
const checkUpdateProcessStates = () => {
if (innerStates.ipa1 && innerStates.ipa2 && innerStates.text2)
setProcessing(false);
};
const updateState = (stateName: keyof typeof innerStates) => () => {
innerStates[stateName] = true;
checkUpdateLocalStorage(newItem);
checkUpdateProcessStates();
};
// Fetch locale for text1
letsFetch(
`/api/v1/locale?text=${encodeURIComponent(text)}`,
(locale: string) => {
newItem.locale1 = locale;
},
console.log,
() => {},
);
if (genIpa)
// Fetch IPA for text1
letsFetch(
`/api/v1/ipa?text=${encodeURIComponent(text)}`,
(ipa: string) => setIpaTexts((prev) => [ipa, prev[1]]),
console.log,
updateState("ipa1"),
);
// Fetch translation for text2
letsFetch(
`/api/v1/translate?text=${encodeURIComponent(text)}&lang=${encodeURIComponent(lang)}`,
(text2) => {
setTresult(text2);
newItem.text2 = text2;
if (genIpa)
// Fetch IPA for text2
letsFetch(
`/api/v1/ipa?text=${encodeURIComponent(text2)}`,
(ipa: string) => setIpaTexts((prev) => [prev[0], ipa]),
console.log,
updateState("ipa2"),
);
// Fetch locale for text2
letsFetch(
`/api/v1/locale?text=${encodeURIComponent(text2)}`,
(locale: string) => {
newItem.locale2 = locale;
},
console.log,
() => {},
);
},
console.log,
updateState("text2"),
);
};
return (
<>
<Navbar></Navbar>
{/* TCard Component */}
<div className="w-screen flex flex-col md:flex-row md:justify-between gap-2 p-2">
<div className="card1 w-full md:w-1/2 flex flex-col-reverse gap-2">
<div className="textarea1 border border-gray-200 rounded-2xl w-full h-64 p-2">
{/* Card Component - Left Side */}
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
{/* ICard1 Component */}
<div className="border border-gray-200 rounded-2xl w-full h-64 p-2">
<textarea
className="resize-none h-8/12 w-full focus:outline-0"
ref={taref}
onKeyDown={(e) => {
if (e.ctrlKey && e.key === "Enter") translate();
}}
onChange={handleInputChange}
className="resize-none h-8/12 w-full focus:outline-0"
></textarea>
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
{sourceIPA}
{ipaTexts[0]}
</div>
<div className="h-2/12 w-full flex justify-end items-center">
<IconClick
onClick={async () => {
if (sourceText.length !== 0)
await navigator.clipboard.writeText(sourceText);
}}
src={IMAGES.copy_all}
alt="copy"
onClick={async () => {
await navigator.clipboard.writeText(
taref.current?.value || "",
);
}}
></IconClick>
<IconClick
onClick={readSource}
src={IMAGES.play_arrow}
alt="play"
onClick={() => {
const t = taref.current?.value;
if (!t) return;
tts(t, tlso.get().find((v) => v.text1 === t)?.locale1 || "");
}}
></IconClick>
</div>
</div>
<div className="option1 w-full flex flex-row justify-between items-center">
<span>{t("detectLanguage")}</span>
<LightButton
selected={ipaEnabled}
onClick={() => setIPAEnabled(!ipaEnabled)}
selected={genIpa}
onClick={() => setGenIpa((prev) => !prev)}
>
{t("generateIPA")}
</LightButton>
</div>
</div>
<div className="card2 w-full md:w-1/2 flex flex-col-reverse gap-2">
<div className="textarea2 bg-gray-100 rounded-2xl w-full h-64 p-2">
<div className="h-8/12 w-full">{targetText}</div>
{/* Card Component - Right Side */}
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
{/* ICard2 Component */}
<div className="bg-gray-100 rounded-2xl w-full h-64 p-2">
<div className="h-8/12 w-full">{tresult}</div>
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
{targetIPA}
{ipaTexts[1]}
</div>
<div className="h-2/12 w-full flex justify-end items-center">
<IconClick
onClick={async () => {
if (targetText.length !== 0)
await navigator.clipboard.writeText(targetText);
}}
src={IMAGES.copy_all}
alt="copy"
onClick={async () => {
await navigator.clipboard.writeText(tresult);
}}
></IconClick>
<IconClick
onClick={readTarget}
src={IMAGES.play_arrow}
alt="play"
onClick={() => {
tts(
tresult,
tlso.get().find((v) => v.text2 === tresult)?.locale2 || "",
);
}}
></IconClick>
</div>
</div>
<div className="option2 w-full flex gap-1 items-center flex-wrap">
<span>{t("translateInto")}</span>
<LightButton
onClick={() => {
setTargetLang("Chinese");
}}
selected={targetLang === "Chinese"}
selected={lang === "chinese"}
onClick={() => setLang("chinese")}
>
{t("chinese")}
</LightButton>
<LightButton
onClick={() => {
setTargetLang("English");
}}
selected={targetLang === "English"}
selected={lang === "english"}
onClick={() => setLang("english")}
>
{t("english")}
</LightButton>
<LightButton
onClick={() => {
setTargetLang("Italian");
}}
selected={targetLang === "Italian"}
selected={lang === "italian"}
onClick={() => setLang("italian")}
>
{t("italian")}
</LightButton>
<LightButton
onClick={inputLanguage}
selected={!tl.includes(targetLang)}
selected={!["chinese", "english", "italian"].includes(lang)}
onClick={() => {
const newLang = prompt("Enter language");
if (newLang) {
setLang(newLang);
}
}}
>
{t("other") + (tl.includes(targetLang) ? "" : ": " + targetLang)}
{t("other")}
</LightButton>
</div>
</div>
</div>
<div className="button-area w-screen flex justify-center items-center">
{/* TranslateButton Component */}
<div className="w-screen flex justify-center items-center">
<button
className={`duration-150 ease-in text-xl font-extrabold border rounded-4xl p-3 border-gray-200 h-16 ${processing ? "bg-gray-200" : "bg-white hover:bg-gray-200 hover:cursor-pointer"}`}
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 ? t("translating") : t("translate")}
{t("translate")}
</button>
</div>
</>

View File

@@ -10,9 +10,8 @@ import {
TEXT_SIZE,
} from "@/config/word-board-config";
import { inspect } from "@/utils";
import { Navbar } from "@/components/Navbar";
export default function WordBoard() {
export default function WordBoardPage() {
const inputRef = useRef<HTMLInputElement>(null);
const inputFileRef = useRef<HTMLInputElement>(null);
const initialWords = [
@@ -147,7 +146,6 @@ export default function WordBoard() {
// }
return (
<>
<Navbar></Navbar>
<div className="flex w-screen h-screen justify-center items-center">
<div
onKeyDown={handleKeyDown}