Compare commits

..

55 Commits

Author SHA1 Message Date
d8f0117359 update react
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-12 17:11:21 +08:00
2c84ab4370 update README.md
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-12 16:47:57 +08:00
e17437a5ad 修复登录问题
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-12 16:45:50 +08:00
ff0954a413 ...
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-12 16:31:48 +08:00
573b1cb7e5 ...
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-12 13:41:00 +08:00
605c57f8bb 重构逐句视频播放器
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-12 13:37:00 +08:00
b69e168558 ...
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-11 21:36:45 +08:00
65aacc1582 update README.md
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-11 20:21:11 +08:00
572534a009 clean code
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-11 17:14:37 +08:00
0d251a7e68 优化navbar链接样式
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-11 17:13:35 +08:00
e845c4abb7 ...
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-10 18:56:53 +08:00
881d9ca921 将next-auth替换为better-auth 2025-12-10 17:54:14 +08:00
db96b86e65 ... 2025-12-05 14:03:08 +08:00
467232457a upgrade nextjs to version 16
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-05 10:27:11 +08:00
af1b445072 fix ci
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-04 22:18:32 +08:00
560966f438 fix ci
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-04 22:03:07 +08:00
7695b2074d fix ci
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-04 21:51:59 +08:00
c6840fb8d6 fix ci
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-04 21:46:52 +08:00
a1a730b547 fix ci
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-04 21:28:30 +08:00
4b6a4735ee fix ci
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-04 21:21:25 +08:00
4a4ae6fb6a fix ci
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-04 21:15:08 +08:00
5ac9450897 更换开源许可证为AGPLv3 2025-12-04 21:13:57 +08:00
41005a4aac 今天做了好多工作啊
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-04 21:07:54 +08:00
fcc20fc2e0 ...
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-02 20:37:50 +08:00
bd5fc06cc5 ...
Some checks are pending
continuous-integration/drone/push Build is running
continuous-integration/drone Build is passing
2025-12-02 17:54:23 +08:00
71955a712a ...
Some checks reported errors
continuous-integration/drone/push Build was killed
2025-12-02 17:47:41 +08:00
a88dd2b91a 优化了一些细节
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-12-02 17:39:55 +08:00
4cbde97f41 背单词可以设置索引 2025-11-24 16:01:53 +08:00
7bf3fd9b17 ... 2025-11-22 09:24:08 +08:00
e8f5ce9751 ...
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-11-22 09:20:41 +08:00
baf7265bf8 添加记忆上一个
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-11-22 09:16:51 +08:00
bc0dab64c6 ...
Some checks reported errors
continuous-integration/drone Build encountered an error
continuous-integration/drone/push Build was killed
2025-11-21 10:22:25 +08:00
cdfd676c0d 添加记忆界面的阿拉伯衬线字体 2025-11-21 10:22:25 +08:00
a2e579cb7b 可以乱序记忆
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-20 10:40:57 +08:00
4eb44422d2 ...
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-20 10:23:22 +08:00
0bf3b718b2 ...
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-17 15:59:35 +08:00
22a0cf46fb 增加翻译器自动保存到文件夹功能
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-17 09:00:24 +08:00
98c771cab4 ... 2025-11-16 22:14:11 +08:00
5d2ec4ac5c clean code 2025-11-16 20:50:31 +08:00
2bbb5008d2 ...
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-16 15:52:05 +08:00
4ed0f43164 调校了ai提示词
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-16 15:42:08 +08:00
1473a72a2f ...
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-16 15:10:49 +08:00
b1a3add1d9 fix translations
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-16 14:57:05 +08:00
f339e5e2f0 ...
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-16 13:44:22 +08:00
52ac68fed4 ...
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-16 13:23:17 +08:00
7c5fc40209 补全翻译
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-16 12:44:52 +08:00
30fc4ed64d ...
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-16 12:23:47 +08:00
d20c40cfb4 ...
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-16 12:11:44 +08:00
0e3d41829c ...
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-16 12:04:09 +08:00
72c6791d93 完成了对记忆功能的升级
Some checks reported errors
continuous-integration/drone/push Build was killed
2025-11-15 22:16:12 +08:00
cf3cb916b7 ... 2025-11-14 15:30:44 +08:00
adcb7920bd 打算使用prisma 2025-11-13 10:30:28 +08:00
94d570557b 用服务器组件写了点/folders
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-11 22:01:32 +08:00
d4f786c990 重构了translator,写了点数据库、后端api路由
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-10 21:42:44 +08:00
b30f9fb0c3 ...
Some checks reported errors
continuous-integration/drone/push Build was killed
2025-11-07 10:43:41 +08:00
145 changed files with 12612 additions and 17490 deletions

View File

@@ -4,4 +4,5 @@ node_modules
npm-debug.log
README.md
.next
.git
.git
certificates

View File

@@ -19,6 +19,15 @@ steps:
registry: registry.edian-studio.com
tags:
- latest
- name: database migrate
image: node:24-alpine
environment:
DATABASE_URL:
from_secret: database_url
commands:
- npm i --no-save prisma@7 @prisma/client@7 "@prisma/adapter-pg"
- npx prisma migrate deploy
- name: deploy
image: appleboy/drone-ssh
@@ -32,7 +41,7 @@ steps:
port: 22
script:
- cd ~/docker/learn-languages
- docker compose up -d
- docker compose up -d --pull always --force-recreate
debug: true
trigger:

12
.env.example Normal file
View File

@@ -0,0 +1,12 @@
// LLM
ZHIPU_API_KEY=
ZHIPU_MODEL_NAME=
// Auth
BETTER_AUTH_SECRET=
BETTER_AUTH_URL=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
// Database
DATABASE_URL=

8
.gitignore vendored
View File

@@ -41,5 +41,11 @@ yarn-error.log*
next-env.d.ts
.env
!.env.example
build.sh
build.sh
test.ts
/generated/prisma
certificates

10
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,10 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"editor.defaultFormatter": null,
"[typescript]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
},
"[typescriptreact]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
}
}

View File

@@ -1,23 +1,27 @@
# syntax=docker.io/docker/dockerfile:1
FROM node:23-alpine AS base
FROM node:24-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
RUN apk add --no-cache libc6-compat openssl
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
# RUN \
# if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
# elif [ -f package-lock.json ]; then npm ci; \
# elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
# else echo "Lockfile not found." && exit 1; \
# fi
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
@@ -29,10 +33,17 @@ COPY . .
# Uncomment the following line in case you want to disable telemetry during the build.
ENV NEXT_TELEMETRY_DISABLED=1
# RUN \
# if [ -f yarn.lock ]; then yarn run build; \
# elif [ -f package-lock.json ]; then npm run build; \
# elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
# else echo "Lockfile not found." && exit 1; \
# fi
RUN DATABASE_URL=postgresql://fake:fake@fake:5432/fake npx prisma@7 generate
RUN \
if [ -f yarn.lock ]; then yarn run build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi

141
LICENSE
View File

@@ -1,5 +1,5 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
@@ -7,17 +7,15 @@
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
@@ -72,7 +60,7 @@ modification follow.
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
GNU Affero General Public License for more details.
You should have received a copy of the GNU General Public License
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

170
README.md
View File

