重构了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

7
.env.example Normal file
View File

@@ -0,0 +1,7 @@
ZHIPU_API_KEY=
AUTH_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
NEXTAUTH_URL=

1
.gitignore vendored
View File

@@ -41,6 +41,7 @@ yarn-error.log*
next-env.d.ts
.env
!.env.example
build.sh

View File

@@ -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();

230
package-lock.json generated
View File

@@ -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",

View File

@@ -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": {

View File

@@ -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 更新了单词板,单词不再会重叠

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@@ -0,0 +1 @@
<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 960 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@@ -0,0 +1 @@
<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#24292f"/></svg>

After

Width:  |  Height:  |  Size: 963 B

View File

@@ -1,5 +1,7 @@
{
"title": "LL",
"about": "About",
"sourceCode": "GitHub"
"sourceCode": "GitHub",
"login": "Login",
"profile": "Profile"
}

View File

@@ -1,5 +1,7 @@
{
"title": "学语言",
"about": "关于",
"sourceCode": "源码"
"sourceCode": "源码",
"login": "登录",
"profile": "个人资料"
}

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 [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 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 tl = ["Chinese", "English", "Italian"];
const lastTTS = useRef({
text: "",
url: "",
});
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}

View File

@@ -0,0 +1,7 @@
export const Center = ({ children }: { children: React.ReactNode }) => {
return (
<div className="flex items-center justify-center h-[calc(100dvh-64px)]">
{children}
</div>
);
};

View File

@@ -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 (
<input
@@ -17,6 +19,7 @@ export default function Input({
placeholder={placeholder}
type={type}
className={`block focus:outline-none border-b-2 border-gray-600 ${className}`}
name={name}
/>
);
}

View File

@@ -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 (
<Link className="font-bold" href={href}>
{label}
{children}
</Link>
);
}
@@ -25,6 +32,7 @@ export function Navbar() {
document.cookie = `locale=${locale}`;
window.location.reload();
};
const session = useSession();
return (
<div className="flex justify-between items-center w-full h-16 px-8 bg-[#35786f] text-white">
<Link href={"/"} className="text-xl flex">
@@ -64,11 +72,17 @@ export function Navbar() {
onClick={handleLanguageClick}
></IconClick>
</div>
<MyLink href="/changelog.txt" label={t("about")}></MyLink>
<MyLink
href="https://github.com/GoddoNebianU/learn-languages"
label={t("sourceCode")}
></MyLink>
{session?.status === "authenticated" ? (
<div className="flex gap-2">
<MyLink href="/profile">{t("profile")}</MyLink>
</div>
) : (
<MyLink href="/login">{t("login")}</MyLink>
)}
<MyLink href="/changelog.txt">{t("about")}</MyLink>
<MyLink href="https://github.com/GoddoNebianU/learn-languages">
{t("sourceCode")}
</MyLink>
</div>
</div>
);

View File

@@ -1,18 +0,0 @@
import NavbarWrapper from "./NavbarWrapper";
interface Props {
children: React.ReactNode;
className?: string;
}
export default function NavbarCenterWrapper({ children, className }: Props) {
return (
<NavbarWrapper>
<div
className={`flex-1 flex justify-center items-center ${className}`}
>
{children}
</div>
</NavbarWrapper>
);
}

View File

@@ -1,14 +0,0 @@
import { Navbar } from "./Navbar";
interface Props {
children: React.ReactNode;
}
export default function NavbarWrapper({ children }: Props) {
return (
<div className="h-screen flex flex-col">
<Navbar></Navbar>
{children}
</div>
);
}

View File

@@ -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<typeof LightButton>) {
return (
<form
action={async () => {
"use server"
await signIn(provider)
}}
>
<LightButton {...props}>Sign In</LightButton>
</form>
)
}
export function SignOut(props: React.ComponentPropsWithRef<typeof LightButton>) {
return (
<form
action={async () => {
"use server"
await signOut()
}}
className="w-full"
>
<LightButton className="w-full p-0" {...props}>
Sign Out
</LightButton>
</form>
)
}

View File

@@ -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 (
<PlainButton
onClick={onClick}
className={`hover:bg-gray-600 text-white ${selected ? "bg-gray-600" : "bg-gray-800"} ${className}`}
type={type}
>
{children}
</PlainButton>

View File

@@ -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 (
<PlainButton
onClick={onClick}
className={`hover:bg-gray-200 text-gray-800 ${selected ? "bg-gray-200" : "bg-white"} ${className}`}
type={type}
>
{children}
</PlainButton>

View File

@@ -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 (
<button
onClick={onClick}
className={`px-2 py-1 rounded shadow font-bold hover:cursor-pointer ${className}`}
type={type}
>
{children}
</button>

View File

@@ -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;

View File

@@ -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<typeof WordDataSchema>;

View File

@@ -0,0 +1,11 @@
"use client";
import { SessionProvider } from "next-auth/react";
export default function SessionWrapper({
children,
}: {
children: React.ReactNode;
}) {
return <SessionProvider>{children}</SessionProvider>;
}

11
src/lib/actions.ts Normal file
View File

@@ -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);
}

63
src/lib/ai.ts Normal file
View File

@@ -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)),
),
),
});
}

View File

@@ -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);
}
}
}

View File

@@ -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<typeof TranslationHistoryArraySchema>(
"translator",
TranslationHistoryArraySchema,
);
export const tlsoPush = (item: z.infer<typeof TranslationHistorySchema>) => {
tlso.set(
[...tlso.get(), item as z.infer<typeof TranslationHistorySchema>].slice(
-MAX_HISTORY_LENGTH,
),
);
};

16
src/lib/tts.ts Normal file
View File

@@ -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;
}
}

View File

@@ -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<typeof TextSpeakerArraySchema>,
export const getLocalStorageOperator = <T extends z.ZodTypeAny>(
key: string,
schema: T,
) => {
if (!localStorage) return;
localStorage.setItem("text-speaker", JSON.stringify(data));
return {
get: (): z.infer<T> => {
try {
if (!localStorage) return [];
const item = localStorage.getItem(key);
if (!item) return [];
const rawData = JSON.parse(item) as z.infer<T>;
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<T>) => {
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);
};