diff --git a/next.config.ts b/next.config.ts index ca2fd69..a4e3c95 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,7 +3,17 @@ import createNextIntlPlugin from "next-intl/plugin"; const nextConfig: NextConfig = { /* config options here */ output: "standalone", - allowedDevOrigins: ["192.168.3.65", "192.168.3.66"], + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "avatars.githubusercontent.com", + port: "", + pathname: "/u/**", + }, + ], + }, + // allowedDevOrigins: ["192.168.3.65", "192.168.3.66"], }; const withNextIntl = createNextIntlPlugin(); diff --git a/package-lock.json b/package-lock.json index 6abab2b..cc89742 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "pg": "^8.16.3", "react": "19.1.0", "react-dom": "19.1.0", + "unstorage": "^1.17.2", "zod": "^3.25.76" }, "devDependencies": { @@ -5229,8 +5230,6 @@ "resolved": "https://mirrors.cloud.tencent.com/npm/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "license": "ISC", - "optional": true, - "peer": true, "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -6133,6 +6132,21 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://mirrors.cloud.tencent.com/npm/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/chrome-launcher": { "version": "0.15.2", "resolved": "https://mirrors.cloud.tencent.com/npm/chrome-launcher/-/chrome-launcher-0.15.2.tgz", @@ -6479,6 +6493,12 @@ "node": ">= 0.6" } }, + "node_modules/cookie-es": { + "version": "1.2.2", + "resolved": "https://mirrors.cloud.tencent.com/npm/cookie-es/-/cookie-es-1.2.2.tgz", + "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==", + "license": "MIT" + }, "node_modules/core-js-compat": { "version": "3.46.0", "resolved": "https://mirrors.cloud.tencent.com/npm/core-js-compat/-/core-js-compat-3.46.0.tgz", @@ -6518,6 +6538,15 @@ "node": ">= 8" } }, + "node_modules/crossws": { + "version": "0.3.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/crossws/-/crossws-0.3.5.tgz", + "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, "node_modules/crypto-random-string": { "version": "2.0.0", "resolved": "https://mirrors.cloud.tencent.com/npm/crypto-random-string/-/crypto-random-string-2.0.0.tgz", @@ -6710,6 +6739,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://mirrors.cloud.tencent.com/npm/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://mirrors.cloud.tencent.com/npm/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -6730,6 +6765,12 @@ "node": ">= 0.8" } }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://mirrors.cloud.tencent.com/npm/destroy/-/destroy-1.2.0.tgz", @@ -8263,6 +8304,23 @@ "dev": true, "license": "MIT" }, + "node_modules/h3": { + "version": "1.15.4", + "resolved": "https://mirrors.cloud.tencent.com/npm/h3/-/h3-1.15.4.tgz", + "integrity": "sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ==", + "license": "MIT", + "dependencies": { + "cookie-es": "^1.2.2", + "crossws": "^0.3.5", + "defu": "^6.1.4", + "destr": "^2.0.5", + "iron-webcrypto": "^1.2.1", + "node-mock-http": "^1.0.2", + "radix3": "^1.1.2", + "ufo": "^1.6.1", + "uncrypto": "^0.1.3" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://mirrors.cloud.tencent.com/npm/has-bigints/-/has-bigints-1.1.0.tgz", @@ -8586,6 +8644,15 @@ "loose-envify": "^1.0.0" } }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://mirrors.cloud.tencent.com/npm/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -11722,6 +11789,12 @@ } } }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://mirrors.cloud.tencent.com/npm/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, "node_modules/node-forge": { "version": "1.3.1", "resolved": "https://mirrors.cloud.tencent.com/npm/node-forge/-/node-forge-1.3.1.tgz", @@ -11741,6 +11814,12 @@ "optional": true, "peer": true }, + "node_modules/node-mock-http": { + "version": "1.0.3", + "resolved": "https://mirrors.cloud.tencent.com/npm/node-mock-http/-/node-mock-http-1.0.3.tgz", + "integrity": "sha512-jN8dK25fsfnMrVsEhluUTPkBFY+6ybu7jSB1n+ri/vOGjJxU8J9CZhpSGkHXSkFjtUhbmoncG/YG9ta5Ludqog==", + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://mirrors.cloud.tencent.com/npm/node-releases/-/node-releases-2.0.27.tgz", @@ -11754,8 +11833,6 @@ "resolved": "https://mirrors.cloud.tencent.com/npm/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -11937,6 +12014,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ofetch": { + "version": "1.5.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/ofetch/-/ofetch-1.5.1.tgz", + "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", + "license": "MIT", + "dependencies": { + "destr": "^2.0.5", + "node-fetch-native": "^1.6.7", + "ufo": "^1.6.1" + } + }, "node_modules/oidc-token-hash": { "version": "5.2.0", "resolved": "https://mirrors.cloud.tencent.com/npm/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", @@ -12464,7 +12552,6 @@ "version": "2.3.1", "resolved": "https://mirrors.cloud.tencent.com/npm/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -12815,6 +12902,12 @@ ], "license": "MIT" }, + "node_modules/radix3": { + "version": "1.1.2", + "resolved": "https://mirrors.cloud.tencent.com/npm/radix3/-/radix3-1.1.2.tgz", + "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://mirrors.cloud.tencent.com/npm/range-parser/-/range-parser-1.2.1.tgz", @@ -12928,6 +13021,19 @@ "node": ">=0.10.0" } }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://mirrors.cloud.tencent.com/npm/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://mirrors.cloud.tencent.com/npm/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -14790,6 +14896,12 @@ "node": ">=14.17" } }, + "node_modules/ufo": { + "version": "1.6.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/ufo/-/ufo-1.6.1.tgz", + "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://mirrors.cloud.tencent.com/npm/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -14809,6 +14921,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://mirrors.cloud.tencent.com/npm/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, "node_modules/undici": { "version": "6.22.0", "resolved": "https://mirrors.cloud.tencent.com/npm/undici/-/undici-6.22.0.tgz", @@ -14945,6 +15063,108 @@ "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, + "node_modules/unstorage": { + "version": "1.17.2", + "resolved": "https://mirrors.cloud.tencent.com/npm/unstorage/-/unstorage-1.17.2.tgz", + "integrity": "sha512-cKEsD6iBWJgOMJ6vW1ID/SYuqNf8oN4yqRk8OYqaVQ3nnkJXOT1PSpaMh2QfzLs78UN5kSNRD2c/mgjT8tX7+w==", + "license": "MIT", + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^4.0.3", + "destr": "^2.0.5", + "h3": "^1.15.4", + "lru-cache": "^10.4.3", + "node-fetch-native": "^1.6.7", + "ofetch": "^1.5.0", + "ufo": "^1.6.1" + }, + "peerDependencies": { + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6.0.3 || ^7.0.0", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/functions": "^2.2.12 || ^3.0.0", + "@vercel/kv": "^1.0.1", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" + }, + "peerDependenciesMeta": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/functions": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { + "optional": true + } + } + }, + "node_modules/unstorage/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://mirrors.cloud.tencent.com/npm/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/update-browserslist-db": { "version": "1.1.4", "resolved": "https://mirrors.cloud.tencent.com/npm/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", diff --git a/package.json b/package.json index 1a6d411..9d85291 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "pg": "^8.16.3", "react": "19.1.0", "react-dom": "19.1.0", + "unstorage": "^1.17.2", "zod": "^3.25.76" }, "devDependencies": { diff --git a/public/changelog.txt b/public/changelog.txt index 0fdf6f2..c617b1a 100644 --- a/public/changelog.txt +++ b/public/changelog.txt @@ -1,3 +1,4 @@ +2025.11.10 重构了translator,将其改为并发请求多个数据,速度大大提升 2025.10.31 添加国际化支持 2025.10.30 添加背单词功能 2025.10.12 添加朗读器本地保存功能 @@ -8,4 +9,3 @@ 2025.10.05 新增IPA生成与文本朗读功能 2025.09.25 优化了主界面UI 2025.09.19 更新了单词板,单词不再会重叠 - diff --git a/public/images/github-mark/github-mark-white.png b/public/images/github-mark/github-mark-white.png new file mode 100644 index 0000000..50b8175 Binary files /dev/null and b/public/images/github-mark/github-mark-white.png differ diff --git a/public/images/github-mark/github-mark-white.svg b/public/images/github-mark/github-mark-white.svg new file mode 100644 index 0000000..d5e6491 --- /dev/null +++ b/public/images/github-mark/github-mark-white.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/github-mark/github-mark.png b/public/images/github-mark/github-mark.png new file mode 100644 index 0000000..6cb3b70 Binary files /dev/null and b/public/images/github-mark/github-mark.png differ diff --git a/public/images/github-mark/github-mark.svg b/public/images/github-mark/github-mark.svg new file mode 100644 index 0000000..37fa923 --- /dev/null +++ b/public/images/github-mark/github-mark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/messages/en-US/navbar.json b/public/messages/en-US/navbar.json index 0600dda..d0fa739 100644 --- a/public/messages/en-US/navbar.json +++ b/public/messages/en-US/navbar.json @@ -1,5 +1,7 @@ { "title": "LL", "about": "About", - "sourceCode": "GitHub" + "sourceCode": "GitHub", + "login": "Login", + "profile": "Profile" } diff --git a/public/messages/zh-CN/navbar.json b/public/messages/zh-CN/navbar.json index f261619..b9514e9 100644 --- a/public/messages/zh-CN/navbar.json +++ b/public/messages/zh-CN/navbar.json @@ -1,5 +1,7 @@ { "title": "学语言", "about": "关于", - "sourceCode": "源码" + "sourceCode": "源码", + "login": "登录", + "profile": "个人资料" } diff --git a/src/app/alphabet/page.tsx b/src/app/alphabet/page.tsx index 25dee3a..b39439a 100644 --- a/src/app/alphabet/page.tsx +++ b/src/app/alphabet/page.tsx @@ -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 ( <> -
{t("chooseCharacters")}
@@ -87,7 +86,6 @@ export default function Alphabet() { if (loadingState === "success" && alphabetData[chosenAlphabet]) { return ( <> - ) { return ( - - - {children} - - + + + + + + {children} + + + + ); } diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 964274a..f924a31 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -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(null); - const passwordRef = useRef(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 ( - - -

Login

-
- - - Submit -
-
-
+
+ {session.status === "loading" ? ( +
Loading...
+ ) : ( + signIn("github")} + > + GitHub Logo + GitHub Login + + )} +
); } diff --git a/src/app/memorize/Choose.tsx b/src/app/memorize/Choose.tsx index aeba7a1..3fd2e20 100644 --- a/src/app/memorize/Choose.tsx +++ b/src/app/memorize/Choose.tsx @@ -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 ( - +
{LOCALES.map((locale, index) => ( @@ -62,6 +62,6 @@ export default function Choose({
- +
); } diff --git a/src/app/memorize/Edit.tsx b/src/app/memorize/Edit.tsx index 3785932..5ea30d5 100644 --- a/src/app/memorize/Edit.tsx +++ b/src/app/memorize/Edit.tsx @@ -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 ( - +
- {sourceIPA} + {ipaTexts[0]}
{ - if (sourceText.length !== 0) - await navigator.clipboard.writeText(sourceText); - }} src={IMAGES.copy_all} alt="copy" + onClick={async () => { + await navigator.clipboard.writeText( + taref.current?.value || "", + ); + }} > { + const t = taref.current?.value; + if (!t) return; + tts(t, tlso.get().find((v) => v.text1 === t)?.locale1 || ""); + }} >
{t("detectLanguage")} setIPAEnabled(!ipaEnabled)} + selected={genIpa} + onClick={() => setGenIpa((prev) => !prev)} > {t("generateIPA")}
-
-
-
{targetText}
+ + {/* Card Component - Right Side */} +
+ {/* ICard2 Component */} +
+
{tresult}
- {targetIPA} + {ipaTexts[1]}
{ - if (targetText.length !== 0) - await navigator.clipboard.writeText(targetText); - }} src={IMAGES.copy_all} alt="copy" + onClick={async () => { + await navigator.clipboard.writeText(tresult); + }} > { + tts( + tresult, + tlso.get().find((v) => v.text2 === tresult)?.locale2 || "", + ); + }} >
{t("translateInto")} { - setTargetLang("Chinese"); - }} - selected={targetLang === "Chinese"} + selected={lang === "chinese"} + onClick={() => setLang("chinese")} > {t("chinese")} { - setTargetLang("English"); - }} - selected={targetLang === "English"} + selected={lang === "english"} + onClick={() => setLang("english")} > {t("english")} { - setTargetLang("Italian"); - }} - selected={targetLang === "Italian"} + selected={lang === "italian"} + onClick={() => setLang("italian")} > {t("italian")} { + const newLang = prompt("Enter language"); + if (newLang) { + setLang(newLang); + } + }} > - {t("other") + (tl.includes(targetLang) ? "" : ": " + targetLang)} + {t("other")}
-
+ {/* TranslateButton Component */} +
diff --git a/src/app/word-board/page.tsx b/src/app/word-board/page.tsx index 5a1d318..5319f5e 100644 --- a/src/app/word-board/page.tsx +++ b/src/app/word-board/page.tsx @@ -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(null); const inputFileRef = useRef(null); const initialWords = [ @@ -147,7 +146,6 @@ export default function WordBoard() { // } return ( <> -
{ + return ( +
+ {children} +
+ ); +}; diff --git a/src/components/Input.tsx b/src/components/Input.tsx index ccd6afe..3bda581 100644 --- a/src/components/Input.tsx +++ b/src/components/Input.tsx @@ -3,6 +3,7 @@ interface Props { placeholder?: string; type?: string; className?: string; + name?: string; } export default function Input({ @@ -10,6 +11,7 @@ export default function Input({ placeholder = "", type = "text", className = "", + name = "", }: Props) { return ( ); } diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index b315bdf..9e96dfb 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -7,11 +7,18 @@ import IconClick from "./IconClick"; import IMAGES from "@/config/images"; import { useState } from "react"; import LightButton from "./buttons/LightButton"; +import { useSession } from "next-auth/react"; -function MyLink({ href, label }: { href: string; label: string }) { +function MyLink({ + href, + children, +}: { + href: string; + children?: React.ReactNode; +}) { return ( - {label} + {children} ); } @@ -25,6 +32,7 @@ export function Navbar() { document.cookie = `locale=${locale}`; window.location.reload(); }; + const session = useSession(); return (
@@ -64,11 +72,17 @@ export function Navbar() { onClick={handleLanguageClick} >
- - + {session?.status === "authenticated" ? ( +
+ {t("profile")} +
+ ) : ( + {t("login")} + )} + {t("about")} + + {t("sourceCode")} +
); diff --git a/src/components/NavbarCenterWrapper.tsx b/src/components/NavbarCenterWrapper.tsx deleted file mode 100644 index d8ba4d6..0000000 --- a/src/components/NavbarCenterWrapper.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import NavbarWrapper from "./NavbarWrapper"; - -interface Props { - children: React.ReactNode; - className?: string; -} - -export default function NavbarCenterWrapper({ children, className }: Props) { - return ( - -
- {children} -
-
- ); -} diff --git a/src/components/NavbarWrapper.tsx b/src/components/NavbarWrapper.tsx deleted file mode 100644 index 0c79d1e..0000000 --- a/src/components/NavbarWrapper.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Navbar } from "./Navbar"; - -interface Props { - children: React.ReactNode; -} - -export default function NavbarWrapper({ children }: Props) { - return ( -
- - {children} -
- ); -} diff --git a/src/components/auth-components.tsx b/src/components/auth-components.tsx new file mode 100644 index 0000000..c2716fe --- /dev/null +++ b/src/components/auth-components.tsx @@ -0,0 +1,34 @@ +import { signIn, signOut } from "next-auth/react"; +import LightButton from "./buttons/LightButton"; + +export function SignIn({ + provider, + ...props +}: { provider?: string } & React.ComponentPropsWithRef) { + return ( +
{ + "use server" + await signIn(provider) + }} + > + Sign In +
+ ) +} + +export function SignOut(props: React.ComponentPropsWithRef) { + return ( +
{ + "use server" + await signOut() + }} + className="w-full" + > + + Sign Out + +
+ ) +} diff --git a/src/components/buttons/DarkButton.tsx b/src/components/buttons/DarkButton.tsx index 91f583c..fa261a7 100644 --- a/src/components/buttons/DarkButton.tsx +++ b/src/components/buttons/DarkButton.tsx @@ -5,16 +5,19 @@ export default function DarkButton({ className, selected, children, + type = "button", }: { onClick?: () => void; className?: string; selected?: boolean; children?: React.ReactNode; + type?: string; }) { return ( {children} diff --git a/src/components/buttons/LightButton.tsx b/src/components/buttons/LightButton.tsx index 1ee68fa..6da1322 100644 --- a/src/components/buttons/LightButton.tsx +++ b/src/components/buttons/LightButton.tsx @@ -5,16 +5,19 @@ export default function LightButton({ className, selected, children, + type = "button" }: { onClick?: () => void; className?: string; selected?: boolean; children?: React.ReactNode; + type?: string; }) { return ( {children} diff --git a/src/components/buttons/PlainButton.tsx b/src/components/buttons/PlainButton.tsx index 33697e1..5b82208 100644 --- a/src/components/buttons/PlainButton.tsx +++ b/src/components/buttons/PlainButton.tsx @@ -2,15 +2,18 @@ export default function PlainButton({ onClick, className, children, + type = "button", }: { onClick?: () => void; className?: string; children?: React.ReactNode; + type?: "button" | "submit" | "reset" | undefined; }) { return ( diff --git a/src/config/images.ts b/src/config/images.ts index 4c3b91c..81f9d3b 100644 --- a/src/config/images.ts +++ b/src/config/images.ts @@ -17,6 +17,7 @@ const IMAGES = { speed: "/images/speed_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg", language_black: "/images/language_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg", language_white: "/images/language_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg", + github_mark: "/images/github-mark/github-mark.svg", }; export default IMAGES; diff --git a/src/interfaces.ts b/src/interfaces.ts index eb16490..1347b4a 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -24,17 +24,33 @@ export const TextSpeakerItemSchema = z.object({ export const TextSpeakerArraySchema = z.array(TextSpeakerItemSchema); export const WordDataSchema = z.object({ - locales: z.tuple([z.string(), z.string()]) + locales: z + .tuple([z.string(), z.string()]) .refine(([first, second]) => first !== second, { - message: "Locales must be different" + message: "Locales must be different", }), - wordPairs: z.array(z.tuple([z.string(), z.string()])) + wordPairs: z + .array(z.tuple([z.string(), z.string()])) .min(1, "At least one word pair is required") - .refine((pairs) => { - return pairs.every(([first, second]) => first.trim() !== '' && second.trim() !== ''); - }, { - message: "Word pairs cannot contain empty strings" - }) + .refine( + (pairs) => { + return pairs.every( + ([first, second]) => first.trim() !== "" && second.trim() !== "", + ); + }, + { + message: "Word pairs cannot contain empty strings", + }, + ), }); +export const TranslationHistorySchema = z.object({ + text1: z.string(), + text2: z.string(), + locale1: z.string(), + locale2: z.string(), +}); + +export const TranslationHistoryArraySchema = z.array(TranslationHistorySchema); + export type WordData = z.infer; diff --git a/src/lib/SessionWrapper.tsx b/src/lib/SessionWrapper.tsx new file mode 100644 index 0000000..a141eba --- /dev/null +++ b/src/lib/SessionWrapper.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { SessionProvider } from "next-auth/react"; + +export default function SessionWrapper({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/src/lib/actions.ts b/src/lib/actions.ts new file mode 100644 index 0000000..f3ba1c1 --- /dev/null +++ b/src/lib/actions.ts @@ -0,0 +1,11 @@ +"use server"; + +import { UserController } from "./db"; + +export async function loginAction(formData: FormData) { + const username = formData.get("username")?.toString(); + const password = formData.get("password")?.toString(); + + + if (username && password) await UserController.createUser(username, password); +} diff --git a/src/lib/ai.ts b/src/lib/ai.ts new file mode 100644 index 0000000..eaf342b --- /dev/null +++ b/src/lib/ai.ts @@ -0,0 +1,63 @@ +import { format } from "util"; + +async function callZhipuAPI( + messages: { role: string; content: string }[], + model = "glm-4.6", +) { + const url = "https://open.bigmodel.cn/api/paas/v4/chat/completions"; + + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: "Bearer " + process.env.ZHIPU_API_KEY, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: model, + messages: messages, + temperature: 0.2, + thinking: { + type: "disabled", + }, + }), + }); + + if (!response.ok) { + throw new Error(`API 调用失败: ${response.status}`); + } + + return await response.json(); +} + +export async function getLLMAnswer(prompt: string) { + return ( + await callZhipuAPI([ + { + role: "user", + content: prompt, + }, + ]) + ).choices[0].message.content.trim() as string; +} + +export async function simpleGetLLMAnswer( + prompt: string, + searchParams: URLSearchParams, + args: string[], +) { + if (args.some((arg) => typeof searchParams.get(arg) !== "string")) { + return Response.json({ + status: "error", + message: "Missing required parameters", + }); + } + return Response.json({ + status: "success", + message: await getLLMAnswer( + format( + prompt, + ...args.map((v) => searchParams.get(v)), + ), + ), + }); +} diff --git a/src/lib/db.ts b/src/lib/db.ts index 0b46399..ea2902f 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -24,14 +24,126 @@ export class UserController { } static async getUserByUsername(username: string) { try { - const user = await pool.query("SELECT * FROM users WHERE username = $1", [username]); + const user = await pool.query("SELECT * FROM users WHERE username = $1", [ + username, + ]); return user.rows[0]; } catch (e) { console.log(e); } } + static async deleteUserById(id: number) { + try { + await pool.query("DELETE FROM users WHERE id = $1", [id]); + } catch (e) { + console.log(e); + } + } } export class FolderController { - + static async getFolderById(id: number) { + try { + const folder = await pool.query("SELECT * FROM folders WHERE id = $1", [ + id, + ]); + return folder.rows[0]; + } catch (e) { + console.log(e); + } + } + static async deleteFolderById(id: number) { + try { + await pool.query("DELETE FROM folders WHERE id = $1", [id]); + } catch (e) { + console.log(e); + } + } + static async getFoldersByOwner(owner: string) { + try { + const folders = await pool.query( + "SELECT * FROM folders WHERE owner = $1", + [owner], + ); + return folders.rows; + } catch (e) { + console.log(e); + } + } + static async createFolder(name: string, owner: string) { + try { + return ( + await pool.query("INSERT INTO folders (name, owner) VALUES ($1, $2)", [ + name, + owner, + ]) + ).rows[0]; + } catch (e) { + console.log(e); + } + } +} + +export class WordPairController { + static async createWordPair( + locale1: string, + locale2: string, + text1: string, + text2: string, + folderId: number, + ) { + try { + await pool.query( + "INSERT INTO word_pairs (locale1, locale2, text1, text2, folder_id) VALUES ($1, $2, $3, $4, $5)", + [locale1, locale2, text1, text2, folderId], + ); + } catch (e) { + console.log(e); + } + } + static async getWordPairById(id: number) { + try { + const wordPair = await pool.query( + "SELECT * FROM word_pairs WHERE id = $1", + [id], + ); + return wordPair.rows[0]; + } catch (e) { + console.log(e); + } + } + static async deleteWordPairById(id: number) { + try { + await pool.query("DELETE FROM word_pairs WHERE id = $1", [id]); + } catch (e) { + console.log(e); + } + } + static async updateWordPairById( + id: number, + locale1: string, + locale2: string, + text1: string, + text2: string, + ) { + try { + await pool.query( + "UPDATE word_pairs SET locale1 = $1, locale2 = $2, text1 = $3, text2 = $4 WHERE id = $5", + [locale1, locale2, text1, text2, id], + ); + } catch (e) { + console.log(e); + } + } + static async getWordPairsByFolderId(folderId: number) { + try { + const wordPairs = await pool.query( + "SELECT * FROM word_pairs WHERE folder_id = $1", + [folderId], + ); + return wordPairs.rows; + } catch (e) { + console.log(e); + } + } } diff --git a/src/lib/localStorageOperators.ts b/src/lib/localStorageOperators.ts new file mode 100644 index 0000000..b75163f --- /dev/null +++ b/src/lib/localStorageOperators.ts @@ -0,0 +1,17 @@ +import { TranslationHistoryArraySchema, TranslationHistorySchema } from "@/interfaces"; +import { getLocalStorageOperator } from "@/utils"; +import z from "zod"; + +const MAX_HISTORY_LENGTH = 50; + +export const tlso = getLocalStorageOperator( + "translator", + TranslationHistoryArraySchema, +); +export const tlsoPush = (item: z.infer) => { + tlso.set( + [...tlso.get(), item as z.infer].slice( + -MAX_HISTORY_LENGTH, + ), + ); +}; \ No newline at end of file diff --git a/src/lib/tts.ts b/src/lib/tts.ts new file mode 100644 index 0000000..9101093 --- /dev/null +++ b/src/lib/tts.ts @@ -0,0 +1,16 @@ +import { ProsodyOptions } from "edge-tts-universal"; +import { EdgeTTS } from "edge-tts-universal/browser"; + +export async function getTTSAudioUrl( + text: string, + short_name: string, + options: ProsodyOptions | undefined = undefined, +) { + const tts = new EdgeTTS(text, short_name, options); + try { + const result = await tts.synthesize(); + return URL.createObjectURL(result.audio); + } catch (e) { + throw e; + } +} diff --git a/src/utils.ts b/src/utils.ts index 97ff5e1..4359588 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,5 @@ import { EdgeTTS, ProsodyOptions } from "edge-tts-universal/browser"; import { env } from "process"; -import { TextSpeakerArraySchema } from "./interfaces"; import z from "zod"; import { NextResponse } from "next/server"; @@ -60,33 +59,43 @@ export async function getTTSAudioUrl( throw e; } } -export const getTextSpeakerData = () => { - try { - if (!localStorage) return []; - const item = localStorage.getItem("text-speaker"); - if (!item) return []; - - const rawData = JSON.parse(item); - const result = TextSpeakerArraySchema.safeParse(rawData); - - if (result.success) { - return result.data; - } else { - console.error("Invalid data structure in localStorage:", result.error); - return []; - } - } catch (e) { - console.error("Failed to parse text-speaker data:", e); - return []; - } -}; -export const setTextSpeakerData = ( - data: z.infer, +export const getLocalStorageOperator = ( + key: string, + schema: T, ) => { - if (!localStorage) return; - localStorage.setItem("text-speaker", JSON.stringify(data)); + return { + get: (): z.infer => { + try { + if (!localStorage) return []; + const item = localStorage.getItem(key); + + if (!item) return []; + + const rawData = JSON.parse(item) as z.infer; + const result = schema.safeParse(rawData); + + if (result.success) { + return result.data; + } else { + console.error( + "Invalid data structure in localStorage:", + result.error, + ); + return []; + } + } catch (e) { + console.error(`Failed to parse ${key} data:`, e); + return []; + } + }, + set: (data: z.infer) => { + if (!localStorage) return; + localStorage.setItem(key, JSON.stringify(data)); + }, + }; }; + export function handleAPIError(error: unknown, message: string) { console.error(message, error); return NextResponse.json( @@ -94,3 +103,24 @@ export function handleAPIError(error: unknown, message: string) { { status: 500 }, ); } + + +export const letsFetch = ( + url: string, + onSuccess: (message: string) => void, + onError: (message: string) => void, + onFinally: () => void, +) => { + return fetch(url) + .then((response) => response.json()) + .then((data) => { + if (data.status === "success") { + onSuccess(data.message); + } else if (data.status === "error") { + onError(data.message); + } else { + onError("Unknown error"); + } + }) + .finally(onFinally); +}; \ No newline at end of file