@@ -1,36 +1,162 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# 多语言学习平台
## Getting Started
一个基于 Next.js 构建的全功能多语言学习平台,提供翻译、发音、字幕播放、字母学习等多种语言学习工具,帮助用户更高效地掌握新语言。
First, run the development server:
## ✨ 主要功能
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
- **智能翻译工具** - 支持多语言互译,包含国际音标(IPA)标注
- **文本语音合成** - 将文本转换为自然语音,提高发音学习效果
- **SRT字幕播放器** - 结合视频字幕学习,支持多种字幕格式
- **字母学习模块** - 针对初学者的字母和发音基础学习
- **记忆强化工具** - 通过科学记忆法巩固学习内容
- **个人学习空间** - 用户可以创建、管理和组织自己的学习资料
## 🛠 技术栈
### 前端框架
- **Next.js 16** - React 全栈框架,使用 App Router
- **React 19** - 用户界面构建
- **TypeScript** - 类型安全的 JavaScript
- **Tailwind CSS** - 实用优先的 CSS 框架
### 数据与后端
- **PostgreSQL** - 主数据库
- **Prisma** - 现代数据库工具包和 ORM
- **better-auth** - 安全的身份验证系统
### 国际化与辅助功能
- **next-intl** - 国际化解决方案
- **edge-tts-universal** - 跨平台文本转语音
### 开发工具
- **ESLint** - 代码质量检查
- **pnpm** - 高效的包管理器
## 📁 项目结构
```
src/
├── app/ # Next.js App Router 路由
│ ├── (features)/ # 功能模块路由
│ ├── api/ # API 路由
│ └── auth/ # 认证相关页面
├── components/ # React 组件
│ ├── buttons/ # 按钮组件
│ ├── cards/ # 卡片组件
│ └── ...
├── lib/ # 工具函数和库
│ ├── actions/ # Server Actions
│ ├── browser/ # 浏览器端工具
│ └── server/ # 服务器端工具
├── hooks/ # 自定义 React Hooks
├── i18n/ # 国际化配置
└── config/ # 应用配置
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
## 🚀 快速开始
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
### 环境要求
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
- Node.js 24
- PostgreSQL 数据库
- pnpm (推荐) 或 npm
## Learn More
### 本地开发
To learn more about Next.js, take a look at the following resources:
1. 克隆项目
```bash
git clone <repository-url>
cd learn-languages
```
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
2. 安装依赖
```bash
pnpm install
```
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
3. 设置环境变量
## Deploy on Vercel
从项目提供的示例文件复制环境变量模板:
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
```bash
cp .env.example .env.local
```
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
然后编辑 `.env.local` 文件,配置所有必要的环境变量:
```env
// LLM
ZHIPU_API_KEY=your-zhipu-api-key
ZHIPU_MODEL_NAME=your-zhipu-model-name
// Auth
BETTER_AUTH_SECRET=your-better-auth-secret
BETTER_AUTH_URL=http://localhost:3000
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret
// Database
DATABASE_URL=postgresql://username:password@localhost:5432/database_name
```
注意:所有带 `your-` 前缀的值需要替换为你的实际配置。
4. 初始化数据库
```bash
pnpm prisma generate
pnpm prisma db push
```
5. 启动开发服务器
```bash
pnpm run dev
```
访问 [http://localhost:3000](http://localhost:3000) 查看应用。
## 📚 API 文档
### 认证系统
应用使用 better-auth 提供安全的用户认证系统,支持邮箱/密码登录和第三方登录。
### 数据模型
核心数据模型包括:
- **User** - 用户信息
- **Folder** - 学习资料文件夹
- **Pair** - 语言对(翻译对、词汇对等)
详细模型定义请参考 [prisma/schema.prisma](./prisma/schema.prisma)
## 🌍 国际化
应用支持多语言,当前语言文件位于 `messages/` 目录。添加新语言:
1.`messages/` 目录创建对应语言的 JSON 文件
2.`src/i18n/config.ts` 中添加语言配置
## 🤝 贡献指南
我们欢迎各种形式的贡献!请遵循以下步骤:
1. Fork 项目
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 打开 Pull Request
## 📄 许可证
本项目采用 AGPL-3.0 许可证 - 查看 [LICENSE](./LICENSE) 文件了解详情。
## 📞 支持
如果您遇到问题或有建议,请通过以下方式联系:
- 提交 [Issue](../../issues)
- 发送邮件至 [goddonebianu@outlook.com]
---
**Happy Learning!** 🌟

View File

@@ -1,25 +1,16 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
ignores: [
"node_modules/**",
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
],
},
];
export default eslintConfig;
import { defineConfig, globalIgnores } from 'eslint/config'
import nextVitals from 'eslint-config-next/core-web-vitals'
const eslintConfig = defineConfig([
...nextVitals,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
'.next/**',
'out/**',
'build/**',
'next-env.d.ts',
]),
])
export default eslintConfig

220
messages/en-US.json Normal file
View File

@@ -0,0 +1,220 @@
{
"alphabet": {
"chooseCharacters": "Please select the characters you want to learn",
"japanese": "Japanese Kana",
"english": "English Alphabet",
"uyghur": "Uyghur Alphabet",
"esperanto": "Esperanto Alphabet",
"loading": "Loading...",
"loadFailed": "Loading failed, please try again",
"hideLetter": "Hide Letter",
"showLetter": "Show Letter",
"hideIPA": "Hide IPA",
"showIPA": "Show IPA",
"roman": "Romanization",
"letter": "Letter",
"random": "Random Mode",
"randomNext": "Random Next"
},
"folders": {
"title": "Folders",
"subtitle": "Manage your collections",
"newFolder": "New Folder",
"creating": "Creating...",
"noFoldersYet": "No folders yet",
"folderInfo": "{id}. {name} ({totalPairs})",
"enterFolderName": "Enter folder name:",
"confirmDelete": "Type \"{name}\" to delete:",
"createFolderSuccess": "Folder created successfully",
"deleteFolderSuccess": "Folder deleted successfully",
"createFolderError": "Failed to create folder",
"deleteFolderError": "Failed to delete folder"
},
"folder_id": {
"unauthorized": "You are not the owner of this folder",
"back": "Back",
"textPairs": "Text Pairs",
"itemsCount": "{count} items",
"memorize": "Memorize",
"loadingTextPairs": "Loading text pairs...",
"noTextPairs": "No text pairs in this folder",
"addNewTextPair": "Add New Text Pair",
"add": "Add",
"updateTextPair": "Update Text Pair",
"update": "Update",
"text1": "Text 1",
"text2": "Text 2",
"locale1": "Locale 1",
"locale2": "Locale 2",
"edit": "Edit",
"delete": "Delete"
},
"home": {
"title": "Learn Languages",
"description": "Here is a very useful website to help you learn almost every language in the world, including constructed ones.",
"explore": "Explore",
"fortune": {
"quote": "Stay hungry, stay foolish.",
"author": "— Steve Jobs"
},
"translator": {
"name": "Translator",
"description": "Translate to any language and annotate with International Phonetic Alphabet (IPA)"
},
"textSpeaker": {
"name": "Text Speaker",
"description": "Recognize and read text aloud, supports loop playback and speed adjustment"
},
"srtPlayer": {
"name": "SRT Video Player",
"description": "Play videos sentence by sentence based on SRT subtitle files to mimic native speaker pronunciation"
},
"alphabet": {
"name": "Alphabet",
"description": "Start learning a new language from the alphabet"
},
"memorize": {
"name": "Memorize",
"description": "Language A to Language B, Language B to Language A, supports dictation"
},
"moreFeatures": {
"name": "More Features",
"description": "Under development, stay tuned"
}
},
"login": {
"loading": "Loading...",
"githubLogin": "GitHub Login"
},
"auth": {
"title": "Authentication",
"signIn": "Sign In",
"signUp": "Sign Up",
"email": "Email",
"password": "Password",
"confirmPassword": "Confirm Password",
"name": "Name",
"signInButton": "Sign In",
"signUpButton": "Sign Up",
"noAccount": "Don't have an account?",
"hasAccount": "Already have an account?",
"signInWithGitHub": "Sign In with GitHub",
"signUpWithGitHub": "Sign Up with GitHub",
"invalidEmail": "Please enter a valid email address",
"passwordTooShort": "Password must be at least 8 characters",
"passwordsNotMatch": "Passwords do not match",
"signInFailed": "Sign in failed, please check your email and password",
"signUpFailed": "Sign up failed, please try again later",
"nameRequired": "Please enter your name",
"emailRequired": "Please enter your email",
"passwordRequired": "Please enter your password",
"confirmPasswordRequired": "Please confirm your password",
"loading": "Loading..."
},
"memorize": {
"folder_selector": {
"selectFolder": "Select a folder",
"noFolders": "No folders found",
"folderInfo": "{id}. {name} ({count})"
},
"memorize": {
"answer": "Answer",
"next": "Next",
"reverse": "Reverse",
"dictation": "Dictation",
"noTextPairs": "No text pairs available",
"disorder": "Disorder",
"previous": "Previous"
},
"page": {
"unauthorized": "You are not authorized to access this folder"
}
},
"navbar": {
"title": "learn-languages",
"sourceCode": "GitHub",
"sign_in": "Sign In",
"profile": "Profile",
"folders": "Folders"
},
"profile": {
"myProfile": "My Profile",
"email": "Email: {email}",
"logout": "Logout"
},
"srt_player": {
"uploadVideo": "Upload Video",
"uploadSubtitle": "Upload Subtitle",
"pause": "Pause",
"play": "Play",
"previous": "Previous",
"next": "Next",
"restart": "Restart",
"autoPause": "Auto Pause ({enabled})",
"playbackSpeed": "Playback Speed",
"subtitleSettings": "Subtitle Settings",
"fontSize": "Font Size",
"backgroundColor": "Background Color",
"textColor": "Text Color",
"fontFamily": "Font Family",
"opacity": "Opacity",
"position": "Position",
"top": "Top",
"center": "Center",
"bottom": "Bottom",
"keyboardShortcuts": "Keyboard Shortcuts",
"uploadVideoAndSubtitle": "Please upload video and subtitle files",
"uploadVideoFile": "Please upload video file",
"uploadSubtitleFile": "Please upload subtitle file",
"processingSubtitle": "Processing subtitle file...",
"needBothFiles": "Both video and subtitle files are required to start learning",
"videoFile": "Video File",
"subtitleFile": "Subtitle File",
"uploaded": "Uploaded",
"notUploaded": "Not Uploaded",
"upload": "Upload",
"autoPauseStatus": "Auto Pause: {enabled}",
"on": "On",
"off": "Off",
"videoUploadFailed": "Video upload failed",
"subtitleUploadFailed": "Subtitle upload failed",
"subtitleLoadSuccess": "Subtitle file loaded successfully",
"subtitleLoadFailed": "Subtitle file loading failed",
"shortcuts": {
"playPause": "Play/Pause",
"next": "Next",
"previous": "Previous",
"restart": "Restart",
"autoPause": "Toggle Auto Pause"
}
},
"text_speaker": {
"generateIPA": "Generate IPA",
"viewSavedItems": "View Saved Items",
"confirmDeleteAll": "Are you sure you want to delete everything? (Y/N)"
},
"translator": {
"detectLanguage": "detect language",
"generateIPA": "generate ipa",
"translateInto": "translate into",
"chinese": "Chinese",
"english": "English",
"italian": "Italian",
"other": "Other",
"translating": "translating...",
"translate": "translate",
"inputLanguage": "Input a language.",
"history": "History",
"enterLanguage": "Enter language",
"add_to_folder": {
"notAuthenticated": "You are not authenticated",
"chooseFolder": "Choose a Folder to Add to",
"noFolders": "No folders found",
"folderInfo": "{id}. {name}",
"close": "Close",
"success": "Text pair added to folder",
"error": "Failed to add text pair to folder"
},
"autoSave": "Auto Save"
}
}

223
messages/zh-CN.json Normal file
View File

@@ -0,0 +1,223 @@
{
"alphabet": {
"chooseCharacters": "请选择您想学习的字符",
"japanese": "日语假名",
"english": "英文字母",
"uyghur": "维吾尔字母",
"esperanto": "世界语字母",
"loading": "加载中...",
"loadFailed": "加载失败,请重试",
"hideLetter": "隐藏字母",
"showLetter": "显示字母",
"hideIPA": "隐藏IPA",
"showIPA": "显示IPA",
"roman": "罗马音",
"letter": "字母",
"random": "随机模式",
"randomNext": "随机下一个"
},
"folders": {
"title": "文件夹",
"subtitle": "管理您的集合",
"newFolder": "新建文件夹",
"creating": "创建中...",
"noFoldersYet": "还没有文件夹",
"folderInfo": "{id}. {name} ({totalPairs})",
"enterFolderName": "输入文件夹名称:",
"confirmDelete": "输入 \"{name}\" 以删除:",
"createFolderSuccess": "文件夹创建成功",
"deleteFolderSuccess": "文件夹删除成功",
"createFolderError": "创建文件夹失败",
"deleteFolderError": "删除文件夹失败"
},
"folder_id": {
"unauthorized": "您不是此文件夹的所有者",
"back": "返回",
"textPairs": "文本对",
"itemsCount": "{count} 个项目",
"memorize": "记忆",
"loadingTextPairs": "加载文本对中...",
"noTextPairs": "此文件夹中没有文本对",
"addNewTextPair": "添加新文本对",
"add": "添加",
"updateTextPair": "更新文本对",
"update": "更新",
"text1": "文本1",
"text2": "文本2",
"locale1": "语言1",
"locale2": "语言2",
"edit": "编辑",
"delete": "删除"
},
"home": {
"title": "学语言",
"description": "这里是一个非常有用的网站,可以帮助您学习世界上几乎每一种语言,包括人造语言。",
"explore": "探索网站",
"fortune": {
"quote": "求知若饥,虚心若愚。",
"author": "—— 史蒂夫·乔布斯"
},
"translator": {
"name": "翻译器",
"description": "翻译到任何语言并标注国际音标IPA"
},
"textSpeaker": {
"name": "朗读器",
"description": "识别并朗读文本,支持循环朗读、朗读速度调节"
},
"srtPlayer": {
"name": "逐句放视频",
"description": "基于SRT字幕文件逐句播放视频以模仿母语者的发音"
},
"alphabet": {
"name": "字母表",
"description": "从字母表开始新语言的学习"
},
"memorize": {
"name": "记忆",
"description": "语言A到语言B语言B到语言A支持听写"
},
"moreFeatures": {
"name": "更多功能",
"description": "开发中,敬请期待"
}
},
"login": {
"loading": "加载中...",
"githubLogin": "GitHub登录"
},
"auth": {
"title": "登录",
"signIn": "登录",
"signUp": "注册",
"email": "邮箱",
"password": "密码",
"confirmPassword": "确认密码",
"name": "用户名",
"signInButton": "登录",
"signUpButton": "注册",
"noAccount": "还没有账户?",
"hasAccount": "已有账户?",
"signInWithGitHub": "使用GitHub登录",
"signUpWithGitHub": "使用GitHub注册",
"invalidEmail": "请输入有效的邮箱地址",
"passwordTooShort": "密码至少需要8个字符",
"passwordsNotMatch": "两次输入的密码不匹配",
"signInFailed": "登录失败,请检查您的邮箱和密码",
"signUpFailed": "注册失败,请稍后再试",
"nameRequired": "请输入用户名",
"emailRequired": "请输入邮箱",
"passwordRequired": "请输入密码",
"confirmPasswordRequired": "请确认密码"
},
"memorize": {
"choose": {
"back": "返回",
"choose": "选择"
},
"folder_selector": {
"selectFolder": "选择文件夹",
"noFolders": "未找到文件夹",
"folderInfo": "{id}. {name} ({count})"
},
"memorize": {
"answer": "答案",
"next": "下一个",
"reverse": "反向",
"dictation": "听写",
"noTextPairs": "没有可用的文本对",
"disorder": "乱序",
"previous": "上一个"
},
"page": {
"unauthorized": "您无权访问该文件夹"
}
},
"navbar": {
"title": "学语言",
"sourceCode": "源码",
"sign_in": "登录",
"profile": "个人资料",
"folders": "文件夹"
},
"profile": {
"myProfile": "我的个人资料",
"email": "邮箱:{email}",
"logout": "退出登录"
},
"srt_player": {
"upload": "上传",
"uploadVideo": "上传视频",
"uploadSubtitle": "上传字幕",
"pause": "暂停",
"play": "播放",
"previous": "上句",
"next": "下句",
"restart": "句首",
"autoPause": "自动暂停({enabled})",
"playbackSpeed": "播放速度",
"subtitleSettings": "字幕设置",
"fontSize": "字体大小",
"backgroundColor": "背景颜色",
"textColor": "文字颜色",
"fontFamily": "字体",
"opacity": "透明度",
"position": "位置",
"top": "顶部",
"center": "居中",
"bottom": "底部",
"keyboardShortcuts": "键盘快捷键",
"uploadVideoAndSubtitle": "请上传视频和字幕文件",
"uploadVideoFile": "请上传视频文件",
"uploadSubtitleFile": "请上传字幕文件",
"processingSubtitle": "字幕文件正在处理中...",
"needBothFiles": "需要同时上传视频和字幕文件才能开始学习",
"videoFile": "视频文件",
"subtitleFile": "字幕文件",
"uploaded": "已上传",
"notUploaded": "未上传",
"autoPauseStatus": "自动暂停: {enabled}",
"on": "开",
"off": "关",
"videoUploadFailed": "视频上传失败",
"subtitleUploadFailed": "字幕上传失败",
"subtitleLoadSuccess": "字幕文件加载成功",
"subtitleLoadFailed": "字幕文件加载失败",
"shortcuts": {
"playPause": "播放/暂停",
"next": "下一句",
"previous": "上一句",
"restart": "句首",
"autoPause": "切换自动暂停"
}
},
"text_speaker": {
"generateIPA": "生成IPA",
"viewSavedItems": "查看保存项",
"confirmDeleteAll": "确定删光吗?(Y/N)"
},
"translator": {
"detectLanguage": "检测语言",
"generateIPA": "生成国际音标",
"translateInto": "翻译为",
"chinese": "中文",
"english": "英文",
"italian": "意大利语",
"other": "其他",
"translating": "翻译中...",
"translate": "翻译",
"inputLanguage": "请输入语言。",
"history": "历史记录",
"enterLanguage": "输入语言",
"add_to_folder": {
"notAuthenticated": "您未通过身份验证",
"chooseFolder": "选择要添加到的文件夹",
"noFolders": "未找到文件夹",
"folderInfo": "{id}. {name}",
"close": "关闭",
"success": "文本对已添加到文件夹",
"error": "添加文本对到文件夹失败"
},
"autoSave": "自动保存"
}
}

View File

@@ -3,7 +3,18 @@ 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/**",
},
],
},
reactCompiler: true
// allowedDevOrigins: ["192.168.3.65", "192.168.3.66"],
};
const withNextIntl = createNextIntlPlugin();

15322
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,29 +3,54 @@
"version": "0.1.0",
"private": true,
"license": "GPL-3.0-only",
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"dev": "next dev --experimental-https",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"edge-tts-universal": "^1.3.2",
"next": "15.5.3",
"next-intl": "^4.4.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"zod": "^3.25.76"
"@prisma/adapter-pg": "^7.1.0",
"@prisma/client": "^7.1.0",
"bcryptjs": "^3.0.3",
"better-auth": "^1.4.6",
"dotenv": "^17.2.3",
"edge-tts-universal": "^1.3.3",
"lucide-react": "^0.561.0",
"next": "16.0.10",
"next-intl": "^4.5.8",
"pg": "^8.16.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"sonner": "^2.0.7",
"unstorage": "^1.17.3",
"zod": "^4.1.13"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.5.3",
"tailwindcss": "^4",
"typescript": "^5"
"@better-auth/cli": "^1.4.6",
"@eslint/eslintrc": "^3.3.3",
"@tailwindcss/postcss": "^4.1.18",
"@types/node": "^25.0.1",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@typescript-eslint/eslint-plugin": "^8.49.0",
"@typescript-eslint/parser": "^8.49.0",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.39.1",
"eslint-config-next": "16.0.10",
"eslint-plugin-react": "^7.37.5",
"prisma": "^7.1.0",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3"
},
"pnpm": {
"overrides": {
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3"
},
"ignoredBuiltDependencies": [
"@prisma/client"
]
}
}

6607
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

11
prisma.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: env("DATABASE_URL"),
},
});

View File

@@ -0,0 +1,120 @@
-- CreateTable
CREATE TABLE "pairs" (
"id" SERIAL NOT NULL,
"locale1" VARCHAR(10) NOT NULL,
"locale2" VARCHAR(10) NOT NULL,
"text1" TEXT NOT NULL,
"text2" TEXT NOT NULL,
"ipa1" TEXT,
"ipa2" TEXT,
"folder_id" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "pairs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "folders" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "folders_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
"image" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "session" (
"id" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"token" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"ipAddress" TEXT,
"userAgent" TEXT,
"userId" TEXT NOT NULL,
CONSTRAINT "session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "account" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"accessToken" TEXT,
"refreshToken" TEXT,
"idToken" TEXT,
"accessTokenExpiresAt" TIMESTAMP(3),
"refreshTokenExpiresAt" TIMESTAMP(3),
"scope" TEXT,
"password" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "verification" (
"id" TEXT NOT NULL,
"identifier" TEXT NOT NULL,
"value" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "verification_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "pairs_folder_id_idx" ON "pairs"("folder_id");
-- CreateIndex
CREATE UNIQUE INDEX "pairs_folder_id_locale1_locale2_text1_key" ON "pairs"("folder_id", "locale1", "locale2", "text1");
-- CreateIndex
CREATE INDEX "folders_user_id_idx" ON "folders"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");
-- CreateIndex
CREATE INDEX "session_userId_idx" ON "session"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "session_token_key" ON "session"("token");
-- CreateIndex
CREATE INDEX "account_userId_idx" ON "account"("userId");
-- CreateIndex
CREATE INDEX "verification_identifier_idx" ON "verification"("identifier");
-- AddForeignKey
ALTER TABLE "pairs" ADD CONSTRAINT "pairs_folder_id_fkey" FOREIGN KEY ("folder_id") REFERENCES "folders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "folders" ADD CONSTRAINT "folders_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

106
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,106 @@
generator client {
provider = "prisma-client"
output = "../generated/prisma"
}
datasource db {
provider = "postgresql"
}
model Pair {
id Int @id @default(autoincrement())
locale1 String @db.VarChar(10)
locale2 String @db.VarChar(10)
text1 String
text2 String
ipa1 String?
ipa2 String?
folderId Int @map("folder_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
@@unique([folderId, locale1, locale2, text1])
@@index([folderId])
@@map("pairs")
}
model Folder {
id Int @id @default(autoincrement())
name String
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
pairs Pair[]
@@index([userId])
@@map("folders")
}
model User {
id String @id
name String
email String
emailVerified Boolean @default(false)
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
accounts Account[]
folders Folder[]
@@unique([email])
@@map("user")
}
model Session {
id String @id
expiresAt DateTime
token String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ipAddress String?
userAgent String?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([token])
@@index([userId])
@@map("session")
}
model Account {
id String @id
accountId String
providerId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@map("account")
}
model Verification {
id String @id
identifier String
value String
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([identifier])
@@map("verification")
}

View File

@@ -1,11 +0,0 @@
2025.10.31 添加国际化支持
2025.10.30 添加背单词功能
2025.10.12 添加朗读器本地保存功能
2025.10.09 新增记忆字母表功能
2025.10.08 加快了TTS的生成速度将IPA生成设置为可选项
2025.10.07 新增文本朗读器优化了视频播放器UI
2025.10.06 更新了主页面UI移除IPA生成与文本朗读功能新增全语言翻译器
2025.10.05 新增IPA生成与文本朗读功能
2025.09.25 优化了主界面UI
2025.09.19 更新了单词板,单词不再会重叠

Binary file not shown.

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

36
public/images/logo.svg Normal file
View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="180.425mm"
height="66.658363mm"
viewBox="0 0 180.425 66.658363"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<g
id="layer1"
transform="translate(-19.117989,-118.50376)">
<rect
style="fill:#00ccff;stroke-width:4.38923"
id="rect1"
width="180.42502"
height="66.658356"
x="19.117989"
y="118.50375" />
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:52.6706px;font-family:'Noto Serif';-inkscape-font-specification:'Noto Serif';text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#f2f2f2;stroke-width:4.38923"
x="29.942305"
y="167.45377"
id="text1"
transform="scale(0.98306332,1.0172285)"><tspan
id="tspan1"
style="fill:#f2f2f2;stroke-width:4.38923"
x="29.942305"
y="167.45377">Learn!</tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,13 +0,0 @@
{
"chooseCharacters": "Please select the characters you want to learn",
"japanese": "Japanese Kana",
"english": "English Alphabet",
"uyghur": "Uyghur Alphabet",
"esperanto": "Esperanto Alphabet",
"loading": "Loading...",
"loadFailed": "Loading failed, please try again",
"hideLetter": "Hide Letter",
"showLetter": "Show Letter",
"hideIPA": "Hide IPA",
"showIPA": "Show IPA"
}

View File

@@ -1,33 +0,0 @@
{
"title": "Learn Languages",
"description": "Here is a very useful website to help you learn almost every language in the world, including constructed ones.",
"explore": "Explore",
"fortune": {
"quote": "Stay hungry, stay foolish.",
"author": "— Steve Jobs"
},
"translator": {
"name": "Translator",
"description": "Translate to any language and annotate with International Phonetic Alphabet (IPA)"
},
"textSpeaker": {
"name": "Text Speaker",
"description": "Recognize and read text aloud, supports loop playback and speed adjustment"
},
"srtPlayer": {
"name": "SRT Video Player",
"description": "Play videos sentence by sentence based on SRT subtitle files to mimic native speaker pronunciation"
},
"alphabet": {
"name": "Memorize Alphabet",
"description": "Start learning a new language from the alphabet"
},
"memorize": {
"name": "Memorize Words",
"description": "Language A to Language B, Language B to Language A, supports dictation"
},
"moreFeatures": {
"name": "More Features",
"description": "Under development, stay tuned"
}
}

View File

@@ -1,4 +0,0 @@
{
"back": "Back",
"choose": "Choose"
}

View File

@@ -1,6 +0,0 @@
{
"back": "Back",
"save": "Save Word Pairs",
"locale1": "Locale 1",
"locale2": "Locale 2"
}

View File

@@ -1,10 +0,0 @@
{
"title": "Memorize",
"locale1": "Your selected locale 1 is {locale}",
"locale2": "Your selected locale 2 is {locale}",
"total": "There are {total} word pairs in total",
"start": "Start",
"import": "Import",
"export": "Export",
"edit": "Edit"
}

View File

@@ -1,7 +0,0 @@
{
"show": "Show",
"reverse": "Reverse",
"dictation": "Dictation",
"back": "Back",
"next": "Next"
}

View File

@@ -1,5 +0,0 @@
{
"title": "LL",
"about": "About",
"sourceCode": "GitHub"
}

View File

@@ -1,10 +0,0 @@
{
"uploadVideo": "Upload Video",
"uploadSubtitle": "Upload Subtitle",
"pause": "Pause",
"play": "Play",
"previous": "Previous",
"next": "Next",
"restart": "Restart",
"autoPause": "Auto Pause ({enabled})"
}

View File

@@ -1,5 +0,0 @@
{
"generateIPA": "Generate IPA",
"viewSavedItems": "View Saved Items",
"confirmDeleteAll": "Are you sure you want to delete everything? (Y/N)"
}

View File

@@ -1,12 +0,0 @@
{
"detectLanguage": "detect language",
"generateIPA": "generate ipa",
"translateInto": "translate into",
"chinese": "Chinese",
"english": "English",
"italian": "Italian",
"other": "Other",
"translating": "translating...",
"translate": "translate",
"inputLanguage": "Input a language."
}

View File

@@ -1,13 +0,0 @@
{
"chooseCharacters": "请选择您想学习的字符",
"japanese": "日语假名",
"english": "英文字母",
"uyghur": "维吾尔字母",
"esperanto": "世界语字母",
"loading": "加载中...",
"loadFailed": "加载失败,请重试",
"hideLetter": "隐藏字母",
"showLetter": "显示字母",
"hideIPA": "隐藏IPA",
"showIPA": "显示IPA"
}

View File

@@ -1,33 +0,0 @@
{
"title": "学语言",
"description": "这里是一个非常有用的网站,可以帮助你学习世界上几乎每一种语言,包括人造语言。",
"explore": "探索网站",
"fortune": {
"quote": "求知若饥,虚心若愚。",
"author": "—— 史蒂夫·乔布斯"
},
"translator": {
"name": "翻译器",
"description": "翻译到任何语言并标注国际音标IPA"
},
"textSpeaker": {
"name": "朗读器",
"description": "识别并朗读文本,支持循环朗读、朗读速度调节"
},
"srtPlayer": {
"name": "逐句视频播放器",
"description": "基于SRT字幕文件逐句播放视频以模仿母语者的发音"
},
"alphabet": {
"name": "背字母",
"description": "从字母表开始新语言的学习"
},
"memorize": {
"name": "背单词",
"description": "语言A到语言B语言B到语言A支持听写"
},
"moreFeatures": {
"name": "更多功能",
"description": "开发中,敬请期待"
}
}

View File

@@ -1,4 +0,0 @@
{
"back": "返回",
"choose": "选择"
}

View File

@@ -1,6 +0,0 @@
{
"back": "返回",
"save": "保存单词对",
"locale1": "区域1",
"locale2": "区域2"
}

View File

@@ -1,10 +0,0 @@
{
"title": "记忆",
"locale1": "您选择的区域一是{locale}",
"locale2": "您选择的区域二是{locale}",
"total": "总计有{total}个单词对",
"start": "开始",
"import": "导入",
"export": "导出",
"edit": "编辑"
}

View File

@@ -1,7 +0,0 @@
{
"show": "显示",
"reverse": "反向",
"dictation": "听写",
"back": "返回",
"next": "下个"
}

View File

@@ -1,5 +0,0 @@
{
"title": "学语言",
"about": "关于",
"sourceCode": "源码"
}

View File

@@ -1,10 +0,0 @@
{
"uploadVideo": "上传视频",
"uploadSubtitle": "上传字幕",
"pause": "暂停",
"play": "播放",
"previous": "上句",
"next": "下句",
"restart": "句首",
"autoPause": "自动暂停({enabled})"
}

View File

@@ -1,5 +0,0 @@
{
"generateIPA": "生成IPA",
"viewSavedItems": "查看保存项",
"confirmDeleteAll": "确定删光吗?(Y/N)"
}

View File

@@ -1,12 +0,0 @@
{
"detectLanguage": "检测语言",
"generateIPA": "生成国际音标",
"translateInto": "翻译为",
"chinese": "中文",
"english": "英文",
"italian": "意大利语",
"other": "其他",
"translating": "翻译中...",
"translate": "翻译",
"inputLanguage": "请输入语言。"
}

View File

@@ -0,0 +1,258 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
import IconClick from "@/components/ui/buttons/IconClick";
import IMAGES from "@/config/images";
import { ChevronLeft, ChevronRight } from "lucide-react";
interface AlphabetCardProps {
alphabet: Letter[];
alphabetType: SupportedAlphabets;
onBack: () => void;
}
export default function AlphabetCard({ alphabet, alphabetType, onBack }: AlphabetCardProps) {
const t = useTranslations("alphabet");
const [currentIndex, setCurrentIndex] = useState(0);
const [showIPA, setShowIPA] = useState(true);
const [showLetter, setShowLetter] = useState(true);
const [showRoman, setShowRoman] = useState(false);
const [isRandomMode, setIsRandomMode] = useState(false);
// 只有日语假名显示罗马音按钮
const hasRomanization = alphabetType === "japanese";
const currentLetter = alphabet[currentIndex];
const goToNext = useCallback(() => {
if (isRandomMode) {
setCurrentIndex(Math.floor(Math.random() * alphabet.length));
} else {
setCurrentIndex((prev) => (prev === alphabet.length - 1 ? 0 : prev + 1));
}
}, [alphabet.length, isRandomMode]);
const goToPrevious = useCallback(() => {
if (isRandomMode) {
setCurrentIndex(Math.floor(Math.random() * alphabet.length));
} else {
setCurrentIndex((prev) => (prev === 0 ? alphabet.length - 1 : prev - 1));
}
}, [alphabet.length, isRandomMode]);
const goToRandom = useCallback(() => {
setCurrentIndex(Math.floor(Math.random() * alphabet.length));
}, [alphabet.length]);
// 键盘快捷键支持
useEffect(() => {
const handleKeyDown = (e: globalThis.KeyboardEvent) => {
if (e.key === "ArrowLeft") {
goToPrevious();
} else if (e.key === "ArrowRight") {
goToNext();
} else if (e.key === " ") {
e.preventDefault();
goToRandom();
} else if (e.key === "Escape") {
onBack();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [goToPrevious, goToNext, goToRandom, onBack]);
// 触摸滑动支持
const [touchStart, setTouchStart] = useState<number | null>(null);
const [touchEnd, setTouchEnd] = useState<number | null>(null);
const minSwipeDistance = 50;
const onTouchStart = (e: React.TouchEvent) => {
setTouchEnd(null);
setTouchStart(e.targetTouches[0].clientX);
};
const onTouchMove = (e: React.TouchEvent) => {
setTouchEnd(e.targetTouches[0].clientX);
};
const onTouchEnd = () => {
if (!touchStart || !touchEnd) return;
const distance = touchStart - touchEnd;
const isLeftSwipe = distance > minSwipeDistance;
const isRightSwipe = distance < -minSwipeDistance;
if (isLeftSwipe) {
goToNext();
}
if (isRightSwipe) {
goToPrevious();
}
};
return (
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4 py-8">
<div className="w-full max-w-2xl">
{/* 返回按钮 */}
<div className="flex justify-end mb-4">
<IconClick
size={32}
alt="close"
src={IMAGES.close}
onClick={onBack}
className="bg-white rounded-full shadow-md"
/>
</div>
{/* 主卡片 */}
<div className="bg-white rounded-2xl shadow-xl p-8 md:p-12">
{/* 进度指示器 */}
<div className="flex justify-between items-center mb-6">
<span className="text-sm text-gray-500">
{currentIndex + 1} / {alphabet.length}
</span>
<div className="flex gap-2 flex-wrap">
<button
onClick={() => setShowLetter(!showLetter)}
className={`px-3 py-1 rounded-full text-sm transition-colors ${
showLetter
? "bg-[#35786f] text-white"
: "bg-gray-200 text-gray-600"
}`}
>
{t("letter")}
</button>
<button
onClick={() => setShowIPA(!showIPA)}
className={`px-3 py-1 rounded-full text-sm transition-colors ${
showIPA
? "bg-[#35786f] text-white"
: "bg-gray-200 text-gray-600"
}`}
>
IPA
</button>
{hasRomanization && (
<button
onClick={() => setShowRoman(!showRoman)}
className={`px-3 py-1 rounded-full text-sm transition-colors ${
showRoman
? "bg-[#35786f] text-white"
: "bg-gray-200 text-gray-600"
}`}
>
{t("roman")}
</button>
)}
<button
onClick={() => setIsRandomMode(!isRandomMode)}
className={`px-3 py-1 rounded-full text-sm transition-colors ${
isRandomMode
? "bg-[#35786f] text-white"
: "bg-gray-200 text-gray-600"
}`}
>
{t("random")}
</button>
</div>
</div>
{/* 字母显示区域 */}
<div className="text-center mb-8">
{showLetter ? (
<div className="text-6xl md:text-8xl font-bold text-gray-800 mb-4">
{currentLetter.letter}
</div>
) : (
<div className="text-6xl md:text-8xl font-bold text-gray-300 mb-4 h-20 md:h-24 flex items-center justify-center">
<span className="text-2xl md:text-3xl text-gray-400">?</span>
</div>
)}
{showIPA && (
<div className="text-2xl md:text-3xl text-gray-600 mb-2">
{currentLetter.letter_sound_ipa}
</div>
)}
{showRoman && hasRomanization && currentLetter.roman_letter && (
<div className="text-lg md:text-xl text-gray-500">
{currentLetter.roman_letter}
</div>
)}
</div>
{/* 导航控制 */}
<div className="flex justify-between items-center">
<button
onClick={goToPrevious}
className="p-3 rounded-full bg-gray-100 hover:bg-gray-200 transition-colors"
aria-label="上一个字母"
>
<ChevronLeft size={24} />
</button>
<div className="flex gap-2 items-center">
{isRandomMode ? (
<button
onClick={goToRandom}
className="px-4 py-2 rounded-full bg-[#35786f] text-white text-sm font-medium hover:bg-[#2d5f58] transition-colors"
>
{t("randomNext")}
</button>
) : (
<div className="flex gap-1 flex-wrap max-w-xs justify-center">
{alphabet.slice(0, 20).map((_, index) => (
<div
key={index}
className={`h-2 rounded-full transition-all ${
index === currentIndex
? "w-8 bg-[#35786f]"
: "w-2 bg-gray-300"
}`}
/>
))}
{alphabet.length > 20 && (
<div className="text-xs text-gray-500 flex items-center">...</div>
)}
</div>
)}
</div>
<button
onClick={goToNext}
className="p-3 rounded-full bg-gray-100 hover:bg-gray-200 transition-colors"
aria-label="下一个字母"
>
<ChevronRight size={24} />
</button>
</div>
</div>
{/* 操作提示 */}
<div className="text-center mt-6 text-white text-sm">
<p>
{isRandomMode
? "使用左右箭头键或空格键随机切换字母ESC键返回"
: "使用左右箭头键或滑动切换字母ESC键返回"
}
</p>
</div>
</div>
{/* 触摸事件处理 */}
<div
className="absolute inset-0 pointer-events-none"
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
/>
</div>
);
}

View File

@@ -1,11 +1,12 @@
import LightButton from "@/components/buttons/LightButton";
import IconClick from "@/components/IconClick";
import LightButton from "@/components/ui/buttons/LightButton";
import IconClick from "@/components/ui/buttons/IconClick";
import IMAGES from "@/config/images";
import { Letter, SupportedAlphabets } from "@/interfaces";
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
import {
Dispatch,
KeyboardEvent,
SetStateAction,
useCallback,
useEffect,
useState,
} from "react";
@@ -19,25 +20,26 @@ export default function MemoryCard({
setChosenAlphabet: Dispatch<SetStateAction<SupportedAlphabets | null>>;
}) {
const t = useTranslations("alphabet");
const [index, setIndex] = useState(
Math.floor(Math.random() * alphabet.length),
);
const [index, setIndex] = useState(() => alphabet.length > 0 ? Math.floor(Math.random() * alphabet.length) : 0);
const [more, setMore] = useState(false);
const [ipaDisplay, setIPADisplay] = useState(true);
const [letterDisplay, setLetterDisplay] = useState(true);
const refresh = useCallback(() => {
if (alphabet.length > 0) {
setIndex(Math.floor(Math.random() * alphabet.length));
}
}, [alphabet.length]);
useEffect(() => {
const handleKeydown = (e: globalThis.KeyboardEvent) => {
if (e.key === " ") refresh();
};
document.addEventListener("keydown", handleKeydown);
return () => document.removeEventListener("keydown", handleKeydown);
});
}, [refresh]);
const letter = alphabet[index];
const refresh = () => {
setIndex(Math.floor(Math.random() * alphabet.length));
};
const letter = alphabet[index] || { letter: "", letter_name_ipa: "", letter_sound_ipa: "" };
return (
<div
className="w-full flex justify-center items-center"

View File

@@ -0,0 +1,144 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
import Container from "@/components/ui/Container";
import LightButton from "@/components/ui/buttons/LightButton";
import AlphabetCard from "./AlphabetCard";
export default function Alphabet() {
const t = useTranslations("alphabet");
const [chosenAlphabet, setChosenAlphabet] = useState<SupportedAlphabets | null>(null);
const [alphabetData, setAlphabetData] = useState<Letter[] | null>(null);
const [loadingState, setLoadingState] = useState<"idle" | "loading" | "success" | "error">("idle");
useEffect(() => {
const loadAlphabetData = async () => {
if (chosenAlphabet && !alphabetData) {
try {
setLoadingState("loading");
const res = await fetch("/alphabets/" + chosenAlphabet + ".json");
if (!res.ok) throw new Error("Network response was not ok");
const obj = await res.json();
setAlphabetData(obj as Letter[]);
setLoadingState("success");
} catch (error) {
setLoadingState("error");
}
}
};
loadAlphabetData();
}, [chosenAlphabet, alphabetData]);
useEffect(() => {
if (loadingState === "error") {
const timer = setTimeout(() => {
setLoadingState("idle");
setChosenAlphabet(null);
setAlphabetData(null);
}, 2000);
return () => clearTimeout(timer);
}
}, [loadingState]);
// 语言选择界面
if (!chosenAlphabet) {
return (
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex flex-col items-center justify-center px-4">
<Container className="p-8 max-w-2xl w-full text-center">
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
{t("chooseCharacters")}
</h1>
<p className="text-gray-600 mb-8 text-lg">
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<LightButton
onClick={() => setChosenAlphabet("japanese")}
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
>
<div className="flex flex-col items-center">
<span className="text-2xl mb-2"></span>
<span>{t("japanese")}</span>
</div>
</LightButton>
<LightButton
onClick={() => setChosenAlphabet("english")}
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
>
<div className="flex flex-col items-center">
<span className="text-2xl mb-2">ABC</span>
<span>{t("english")}</span>
</div>
</LightButton>
<LightButton
onClick={() => setChosenAlphabet("uyghur")}
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
>
<div className="flex flex-col items-center">
<span className="text-2xl mb-2">ئۇيغۇر</span>
<span>{t("uyghur")}</span>
</div>
</LightButton>
<LightButton
onClick={() => setChosenAlphabet("esperanto")}
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
>
<div className="flex flex-col items-center">
<span className="text-2xl mb-2">ABCĜĤ</span>
<span>{t("esperanto")}</span>
</div>
</LightButton>
</div>
</Container>
</div>
);
}
// 加载状态
if (loadingState === "loading") {
return (
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center">
<Container className="p-8 text-center">
<div className="text-2xl text-gray-600">{t("loading")}</div>
</Container>
</div>
);
}
// 错误状态
if (loadingState === "error") {
return (
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center">
<Container className="p-8 text-center">
<div className="text-2xl text-red-600">{t("loadFailed")}</div>
</Container>
</div>
);
}
// 字母卡片界面
if (loadingState === "success" && alphabetData) {
return (
<AlphabetCard
alphabet={alphabetData}
alphabetType={chosenAlphabet}
onBack={() => {
setChosenAlphabet(null);
setAlphabetData(null);
setLoadingState("idle");
}}
/>
);
}
return null;
}

View File

@@ -0,0 +1,64 @@
"use client";
import Container from "@/components/ui/Container";
import { useRouter } from "next/navigation";
import { Center } from "@/components/common/Center";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { Folder } from "../../../../generated/prisma/browser";
import { Folder as Fd } from "lucide-react";
interface FolderSelectorProps {
folders: (Folder & { total: number })[];
}
const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
const t = useTranslations("memorize.folder_selector");
const router = useRouter();
return (
<Center>
<Container className="p-6 gap-4 flex flex-col">
{(folders.length === 0 && (
<h1 className="text-2xl text-gray-900 font-light">
{t("noFolders")}
<Link className="text-blue-900 border-b" href={"/folders"}>
folders
</Link>
</h1>
)) || (
<>
<h1 className="text-2xl text-gray-900 font-light">
{t("selectFolder")}
</h1>
<div className="text-gray-900 border border-gray-200 rounded-2xl max-h-96 overflow-y-auto">
{folders
.toSorted((a, b) => a.id - b.id)
.map((folder) => (
<div
key={folder.id}
onClick={() =>
router.push(`/memorize?folder_id=${folder.id}`)
}
className="flex flex-row justify-center items-center group p-2 gap-2 hover:cursor-pointer hover:bg-gray-50"
>
<Fd />
<div className="flex-1 flex gap-2">
<span className="group-hover:text-blue-500">
{t("folderInfo", {
id: folder.id,
name: folder.name,
count: folder.total,
})}
</span>
</div>
</div>
))}
</div>
</>
)}
</Container>
</Center>
);
};
export default FolderSelector;

View File

@@ -0,0 +1,173 @@
"use client";
import { useState } from "react";
import LightButton from "@/components/ui/buttons/LightButton";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { getTTSAudioUrl } from "@/lib/browser/tts";
import { VOICES } from "@/config/locales";
import { useTranslations } from "next-intl";
import localFont from "next/font/local";
import { isNonNegativeInteger, SeededRandom } from "@/lib/utils";
import { Pair } from "../../../../generated/prisma/browser";
const myFont = localFont({
src: "../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf",
});
interface MemorizeProps {
textPairs: Pair[];
}
const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
const t = useTranslations("memorize.memorize");
const [reverse, setReverse] = useState(false);
const [dictation, setDictation] = useState(false);
const [disorder, setDisorder] = useState(false);
const [index, setIndex] = useState(0);
const [show, setShow] = useState<"question" | "answer">("question");
const { load, play } = useAudioPlayer();
if (textPairs.length === 0) {
return <p>{t("noTextPairs")}</p>;
}
const rng = new SeededRandom(textPairs[0].folderId);
const disorderedTextPairs = textPairs.toSorted(() => rng.next() - 0.5);
textPairs.sort((a, b) => a.id - b.id);
const getTextPairs = () => disorder ? disorderedTextPairs : textPairs;
return (
<>
{(getTextPairs().length > 0 && (
<>
<div className="text-center">
<div
className="text-sm text-gray-500"
onClick={() => {
const newIndex = prompt("Input a index number.")?.trim();
if (
newIndex &&
isNonNegativeInteger(newIndex) &&
parseInt(newIndex) <= textPairs.length &&
parseInt(newIndex) > 0
) {
setIndex(parseInt(newIndex) - 1);
}
}}
>
{index + 1}
{"/" + getTextPairs().length}
</div>
<div className={`h-[40dvh] md:px-16 px-4 ${myFont.className}`}>
{(() => {
const createText = (text: string) => {
return (
<div className="text-gray-900 text-xl border-y border-y-gray-200 p-4 md:text-3xl h-[20dvh] overflow-y-auto">
{text}
</div>
);
};
const [text1, text2] = reverse
? [getTextPairs()[index].text2, getTextPairs()[index].text1]
: [getTextPairs()[index].text1, getTextPairs()[index].text2];
if (dictation) {
// dictation
if (show === "question") {
return createText("");
} else {
return (
<>
{createText(text1)}
{createText(text2)}
</>
);
}
} else {
// non-dictation
if (show === "question") {
return createText(text1);
} else {
return (
<>
{createText(text1)}
{createText(text2)}
</>
);
}
}
})()}
</div>
</div>
<div className="flex flex-row gap-2 items-center justify-center flex-wrap">
<LightButton
className="w-20"
onClick={async () => {
if (show === "answer") {
const newIndex = (index + 1) % getTextPairs().length;
setIndex(newIndex);
if (dictation)
getTTSAudioUrl(
getTextPairs()[newIndex][reverse ? "text2" : "text1"],
VOICES.find(
(v) =>
v.locale ===
getTextPairs()[newIndex][
reverse ? "locale2" : "locale1"
],
)!.short_name,
).then((url) => {
load(url);
play();
});
}
setShow(show === "question" ? "answer" : "question");
}}
>
{show === "question" ? t("answer") : t("next")}
</LightButton>
<LightButton
onClick={() => {
setIndex(
(index - 1 + getTextPairs().length) % getTextPairs().length,
);
setShow("question");
}}
>
{t("previous")}
</LightButton>
<LightButton
onClick={() => {
setReverse(!reverse);
}}
selected={reverse}
>
{t("reverse")}
</LightButton>
<LightButton
onClick={() => {
setDictation(!dictation);
}}
selected={dictation}
>
{t("dictation")}
</LightButton>
<LightButton
onClick={() => {
setDisorder(!disorder);
}}
selected={disorder}
>
{t("disorder")}
</LightButton>
</div>
</>
)) || <p>{t("noTextPairs")}</p>}
</>
);
};
export default Memorize;

View File

@@ -0,0 +1,53 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import {
getFoldersWithTotalPairsByUserId,
getUserIdByFolderId,
} from "@/lib/server/services/folderService";
import { isNonNegativeInteger } from "@/lib/utils";
import FolderSelector from "./FolderSelector";
import Memorize from "./Memorize";
import { getPairsByFolderId } from "@/lib/server/services/pairService";
import { auth } from "@/auth";
import { headers } from "next/headers";
export default async function MemorizePage({
searchParams,
}: {
searchParams: Promise<{ folder_id?: string; }>;
}) {
const session = await auth.api.getSession({ headers: await headers() });
const tParam = (await searchParams).folder_id;
if (!session) {
redirect(
`/auth?redirect=/memorize${(await searchParams).folder_id
? `?folder_id=${tParam}`
: ""
}`,
);
}
const t = await getTranslations("memorize.page");
const folder_id = tParam
? isNonNegativeInteger(tParam)
? parseInt(tParam)
: null
: null;
if (!folder_id) {
return (
<FolderSelector
folders={await getFoldersWithTotalPairsByUserId(session.user.id)}
/>
);
}
const owner = await getUserIdByFolderId(folder_id);
if (owner !== session.user.id) {
return <p>{t("unauthorized")}</p>;
}
return <Memorize textPairs={await getPairsByFolderId(folder_id)} />;
}

View File

@@ -1,13 +1,16 @@
import { inspect } from "@/utils";
export default function SubtitleDisplay({ subtitle }: { subtitle: string }) {
const words = subtitle.match(/\b[\w']+(?:-[\w']+)*\b/g) || [];
let i = 0;
return (
<div className="w-full subtitle overflow-auto h-16 mt-2 break-words bg-black/50 font-sans text-white text-center text-2xl">
<div className="w-full subtitle overflow-auto h-16 mt-2 wrap-break-word bg-black/50 font-sans text-white text-center text-2xl">
{words.map((v) => (
<span
onClick={inspect(v)}
onClick={() => {
window.open(
`https://www.youdao.com/result?word=${v}&lang=en`,
"_blank",
);
}}
key={i++}
className="hover:bg-gray-700 hover:underline hover:cursor-pointer"
>

