This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
18
src/app/api/folder/[id]/route.ts
Normal file
18
src/app/api/folder/[id]/route.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
35
src/app/api/folders/route.ts
Normal file
35
src/app/api/folders/route.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 });
|
||||
}
|
||||
10
src/app/api/v1/ipa/route.ts
Normal file
10
src/app/api/v1/ipa/route.ts
Normal 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"],
|
||||
);
|
||||
}
|
||||
10
src/app/api/v1/locale/route.ts
Normal file
10
src/app/api/v1/locale/route.ts
Normal 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"],
|
||||
);
|
||||
}
|
||||
10
src/app/api/v1/translate/route.ts
Normal file
10
src/app/api/v1/translate/route.ts
Normal 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"],
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
55
src/app/profile/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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()}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user