重构了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 next-env.d.ts
.env .env
!.env.example
build.sh build.sh

View File

@@ -3,7 +3,17 @@ import createNextIntlPlugin from "next-intl/plugin";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
output: "standalone", 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(); const withNextIntl = createNextIntlPlugin();

230
package-lock.json generated
View File

@@ -17,6 +17,7 @@
"pg": "^8.16.3", "pg": "^8.16.3",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"unstorage": "^1.17.2",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
@@ -5229,8 +5230,6 @@
"resolved": "https://mirrors.cloud.tencent.com/npm/anymatch/-/anymatch-3.1.3.tgz", "resolved": "https://mirrors.cloud.tencent.com/npm/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"license": "ISC", "license": "ISC",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"normalize-path": "^3.0.0", "normalize-path": "^3.0.0",
"picomatch": "^2.0.4" "picomatch": "^2.0.4"
@@ -6133,6 +6132,21 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "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": { "node_modules/chrome-launcher": {
"version": "0.15.2", "version": "0.15.2",
"resolved": "https://mirrors.cloud.tencent.com/npm/chrome-launcher/-/chrome-launcher-0.15.2.tgz", "resolved": "https://mirrors.cloud.tencent.com/npm/chrome-launcher/-/chrome-launcher-0.15.2.tgz",
@@ -6479,6 +6493,12 @@
"node": ">= 0.6" "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": { "node_modules/core-js-compat": {
"version": "3.46.0", "version": "3.46.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/core-js-compat/-/core-js-compat-3.46.0.tgz", "resolved": "https://mirrors.cloud.tencent.com/npm/core-js-compat/-/core-js-compat-3.46.0.tgz",
@@ -6518,6 +6538,15 @@
"node": ">= 8" "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": { "node_modules/crypto-random-string": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/crypto-random-string/-/crypto-random-string-2.0.0.tgz", "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" "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": { "node_modules/delayed-stream": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://mirrors.cloud.tencent.com/npm/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -6730,6 +6765,12 @@
"node": ">= 0.8" "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": { "node_modules/destroy": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/destroy/-/destroy-1.2.0.tgz", "resolved": "https://mirrors.cloud.tencent.com/npm/destroy/-/destroy-1.2.0.tgz",
@@ -8263,6 +8304,23 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/has-bigints": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/has-bigints/-/has-bigints-1.1.0.tgz", "resolved": "https://mirrors.cloud.tencent.com/npm/has-bigints/-/has-bigints-1.1.0.tgz",
@@ -8586,6 +8644,15 @@
"loose-envify": "^1.0.0" "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": { "node_modules/is-array-buffer": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://mirrors.cloud.tencent.com/npm/is-array-buffer/-/is-array-buffer-3.0.5.tgz", "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": { "node_modules/node-forge": {
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/node-forge/-/node-forge-1.3.1.tgz", "resolved": "https://mirrors.cloud.tencent.com/npm/node-forge/-/node-forge-1.3.1.tgz",
@@ -11741,6 +11814,12 @@
"optional": true, "optional": true,
"peer": 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": { "node_modules/node-releases": {
"version": "2.0.27", "version": "2.0.27",
"resolved": "https://mirrors.cloud.tencent.com/npm/node-releases/-/node-releases-2.0.27.tgz", "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", "resolved": "https://mirrors.cloud.tencent.com/npm/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -11937,6 +12014,17 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/oidc-token-hash": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", "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", "version": "2.3.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/picomatch/-/picomatch-2.3.1.tgz", "resolved": "https://mirrors.cloud.tencent.com/npm/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8.6" "node": ">=8.6"
@@ -12815,6 +12902,12 @@
], ],
"license": "MIT" "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": { "node_modules/range-parser": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/range-parser/-/range-parser-1.2.1.tgz", "resolved": "https://mirrors.cloud.tencent.com/npm/range-parser/-/range-parser-1.2.1.tgz",
@@ -12928,6 +13021,19 @@
"node": ">=0.10.0" "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": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://mirrors.cloud.tencent.com/npm/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://mirrors.cloud.tencent.com/npm/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -14790,6 +14896,12 @@
"node": ">=14.17" "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": { "node_modules/unbox-primitive": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/unbox-primitive/-/unbox-primitive-1.1.0.tgz", "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" "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": { "node_modules/undici": {
"version": "6.22.0", "version": "6.22.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/undici/-/undici-6.22.0.tgz", "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" "@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": { "node_modules/update-browserslist-db": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://mirrors.cloud.tencent.com/npm/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", "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", "pg": "^8.16.3",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"unstorage": "^1.17.2",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,3 +1,4 @@
2025.11.10 重构了translator将其改为并发请求多个数据速度大大提升
2025.10.31 添加国际化支持 2025.10.31 添加国际化支持
2025.10.30 添加背单词功能 2025.10.30 添加背单词功能
2025.10.12 添加朗读器本地保存功能 2025.10.12 添加朗读器本地保存功能
@@ -8,4 +9,3 @@
2025.10.05 新增IPA生成与文本朗读功能 2025.10.05 新增IPA生成与文本朗读功能
2025.09.25 优化了主界面UI 2025.09.25 优化了主界面UI
2025.09.19 更新了单词板,单词不再会重叠 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", "title": "LL",
"about": "About", "about": "About",
"sourceCode": "GitHub" "sourceCode": "GitHub",
"login": "Login",
"profile": "Profile"
} }

View File

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

View File

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

View File

@@ -1,53 +1,15 @@
import { pool } from "@/lib/db"; import NextAuth, { AuthOptions } from "next-auth";
import NextAuth, { SessionStrategy } from "next-auth"; import GithubProvider from "next-auth/providers/github";
import CredentialsProvider from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
export const authOptions = { export const authOptions: AuthOptions = {
providers: [ providers: [
CredentialsProvider({ GithubProvider({
name: "Credentials", clientId: process.env.GITHUB_CLIENT_ID!,
credentials: { clientSecret: process.env.GITHUB_CLIENT_SECRET!,
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;
}
},
}), }),
], ],
session: { strategy: "jwt" as SessionStrategy },
pages: { signIn: "/login" },
}; };
const handler = NextAuth(authOptions); const handler = NextAuth(authOptions);
export { handler as GET, handler as POST }; 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 "./globals.css";
import type { Viewport } from "next"; import type { Viewport } from "next";
import { NextIntlClientProvider } from "next-intl"; import { NextIntlClientProvider } from "next-intl";
import SessionWrapper from "@/lib/SessionWrapper";
import { Navbar } from "@/components/Navbar";
export const viewport: Viewport = { export const viewport: Viewport = {
width: "device-width", width: "device-width",
@@ -19,12 +21,15 @@ export default async function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en"> <SessionWrapper>
<body <html lang="en">
className={`antialiased`} <body className={`antialiased`}>
> <NextIntlClientProvider>
<NextIntlClientProvider>{children}</NextIntlClientProvider> <Navbar></Navbar>
</body> {children}
</html> </NextIntlClientProvider>
</body>
</html>
</SessionWrapper>
); );
} }

View File

@@ -1,25 +1,42 @@
"use client"; "use client";
import LightButton from "@/components/buttons/LightButton"; import LightButton from "@/components/buttons/LightButton";
import ACard from "@/components/cards/ACard"; import { Center } from "@/components/Center";
import Input from "@/components/Input"; import IMAGES from "@/config/images";
import NavbarCenterWrapper from "@/components/NavbarCenterWrapper"; import { signIn, useSession } from "next-auth/react";
import { useRef } from "react"; import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
export default function Login() { export default function LoginPage() {
const usernameRef = useRef<HTMLInputElement>(null); const session = useSession();
const passwordRef = useRef<HTMLInputElement>(null); const router = useRouter();
const searchParams = useSearchParams();
useEffect(() => {
if (session.status === "authenticated") {
router.push(searchParams.get("redirect") || "/");
}
}, [session.status, router, searchParams]);
return ( return (
<NavbarCenterWrapper> <Center>
<ACard className="md:border-2 border-gray-200 flex items-center justify-center flex-col gap-8"> {session.status === "loading" ? (
<h1 className="text-2xl md:text-4xl font-bold">Login</h1> <div>Loading...</div>
<form className="flex flex-col gap-2 md:text-xl"> ) : (
<Input ref={usernameRef} placeholder="username" type="text" /> <LightButton
<Input ref={passwordRef} placeholder="password" type="password" /> className="flex flex-row p-2 gap-2"
<LightButton>Submit</LightButton> onClick={() => signIn("github")}
</form> >
</ACard> <Image
</NavbarCenterWrapper> 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 { LOCALES } from "@/config/locales";
import { Dispatch, SetStateAction, useState } from "react"; import { Dispatch, SetStateAction, useState } from "react";
import { WordData } from "@/interfaces"; import { WordData } from "@/interfaces";
import NavbarCenterWrapper from "@/components/NavbarCenterWrapper";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
interface Props { interface Props {
@@ -39,7 +39,7 @@ export default function Choose({
}; };
return ( return (
<NavbarCenterWrapper className="bg-gray-100"> <div className="w-screen flex justify-center items-center">
<ACard className="flex flex-col"> <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"> <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) => ( {LOCALES.map((locale, index) => (
@@ -62,6 +62,6 @@ export default function Choose({
</BCard> </BCard>
</div> </div>
</ACard> </ACard>
</NavbarCenterWrapper> </div>
); );
} }

View File

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

View File

@@ -4,7 +4,6 @@ import BCard from "@/components/cards/BCard";
import { WordData, WordDataSchema } from "@/interfaces"; import { WordData, WordDataSchema } from "@/interfaces";
import { Dispatch, SetStateAction } from "react"; import { Dispatch, SetStateAction } from "react";
import useFileUpload from "@/hooks/useFileUpload"; import useFileUpload from "@/hooks/useFileUpload";
import NavbarCenterWrapper from "@/components/NavbarCenterWrapper";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
interface Props { interface Props {
@@ -43,7 +42,7 @@ export default function Main({
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
return ( return (
<NavbarCenterWrapper className="bg-gray-100"> <div className="w-screen flex justify-center items-center">
<ACard className="flex-col flex"> <ACard className="flex-col flex">
<h1 className="text-center font-extrabold text-4xl text-gray-800 m-2 mb-4"> <h1 className="text-center font-extrabold text-4xl text-gray-800 m-2 mb-4">
{t("title")} {t("title")}
@@ -69,6 +68,6 @@ export default function Main({
</div> </div>
</ACard> </ACard>
<input type="file" hidden ref={inputRef}></input> <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 { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { getTTSAudioUrl } from "@/utils"; import { getTTSAudioUrl } from "@/utils";
import { VOICES } from "@/config/locales"; import { VOICES } from "@/config/locales";
import NavbarCenterWrapper from "@/components/NavbarCenterWrapper";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
interface WordBoardProps { interface WordBoardProps {
@@ -49,7 +49,7 @@ export default function Start({ wordData, setPage }: Props) {
).then(play); ).then(play);
}; };
return ( 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-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"> <div className="flex-1 w-[95dvw] md:w-fit p-4 gap-4 flex flex-col overflow-x-auto">
{dictation ? ( {dictation ? (
@@ -95,6 +95,6 @@ export default function Start({ wordData, setPage }: Props) {
</div> </div>
</div> </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 [page, setPage] = useState<"start" | "main" | "edit">("main");
const [wordData, setWordData] = useState<WordData>(getLocalWordData()); const [wordData, setWordData] = useState<WordData>(getLocalWordData());
if (page === "main") if (page === "main")

View File

@@ -1,8 +1,7 @@
import { Navbar } from "@/components/Navbar";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
export default function Home() { export default function HomePage() {
const t = useTranslations("home"); const t = useTranslations("home");
function TopArea() { function TopArea() {
return ( return (
@@ -101,7 +100,6 @@ export default function Home() {
} }
return ( return (
<> <>
<Navbar></Navbar>
<TopArea></TopArea> <TopArea></TopArea>
<Fortune></Fortune> <Fortune></Fortune>
<Explore></Explore> <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 { KeyboardEvent, useRef, useState } from "react";
import UploadArea from "./UploadArea"; import UploadArea from "./UploadArea";
import VideoPanel from "./VideoPlayer/VideoPanel"; import VideoPanel from "./VideoPlayer/VideoPanel";
import { Navbar } from "@/components/Navbar";
export default function SrtPlayer() { export default function SrtPlayerPage() {
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const [videoUrl, setVideoUrl] = useState<string | null>(null); const [videoUrl, setVideoUrl] = useState<string | null>(null);
const [srtUrl, setSrtUrl] = useState<string | null>(null); const [srtUrl, setSrtUrl] = useState<string | null>(null);
return ( return (
<> <>
<Navbar></Navbar>
<div <div
className="flex w-screen pt-8 items-center justify-center" className="flex w-screen pt-8 items-center justify-center"
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => e.preventDefault()} onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => e.preventDefault()}

View File

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

View File

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

View File

@@ -1,293 +1,253 @@
"use client"; "use client";
import { ChangeEvent, useState } from "react";
import LightButton from "@/components/buttons/LightButton"; import LightButton from "@/components/buttons/LightButton";
import IconClick from "@/components/IconClick"; import IconClick from "@/components/IconClick";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import IMAGES from "@/config/images"; import IMAGES from "@/config/images";
import { getTTSAudioUrl } from "@/utils";
import { Navbar } from "@/components/Navbar";
import { VOICES } from "@/config/locales"; 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 { 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 t = useTranslations("translator");
const [ipaEnabled, setIPAEnabled] = useState(true);
const [targetLang, setTargetLang] = useState("Chinese");
const [sourceText, setSourceText] = useState(""); const taref = useRef<HTMLTextAreaElement>(null);
const [targetText, setTargetText] = useState(""); const [lang, setLang] = useState<string>("chinese");
const [sourceIPA, setSourceIPA] = useState(""); const [tresult, setTresult] = useState<string>("");
const [targetIPA, setTargetIPA] = useState(""); const [genIpa, setGenIpa] = useState(true);
const [sourceLocale, setSourceLocale] = useState<string | null>(null); const [ipaTexts, setIpaTexts] = useState(["", ""]);
const [targetLocale, setTargetLocale] = useState<string | null>(null); const [processing, setProcessing] = useState(false);
const [translating, setTranslating] = useState(false); const { load, play } = useAudioPlayer();
const { play, load } = useAudioPlayer();
const tl = ["Chinese", "English", "Italian"]; const lastTTS = useRef({
text: "",
url: "",
});
const inputLanguage = () => { const tts = async (text: string, locale: string) => {
const lang = prompt(t("inputLanguage"))?.trim(); if (lastTTS.current.text !== text) {
if (lang) { const url = await getTTSAudioUrl(
setTargetLang(lang); text,
} VOICES.find((v) => v.locale === locale)!.short_name,
}; );
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);
await load(url); await load(url);
await play(); lastTTS.current.text = text;
URL.revokeObjectURL(url); lastTTS.current.url = url;
} }
play();
}; };
const readTarget = async () => { const translate = async () => {
if (targetText.length === 0) return; if (processing) return;
setProcessing(true);
if (targetIPA.length === 0 && ipaEnabled) { if (!taref.current) return;
const params = new URLSearchParams({ const text = taref.current.value;
text: targetText,
});
fetch(`/api/ipa?${params}`)
.then((res) => res.json())
.then((data) => {
setTargetIPA(data.ipa);
})
.catch((e) => {
console.error(e);
setTargetIPA("");
});
}
const voice = VOICES.find((v) => v.locale.startsWith(targetLocale!)); const newItem: {
if (!voice) return; 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); const checkUpdateLocalStorage = (item: typeof newItem) => {
await load(url); if (item.text1 && item.text2 && item.locale1 && item.locale2) {
await play(); tlsoPush(item as z.infer<typeof TranslationHistorySchema>);
URL.revokeObjectURL(url); }
};
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 ( return (
<> <>
<Navbar></Navbar> {/* TCard Component */}
<div className="w-screen flex flex-col md:flex-row md:justify-between gap-2 p-2"> <div className="w-screen flex flex-col md:flex-row md:justify-between gap-2 p-2">
<div className="card1 w-full md:w-1/2 flex flex-col-reverse gap-2"> {/* Card Component - Left Side */}
<div className="textarea1 border border-gray-200 rounded-2xl w-full h-64 p-2"> <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 <textarea
className="resize-none h-8/12 w-full focus:outline-0"
ref={taref}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.ctrlKey && e.key === "Enter") translate(); if (e.ctrlKey && e.key === "Enter") translate();
}} }}
onChange={handleInputChange}
className="resize-none h-8/12 w-full focus:outline-0"
></textarea> ></textarea>
<div className="ipa w-full h-2/12 overflow-auto text-gray-600"> <div className="ipa w-full h-2/12 overflow-auto text-gray-600">
{sourceIPA} {ipaTexts[0]}
</div> </div>
<div className="h-2/12 w-full flex justify-end items-center"> <div className="h-2/12 w-full flex justify-end items-center">
<IconClick <IconClick
onClick={async () => {
if (sourceText.length !== 0)
await navigator.clipboard.writeText(sourceText);
}}
src={IMAGES.copy_all} src={IMAGES.copy_all}
alt="copy" alt="copy"
onClick={async () => {
await navigator.clipboard.writeText(
taref.current?.value || "",
);
}}
></IconClick> ></IconClick>
<IconClick <IconClick
onClick={readSource}
src={IMAGES.play_arrow} src={IMAGES.play_arrow}
alt="play" alt="play"
onClick={() => {
const t = taref.current?.value;
if (!t) return;
tts(t, tlso.get().find((v) => v.text1 === t)?.locale1 || "");
}}
></IconClick> ></IconClick>
</div> </div>
</div> </div>
<div className="option1 w-full flex flex-row justify-between items-center"> <div className="option1 w-full flex flex-row justify-between items-center">
<span>{t("detectLanguage")}</span> <span>{t("detectLanguage")}</span>
<LightButton <LightButton
selected={ipaEnabled} selected={genIpa}
onClick={() => setIPAEnabled(!ipaEnabled)} onClick={() => setGenIpa((prev) => !prev)}
> >
{t("generateIPA")} {t("generateIPA")}
</LightButton> </LightButton>
</div> </div>
</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"> {/* Card Component - Right Side */}
<div className="h-8/12 w-full">{targetText}</div> <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"> <div className="ipa w-full h-2/12 overflow-auto text-gray-600">
{targetIPA} {ipaTexts[1]}
</div> </div>
<div className="h-2/12 w-full flex justify-end items-center"> <div className="h-2/12 w-full flex justify-end items-center">
<IconClick <IconClick
onClick={async () => {
if (targetText.length !== 0)
await navigator.clipboard.writeText(targetText);
}}
src={IMAGES.copy_all} src={IMAGES.copy_all}
alt="copy" alt="copy"
onClick={async () => {
await navigator.clipboard.writeText(tresult);
}}
></IconClick> ></IconClick>
<IconClick <IconClick
onClick={readTarget}
src={IMAGES.play_arrow} src={IMAGES.play_arrow}
alt="play" alt="play"
onClick={() => {
tts(
tresult,
tlso.get().find((v) => v.text2 === tresult)?.locale2 || "",
);
}}
></IconClick> ></IconClick>
</div> </div>
</div> </div>
<div className="option2 w-full flex gap-1 items-center flex-wrap"> <div className="option2 w-full flex gap-1 items-center flex-wrap">
<span>{t("translateInto")}</span> <span>{t("translateInto")}</span>
<LightButton <LightButton
onClick={() => { selected={lang === "chinese"}
setTargetLang("Chinese"); onClick={() => setLang("chinese")}
}}
selected={targetLang === "Chinese"}
> >
{t("chinese")} {t("chinese")}
</LightButton> </LightButton>
<LightButton <LightButton
onClick={() => { selected={lang === "english"}
setTargetLang("English"); onClick={() => setLang("english")}
}}
selected={targetLang === "English"}
> >
{t("english")} {t("english")}
</LightButton> </LightButton>
<LightButton <LightButton
onClick={() => { selected={lang === "italian"}
setTargetLang("Italian"); onClick={() => setLang("italian")}
}}
selected={targetLang === "Italian"}
> >
{t("italian")} {t("italian")}
</LightButton> </LightButton>
<LightButton <LightButton
onClick={inputLanguage} selected={!["chinese", "english", "italian"].includes(lang)}
selected={!tl.includes(targetLang)} onClick={() => {
const newLang = prompt("Enter language");
if (newLang) {
setLang(newLang);
}
}}
> >
{t("other") + (tl.includes(targetLang) ? "" : ": " + targetLang)} {t("other")}
</LightButton> </LightButton>
</div> </div>
</div> </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 <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} 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> </button>
</div> </div>
</> </>

View File

@@ -10,9 +10,8 @@ import {
TEXT_SIZE, TEXT_SIZE,
} from "@/config/word-board-config"; } from "@/config/word-board-config";
import { inspect } from "@/utils"; import { inspect } from "@/utils";
import { Navbar } from "@/components/Navbar";
export default function WordBoard() { export default function WordBoardPage() {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const inputFileRef = useRef<HTMLInputElement>(null); const inputFileRef = useRef<HTMLInputElement>(null);
const initialWords = [ const initialWords = [
@@ -147,7 +146,6 @@ export default function WordBoard() {
// } // }
return ( return (
<> <>
<Navbar></Navbar>
<div className="flex w-screen h-screen justify-center items-center"> <div className="flex w-screen h-screen justify-center items-center">
<div <div
onKeyDown={handleKeyDown} 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; placeholder?: string;
type?: string; type?: string;
className?: string; className?: string;
name?: string;
} }
export default function Input({ export default function Input({
@@ -10,6 +11,7 @@ export default function Input({
placeholder = "", placeholder = "",
type = "text", type = "text",
className = "", className = "",
name = "",
}: Props) { }: Props) {
return ( return (
<input <input
@@ -17,6 +19,7 @@ export default function Input({
placeholder={placeholder} placeholder={placeholder}
type={type} type={type}
className={`block focus:outline-none border-b-2 border-gray-600 ${className}`} 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 IMAGES from "@/config/images";
import { useState } from "react"; import { useState } from "react";
import LightButton from "./buttons/LightButton"; 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 ( return (
<Link className="font-bold" href={href}> <Link className="font-bold" href={href}>
{label} {children}
</Link> </Link>
); );
} }
@@ -25,6 +32,7 @@ export function Navbar() {
document.cookie = `locale=${locale}`; document.cookie = `locale=${locale}`;
window.location.reload(); window.location.reload();
}; };
const session = useSession();
return ( return (
<div className="flex justify-between items-center w-full h-16 px-8 bg-[#35786f] text-white"> <div className="flex justify-between items-center w-full h-16 px-8 bg-[#35786f] text-white">
<Link href={"/"} className="text-xl flex"> <Link href={"/"} className="text-xl flex">
@@ -64,11 +72,17 @@ export function Navbar() {
onClick={handleLanguageClick} onClick={handleLanguageClick}
></IconClick> ></IconClick>
</div> </div>
<MyLink href="/changelog.txt" label={t("about")}></MyLink> {session?.status === "authenticated" ? (
<MyLink <div className="flex gap-2">
href="https://github.com/GoddoNebianU/learn-languages" <MyLink href="/profile">{t("profile")}</MyLink>
label={t("sourceCode")} </div>
></MyLink> ) : (
<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>
</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, className,
selected, selected,
children, children,
type = "button",
}: { }: {
onClick?: () => void; onClick?: () => void;
className?: string; className?: string;
selected?: boolean; selected?: boolean;
children?: React.ReactNode; children?: React.ReactNode;
type?: string;
}) { }) {
return ( return (
<PlainButton <PlainButton
onClick={onClick} onClick={onClick}
className={`hover:bg-gray-600 text-white ${selected ? "bg-gray-600" : "bg-gray-800"} ${className}`} className={`hover:bg-gray-600 text-white ${selected ? "bg-gray-600" : "bg-gray-800"} ${className}`}
type={type}
> >
{children} {children}
</PlainButton> </PlainButton>

View File

@@ -5,16 +5,19 @@ export default function LightButton({
className, className,
selected, selected,
children, children,
type = "button"
}: { }: {
onClick?: () => void; onClick?: () => void;
className?: string; className?: string;
selected?: boolean; selected?: boolean;
children?: React.ReactNode; children?: React.ReactNode;
type?: string;
}) { }) {
return ( return (
<PlainButton <PlainButton
onClick={onClick} onClick={onClick}
className={`hover:bg-gray-200 text-gray-800 ${selected ? "bg-gray-200" : "bg-white"} ${className}`} className={`hover:bg-gray-200 text-gray-800 ${selected ? "bg-gray-200" : "bg-white"} ${className}`}
type={type}
> >
{children} {children}
</PlainButton> </PlainButton>

View File

@@ -2,15 +2,18 @@ export default function PlainButton({
onClick, onClick,
className, className,
children, children,
type = "button",
}: { }: {
onClick?: () => void; onClick?: () => void;
className?: string; className?: string;
children?: React.ReactNode; children?: React.ReactNode;
type?: "button" | "submit" | "reset" | undefined;
}) { }) {
return ( return (
<button <button
onClick={onClick} onClick={onClick}
className={`px-2 py-1 rounded shadow font-bold hover:cursor-pointer ${className}`} className={`px-2 py-1 rounded shadow font-bold hover:cursor-pointer ${className}`}
type={type}
> >
{children} {children}
</button> </button>

View File

@@ -17,6 +17,7 @@ const IMAGES = {
speed: "/images/speed_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg", speed: "/images/speed_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg",
language_black: "/images/language_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", language_white: "/images/language_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg",
github_mark: "/images/github-mark/github-mark.svg",
}; };
export default IMAGES; export default IMAGES;

View File

@@ -24,17 +24,33 @@ export const TextSpeakerItemSchema = z.object({
export const TextSpeakerArraySchema = z.array(TextSpeakerItemSchema); export const TextSpeakerArraySchema = z.array(TextSpeakerItemSchema);
export const WordDataSchema = z.object({ export const WordDataSchema = z.object({
locales: z.tuple([z.string(), z.string()]) locales: z
.tuple([z.string(), z.string()])
.refine(([first, second]) => first !== second, { .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") .min(1, "At least one word pair is required")
.refine((pairs) => { .refine(
return pairs.every(([first, second]) => first.trim() !== '' && second.trim() !== ''); (pairs) => {
}, { return pairs.every(
message: "Word pairs cannot contain empty strings" ([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>; 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) { static async getUserByUsername(username: string) {
try { 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]; return user.rows[0];
} catch (e) { } catch (e) {
console.log(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 { 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 { EdgeTTS, ProsodyOptions } from "edge-tts-universal/browser";
import { env } from "process"; import { env } from "process";
import { TextSpeakerArraySchema } from "./interfaces";
import z from "zod"; import z from "zod";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
@@ -60,33 +59,43 @@ export async function getTTSAudioUrl(
throw e; throw e;
} }
} }
export const getTextSpeakerData = () => {
try {
if (!localStorage) return [];
const item = localStorage.getItem("text-speaker");
if (!item) return []; export const getLocalStorageOperator = <T extends z.ZodTypeAny>(
key: string,
const rawData = JSON.parse(item); schema: T,
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>,
) => { ) => {
if (!localStorage) return; return {
localStorage.setItem("text-speaker", JSON.stringify(data)); 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) { export function handleAPIError(error: unknown, message: string) {
console.error(message, error); console.error(message, error);
return NextResponse.json( return NextResponse.json(
@@ -94,3 +103,24 @@ export function handleAPIError(error: unknown, message: string) {
{ status: 500 }, { 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);
};