View File

@@ -1,6 +1,6 @@
import { useState, useRef, forwardRef, useEffect, useCallback } from "react";
import SubtitleDisplay from "./SubtitleDisplay";
import LightButton from "@/components/buttons/LightButton";
import LightButton from "@/components/ui/buttons/LightButton";
import { getIndex, parseSrt, getNearistIndex } from "../subtitle";
import { useTranslations } from "next-intl";
@@ -11,7 +11,7 @@ type VideoPanelProps = {
const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>(
({ videoUrl, srtUrl }, videoRef) => {
const t = useTranslations("srt-player");
const t = useTranslations("srt_player");
videoRef = videoRef as React.RefObject<HTMLVideoElement>;
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [srtLength, setSrtLength] = useState<number>(0);
@@ -20,7 +20,7 @@ const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>(
const [spanText, setSpanText] = useState<string>("");
const [subtitle, setSubtitle] = useState<string>("");
const parsedSrtRef = useRef<
{ start: number; end: number; text: string }[] | null
{ start: number; end: number; text: string; }[] | null
>(null);
const rafldRef = useRef<number>(0);
const ready = useRef({

View File

@@ -0,0 +1,45 @@
"use client";
import React, { useRef } from "react";
import { FileInputProps } from "../../types/controls";
interface FileInputComponentProps extends FileInputProps {
children: React.ReactNode;
}
export default function FileInput({ accept, onFileSelect, disabled, className, children }: FileInputComponentProps) {
const inputRef = useRef<HTMLInputElement>(null);
const handleClick = React.useCallback(() => {
if (!disabled && inputRef.current) {
inputRef.current.click();
}
}, [disabled]);
const handleChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
onFileSelect(file);
}
}, [onFileSelect]);
return (
<>
<input
ref={inputRef}
type="file"
accept={accept}
onChange={handleChange}
disabled={disabled}
className="hidden"
/>
<button
onClick={handleClick}
disabled={disabled}
className={`px-2 py-1 rounded shadow font-bold hover:cursor-pointer hover:bg-gray-200 text-gray-800 bg-white ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className || ''}`}
>
{children}
</button>
</>
);
}

View File

@@ -0,0 +1,20 @@
"use client";
import React from "react";
import { useTranslations } from "next-intl";
import LightButton from "@/components/ui/buttons/LightButton";
import { PlayButtonProps } from "../../types/player";
export default function PlayButton({ isPlaying, onToggle, disabled, className }: PlayButtonProps) {
const t = useTranslations("srt_player");
return (
<LightButton
onClick={disabled ? undefined : onToggle}
disabled={disabled}
className={`px-4 py-2 ${className || ''}`}
>
{isPlaying ? t("pause") : t("play")}
</LightButton>
);
}

View File

@@ -0,0 +1,26 @@
"use client";
import React from "react";
import { SeekBarProps } from "../../types/player";
export default function SeekBar({ value, max, onChange, disabled, className }: SeekBarProps) {
const handleChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const newValue = parseInt(event.target.value);
onChange(newValue);
}, [onChange]);
return (
<input
type="range"
min={0}
max={max}
value={value}
onChange={handleChange}
disabled={disabled}
className={`w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className || ''}`}
style={{
background: `linear-gradient(to right, #374151 0%, #374151 ${(value / max) * 100}%, #e5e7eb ${(value / max) * 100}%, #e5e7eb 100%)`
}}
/>
);
}

View File

@@ -0,0 +1,25 @@
"use client";
import React from "react";
import LightButton from "@/components/ui/buttons/LightButton";
import { SpeedControlProps } from "../../types/player";
import { getPlaybackRateOptions, getPlaybackRateLabel } from "../../utils/timeUtils";
export default function SpeedControl({ playbackRate, onPlaybackRateChange, disabled, className }: SpeedControlProps) {
const speedOptions = getPlaybackRateOptions();
const handleSpeedChange = React.useCallback(() => {
const currentIndex = speedOptions.indexOf(playbackRate);
const nextIndex = (currentIndex + 1) % speedOptions.length;
onPlaybackRateChange(speedOptions[nextIndex]);
}, [playbackRate, onPlaybackRateChange, speedOptions]);
return (
<LightButton
onClick={disabled ? undefined : handleSpeedChange}
className={`${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className || ''}`}
>
{getPlaybackRateLabel(playbackRate)}
</LightButton>
);
}

View File

@@ -0,0 +1,44 @@
"use client";
import React from "react";
import { SubtitleTextProps } from "../../types/subtitle";
export default function SubtitleText({ text, onWordClick, style, className }: SubtitleTextProps) {
const handleWordClick = React.useCallback((word: string) => {
onWordClick?.(word);
}, [onWordClick]);
// 将文本分割成单词,保持标点符号
const renderTextWithClickableWords = () => {
if (!text) return null;
// 匹配单词和标点符号
const parts = text.match(/[\w']+|[^\w\s]+|\s+/g) || [];
return parts.map((part, index) => {
// 如果是单词(字母和撇号组成)
if (/^[\w']+$/.test(part)) {
return (
<span
key={index}
onClick={() => handleWordClick(part)}
className="hover:bg-gray-700 hover:underline hover:cursor-pointer rounded px-1 transition-colors"
>
{part}
</span>
);
}
// 如果是空格或其他字符,直接渲染
return <span key={index}>{part}</span>;
});
};
return (
<div
className={`overflow-auto h-16 mt-2 wrap-break-words font-sans text-white text-center text-2xl ${className || ''}`}
style={style}
>
{renderTextWithClickableWords()}
</div>
);
}

View File

@@ -0,0 +1,49 @@
"use client";
import React, { forwardRef } from "react";
import { VideoElementProps } from "../../types/player";
const VideoElement = forwardRef<HTMLVideoElement, VideoElementProps>(
({ src, onTimeUpdate, onLoadedMetadata, onPlay, onPause, onEnded, className }, ref) => {
const handleTimeUpdate = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
const video = event.currentTarget;
onTimeUpdate?.(video.currentTime);
}, [onTimeUpdate]);
const handleLoadedMetadata = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
const video = event.currentTarget;
onLoadedMetadata?.(video.duration);
}, [onLoadedMetadata]);
const handlePlay = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
onPlay?.();
}, [onPlay]);
const handlePause = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
onPause?.();
}, [onPause]);
const handleEnded = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
onEnded?.();
}, [onEnded]);
return (
<video
ref={ref}
src={src}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onPlay={handlePlay}
onPause={handlePause}
onEnded={handleEnded}
className={`bg-gray-200 w-full ${className || ""}`}
playsInline
controls={false}
/>
);
}
);
VideoElement.displayName = "VideoElement";
export default VideoElement;

