重构了translator,写了点数据库、后端api路由
Some checks reported errors
continuous-integration/drone/push Build was killed
Some checks reported errors
continuous-integration/drone/push Build was killed
This commit is contained in:
@@ -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
230
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 更新了单词板,单词不再会重叠
|
||||
|
||||
|
||||
BIN
public/images/github-mark/github-mark-white.png
Normal file
BIN
public/images/github-mark/github-mark-white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
1
public/images/github-mark/github-mark-white.svg
Normal file
1
public/images/github-mark/github-mark-white.svg
Normal 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 |
BIN
public/images/github-mark/github-mark.png
Normal file
BIN
public/images/github-mark/github-mark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
1
public/images/github-mark/github-mark.svg
Normal file
1
public/images/github-mark/github-mark.svg
Normal 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 |
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"title": "LL",
|
||||
"about": "About",
|
||||
"sourceCode": "GitHub"
|
||||
"sourceCode": "GitHub",
|
||||
"login": "Login",
|
||||
"profile": "Profile"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"title": "学语言",
|
||||
"about": "关于",
|
||||
"sourceCode": "源码"
|
||||
"sourceCode": "源码",
|
||||
"login": "登录",
|
||||
"profile": "个人资料"
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import LightButton from "@/components/buttons/LightButton";
|
||||
import { Letter, SupportedAlphabets } from "@/interfaces";
|
||||
import { useEffect, useState } from "react";
|
||||
import MemoryCard from "./MemoryCard";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function Alphabet() {
|
||||
@@ -58,7 +58,6 @@ export default function Alphabet() {
|
||||
if (!chosenAlphabet)
|
||||
return (
|
||||
<>
|
||||
<Navbar></Navbar>
|
||||
<div className="border border-gray-200 m-4 mt-4 flex flex-col justify-center items-center p-4 rounded-2xl gap-2">
|
||||
<span className="text-2xl md:text-3xl">{t("chooseCharacters")}</span>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
@@ -87,7 +86,6 @@ export default function Alphabet() {
|
||||
if (loadingState === "success" && alphabetData[chosenAlphabet]) {
|
||||
return (
|
||||
<>
|
||||
<Navbar></Navbar>
|
||||
<MemoryCard
|
||||
alphabet={alphabetData[chosenAlphabet]}
|
||||
setChosenAlphabet={setChosenAlphabet}
|
||||
|
||||
@@ -1,53 +1,15 @@
|
||||
import { pool } from "@/lib/db";
|
||||
import NextAuth, { SessionStrategy } from "next-auth";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import bcrypt from "bcryptjs";
|
||||
import NextAuth, { AuthOptions } from "next-auth";
|
||||
import GithubProvider from "next-auth/providers/github";
|
||||
|
||||
export const authOptions = {
|
||||
export const authOptions: AuthOptions = {
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: "Credentials",
|
||||
credentials: {
|
||||
username: { label: "Username", type: "text", placeholder: "jsmith" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.username || !credentials?.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
"SELECT * FROM users WHERE username = $1",
|
||||
[credentials.username],
|
||||
);
|
||||
|
||||
const user = result.rows[0];
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isValidPassword = await bcrypt.compare(
|
||||
credentials.password,
|
||||
user.password,
|
||||
);
|
||||
if (!isValidPassword) return null;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Auth error:", error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
GithubProvider({
|
||||
clientId: process.env.GITHUB_CLIENT_ID!,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
||||
}),
|
||||
],
|
||||
session: { strategy: "jwt" as SessionStrategy },
|
||||
pages: { signIn: "/login" },
|
||||
};
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
|
||||
18
src/app/api/folder/[id]/route.ts
Normal file
18
src/app/api/folder/[id]/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { NextResponse } from "next/server";
|
||||
import { authOptions } from "../../auth/[...nextauth]/route";
|
||||
import { WordPairController } from "@/lib/db";
|
||||
|
||||
export async function GET({ params }: { params: { slug: number } }) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (session) {
|
||||
const id = params.slug;
|
||||
return new NextResponse(
|
||||
JSON.stringify(
|
||||
await WordPairController.getWordPairsByFolderId(id),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return new NextResponse("Unauthorized");
|
||||
}
|
||||
}
|
||||
35
src/app/api/folders/route.ts
Normal file
35
src/app/api/folders/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { authOptions } from "../auth/[...nextauth]/route";
|
||||
import { FolderController } from "@/lib/db";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (session) {
|
||||
return new NextResponse(
|
||||
JSON.stringify(
|
||||
await FolderController.getFoldersByOwner(session.user!.name as string),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return new NextResponse("Unauthorized");
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (session) {
|
||||
const body = await req.json();
|
||||
|
||||
return new NextResponse(
|
||||
JSON.stringify(
|
||||
await FolderController.createFolder(
|
||||
body.name,
|
||||
session.user!.name as string,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return new NextResponse("Unauthorized");
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { UserController } from "@/lib/db";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
async function handler(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { slug: string[] } },
|
||||
) {
|
||||
const { slug } = params;
|
||||
if (slug.length !== 1) {
|
||||
return new Response("Invalid slug", { status: 400 });
|
||||
}
|
||||
|
||||
if (req.method === "GET") {
|
||||
return UserController.getUsers();
|
||||
} else if (req.method === "POST") {
|
||||
return UserController.createUser(await req.json());
|
||||
} else {
|
||||
return new Response("Method not allowed", { status: 405 });
|
||||
}
|
||||
}
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
@@ -1,7 +0,0 @@
|
||||
import { UserController } from "@/lib/db";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
const users = await UserController.getUsers();
|
||||
return NextResponse.json(users, { status: 200 });
|
||||
}
|
||||
10
src/app/api/v1/ipa/route.ts
Normal file
10
src/app/api/v1/ipa/route.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { simpleGetLLMAnswer } from "@/lib/ai";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return await simpleGetLLMAnswer(
|
||||
`请生成%s的严式国际音标(International Phonetic Alphabet),然后直接发给我。`,
|
||||
req.nextUrl.searchParams,
|
||||
["text"],
|
||||
);
|
||||
}
|
||||
10
src/app/api/v1/locale/route.ts
Normal file
10
src/app/api/v1/locale/route.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { simpleGetLLMAnswer } from "@/lib/ai";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return await simpleGetLLMAnswer(
|
||||
`请根据文本“%s”推断地区(locale),形如zh-CN、en-US,然后直接发给我。`,
|
||||
req.nextUrl.searchParams,
|
||||
["text"],
|
||||
);
|
||||
}
|
||||
10
src/app/api/v1/translate/route.ts
Normal file
10
src/app/api/v1/translate/route.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { simpleGetLLMAnswer } from "@/lib/ai";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return await simpleGetLLMAnswer(
|
||||
`请翻译%s到%s然后直接发给我。`,
|
||||
req.nextUrl.searchParams,
|
||||
["text", "lang"],
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import type { Viewport } from "next";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import SessionWrapper from "@/lib/SessionWrapper";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
@@ -19,12 +21,15 @@ export default async function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`antialiased`}
|
||||
>
|
||||
<NextIntlClientProvider>{children}</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
<SessionWrapper>
|
||||
<html lang="en">
|
||||
<body className={`antialiased`}>
|
||||
<NextIntlClientProvider>
|
||||
<Navbar></Navbar>
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
</SessionWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import ACard from "@/components/cards/ACard";
|
||||
import Input from "@/components/Input";
|
||||
import NavbarCenterWrapper from "@/components/NavbarCenterWrapper";
|
||||
import { useRef } from "react";
|
||||
import { Center } from "@/components/Center";
|
||||
import IMAGES from "@/config/images";
|
||||
import { signIn, useSession } from "next-auth/react";
|
||||
import Image from "next/image";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function Login() {
|
||||
const usernameRef = useRef<HTMLInputElement>(null);
|
||||
const passwordRef = useRef<HTMLInputElement>(null);
|
||||
export default function LoginPage() {
|
||||
const session = useSession();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (session.status === "authenticated") {
|
||||
router.push(searchParams.get("redirect") || "/");
|
||||
}
|
||||
}, [session.status, router, searchParams]);
|
||||
|
||||
return (
|
||||
<NavbarCenterWrapper>
|
||||
<ACard className="md:border-2 border-gray-200 flex items-center justify-center flex-col gap-8">
|
||||
<h1 className="text-2xl md:text-4xl font-bold">Login</h1>
|
||||
<form className="flex flex-col gap-2 md:text-xl">
|
||||
<Input ref={usernameRef} placeholder="username" type="text" />
|
||||
<Input ref={passwordRef} placeholder="password" type="password" />
|
||||
<LightButton>Submit</LightButton>
|
||||
</form>
|
||||
</ACard>
|
||||
</NavbarCenterWrapper>
|
||||
<Center>
|
||||
{session.status === "loading" ? (
|
||||
<div>Loading...</div>
|
||||
) : (
|
||||
<LightButton
|
||||
className="flex flex-row p-2 gap-2"
|
||||
onClick={() => signIn("github")}
|
||||
>
|
||||
<Image
|
||||
src={IMAGES.github_mark}
|
||||
alt="GitHub Logo"
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
<span>GitHub Login</span>
|
||||
</LightButton>
|
||||
)}
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import BCard from "@/components/cards/BCard";
|
||||
import { LOCALES } from "@/config/locales";
|
||||
import { Dispatch, SetStateAction, useState } from "react";
|
||||
import { WordData } from "@/interfaces";
|
||||
import NavbarCenterWrapper from "@/components/NavbarCenterWrapper";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface Props {
|
||||
@@ -39,7 +39,7 @@ export default function Choose({
|
||||
};
|
||||
|
||||
return (
|
||||
<NavbarCenterWrapper className="bg-gray-100">
|
||||
<div className="w-screen flex justify-center items-center">
|
||||
<ACard className="flex flex-col">
|
||||
<div className="overflow-y-auto flex-1 border border-gray-200 rounded-2xl p-2 grid grid-cols-4 md:grid-cols-6 md:gap-2">
|
||||
{LOCALES.map((locale, index) => (
|
||||
@@ -62,6 +62,6 @@ export default function Choose({
|
||||
</BCard>
|
||||
</div>
|
||||
</ACard>
|
||||
</NavbarCenterWrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ChangeEvent, Dispatch, SetStateAction, useRef, useState } from "react";
|
||||
import DarkButton from "@/components/buttons/DarkButton";
|
||||
import { WordData } from "@/interfaces";
|
||||
import Choose from "./Choose";
|
||||
import NavbarCenterWrapper from "@/components/NavbarCenterWrapper";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface Props {
|
||||
@@ -51,7 +51,7 @@ export default function Edit({ setPage, wordData, setWordData }: Props) {
|
||||
setWordData(newWordData);
|
||||
if (textareaRef.current)
|
||||
textareaRef.current.value = convertFromWordData(newWordData);
|
||||
if(localStorage) {
|
||||
if (localStorage) {
|
||||
localStorage.setItem("wordData", JSON.stringify(newWordData));
|
||||
}
|
||||
};
|
||||
@@ -60,7 +60,7 @@ export default function Edit({ setPage, wordData, setWordData }: Props) {
|
||||
};
|
||||
if (editPage === "edit")
|
||||
return (
|
||||
<NavbarCenterWrapper className="bg-gray-100">
|
||||
<div className="w-screen flex justify-center items-center">
|
||||
<ACard className="flex flex-col">
|
||||
<textarea
|
||||
onKeyDown={(e) => {
|
||||
@@ -96,7 +96,7 @@ export default function Edit({ setPage, wordData, setWordData }: Props) {
|
||||
</BCard>
|
||||
</div>
|
||||
</ACard>
|
||||
</NavbarCenterWrapper>
|
||||
</div>
|
||||
);
|
||||
if (editPage === "choose")
|
||||
return (
|
||||
|
||||
@@ -4,7 +4,6 @@ import BCard from "@/components/cards/BCard";
|
||||
import { WordData, WordDataSchema } from "@/interfaces";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import useFileUpload from "@/hooks/useFileUpload";
|
||||
import NavbarCenterWrapper from "@/components/NavbarCenterWrapper";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface Props {
|
||||
@@ -43,7 +42,7 @@ export default function Main({
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
return (
|
||||
<NavbarCenterWrapper className="bg-gray-100">
|
||||
<div className="w-screen flex justify-center items-center">
|
||||
<ACard className="flex-col flex">
|
||||
<h1 className="text-center font-extrabold text-4xl text-gray-800 m-2 mb-4">
|
||||
{t("title")}
|
||||
@@ -69,6 +68,6 @@ export default function Main({
|
||||
</div>
|
||||
</ACard>
|
||||
<input type="file" hidden ref={inputRef}></input>
|
||||
</NavbarCenterWrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Dispatch, SetStateAction, useState } from "react";
|
||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||
import { getTTSAudioUrl } from "@/utils";
|
||||
import { VOICES } from "@/config/locales";
|
||||
import NavbarCenterWrapper from "@/components/NavbarCenterWrapper";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface WordBoardProps {
|
||||
@@ -49,7 +49,7 @@ export default function Start({ wordData, setPage }: Props) {
|
||||
).then(play);
|
||||
};
|
||||
return (
|
||||
<NavbarCenterWrapper className="bg-gray-100">
|
||||
<div className="w-screen flex justify-center items-center">
|
||||
<div className="flex-col flex items-center h-96">
|
||||
<div className="flex-1 w-[95dvw] md:w-fit p-4 gap-4 flex flex-col overflow-x-auto">
|
||||
{dictation ? (
|
||||
@@ -95,6 +95,6 @@ export default function Start({ wordData, setPage }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NavbarCenterWrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ const getLocalWordData = (): WordData => {
|
||||
}
|
||||
}
|
||||
|
||||
export default function Memorize() {
|
||||
export default function MemorizePage() {
|
||||
const [page, setPage] = useState<"start" | "main" | "edit">("main");
|
||||
const [wordData, setWordData] = useState<WordData>(getLocalWordData());
|
||||
if (page === "main")
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Home() {
|
||||
export default function HomePage() {
|
||||
const t = useTranslations("home");
|
||||
function TopArea() {
|
||||
return (
|
||||
@@ -101,7 +100,6 @@ export default function Home() {
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Navbar></Navbar>
|
||||
<TopArea></TopArea>
|
||||
<Fortune></Fortune>
|
||||
<Explore></Explore>
|
||||
|
||||
55
src/app/profile/page.tsx
Normal file
55
src/app/profile/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import DarkButton from "@/components/buttons/DarkButton";
|
||||
import { useEffect } from "react";
|
||||
import ACard from "@/components/cards/ACard";
|
||||
import { Center } from "@/components/Center";
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
|
||||
export default function MePage() {
|
||||
const session = useSession();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
if (session.status !== "authenticated") {
|
||||
router.push(`/login?redirect=${encodeURIComponent(pathname)}`);
|
||||
}
|
||||
}, [session.status, router, pathname]);
|
||||
|
||||
return (
|
||||
<Center>
|
||||
<ACard>
|
||||
<h1>My Profile</h1>
|
||||
{(session.data?.user?.image as string) && (
|
||||
<Image
|
||||
width={64}
|
||||
height={64}
|
||||
alt="User Avatar"
|
||||
src={session.data?.user?.image as string}
|
||||
className="rounded-4xl"
|
||||
></Image>
|
||||
)}
|
||||
<p>{session.data?.user?.name}</p>
|
||||
<p>Email: {session.data?.user?.email}</p>
|
||||
<DarkButton onClick={signOut}>Logout</DarkButton>
|
||||
<LightButton
|
||||
onClick={() => {
|
||||
fetch("/api/folders", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ name: "New Folder" }),
|
||||
}).then(async (res) => console.log(await res.json()));
|
||||
}}
|
||||
>
|
||||
POST
|
||||
</LightButton>
|
||||
</ACard>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -3,16 +3,14 @@
|
||||
import { KeyboardEvent, useRef, useState } from "react";
|
||||
import UploadArea from "./UploadArea";
|
||||
import VideoPanel from "./VideoPlayer/VideoPanel";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
|
||||
export default function SrtPlayer() {
|
||||
export default function SrtPlayerPage() {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
||||
const [srtUrl, setSrtUrl] = useState<string | null>(null);
|
||||
return (
|
||||
<>
|
||||
<Navbar></Navbar>
|
||||
<div
|
||||
className="flex w-screen pt-8 items-center justify-center"
|
||||
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => e.preventDefault()}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { getTextSpeakerData, setTextSpeakerData } from "@/utils";
|
||||
import { getLocalStorageOperator } from "@/utils";
|
||||
import { useState } from "react";
|
||||
import z from "zod";
|
||||
import { TextSpeakerItemSchema } from "@/interfaces";
|
||||
import { TextSpeakerArraySchema, TextSpeakerItemSchema } from "@/interfaces";
|
||||
import IconClick from "@/components/IconClick";
|
||||
import IMAGES from "@/config/images";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -49,23 +49,27 @@ interface SaveListProps {
|
||||
}
|
||||
export default function SaveList({ show = false, handleUse }: SaveListProps) {
|
||||
const t = useTranslations("text-speaker");
|
||||
const [data, setData] = useState(getTextSpeakerData());
|
||||
const { get: getFromLocalStorage, set: setIntoLocalStorage } = getLocalStorageOperator<
|
||||
typeof TextSpeakerArraySchema
|
||||
>("text-speaker", TextSpeakerArraySchema);
|
||||
const [data, setData] = useState(getFromLocalStorage());
|
||||
const handleDel = (item: z.infer<typeof TextSpeakerItemSchema>) => {
|
||||
const current_data = getTextSpeakerData();
|
||||
const current_data = getFromLocalStorage();
|
||||
|
||||
current_data.splice(
|
||||
current_data.findIndex((v) => v.text === item.text),
|
||||
1,
|
||||
);
|
||||
setTextSpeakerData(current_data);
|
||||
setIntoLocalStorage(current_data);
|
||||
refresh();
|
||||
};
|
||||
const refresh = () => {
|
||||
setData(getTextSpeakerData());
|
||||
setData(getFromLocalStorage());
|
||||
};
|
||||
const handleDeleteAll = () => {
|
||||
const yesorno = prompt(t("confirmDeleteAll"))?.trim();
|
||||
if (yesorno && (yesorno === "Y" || yesorno === "y")) {
|
||||
setTextSpeakerData([]);
|
||||
setIntoLocalStorage([]);
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,20 +4,16 @@ import LightButton from "@/components/buttons/LightButton";
|
||||
import IconClick from "@/components/IconClick";
|
||||
import IMAGES from "@/config/images";
|
||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||
import {
|
||||
getTextSpeakerData,
|
||||
getTTSAudioUrl,
|
||||
setTextSpeakerData,
|
||||
} from "@/utils";
|
||||
import { TextSpeakerArraySchema, TextSpeakerItemSchema } from "@/interfaces";
|
||||
import { getLocalStorageOperator, getTTSAudioUrl } from "@/utils";
|
||||
import { ChangeEvent, useEffect, useRef, useState } from "react";
|
||||
import SaveList from "./SaveList";
|
||||
import { TextSpeakerItemSchema } from "@/interfaces";
|
||||
import z from "zod";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import SaveList from "./SaveList";
|
||||
|
||||
import { VOICES } from "@/config/locales";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function TextSpeaker() {
|
||||
export default function TextSpeakerPage() {
|
||||
const t = useTranslations("text-speaker");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [showSpeedAdjust, setShowSpeedAdjust] = useState(false);
|
||||
@@ -33,6 +29,11 @@ export default function TextSpeaker() {
|
||||
const objurlRef = useRef<string | null>(null);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const { play, stop, load, audioRef } = useAudioPlayer();
|
||||
|
||||
const { get: getFromLocalStorage, set: setIntoLocalStorage } = getLocalStorageOperator<
|
||||
typeof TextSpeakerArraySchema
|
||||
>("text-speaker", TextSpeakerArraySchema);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
@@ -196,14 +197,14 @@ export default function TextSpeaker() {
|
||||
theIPA = tmp.ipa;
|
||||
}
|
||||
|
||||
const save = getTextSpeakerData();
|
||||
const save = getFromLocalStorage();
|
||||
const oldIndex = save.findIndex((v) => v.text === textRef.current);
|
||||
if (oldIndex !== -1) {
|
||||
const oldItem = save[oldIndex];
|
||||
if (theIPA) {
|
||||
if (!oldItem.ipa || oldItem.ipa !== theIPA) {
|
||||
oldItem.ipa = theIPA;
|
||||
setTextSpeakerData(save);
|
||||
setIntoLocalStorage(save);
|
||||
}
|
||||
}
|
||||
} else if (theIPA.length === 0) {
|
||||
@@ -218,7 +219,7 @@ export default function TextSpeaker() {
|
||||
ipa: theIPA,
|
||||
});
|
||||
}
|
||||
setTextSpeakerData(save);
|
||||
setIntoLocalStorage(save);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setLocale(null);
|
||||
@@ -229,9 +230,8 @@ export default function TextSpeaker() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar></Navbar>
|
||||
<div
|
||||
className="my-4 p-4 mx-4 md:mx-32 border-1 border-gray-200 rounded-2xl"
|
||||
className="my-4 p-4 mx-4 md:mx-32 border border-gray-200 rounded-2xl"
|
||||
style={{ fontFamily: "Times New Roman, serif" }}
|
||||
>
|
||||
<textarea
|
||||
|
||||
@@ -1,293 +1,253 @@
|
||||
"use client";
|
||||
|
||||
import { ChangeEvent, useState } from "react";
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import IconClick from "@/components/IconClick";
|
||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||
import IMAGES from "@/config/images";
|
||||
import { getTTSAudioUrl } from "@/utils";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { VOICES } from "@/config/locales";
|
||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||
import { TranslationHistorySchema } from "@/interfaces";
|
||||
import { tlsoPush, tlso } from "@/lib/localStorageOperators";
|
||||
import { getTTSAudioUrl } from "@/lib/tts";
|
||||
import { letsFetch } from "@/utils";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRef, useState } from "react";
|
||||
import z from "zod";
|
||||
|
||||
export default function Translator() {
|
||||
export default function TranslatorPage() {
|
||||
const t = useTranslations("translator");
|
||||
const [ipaEnabled, setIPAEnabled] = useState(true);
|
||||
const [targetLang, setTargetLang] = useState("Chinese");
|
||||
|
||||
const [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>
|
||||
</>
|
||||
|
||||
@@ -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}
|
||||
|
||||
7
src/components/Center.tsx
Normal file
7
src/components/Center.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
34
src/components/auth-components.tsx
Normal file
34
src/components/auth-components.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
11
src/lib/SessionWrapper.tsx
Normal file
11
src/lib/SessionWrapper.tsx
Normal 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
11
src/lib/actions.ts
Normal 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
63
src/lib/ai.ts
Normal 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)),
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
116
src/lib/db.ts
116
src/lib/db.ts
@@ -24,14 +24,126 @@ export class UserController {
|
||||
}
|
||||
static async getUserByUsername(username: string) {
|
||||
try {
|
||||
const user = await pool.query("SELECT * FROM users WHERE username = $1", [username]);
|
||||
const user = await pool.query("SELECT * FROM users WHERE username = $1", [
|
||||
username,
|
||||
]);
|
||||
return user.rows[0];
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
static async deleteUserById(id: number) {
|
||||
try {
|
||||
await pool.query("DELETE FROM users WHERE id = $1", [id]);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class FolderController {
|
||||
|
||||
static async getFolderById(id: number) {
|
||||
try {
|
||||
const folder = await pool.query("SELECT * FROM folders WHERE id = $1", [
|
||||
id,
|
||||
]);
|
||||
return folder.rows[0];
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
static async deleteFolderById(id: number) {
|
||||
try {
|
||||
await pool.query("DELETE FROM folders WHERE id = $1", [id]);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
static async getFoldersByOwner(owner: string) {
|
||||
try {
|
||||
const folders = await pool.query(
|
||||
"SELECT * FROM folders WHERE owner = $1",
|
||||
[owner],
|
||||
);
|
||||
return folders.rows;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
static async createFolder(name: string, owner: string) {
|
||||
try {
|
||||
return (
|
||||
await pool.query("INSERT INTO folders (name, owner) VALUES ($1, $2)", [
|
||||
name,
|
||||
owner,
|
||||
])
|
||||
).rows[0];
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class WordPairController {
|
||||
static async createWordPair(
|
||||
locale1: string,
|
||||
locale2: string,
|
||||
text1: string,
|
||||
text2: string,
|
||||
folderId: number,
|
||||
) {
|
||||
try {
|
||||
await pool.query(
|
||||
"INSERT INTO word_pairs (locale1, locale2, text1, text2, folder_id) VALUES ($1, $2, $3, $4, $5)",
|
||||
[locale1, locale2, text1, text2, folderId],
|
||||
);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
static async getWordPairById(id: number) {
|
||||
try {
|
||||
const wordPair = await pool.query(
|
||||
"SELECT * FROM word_pairs WHERE id = $1",
|
||||
[id],
|
||||
);
|
||||
return wordPair.rows[0];
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
static async deleteWordPairById(id: number) {
|
||||
try {
|
||||
await pool.query("DELETE FROM word_pairs WHERE id = $1", [id]);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
static async updateWordPairById(
|
||||
id: number,
|
||||
locale1: string,
|
||||
locale2: string,
|
||||
text1: string,
|
||||
text2: string,
|
||||
) {
|
||||
try {
|
||||
await pool.query(
|
||||
"UPDATE word_pairs SET locale1 = $1, locale2 = $2, text1 = $3, text2 = $4 WHERE id = $5",
|
||||
[locale1, locale2, text1, text2, id],
|
||||
);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
static async getWordPairsByFolderId(folderId: number) {
|
||||
try {
|
||||
const wordPairs = await pool.query(
|
||||
"SELECT * FROM word_pairs WHERE folder_id = $1",
|
||||
[folderId],
|
||||
);
|
||||
return wordPairs.rows;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
17
src/lib/localStorageOperators.ts
Normal file
17
src/lib/localStorageOperators.ts
Normal 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
16
src/lib/tts.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
80
src/utils.ts
80
src/utils.ts
@@ -1,6 +1,5 @@
|
||||
import { EdgeTTS, ProsodyOptions } from "edge-tts-universal/browser";
|
||||
import { env } from "process";
|
||||
import { TextSpeakerArraySchema } from "./interfaces";
|
||||
import z from "zod";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
@@ -60,33 +59,43 @@ export async function getTTSAudioUrl(
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
export const getTextSpeakerData = () => {
|
||||
try {
|
||||
if (!localStorage) return [];
|
||||
const item = localStorage.getItem("text-speaker");
|
||||
|
||||
if (!item) return [];
|
||||
|
||||
const rawData = JSON.parse(item);
|
||||
const result = TextSpeakerArraySchema.safeParse(rawData);
|
||||
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
} else {
|
||||
console.error("Invalid data structure in localStorage:", result.error);
|
||||
return [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse text-speaker data:", e);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
export const setTextSpeakerData = (
|
||||
data: z.infer<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);
|
||||
};
|
||||
Reference in New Issue
Block a user