Compare commits
1 Commits
dev
...
3db1b3716f
| Author | SHA1 | Date | |
|---|---|---|---|
| 3db1b3716f |
@@ -4,5 +4,4 @@ node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
.next
|
||||
.git
|
||||
certificates
|
||||
.git
|
||||
11
.drone.yml
11
.drone.yml
@@ -19,15 +19,6 @@ 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
|
||||
@@ -41,7 +32,7 @@ steps:
|
||||
port: 22
|
||||
script:
|
||||
- cd ~/docker/learn-languages
|
||||
- docker compose up -d --pull always --force-recreate
|
||||
- docker compose up -d
|
||||
debug: true
|
||||
|
||||
trigger:
|
||||
|
||||
12
.env.example
12
.env.example
@@ -1,12 +0,0 @@
|
||||
// LLM
|
||||
ZHIPU_API_KEY=
|
||||
ZHIPU_MODEL_NAME=
|
||||
|
||||
// Auth
|
||||
BETTER_AUTH_SECRET=
|
||||
BETTER_AUTH_URL=
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
|
||||
// Database
|
||||
DATABASE_URL=
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -41,11 +41,7 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
|
||||
.env
|
||||
!.env.example
|
||||
|
||||
build.sh
|
||||
|
||||
test.ts
|
||||
/generated/prisma
|
||||
|
||||
certificates
|
||||
test.ts
|
||||
10
.vscode/settings.json
vendored
10
.vscode/settings.json
vendored
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"editor.defaultFormatter": null,
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
||||
}
|
||||
}
|
||||
29
Dockerfile
29
Dockerfile
@@ -1,27 +1,23 @@
|
||||
# syntax=docker.io/docker/dockerfile:1
|
||||
|
||||
FROM node:24-alpine AS base
|
||||
FROM node:23-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 openssl
|
||||
RUN apk add --no-cache libc6-compat
|
||||
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 pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
|
||||
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
|
||||
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
@@ -33,17 +29,10 @@ 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 pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
|
||||
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
|
||||
|
||||
|
||||
141
LICENSE
141
LICENSE
@@ -1,5 +1,5 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
@@ -7,15 +7,17 @@
|
||||
|
||||
Preamble
|
||||
|
||||
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 GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
the GNU General Public License is 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.
|
||||
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.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
@@ -24,34 +26,44 @@ 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
@@ -60,7 +72,7 @@ modification follow.
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
@@ -537,45 +549,35 @@ 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. 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.
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
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 General Public License into a single
|
||||
under version 3 of the GNU Affero 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 work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
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.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
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
|
||||
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
|
||||
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 Affero General
|
||||
Program specifies that a certain numbered version of the GNU 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 Affero General Public License, you may choose any version ever published
|
||||
GNU 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 Affero General Public License can be used, that proxy's
|
||||
versions of the GNU 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.
|
||||
|
||||
@@ -633,29 +635,40 @@ 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 Affero General Public License as published by
|
||||
it under the terms of the GNU 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 Affero General Public License for more details.
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
You should have received a copy of the GNU 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 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.
|
||||
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".
|
||||
|
||||
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 AGPL, see
|
||||
For more information on this, and how to apply and follow the GNU GPL, 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>.
|
||||
|
||||
168
README.md
168
README.md
@@ -1,162 +1,36 @@
|
||||
# 多语言学习平台
|
||||
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).
|
||||
|
||||
一个基于 Next.js 构建的全功能多语言学习平台,提供翻译、发音、字幕播放、字母学习等多种语言学习工具,帮助用户更高效地掌握新语言。
|
||||
## Getting Started
|
||||
|
||||
## ✨ 主要功能
|
||||
|
||||
- **智能翻译工具** - 支持多语言互译,包含国际音标(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/ # 应用配置
|
||||
```
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Node.js 24
|
||||
- PostgreSQL 数据库
|
||||
- pnpm (推荐) 或 npm
|
||||
|
||||
### 本地开发
|
||||
|
||||
1. 克隆项目
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd learn-languages
|
||||
```
|
||||
|
||||
2. 安装依赖
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. 设置环境变量
|
||||
|
||||
从项目提供的示例文件复制环境变量模板:
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
然后编辑 `.env.local` 文件,配置所有必要的环境变量:
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
```env
|
||||
// LLM
|
||||
ZHIPU_API_KEY=your-zhipu-api-key
|
||||
ZHIPU_MODEL_NAME=your-zhipu-model-name
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
// 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
|
||||
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.
|
||||
|
||||
// Database
|
||||
DATABASE_URL=postgresql://username:password@localhost:5432/database_name
|
||||
```
|
||||
## Learn More
|
||||
|
||||
注意:所有带 `your-` 前缀的值需要替换为你的实际配置。
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
4. 初始化数据库
|
||||
```bash
|
||||
pnpm prisma generate
|
||||
pnpm prisma db push
|
||||
```
|
||||
- [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.
|
||||
|
||||
5. 启动开发服务器
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
访问 [http://localhost:3000](http://localhost:3000) 查看应用。
|
||||
## Deploy on Vercel
|
||||
|
||||
## 📚 API 文档
|
||||
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.
|
||||
|
||||
### 认证系统
|
||||
|
||||
应用使用 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!** 🌟
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
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
|
||||
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;
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
{
|
||||
"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": "自动保存"
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
reactCompiler: true
|
||||
// allowedDevOrigins: ["192.168.3.65", "192.168.3.66"],
|
||||
};
|
||||
|
||||
|
||||
15872
package-lock.json
generated
Normal file
15872
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
61
package.json
61
package.json
@@ -3,54 +3,35 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "GPL-3.0-only",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --experimental-https",
|
||||
"build": "next build",
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build --turbopack",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"edge-tts-universal": "^1.3.2",
|
||||
"next": "15.5.3",
|
||||
"next-auth": "^4.24.13",
|
||||
"next-intl": "^4.4.0",
|
||||
"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"
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"unstorage": "^1.17.2",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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"
|
||||
]
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^20",
|
||||
"@types/pg": "^8.15.6",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.5.3",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
6607
pnpm-lock.yaml
generated
6607
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,11 +0,0 @@
|
||||
import { defineConfig, env } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
datasource: {
|
||||
url: env("DATABASE_URL"),
|
||||
},
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,3 +0,0 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -1,106 +0,0 @@
|
||||
|
||||
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")
|
||||
}
|
||||
11
public/changelog.txt
Normal file
11
public/changelog.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
2025.11.10 重构了translator,将其改为并发请求多个数据,速度大大提升
|
||||
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.
@@ -1,36 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
13
public/messages/en-US/alphabet.json
Normal file
13
public/messages/en-US/alphabet.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
33
public/messages/en-US/home.json
Normal file
33
public/messages/en-US/home.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
4
public/messages/en-US/memorize/choose.json
Normal file
4
public/messages/en-US/memorize/choose.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"back": "Back",
|
||||
"choose": "Choose"
|
||||
}
|
||||
6
public/messages/en-US/memorize/edit.json
Normal file
6
public/messages/en-US/memorize/edit.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"back": "Back",
|
||||
"save": "Save Word Pairs",
|
||||
"locale1": "Locale 1",
|
||||
"locale2": "Locale 2"
|
||||
}
|
||||
10
public/messages/en-US/memorize/main.json
Normal file
10
public/messages/en-US/memorize/main.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
7
public/messages/en-US/memorize/start.json
Normal file
7
public/messages/en-US/memorize/start.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"show": "Show",
|
||||
"reverse": "Reverse",
|
||||
"dictation": "Dictation",
|
||||
"back": "Back",
|
||||
"next": "Next"
|
||||
}
|
||||
7
public/messages/en-US/navbar.json
Normal file
7
public/messages/en-US/navbar.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"title": "LL",
|
||||
"about": "About",
|
||||
"sourceCode": "GitHub",
|
||||
"login": "Login",
|
||||
"profile": "Profile"
|
||||
}
|
||||
10
public/messages/en-US/srt-player.json
Normal file
10
public/messages/en-US/srt-player.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"uploadVideo": "Upload Video",
|
||||
"uploadSubtitle": "Upload Subtitle",
|
||||
"pause": "Pause",
|
||||
"play": "Play",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"restart": "Restart",
|
||||
"autoPause": "Auto Pause ({enabled})"
|
||||
}
|
||||
5
public/messages/en-US/text-speaker.json
Normal file
5
public/messages/en-US/text-speaker.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"generateIPA": "Generate IPA",
|
||||
"viewSavedItems": "View Saved Items",
|
||||
"confirmDeleteAll": "Are you sure you want to delete everything? (Y/N)"
|
||||
}
|
||||
12
public/messages/en-US/translator.json
Normal file
12
public/messages/en-US/translator.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
13
public/messages/zh-CN/alphabet.json
Normal file
13
public/messages/zh-CN/alphabet.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"chooseCharacters": "请选择您想学习的字符",
|
||||
"japanese": "日语假名",
|
||||
"english": "英文字母",
|
||||
"uyghur": "维吾尔字母",
|
||||
"esperanto": "世界语字母",
|
||||
"loading": "加载中...",
|
||||
"loadFailed": "加载失败,请重试",
|
||||
"hideLetter": "隐藏字母",
|
||||
"showLetter": "显示字母",
|
||||
"hideIPA": "隐藏IPA",
|
||||
"showIPA": "显示IPA"
|
||||
}
|
||||
33
public/messages/zh-CN/home.json
Normal file
33
public/messages/zh-CN/home.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"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": "开发中,敬请期待"
|
||||
}
|
||||
}
|
||||
4
public/messages/zh-CN/memorize/choose.json
Normal file
4
public/messages/zh-CN/memorize/choose.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"back": "返回",
|
||||
"choose": "选择"
|
||||
}
|
||||
6
public/messages/zh-CN/memorize/edit.json
Normal file
6
public/messages/zh-CN/memorize/edit.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"back": "返回",
|
||||
"save": "保存单词对",
|
||||
"locale1": "区域1",
|
||||
"locale2": "区域2"
|
||||
}
|
||||
10
public/messages/zh-CN/memorize/main.json
Normal file
10
public/messages/zh-CN/memorize/main.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"title": "记忆",
|
||||
"locale1": "您选择的区域一是{locale}",
|
||||
"locale2": "您选择的区域二是{locale}",
|
||||
"total": "总计有{total}个单词对",
|
||||
"start": "开始",
|
||||
"import": "导入",
|
||||
"export": "导出",
|
||||
"edit": "编辑"
|
||||
}
|
||||
7
public/messages/zh-CN/memorize/start.json
Normal file
7
public/messages/zh-CN/memorize/start.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"show": "显示",
|
||||
"reverse": "反向",
|
||||
"dictation": "听写",
|
||||
"back": "返回",
|
||||
"next": "下个"
|
||||
}
|
||||
7
public/messages/zh-CN/navbar.json
Normal file
7
public/messages/zh-CN/navbar.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"title": "学语言",
|
||||
"about": "关于",
|
||||
"sourceCode": "源码",
|
||||
"login": "登录",
|
||||
"profile": "个人资料"
|
||||
}
|
||||
10
public/messages/zh-CN/srt-player.json
Normal file
10
public/messages/zh-CN/srt-player.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"uploadVideo": "上传视频",
|
||||
"uploadSubtitle": "上传字幕",
|
||||
"pause": "暂停",
|
||||
"play": "播放",
|
||||
"previous": "上句",
|
||||
"next": "下句",
|
||||
"restart": "句首",
|
||||
"autoPause": "自动暂停({enabled})"
|
||||
}
|
||||
5
public/messages/zh-CN/text-speaker.json
Normal file
5
public/messages/zh-CN/text-speaker.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"generateIPA": "生成IPA",
|
||||
"viewSavedItems": "查看保存项",
|
||||
"confirmDeleteAll": "确定删光吗?(Y/N)"
|
||||
}
|
||||
12
public/messages/zh-CN/translator.json
Normal file
12
public/messages/zh-CN/translator.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"detectLanguage": "检测语言",
|
||||
"generateIPA": "生成国际音标",
|
||||
"translateInto": "翻译为",
|
||||
"chinese": "中文",
|
||||
"english": "英文",
|
||||
"italian": "意大利语",
|
||||
"other": "其他",
|
||||
"translating": "翻译中...",
|
||||
"translate": "翻译",
|
||||
"inputLanguage": "请输入语言。"
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
"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;
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
"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;
|
||||
@@ -1,173 +0,0 @@
|
||||
"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;
|
||||
@@ -1,53 +0,0 @@
|
||||
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)} />;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
"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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
"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%)`
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
"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;
|
||||
@@ -1,77 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
"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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
"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;
|
||||
@@ -1,85 +0,0 @@
|
||||
"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,
|
||||
};
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
"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,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
"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>;
|
||||
@@ -1,110 +0,0 @@
|
||||
"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,
|
||||
};
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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`;
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
"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;
|
||||
@@ -1,57 +0,0 @@
|
||||
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;
|
||||
@@ -1,379 +0,0 @@
|
||||
"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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
import LightButton from "@/components/ui/buttons/LightButton";
|
||||
import IconClick from "@/components/ui/buttons/IconClick";
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import IconClick from "@/components/IconClick";
|
||||
import IMAGES from "@/config/images";
|
||||
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
||||
import { Letter, SupportedAlphabets } from "@/interfaces";
|
||||
import {
|
||||
Dispatch,
|
||||
KeyboardEvent,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
@@ -20,26 +19,25 @@ export default function MemoryCard({
|
||||
setChosenAlphabet: Dispatch<SetStateAction<SupportedAlphabets | null>>;
|
||||
}) {
|
||||
const t = useTranslations("alphabet");
|
||||
const [index, setIndex] = useState(() => alphabet.length > 0 ? Math.floor(Math.random() * alphabet.length) : 0);
|
||||
const [index, setIndex] = useState(
|
||||
Math.floor(Math.random() * alphabet.length),
|
||||
);
|
||||
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] || { letter: "", letter_name_ipa: "", letter_sound_ipa: "" };
|
||||
const letter = alphabet[index];
|
||||
const refresh = () => {
|
||||
setIndex(Math.floor(Math.random() * alphabet.length));
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className="w-full flex justify-center items-center"
|
||||
97
src/app/alphabet/page.tsx
Normal file
97
src/app/alphabet/page.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import { Letter, SupportedAlphabets } from "@/interfaces";
|
||||
import { useEffect, useState } from "react";
|
||||
import MemoryCard from "./MemoryCard";
|
||||
|
||||
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 (
|
||||
<>
|
||||
<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 (
|
||||
<>
|
||||
<MemoryCard
|
||||
alphabet={alphabetData[chosenAlphabet]}
|
||||
setChosenAlphabet={setChosenAlphabet}
|
||||
></MemoryCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import { auth } from "@/auth";
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
|
||||
export const { POST, GET } = toNextJsHandler(auth);
|
||||
15
src/app/api/auth/[...nextauth]/route.ts
Normal file
15
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import NextAuth, { AuthOptions } from "next-auth";
|
||||
import GithubProvider from "next-auth/providers/github";
|
||||
|
||||
export const authOptions: AuthOptions = {
|
||||
providers: [
|
||||
GithubProvider({
|
||||
clientId: process.env.GITHUB_CLIENT_ID!,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
18
src/app/api/folder/[id]/route.ts
Normal file
18
src/app/api/folder/[id]/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { NextResponse } from "next/server";
|
||||
import { authOptions } from "../../auth/[...nextauth]/route";
|
||||
import { WordPairController } from "@/lib/db";
|
||||
|
||||
export async function GET({ params }: { params: { slug: number } }) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (session) {
|
||||
const id = params.slug;
|
||||
return new NextResponse(
|
||||
JSON.stringify(
|
||||
await WordPairController.getWordPairsByFolderId(id),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return new NextResponse("Unauthorized");
|
||||
}
|
||||
}
|
||||
35
src/app/api/folders/route.ts
Normal file
35
src/app/api/folders/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { authOptions } from "../auth/[...nextauth]/route";
|
||||
import { FolderController } from "@/lib/db";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (session) {
|
||||
return new NextResponse(
|
||||
JSON.stringify(
|
||||
await FolderController.getFoldersByOwner(session.user!.name as string),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return new NextResponse("Unauthorized");
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (session) {
|
||||
const body = await req.json();
|
||||
|
||||
return new NextResponse(
|
||||
JSON.stringify(
|
||||
await FolderController.createFolder(
|
||||
body.name,
|
||||
session.user!.name as string,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return new NextResponse("Unauthorized");
|
||||
}
|
||||
}
|
||||
62
src/app/api/ipa/route.ts
Normal file
62
src/app/api/ipa/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
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, "请稍后再试");
|
||||
}
|
||||
}
|
||||
64
src/app/api/locale/route.ts
Normal file
64
src/app/api/locale/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
12
src/app/api/route.ts
Normal file
12
src/app/api/route.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
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 },
|
||||
);
|
||||
}
|
||||
66
src/app/api/translate/route.ts
Normal file
66
src/app/api/translate/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
10
src/app/api/v1/ipa/route.ts
Normal file
10
src/app/api/v1/ipa/route.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { simpleGetLLMAnswer } from "@/lib/ai";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return await simpleGetLLMAnswer(
|
||||
`请生成%s的严式国际音标(International Phonetic Alphabet),然后直接发给我。`,
|
||||
req.nextUrl.searchParams,
|
||||
["text"],
|
||||
);
|
||||
}
|
||||
10
src/app/api/v1/locale/route.ts
Normal file
10
src/app/api/v1/locale/route.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { simpleGetLLMAnswer } from "@/lib/ai";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return await simpleGetLLMAnswer(
|
||||
`请根据文本“%s”推断地区(locale),形如zh-CN、en-US,然后直接发给我。`,
|
||||
req.nextUrl.searchParams,
|
||||
["text"],
|
||||
);
|
||||
}
|
||||
10
src/app/api/v1/translate/route.ts
Normal file
10
src/app/api/v1/translate/route.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { simpleGetLLMAnswer } from "@/lib/ai";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return await simpleGetLLMAnswer(
|
||||
`请翻译%s到%s然后直接发给我。`,
|
||||
req.nextUrl.searchParams,
|
||||
["text", "lang"],
|
||||
);
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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} />;
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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)} />;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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} />;
|
||||
}
|
||||
@@ -2,8 +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";
|
||||
import SessionWrapper from "@/lib/SessionWrapper";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
@@ -21,14 +21,15 @@ export default async function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={`antialiased`}>
|
||||
<NextIntlClientProvider>
|
||||
<Navbar></Navbar>
|
||||
{children}
|
||||
<Toaster />
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
<SessionWrapper>
|
||||
<html lang="en">
|
||||
<body className={`antialiased`}>
|
||||
<NextIntlClientProvider>
|
||||
<Navbar></Navbar>
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
</SessionWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
42
src/app/login/page.tsx
Normal file
42
src/app/login/page.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import { Center } from "@/components/Center";
|
||||
import IMAGES from "@/config/images";
|
||||
import { signIn, useSession } from "next-auth/react";
|
||||
import Image from "next/image";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function LoginPage() {
|
||||
const session = useSession();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (session.status === "authenticated") {
|
||||
router.push(searchParams.get("redirect") || "/");
|
||||
}
|
||||
}, [session.status, router, searchParams]);
|
||||
|
||||
return (
|
||||
<Center>
|
||||
{session.status === "loading" ? (
|
||||
<div>Loading...</div>
|
||||
) : (
|
||||
<LightButton
|
||||
className="flex flex-row p-2 gap-2"
|
||||
onClick={() => signIn("github")}
|
||||
>
|
||||
<Image
|
||||
src={IMAGES.github_mark}
|
||||
alt="GitHub Logo"
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
<span>GitHub Login</span>
|
||||
</LightButton>
|
||||
)}
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
67
src/app/memorize/Choose.tsx
Normal file
67
src/app/memorize/Choose.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
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 { 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 (
|
||||
<div className="w-screen flex justify-center items-center">
|
||||
<ACard className="flex flex-col">
|
||||
<div className="overflow-y-auto flex-1 border border-gray-200 rounded-2xl p-2 grid grid-cols-4 md:grid-cols-6 md:gap-2">
|
||||
{LOCALES.map((locale, index) => (
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
src/app/memorize/Edit.tsx
Normal file
110
src/app/memorize/Edit.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
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 { 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 (
|
||||
<div className="w-screen flex justify-center items-center">
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
if (editPage === "choose")
|
||||
return (
|
||||
<Choose
|
||||
wordData={wordData}
|
||||
setEditPage={setEditPage}
|
||||
setWordData={setWordData}
|
||||
localeKey={localeKey}
|
||||
></Choose>
|
||||
);
|
||||
}
|
||||
73
src/app/memorize/Main.tsx
Normal file
73
src/app/memorize/Main.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
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 { 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 (
|
||||
<div className="w-screen flex justify-center items-center">
|
||||
<ACard className="flex-col flex">
|
||||
<h1 className="text-center font-extrabold text-4xl text-gray-800 m-2 mb-4">
|
||||
{t("title")}
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
src/app/memorize/Start.tsx
Normal file
100
src/app/memorize/Start.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
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 { 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 (
|
||||
<div className="w-screen flex justify-center items-center">
|
||||
<div className="flex-col flex items-center h-96">
|
||||
<div className="flex-1 w-[95dvw] md:w-fit p-4 gap-4 flex flex-col overflow-x-auto">
|
||||
{dictation ? (
|
||||
<>
|
||||
{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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
src/app/memorize/page.tsx
Normal file
49
src/app/memorize/page.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Main from "./Main";
|
||||
import Edit from "./Edit";
|
||||
import Start from "./Start";
|
||||
import { WordData, WordDataSchema } from "@/interfaces";
|
||||
|
||||
const getLocalWordData = (): WordData => {
|
||||
const data = localStorage.getItem("wordData");
|
||||
if (!data) return {
|
||||
locales: ['en-US', 'zh-CN'],
|
||||
wordPairs: []
|
||||
};
|
||||
try {
|
||||
const parsedData = JSON.parse(data);
|
||||
const parsedData2 = WordDataSchema.parse(parsedData);
|
||||
return parsedData2;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return {
|
||||
locales: ['en-US', 'zh-CN'],
|
||||
wordPairs: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function MemorizePage() {
|
||||
const [page, setPage] = useState<"start" | "main" | "edit">("main");
|
||||
const [wordData, setWordData] = useState<WordData>(getLocalWordData());
|
||||
if (page === "main")
|
||||
return (
|
||||
<Main
|
||||
wordData={wordData}
|
||||
setWordData={setWordData}
|
||||
setPage={setPage}
|
||||
></Main>
|
||||
);
|
||||
if (page === "edit")
|
||||
return (
|
||||
<Edit
|
||||
setPage={setPage}
|
||||
wordData={wordData}
|
||||
setWordData={setWordData}
|
||||
></Edit>
|
||||
);
|
||||
if (page === "start")
|
||||
return <Start setPage={setPage} wordData={wordData}></Start>;
|
||||
}
|
||||
105
src/app/page.tsx
105
src/app/page.tsx
@@ -1,32 +1,10 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
|
||||
interface LinkAreaProps {
|
||||
href: string;
|
||||
name: string;
|
||||
description: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function LinkArea({ href, name, description, color }: LinkAreaProps) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
style={{ backgroundColor: color }}
|
||||
className={`h-32 md:h-64 flex md:justify-center items-center`}
|
||||
>
|
||||
<div className="text-white m-8">
|
||||
<h1 className="md:text-4xl text-3xl">{name}</h1>
|
||||
<p className="md:text-xl">{description}</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function HomePage() {
|
||||
const t = await getTranslations("home");
|
||||
return (
|
||||
<>
|
||||
export default function HomePage() {
|
||||
const t = useTranslations("home");
|
||||
function TopArea() {
|
||||
return (
|
||||
<div className="bg-[#35786f] text-white w-full min-h-[75dvh] flex justify-center items-center">
|
||||
<div className="mb-16 mx-16 md:mx-0 md:max-w-[60dvw]">
|
||||
<h1 className="text-6xl md:text-9xl mb-8 font-extrabold">
|
||||
@@ -35,25 +13,48 @@ export default async function HomePage() {
|
||||
<p className="text-2xl md:text-5xl font-medium">{t("description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex justify-center font-serif items-center flex-col min-h-64 h-[25vdh]">
|
||||
<p className="text-3xl">{t("fortune.quote")}</p>
|
||||
<cite className="text-[#e9b353] text-xl">{t("fortune.author")}</cite>
|
||||
</div>
|
||||
<div className="bg-[#bbbbbb] w-full flex justify-center items-center flex-col h-32">
|
||||
<div className="w-0 h-0 border-l-40 border-r-40 border-t-30 border-l-transparent border-r-transparent border-t-white"></div>
|
||||
</div>
|
||||
<div className="w-full grid grid-cols-1 grid-rows-6 md:grid-cols-3"><LinkArea
|
||||
href="/translator"
|
||||
name={t("translator.name")}
|
||||
description={t("translator.description")}
|
||||
color="#a56068"
|
||||
></LinkArea>
|
||||
);
|
||||
}
|
||||
interface LinkAreaProps {
|
||||
href: string;
|
||||
name: string;
|
||||
description: string;
|
||||
color: string;
|
||||
}
|
||||
function LinkArea({ href, name, description, color }: LinkAreaProps) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
style={{ backgroundColor: color }}
|
||||
className={`h-32 md:h-64 flex justify-center items-center`}
|
||||
>
|
||||
<div className="text-white m-8">
|
||||
<h1 className="text-4xl">{name}</h1>
|
||||
<p className="text-xl">{description}</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
function LinkGrid() {
|
||||
return (
|
||||
<div className="w-full grid grid-cols-1 grid-rows-6 md:grid-cols-3">
|
||||
<LinkArea
|
||||
href="/translator"
|
||||
name={t("translator.name")}
|
||||
description={t("translator.description")}
|
||||
color="#a56068"
|
||||
></LinkArea>
|
||||
<LinkArea
|
||||
href="/text-speaker"
|
||||
name={t("textSpeaker.name")}
|
||||
description={t("textSpeaker.description")}
|
||||
color="#578aad"
|
||||
></LinkArea>
|
||||
{/* <LinkArea
|
||||
href="/word-board"
|
||||
name="词墙"
|
||||
description="将单词固定到一片区域,高效便捷地记忆单词"
|
||||
color="#e9b353"></LinkArea> */}
|
||||
<LinkArea
|
||||
href="/srt-player"
|
||||
name={t("srtPlayer.name")}
|
||||
@@ -79,6 +80,30 @@ export default async function HomePage() {
|
||||
color="#cab48a"
|
||||
></LinkArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function Fortune() {
|
||||
return (
|
||||
<div className="w-full flex justify-center font-serif items-center flex-col min-h-64 h-[25vdh]">
|
||||
<p className="text-3xl">{t("fortune.quote")}</p>
|
||||
<cite className="text-[#e9b353] text-xl">{t("fortune.author")}</cite>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function Explore() {
|
||||
return (
|
||||
<div className="bg-[#bbbbbb] w-full flex justify-center items-center flex-col h-52">
|
||||
<span className="text-[100px] text-white">{t("explore")}</span>
|
||||
<div className="w-0 h-0 border-l-40 border-r-40 border-t-30 border-l-transparent border-r-transparent border-t-white"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<TopArea></TopArea>
|
||||
<Fortune></Fortune>
|
||||
<Explore></Explore>
|
||||
<LinkGrid></LinkGrid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user