View File

@@ -0,0 +1,77 @@
"use client";
import React from "react";
import { useTranslations } from "next-intl";
import { ChevronLeft, ChevronRight, RotateCcw, Pause } from "lucide-react";
import DarkButton from "@/components/ui/buttons/DarkButton";
import { ControlBarProps } from "../../types/controls";
import PlayButton from "../atoms/PlayButton";
import SpeedControl from "../atoms/SpeedControl";
export default function ControlBar({
isPlaying,
onPlayPause,
onPrevious,
onNext,
onRestart,
playbackRate,
onPlaybackRateChange,
autoPause,
onAutoPauseToggle,
disabled,
className
}: ControlBarProps) {
const t = useTranslations("srt_player");
return (
<div className={`flex flex-wrap gap-2 justify-center ${className || ''}`}>
<PlayButton
isPlaying={isPlaying}
onToggle={onPlayPause}
disabled={disabled}
/>
<DarkButton
onClick={disabled ? undefined : onPrevious}
disabled={disabled}
className="flex items-center px-3 py-2"
>
<ChevronLeft className="w-4 h-4 mr-2" />
{t("previous")}
</DarkButton>
<DarkButton
onClick={disabled ? undefined : onNext}
disabled={disabled}
className="flex items-center px-3 py-2"
>
{t("next")}
<ChevronRight className="w-4 h-4 ml-2" />
</DarkButton>
<DarkButton
onClick={disabled ? undefined : onRestart}
disabled={disabled}
className="flex items-center px-3 py-2"
>
<RotateCcw className="w-4 h-4 mr-2" />
{t("restart")}
</DarkButton>
<SpeedControl
playbackRate={playbackRate}
onPlaybackRateChange={onPlaybackRateChange}
disabled={disabled}
/>
<DarkButton
onClick={disabled ? undefined : onAutoPauseToggle}
disabled={disabled}
className="flex items-center px-3 py-2"
>
<Pause className="w-4 h-4 mr-2" />
{t("autoPause", { enabled: autoPause ? t("on") : t("off") })}
</DarkButton>
</div>
);
}

View File

@@ -0,0 +1,37 @@
"use client";
import React from "react";
import { SubtitleDisplayProps } from "../../types/subtitle";
import SubtitleText from "../atoms/SubtitleText";
export default function SubtitleArea({ subtitle, onWordClick, settings, className }: SubtitleDisplayProps) {
const handleWordClick = React.useCallback((word: string) => {
// 打开有道词典页面查询单词
window.open(
`https://www.youdao.com/result?word=${encodeURIComponent(word)}&lang=en`,
"_blank"
);
onWordClick?.(word);
}, [onWordClick]);
const subtitleStyle = React.useMemo(() => {
if (!settings) return { backgroundColor: 'rgba(0, 0, 0, 0.5)' };
return {
backgroundColor: settings.backgroundColor,
color: settings.textColor,
fontSize: `${settings.fontSize}px`,
fontFamily: settings.fontFamily,
opacity: settings.opacity,
};
}, [settings]);
return (
<SubtitleText
text={subtitle}
onWordClick={handleWordClick}
style={subtitleStyle}
className={className}
/>
);
}

View File

@@ -0,0 +1,46 @@
"use client";
import React from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Video, FileText } from "lucide-react";
import DarkButton from "@/components/ui/buttons/DarkButton";
import { FileUploadProps } from "../../types/controls";
import { useFileUpload } from "../../hooks/useFileUpload";
export default function UploadZone({ onVideoUpload, onSubtitleUpload, className }: FileUploadProps) {
const t = useTranslations("srt_player");
const { uploadVideo, uploadSubtitle } = useFileUpload();
const handleVideoUpload = React.useCallback(() => {
uploadVideo(onVideoUpload, (error) => {
toast.error(t("videoUploadFailed") + ": " + error.message);
});
}, [uploadVideo, onVideoUpload, t]);
const handleSubtitleUpload = React.useCallback(() => {
uploadSubtitle(onSubtitleUpload, (error) => {
toast.error(t("subtitleUploadFailed") + ": " + error.message);
});
}, [uploadSubtitle, onSubtitleUpload, t]);
return (
<div className={`flex gap-3 ${className || ''}`}>
<DarkButton
onClick={handleVideoUpload}
className="flex-1 py-2 px-3 text-sm"
>
<Video className="w-4 h-4 mr-2" />
{t("uploadVideo")}
</DarkButton>
<DarkButton
onClick={handleSubtitleUpload}
className="flex-1 py-2 px-3 text-sm"
>
<FileText className="w-4 h-4 mr-2" />
{t("uploadSubtitle")}
</DarkButton>
</div>
);
}

View File

@@ -0,0 +1,41 @@
"use client";
import React, { forwardRef } from "react";
import { VideoElementProps } from "../../types/player";
import VideoElement from "../atoms/VideoElement";
interface VideoPlayerComponentProps extends VideoElementProps {
children?: React.ReactNode;
}
const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerComponentProps>(
({
src,
onTimeUpdate,
onLoadedMetadata,
onPlay,
onPause,
onEnded,
className,
children
}, ref) => {
return (
<div className={`w-full flex flex-col ${className || ''}`}>
<VideoElement
ref={ref}
src={src}
onTimeUpdate={onTimeUpdate}
onLoadedMetadata={onLoadedMetadata}
onPlay={onPlay}
onPause={onPause}
onEnded={onEnded}
/>
{children}
</div>
);
}
);
VideoPlayer.displayName = "VideoPlayer";
export default VideoPlayer;

View File

@@ -0,0 +1,85 @@
"use client";
import { useCallback } from "react";
export function useFileUpload() {
const uploadFile = useCallback((
file: File,
onSuccess: (url: string) => void,
onError?: (error: Error) => void
) => {
try {
// 验证文件大小限制为100MB
const maxSize = 100 * 1024 * 1024; // 100MB
if (file.size > maxSize) {
throw new Error(`文件大小超过限制 (${(file.size / 1024 / 1024).toFixed(2)}MB > 100MB)`);
}
const url = URL.createObjectURL(file);
onSuccess(url);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '文件上传失败';
onError?.(new Error(errorMessage));
}
}, []);
const uploadVideo = useCallback((
onVideoUpload: (url: string) => void,
onError?: (error: Error) => void
) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'video/*';
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
// 验证文件类型
if (!file.type.startsWith('video/')) {
onError?.(new Error('请选择有效的视频文件'));
return;
}
uploadFile(file, onVideoUpload, onError);
}
};
input.onerror = () => {
onError?.(new Error('文件选择失败'));
};
input.click();
}, [uploadFile]);
const uploadSubtitle = useCallback((
onSubtitleUpload: (url: string) => void,
onError?: (error: Error) => void
) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.srt';
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
// 验证文件扩展名
if (!file.name.toLowerCase().endsWith('.srt')) {
onError?.(new Error('请选择.srt格式的字幕文件'));
return;
}
uploadFile(file, onSubtitleUpload, onError);
}
};
input.onerror = () => {
onError?.(new Error('文件选择失败'));
};
input.click();
}, [uploadFile]);
return {
uploadVideo,
uploadSubtitle,
uploadFile,
};
}

View File

@@ -0,0 +1,68 @@
"use client";
import { useCallback, useEffect } from "react";
import { KeyboardShortcut } from "../types/controls";
export function useKeyboardShortcuts(
shortcuts: KeyboardShortcut[],
enabled: boolean = true
) {
const handleKeyDown = useCallback((event: globalThis.KeyboardEvent) => {
if (!enabled) return;
// 防止在输入框中触发快捷键
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
return;
}
const shortcut = shortcuts.find(s => s.key === event.key);
if (shortcut) {
event.preventDefault();
shortcut.action();
}
}, [shortcuts, enabled]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);
}
export function createSrtPlayerShortcuts(
playPause: () => void,
next: () => void,
previous: () => void,
restart: () => void,
toggleAutoPause: () => void
): KeyboardShortcut[] {
return [
{
key: ' ',
description: '播放/暂停',
action: playPause,
},
{
key: 'n',
description: '下一句',
action: next,
},
{
key: 'p',
description: '上一句',
action: previous,
},
{
key: 'r',
description: '句首',
action: restart,
},
{
key: 'a',
description: '切换自动暂停',
action: toggleAutoPause,
},
];
}

View File

@@ -0,0 +1,306 @@
"use client";
import { useReducer, useCallback, useRef, useEffect } from "react";
import { toast } from "sonner";
import { VideoState, VideoControls } from "../types/player";
import { SubtitleState, SubtitleEntry } from "../types/subtitle";
import { ControlState, ControlActions } from "../types/controls";
export interface SrtPlayerState {
video: VideoState;
subtitle: SubtitleState;
controls: ControlState;
}
export interface SrtPlayerActions extends VideoControls, ControlActions {
setVideoUrl: (url: string | null) => void;
setSubtitleUrl: (url: string | null) => void;
nextSubtitle: () => void;
previousSubtitle: () => void;
restartSubtitle: () => void;
setSubtitleSettings: (settings: Partial<SubtitleState['settings']>) => void;
}
const initialState: SrtPlayerState = {
video: {
url: null,
isPlaying: false,
currentTime: 0,
duration: 0,
playbackRate: 1.0,
volume: 1.0,
},
subtitle: {
url: null,
data: [],
currentText: "",
currentIndex: null,
settings: {
fontSize: 24,
backgroundColor: "rgba(0, 0, 0, 0.5)",
textColor: "#ffffff",
position: "bottom",
fontFamily: "sans-serif",
opacity: 1,
},
},
controls: {
autoPause: true,
showShortcuts: false,
showSettings: false,
},
};
type SrtPlayerAction =
| { type: "SET_VIDEO_URL"; payload: string | null }
| { type: "SET_PLAYING"; payload: boolean }
| { type: "SET_CURRENT_TIME"; payload: number }
| { type: "SET_DURATION"; payload: number }
| { type: "SET_PLAYBACK_RATE"; payload: number }
| { type: "SET_VOLUME"; payload: number }
| { type: "SET_SUBTITLE_URL"; payload: string | null }
| { type: "SET_SUBTITLE_DATA"; payload: SubtitleEntry[] }
| { type: "SET_CURRENT_SUBTITLE"; payload: { text: string; index: number | null } }
| { type: "SET_SUBTITLE_SETTINGS"; payload: Partial<SubtitleState['settings']> }
| { type: "TOGGLE_AUTO_PAUSE" }
| { type: "TOGGLE_SHORTCUTS" }
| { type: "TOGGLE_SETTINGS" };
function srtPlayerReducer(state: SrtPlayerState, action: SrtPlayerAction): SrtPlayerState {
switch (action.type) {
case "SET_VIDEO_URL":
return { ...state, video: { ...state.video, url: action.payload } };
case "SET_PLAYING":
return { ...state, video: { ...state.video, isPlaying: action.payload } };
case "SET_CURRENT_TIME":
return { ...state, video: { ...state.video, currentTime: action.payload } };
case "SET_DURATION":
return { ...state, video: { ...state.video, duration: action.payload } };
case "SET_PLAYBACK_RATE":
return { ...state, video: { ...state.video, playbackRate: action.payload } };
case "SET_VOLUME":
return { ...state, video: { ...state.video, volume: action.payload } };
case "SET_SUBTITLE_URL":
return { ...state, subtitle: { ...state.subtitle, url: action.payload } };
case "SET_SUBTITLE_DATA":
return { ...state, subtitle: { ...state.subtitle, data: action.payload } };
case "SET_CURRENT_SUBTITLE":
return {
...state,
subtitle: {
...state.subtitle,
currentText: action.payload.text,
currentIndex: action.payload.index,
},
};
case "SET_SUBTITLE_SETTINGS":
return {
...state,
subtitle: {
...state.subtitle,
settings: { ...state.subtitle.settings, ...action.payload },
},
};
case "TOGGLE_AUTO_PAUSE":
return { ...state, controls: { ...state.controls, autoPause: !state.controls.autoPause } };
case "TOGGLE_SHORTCUTS":
return { ...state, controls: { ...state.controls, showShortcuts: !state.controls.showShortcuts } };
case "TOGGLE_SETTINGS":
return { ...state, controls: { ...state.controls, showSettings: !state.controls.showSettings } };
default:
return state;
}
}
export function useSrtPlayer() {
const [state, dispatch] = useReducer(srtPlayerReducer, initialState);
const videoRef = useRef<HTMLVideoElement>(null);
// Video controls
const play = useCallback(() => {
// 检查是否同时有视频和字幕
if (!state.video.url || !state.subtitle.url || state.subtitle.data.length === 0) {
toast.error("请先上传视频和字幕文件");
return;
}
if (videoRef.current) {
videoRef.current.play().catch(error => {
toast.error("视频播放失败: " + error.message);
});
dispatch({ type: "SET_PLAYING", payload: true });
}
}, [state.video.url, state.subtitle.url, state.subtitle.data.length, dispatch]);
const pause = useCallback(() => {
if (videoRef.current) {
videoRef.current.pause();
dispatch({ type: "SET_PLAYING", payload: false });
}
}, []);
const togglePlayPause = useCallback(() => {
if (state.video.isPlaying) {
pause();
} else {
play();
}
}, [state.video.isPlaying, play, pause]);
const seek = useCallback((time: number) => {
if (videoRef.current) {
videoRef.current.currentTime = time;
dispatch({ type: "SET_CURRENT_TIME", payload: time });
}
}, []);
const setPlaybackRate = useCallback((rate: number) => {
if (videoRef.current) {
videoRef.current.playbackRate = rate;
dispatch({ type: "SET_PLAYBACK_RATE", payload: rate });
}
}, []);
const setVolume = useCallback((volume: number) => {
if (videoRef.current) {
videoRef.current.volume = volume;
dispatch({ type: "SET_VOLUME", payload: volume });
}
}, []);
const restart = useCallback(() => {
if (videoRef.current && state.subtitle.currentIndex !== null) {
const currentSubtitle = state.subtitle.data[state.subtitle.currentIndex];
if (currentSubtitle) {
seek(currentSubtitle.start);
play();
}
}
}, [state.subtitle.currentIndex, state.subtitle.data, seek, play]);
// URL setters
const setVideoUrl = useCallback((url: string | null) => {
dispatch({ type: "SET_VIDEO_URL", payload: url });
if (url && videoRef.current) {
videoRef.current.src = url;
videoRef.current.load();
}
}, []);
const setSubtitleUrl = useCallback((url: string | null) => {
dispatch({ type: "SET_SUBTITLE_URL", payload: url });
}, []);
// Subtitle controls
const nextSubtitle = useCallback(() => {
if (state.subtitle.currentIndex !== null &&
state.subtitle.currentIndex + 1 < state.subtitle.data.length) {
const nextIndex = state.subtitle.currentIndex + 1;
const nextSubtitle = state.subtitle.data[nextIndex];
seek(nextSubtitle.start);
play();
}
}, [state.subtitle.currentIndex, state.subtitle.data, seek, play]);
const previousSubtitle = useCallback(() => {
if (state.subtitle.currentIndex !== null && state.subtitle.currentIndex > 0) {
const prevIndex = state.subtitle.currentIndex - 1;
const prevSubtitle = state.subtitle.data[prevIndex];
seek(prevSubtitle.start);
play();
}
}, [state.subtitle.currentIndex, state.subtitle.data, seek, play]);
const restartSubtitle = useCallback(() => {
if (state.subtitle.currentIndex !== null) {
const currentSubtitle = state.subtitle.data[state.subtitle.currentIndex];
seek(currentSubtitle.start);
play();
}
}, [state.subtitle.currentIndex, state.subtitle.data, seek, play]);
const setSubtitleSettings = useCallback((settings: Partial<SubtitleState['settings']>) => {
dispatch({ type: "SET_SUBTITLE_SETTINGS", payload: settings });
}, []);
// Control actions
const toggleAutoPause = useCallback(() => {
dispatch({ type: "TOGGLE_AUTO_PAUSE" });
}, []);
const toggleShortcuts = useCallback(() => {
dispatch({ type: "TOGGLE_SHORTCUTS" });
}, []);
const toggleSettings = useCallback(() => {
dispatch({ type: "TOGGLE_SETTINGS" });
}, []);
// Video event handlers
const handleTimeUpdate = useCallback(() => {
if (videoRef.current) {
dispatch({ type: "SET_CURRENT_TIME", payload: videoRef.current.currentTime });
}
}, []);
const handleLoadedMetadata = useCallback(() => {
if (videoRef.current) {
dispatch({ type: "SET_DURATION", payload: videoRef.current.duration });
}
}, []);
const handlePlay = useCallback(() => {
dispatch({ type: "SET_PLAYING", payload: true });
}, []);
const handlePause = useCallback(() => {
dispatch({ type: "SET_PLAYING", payload: false });
}, []);
// Set subtitle data
const setSubtitleData = useCallback((data: SubtitleEntry[]) => {
dispatch({ type: "SET_SUBTITLE_DATA", payload: data });
}, []);
// Set current subtitle
const setCurrentSubtitle = useCallback((text: string, index: number | null) => {
dispatch({ type: "SET_CURRENT_SUBTITLE", payload: { text, index } });
}, []);
const actions: SrtPlayerActions = {
play,
pause,
togglePlayPause,
seek,
setPlaybackRate,
setVolume,
restart,
setVideoUrl,
setSubtitleUrl,
nextSubtitle,
previousSubtitle,
restartSubtitle,
setSubtitleSettings,
toggleAutoPause,
toggleShortcuts,
toggleSettings,
};
return {
state,
actions,
videoRef,
videoEventHandlers: {
onTimeUpdate: handleTimeUpdate,
onLoadedMetadata: handleLoadedMetadata,
onPlay: handlePlay,
onPause: handlePause,
},
subtitleActions: {
setSubtitleData,
setCurrentSubtitle,
},
};
}
export type UseSrtPlayerReturn = ReturnType<typeof useSrtPlayer>;

View File

@@ -0,0 +1,110 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { SubtitleEntry } from "../types/subtitle";
export function useSubtitleSync(
subtitles: SubtitleEntry[],
currentTime: number,
isPlaying: boolean,
autoPause: boolean,
onSubtitleChange: (subtitle: SubtitleEntry | null) => void,
onAutoPauseTrigger?: (subtitle: SubtitleEntry) => void
) {
const lastSubtitleRef = useRef<SubtitleEntry | null>(null);
const rafIdRef = useRef<number>(0);
// 获取当前时间对应的字幕
const getCurrentSubtitle = useCallback((time: number): SubtitleEntry | null => {
return subtitles.find(subtitle => time >= subtitle.start && time <= subtitle.end) || null;
}, [subtitles]);
// 获取最近的字幕索引
const getNearestIndex = useCallback((time: number): number | null => {
if (subtitles.length === 0) return null;
// 如果时间早于第一个字幕开始时间
if (time < subtitles[0].start) return null;
// 如果时间晚于最后一个字幕结束时间
if (time > subtitles[subtitles.length - 1].end) return subtitles.length - 1;
// 二分查找找到当前时间对应的字幕
let left = 0;
let right = subtitles.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const subtitle = subtitles[mid];
if (time >= subtitle.start && time <= subtitle.end) {
return mid;
} else if (time < subtitle.start) {
right = mid - 1;
} else {
left = mid + 1;
}
}
// 如果没有找到完全匹配的字幕,返回最近的字幕索引
return right >= 0 ? right : null;
}, [subtitles]);
// 检查是否需要自动暂停
const shouldAutoPause = useCallback((subtitle: SubtitleEntry, time: number): boolean => {
return autoPause &&
time >= subtitle.end - 0.2 && // 增加时间窗口,确保自动暂停更可靠
time < subtitle.end;
}, [autoPause]);
// 启动/停止同步循环
useEffect(() => {
const syncSubtitles = () => {
const currentSubtitle = getCurrentSubtitle(currentTime);
// 检查字幕是否发生变化
if (currentSubtitle !== lastSubtitleRef.current) {
const previousSubtitle = lastSubtitleRef.current;
lastSubtitleRef.current = currentSubtitle;
// 只有当有当前字幕时才调用onSubtitleChange
// 在字幕间隙时保持之前的字幕索引避免进度条跳到0
if (currentSubtitle) {
onSubtitleChange(currentSubtitle);
}
}
// 检查是否需要自动暂停
// 每次都检查,不只在字幕变化时检查
if (currentSubtitle && shouldAutoPause(currentSubtitle, currentTime)) {
onAutoPauseTrigger?.(currentSubtitle);
} else if (!currentSubtitle && lastSubtitleRef.current && shouldAutoPause(lastSubtitleRef.current, currentTime)) {
// 在字幕结束时,如果前一个字幕需要自动暂停,也要触发
onAutoPauseTrigger?.(lastSubtitleRef.current);
}
rafIdRef.current = requestAnimationFrame(syncSubtitles);
};
if (subtitles.length > 0) {
rafIdRef.current = requestAnimationFrame(syncSubtitles);
}
return () => {
if (rafIdRef.current) {
cancelAnimationFrame(rafIdRef.current);
}
};
}, [subtitles.length, currentTime, getCurrentSubtitle, onSubtitleChange, shouldAutoPause, onAutoPauseTrigger]);
// 重置最后字幕引用
useEffect(() => {
lastSubtitleRef.current = null;
}, [subtitles]);
return {
getCurrentSubtitle,
getNearestIndex,
shouldAutoPause,
};
}

View File

@@ -0,0 +1,280 @@
"use client";
import React from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Video, FileText } from "lucide-react";
import { useSrtPlayer } from "./hooks/useSrtPlayer";
import { useSubtitleSync } from "./hooks/useSubtitleSync";
import { useKeyboardShortcuts, createSrtPlayerShortcuts } from "./hooks/useKeyboardShortcuts";
import { useFileUpload } from "./hooks/useFileUpload";
import { loadSubtitle } from "./utils/subtitleParser";
import VideoPlayer from "./components/compounds/VideoPlayer";
import SubtitleArea from "./components/compounds/SubtitleArea";
import ControlBar from "./components/compounds/ControlBar";
import UploadZone from "./components/compounds/UploadZone";
import SeekBar from "./components/atoms/SeekBar";
import DarkButton from "@/components/ui/buttons/DarkButton";
export default function SrtPlayerPage() {
const t = useTranslations("home");
const srtT = useTranslations("srt_player");
const { uploadVideo, uploadSubtitle } = useFileUpload();
const {
state,
actions,
videoRef,
videoEventHandlers,
subtitleActions
} = useSrtPlayer();
// 字幕同步
useSubtitleSync(
state.subtitle.data,
state.video.currentTime,
state.video.isPlaying,
state.controls.autoPause,
(subtitle) => {
if (subtitle) {
subtitleActions.setCurrentSubtitle(subtitle.text, subtitle.index);
} else {
subtitleActions.setCurrentSubtitle("", null);
}
},
(subtitle) => {
// 自动暂停逻辑
actions.seek(subtitle.start);
actions.pause();
}
);
// 键盘快捷键
const shortcuts = React.useMemo(() =>
createSrtPlayerShortcuts(
actions.togglePlayPause,
actions.nextSubtitle,
actions.previousSubtitle,
actions.restartSubtitle,
actions.toggleAutoPause
), [
actions.togglePlayPause,
actions.nextSubtitle,
actions.previousSubtitle,
actions.restartSubtitle,
actions.toggleAutoPause
]
);
useKeyboardShortcuts(shortcuts);
// 处理字幕文件加载
React.useEffect(() => {
if (state.subtitle.url) {
loadSubtitle(state.subtitle.url)
.then(subtitleData => {
subtitleActions.setSubtitleData(subtitleData);
toast.success(srtT("subtitleLoadSuccess"));
})
.catch(error => {
toast.error(srtT("subtitleLoadFailed") + ": " + error.message);
});
}
}, [srtT, state.subtitle.url, subtitleActions]);
// 处理进度条变化
const handleSeek = React.useCallback((index: number) => {
if (state.subtitle.data[index]) {
actions.seek(state.subtitle.data[index].start);
}
}, [state.subtitle.data, actions]);
// 处理视频上传
const handleVideoUpload = React.useCallback(() => {
uploadVideo(actions.setVideoUrl, (error) => {
toast.error(srtT("videoUploadFailed") + ": " + error.message);
});
}, [uploadVideo, actions.setVideoUrl, srtT]);
// 处理字幕上传
const handleSubtitleUpload = React.useCallback(() => {
uploadSubtitle(actions.setSubtitleUrl, (error) => {
toast.error(srtT("subtitleUploadFailed") + ": " + error.message);
});
}, [uploadSubtitle, actions.setSubtitleUrl, srtT]);
// 检查是否可以播放
const canPlay = state.video.url && state.subtitle.url && state.subtitle.data.length > 0;
return (
<div className="min-h-screen bg-gray-50">
<div className="container mx-auto px-4 py-8">
<div className="max-w-6xl mx-auto">
{/* 标题区域 */}
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-800 mb-2">
{t("srtPlayer.name")}
</h1>
<p className="text-lg text-gray-600">
{t("srtPlayer.description")}
</p>
</div>
{/* 主要内容区域 */}
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
{/* 视频播放器区域 */}
<div className="aspect-video bg-black relative">
{(!state.video.url || !state.subtitle.url || state.subtitle.data.length === 0) && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-75 z-10">
<div className="text-center text-white">
<p className="text-lg mb-2">
{!state.video.url && !state.subtitle.url
? srtT("uploadVideoAndSubtitle")
: !state.video.url
? srtT("uploadVideoFile")
: !state.subtitle.url
? srtT("uploadSubtitleFile")
: srtT("processingSubtitle")
}
</p>
{(!state.video.url || !state.subtitle.url) && (
<p className="text-sm text-gray-300">
{srtT("needBothFiles")}
</p>
)}
</div>
</div>
)}
{state.video.url && (
<VideoPlayer
ref={videoRef}
src={state.video.url}
{...videoEventHandlers}
className="w-full h-full"
>
{state.subtitle.url && state.subtitle.data.length > 0 && (
<SubtitleArea
subtitle={state.subtitle.currentText}
settings={state.subtitle.settings}
className="absolute bottom-0 left-0 right-0 px-4 py-2"
/>
)}
</VideoPlayer>
)}
</div>
{/* 控制面板 */}
<div className="p-3 bg-gray-50 border-t">
{/* 上传区域和状态指示器 */}
<div className="mb-3">
<div className="flex gap-3">
<div className={`flex-1 p-2 rounded-lg border-2 transition-all ${state.video.url
? 'border-gray-800 bg-gray-100'
: 'border-gray-300 bg-white'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Video className="w-5 h-5 text-gray-600" />
<div className="text-left">
<h3 className="font-semibold text-gray-800 text-sm">{srtT("videoFile")}</h3>
<p className="text-xs text-gray-600">
{state.video.url ? srtT("uploaded") : srtT("notUploaded")}
</p>
</div>
</div>
<DarkButton
onClick={state.video.url ? undefined : handleVideoUpload}
disabled={!!state.video.url}
className="px-2 py-1 text-xs"
>
{state.video.url ? srtT("uploaded") : srtT("upload")}
</DarkButton>
</div>
</div>
<div className={`flex-1 p-2 rounded-lg border-2 transition-all ${state.subtitle.url
? 'border-gray-800 bg-gray-100'
: 'border-gray-300 bg-white'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileText className="w-5 h-5 text-gray-600" />
<div className="text-left">
<h3 className="font-semibold text-gray-800 text-sm">{srtT("subtitleFile")}</h3>
<p className="text-xs text-gray-600">
{state.subtitle.url ? srtT("uploaded") : srtT("notUploaded")}
</p>
</div>
</div>
<DarkButton
onClick={state.subtitle.url ? undefined : handleSubtitleUpload}
disabled={!!state.subtitle.url}
className="px-2 py-1 text-xs"
>
{state.subtitle.url ? srtT("uploaded") : srtT("upload")}
</DarkButton>
</div>
</div>
</div>
</div>
{/* 控制按钮和进度条 */}
<div className={`space-y-4 ${canPlay ? '' : 'opacity-50 pointer-events-none'}`}>
{/* 控制按钮 */}
<ControlBar
isPlaying={state.video.isPlaying}
onPlayPause={actions.togglePlayPause}
onPrevious={actions.previousSubtitle}
onNext={actions.nextSubtitle}
onRestart={actions.restartSubtitle}
playbackRate={state.video.playbackRate}
onPlaybackRateChange={actions.setPlaybackRate}
autoPause={state.controls.autoPause}
onAutoPauseToggle={actions.toggleAutoPause}
disabled={!canPlay}
className="justify-center"
/>
{/* 进度条 */}
<div className="space-y-2">
<SeekBar
value={state.subtitle.currentIndex ?? 0}
max={Math.max(0, state.subtitle.data.length - 1)}
onChange={handleSeek}
disabled={!canPlay}
className="h-3"
/>
{/* 字幕进度显示 */}
<div className="flex justify-between items-center text-sm text-gray-600 px-2">
<span>
{state.subtitle.currentIndex !== null ?
`${state.subtitle.currentIndex + 1}/${state.subtitle.data.length}` :
'0/0'
}
</span>
<div className="flex items-center gap-4">
{/* 播放速度显示 */}
<span className="bg-gray-200 px-2 py-1 rounded text-xs">
{state.video.playbackRate}x
</span>
{/* 自动暂停状态 */}
<span className={`px-2 py-1 rounded text-xs ${state.controls.autoPause
? 'bg-gray-800 text-white'
: 'bg-gray-100 text-gray-600'
}`}>
{srtT("autoPauseStatus", { enabled: state.controls.autoPause ? srtT("on") : srtT("off") })}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,65 @@
export interface ControlState {
autoPause: boolean;
showShortcuts: boolean;
showSettings: boolean;
}
export interface ControlActions {
toggleAutoPause: () => void;
toggleShortcuts: () => void;
toggleSettings: () => void;
}
export interface ControlBarProps {
isPlaying: boolean;
onPlayPause: () => void;
onPrevious: () => void;
onNext: () => void;
onRestart: () => void;
playbackRate: number;
onPlaybackRateChange: (rate: number) => void;
autoPause: boolean;
onAutoPauseToggle: () => void;
disabled?: boolean;
className?: string;
}
export interface NavigationButtonProps {
onClick: () => void;
disabled?: boolean;
className?: string;
children: React.ReactNode;
}
export interface AutoPauseToggleProps {
enabled: boolean;
onToggle: () => void;
disabled?: boolean;
className?: string;
}
export interface KeyboardShortcut {
key: string;
description: string;
action: () => void;
}
export interface ShortcutHintProps {
shortcuts: KeyboardShortcut[];
visible: boolean;
onClose: () => void;
className?: string;
}
export interface FileUploadProps {
onVideoUpload: (url: string) => void;
onSubtitleUpload: (url: string) => void;
className?: string;
}
export interface FileInputProps {
accept: string;
onFileSelect: (file: File) => void;
disabled?: boolean;
className?: string;
}

View File

@@ -0,0 +1,57 @@
export interface VideoState {
url: string | null;
isPlaying: boolean;
currentTime: number;
duration: number;
playbackRate: number;
volume: number;
}
export interface VideoControls {
play: () => void;
pause: () => void;
togglePlayPause: () => void;
seek: (time: number) => void;
setPlaybackRate: (rate: number) => void;
setVolume: (volume: number) => void;
restart: () => void;
}
export interface VideoElementProps {
src?: string;
onTimeUpdate?: (time: number) => void;
onLoadedMetadata?: (duration: number) => void;
onPlay?: () => void;
onPause?: () => void;
onEnded?: () => void;
className?: string;
}
export interface PlayButtonProps {
isPlaying: boolean;
onToggle: () => void;
disabled?: boolean;
className?: string;
}
export interface SeekBarProps {
value: number;
max: number;
onChange: (value: number) => void;
disabled?: boolean;
className?: string;
}
export interface SpeedControlProps {
playbackRate: number;
onPlaybackRateChange: (rate: number) => void;
disabled?: boolean;
className?: string;
}
export interface VolumeControlProps {
volume: number;
onVolumeChange: (volume: number) => void;
disabled?: boolean;
className?: string;
}

View File

@@ -0,0 +1,59 @@
export interface SubtitleEntry {
start: number;
end: number;
text: string;
index: number;
}
export interface SubtitleState {
url: string | null;
data: SubtitleEntry[];
currentText: string;
currentIndex: number | null;
settings: SubtitleSettings;
}
export interface SubtitleSettings {
fontSize: number;
backgroundColor: string;
textColor: string;
position: 'top' | 'center' | 'bottom';
fontFamily: string;
opacity: number;
}
export interface SubtitleDisplayProps {
subtitle: string;
onWordClick?: (word: string) => void;
settings?: SubtitleSettings;
className?: string;
}
export interface SubtitleTextProps {
text: string;
onWordClick?: (word: string) => void;
style?: React.CSSProperties;
className?: string;
}
export interface SubtitleSettingsProps {
settings: SubtitleSettings;
onSettingsChange: (settings: SubtitleSettings) => void;
className?: string;
}
export interface SubtitleControls {
next: () => void;
previous: () => void;
goToIndex: (index: number) => void;
toggleAutoPause: () => void;
}
export interface SubtitleSyncProps {
subtitles: SubtitleEntry[];
currentTime: number;
isPlaying: boolean;
autoPause: boolean;
onSubtitleChange: (subtitle: SubtitleEntry | null) => void;
onAutoPauseTrigger?: (subtitle: SubtitleEntry) => void;
}

View File

@@ -0,0 +1,99 @@
import { SubtitleEntry } from "../types/subtitle";
export function parseSrt(data: string): SubtitleEntry[] {
const lines = data.split(/\r?\n/);
const result: SubtitleEntry[] = [];
const re = new RegExp(
"(\\d{2}:\\d{2}:\\d{2},\\d{3})\\s*-->\\s*(\\d{2}:\\d{2}:\\d{2},\\d{3})",
);
let i = 0;
while (i < lines.length) {
if (!lines[i].trim()) {
i++;
continue;
}
i++;
if (i >= lines.length) break;
const timeMatch = lines[i].match(re);
if (!timeMatch) {
i++;
continue;
}
const start = toSeconds(timeMatch[1]);
const end = toSeconds(timeMatch[2]);
i++;
let text = "";
while (i < lines.length && lines[i].trim()) {
text += lines[i] + "\n";
i++;
}
result.push({
start,
end,
text: text.trim(),
index: result.length,
});
i++;
}
return result;
}
export function getSubtitleIndex(
subtitles: SubtitleEntry[],
currentTime: number,
): number | null {
for (let i = 0; i < subtitles.length; i++) {
if (currentTime >= subtitles[i].start && currentTime <= subtitles[i].end) {
return i;
}
}
return null;
}
export function getNearestIndex(
subtitles: SubtitleEntry[],
currentTime: number,
): number | null {
for (let i = 0; i < subtitles.length; i++) {
const subtitle = subtitles[i];
const isBefore = currentTime - subtitle.start >= 0;
const isAfter = currentTime - subtitle.end >= 0;
if (!isBefore || !isAfter) return i - 1;
if (isBefore && !isAfter) return i;
}
return null;
}
export function getCurrentSubtitle(
subtitles: SubtitleEntry[],
currentTime: number,
): SubtitleEntry | null {
return subtitles.find((subtitle) =>
currentTime >= subtitle.start && currentTime <= subtitle.end
) || null;
}
function toSeconds(timeStr: string): number {
const [h, m, s] = timeStr.replace(",", ".").split(":");
return parseFloat(
(parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s)).toFixed(3),
);
}
export async function loadSubtitle(url: string): Promise<SubtitleEntry[]> {
try {
const response = await fetch(url);
const data = await response.text();
return parseSrt(data);
} catch (error) {
console.error('Failed to load subtitle:', error);
return [];
}
}

View File

@@ -0,0 +1,48 @@
export function formatTime(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
export function timeToSeconds(timeStr: string): number {
const parts = timeStr.split(':');
if (parts.length === 3) {
// HH:MM:SS format
const [h, m, s] = parts;
return parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s);
} else if (parts.length === 2) {
// MM:SS format
const [m, s] = parts;
return parseInt(m) * 60 + parseFloat(s);
}
return 0;
}
export function secondsToTime(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
const ms = Math.floor((seconds % 1) * 1000);
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')},${ms.toString().padStart(3, '0')}`;
}
export function clampTime(time: number, min: number = 0, max: number = Infinity): number {
return Math.min(Math.max(time, min), max);
}
export function getPlaybackRateOptions(): number[] {
return [0.5, 0.7, 1.0, 1.2, 1.5, 2.0];
}
export function getPlaybackRateLabel(rate: number): string {
return `${rate}x`;
}

View File

@@ -1,12 +1,15 @@
"use client";
import { getTextSpeakerData, setTextSpeakerData } from "@/utils";
import { useState } from "react";
import z from "zod";
import { TextSpeakerItemSchema } from "@/interfaces";
import IconClick from "@/components/IconClick";
import {
TextSpeakerArraySchema,
TextSpeakerItemSchema,
} from "@/lib/interfaces";
import IconClick from "@/components/ui/buttons/IconClick";
import IMAGES from "@/config/images";
import { useTranslations } from "next-intl";
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
interface TextCardProps {
item: z.infer<typeof TextSpeakerItemSchema>;
@@ -48,24 +51,30 @@ interface SaveListProps {
handleUse: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
}
export default function SaveList({ show = false, handleUse }: SaveListProps) {
const t = useTranslations("text-speaker");
const [data, setData] = useState(getTextSpeakerData());
const t = useTranslations("text_speaker");
const { get: getFromLocalStorage, set: setIntoLocalStorage } =
getLocalStorageOperator<typeof TextSpeakerArraySchema>(
"text-speaker",
TextSpeakerArraySchema,
);
const [data, setData] = useState(getFromLocalStorage());
const handleDel = (item: z.infer<typeof TextSpeakerItemSchema>) => {
const current_data = getTextSpeakerData();
const current_data = getFromLocalStorage();
current_data.splice(
current_data.findIndex((v) => v.text === item.text),
1,
);
setTextSpeakerData(current_data);
setIntoLocalStorage(current_data);
refresh();
};
const refresh = () => {
setData(getTextSpeakerData());
setData(getFromLocalStorage());
};
const handleDeleteAll = () => {
const yesorno = prompt(t("confirmDeleteAll"))?.trim();
if (yesorno && (yesorno === "Y" || yesorno === "y")) {
setTextSpeakerData([]);
setIntoLocalStorage([]);
refresh();
}
};

View File

@@ -1,24 +1,25 @@
"use client";
import LightButton from "@/components/buttons/LightButton";
import IconClick from "@/components/IconClick";
import LightButton from "@/components/ui/buttons/LightButton";
import IconClick from "@/components/ui/buttons/IconClick";
import IMAGES from "@/config/images";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import {
getTextSpeakerData,
getTTSAudioUrl,
setTextSpeakerData,
} from "@/utils";
TextSpeakerArraySchema,
TextSpeakerItemSchema,
} from "@/lib/interfaces";
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";
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
import { getTTSAudioUrl } from "@/lib/browser/tts";
import { genIPA, genLocale } from "@/lib/server/translatorActions";
export default function TextSpeaker() {
const t = useTranslations("text-speaker");
export default function TextSpeakerPage() {
const t = useTranslations("text_speaker");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [showSpeedAdjust, setShowSpeedAdjust] = useState(false);
const [showSaveList, setShowSaveList] = useState(false);
@@ -33,6 +34,13 @@ 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;
@@ -88,14 +96,9 @@ export default function TextSpeaker() {
let theLocale = locale;
if (!theLocale) {
console.log("downloading text info");
const params = new URLSearchParams({
text: textRef.current.slice(0, 30),
});
const textinfo = await (
await fetch(`/api/locale?${params}`)
).json();
setLocale(textinfo.locale);
theLocale = textinfo.locale as string;
const tmp_locale = await genLocale(textRef.current.slice(0, 30));
setLocale(tmp_locale);
theLocale = tmp_locale;
}
const voice = VOICES.find((v) => v.locale.startsWith(theLocale));
@@ -178,32 +181,26 @@ export default function TextSpeaker() {
let theLocale = locale;
if (!theLocale) {
console.log("downloading text info");
const params = new URLSearchParams({
text: textRef.current.slice(0, 30),
});
const textinfo = await (await fetch(`/api/locale?${params}`)).json();
setLocale(textinfo.locale);
theLocale = textinfo.locale as string;
const tmp_locale = await genLocale(textRef.current.slice(0, 30));
setLocale(tmp_locale);
theLocale = tmp_locale;
}
let theIPA = ipa;
if (ipa.length === 0 && ipaEnabled) {
const params = new URLSearchParams({
text: textRef.current,
});
const tmp = await (await fetch(`/api/ipa?${params}`)).json();
setIPA(tmp.ipa);
theIPA = tmp.ipa;
const tmp_ipa = await genIPA(textRef.current);
setIPA(tmp_ipa);
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 +215,7 @@ export default function TextSpeaker() {
ipa: theIPA,
});
}
setTextSpeakerData(save);
setIntoLocalStorage(save);
} catch (e) {
console.error(e);
setLocale(null);
@@ -229,9 +226,8 @@ export default function TextSpeaker() {
return (
<>
<Navbar></Navbar>
<div
className="my-4 p-4 mx-4 md:mx-32 border-1 border-gray-200 rounded-2xl"
className="my-4 p-4 mx-4 md:mx-32 border border-gray-200 rounded-2xl"
style={{ fontFamily: "Times New Roman, serif" }}
>
<textarea

View File

@@ -0,0 +1,88 @@
"use client";
import LightButton from "@/components/ui/buttons/LightButton";
import Container from "@/components/ui/Container";
import { TranslationHistorySchema } from "@/lib/interfaces";
import { Dispatch, useEffect, useState } from "react";
import z from "zod";
import { Folder } from "../../../../generated/prisma/browser";
import { getFoldersByUserId } from "@/lib/server/services/folderService";
import { Folder as Fd } from "lucide-react";
import { createPair } from "@/lib/server/services/pairService";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { authClient } from "@/lib/auth-client";
interface AddToFolderProps {
item: z.infer<typeof TranslationHistorySchema>;
setShow: Dispatch<React.SetStateAction<boolean>>;
}
const AddToFolder: React.FC<AddToFolderProps> = ({ item, setShow }) => {
const { data: session } = authClient.useSession();
const [folders, setFolders] = useState<Folder[]>([]);
const t = useTranslations("translator.add_to_folder");
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!session) return;
const userId = session.user.id;
getFoldersByUserId(userId)
.then(setFolders)
.then(() => setLoading(false));
}, [session]);
if (!session) {
return (
<div className="fixed left-0 top-0 z-50 w-screen h-screen bg-black/50 flex justify-center items-center">
<Container className="p-6">
<div>{t("notAuthenticated")}</div>
</Container>
</div>
);
}
return (
<div className="fixed left-0 top-0 z-50 w-screen h-screen bg-black/50 flex justify-center items-center">
<Container className="p-6">
<h1>{t("chooseFolder")}</h1>
<div className="border border-gray-200 rounded-2xl">
{(loading && <span>...</span>) ||
(folders.length > 0 &&
folders.map((folder) => (
<button
key={folder.id}
className="p-2 flex items-center justify-start hover:bg-gray-50 gap-2 hover:cursor-pointer w-full border-b border-gray-200"
onClick={() => {
createPair({
text1: item.text1,
text2: item.text2,
locale1: item.locale1,
locale2: item.locale2,
folder: {
connect: {
id: folder.id,
},
},
})
.then(() => {
toast.success(t("success"));
setShow(false);
})
.catch(() => {
toast.error(t("error"));
});
}}
>
<Fd />
{t("folderInfo", { id: folder.id, name: folder.name })}
</button>
))) || <div>{t("noFolders")}</div>}
</div>
<LightButton onClick={() => setShow(false)}>{t("close")}</LightButton>
</Container>
</div>
);
};
export default AddToFolder;

View File

@@ -0,0 +1,57 @@
import Container from "@/components/ui/Container";
import { useEffect, useState } from "react";
import { Folder } from "../../../../generated/prisma/browser";
import { getFoldersByUserId } from "@/lib/server/services/folderService";
import LightButton from "@/components/ui/buttons/LightButton";
import { Folder as Fd } from "lucide-react";
interface FolderSelectorProps {
setSelectedFolderId: (id: number) => void;
userId: string;
cancel: () => void;
}
const FolderSelector: React.FC<FolderSelectorProps> = ({
setSelectedFolderId,
userId,
cancel,
}) => {
const [loading, setLoading] = useState(false);
const [folders, setFolders] = useState<Folder[]>([]);
useEffect(() => {
getFoldersByUserId(userId)
.then(setFolders)
.then(() => setLoading(false));
}, [userId]);
return (
<div
className={`bg-black/50 fixed inset-0 z-50 flex justify-center items-center`}
>
<Container className="p-6">
{(loading && <p>Loading...</p>) ||
(folders.length > 0 && (
<>
<h1>Select a Folder</h1>
<div className="m-2 border-gray-200 border rounded-2xl max-h-96 overflow-y-auto">
{folders.map((folder) => (
<button
className="p-2 w-full flex hover:bg-gray-50 gap-2"
key={folder.id}
onClick={() => setSelectedFolderId(folder.id)}
>
<Fd />
{folder.id}. {folder.name}
</button>
))}
</div>
</>
)) || <p>No folders found</p>}
<LightButton onClick={cancel}>Cancel</LightButton>
</Container>
</div>
);
};
export default FolderSelector;

View File

@@ -0,0 +1,379 @@
"use client";
import LightButton from "@/components/ui/buttons/LightButton";
import IconClick from "@/components/ui/buttons/IconClick";
import IMAGES from "@/config/images";
import { VOICES } from "@/config/locales";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { TranslationHistorySchema } from "@/lib/interfaces";
import { tlsoPush, tlso } from "@/lib/browser/localStorageOperators";
import { getTTSAudioUrl } from "@/lib/browser/tts";
import { Plus, Trash } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";
import z from "zod";
import AddToFolder from "./AddToFolder";
import {
genIPA,
genLocale,
genTranslation,
} from "@/lib/server/translatorActions";
import { toast } from "sonner";
import FolderSelector from "./FolderSelector";
import { createPair } from "@/lib/server/services/pairService";
import { shallowEqual } from "@/lib/utils";
import { authClient } from "@/lib/auth-client";
export default function TranslatorPage() {
const t = useTranslations("translator");
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 [history, setHistory] = useState<
z.infer<typeof TranslationHistorySchema>[]
>([]);
const [showAddToFolder, setShowAddToFolder] = useState(false);
const [addToFolderItem, setAddToFolderItem] = useState<z.infer<
typeof TranslationHistorySchema
> | null>(null);
const lastTTS = useRef({
text: "",
url: "",
});
const [autoSave, setAutoSave] = useState(false);
const [autoSaveFolderId, setAutoSaveFolderId] = useState<number | null>(null);
const { data: session } = authClient.useSession();
useEffect(() => {
setHistory(tlso.get());
}, []);
const tts = async (text: string, locale: string) => {
if (lastTTS.current.text !== text) {
const shortName = VOICES.find((v) => v.locale === locale)?.short_name;
if (!shortName) {
toast.error("Voice not found");
return;
}
try {
const url = await getTTSAudioUrl(text, shortName);
await load(url);
lastTTS.current.text = text;
lastTTS.current.url = url;
} catch (error) {
toast.error("Failed to generate audio");
console.error(error);
}
}
await play();
};
const translate = async () => {
if (!taref.current) return;
if (processing) return;
setProcessing(true);
const text1 = taref.current.value;
const llmres: {
text1: string | null;
text2: string | null;
locale1: string | null;
locale2: string | null;
ipa1: string | null;
ipa2: string | null;
} = {
text1: text1,
text2: null,
locale1: null,
locale2: null,
ipa1: null,
ipa2: null,
};
let historyUpdated = false;
// 检查更新历史记录
const checkUpdateLocalStorage = () => {
if (historyUpdated) return;
if (llmres.text1 && llmres.text2 && llmres.locale1 && llmres.locale2) {
setHistory(
tlsoPush({
text1: llmres.text1,
text2: llmres.text2,
locale1: llmres.locale1,
locale2: llmres.locale2,
}),
);
if (autoSave && autoSaveFolderId) {
createPair({
text1: llmres.text1,
text2: llmres.text2,
locale1: llmres.locale1,
locale2: llmres.locale2,
folder: {
connect: {
id: autoSaveFolderId,
},
},
})
.then(() => {
toast.success(
llmres.text1 + "保存到文件夹" + autoSaveFolderId + "成功",
);
})
.catch((error) => {
toast.error(
llmres.text1 +
"保存到文件夹" +
autoSaveFolderId +
"失败:" +
error.message,
);
});
}
historyUpdated = true;
}
};
// 更新局部翻译状态
const updateState = (stateName: keyof typeof llmres, value: string) => {
llmres[stateName] = value;
checkUpdateLocalStorage();
};
genTranslation(text1, lang)
.then(async (text2) => {
updateState("text2", text2);
setTresult(text2);
// 生成两个locale
genLocale(text1).then((locale) => {
updateState("locale1", locale);
});
genLocale(text2).then((locale) => {
updateState("locale2", locale);
});
// 生成俩IPA
if (genIpa) {
genIPA(text1).then((ipa1) => {
setIpaTexts((prev) => [ipa1, prev[1]]);
updateState("ipa1", ipa1);
});
genIPA(text2).then((ipa2) => {
setIpaTexts((prev) => [prev[0], ipa2]);
updateState("ipa2", ipa2);
});
}
})
.catch(() => {
toast.error("Translation failed");
})
.finally(() => {
setProcessing(false);
});
};
return (
<>
{/* TCard Component */}
<div className="w-screen flex flex-col md:flex-row md:justify-between gap-2 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();
}}
></textarea>
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
{ipaTexts[0]}
</div>
<div className="h-2/12 w-full flex justify-end items-center">
<IconClick
src={IMAGES.copy_all}
alt="copy"
onClick={async () => {
await navigator.clipboard.writeText(
taref.current?.value || "",
);
}}
></IconClick>
<IconClick
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={genIpa}
onClick={() => setGenIpa((prev) => !prev)}
>
{t("generateIPA")}
</LightButton>
</div>
</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-2/3 w-full overflow-y-auto">{tresult}</div>
<div className="ipa w-full h-1/6 overflow-y-auto text-gray-600">
{ipaTexts[1]}
</div>
<div className="h-1/6 w-full flex justify-end items-center">
<IconClick
src={IMAGES.copy_all}
alt="copy"
onClick={async () => {
await navigator.clipboard.writeText(tresult);
}}
></IconClick>
<IconClick
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
selected={lang === "chinese"}
onClick={() => setLang("chinese")}
>
{t("chinese")}
</LightButton>
<LightButton
selected={lang === "english"}
onClick={() => setLang("english")}
>
{t("english")}
</LightButton>
<LightButton
selected={lang === "italian"}
onClick={() => setLang("italian")}
>
{t("italian")}
</LightButton>
<LightButton
selected={!["chinese", "english", "italian"].includes(lang)}
onClick={() => {
const newLang = prompt(t("enterLanguage"));
if (newLang) {
setLang(newLang);
}
}}
>
{t("other")}
</LightButton>
</div>
</div>
</div>
{/* 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}
>
{t("translate")}
</button>
</div>
{/* AutoSave Component */}
<div className="w-screen flex justify-center items-center">
<label className="flex items-center">
<input
type="checkbox"
checked={autoSave}
onChange={(e) => {
const checked = e.target.checked;
if (checked === true && !session) {
toast.warning("Please login to enable auto-save");
return;
}
if (checked === false) setAutoSaveFolderId(null);
setAutoSave(checked);
}}
className="mr-2"
/>
{t("autoSave")}
{autoSaveFolderId ? ` (${autoSaveFolderId})` : ""}
</label>
</div>
{history.length > 0 && (
<div className="m-6 flex flex-col items-center">
<h1 className="text-2xl font-light">{t("history")}</h1>
<div className="border border-gray-200 rounded-2xl m-4">
{history.toReversed().map((item, index) => (
<div
key={index}
className="border-b border-gray-200 p-2 group hover:bg-gray-50 flex gap-2 flex-row justify-between items-start"
>
<div className="flex-1 flex flex-col">
<p className="text-sm font-light">{item.text1}</p>
<p className="text-sm font-light">{item.text2}</p>
</div>
<div className="flex gap-2">
<button
onClick={() => {
setShowAddToFolder(true);
setAddToFolderItem(item);
}}
className="hover:bg-gray-200 hover:cursor-pointer rounded-4xl border border-gray-200 w-8 h-8 flex justify-center items-center"
>
<Plus />
</button>
<button
onClick={() => {
setHistory(
tlso.set(
tlso.get().filter((v) => !shallowEqual(v, item)),
) || [],
);
}}
className="hover:bg-gray-200 hover:cursor-pointer rounded-4xl border border-gray-200 w-8 h-8 flex justify-center items-center"
>
<Trash />
</button>
</div>
</div>
))}
</div>
{showAddToFolder && (
<AddToFolder setShow={setShowAddToFolder} item={addToFolderItem!} />
)}
{autoSave && !autoSaveFolderId && (
<FolderSelector
userId={session!.user.id as string}
cancel={() => setAutoSave(false)}
setSelectedFolderId={(id) => setAutoSaveFolderId(id)}
/>
)}
</div>
)}
</>
);
}

View File

@@ -1,99 +0,0 @@
"use client";
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() {
const t = useTranslations("alphabet");
const [chosenAlphabet, setChosenAlphabet] =
useState<SupportedAlphabets | null>(null);
const [alphabetData, setAlphabetData] = useState<
Record<SupportedAlphabets, Letter[] | null>
>({
japanese: null,
english: null,
esperanto: null,
uyghur: null,
});
const [loadingState, setLoadingState] = useState<
"idle" | "loading" | "success" | "error"
>("idle");
useEffect(() => {
if (chosenAlphabet && !alphabetData[chosenAlphabet]) {
setLoadingState("loading");
fetch("/alphabets/" + chosenAlphabet + ".json")
.then((res) => {
if (!res.ok) throw new Error("Network response was not ok");
return res.json();
})
.then((obj) => {
setAlphabetData((prev) => ({
...prev,
[chosenAlphabet]: obj as Letter[],
}));
setLoadingState("success");
})
.catch(() => {
setLoadingState("error");
});
}
}, [chosenAlphabet, alphabetData]);
useEffect(() => {
if (loadingState === "error") {
const timer = setTimeout(() => {
setLoadingState("idle");
setChosenAlphabet(null);
}, 2000);
return () => clearTimeout(timer);
}
}, [loadingState]);
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">
<LightButton onClick={() => setChosenAlphabet("japanese")}>
{t("japanese")}
</LightButton>
<LightButton onClick={() => setChosenAlphabet("english")}>
{t("english")}
</LightButton>
<LightButton onClick={() => setChosenAlphabet("uyghur")}>
{t("uyghur")}
</LightButton>
<LightButton onClick={() => setChosenAlphabet("esperanto")}>
{t("esperanto")}
</LightButton>
</div>
</div>
</>
);
if (loadingState === "loading") {
return t("loading");
}
if (loadingState === "error") {
return t("loadFailed");
}
if (loadingState === "success" && alphabetData[chosenAlphabet]) {
return (
<>
<Navbar></Navbar>
<MemoryCard
alphabet={alphabetData[chosenAlphabet]}
setChosenAlphabet={setChosenAlphabet}
></MemoryCard>
</>
);
}
return null;
}

View File

@@ -0,0 +1,4 @@
import { auth } from "@/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { POST, GET } = toNextJsHandler(auth);

View File

@@ -1,62 +0,0 @@
import { callZhipuAPI, handleAPIError } from "@/utils";
import { NextRequest, NextResponse } from "next/server";
async function getIPA(text: string) {
console.log(`get ipa of ${text}`);
const messages = [
{
role: "user",
content: `
请推断以下文本的语言生成对应的宽式国际音标IPA以及locale以JSON格式返回
[${text}]
结果如:
{
"ipa": "[ni˨˩˦ xɑʊ˨˩˦]",
"locale": "zh-CN"
}
注意:
直接返回json文本
ipa一定要加[]
locale如果可能有多个选取最可能的一个其中使用符号"-"
locale如果推断失败就返回{"locale": "en-US"}
`,
},
];
try {
const response = await callZhipuAPI(messages);
let to_parse = response.choices[0].message.content.trim() as string;
if (to_parse.startsWith("`"))
to_parse = to_parse.slice(7, to_parse.length - 3);
if (to_parse.length === 0) throw Error("ai啥也每说");
return JSON.parse(to_parse);
} catch (error) {
console.error(error);
return null;
}
}
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const text = searchParams.get("text");
if (!text) {
return NextResponse.json(
{ error: "查询参数错误", message: "text参数是必需的" },
{ status: 400 },
);
}
const textInfo = await getIPA(text);
if (!textInfo) {
return NextResponse.json(
{ error: "服务暂时不可用", message: "LLM API 请求失败" },
{ status: 503 },
);
}
return NextResponse.json(textInfo, { status: 200 });
} catch (error) {
handleAPIError(error, "请稍后再试");
}
}

View File

@@ -1,64 +0,0 @@
import { callZhipuAPI } from "@/utils";
import { NextRequest, NextResponse } from "next/server";
async function getLocale(text: string) {
console.log(`get locale of ${text}`);
const messages = [
{
role: "user",
content: `
请推断以下文本的的locale以JSON格式返回
[${text}]
结果如:
{
"locale": "zh-CN"
}
注意:
直接返回json文本
locale如果可能有多个选取最可能的一个其中使用符号"-"
locale如果推断失败就返回{"locale": "en-US"}
`,
},
];
try {
const response = await callZhipuAPI(messages);
let to_parse = response.choices[0].message.content.trim() as string;
if (to_parse.startsWith("`"))
to_parse = to_parse.slice(7, to_parse.length - 3);
if (to_parse.length === 0) throw Error("ai啥也每说");
return JSON.parse(to_parse);
} catch (error) {
console.error(error);
return null;
}
}
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const text = searchParams.get("text");
if (!text) {
return NextResponse.json(
{ error: "查询参数错误", message: "text参数是必需的" },
{ status: 400 },
);
}
const textInfo = await getLocale(text.slice(0, 30));
if (!textInfo) {
return NextResponse.json(
{ error: "服务暂时不可用", message: "LLM API 请求失败" },
{ status: 503 },
);
}
return NextResponse.json(textInfo, { status: 200 });
} catch (error) {
console.error("API 错误:", error);
return NextResponse.json(
{ error: "服务器内部错误", message: "请稍后重试" },
{ status: 500 },
);
}
}

View File

@@ -1,12 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const url = request.url;
return NextResponse.json(
{
message: "Hello World",
url: url,
},
{ status: 200 },
);
}

View File

@@ -1,66 +0,0 @@
import { callZhipuAPI } from "@/utils";
import { NextRequest, NextResponse } from "next/server";
async function translate(text: string, target_lang: string) {
console.log(`translate "${text}" into ${target_lang}`);
const messages = [
{
role: "user",
content: `
请推断以下文本的locale并翻译到目标语言${target_lang}同样需要locale信息以JSON格式返回
[${text}]
结果如:
{
"source_locale": "zh-CN",
"target_locale": "de-DE",
"target_text": "Halo"
}
注意:
直接返回json文本
locale如果可能有多个选取最可能的一个其中使用符号"-"
locale如果推断失败就当作是en-US
`,
},
];
try {
const response = await callZhipuAPI(messages);
let to_parse = response.choices[0].message.content.trim() as string;
if (to_parse.startsWith("`"))
to_parse = to_parse.slice(7, to_parse.length - 3);
if (to_parse.length === 0) throw Error("ai啥也每说");
return JSON.parse(to_parse);
} catch (error) {
console.error(error);
return null;
}
}
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const text = searchParams.get("text");
const target_lang = searchParams.get("target");
if (!text || !target_lang) {
return NextResponse.json(
{ error: "查询参数错误", message: "text参数, target参数是必需的" },
{ status: 400 },
);
}
const textInfo = await translate(text, target_lang);
if (!textInfo) {
return NextResponse.json(
{ error: "服务暂时不可用", message: "LLM API 请求失败" },
{ status: 503 },
);
}
return NextResponse.json(textInfo, { status: 200 });
} catch (error) {
console.error("API 错误:", error);
return NextResponse.json(
{ error: "服务器内部错误", message: "请稍后重试" },
{ status: 500 },
);
}
}

246
src/app/auth/AuthForm.tsx Normal file
View File

@@ -0,0 +1,246 @@
"use client";
import { useState, useActionState, startTransition } from "react";
import { useTranslations } from "next-intl";
import { signInAction, signUpAction, SignUpState } from "@/lib/actions/auth";
import Container from "@/components/ui/Container";
import Input from "@/components/ui/Input";
import LightButton from "@/components/ui/buttons/LightButton";
import DarkButton from "@/components/ui/buttons/DarkButton";
import { authClient } from "@/lib/auth-client";
interface AuthFormProps {
redirectTo?: string;
}
export default function AuthForm({ redirectTo }: AuthFormProps) {
const t = useTranslations("auth");
const [mode, setMode] = useState<'signin' | 'signup'>('signin');
const [clearSignIn, setClearSignIn] = useState(false);
const [clearSignUp, setClearSignUp] = useState(false);
const [signInState, signInActionForm, isSignInPending] = useActionState(
async (prevState: SignUpState | undefined, formData: FormData) => {
if (clearSignIn) {
setClearSignIn(false);
return undefined;
}
return signInAction(prevState || {}, formData);
},
undefined
);
const [signUpState, signUpActionForm, isSignUpPending] = useActionState(
async (prevState: SignUpState | undefined, formData: FormData) => {
if (clearSignUp) {
setClearSignUp(false);
return undefined;
}
return signUpAction(prevState || {}, formData);
},
undefined
);
const [errors, setErrors] = useState<Record<string, string>>({});
const validateForm = (formData: FormData): boolean => {
const newErrors: Record<string, string> = {};
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const name = formData.get("name") as string;
const confirmPassword = formData.get("confirmPassword") as string;
if (!email) {
newErrors.email = t("emailRequired");
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
newErrors.email = t("invalidEmail");
}
if (!password) {
newErrors.password = t("passwordRequired");
} else if (password.length < 8) {
newErrors.password = t("passwordTooShort");
}
if (mode === 'signup') {
if (!name) {
newErrors.name = t("nameRequired");
}
if (!confirmPassword) {
newErrors.confirmPassword = t("confirmPasswordRequired");
} else if (password !== confirmPassword) {
newErrors.confirmPassword = t("passwordsNotMatch");
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
// 基本客户端验证
if (!validateForm(formData)) {
return;
}
// 添加 redirectTo 到 formData
if (redirectTo) {
formData.append("redirectTo", redirectTo);
}
// 使用 startTransition 包装 action 调用
startTransition(() => {
// 根据模式调用相应的 action
if (mode === 'signin') {
signInActionForm(formData);
} else {
signUpActionForm(formData);
}
});
};
const handleGitHubSignIn = async () => {
await authClient.signIn.social({
provider: "github",
callbackURL: redirectTo || "/"
});
};
const currentError = mode === 'signin' ? signInState : signUpState;
return (
<div className="h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4">
<Container className="p-8 max-w-md w-full">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-800 mb-2">{t(mode === 'signin' ? 'signIn' : 'signUp')}</h1>
</div>
{currentError?.message && (
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
{currentError.message}
</div>
)}
<form onSubmit={handleFormSubmit} className="space-y-4">
{mode === 'signup' && (
<div>
<Input
type="text"
name="name"
placeholder={t("name")}
className="w-full px-3 py-2"
/>
{errors.name && (
<p className="text-red-500 text-sm mt-1">{errors.name}</p>
)}
{currentError?.errors?.username && (
<p className="text-red-500 text-sm mt-1">{currentError.errors.username[0]}</p>
)}
</div>
)}
<div>
<Input
type="email"
name="email"
placeholder={t("email")}
className="w-full px-3 py-2"
/>
{errors.email && (
<p className="text-red-500 text-sm mt-1">{errors.email}</p>
)}
{currentError?.errors?.email && (
<p className="text-red-500 text-sm mt-1">{currentError.errors.email[0]}</p>
)}
</div>
<div>
<Input
type="password"
name="password"
placeholder={t("password")}
className="w-full px-3 py-2"
/>
{errors.password && (
<p className="text-red-500 text-sm mt-1">{errors.password}</p>
)}
{currentError?.errors?.password && (
<p className="text-red-500 text-sm mt-1">{currentError.errors.password[0]}</p>
)}
</div>
{mode === 'signup' && (
<div>
<Input
type="password"
name="confirmPassword"
placeholder={t("confirmPassword")}
className="w-full px-3 py-2"
/>
{errors.confirmPassword && (
<p className="text-red-500 text-sm mt-1">{errors.confirmPassword}</p>
)}
</div>
)}
<DarkButton
type="submit"
className={`w-full py-2 ${isSignInPending || isSignUpPending ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{isSignInPending || isSignUpPending
? t("loading")
: t(mode === 'signin' ? 'signInButton' : 'signUpButton')
}
</DarkButton>
</form>
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500"></span>
</div>
</div>
<LightButton
onClick={handleGitHubSignIn}
className="w-full mt-4 py-2 flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
{t(mode === 'signin' ? 'signInWithGitHub' : 'signUpWithGitHub')}
</LightButton>
</div>
<div className="mt-6 text-center">
<button
type="button"
onClick={() => {
setMode(mode === 'signin' ? 'signup' : 'signin');
setErrors({});
// 清除服务器端错误状态
if (mode === 'signin') {
setClearSignIn(true);
} else {
setClearSignUp(true);
}
}}
className="text-[#35786f] hover:underline"
>
{mode === 'signin'
? `${t("noAccount")} ${t("signUp")}`
: `${t("hasAccount")} ${t("signIn")}`
}
</button>
</div>
</Container>
</div>
);
}

20
src/app/auth/page.tsx Normal file
View File

@@ -0,0 +1,20 @@
import { auth } from "@/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import AuthForm from "./AuthForm";
export default async function AuthPage(
props: {
searchParams: Promise<{ [key: string]: string | string[] | undefined; }>
}
) {
const searchParams = await props.searchParams;
const redirectTo = searchParams.redirect as string | undefined;
const session = await auth.api.getSession({ headers: await headers() });
if (session) {
redirect(redirectTo || '/');
}
return <AuthForm redirectTo={redirectTo} />;
}

View File

@@ -0,0 +1,171 @@
"use client";
import {
ChevronRight,
Folder as Fd,
FolderPen,
FolderPlus,
Trash2,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Center } from "@/components/common/Center";
import { useRouter } from "next/navigation";
import { Folder } from "../../../generated/prisma/browser";
import {
createFolder,
deleteFolderById,
getFoldersWithTotalPairsByUserId,
renameFolderById,
} from "@/lib/server/services/folderService";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
interface FolderProps {
folder: Folder & { total: number };
refresh: () => void;
}
const FolderCard = ({ folder, refresh }: FolderProps) => {
const router = useRouter();
const t = useTranslations("folders");
return (
<div
className="flex justify-between items-center group p-4 border-b border-gray-100 hover:bg-gray-50 cursor-pointer transition-colors"
onClick={() => {
router.push(`/folders/${folder.id}`);
}}
>
<div className="flex items-center gap-3 flex-1">
<div className="w-10 h-10 rounded-lg bg-linear-to-br from-blue-50 to-blue-100 flex items-center justify-center group-hover:from-blue-100 group-hover:to-blue-200 transition-colors">
<Fd></Fd>
</div>
<div className="flex-1">
<h3 className="font-medium text-gray-900">
{t("folderInfo", {
id: folder.id,
name: folder.name,
totalPairs: folder.total,
})}
</h3>
</div>
<div className="text-xs text-gray-400">#{folder.id}</div>
</div>
<div className="flex items-center gap-2 opacity-50 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => {
e.stopPropagation();
const newName = prompt("Input a new name.")?.trim();
if (newName && newName.length > 0) {
renameFolderById(folder.id, newName).then(refresh);
}
}}
className="p-2 text-gray-400 hover:bg-red-50 rounded-lg transition-colors"
>
<FolderPen size={16} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
const confirm = prompt(t("confirmDelete", { name: folder.name }));
if (confirm === folder.name) {
deleteFolderById(folder.id).then(refresh);
}
}}
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 size={16} />
</button>
<ChevronRight size={18} className="text-gray-400" />
</div>
</div>
);
};
export default function FoldersClient({ userId }: { userId: string }) {
const t = useTranslations("folders");
const [folders, setFolders] = useState<(Folder & { total: number })[]>(
[],
);
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
getFoldersWithTotalPairsByUserId(userId)
.then((folders) => {
setFolders(folders);
setLoading(false);
})
.catch((error) => {
console.error(error);
toast.error("加载出错,请重试。");
});
}, [userId]);
const updateFolders = async () => {
try {
const updatedFolders = await getFoldersWithTotalPairsByUserId(userId);
setFolders(updatedFolders);
} catch (error) {
console.error(error);
}
};
return (
<Center>
<div className="w-full max-w-2xl mx-auto bg-white border border-gray-200 rounded-2xl p-6">
<div className="mb-6">
<h1 className="text-2xl font-light text-gray-900">{t("title")}</h1>
<p className="text-sm text-gray-500 mt-1">{t("subtitle")}</p>
</div>
<button
onClick={async () => {
const folderName = prompt(t("enterFolderName"));
if (!folderName) return;
setLoading(true);
try {
await createFolder({
name: folderName,
user: { connect: { id: userId } },
});
await updateFolders();
} finally {
setLoading(false);
}
}}
disabled={loading}
className="w-full p-3 border-2 border-dashed border-gray-300 rounded-xl text-gray-500 hover:border-gray-400 hover:text-gray-600 transition-colors flex items-center justify-center gap-2"
>
<FolderPlus size={18} />
<span>{loading ? t("creating") : t("newFolder")}</span>
</button>
<div className="mt-4 max-h-96 overflow-y-auto">
{folders.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<div className="w-16 h-16 mx-auto mb-3 rounded-lg bg-gray-100 flex items-center justify-center">
<FolderPlus size={24} className="text-gray-400" />
</div>
<p className="text-sm">{t("noFoldersYet")}</p>
</div>
) : (
<div className="rounded-xl border border-gray-200 overflow-hidden">
{folders
.toSorted((a, b) => a.id - b.id)
.map((folder) => (
<FolderCard
key={folder.id}
folder={folder}
refresh={updateFolders}
/>
))}
</div>
)}
</div>
</div>
</Center>
);
}

View File

@@ -0,0 +1,105 @@
import LightButton from "@/components/ui/buttons/LightButton";
import Input from "@/components/ui/Input";
import { X } from "lucide-react";
import { useRef } from "react";
import { useTranslations } from "next-intl";
interface AddTextPairModalProps {
isOpen: boolean;
onClose: () => void;
onAdd: (
text1: string,
text2: string,
locale1: string,
locale2: string,
) => void;
}
export default function AddTextPairModal({
isOpen,
onClose,
onAdd,
}: AddTextPairModalProps) {
const t = useTranslations("folder_id");
const input1Ref = useRef<HTMLInputElement>(null);
const input2Ref = useRef<HTMLInputElement>(null);
const input3Ref = useRef<HTMLInputElement>(null);
const input4Ref = useRef<HTMLInputElement>(null);
if (!isOpen) return null;
const handleAdd = () => {
if (
!input1Ref.current?.value ||
!input2Ref.current?.value ||
!input3Ref.current?.value ||
!input4Ref.current?.value
)
return;
const text1 = input1Ref.current.value;
const text2 = input2Ref.current.value;
const locale1 = input3Ref.current.value;
const locale2 = input4Ref.current.value;
if (
typeof text1 === "string" &&
typeof text2 === "string" &&
typeof locale1 === "string" &&
typeof locale2 === "string" &&
text1.trim() !== "" &&
text2.trim() !== "" &&
locale1.trim() !== "" &&
locale2.trim() !== ""
) {
onAdd(text1, text2, locale1, locale2);
input1Ref.current.value = "";
input2Ref.current.value = "";
}
};
return (
<div
className="fixed inset-0 bg-black/50 backdrop-blur flex items-center justify-center z-50"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAdd();
}
}}
>
<div className="bg-white rounded-xl p-6 w-full max-w-md mx-4">
<div className="flex">
<h2 className="flex-1 text-xl font-light mb-4 text-center">
{t("addNewTextPair")}
</h2>
<X onClick={onClose} className="hover:cursor-pointer"></X>
</div>
<div>
<div>
{t("text1")}
<Input ref={input1Ref} className="w-full"></Input>
</div>
<div>
{t("text2")}
<Input ref={input2Ref} className="w-full"></Input>
</div>
<div>
{t("locale1")}
<Input
ref={input3Ref}
className="w-full"
placeholder="en-US"
></Input>
</div>
<div>
{t("locale2")}
<Input
ref={input4Ref}
className="w-full"
placeholder="zh-CN"
></Input>
</div>
</div>
<LightButton onClick={handleAdd}>{t("add")}</LightButton>
</div>
</div>
);
}

View File

@@ -0,0 +1,156 @@
"use client";
import { ArrowLeft, Plus } from "lucide-react";
import { Center } from "@/components/common/Center";
import { useEffect, useState } from "react";
import { redirect, useRouter } from "next/navigation";
import Container from "@/components/ui/Container";
import {
createPair,
deletePairById,
getPairsByFolderId,
} from "@/lib/server/services/pairService";
import AddTextPairModal from "./AddTextPairModal";
import TextPairCard from "./TextPairCard";
import LightButton from "@/components/ui/buttons/LightButton";
import { useTranslations } from "next-intl";
export interface TextPair {
id: number;
text1: string;
text2: string;
locale1: string;
locale2: string;
}
export default function InFolder({ folderId }: { folderId: number }) {
const [textPairs, setTextPairs] = useState<TextPair[]>([]);
const [loading, setLoading] = useState(true);
const [openAddModal, setAddModal] = useState(false);
const router = useRouter();
const t = useTranslations("folder_id");
useEffect(() => {
const fetchTextPairs = async () => {
setLoading(true);
try {
const data = await getPairsByFolderId(folderId);
setTextPairs(data as TextPair[]);
} catch (error) {
console.error("Failed to fetch text pairs:", error);
} finally {
setLoading(false);
}
};
fetchTextPairs();
}, [folderId]);
const refreshTextPairs = async () => {
try {
const data = await getPairsByFolderId(folderId);
setTextPairs(data as TextPair[]);
} catch (error) {
console.error("Failed to fetch text pairs:", error);
}
};
return (
<Center>
<Container className="p-6">
<div className="mb-6">
<button
onClick={router.back}
className="flex items-center gap-2 text-gray-500 hover:text-gray-700 transition-colors mb-4"
>
<ArrowLeft size={16} />
<span className="text-sm">{t("back")}</span>
</button>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-light text-gray-900">
{t("textPairs")}
</h1>
<p className="text-sm text-gray-500 mt-1">
{t("itemsCount", { count: textPairs.length })}
</p>
</div>
<div className="flex items-center gap-2">
<LightButton
onClick={() => {
redirect(`/memorize?folder_id=${folderId}`);
}}
>
{t("memorize")}
</LightButton>
<button
className="p-2 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
onClick={() => {
setAddModal(true);
}}
>
<Plus
size={18}
className="text-gray-600 hover:cursor-pointer"
/>
</button>
</div>
</div>
</div>
<div className="max-h-96 overflow-y-auto rounded-xl border border-gray-200 overflow-hidden">
{loading ? (
<div className="p-8 text-center">
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
<p className="text-sm text-gray-500">{t("loadingTextPairs")}</p>
</div>
) : textPairs.length === 0 ? (
<div className="p-12 text-center">
<p className="text-sm text-gray-500 mb-2">{t("noTextPairs")}</p>
</div>
) : (
<div className="divide-y divide-gray-100">
{textPairs
.toSorted((a, b) => a.id - b.id)
.map((textPair) => (
<TextPairCard
key={textPair.id}
textPair={textPair}
onDel={() => {
deletePairById(textPair.id);
refreshTextPairs();
}}
refreshTextPairs={refreshTextPairs}
/>
))}
</div>
)}
</div>
</Container>
<AddTextPairModal
isOpen={openAddModal}
onClose={() => setAddModal(false)}
onAdd={async (
text1: string,
text2: string,
locale1: string,
locale2: string,
) => {
await createPair({
text1: text1,
text2: text2,
locale1: locale1,
locale2: locale2,
folder: {
connect: {
id: folderId,
},
},
});
refreshTextPairs();
}}
/>
</Center>
);
}

View File

@@ -0,0 +1,78 @@
import { Edit, Trash2 } from "lucide-react";
import { TextPair } from "./InFolder";
import { updatePairById } from "@/lib/server/services/pairService";
import { useState } from "react";
import UpdateTextPairModal from "./UpdateTextPairModal";
import { useTranslations } from "next-intl";
import { PairUpdateInput } from "../../../../generated/prisma/models";
interface TextPairCardProps {
textPair: TextPair;
onDel: () => void;
refreshTextPairs: () => void;
}
export default function TextPairCard({
textPair,
onDel,
refreshTextPairs,
}: TextPairCardProps) {
const [openUpdateModal, setOpenUpdateModal] = useState(false);
const t = useTranslations("folder_id");
return (
<div className="group border-b border-gray-100 hover:bg-gray-50 transition-colors">
<div className="p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2 text-xs text-gray-500">
<span className="px-2 py-1 bg-gray-100 rounded-md">
{textPair.locale1.toUpperCase()}
</span>
<span></span>
<span className="px-2 py-1 bg-gray-100 rounded-md">
{textPair.locale2.toUpperCase()}
</span>
</div>
<div className="flex items-center gap-1 opacity-50 group-hover:opacity-100 transition-opacity">
<button
className="p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-md transition-colors"
onClick={() => setOpenUpdateModal(true)}
title={t("edit")}
>
<Edit size={14} />
</button>
<button
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-md transition-colors"
onClick={onDel}
title={t("delete")}
>
<Trash2 size={14} />
</button>
</div>
</div>
<div className="text-gray-900 grid grid-cols-2 gap-4 w-3/4">
<div>
{textPair.text1.length > 30
? textPair.text1.substring(0, 30) + "..."
: textPair.text1}
</div>
<div>
{textPair.text2.length > 30
? textPair.text2.substring(0, 30) + "..."
: textPair.text2}
</div>
</div>
</div>
<UpdateTextPairModal
isOpen={openUpdateModal}
onClose={() => setOpenUpdateModal(false)}
onUpdate={async (id: number, data: PairUpdateInput) => {
await updatePairById(id, data);
setOpenUpdateModal(false);
refreshTextPairs();
}}
textPair={textPair}
/>
</div>
);
}

View File

@@ -0,0 +1,112 @@
import LightButton from "@/components/ui/buttons/LightButton";
import Input from "@/components/ui/Input";
import { X } from "lucide-react";
import { useRef } from "react";
import { PairUpdateInput } from "../../../../generated/prisma/models";
import { TextPair } from "./InFolder";
import { useTranslations } from "next-intl";
interface UpdateTextPairModalProps {
isOpen: boolean;
onClose: () => void;
textPair: TextPair;
onUpdate: (id: number, tp: PairUpdateInput) => void;
}
export default function UpdateTextPairModal({
isOpen,
onClose,
onUpdate,
textPair,
}: UpdateTextPairModalProps) {
const t = useTranslations("folder_id");
const input1Ref = useRef<HTMLInputElement>(null);
const input2Ref = useRef<HTMLInputElement>(null);
const input3Ref = useRef<HTMLInputElement>(null);
const input4Ref = useRef<HTMLInputElement>(null);
if (!isOpen) return null;
const handleUpdate = () => {
if (
!input1Ref.current?.value ||
!input2Ref.current?.value ||
!input3Ref.current?.value ||
!input4Ref.current?.value
)
return;
const text1 = input1Ref.current.value;
const text2 = input2Ref.current.value;
const locale1 = input3Ref.current.value;
const locale2 = input4Ref.current.value;
if (
typeof text1 === "string" &&
typeof text2 === "string" &&
typeof locale1 === "string" &&
typeof locale2 === "string" &&
text1.trim() !== "" &&
text2.trim() !== "" &&
locale1.trim() !== "" &&
locale2.trim() !== ""
) {
onUpdate(textPair.id, { text1, text2, locale1, locale2 });
input1Ref.current.value = "";
input2Ref.current.value = "";
}
};
return (
<div
className="fixed inset-0 bg-black/50 backdrop-blur flex items-center justify-center z-50"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleUpdate();
}
}}
>
<div className="bg-white rounded-xl p-6 w-full max-w-md mx-4">
<div className="flex">
<h2 className="flex-1 text-xl font-light mb-4 text-center">
{t("updateTextPair")}
</h2>
<X onClick={onClose} className="hover:cursor-pointer"></X>
</div>
<div>
<div>
{t("text1")}
<Input
defaultValue={textPair.text1}
ref={input1Ref}
className="w-full"
></Input>
</div>
<div>
{t("text2")}
<Input
defaultValue={textPair.text2}
ref={input2Ref}
className="w-full"
></Input>
</div>
<div>
{t("locale1")}
<Input
defaultValue={textPair.locale1}
ref={input3Ref}
className="w-full"
></Input>
</div>
<div>
{t("locale2")}
<Input
defaultValue={textPair.locale2}
ref={input4Ref}
className="w-full"
></Input>
</div>
</div>
<LightButton onClick={handleUpdate}>{t("update")}</LightButton>
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import InFolder from "./InFolder";
import { getUserIdByFolderId } from "@/lib/server/services/folderService";
import { auth } from "@/auth";
import { headers } from "next/headers";
export default async function FoldersPage({
params,
}: {
params: Promise<{ folder_id: number; }>;
}) {
const session = await auth.api.getSession({ headers: await headers() });
const { folder_id } = await params;
const t = await getTranslations("folder_id");
if (!folder_id) {
redirect("/folders");
}
if (!session) redirect(`/auth?redirect=/folders/${folder_id}`);
if ((await getUserIdByFolderId(Number(folder_id))) !== session.user.id) {
return <p>{t("unauthorized")}</p>;
}
return <InFolder folderId={Number(folder_id)} />;
}

12
src/app/folders/page.tsx Normal file
View File

@@ -0,0 +1,12 @@
import { auth } from "@/auth";
import FoldersClient from "./FoldersClient";
import { redirect } from "next/navigation";
import { headers } from "next/headers";
export default async function FoldersPage() {
const session = await auth.api.getSession(
{ headers: await headers() }
);
if (!session) redirect(`/auth?redirect=/folders`);
return <FoldersClient userId={session.user.id} />;
}

View File

@@ -2,6 +2,8 @@ import type { Metadata } from "next";
import "./globals.css";
import type { Viewport } from "next";
import { NextIntlClientProvider } from "next-intl";
import { Navbar } from "@/components/layout/Navbar";
import { Toaster } from "sonner";
export const viewport: Viewport = {
width: "device-width",
@@ -20,10 +22,12 @@ export default async function RootLayout({
}>) {
return (
<html lang="en">
<body
className={`antialiased`}
>
<NextIntlClientProvider>{children}</NextIntlClientProvider>
<body className={`antialiased`}>
<NextIntlClientProvider>
<Navbar></Navbar>
{children}
<Toaster />
</NextIntlClientProvider>
</body>
</html>
);

View File

@@ -1,67 +0,0 @@
import LightButton from "@/components/buttons/LightButton";
import ACard from "@/components/cards/ACard";
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 {
setEditPage: Dispatch<SetStateAction<"choose" | "edit">>;
wordData: WordData;
setWordData: Dispatch<SetStateAction<WordData>>;
localeKey: 0 | 1;
}
export default function Choose({
setEditPage,
wordData,
setWordData,
localeKey,
}: Props) {
const t = useTranslations("memorize.choose");
const [chosenLocale, setChosenLocale] = useState<
(typeof LOCALES)[number] | null
>(null);
const handleChooseClick = () => {
if (chosenLocale) {
setWordData({
locales: [
localeKey === 0 ? chosenLocale : wordData.locales[0],
localeKey === 1 ? chosenLocale : wordData.locales[1],
],
wordPairs: wordData.wordPairs,
});
setEditPage("edit");
}
};
return (
<NavbarCenterWrapper className="bg-gray-100">
<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) => (
<LightButton
key={index}
className="md:w-26 w-18"
selected={locale === chosenLocale}
onClick={() => setChosenLocale(locale)}
>
{locale}
</LightButton>
))}
</div>
<div className="w-full flex items-center justify-center">
<BCard className="flex gap-2 justify-center items-center w-fit">
<LightButton onClick={handleChooseClick}>{t("choose")}</LightButton>
<LightButton onClick={() => setEditPage("edit")}>
{t("back")}
</LightButton>
</BCard>
</div>
</ACard>
</NavbarCenterWrapper>
);
}

View File

@@ -1,110 +0,0 @@
import LightButton from "@/components/buttons/LightButton";
import ACard from "@/components/cards/ACard";
import BCard from "@/components/cards/BCard";
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 {
setPage: Dispatch<SetStateAction<"start" | "main" | "edit">>;
wordData: WordData;
setWordData: Dispatch<SetStateAction<WordData>>;
}
export default function Edit({ setPage, wordData, setWordData }: Props) {
const t = useTranslations("memorize.edit");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [localeKey, setLocaleKey] = useState<0 | 1>(0);
const [editPage, setEditPage] = useState<"choose" | "edit">("edit");
const convertIntoWordData = (text: string) => {
const t1 = text
.replace("", ",")
.split("\n")
.map((v) => v.trim())
.filter((v) => v.includes(","));
const t2 = t1
.map((v) => {
const [left, right] = v.split(",", 2).map((v) => v.trim());
if (left && right) return [left, right] as [string, string];
else return null;
})
.filter((v) => v !== null);
const new_data: WordData = {
locales: [...wordData.locales],
wordPairs: t2,
};
return new_data;
};
const convertFromWordData = (wdata: WordData) => {
let result = "";
for (const pair of wdata.wordPairs) {
result += `${pair[0]}, ${pair[1]}\n`;
}
return result;
};
let input = convertFromWordData(wordData);
const handleSave = () => {
const newWordData = convertIntoWordData(input);
setWordData(newWordData);
if (textareaRef.current)
textareaRef.current.value = convertFromWordData(newWordData);
if(localStorage) {
localStorage.setItem("wordData", JSON.stringify(newWordData));
}
};
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
input = e.target.value;
};
if (editPage === "edit")
return (
<NavbarCenterWrapper className="bg-gray-100">
<ACard className="flex flex-col">
<textarea
onKeyDown={(e) => {
if (e.key === "Enter" && e.ctrlKey) handleSave();
}}
ref={textareaRef}
className="flex-1 text-gray-800 font-mono md:text-2xl border-gray-200 border rounded-2xl w-full resize-none outline-0 p-2"
defaultValue={input}
onChange={handleChange}
></textarea>
<div className="w-full flex items-center justify-center">
<BCard className="flex gap-2 justify-center items-center w-fit">
<LightButton onClick={() => setPage("main")}>
{t("back")}
</LightButton>
<LightButton onClick={handleSave}>{t("save")}</LightButton>
<DarkButton
onClick={() => {
setLocaleKey(0);
setEditPage("choose");
}}
>
{t("locale1")}
</DarkButton>
<DarkButton
onClick={() => {
setLocaleKey(1);
setEditPage("choose");
}}
>
{t("locale2")}
</DarkButton>
</BCard>
</div>
</ACard>
</NavbarCenterWrapper>
);
if (editPage === "choose")
return (
<Choose
wordData={wordData}
setEditPage={setEditPage}
setWordData={setWordData}
localeKey={localeKey}
></Choose>
);
}

View File

@@ -1,74 +0,0 @@
import LightButton from "@/components/buttons/LightButton";
import ACard from "@/components/cards/ACard";
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 {
wordData: WordData;
setWordData: Dispatch<SetStateAction<WordData>>;
setPage: Dispatch<SetStateAction<"start" | "main" | "edit">>;
}
export default function Main({
wordData,
setWordData,
setPage: setPage,
}: Props) {
const t = useTranslations("memorize.main");
const { upload, inputRef } = useFileUpload(async (file) => {
try {
const obj = JSON.parse(await file.text());
const newWordData = WordDataSchema.parse(obj);
setWordData(newWordData);
} catch (error) {
console.error(error);
}
});
const handleLoad = async () => {
upload("application/json");
};
const handleSave = () => {
const blob = new Blob([JSON.stringify(wordData)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "word_data.json";
a.click();
URL.revokeObjectURL(url);
};
return (
<NavbarCenterWrapper className="bg-gray-100">
<ACard className="flex-col flex">
<h1 className="text-center font-extrabold text-4xl text-gray-800 m-2 mb-4">
{t("title")}
</h1>
<div className="flex-1 font-serif text-2xl w-full h-full text-gray-800">
<BCard>
<p>{t("locale1", { locale: wordData.locales[0] })}</p>
<p>{t("locale2", { locale: wordData.locales[1] })}</p>
<p>{t("total", { total: wordData.wordPairs.length })}</p>
</BCard>
</div>
<div className="w-full flex items-center justify-center">
<BCard className="flex gap-2 justify-center items-center w-fit">
<LightButton onClick={() => setPage("start")}>
{t("start")}
</LightButton>
<LightButton onClick={handleLoad}>{t("import")}</LightButton>
<LightButton onClick={handleSave}>{t("export")}</LightButton>
<LightButton onClick={() => setPage("edit")}>
{t("edit")}
</LightButton>
</BCard>
</div>
</ACard>
<input type="file" hidden ref={inputRef}></input>
</NavbarCenterWrapper>
);
}

View File

@@ -1,100 +0,0 @@
import LightButton from "@/components/buttons/LightButton";
import { WordData } from "@/interfaces";
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 {
children: React.ReactNode;
}
function WordBoard({ children }: WordBoardProps) {
return (
<div className="text-nowrap w-full h-36 border border-white rounded flex justify-center items-center text-4xl md:text-6xl font-serif overflow-x-auto">
{children}
</div>
);
}
interface Props {
wordData: WordData;
setPage: Dispatch<SetStateAction<"start" | "main" | "edit">>;
}
export default function Start({ wordData, setPage }: Props) {
const t = useTranslations("memorize.start");
const [display, setDisplay] = useState<"ask" | "show">("ask");
const [wordPair, setWordPair] = useState(
wordData.wordPairs[Math.floor(Math.random() * wordData.wordPairs.length)],
);
const [reverse, setReverse] = useState(false);
const [dictation, setDictation] = useState(false);
const { load, play } = useAudioPlayer();
const show = () => {
setDisplay("show");
};
const next = async () => {
setDisplay("ask");
const newWordPair =
wordData.wordPairs[Math.floor(Math.random() * wordData.wordPairs.length)];
setWordPair(newWordPair);
if (dictation)
await load(
await getTTSAudioUrl(
newWordPair[reverse ? 1 : 0],
VOICES.find((v) => v.locale === wordData.locales[reverse ? 1 : 0])!
.short_name,
),
).then(play);
};
return (
<NavbarCenterWrapper className="bg-gray-100">
<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 ? (
<>
{display === "show" && (
<>
<WordBoard>{wordPair[reverse ? 1 : 0]}</WordBoard>
<WordBoard>{wordPair[reverse ? 0 : 1]}</WordBoard>
</>
)}
</>
) : (
<>
<WordBoard>{wordPair[reverse ? 1 : 0]}</WordBoard>
{display === "show" && (
<WordBoard>{wordPair[reverse ? 0 : 1]}</WordBoard>
)}
</>
)}
</div>
<div className="w-full flex items-center justify-center">
<div className="flex gap-2 justify-center items-center w-fit font-mono flex-wrap">
{display === "ask" ? (
<LightButton onClick={show}>{t("show")}</LightButton>
) : (
<LightButton onClick={next}>{t("next")}</LightButton>
)}
<LightButton
onClick={() => setReverse(!reverse)}
selected={reverse}
>
{t("reverse")}
</LightButton>
<LightButton
onClick={() => setDictation(!dictation)}
selected={dictation}
>
{t("dictation")}
</LightButton>
<LightButton onClick={() => setPage("main")}>
{t("back")}
</LightButton>
</div>
</div>
</div>
</NavbarCenterWrapper>
);
}

Some files were not shown because too many files have changed in this diff Show More