Compare commits
1 Commits
main
...
3db1b3716f
| Author | SHA1 | Date | |
|---|---|---|---|
| 3db1b3716f |
@@ -4,36 +4,4 @@ node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
.next
|
||||
.git
|
||||
certificates
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
test.ts
|
||||
test.js
|
||||
|
||||
# build outputs
|
||||
/out/
|
||||
/build
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# debug logs
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
.vercel
|
||||
build.sh
|
||||
|
||||
# prisma
|
||||
/generated/prisma
|
||||
|
||||
.claude
|
||||
.git
|
||||
13
.drone.yml
13
.drone.yml
@@ -2,8 +2,6 @@
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: learn-languages
|
||||
concurrency:
|
||||
limit: 1
|
||||
|
||||
platform:
|
||||
os: linux
|
||||
@@ -21,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
|
||||
@@ -43,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:
|
||||
|
||||
23
.env.example
23
.env.example
@@ -1,23 +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=
|
||||
|
||||
// DashScore
|
||||
DASHSCORE_API_KEY=
|
||||
|
||||
// SMTP Email - Resend (https://resend.com)
|
||||
SMTP_HOST=smtp.resend.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=resend
|
||||
SMTP_PASS=re_your_resend_api_key
|
||||
SMTP_FROM=onboarding@resend.dev
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -41,14 +41,7 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
|
||||
.env
|
||||
!.env.example
|
||||
|
||||
build.sh
|
||||
|
||||
test.ts
|
||||
test.js
|
||||
/generated/prisma
|
||||
|
||||
certificates
|
||||
|
||||
.opencode
|
||||
test.ts
|
||||
17
.vscode/settings.json
vendored
17
.vscode/settings.json
vendored
@@ -1,17 +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"
|
||||
},
|
||||
"[css]": {
|
||||
"editor.defaultFormatter": "rvest.vs-code-prettier-eslint"
|
||||
},
|
||||
"tailwindCSS.classFunctions": [
|
||||
"cva",
|
||||
"cx"
|
||||
]
|
||||
}
|
||||
168
AGENTS.md
168
AGENTS.md
@@ -1,168 +0,0 @@
|
||||
# LEARN-LANGUAGES 知识库
|
||||
|
||||
**生成时间:** 2026-03-08
|
||||
**提交:** 6ba5ae9
|
||||
**分支:** dev
|
||||
|
||||
## 概述
|
||||
|
||||
全栈语言学习平台,集成 AI 翻译、词典和 TTS。Next.js 16 App Router + PostgreSQL + better-auth + next-intl。
|
||||
|
||||
## 结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # Next.js 路由 (Server Components)
|
||||
│ ├── (auth)/ # 认证页面: 登录、注册、个人资料
|
||||
│ ├── (features)/ # 功能页面: 翻译、词典、字幕播放器
|
||||
│ ├── folders/ # 文件夹管理
|
||||
│ └── api/auth/ # better-auth catch-all
|
||||
├── modules/ # 业务逻辑 (action-service-repository)
|
||||
│ ├── auth/ # 认证 actions, services, repositories
|
||||
│ ├── translator/ # 翻译模块
|
||||
│ ├── dictionary/ # 词典模块
|
||||
│ └── folder/ # 文件夹管理模块
|
||||
├── design-system/ # 可复用 UI 基础组件 (CVA)
|
||||
├── components/ # 业务组件
|
||||
├── lib/ # 集成层 (db, auth, bigmodel AI)
|
||||
├── hooks/ # 自定义 hooks (useAudioPlayer, useFileUpload)
|
||||
├── utils/ # 纯工具函数 (cn, validate, json)
|
||||
└── shared/ # 类型和常量
|
||||
```
|
||||
|
||||
## 查找位置
|
||||
|
||||
| 任务 | 位置 | 备注 |
|
||||
|------|------|------|
|
||||
| 添加功能页面 | `src/app/(features)/` | 路由组,无 URL 前缀 |
|
||||
| 添加认证页面 | `src/app/(auth)/` | 登录、注册、个人资料 |
|
||||
| 添加业务逻辑 | `src/modules/{name}/` | 遵循 action-service-repository |
|
||||
| 添加 AI 管道 | `src/lib/bigmodel/{name}/` | 多阶段 orchestrator |
|
||||
| 添加 UI 组件 | `src/design-system/{category}/` | base, feedback, layout, overlay |
|
||||
| 添加工具函数 | `src/utils/` | 纯函数 |
|
||||
| 添加类型定义 | `src/shared/` | 业务类型 |
|
||||
| 数据库查询 | `src/modules/*/` | Repository 层 |
|
||||
| i18n 翻译 | `messages/*.json` | 8 种语言 |
|
||||
|
||||
## 约定
|
||||
|
||||
### 架构: Action-Service-Repository
|
||||
每个模块 6 个文件:
|
||||
```
|
||||
{name}-action.ts # Server Actions, "use server"
|
||||
{name}-action-dto.ts # Zod schemas, ActionInput*/ActionOutput*
|
||||
{name}-service.ts # 业务逻辑, 跨模块调用
|
||||
{name}-service-dto.ts # ServiceInput*/ServiceOutput*
|
||||
{name}-repository.ts # Prisma 操作
|
||||
{name}-repository-dto.ts # RepoInput*/RepoOutput*
|
||||
```
|
||||
|
||||
### 命名
|
||||
- 类型: `{Layer}{Input|Output}{Feature}` → `ActionInputSignUp`
|
||||
- 函数: `{layer}{Feature}` → `actionSignUp`, `serviceSignUp`
|
||||
- 文件: `kebab-case` 带角色后缀
|
||||
|
||||
### Server/Client 划分
|
||||
- **默认**: Server Components (无 "use client")
|
||||
- **Client**: 仅在需要时 (useState, useEffect, 浏览器 API)
|
||||
- **Actions**: 必须有 `"use server"`
|
||||
|
||||
### 导入风格
|
||||
- 显式路径: `@/design-system/base/button` (无 barrel exports)
|
||||
- 不创建 `index.ts` 文件
|
||||
|
||||
### 验证
|
||||
- Zod schemas 放在 `*-dto.ts`
|
||||
- 使用 `validate()` from `@/utils/validate`
|
||||
|
||||
### 认证
|
||||
```typescript
|
||||
// 服务端
|
||||
import { auth } from "@/auth";
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
// 客户端
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
const { data } = authClient.useSession();
|
||||
```
|
||||
|
||||
### 受保护操作
|
||||
```typescript
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) return { success: false, message: "未授权" };
|
||||
// 变更前检查所有权
|
||||
```
|
||||
|
||||
### 日志
|
||||
```typescript
|
||||
import { createLogger } from "@/lib/logger";
|
||||
|
||||
const log = createLogger("folder-repository");
|
||||
|
||||
log.debug("Fetching public folders");
|
||||
log.info("Fetched folders", { count: folders.length });
|
||||
log.error("Failed to fetch folders", { error });
|
||||
```
|
||||
|
||||
## 反模式 (本项目)
|
||||
|
||||
- ❌ `index.ts` barrel exports
|
||||
- ❌ `as any`, `@ts-ignore`, `@ts-expect-error`
|
||||
- ❌ 用 API routes 做数据操作 (使用 Server Actions)
|
||||
- ❌ Server Component 可行时用 Client Component
|
||||
- ❌ npm 或 yarn (使用 pnpm)
|
||||
- ❌ 生产代码中使用 `console.log` (使用 winston logger)
|
||||
- ❌ 擅自运行 `pnpm dev` (不需要,用 `pnpm build` 验证即可)
|
||||
|
||||
## 独特风格
|
||||
|
||||
### 设计系统分类
|
||||
- `base/` — 原子组件: button, input, card, checkbox, radio, switch, select, textarea, range
|
||||
- `feedback/` — 反馈: alert, progress, skeleton, toast
|
||||
- `layout/` — 布局: container, grid, stack (VStack, HStack)
|
||||
- `overlay/` — 覆盖层: modal
|
||||
- `navigation/` — 导航: tabs
|
||||
|
||||
### AI 管道模式
|
||||
`src/lib/bigmodel/` 中的多阶段 orchestrator:
|
||||
```
|
||||
{name}/
|
||||
├── orchestrator.ts # 协调各阶段
|
||||
├── types.ts # 共享接口
|
||||
└── stage{n}-{name}.ts # 各阶段实现
|
||||
```
|
||||
|
||||
### 废弃函数
|
||||
`translator-action.ts` 中的 `genIPA()` 和 `genLanguage()` — 保留用于 text-speaker 兼容
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
pnpm dev # 开发服务器 (HTTPS)
|
||||
pnpm build # 生产构建 (验证代码)
|
||||
pnpm lint # ESLint
|
||||
pnpm prisma studio # 数据库 GUI
|
||||
```
|
||||
|
||||
### 数据库迁移
|
||||
|
||||
**必须使用 `prisma migrate dev`,禁止使用 `db push`:**
|
||||
|
||||
```bash
|
||||
# 修改 schema 后创建迁移
|
||||
DATABASE_URL=your_db_url pnpm prisma migrate dev --name your_migration_name
|
||||
|
||||
# 生成 Prisma Client
|
||||
DATABASE_URL=your_db_url pnpm prisma generate
|
||||
```
|
||||
|
||||
`db push` 会绕过迁移历史,导致生产环境无法正确迁移。
|
||||
|
||||
## 备注
|
||||
|
||||
- Tailwind CSS v4 (无 tailwind.config.ts)
|
||||
- React Compiler 已启用
|
||||
- i18n: 8 种语言 (en-US, zh-CN, ja-JP, ko-KR, de-DE, fr-FR, it-IT, ug-CN)
|
||||
- TTS: 阿里云千问 (qwen3-tts-flash)
|
||||
- 数据库: PostgreSQL via Prisma (生成在 `generated/prisma/`)
|
||||
- 未配置测试基础设施
|
||||
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>.
|
||||
|
||||
376
README.md
376
README.md
@@ -1,372 +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).
|
||||
|
||||
<div align="center">
|
||||
## Getting Started
|
||||
|
||||
[](https://nextjs.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://www.postgresql.org/)
|
||||
[](./LICENSE)
|
||||
|
||||
**一个现代化的全栈多语言学习平台,集成 AI 驱动的翻译、发音、词典和学习管理功能**
|
||||
|
||||
[在线演示](#) · [报告问题](../../issues) · [功能建议](../../issues)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## ✨ 核心特性
|
||||
|
||||
### 🎯 学习工具
|
||||
- **智能翻译** - 基于 AI 的多语言互译,支持 IPA 音标标注
|
||||
- **词典查询** - 详细的单词释义、词性分析、例句展示
|
||||
- **语音合成** - 阿里云千问 TTS 提供自然的语音输出
|
||||
- **个人学习空间** - 文件夹管理、学习资料组织
|
||||
|
||||
### 🔐 用户系统
|
||||
- **多方式认证** - 邮箱/用户名登录、GitHub OAuth
|
||||
- **个人资料** - 用户主页、学习进度追踪
|
||||
- **数据安全** - better-auth 提供企业级安全保障
|
||||
|
||||
### 🌐 国际化
|
||||
- **8 种语言** - en-US, zh-CN, ja-JP, ko-KR, de-DE, fr-FR, it-IT, ug-CN
|
||||
- **完整本地化** - 所有界面文本支持多语言
|
||||
|
||||
### 🏗️ 技术亮点
|
||||
- **App Router** - 采用 Next.js 16 最新路由系统
|
||||
- **Server Components** - 优先服务端渲染,优化性能
|
||||
- **Action-Service-Repository** - 清晰的三层架构设计
|
||||
- **类型安全** - TypeScript 严格模式 + Zod 验证
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 前置要求
|
||||
|
||||
- Node.js 24+
|
||||
- PostgreSQL 14+
|
||||
- pnpm 8+ (推荐) 或 npm/yarn
|
||||
|
||||
### 安装步骤
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
# 1. 克隆项目
|
||||
git clone <repository-url>
|
||||
cd learn-languages
|
||||
|
||||
# 2. 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 3. 配置环境变量
|
||||
cp .env.example .env.local
|
||||
# 编辑 .env.local 填写必要配置
|
||||
|
||||
# 4. 初始化数据库
|
||||
pnpm prisma generate
|
||||
pnpm prisma db push
|
||||
|
||||
# 5. 启动开发服务器
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
访问 **http://localhost:3000** 开始使用!
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
### 环境变量配置
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
```env
|
||||
# 🤖 AI 服务(必需)
|
||||
ZHIPU_API_KEY=your-api-key # 智谱 AI - 翻译和词典
|
||||
ZHIPU_MODEL_NAME=your-model-name # 模型名称
|
||||
DASHSCORE_API_KEY=your-api-key # 阿里云 TTS
|
||||
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.
|
||||
|
||||
# 🔐 认证配置(必需)
|
||||
BETTER_AUTH_SECRET=your-secret # 随机字符串
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
## Learn More
|
||||
|
||||
# 🐙 GitHub OAuth(可选)
|
||||
GITHUB_CLIENT_ID=your-client-id
|
||||
GITHUB_CLIENT_SECRET=your-client-secret
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
# 💾 数据库(必需)
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
|
||||
```
|
||||
- [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.
|
||||
|
||||
---
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## 🛠️ 技术栈
|
||||
## Deploy on Vercel
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
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.
|
||||
|
||||
### 前端
|
||||
- **Next.js 16** - App Router
|
||||
- **React 19** - UI 框架
|
||||
- **TypeScript 5.9** - 类型安全
|
||||
- **Tailwind CSS 4** - 样式方案
|
||||
- **Zustand** - 状态管理
|
||||
- **next-intl** - 国际化
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
### 后端
|
||||
- **PostgreSQL** - 关系数据库
|
||||
- **Prisma 7** - ORM
|
||||
- **better-auth** - 认证系统
|
||||
- **智谱 AI** - LLM 服务
|
||||
- **阿里云 TTS** - 语音合成
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## 📁 项目架构
|
||||
|
||||
```
|
||||
learn-languages/
|
||||
├── 📂 src/
|
||||
│ ├── 📂 app/ # Next.js App Router
|
||||
│ │ ├── 📂 (auth)/ # 认证相关页面
|
||||
│ │ ├── 📂 folders/ # 文件夹管理
|
||||
│ │ ├── 📂 users/[username]/ # 用户资料
|
||||
│ │ └── 📂 api/ # API 路由
|
||||
│ │
|
||||
│ ├── 📂 modules/ # 业务模块(三层架构)
|
||||
│ │ ├── 📂 auth/ # 认证模块
|
||||
│ │ ├── 📂 folder/ # 文件夹模块
|
||||
│ │ ├── 📂 dictionary/ # 词典模块
|
||||
│ │ └── 📂 translator/ # 翻译模块
|
||||
│ │
|
||||
│ ├── 📂 components/ # React 组件
|
||||
│ │ ├── 📂 ui/ # 通用 UI 组件
|
||||
│ │ └── 📂 layout/ # 布局组件
|
||||
│ │
|
||||
│ ├── 📂 design-system/ # 设计系统
|
||||
│ │ ├── 📂 base/ # 基础组件
|
||||
│ │ ├── 📂 layout/ # 布局组件
|
||||
│ │ └── 📂 feedback/ # 反馈组件
|
||||
│ │
|
||||
│ ├── 📂 lib/ # 工具库
|
||||
│ │ ├── 📂 bigmodel/ # AI 集成
|
||||
│ │ ├── 📂 browser/ # 浏览器工具
|
||||
│ │ └── 📂 server/ # 服务端工具
|
||||
│ │
|
||||
│ ├── 📂 hooks/ # 自定义 Hooks
|
||||
│ ├── 📂 i18n/ # 国际化配置
|
||||
│ ├── 📂 shared/ # 共享类型和常量
|
||||
│ └── 📂 config/ # 应用配置
|
||||
│
|
||||
├── 📂 prisma/ # 数据库 Schema
|
||||
├── 📂 messages/ # 多语言文件
|
||||
└── 📂 public/ # 静态资源
|
||||
```
|
||||
|
||||
### 架构设计:Action-Service-Repository
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Presentation Layer │
|
||||
│ (Server Components / Client Components)│
|
||||
└────────────────┬────────────────────────┘
|
||||
│
|
||||
┌────────────────▼────────────────────────┐
|
||||
│ Action Layer │
|
||||
│ • Server Actions │
|
||||
│ • Form Validation (Zod) │
|
||||
│ • Redirect & Error Handling │
|
||||
└────────────────┬────────────────────────┘
|
||||
│
|
||||
┌────────────────▼────────────────────────┐
|
||||
│ Service Layer │
|
||||
│ • Business Logic │
|
||||
│ • better-auth Integration │
|
||||
│ • Cross-module Coordination │
|
||||
└────────────────┬────────────────────────┘
|
||||
│
|
||||
┌────────────────▼────────────────────────┐
|
||||
│ Repository Layer │
|
||||
│ • Prisma Database Operations │
|
||||
│ • Data Access Abstraction │
|
||||
│ • Query Optimization │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 核心模块
|
||||
|
||||
### 认证系统 (auth)
|
||||
|
||||
```typescript
|
||||
// 支持多种登录方式
|
||||
- 邮箱/密码登录
|
||||
- 用户名登录
|
||||
- GitHub OAuth
|
||||
- 邮箱验证
|
||||
```
|
||||
|
||||
### 翻译模块 (translator)
|
||||
|
||||
```typescript
|
||||
// AI 驱动的智能翻译
|
||||
- 多语言互译
|
||||
- IPA 音标标注
|
||||
- 翻译历史记录
|
||||
- 上下文理解
|
||||
```
|
||||
|
||||
### 词典模块 (dictionary)
|
||||
|
||||
```typescript
|
||||
// 智能词典查询
|
||||
- 单词释义
|
||||
- 词性分析
|
||||
- 例句展示
|
||||
- 词频统计
|
||||
```
|
||||
|
||||
### 文件夹模块 (folder)
|
||||
|
||||
```typescript
|
||||
// 学习资料管理
|
||||
- 创建/删除文件夹
|
||||
- 添加语言对
|
||||
- IPA 标注
|
||||
- 批量管理
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ 数据模型
|
||||
|
||||
核心数据模型关系:
|
||||
|
||||
```
|
||||
User (用户)
|
||||
├─ Account (账户)
|
||||
├─ Session (会话)
|
||||
├─ Folder (文件夹)
|
||||
│ └─ Pair (语言对)
|
||||
├─ DictionaryLookUp (查询记录)
|
||||
│ └─ DictionaryItem (词典项)
|
||||
│ └─ DictionaryEntry (词条)
|
||||
└─ TranslationHistory (翻译历史)
|
||||
```
|
||||
|
||||
详细模型定义:[prisma/schema.prisma](./prisma/schema.prisma)
|
||||
|
||||
---
|
||||
|
||||
## 🌍 国际化支持
|
||||
|
||||
当前支持的语言:
|
||||
|
||||
| 语言 | 代码 | 区域 |
|
||||
|------|------|------|
|
||||
| English | en-US | 美国 |
|
||||
| 中文 | zh-CN | 中国 |
|
||||
| 日本語 | ja-JP | 日本 |
|
||||
| 한국어 | ko-KR | 韩国 |
|
||||
| Deutsch | de-DE | 德国 |
|
||||
| Français | fr-FR | 法国 |
|
||||
| Italiano | it-IT | 意大利 |
|
||||
| ئۇيغۇرچە | ug-CN | 新疆 |
|
||||
|
||||
添加新语言:
|
||||
|
||||
1. 在 `messages/` 创建语言文件
|
||||
2. 在 `src/i18n/config.ts` 添加配置
|
||||
3. 更新语言选择器组件
|
||||
|
||||
---
|
||||
|
||||
## 🔧 开发指南
|
||||
|
||||
### 可用脚本
|
||||
|
||||
```bash
|
||||
# 开发
|
||||
pnpm dev # 启动开发服务器 (HTTPS)
|
||||
pnpm build # 构建生产版本
|
||||
pnpm start # 启动生产服务器
|
||||
pnpm lint # 代码检查
|
||||
|
||||
# 数据库
|
||||
pnpm prisma studio # 打开数据库 GUI
|
||||
pnpm prisma db push # 同步 Schema
|
||||
pnpm prisma migrate # 创建迁移
|
||||
```
|
||||
|
||||
### 代码规范
|
||||
|
||||
- ✅ TypeScript 严格模式
|
||||
- ✅ ESLint + TypeScript Plugin
|
||||
- ✅ 优先使用 Server Components
|
||||
- ✅ 新功能遵循 Action-Service-Repository
|
||||
- ✅ 所有用户文本需要国际化
|
||||
- ✅ 组件复用设计系统和业务组件
|
||||
|
||||
### 目录约定
|
||||
|
||||
- `modules/` - 业务模块,每个模块包含:
|
||||
- `*-action.ts` - Server Actions
|
||||
- `*-service.ts` - 业务逻辑
|
||||
- `*-repository.ts` - 数据访问
|
||||
- `*-dto.ts` - 数据传输对象
|
||||
- `components/` - 业务相关组件
|
||||
- `design-system/` - 可复用基础组件
|
||||
- `lib/` - 工具函数和库
|
||||
|
||||
---
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
我们欢迎各种贡献!
|
||||
|
||||
### 贡献流程
|
||||
|
||||
1. Fork 本仓库
|
||||
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add: AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 开启 Pull Request
|
||||
|
||||
### 代码提交规范
|
||||
|
||||
```
|
||||
feat: 新功能
|
||||
fix: 修复问题
|
||||
docs: 文档变更
|
||||
style: 代码格式
|
||||
refactor: 重构
|
||||
test: 测试相关
|
||||
chore: 构建/工具
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用 [AGPL-3.0](./LICENSE) 许可证。
|
||||
|
||||
---
|
||||
|
||||
## 📞 联系方式
|
||||
|
||||
- **问题反馈**:[GitHub Issues](../../issues)
|
||||
- **邮箱**:goddonebianu@outlook.com
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**如果这个项目对你有帮助,请给一个 ⭐️ Star!**
|
||||
|
||||
Made with ❤️ by the community
|
||||
|
||||
</div>
|
||||
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,360 +0,0 @@
|
||||
{
|
||||
"alphabet": {
|
||||
"chooseCharacters": "Bitte wählen Sie die Zeichen aus, die Sie lernen möchten",
|
||||
"chooseAlphabetHint": "Wählen Sie ein Alphabet, um mit dem Lernen zu beginnen",
|
||||
"japanese": "Japanische Kana",
|
||||
"english": "Englisches Alphabet",
|
||||
"uyghur": "Uigurisches Alphabet",
|
||||
"esperanto": "Esperanto-Alphabet",
|
||||
"loading": "Wird geladen...",
|
||||
"loadFailed": "Laden fehlgeschlagen, bitte versuchen Sie es erneut",
|
||||
"hideLetter": "Buchstabe ausblenden",
|
||||
"showLetter": "Buchstabe anzeigen",
|
||||
"hideIPA": "IPA ausblenden",
|
||||
"showIPA": "IPA anzeigen",
|
||||
"roman": "Romanisierung",
|
||||
"letter": "Buchstabe",
|
||||
"random": "Zufallsmodus",
|
||||
"randomNext": "Zufällig weiter",
|
||||
"previousLetter": "Vorheriger Buchstabe",
|
||||
"nextLetter": "Nächster Buchstabe",
|
||||
"keyboardHint": "Verwenden Sie die Pfeiltasten links/rechts oder Leertaste für Zufall, ESC zum Zurückgehen",
|
||||
"swipeHint": "Verwenden Sie die Pfeiltasten links/rechts oder wischen Sie zum Navigieren, ESC zum Zurückgehen"
|
||||
},
|
||||
"folders": {
|
||||
"title": "Ordner",
|
||||
"subtitle": "Verwalten Sie Ihre Sammlungen",
|
||||
"newFolder": "Neuer Ordner",
|
||||
"creating": "Wird erstellt...",
|
||||
"noFoldersYet": "Noch keine Ordner vorhanden",
|
||||
"folderInfo": "ID: {id} • {totalPairs} Paare",
|
||||
"enterFolderName": "Ordnernamen eingeben:",
|
||||
"confirmDelete": "Geben Sie \"{name}\" zum Löschen ein:",
|
||||
"myFolders": "Meine Ordner",
|
||||
"publicFolders": "Öffentliche Ordner",
|
||||
"public": "Öffentlich",
|
||||
"private": "Privat",
|
||||
"setPublic": "Öffentlich machen",
|
||||
"setPrivate": "Privat machen",
|
||||
"publicFolderInfo": "{userName} • {totalPairs} Paare",
|
||||
"searchPlaceholder": "Öffentliche Ordner durchsuchen...",
|
||||
"loading": "Wird geladen...",
|
||||
"noPublicFolders": "Keine öffentlichen Ordner gefunden",
|
||||
"unknownUser": "Unbekannter Benutzer",
|
||||
"enterNewName": "Neuen Namen eingeben:",
|
||||
"favorite": "Favorisieren",
|
||||
"unfavorite": "Aus Favoriten entfernen",
|
||||
"pleaseLogin": "Bitte melden Sie sich zuerst an"
|
||||
},
|
||||
"folder_id": {
|
||||
"unauthorized": "Sie sind nicht der Besitzer dieses Ordners",
|
||||
"back": "Zurück",
|
||||
"textPairs": "Textpaare",
|
||||
"itemsCount": "{count} Einträge",
|
||||
"memorize": "Auswendig lernen",
|
||||
"loadingTextPairs": "Textpaare werden geladen...",
|
||||
"noTextPairs": "Keine Textpaare in diesem Ordner",
|
||||
"addNewTextPair": "Neues Textpaar hinzufügen",
|
||||
"add": "Hinzufügen",
|
||||
"updateTextPair": "Textpaar aktualisieren",
|
||||
"update": "Aktualisieren",
|
||||
"text1": "Text 1",
|
||||
"text2": "Text 2",
|
||||
"language1": "Sprache 1",
|
||||
"language2": "Sprache 2",
|
||||
"enterLanguageName": "Bitte Sprachnamen eingeben",
|
||||
"edit": "Bearbeiten",
|
||||
"delete": "Löschen",
|
||||
"permissionDenied": "Sie haben keine Berechtigung für diese Aktion",
|
||||
"error": {
|
||||
"update": "Sie haben keine Berechtigung, diesen Eintrag zu aktualisieren.",
|
||||
"delete": "Sie haben keine Berechtigung, diesen Eintrag zu löschen.",
|
||||
"add": "Sie haben keine Berechtigung, Einträge zu diesem Ordner hinzuzufügen.",
|
||||
"rename": "Sie haben keine Berechtigung, diesen Ordner umzubenennen.",
|
||||
"deleteFolder": "Sie haben keine Berechtigung, diesen Ordner zu löschen."
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"title": "Sprachen lernen",
|
||||
"description": "Hier ist eine sehr nützliche Website, die Ihnen hilft, fast jede Sprache der Welt zu lernen, einschließlich konstruierter Sprachen.",
|
||||
"explore": "Entdecken",
|
||||
"fortune": {
|
||||
"quote": "Stay hungry, stay foolish.",
|
||||
"author": "— Steve Jobs"
|
||||
},
|
||||
"translator": {
|
||||
"name": "Übersetzer",
|
||||
"description": "In jede Sprache übersetzen und mit dem Internationalen Phonetischen Alphabet (IPA) annotieren"
|
||||
},
|
||||
"textSpeaker": {
|
||||
"name": "Textvorleser",
|
||||
"description": "Text erkennen und vorlesen, unterstützt Schleifenwiedergabe und Geschwindigkeitsanpassung"
|
||||
},
|
||||
"srtPlayer": {
|
||||
"name": "SRT-Videoplayer",
|
||||
"description": "Videos Satz für Satz basierend auf SRT-Untertiteldateien abspielen, um die Aussprache von Muttersprachlern nachzuahmen"
|
||||
},
|
||||
"alphabet": {
|
||||
"name": "Alphabet",
|
||||
"description": "Beginnen Sie mit dem Lernen einer neuen Sprache vom Alphabet aus"
|
||||
},
|
||||
"memorize": {
|
||||
"name": "Auswendig lernen",
|
||||
"description": "Sprache A zu Sprache B, Sprache B zu Sprache A, unterstützt Diktat"
|
||||
},
|
||||
"dictionary": {
|
||||
"name": "Wörterbuch",
|
||||
"description": "Wörter und Ausdrücke mit detaillierten Definitionen und Beispielen nachschlagen"
|
||||
},
|
||||
"moreFeatures": {
|
||||
"name": "Weitere Funktionen",
|
||||
"description": "In Entwicklung, bleiben Sie gespannt"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"title": "Anmelden",
|
||||
"signUpTitle": "Registrieren",
|
||||
"signIn": "Anmelden",
|
||||
"signUp": "Registrieren",
|
||||
"email": "E-Mail",
|
||||
"password": "Passwort",
|
||||
"confirmPassword": "Passwort bestätigen",
|
||||
"name": "Name",
|
||||
"username": "Benutzername",
|
||||
"emailOrUsername": "E-Mail oder Benutzername",
|
||||
"signInButton": "Anmelden",
|
||||
"signUpButton": "Registrieren",
|
||||
"noAccount": "Haben Sie kein Konto?",
|
||||
"hasAccount": "Haben Sie bereits ein Konto?",
|
||||
"signInWithGitHub": "Mit GitHub anmelden",
|
||||
"signUpWithGitHub": "Mit GitHub registrieren",
|
||||
"invalidEmail": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
|
||||
"passwordTooShort": "Das Passwort muss mindestens 8 Zeichen lang sein",
|
||||
"passwordsNotMatch": "Die Passwörter stimmen nicht überein",
|
||||
"nameRequired": "Bitte geben Sie Ihren Namen ein",
|
||||
"usernameRequired": "Bitte geben Sie einen Benutzernamen ein",
|
||||
"usernameTooShort": "Der Benutzername muss mindestens 3 Zeichen lang sein",
|
||||
"usernameInvalid": "Der Benutzername darf nur Buchstaben, Zahlen und Unterstriche enthalten",
|
||||
"emailRequired": "Bitte geben Sie Ihre E-Mail ein",
|
||||
"identifierRequired": "Bitte geben Sie Ihre E-Mail oder Ihren Benutzernamen ein",
|
||||
"passwordRequired": "Bitte geben Sie Ihr Passwort ein",
|
||||
"confirmPasswordRequired": "Bitte bestätigen Sie Ihr Passwort",
|
||||
"loading": "Wird geladen...",
|
||||
"confirm": "Bestätigen",
|
||||
"noAccountLink": "Haben Sie kein Konto? Registrieren Sie sich",
|
||||
"hasAccountLink": "Haben Sie bereits ein Konto? Anmelden",
|
||||
"usernamePlaceholder": "Benutzername",
|
||||
"emailPlaceholder": "E-Mail-Adresse",
|
||||
"passwordPlaceholder": "Passwort",
|
||||
"usernameOrEmailPlaceholder": "Benutzername oder E-Mail",
|
||||
"loginFailed": "Anmeldung fehlgeschlagen",
|
||||
"signUpFailed": "Registrierung fehlgeschlagen",
|
||||
"fillAllFields": "Bitte füllen Sie alle Felder aus",
|
||||
"enterCredentials": "Bitte geben Sie Benutzername und Passwort ein",
|
||||
"forgotPassword": "Passwort vergessen",
|
||||
"forgotPasswordHint": "Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen einen Link zum Zurücksetzen Ihres Passworts.",
|
||||
"sendResetEmail": "Reset-E-Mail senden",
|
||||
"resetPasswordFailed": "Reset-E-Mail konnte nicht gesendet werden",
|
||||
"resetPasswordEmailSent": "Reset-E-Mail erfolgreich gesendet",
|
||||
"resetPasswordEmailSentHint": "Wir haben einen Link zum Zurücksetzen Ihres Passworts an Ihre E-Mail-Adresse gesendet. Bitte überprüfen Sie Ihren Posteingang.",
|
||||
"checkYourEmail": "Überprüfen Sie Ihre E-Mail",
|
||||
"backToLogin": "Zurück zur Anmeldung",
|
||||
"resetPassword": "Passwort zurücksetzen",
|
||||
"newPassword": "Neues Passwort",
|
||||
"invalidToken": "Ungültiger oder abgelaufener Link",
|
||||
"invalidTokenHint": "Dieser Link zum Zurücksetzen des Passworts ist ungültig oder abgelaufen. Bitte fordern Sie einen neuen an.",
|
||||
"requestNewToken": "Neuen Reset-Link anfordern",
|
||||
"resetPasswordSuccess": "Passwort erfolgreich zurückgesetzt",
|
||||
"resetPasswordSuccessTitle": "Passwort-Zurücksetzung abgeschlossen",
|
||||
"resetPasswordSuccessHint": "Ihr Passwort wurde erfolgreich zurückgesetzt. Sie können sich jetzt mit Ihrem neuen Passwort anmelden."
|
||||
},
|
||||
"memorize": {
|
||||
"folder_selector": {
|
||||
"selectFolder": "Wählen Sie einen Ordner",
|
||||
"noFolders": "Keine Ordner gefunden",
|
||||
"folderInfo": "{id}. {name} ({count})"
|
||||
},
|
||||
"memorize": {
|
||||
"answer": "Antwort",
|
||||
"next": "Weiter",
|
||||
"reverse": "Umkehren",
|
||||
"dictation": "Diktat",
|
||||
"noTextPairs": "Keine Textpaare verfügbar",
|
||||
"disorder": "Mischen",
|
||||
"previous": "Zurück"
|
||||
},
|
||||
"page": {
|
||||
"unauthorized": "Sie sind nicht berechtigt, auf diesen Ordner zuzugreifen"
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
"title": "learn-languages",
|
||||
"sourceCode": "GitHub",
|
||||
"sign_in": "Anmelden",
|
||||
"profile": "Profil",
|
||||
"folders": "Ordner",
|
||||
"explore": "Entdecken",
|
||||
"favorites": "Favoriten"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "Mein Profil",
|
||||
"email": "E-Mail: {email}",
|
||||
"logout": "Abmelden"
|
||||
},
|
||||
"srt_player": {
|
||||
"uploadVideo": "Video hochladen",
|
||||
"uploadSubtitle": "Untertitel hochladen",
|
||||
"pause": "Pause",
|
||||
"play": "Abspielen",
|
||||
"previous": "Zurück",
|
||||
"next": "Weiter",
|
||||
"restart": "Neustart",
|
||||
"autoPause": "Auto-Pause ({enabled})",
|
||||
"uploadVideoAndSubtitle": "Bitte laden Sie Video- und Untertiteldateien hoch",
|
||||
"uploadVideoFile": "Bitte laden Sie eine Videodatei hoch",
|
||||
"uploadSubtitleFile": "Bitte laden Sie eine Untertiteldatei hoch",
|
||||
"processingSubtitle": "Untertiteldatei wird verarbeitet...",
|
||||
"needBothFiles": "Sowohl Video- als auch Untertiteldateien sind erforderlich, um mit dem Lernen zu beginnen",
|
||||
"videoFile": "Videodatei",
|
||||
"subtitleFile": "Untertiteldatei",
|
||||
"uploaded": "Hochgeladen",
|
||||
"notUploaded": "Nicht hochgeladen",
|
||||
"upload": "Hochladen",
|
||||
"uploadVideoButton": "Video hochladen",
|
||||
"uploadSubtitleButton": "Untertitel hochladen",
|
||||
"subtitleUploaded": "Untertitel hochgeladen ({count} Einträge)",
|
||||
"subtitleNotUploaded": "Untertitel nicht hochgeladen",
|
||||
"autoPauseStatus": "Auto-Pause: {enabled}",
|
||||
"on": "Ein",
|
||||
"off": "Aus",
|
||||
"videoUploadFailed": "Video-Upload fehlgeschlagen",
|
||||
"subtitleUploadFailed": "Untertitel-Upload fehlgeschlagen",
|
||||
"subtitleLoadSuccess": "Untertitel erfolgreich geladen",
|
||||
"subtitleLoadFailed": "Laden der Untertitel fehlgeschlagen"
|
||||
},
|
||||
"text_speaker": {
|
||||
"generateIPA": "IPA generieren",
|
||||
"viewSavedItems": "Gespeicherte Einträge anzeigen",
|
||||
"confirmDeleteAll": "Sind Sie sicher, dass Sie alles löschen möchten? (J/N)"
|
||||
},
|
||||
"translator": {
|
||||
"detectLanguage": "Sprache erkennen",
|
||||
"generateIPA": "IPA generieren",
|
||||
"translateInto": "übersetzen in",
|
||||
"chinese": "Chinesisch",
|
||||
"english": "Englisch",
|
||||
"french": "Französisch",
|
||||
"german": "Deutsch",
|
||||
"italian": "Italienisch",
|
||||
"japanese": "Japanisch",
|
||||
"korean": "Koreanisch",
|
||||
"portuguese": "Portugiesisch",
|
||||
"russian": "Russisch",
|
||||
"spanish": "Spanisch",
|
||||
"other": "Andere",
|
||||
"translating": "wird übersetzt...",
|
||||
"translate": "übersetzen",
|
||||
"inputLanguage": "Geben Sie eine Sprache ein.",
|
||||
"history": "Verlauf",
|
||||
"enterLanguage": "Sprache eingeben",
|
||||
"add_to_folder": {
|
||||
"notAuthenticated": "Sie sind nicht authentifiziert",
|
||||
"chooseFolder": "Wählen Sie einen Ordner zum Hinzufügen",
|
||||
"noFolders": "Keine Ordner gefunden",
|
||||
"folderInfo": "{id}. {name}",
|
||||
"close": "Schließen",
|
||||
"success": "Textpaar zum Ordner hinzugefügt",
|
||||
"error": "Fehler beim Hinzufügen des Textpaars zum Ordner"
|
||||
},
|
||||
"autoSave": "Autom. Speichern"
|
||||
},
|
||||
"dictionary": {
|
||||
"title": "Wörterbuch",
|
||||
"description": "Wörter und Ausdrücke mit detaillierten Definitionen und Beispielen nachschlagen",
|
||||
"searchPlaceholder": "Geben Sie ein Wort oder einen Ausdruck zum Nachschlagen ein...",
|
||||
"searching": "Suche läuft...",
|
||||
"search": "Suchen",
|
||||
"languageSettings": "Spracheinstellungen",
|
||||
"queryLanguage": "Abfragesprache",
|
||||
"queryLanguageHint": "In welcher Sprache ist das Wort/der Ausdruck, den Sie nachschlagen möchten",
|
||||
"definitionLanguage": "Definitionssprache",
|
||||
"definitionLanguageHint": "In welcher Sprache möchten Sie die Definitionen",
|
||||
"otherLanguagePlaceholder": "Oder geben Sie eine andere Sprache ein...",
|
||||
"other": "Andere",
|
||||
"currentSettings": "Aktuelle Einstellungen: Abfrage {queryLang}, Definition {definitionLang}",
|
||||
"relookup": "Erneut suchen",
|
||||
"saveToFolder": "In Ordner speichern",
|
||||
"loading": "Wird geladen...",
|
||||
"noResults": "Keine Ergebnisse gefunden",
|
||||
"tryOtherWords": "Versuchen Sie andere Wörter oder Ausdrücke",
|
||||
"welcomeTitle": "Willkommen im Wörterbuch",
|
||||
"welcomeHint": "Geben Sie oben in das Suchfeld ein Wort oder einen Ausdruck ein, um mit dem Nachschlagen zu beginnen",
|
||||
"lookupFailed": "Suche fehlgeschlagen, bitte versuchen Sie es später erneut",
|
||||
"relookupSuccess": "Erneute Suche erfolgreich",
|
||||
"relookupFailed": "Erneute Wörterbuchsuche fehlgeschlagen",
|
||||
"pleaseLogin": "Bitte melden Sie sich zuerst an",
|
||||
"pleaseCreateFolder": "Bitte erstellen Sie zuerst einen Ordner",
|
||||
"savedToFolder": "In Ordner gespeichert: {folderName}",
|
||||
"saveFailed": "Speichern fehlgeschlagen, bitte versuchen Sie es später erneut",
|
||||
"definition": "Definition",
|
||||
"example": "Beispiel"
|
||||
},
|
||||
"explore": {
|
||||
"title": "Entdecken",
|
||||
"subtitle": "Öffentliche Ordner entdecken",
|
||||
"searchPlaceholder": "Öffentliche Ordner durchsuchen...",
|
||||
"loading": "Wird geladen...",
|
||||
"noFolders": "Keine öffentlichen Ordner gefunden",
|
||||
"folderInfo": "{userName} • {totalPairs} Paare",
|
||||
"unknownUser": "Unbekannter Benutzer",
|
||||
"favorite": "Favorisieren",
|
||||
"unfavorite": "Aus Favoriten entfernen",
|
||||
"pleaseLogin": "Bitte melden Sie sich zuerst an",
|
||||
"sortByFavorites": "Nach Favoriten sortieren",
|
||||
"sortByFavoritesActive": "Sortierung nach Favoriten aufheben"
|
||||
},
|
||||
"exploreDetail": {
|
||||
"title": "Ordnerdetails",
|
||||
"createdBy": "Erstellt von: {name}",
|
||||
"unknownUser": "Unbekannter Benutzer",
|
||||
"totalPairs": "Gesamtpaare",
|
||||
"favorites": "Favoriten",
|
||||
"createdAt": "Erstellt am",
|
||||
"viewContent": "Inhalt anzeigen",
|
||||
"favorite": "Favorisieren",
|
||||
"unfavorite": "Aus Favoriten entfernen",
|
||||
"favorited": "Favorisiert",
|
||||
"unfavorited": "Aus Favoriten entfernt",
|
||||
"pleaseLogin": "Bitte melden Sie sich zuerst an"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "Meine Favoriten",
|
||||
"subtitle": "Ordner, die Sie favorisiert haben",
|
||||
"loading": "Wird geladen...",
|
||||
"noFavorites": "Noch keine Favoriten",
|
||||
"folderInfo": "{userName} • {totalPairs} Paare",
|
||||
"unknownUser": "Unbekannter Benutzer"
|
||||
},
|
||||
"user_profile": {
|
||||
"anonymous": "Anonym",
|
||||
"email": "E-Mail",
|
||||
"verified": "Verifiziert",
|
||||
"unverified": "Nicht verifiziert",
|
||||
"accountInfo": "Kontoinformationen",
|
||||
"userId": "Benutzer-ID",
|
||||
"username": "Benutzername",
|
||||
"displayName": "Anzeigename",
|
||||
"notSet": "Nicht festgelegt",
|
||||
"memberSince": "Mitglied seit",
|
||||
"logout": "Abmelden",
|
||||
"folders": {
|
||||
"title": "Ordner",
|
||||
"noFolders": "Noch keine Ordner",
|
||||
"folderName": "Ordnername",
|
||||
"totalPairs": "Gesamtpaare",
|
||||
"createdAt": "Erstellt am",
|
||||
"actions": "Aktionen",
|
||||
"view": "Anzeigen"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,360 +0,0 @@
|
||||
{
|
||||
"alphabet": {
|
||||
"chooseCharacters": "Please select the characters you want to learn",
|
||||
"chooseAlphabetHint": "Select an alphabet to start learning",
|
||||
"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",
|
||||
"previousLetter": "Previous letter",
|
||||
"nextLetter": "Next letter",
|
||||
"keyboardHint": "Use left/right arrow keys or space for random, ESC to go back",
|
||||
"swipeHint": "Use left/right arrow keys or swipe to navigate, ESC to go back"
|
||||
},
|
||||
"folders": {
|
||||
"title": "Folders",
|
||||
"subtitle": "Manage your collections",
|
||||
"newFolder": "New Folder",
|
||||
"creating": "Creating...",
|
||||
"noFoldersYet": "No folders yet",
|
||||
"folderInfo": "ID: {id} • {totalPairs} pairs",
|
||||
"enterFolderName": "Enter folder name:",
|
||||
"confirmDelete": "Type \"{name}\" to delete:",
|
||||
"myFolders": "My Folders",
|
||||
"publicFolders": "Public Folders",
|
||||
"public": "Public",
|
||||
"private": "Private",
|
||||
"setPublic": "Set Public",
|
||||
"setPrivate": "Set Private",
|
||||
"publicFolderInfo": "{userName} • {totalPairs} pairs",
|
||||
"searchPlaceholder": "Search public folders...",
|
||||
"loading": "Loading...",
|
||||
"noPublicFolders": "No public folders found",
|
||||
"unknownUser": "Unknown User",
|
||||
"enterNewName": "Enter new name:",
|
||||
"favorite": "Favorite",
|
||||
"unfavorite": "Unfavorite",
|
||||
"pleaseLogin": "Please login first"
|
||||
},
|
||||
"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",
|
||||
"language1": "Locale 1",
|
||||
"language2": "Locale 2",
|
||||
"enterLanguageName": "Please enter language name",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"permissionDenied": "You do not have permission to perform this action",
|
||||
"error": {
|
||||
"update": "You do not have permission to update this item.",
|
||||
"delete": "You do not have permission to delete this item.",
|
||||
"add": "You do not have permission to add items to this folder.",
|
||||
"rename": "You do not have permission to rename this folder.",
|
||||
"deleteFolder": "You do not have permission to delete this folder."
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"dictionary": {
|
||||
"name": "Dictionary",
|
||||
"description": "Look up words and phrases with detailed definitions and examples"
|
||||
},
|
||||
"moreFeatures": {
|
||||
"name": "More Features",
|
||||
"description": "Under development, stay tuned"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"title": "Sign In",
|
||||
"signUpTitle": "Sign Up",
|
||||
"signIn": "Sign In",
|
||||
"signUp": "Sign Up",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"name": "Name",
|
||||
"username": "Username",
|
||||
"emailOrUsername": "Email or Username",
|
||||
"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",
|
||||
"nameRequired": "Please enter your name",
|
||||
"usernameRequired": "Please enter a username",
|
||||
"usernameTooShort": "Username must be at least 3 characters",
|
||||
"usernameInvalid": "Username can only contain letters, numbers, and underscores",
|
||||
"emailRequired": "Please enter your email",
|
||||
"identifierRequired": "Please enter your email or username",
|
||||
"passwordRequired": "Please enter your password",
|
||||
"confirmPasswordRequired": "Please confirm your password",
|
||||
"loading": "Loading...",
|
||||
"confirm": "Confirm",
|
||||
"noAccountLink": "Don't have an account? Sign up",
|
||||
"hasAccountLink": "Already have an account? Sign in",
|
||||
"usernamePlaceholder": "Username",
|
||||
"emailPlaceholder": "Email address",
|
||||
"passwordPlaceholder": "Password",
|
||||
"usernameOrEmailPlaceholder": "Username or email",
|
||||
"loginFailed": "Login failed",
|
||||
"signUpFailed": "Sign up failed",
|
||||
"fillAllFields": "Please fill in all fields",
|
||||
"enterCredentials": "Please enter username and password",
|
||||
"forgotPassword": "Forgot Password",
|
||||
"forgotPasswordHint": "Enter your email address and we'll send you a link to reset your password.",
|
||||
"sendResetEmail": "Send Reset Email",
|
||||
"resetPasswordFailed": "Failed to send reset email",
|
||||
"resetPasswordEmailSent": "Reset email sent successfully",
|
||||
"resetPasswordEmailSentHint": "We've sent a password reset link to your email address. Please check your inbox.",
|
||||
"checkYourEmail": "Check Your Email",
|
||||
"backToLogin": "Back to Login",
|
||||
"resetPassword": "Reset Password",
|
||||
"newPassword": "New Password",
|
||||
"invalidToken": "Invalid or Expired Link",
|
||||
"invalidTokenHint": "This password reset link is invalid or has expired. Please request a new one.",
|
||||
"requestNewToken": "Request New Reset Link",
|
||||
"resetPasswordSuccess": "Password reset successfully",
|
||||
"resetPasswordSuccessTitle": "Password Reset Complete",
|
||||
"resetPasswordSuccessHint": "Your password has been reset successfully. You can now log in with your new password."
|
||||
},
|
||||
"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",
|
||||
"explore": "Explore",
|
||||
"favorites": "Favorites"
|
||||
},
|
||||
"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})",
|
||||
"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",
|
||||
"uploadVideoButton": "Upload Video",
|
||||
"uploadSubtitleButton": "Upload Subtitle",
|
||||
"subtitleUploaded": "Subtitle Uploaded ({count} entries)",
|
||||
"subtitleNotUploaded": "Subtitle Not Uploaded",
|
||||
"autoPauseStatus": "Auto Pause: {enabled}",
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"videoUploadFailed": "Video upload failed",
|
||||
"subtitleUploadFailed": "Subtitle upload failed",
|
||||
"subtitleLoadSuccess": "Subtitle loaded successfully",
|
||||
"subtitleLoadFailed": "Subtitle load failed"
|
||||
},
|
||||
"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",
|
||||
"french": "French",
|
||||
"german": "German",
|
||||
"italian": "Italian",
|
||||
"japanese": "Japanese",
|
||||
"korean": "Korean",
|
||||
"portuguese": "Portuguese",
|
||||
"russian": "Russian",
|
||||
"spanish": "Spanish",
|
||||
"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"
|
||||
},
|
||||
"dictionary": {
|
||||
"title": "Dictionary",
|
||||
"description": "Look up words and phrases with detailed definitions and examples",
|
||||
"searchPlaceholder": "Enter a word or phrase to look up...",
|
||||
"searching": "Searching...",
|
||||
"search": "Search",
|
||||
"languageSettings": "Language Settings",
|
||||
"queryLanguage": "Query Language",
|
||||
"queryLanguageHint": "What language is the word/phrase you want to look up",
|
||||
"definitionLanguage": "Definition Language",
|
||||
"definitionLanguageHint": "What language do you want the definitions in",
|
||||
"otherLanguagePlaceholder": "Or enter another language...",
|
||||
"other": "Other",
|
||||
"currentSettings": "Current settings: Query {queryLang}, Definition {definitionLang}",
|
||||
"relookup": "Re-search",
|
||||
"saveToFolder": "Save to folder",
|
||||
"loading": "Loading...",
|
||||
"noResults": "No results found",
|
||||
"tryOtherWords": "Try other words or phrases",
|
||||
"welcomeTitle": "Welcome to Dictionary",
|
||||
"welcomeHint": "Enter a word or phrase in the search box above to start looking up",
|
||||
"lookupFailed": "Search failed, please try again later",
|
||||
"relookupSuccess": "Re-searched successfully",
|
||||
"relookupFailed": "Dictionary re-search failed",
|
||||
"pleaseLogin": "Please log in first",
|
||||
"pleaseCreateFolder": "Please create a folder first",
|
||||
"savedToFolder": "Saved to folder: {folderName}",
|
||||
"saveFailed": "Save failed, please try again later",
|
||||
"definition": "Definition",
|
||||
"example": "Example"
|
||||
},
|
||||
"explore": {
|
||||
"title": "Explore",
|
||||
"subtitle": "Discover public folders",
|
||||
"searchPlaceholder": "Search public folders...",
|
||||
"loading": "Loading...",
|
||||
"noFolders": "No public folders found",
|
||||
"folderInfo": "{userName} • {totalPairs} pairs",
|
||||
"unknownUser": "Unknown User",
|
||||
"favorite": "Favorite",
|
||||
"unfavorite": "Unfavorite",
|
||||
"pleaseLogin": "Please login first",
|
||||
"sortByFavorites": "Sort by favorites",
|
||||
"sortByFavoritesActive": "Undo sort by favorites"
|
||||
},
|
||||
"exploreDetail": {
|
||||
"title": "Folder Details",
|
||||
"createdBy": "Created by: {name}",
|
||||
"unknownUser": "Unknown User",
|
||||
"totalPairs": "Total Pairs",
|
||||
"favorites": "Favorites",
|
||||
"createdAt": "Created At",
|
||||
"viewContent": "View Content",
|
||||
"favorite": "Favorite",
|
||||
"unfavorite": "Unfavorite",
|
||||
"favorited": "Favorited",
|
||||
"unfavorited": "Unfavorited",
|
||||
"pleaseLogin": "Please login first"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "My Favorites",
|
||||
"subtitle": "Folders you've favorited",
|
||||
"loading": "Loading...",
|
||||
"noFavorites": "No favorites yet",
|
||||
"folderInfo": "{userName} • {totalPairs} pairs",
|
||||
"unknownUser": "Unknown User"
|
||||
},
|
||||
"user_profile": {
|
||||
"anonymous": "Anonymous",
|
||||
"email": "Email",
|
||||
"verified": "Verified",
|
||||
"unverified": "Unverified",
|
||||
"accountInfo": "Account Information",
|
||||
"userId": "User ID",
|
||||
"username": "Username",
|
||||
"displayName": "Display Name",
|
||||
"notSet": "Not Set",
|
||||
"memberSince": "Member Since",
|
||||
"logout": "Logout",
|
||||
"folders": {
|
||||
"title": "Folders",
|
||||
"noFolders": "No folders yet",
|
||||
"folderName": "Folder Name",
|
||||
"totalPairs": "Total Pairs",
|
||||
"createdAt": "Created At",
|
||||
"actions": "Actions",
|
||||
"view": "View"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,360 +0,0 @@
|
||||
{
|
||||
"alphabet": {
|
||||
"chooseCharacters": "Veuillez sélectionner les caractères que vous souhaitez apprendre",
|
||||
"chooseAlphabetHint": "Sélectionnez un alphabet pour commencer à apprendre",
|
||||
"japanese": "Kana japonais",
|
||||
"english": "Alphabet anglais",
|
||||
"uyghur": "Alphabet ouïghour",
|
||||
"esperanto": "Alphabet espéranto",
|
||||
"loading": "Chargement...",
|
||||
"loadFailed": "Échec du chargement, veuillez réessayer",
|
||||
"hideLetter": "Masquer la lettre",
|
||||
"showLetter": "Afficher la lettre",
|
||||
"hideIPA": "Masquer l'API",
|
||||
"showIPA": "Afficher l'API",
|
||||
"roman": "Romanisation",
|
||||
"letter": "Lettre",
|
||||
"random": "Mode aléatoire",
|
||||
"randomNext": "Suivant aléatoire",
|
||||
"previousLetter": "Lettre précédente",
|
||||
"nextLetter": "Lettre suivante",
|
||||
"keyboardHint": "Utilisez les touches fléchées gauche/droite ou espace pour aléatoire, ÉCHAP pour revenir",
|
||||
"swipeHint": "Utilisez les touches fléchées gauche/droite ou balayez pour naviguer, ÉCHAP pour revenir"
|
||||
},
|
||||
"folders": {
|
||||
"title": "Dossiers",
|
||||
"subtitle": "Gérez vos collections",
|
||||
"newFolder": "Nouveau dossier",
|
||||
"creating": "Création...",
|
||||
"noFoldersYet": "Pas encore de dossiers",
|
||||
"folderInfo": "ID : {id} • {totalPairs} paires",
|
||||
"enterFolderName": "Entrez le nom du dossier :",
|
||||
"confirmDelete": "Tapez \"{name}\" pour supprimer :",
|
||||
"myFolders": "Mes dossiers",
|
||||
"publicFolders": "Dossiers publics",
|
||||
"public": "Public",
|
||||
"private": "Privé",
|
||||
"setPublic": "Définir comme public",
|
||||
"setPrivate": "Définir comme privé",
|
||||
"publicFolderInfo": "{userName} • {totalPairs} paires",
|
||||
"searchPlaceholder": "Rechercher des dossiers publics...",
|
||||
"loading": "Chargement...",
|
||||
"noPublicFolders": "Aucun dossier public trouvé",
|
||||
"unknownUser": "Utilisateur inconnu",
|
||||
"enterNewName": "Entrez le nouveau nom :",
|
||||
"favorite": "Favori",
|
||||
"unfavorite": "Retirer des favoris",
|
||||
"pleaseLogin": "Veuillez vous connecter d'abord"
|
||||
},
|
||||
"folder_id": {
|
||||
"unauthorized": "Vous n'êtes pas le propriétaire de ce dossier",
|
||||
"back": "Retour",
|
||||
"textPairs": "Paires de texte",
|
||||
"itemsCount": "{count} éléments",
|
||||
"memorize": "Mémoriser",
|
||||
"loadingTextPairs": "Chargement des paires de texte...",
|
||||
"noTextPairs": "Aucune paire de texte dans ce dossier",
|
||||
"addNewTextPair": "Ajouter une nouvelle paire de texte",
|
||||
"add": "Ajouter",
|
||||
"updateTextPair": "Mettre à jour la paire de texte",
|
||||
"update": "Mettre à jour",
|
||||
"text1": "Texte 1",
|
||||
"text2": "Texte 2",
|
||||
"language1": "Langue 1",
|
||||
"language2": "Langue 2",
|
||||
"enterLanguageName": "Veuillez entrer le nom de la langue",
|
||||
"edit": "Modifier",
|
||||
"delete": "Supprimer",
|
||||
"permissionDenied": "Vous n'avez pas la permission d'effectuer cette action",
|
||||
"error": {
|
||||
"update": "Vous n'avez pas la permission de mettre à jour cet élément.",
|
||||
"delete": "Vous n'avez pas la permission de supprimer cet élément.",
|
||||
"add": "Vous n'avez pas la permission d'ajouter des éléments à ce dossier.",
|
||||
"rename": "Vous n'avez pas la permission de renommer ce dossier.",
|
||||
"deleteFolder": "Vous n'avez pas la permission de supprimer ce dossier."
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"title": "Apprendre les langues",
|
||||
"description": "Voici un site Web très utile pour vous aider à apprendre presque toutes les langues du monde, y compris les langues construites.",
|
||||
"explore": "Explorer",
|
||||
"fortune": {
|
||||
"quote": "Restez affamés, restez fous.",
|
||||
"author": "— Steve Jobs"
|
||||
},
|
||||
"translator": {
|
||||
"name": "Traducteur",
|
||||
"description": "Traduire vers n'importe quelle langue et annoter avec l'Alphabet Phonétique International (API)"
|
||||
},
|
||||
"textSpeaker": {
|
||||
"name": "Lecteur de texte",
|
||||
"description": "Reconnaître et lire le texte à haute voix, prend en charge la lecture en boucle et le réglage de la vitesse"
|
||||
},
|
||||
"srtPlayer": {
|
||||
"name": "Lecteur vidéo SRT",
|
||||
"description": "Lire des vidéos phrase par phrase basées sur des fichiers de sous-titres SRT pour imiter la prononciation des locuteurs natifs"
|
||||
},
|
||||
"alphabet": {
|
||||
"name": "Alphabet",
|
||||
"description": "Commencez à apprendre une nouvelle langue à partir de l'alphabet"
|
||||
},
|
||||
"memorize": {
|
||||
"name": "Mémoriser",
|
||||
"description": "Langue A vers Langue B, Langue B vers Langue A, prend en charge la dictée"
|
||||
},
|
||||
"dictionary": {
|
||||
"name": "Dictionnaire",
|
||||
"description": "Rechercher des mots et des expressions avec des définitions détaillées et des exemples"
|
||||
},
|
||||
"moreFeatures": {
|
||||
"name": "Plus de fonctionnalités",
|
||||
"description": "En développement, restez à l'écoute"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"title": "Se connecter",
|
||||
"signUpTitle": "S'inscrire",
|
||||
"signIn": "Se connecter",
|
||||
"signUp": "S'inscrire",
|
||||
"email": "E-mail",
|
||||
"password": "Mot de passe",
|
||||
"confirmPassword": "Confirmer le mot de passe",
|
||||
"name": "Nom",
|
||||
"username": "Nom d'utilisateur",
|
||||
"emailOrUsername": "E-mail ou nom d'utilisateur",
|
||||
"signInButton": "Se connecter",
|
||||
"signUpButton": "S'inscrire",
|
||||
"noAccount": "Vous n'avez pas de compte ?",
|
||||
"hasAccount": "Vous avez déjà un compte ?",
|
||||
"signInWithGitHub": "Se connecter avec GitHub",
|
||||
"signUpWithGitHub": "S'inscrire avec GitHub",
|
||||
"invalidEmail": "Veuillez entrer une adresse e-mail valide",
|
||||
"passwordTooShort": "Le mot de passe doit contenir au moins 8 caractères",
|
||||
"passwordsNotMatch": "Les mots de passe ne correspondent pas",
|
||||
"nameRequired": "Veuillez entrer votre nom",
|
||||
"usernameRequired": "Veuillez entrer un nom d'utilisateur",
|
||||
"usernameTooShort": "Le nom d'utilisateur doit contenir au moins 3 caractères",
|
||||
"usernameInvalid": "Le nom d'utilisateur ne peut contenir que des lettres, des chiffres et des underscores",
|
||||
"emailRequired": "Veuillez entrer votre e-mail",
|
||||
"identifierRequired": "Veuillez entrer votre e-mail ou nom d'utilisateur",
|
||||
"passwordRequired": "Veuillez entrer votre mot de passe",
|
||||
"confirmPasswordRequired": "Veuillez confirmer votre mot de passe",
|
||||
"loading": "Chargement...",
|
||||
"confirm": "Confirmer",
|
||||
"noAccountLink": "Vous n'avez pas de compte ? Inscrivez-vous",
|
||||
"hasAccountLink": "Vous avez déjà un compte ? Connectez-vous",
|
||||
"usernamePlaceholder": "Nom d'utilisateur",
|
||||
"emailPlaceholder": "Adresse e-mail",
|
||||
"passwordPlaceholder": "Mot de passe",
|
||||
"usernameOrEmailPlaceholder": "Nom d'utilisateur ou e-mail",
|
||||
"loginFailed": "Échec de la connexion",
|
||||
"signUpFailed": "Échec de l'inscription",
|
||||
"fillAllFields": "Veuillez remplir tous les champs",
|
||||
"enterCredentials": "Veuillez entrer le nom d'utilisateur et le mot de passe",
|
||||
"forgotPassword": "Mot de passe oublié",
|
||||
"forgotPasswordHint": "Entrez votre adresse e-mail et nous vous enverrons un lien pour réinitialiser votre mot de passe.",
|
||||
"sendResetEmail": "Envoyer l'e-mail de réinitialisation",
|
||||
"resetPasswordFailed": "Échec de l'envoi de l'e-mail de réinitialisation",
|
||||
"resetPasswordEmailSent": "E-mail de réinitialisation envoyé avec succès",
|
||||
"resetPasswordEmailSentHint": "Nous avons envoyé un lien de réinitialisation de mot de passe à votre adresse e-mail. Veuillez vérifier votre boîte de réception.",
|
||||
"checkYourEmail": "Vérifiez votre e-mail",
|
||||
"backToLogin": "Retour à la connexion",
|
||||
"resetPassword": "Réinitialiser le mot de passe",
|
||||
"newPassword": "Nouveau mot de passe",
|
||||
"invalidToken": "Lien invalide ou expiré",
|
||||
"invalidTokenHint": "Ce lien de réinitialisation de mot de passe est invalide ou a expiré. Veuillez en demander un nouveau.",
|
||||
"requestNewToken": "Demander un nouveau lien de réinitialisation",
|
||||
"resetPasswordSuccess": "Mot de passe réinitialisé avec succès",
|
||||
"resetPasswordSuccessTitle": "Réinitialisation du mot de passe terminée",
|
||||
"resetPasswordSuccessHint": "Votre mot de passe a été réinitialisé avec succès. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe."
|
||||
},
|
||||
"memorize": {
|
||||
"folder_selector": {
|
||||
"selectFolder": "Sélectionner un dossier",
|
||||
"noFolders": "Aucun dossier trouvé",
|
||||
"folderInfo": "{id}. {name} ({count})"
|
||||
},
|
||||
"memorize": {
|
||||
"answer": "Réponse",
|
||||
"next": "Suivant",
|
||||
"reverse": "Inverser",
|
||||
"dictation": "Dictée",
|
||||
"noTextPairs": "Aucune paire de texte disponible",
|
||||
"disorder": "Désordre",
|
||||
"previous": "Précédent"
|
||||
},
|
||||
"page": {
|
||||
"unauthorized": "Vous n'êtes pas autorisé à accéder à ce dossier"
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
"title": "apprendre-langues",
|
||||
"sourceCode": "GitHub",
|
||||
"sign_in": "Se connecter",
|
||||
"profile": "Profil",
|
||||
"folders": "Dossiers",
|
||||
"explore": "Explorer",
|
||||
"favorites": "Favoris"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "Mon profil",
|
||||
"email": "E-mail : {email}",
|
||||
"logout": "Déconnexion"
|
||||
},
|
||||
"srt_player": {
|
||||
"uploadVideo": "Télécharger la vidéo",
|
||||
"uploadSubtitle": "Télécharger les sous-titres",
|
||||
"pause": "Pause",
|
||||
"play": "Lecture",
|
||||
"previous": "Précédent",
|
||||
"next": "Suivant",
|
||||
"restart": "Recommencer",
|
||||
"autoPause": "Pause automatique ({enabled})",
|
||||
"uploadVideoAndSubtitle": "Veuillez télécharger les fichiers vidéo et sous-titres",
|
||||
"uploadVideoFile": "Veuillez télécharger le fichier vidéo",
|
||||
"uploadSubtitleFile": "Veuillez télécharger le fichier de sous-titres",
|
||||
"processingSubtitle": "Traitement du fichier de sous-titres...",
|
||||
"needBothFiles": "Les fichiers vidéo et sous-titres sont tous deux requis pour commencer l'apprentissage",
|
||||
"videoFile": "Fichier vidéo",
|
||||
"subtitleFile": "Fichier de sous-titres",
|
||||
"uploaded": "Téléchargé",
|
||||
"notUploaded": "Non téléchargé",
|
||||
"upload": "Télécharger",
|
||||
"uploadVideoButton": "Télécharger la vidéo",
|
||||
"uploadSubtitleButton": "Télécharger les sous-titres",
|
||||
"subtitleUploaded": "Sous-titres téléchargés ({count} entrées)",
|
||||
"subtitleNotUploaded": "Sous-titres non téléchargés",
|
||||
"autoPauseStatus": "Pause automatique : {enabled}",
|
||||
"on": "Activé",
|
||||
"off": "Désactivé",
|
||||
"videoUploadFailed": "Échec du téléchargement de la vidéo",
|
||||
"subtitleUploadFailed": "Échec du téléchargement des sous-titres",
|
||||
"subtitleLoadSuccess": "Sous-titres chargés avec succès",
|
||||
"subtitleLoadFailed": "Échec du chargement des sous-titres"
|
||||
},
|
||||
"text_speaker": {
|
||||
"generateIPA": "Générer l'API",
|
||||
"viewSavedItems": "Voir les éléments enregistrés",
|
||||
"confirmDeleteAll": "Êtes-vous sûr de vouloir tout supprimer ? (O/N)"
|
||||
},
|
||||
"translator": {
|
||||
"detectLanguage": "détecter la langue",
|
||||
"generateIPA": "générer l'api",
|
||||
"translateInto": "traduire en",
|
||||
"chinese": "Chinois",
|
||||
"english": "Anglais",
|
||||
"french": "Français",
|
||||
"german": "Allemand",
|
||||
"italian": "Italien",
|
||||
"japanese": "Japonais",
|
||||
"korean": "Coréen",
|
||||
"portuguese": "Portugais",
|
||||
"russian": "Russe",
|
||||
"spanish": "Espagnol",
|
||||
"other": "Autre",
|
||||
"translating": "traduction...",
|
||||
"translate": "traduire",
|
||||
"inputLanguage": "Entrez une langue.",
|
||||
"history": "Historique",
|
||||
"enterLanguage": "Entrez la langue",
|
||||
"add_to_folder": {
|
||||
"notAuthenticated": "Vous n'êtes pas authentifié",
|
||||
"chooseFolder": "Choisissez un dossier à ajouter",
|
||||
"noFolders": "Aucun dossier trouvé",
|
||||
"folderInfo": "{id}. {name}",
|
||||
"close": "Fermer",
|
||||
"success": "Paire de texte ajoutée au dossier",
|
||||
"error": "Échec de l'ajout de la paire de texte au dossier"
|
||||
},
|
||||
"autoSave": "Sauvegarde automatique"
|
||||
},
|
||||
"dictionary": {
|
||||
"title": "Dictionnaire",
|
||||
"description": "Rechercher des mots et des expressions avec des définitions détaillées et des exemples",
|
||||
"searchPlaceholder": "Entrez un mot ou une expression à rechercher...",
|
||||
"searching": "Recherche...",
|
||||
"search": "Rechercher",
|
||||
"languageSettings": "Paramètres de langue",
|
||||
"queryLanguage": "Langue de requête",
|
||||
"queryLanguageHint": "Dans quelle langue est le mot/l'expression que vous voulez rechercher",
|
||||
"definitionLanguage": "Langue de définition",
|
||||
"definitionLanguageHint": "Dans quelle langue voulez-vous les définitions",
|
||||
"otherLanguagePlaceholder": "Ou entrez une autre langue...",
|
||||
"other": "Autre",
|
||||
"currentSettings": "Paramètres actuels : Requête {queryLang}, Définition {definitionLang}",
|
||||
"relookup": "Rechercher à nouveau",
|
||||
"saveToFolder": "Enregistrer dans le dossier",
|
||||
"loading": "Chargement...",
|
||||
"noResults": "Aucun résultat trouvé",
|
||||
"tryOtherWords": "Essayez d'autres mots ou expressions",
|
||||
"welcomeTitle": "Bienvenue dans le dictionnaire",
|
||||
"welcomeHint": "Entrez un mot ou une expression dans la zone de recherche ci-dessus pour commencer la recherche",
|
||||
"lookupFailed": "La recherche a échoué, veuillez réessayer plus tard",
|
||||
"relookupSuccess": "Recherche effectuée avec succès",
|
||||
"relookupFailed": "La nouvelle recherche dans le dictionnaire a échoué",
|
||||
"pleaseLogin": "Veuillez vous connecter d'abord",
|
||||
"pleaseCreateFolder": "Veuillez créer un dossier d'abord",
|
||||
"savedToFolder": "Enregistré dans le dossier : {folderName}",
|
||||
"saveFailed": "Échec de l'enregistrement, veuillez réessayer plus tard",
|
||||
"definition": "Définition",
|
||||
"example": "Exemple"
|
||||
},
|
||||
"explore": {
|
||||
"title": "Explorer",
|
||||
"subtitle": "Découvrir les dossiers publics",
|
||||
"searchPlaceholder": "Rechercher des dossiers publics...",
|
||||
"loading": "Chargement...",
|
||||
"noFolders": "Aucun dossier public trouvé",
|
||||
"folderInfo": "{userName} • {totalPairs} paires",
|
||||
"unknownUser": "Utilisateur inconnu",
|
||||
"favorite": "Favori",
|
||||
"unfavorite": "Retirer des favoris",
|
||||
"pleaseLogin": "Veuillez vous connecter d'abord",
|
||||
"sortByFavorites": "Trier par favoris",
|
||||
"sortByFavoritesActive": "Annuler le tri par favoris"
|
||||
},
|
||||
"exploreDetail": {
|
||||
"title": "Détails du dossier",
|
||||
"createdBy": "Créé par : {name}",
|
||||
"unknownUser": "Utilisateur inconnu",
|
||||
"totalPairs": "Total des paires",
|
||||
"favorites": "Favoris",
|
||||
"createdAt": "Créé le",
|
||||
"viewContent": "Voir le contenu",
|
||||
"favorite": "Favori",
|
||||
"unfavorite": "Retirer des favoris",
|
||||
"favorited": "Ajouté aux favoris",
|
||||
"unfavorited": "Retiré des favoris",
|
||||
"pleaseLogin": "Veuillez vous connecter d'abord"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "Mes favoris",
|
||||
"subtitle": "Les dossiers que vous avez mis en favoris",
|
||||
"loading": "Chargement...",
|
||||
"noFavorites": "Pas encore de favoris",
|
||||
"folderInfo": "{userName} • {totalPairs} paires",
|
||||
"unknownUser": "Utilisateur inconnu"
|
||||
},
|
||||
"user_profile": {
|
||||
"anonymous": "Anonyme",
|
||||
"email": "E-mail",
|
||||
"verified": "Vérifié",
|
||||
"unverified": "Non vérifié",
|
||||
"accountInfo": "Informations du compte",
|
||||
"userId": "ID utilisateur",
|
||||
"username": "Nom d'utilisateur",
|
||||
"displayName": "Nom d'affichage",
|
||||
"notSet": "Non défini",
|
||||
"memberSince": "Membre depuis",
|
||||
"logout": "Déconnexion",
|
||||
"folders": {
|
||||
"title": "Dossiers",
|
||||
"noFolders": "Pas encore de dossiers",
|
||||
"folderName": "Nom du dossier",
|
||||
"totalPairs": "Total des paires",
|
||||
"createdAt": "Créé le",
|
||||
"actions": "Actions",
|
||||
"view": "Voir"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,360 +0,0 @@
|
||||
{
|
||||
"alphabet": {
|
||||
"chooseCharacters": "Seleziona i caratteri che vuoi imparare",
|
||||
"chooseAlphabetHint": "Seleziona un alfabeto per iniziare a imparare",
|
||||
"japanese": "Kana Giapponese",
|
||||
"english": "Alfabeto Inglese",
|
||||
"uyghur": "Alfabeto Uiguro",
|
||||
"esperanto": "Alfabeto Esperanto",
|
||||
"loading": "Caricamento...",
|
||||
"loadFailed": "Caricamento fallito, riprova",
|
||||
"hideLetter": "Nascondi Lettera",
|
||||
"showLetter": "Mostra Lettera",
|
||||
"hideIPA": "Nascondi IPA",
|
||||
"showIPA": "Mostra IPA",
|
||||
"roman": "Romanizzazione",
|
||||
"letter": "Lettera",
|
||||
"random": "Modalità Casuale",
|
||||
"randomNext": "Prossimo Casuale",
|
||||
"previousLetter": "Lettera precedente",
|
||||
"nextLetter": "Lettera successiva",
|
||||
"keyboardHint": "Usa le frecce sinistra/destra o spazio per casuale, ESC per tornare indietro",
|
||||
"swipeHint": "Usa le frecce sinistra/destra o scorri per navigare, ESC per tornare indietro"
|
||||
},
|
||||
"folders": {
|
||||
"title": "Cartelle",
|
||||
"subtitle": "Gestisci le tue collezioni",
|
||||
"newFolder": "Nuova Cartella",
|
||||
"creating": "Creazione...",
|
||||
"noFoldersYet": "Nessuna cartella ancora",
|
||||
"folderInfo": "ID: {id} • {totalPairs} coppie",
|
||||
"enterFolderName": "Inserisci il nome della cartella:",
|
||||
"confirmDelete": "Digita \"{name}\" per eliminare:",
|
||||
"myFolders": "Le Mie Cartelle",
|
||||
"publicFolders": "Cartelle Pubbliche",
|
||||
"public": "Pubblica",
|
||||
"private": "Privata",
|
||||
"setPublic": "Imposta Pubblica",
|
||||
"setPrivate": "Imposta Privata",
|
||||
"publicFolderInfo": "{userName} • {totalPairs} coppie",
|
||||
"searchPlaceholder": "Cerca cartelle pubbliche...",
|
||||
"loading": "Caricamento...",
|
||||
"noPublicFolders": "Nessuna cartella pubblica trovata",
|
||||
"unknownUser": "Utente Sconosciuto",
|
||||
"enterNewName": "Inserisci nuovo nome:",
|
||||
"favorite": "Preferito",
|
||||
"unfavorite": "Rimuovi dai preferiti",
|
||||
"pleaseLogin": "Per favore accedi prima"
|
||||
},
|
||||
"folder_id": {
|
||||
"unauthorized": "Non sei il proprietario di questa cartella",
|
||||
"back": "Indietro",
|
||||
"textPairs": "Coppie di Testo",
|
||||
"itemsCount": "{count} elementi",
|
||||
"memorize": "Memorizza",
|
||||
"loadingTextPairs": "Caricamento coppie di testo...",
|
||||
"noTextPairs": "Nessuna coppia di testo in questa cartella",
|
||||
"addNewTextPair": "Aggiungi Nuova Coppia di Testo",
|
||||
"add": "Aggiungi",
|
||||
"updateTextPair": "Aggiorna Coppia di Testo",
|
||||
"update": "Aggiorna",
|
||||
"text1": "Testo 1",
|
||||
"text2": "Testo 2",
|
||||
"language1": "Locale 1",
|
||||
"language2": "Locale 2",
|
||||
"enterLanguageName": "Per favore inserisci il nome della lingua",
|
||||
"edit": "Modifica",
|
||||
"delete": "Elimina",
|
||||
"permissionDenied": "Non hai il permesso di eseguire questa azione",
|
||||
"error": {
|
||||
"update": "Non hai il permesso di aggiornare questo elemento.",
|
||||
"delete": "Non hai il permesso di eliminare questo elemento.",
|
||||
"add": "Non hai il permesso di aggiungere elementi a questa cartella.",
|
||||
"rename": "Non hai il permesso di rinominare questa cartella.",
|
||||
"deleteFolder": "Non hai il permesso di eliminare questa cartella."
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"title": "Impara le Lingue",
|
||||
"description": "Ecco un sito molto utile per aiutarti a imparare quasi tutte le lingue del mondo, incluse quelle costruite.",
|
||||
"explore": "Esplora",
|
||||
"fortune": {
|
||||
"quote": "Stay hungry, stay foolish.",
|
||||
"author": "— Steve Jobs"
|
||||
},
|
||||
"translator": {
|
||||
"name": "Traduttore",
|
||||
"description": "Traduci in qualsiasi lingua e annota con l'Alfabeto Fonetico Internazionale (IPA)"
|
||||
},
|
||||
"textSpeaker": {
|
||||
"name": "Lettore Testo",
|
||||
"description": "Riconosci e leggi il testo ad alta voce, supporta riproduzione in loop e regolazione della velocità"
|
||||
},
|
||||
"srtPlayer": {
|
||||
"name": "Lettore Video SRT",
|
||||
"description": "Riproduci video frase per frase basandoti sui file di sottotitoli SRT per imitare la pronuncia dei madrelingua"
|
||||
},
|
||||
"alphabet": {
|
||||
"name": "Alfabeto",
|
||||
"description": "Inizia a imparare una nuova lingua dall'alfabeto"
|
||||
},
|
||||
"memorize": {
|
||||
"name": "Memorizza",
|
||||
"description": "Lingua A a Lingua B, Lingua B a Lingua A, supporta dettatura"
|
||||
},
|
||||
"dictionary": {
|
||||
"name": "Dizionario",
|
||||
"description": "Cerca parole e frasi con definizioni dettagliate ed esempi"
|
||||
},
|
||||
"moreFeatures": {
|
||||
"name": "Altre Funzionalità",
|
||||
"description": "In sviluppo, resta sintonizzato"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"title": "Accedi",
|
||||
"signUpTitle": "Registrati",
|
||||
"signIn": "Accedi",
|
||||
"signUp": "Registrati",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Conferma Password",
|
||||
"name": "Nome",
|
||||
"username": "Nome Utente",
|
||||
"emailOrUsername": "Email o Nome Utente",
|
||||
"signInButton": "Accedi",
|
||||
"signUpButton": "Registrati",
|
||||
"noAccount": "Non hai un account?",
|
||||
"hasAccount": "Hai già un account?",
|
||||
"signInWithGitHub": "Accedi con GitHub",
|
||||
"signUpWithGitHub": "Registrati con GitHub",
|
||||
"invalidEmail": "Per favore inserisci un indirizzo email valido",
|
||||
"passwordTooShort": "La password deve essere di almeno 8 caratteri",
|
||||
"passwordsNotMatch": "Le password non corrispondono",
|
||||
"nameRequired": "Per favore inserisci il tuo nome",
|
||||
"usernameRequired": "Per favore inserisci un nome utente",
|
||||
"usernameTooShort": "Il nome utente deve essere di almeno 3 caratteri",
|
||||
"usernameInvalid": "Il nome utente può contenere solo lettere, numeri e trattini bassi",
|
||||
"emailRequired": "Per favore inserisci la tua email",
|
||||
"identifierRequired": "Per favore inserisci la tua email o nome utente",
|
||||
"passwordRequired": "Per favore inserisci la tua password",
|
||||
"confirmPasswordRequired": "Per favore conferma la tua password",
|
||||
"loading": "Caricamento...",
|
||||
"confirm": "Conferma",
|
||||
"noAccountLink": "Non hai un account? Registrati",
|
||||
"hasAccountLink": "Hai già un account? Accedi",
|
||||
"usernamePlaceholder": "Nome utente",
|
||||
"emailPlaceholder": "Indirizzo email",
|
||||
"passwordPlaceholder": "Password",
|
||||
"usernameOrEmailPlaceholder": "Nome utente o email",
|
||||
"loginFailed": "Accesso fallito",
|
||||
"signUpFailed": "Registrazione fallita",
|
||||
"fillAllFields": "Per favore compila tutti i campi",
|
||||
"enterCredentials": "Per favore inserisci nome utente e password",
|
||||
"forgotPassword": "Password Dimenticata",
|
||||
"forgotPasswordHint": "Inserisci il tuo indirizzo email e ti invieremo un link per reimpostare la password.",
|
||||
"sendResetEmail": "Invia Email di Reset",
|
||||
"resetPasswordFailed": "Impossibile inviare email di reset",
|
||||
"resetPasswordEmailSent": "Email di reset inviata con successo",
|
||||
"resetPasswordEmailSentHint": "Abbiamo inviato un link per reimpostare la password al tuo indirizzo email. Controlla la tua casella di posta.",
|
||||
"checkYourEmail": "Controlla la tua Email",
|
||||
"backToLogin": "Torna al Login",
|
||||
"resetPassword": "Reimposta Password",
|
||||
"newPassword": "Nuova Password",
|
||||
"invalidToken": "Link Non Valido o Scaduto",
|
||||
"invalidTokenHint": "Questo link per reimpostare la password non è valido o è scaduto. Richiedine uno nuovo.",
|
||||
"requestNewToken": "Richiedi Nuovo Link di Reset",
|
||||
"resetPasswordSuccess": "Password reimpostata con successo",
|
||||
"resetPasswordSuccessTitle": "Reimpostazione Password Completata",
|
||||
"resetPasswordSuccessHint": "La tua password è stata reimpostata con successo. Ora puoi accedere con la tua nuova password."
|
||||
},
|
||||
"memorize": {
|
||||
"folder_selector": {
|
||||
"selectFolder": "Seleziona una cartella",
|
||||
"noFolders": "Nessuna cartella trovata",
|
||||
"folderInfo": "{id}. {name} ({count})"
|
||||
},
|
||||
"memorize": {
|
||||
"answer": "Risposta",
|
||||
"next": "Successivo",
|
||||
"reverse": "Inverti",
|
||||
"dictation": "Dettatura",
|
||||
"noTextPairs": "Nessuna coppia di testo disponibile",
|
||||
"disorder": "Disordina",
|
||||
"previous": "Precedente"
|
||||
},
|
||||
"page": {
|
||||
"unauthorized": "Non sei autorizzato ad accedere a questa cartella"
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
"title": "impara-lingue",
|
||||
"sourceCode": "GitHub",
|
||||
"sign_in": "Accedi",
|
||||
"profile": "Profilo",
|
||||
"folders": "Cartelle",
|
||||
"explore": "Esplora",
|
||||
"favorites": "Preferiti"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "Il Mio Profilo",
|
||||
"email": "Email: {email}",
|
||||
"logout": "Esci"
|
||||
},
|
||||
"srt_player": {
|
||||
"uploadVideo": "Carica Video",
|
||||
"uploadSubtitle": "Carica Sottotitoli",
|
||||
"pause": "Pausa",
|
||||
"play": "Riproduci",
|
||||
"previous": "Precedente",
|
||||
"next": "Successivo",
|
||||
"restart": "Riavvia",
|
||||
"autoPause": "Pausa Automatica ({enabled})",
|
||||
"uploadVideoAndSubtitle": "Per favore carica file video e sottotitoli",
|
||||
"uploadVideoFile": "Per favore carica il file video",
|
||||
"uploadSubtitleFile": "Per favore carica il file sottotitoli",
|
||||
"processingSubtitle": "Elaborazione file sottotitoli...",
|
||||
"needBothFiles": "Sono richiesti sia il file video che quello dei sottotitoli per iniziare a imparare",
|
||||
"videoFile": "File Video",
|
||||
"subtitleFile": "File Sottotitoli",
|
||||
"uploaded": "Caricato",
|
||||
"notUploaded": "Non Caricato",
|
||||
"upload": "Carica",
|
||||
"uploadVideoButton": "Carica Video",
|
||||
"uploadSubtitleButton": "Carica Sottotitoli",
|
||||
"subtitleUploaded": "Sottotitoli Caricati ({count} voci)",
|
||||
"subtitleNotUploaded": "Sottotitoli Non Caricati",
|
||||
"autoPauseStatus": "Pausa Automatica: {enabled}",
|
||||
"on": "Attivo",
|
||||
"off": "Disattivo",
|
||||
"videoUploadFailed": "Caricamento video fallito",
|
||||
"subtitleUploadFailed": "Caricamento sottotitoli fallito",
|
||||
"subtitleLoadSuccess": "Sottotitoli caricati con successo",
|
||||
"subtitleLoadFailed": "Caricamento sottotitoli fallito"
|
||||
},
|
||||
"text_speaker": {
|
||||
"generateIPA": "Genera IPA",
|
||||
"viewSavedItems": "Visualizza Elementi Salvati",
|
||||
"confirmDeleteAll": "Sei sicuro di voler eliminare tutto? (S/N)"
|
||||
},
|
||||
"translator": {
|
||||
"detectLanguage": "rileva lingua",
|
||||
"generateIPA": "genera ipa",
|
||||
"translateInto": "traduci in",
|
||||
"chinese": "Cinese",
|
||||
"english": "Inglese",
|
||||
"french": "Francese",
|
||||
"german": "Tedesco",
|
||||
"italian": "Italiano",
|
||||
"japanese": "Giapponese",
|
||||
"korean": "Coreano",
|
||||
"portuguese": "Portoghese",
|
||||
"russian": "Russo",
|
||||
"spanish": "Spagnolo",
|
||||
"other": "Altro",
|
||||
"translating": "traduzione...",
|
||||
"translate": "traduci",
|
||||
"inputLanguage": "Inserisci una lingua.",
|
||||
"history": "Cronologia",
|
||||
"enterLanguage": "Inserisci lingua",
|
||||
"add_to_folder": {
|
||||
"notAuthenticated": "Non sei autenticato",
|
||||
"chooseFolder": "Scegli una Cartella a cui Aggiungere",
|
||||
"noFolders": "Nessuna cartella trovata",
|
||||
"folderInfo": "{id}. {name}",
|
||||
"close": "Chiudi",
|
||||
"success": "Coppia di testo aggiunta alla cartella",
|
||||
"error": "Impossibile aggiungere coppia di testo alla cartella"
|
||||
},
|
||||
"autoSave": "Salvataggio Automatico"
|
||||
},
|
||||
"dictionary": {
|
||||
"title": "Dizionario",
|
||||
"description": "Cerca parole e frasi con definizioni dettagliate ed esempi",
|
||||
"searchPlaceholder": "Inserisci una parola o frase da cercare...",
|
||||
"searching": "Ricerca...",
|
||||
"search": "Cerca",
|
||||
"languageSettings": "Impostazioni Lingua",
|
||||
"queryLanguage": "Lingua di Query",
|
||||
"queryLanguageHint": "In che lingua è la parola/frase che vuoi cercare",
|
||||
"definitionLanguage": "Lingua delle Definizioni",
|
||||
"definitionLanguageHint": "In che lingua vuoi le definizioni",
|
||||
"otherLanguagePlaceholder": "Oppure inserisci un'altra lingua...",
|
||||
"other": "Altro",
|
||||
"currentSettings": "Impostazioni attuali: Query {queryLang}, Definizione {definitionLang}",
|
||||
"relookup": "Ricerca di nuovo",
|
||||
"saveToFolder": "Salva nella cartella",
|
||||
"loading": "Caricamento...",
|
||||
"noResults": "Nessun risultato trovato",
|
||||
"tryOtherWords": "Prova altre parole o frasi",
|
||||
"welcomeTitle": "Benvenuto nel Dizionario",
|
||||
"welcomeHint": "Inserisci una parola o frase nella casella di ricerca sopra per iniziare a cercare",
|
||||
"lookupFailed": "Ricerca fallita, riprova più tardi",
|
||||
"relookupSuccess": "Ricerca effettuata con successo",
|
||||
"relookupFailed": "Ricerca dizionario fallita",
|
||||
"pleaseLogin": "Per favore accedi prima",
|
||||
"pleaseCreateFolder": "Per favore crea prima una cartella",
|
||||
"savedToFolder": "Salvato nella cartella: {folderName}",
|
||||
"saveFailed": "Salvataggio fallito, riprova più tardi",
|
||||
"definition": "Definizione",
|
||||
"example": "Esempio"
|
||||
},
|
||||
"explore": {
|
||||
"title": "Esplora",
|
||||
"subtitle": "Scopri cartelle pubbliche",
|
||||
"searchPlaceholder": "Cerca cartelle pubbliche...",
|
||||
"loading": "Caricamento...",
|
||||
"noFolders": "Nessuna cartella pubblica trovata",
|
||||
"folderInfo": "{userName} • {totalPairs} coppie",
|
||||
"unknownUser": "Utente Sconosciuto",
|
||||
"favorite": "Preferito",
|
||||
"unfavorite": "Rimuovi dai preferiti",
|
||||
"pleaseLogin": "Per favore accedi prima",
|
||||
"sortByFavorites": "Ordina per preferiti",
|
||||
"sortByFavoritesActive": "Annulla ordinamento per preferiti"
|
||||
},
|
||||
"exploreDetail": {
|
||||
"title": "Dettagli Cartella",
|
||||
"createdBy": "Creata da: {name}",
|
||||
"unknownUser": "Utente Sconosciuto",
|
||||
"totalPairs": "Coppie Totali",
|
||||
"favorites": "Preferiti",
|
||||
"createdAt": "Creata Il",
|
||||
"viewContent": "Visualizza Contenuto",
|
||||
"favorite": "Preferito",
|
||||
"unfavorite": "Rimuovi dai preferiti",
|
||||
"favorited": "Aggiunto ai preferiti",
|
||||
"unfavorited": "Rimosso dai preferiti",
|
||||
"pleaseLogin": "Per favore accedi prima"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "I Miei Preferiti",
|
||||
"subtitle": "Cartelle che hai aggiunto ai preferiti",
|
||||
"loading": "Caricamento...",
|
||||
"noFavorites": "Nessun preferito ancora",
|
||||
"folderInfo": "{userName} • {totalPairs} coppie",
|
||||
"unknownUser": "Utente Sconosciuto"
|
||||
},
|
||||
"user_profile": {
|
||||
"anonymous": "Anonimo",
|
||||
"email": "Email",
|
||||
"verified": "Verificato",
|
||||
"unverified": "Non Verificato",
|
||||
"accountInfo": "Informazioni Account",
|
||||
"userId": "ID Utente",
|
||||
"username": "Nome Utente",
|
||||
"displayName": "Nome Visualizzato",
|
||||
"notSet": "Non Impostato",
|
||||
"memberSince": "Membro Dal",
|
||||
"logout": "Esci",
|
||||
"folders": {
|
||||
"title": "Cartelle",
|
||||
"noFolders": "Nessuna cartella ancora",
|
||||
"folderName": "Nome Cartella",
|
||||
"totalPairs": "Coppie Totali",
|
||||
"createdAt": "Creata Il",
|
||||
"actions": "Azioni",
|
||||
"view": "Visualizza"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,360 +0,0 @@
|
||||
{
|
||||
"alphabet": {
|
||||
"chooseCharacters": "学習したい文字を選択してください",
|
||||
"chooseAlphabetHint": "学習を始めるアルファベットを選択してください",
|
||||
"japanese": "日本語仮名",
|
||||
"english": "英語アルファベット",
|
||||
"uyghur": "ウイグル語アルファベット",
|
||||
"esperanto": "エスペラント語アルファベット",
|
||||
"loading": "読み込み中...",
|
||||
"loadFailed": "読み込みに失敗しました。もう一度お試しください",
|
||||
"hideLetter": "文字を非表示",
|
||||
"showLetter": "文字を表示",
|
||||
"hideIPA": "IPAを非表示",
|
||||
"showIPA": "IPAを表示",
|
||||
"roman": "ローマ字",
|
||||
"letter": "文字",
|
||||
"random": "ランダムモード",
|
||||
"randomNext": "ランダム次へ",
|
||||
"previousLetter": "前の文字",
|
||||
"nextLetter": "次の文字",
|
||||
"keyboardHint": "左右の矢印キーまたはスペースキーでランダム移動、ESCで戻る",
|
||||
"swipeHint": "左右の矢印キーまたはスワイプで移動、ESCで戻る"
|
||||
},
|
||||
"folders": {
|
||||
"title": "フォルダー",
|
||||
"subtitle": "コレクションを管理",
|
||||
"newFolder": "新規フォルダー",
|
||||
"creating": "作成中...",
|
||||
"noFoldersYet": "まだフォルダーがありません",
|
||||
"folderInfo": "ID: {id} • {totalPairs} ペア",
|
||||
"enterFolderName": "フォルダー名を入力:",
|
||||
"confirmDelete": "削除するには「{name}」と入力してください:",
|
||||
"myFolders": "マイフォルダー",
|
||||
"publicFolders": "公開フォルダー",
|
||||
"public": "公開",
|
||||
"private": "非公開",
|
||||
"setPublic": "公開に設定",
|
||||
"setPrivate": "非公開に設定",
|
||||
"publicFolderInfo": "{userName} • {totalPairs} ペア",
|
||||
"searchPlaceholder": "公開フォルダーを検索...",
|
||||
"loading": "読み込み中...",
|
||||
"noPublicFolders": "公開フォルダーが見つかりません",
|
||||
"unknownUser": "不明なユーザー",
|
||||
"enterNewName": "新しい名前を入力:",
|
||||
"favorite": "お気に入り",
|
||||
"unfavorite": "お気に入り解除",
|
||||
"pleaseLogin": "まずログインしてください"
|
||||
},
|
||||
"folder_id": {
|
||||
"unauthorized": "このフォルダーの所有者ではありません",
|
||||
"back": "戻る",
|
||||
"textPairs": "テキストペア",
|
||||
"itemsCount": "{count} 項目",
|
||||
"memorize": "暗記",
|
||||
"loadingTextPairs": "テキストペアを読み込み中...",
|
||||
"noTextPairs": "このフォルダーにはテキストペアがありません",
|
||||
"addNewTextPair": "新しいテキストペアを追加",
|
||||
"add": "追加",
|
||||
"updateTextPair": "テキストペアを更新",
|
||||
"update": "更新",
|
||||
"text1": "テキスト1",
|
||||
"text2": "テキスト2",
|
||||
"language1": "言語1",
|
||||
"language2": "言語2",
|
||||
"enterLanguageName": "言語名を入力してください",
|
||||
"edit": "編集",
|
||||
"delete": "削除",
|
||||
"permissionDenied": "このアクションを実行する権限がありません",
|
||||
"error": {
|
||||
"update": "この項目を更新する権限がありません。",
|
||||
"delete": "この項目を削除する権限がありません。",
|
||||
"add": "このフォルダーに項目を追加する権限がありません。",
|
||||
"rename": "このフォルダーの名前を変更する権限がありません。",
|
||||
"deleteFolder": "このフォルダーを削除する権限がありません。"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"title": "言語を学ぶ",
|
||||
"description": "ここは世界のほぼすべての言語(人工言語を含む)を学ぶのに役立つ非常に便利なウェブサイトです。",
|
||||
"explore": "探索",
|
||||
"fortune": {
|
||||
"quote": "Stay hungry, stay foolish.",
|
||||
"author": "— Steve Jobs"
|
||||
},
|
||||
"translator": {
|
||||
"name": "翻訳者",
|
||||
"description": "あらゆる言語に翻訳し、国際音声記号(IPA)で注釈を付けます"
|
||||
},
|
||||
"textSpeaker": {
|
||||
"name": "テキストスピーカー",
|
||||
"description": "テキストを認識して読み上げ、ループ再生と速度調整をサポート"
|
||||
},
|
||||
"srtPlayer": {
|
||||
"name": "SRTビデオプレーヤー",
|
||||
"description": "SRT字幕ファイルに基づいて文ごとにビデオを再生し、ネイティブスピーカーの発音を模倣"
|
||||
},
|
||||
"alphabet": {
|
||||
"name": "アルファベット",
|
||||
"description": "アルファベットから新しい言語の学習を始めましょう"
|
||||
},
|
||||
"memorize": {
|
||||
"name": "暗記",
|
||||
"description": "言語Aから言語B、言語Bから言語A、書き取りをサポート"
|
||||
},
|
||||
"dictionary": {
|
||||
"name": "辞書",
|
||||
"description": "詳細な定義と例文で単語やフレーズを検索"
|
||||
},
|
||||
"moreFeatures": {
|
||||
"name": "その他の機能",
|
||||
"description": "開発中、お楽しみに"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"title": "サインイン",
|
||||
"signUpTitle": "新規登録",
|
||||
"signIn": "サインイン",
|
||||
"signUp": "新規登録",
|
||||
"email": "メールアドレス",
|
||||
"password": "パスワード",
|
||||
"confirmPassword": "パスワード確認",
|
||||
"name": "名前",
|
||||
"username": "ユーザー名",
|
||||
"emailOrUsername": "メールアドレスまたはユーザー名",
|
||||
"signInButton": "サインイン",
|
||||
"signUpButton": "新規登録",
|
||||
"noAccount": "アカウントをお持ちでないですか?",
|
||||
"hasAccount": "すでにアカウントをお持ちですか?",
|
||||
"signInWithGitHub": "GitHubでサインイン",
|
||||
"signUpWithGitHub": "GitHubで新規登録",
|
||||
"invalidEmail": "有効なメールアドレスを入力してください",
|
||||
"passwordTooShort": "パスワードは8文字以上である必要があります",
|
||||
"passwordsNotMatch": "パスワードが一致しません",
|
||||
"nameRequired": "名前を入力してください",
|
||||
"usernameRequired": "ユーザー名を入力してください",
|
||||
"usernameTooShort": "ユーザー名は3文字以上である必要があります",
|
||||
"usernameInvalid": "ユーザー名には文字、数字、アンダースコアのみ使用できます",
|
||||
"emailRequired": "メールアドレスを入力してください",
|
||||
"identifierRequired": "メールアドレスまたはユーザー名を入力してください",
|
||||
"passwordRequired": "パスワードを入力してください",
|
||||
"confirmPasswordRequired": "パスワードを確認してください",
|
||||
"loading": "読み込み中...",
|
||||
"confirm": "確認",
|
||||
"noAccountLink": "アカウントをお持ちでないですか? 新規登録",
|
||||
"hasAccountLink": "すでにアカウントをお持ちですか? サインイン",
|
||||
"usernamePlaceholder": "ユーザー名",
|
||||
"emailPlaceholder": "メールアドレス",
|
||||
"passwordPlaceholder": "パスワード",
|
||||
"usernameOrEmailPlaceholder": "ユーザー名またはメールアドレス",
|
||||
"loginFailed": "ログインに失敗しました",
|
||||
"signUpFailed": "新規登録に失敗しました",
|
||||
"fillAllFields": "すべてのフィールドに入力してください",
|
||||
"enterCredentials": "ユーザー名とパスワードを入力してください",
|
||||
"forgotPassword": "パスワードをお忘れですか",
|
||||
"forgotPasswordHint": "メールアドレスを入力してください。パスワードリセット用のリンクをお送りします。",
|
||||
"sendResetEmail": "リセットメールを送信",
|
||||
"resetPasswordFailed": "リセットメールの送信に失敗しました",
|
||||
"resetPasswordEmailSent": "リセットメールを送信しました",
|
||||
"resetPasswordEmailSentHint": "パスワードリセット用のリンクをメールでお送りしました。受信トレイをご確認ください。",
|
||||
"checkYourEmail": "メールをご確認ください",
|
||||
"backToLogin": "ログインに戻る",
|
||||
"resetPassword": "パスワードをリセット",
|
||||
"newPassword": "新しいパスワード",
|
||||
"invalidToken": "無効または期限切れのリンク",
|
||||
"invalidTokenHint": "このパスワードリセットリンクは無効または期限切れです。新しいものをリクエストしてください。",
|
||||
"requestNewToken": "新しいリセットリンクをリクエスト",
|
||||
"resetPasswordSuccess": "パスワードのリセットに成功しました",
|
||||
"resetPasswordSuccessTitle": "パスワードリセット完了",
|
||||
"resetPasswordSuccessHint": "パスワードが正常にリセットされました。新しいパスワードでログインできます。"
|
||||
},
|
||||
"memorize": {
|
||||
"folder_selector": {
|
||||
"selectFolder": "フォルダーを選択",
|
||||
"noFolders": "フォルダーが見つかりません",
|
||||
"folderInfo": "{id}. {name} ({count})"
|
||||
},
|
||||
"memorize": {
|
||||
"answer": "答え",
|
||||
"next": "次へ",
|
||||
"reverse": "逆順",
|
||||
"dictation": "書き取り",
|
||||
"noTextPairs": "利用可能なテキストペアがありません",
|
||||
"disorder": "シャッフル",
|
||||
"previous": "前へ"
|
||||
},
|
||||
"page": {
|
||||
"unauthorized": "このフォルダーにアクセスする権限がありません"
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
"title": "learn-languages",
|
||||
"sourceCode": "GitHub",
|
||||
"sign_in": "サインイン",
|
||||
"profile": "プロフィール",
|
||||
"folders": "フォルダー",
|
||||
"explore": "探索",
|
||||
"favorites": "お気に入り"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "マイプロフィール",
|
||||
"email": "メール: {email}",
|
||||
"logout": "ログアウト"
|
||||
},
|
||||
"srt_player": {
|
||||
"uploadVideo": "ビデオをアップロード",
|
||||
"uploadSubtitle": "字幕をアップロード",
|
||||
"pause": "一時停止",
|
||||
"play": "再生",
|
||||
"previous": "前へ",
|
||||
"next": "次へ",
|
||||
"restart": "最初から",
|
||||
"autoPause": "自動一時停止 ({enabled})",
|
||||
"uploadVideoAndSubtitle": "ビデオと字幕ファイルをアップロードしてください",
|
||||
"uploadVideoFile": "ビデオファイルをアップロードしてください",
|
||||
"uploadSubtitleFile": "字幕ファイルをアップロードしてください",
|
||||
"processingSubtitle": "字幕ファイルを処理中...",
|
||||
"needBothFiles": "学習を開始するにはビデオと字幕ファイルの両方が必要です",
|
||||
"videoFile": "ビデオファイル",
|
||||
"subtitleFile": "字幕ファイル",
|
||||
"uploaded": "アップロード済み",
|
||||
"notUploaded": "未アップロード",
|
||||
"upload": "アップロード",
|
||||
"uploadVideoButton": "ビデオをアップロード",
|
||||
"uploadSubtitleButton": "字幕をアップロード",
|
||||
"subtitleUploaded": "字幕をアップロード済み ({count} エントリ)",
|
||||
"subtitleNotUploaded": "字幕がアップロードされていません",
|
||||
"autoPauseStatus": "自動一時停止: {enabled}",
|
||||
"on": "オン",
|
||||
"off": "オフ",
|
||||
"videoUploadFailed": "ビデオのアップロードに失敗しました",
|
||||
"subtitleUploadFailed": "字幕のアップロードに失敗しました",
|
||||
"subtitleLoadSuccess": "字幕の読み込みに成功しました",
|
||||
"subtitleLoadFailed": "字幕の読み込みに失敗しました"
|
||||
},
|
||||
"text_speaker": {
|
||||
"generateIPA": "IPAを生成",
|
||||
"viewSavedItems": "保存済み項目を表示",
|
||||
"confirmDeleteAll": "すべて削除してもよろしいですか? (Y/N)"
|
||||
},
|
||||
"translator": {
|
||||
"detectLanguage": "言語を検出",
|
||||
"generateIPA": "ipaを生成",
|
||||
"translateInto": "翻訳先",
|
||||
"chinese": "中国語",
|
||||
"english": "英語",
|
||||
"french": "フランス語",
|
||||
"german": "ドイツ語",
|
||||
"italian": "イタリア語",
|
||||
"japanese": "日本語",
|
||||
"korean": "韓国語",
|
||||
"portuguese": "ポルトガル語",
|
||||
"russian": "ロシア語",
|
||||
"spanish": "スペイン語",
|
||||
"other": "その他",
|
||||
"translating": "翻訳中...",
|
||||
"translate": "翻訳",
|
||||
"inputLanguage": "言語を入力してください。",
|
||||
"history": "履歴",
|
||||
"enterLanguage": "言語を入力",
|
||||
"add_to_folder": {
|
||||
"notAuthenticated": "認証されていません",
|
||||
"chooseFolder": "追加するフォルダーを選択",
|
||||
"noFolders": "フォルダーが見つかりません",
|
||||
"folderInfo": "{id}. {name}",
|
||||
"close": "閉じる",
|
||||
"success": "テキストペアがフォルダーに追加されました",
|
||||
"error": "テキストペアをフォルダーに追加できませんでした"
|
||||
},
|
||||
"autoSave": "自動保存"
|
||||
},
|
||||
"dictionary": {
|
||||
"title": "辞書",
|
||||
"description": "詳細な定義と例文で単語やフレーズを検索",
|
||||
"searchPlaceholder": "検索する単語やフレーズを入力...",
|
||||
"searching": "検索中...",
|
||||
"search": "検索",
|
||||
"languageSettings": "言語設定",
|
||||
"queryLanguage": "クエリ言語",
|
||||
"queryLanguageHint": "検索したい単語/フレーズの言語",
|
||||
"definitionLanguage": "定義言語",
|
||||
"definitionLanguageHint": "定義を表示する言語",
|
||||
"otherLanguagePlaceholder": "または別の言語を入力...",
|
||||
"other": "その他",
|
||||
"currentSettings": "現在の設定: クエリ {queryLang}, 定義 {definitionLang}",
|
||||
"relookup": "再検索",
|
||||
"saveToFolder": "フォルダーに保存",
|
||||
"loading": "読み込み中...",
|
||||
"noResults": "結果が見つかりません",
|
||||
"tryOtherWords": "別の単語やフレーズを試してください",
|
||||
"welcomeTitle": "辞書へようこそ",
|
||||
"welcomeHint": "上の検索ボックスに単語やフレーズを入力して検索を始めましょう",
|
||||
"lookupFailed": "検索に失敗しました。後でもう一度お試しください",
|
||||
"relookupSuccess": "再検索に成功しました",
|
||||
"relookupFailed": "辞書の再検索に失敗しました",
|
||||
"pleaseLogin": "まずログインしてください",
|
||||
"pleaseCreateFolder": "まずフォルダーを作成してください",
|
||||
"savedToFolder": "フォルダーに保存しました: {folderName}",
|
||||
"saveFailed": "保存に失敗しました。後でもう一度お試しください",
|
||||
"definition": "定義",
|
||||
"example": "例文"
|
||||
},
|
||||
"explore": {
|
||||
"title": "探索",
|
||||
"subtitle": "公開フォルダーを発見",
|
||||
"searchPlaceholder": "公開フォルダーを検索...",
|
||||
"loading": "読み込み中...",
|
||||
"noFolders": "公開フォルダーが見つかりません",
|
||||
"folderInfo": "{userName} • {totalPairs} ペア",
|
||||
"unknownUser": "不明なユーザー",
|
||||
"favorite": "お気に入り",
|
||||
"unfavorite": "お気に入り解除",
|
||||
"pleaseLogin": "まずログインしてください",
|
||||
"sortByFavorites": "お気に入り順に並べ替え",
|
||||
"sortByFavoritesActive": "お気に入り順の並べ替えを解除"
|
||||
},
|
||||
"exploreDetail": {
|
||||
"title": "フォルダー詳細",
|
||||
"createdBy": "作成者: {name}",
|
||||
"unknownUser": "不明なユーザー",
|
||||
"totalPairs": "合計ペア数",
|
||||
"favorites": "お気に入り",
|
||||
"createdAt": "作成日",
|
||||
"viewContent": "コンテンツを表示",
|
||||
"favorite": "お気に入り",
|
||||
"unfavorite": "お気に入り解除",
|
||||
"favorited": "お気に入りに追加しました",
|
||||
"unfavorited": "お気に入りから削除しました",
|
||||
"pleaseLogin": "まずログインしてください"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "マイお気に入り",
|
||||
"subtitle": "お気に入りに追加したフォルダー",
|
||||
"loading": "読み込み中...",
|
||||
"noFavorites": "まだお気に入りがありません",
|
||||
"folderInfo": "{userName} • {totalPairs} ペア",
|
||||
"unknownUser": "不明なユーザー"
|
||||
},
|
||||
"user_profile": {
|
||||
"anonymous": "匿名",
|
||||
"email": "メールアドレス",
|
||||
"verified": "認証済み",
|
||||
"unverified": "未認証",
|
||||
"accountInfo": "アカウント情報",
|
||||
"userId": "ユーザーID",
|
||||
"username": "ユーザー名",
|
||||
"displayName": "表示名",
|
||||
"notSet": "未設定",
|
||||
"memberSince": "登録日",
|
||||
"logout": "ログアウト",
|
||||
"folders": {
|
||||
"title": "フォルダー",
|
||||
"noFolders": "まだフォルダーがありません",
|
||||
"folderName": "フォルダー名",
|
||||
"totalPairs": "合計ペア数",
|
||||
"createdAt": "作成日",
|
||||
"actions": "アクション",
|
||||
"view": "表示"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,360 +0,0 @@
|
||||
{
|
||||
"alphabet": {
|
||||
"chooseCharacters": "배우고 싶은 문자를 선택하세요",
|
||||
"chooseAlphabetHint": "학습을 시작할 알파벳을 선택하세요",
|
||||
"japanese": "일본어 가나",
|
||||
"english": "영어 알파벳",
|
||||
"uyghur": "위구르어 알파벳",
|
||||
"esperanto": "에스페란토 알파벳",
|
||||
"loading": "로딩 중...",
|
||||
"loadFailed": "로딩 실패, 다시 시도해주세요",
|
||||
"hideLetter": "문자 숨기기",
|
||||
"showLetter": "문자 표시",
|
||||
"hideIPA": "IPA 숨기기",
|
||||
"showIPA": "IPA 표시",
|
||||
"roman": "로마자 표기",
|
||||
"letter": "문자",
|
||||
"random": "무작위 모드",
|
||||
"randomNext": "무작위 다음",
|
||||
"previousLetter": "이전 문자",
|
||||
"nextLetter": "다음 문자",
|
||||
"keyboardHint": "왼쪽/오른쪽 화살표 키 또는 스페이스바로 무작위, ESC로 뒤로가기",
|
||||
"swipeHint": "왼쪽/오른쪽 화살표 키 또는 스와이프로 탐색, ESC로 뒤로가기"
|
||||
},
|
||||
"folders": {
|
||||
"title": "폴더",
|
||||
"subtitle": "컬렉션 관리",
|
||||
"newFolder": "새 폴더",
|
||||
"creating": "생성 중...",
|
||||
"noFoldersYet": "아직 폴더가 없습니다",
|
||||
"folderInfo": "ID: {id} • {totalPairs} 쌍",
|
||||
"enterFolderName": "폴더 이름 입력:",
|
||||
"confirmDelete": "삭제하려면 \"{name}\"을(를) 입력하세요:",
|
||||
"myFolders": "내 폴더",
|
||||
"publicFolders": "공개 폴더",
|
||||
"public": "공개",
|
||||
"private": "비공개",
|
||||
"setPublic": "공개로 설정",
|
||||
"setPrivate": "비공개로 설정",
|
||||
"publicFolderInfo": "{userName} • {totalPairs} 쌍",
|
||||
"searchPlaceholder": "공개 폴더 검색...",
|
||||
"loading": "로딩 중...",
|
||||
"noPublicFolders": "공개 폴더를 찾을 수 없습니다",
|
||||
"unknownUser": "알 수 없는 사용자",
|
||||
"enterNewName": "새 이름 입력:",
|
||||
"favorite": "즐겨찾기",
|
||||
"unfavorite": "즐겨찾기 해제",
|
||||
"pleaseLogin": "먼저 로그인해주세요"
|
||||
},
|
||||
"folder_id": {
|
||||
"unauthorized": "이 폴더의 소유자가 아닙니다",
|
||||
"back": "뒤로",
|
||||
"textPairs": "텍스트 쌍",
|
||||
"itemsCount": "{count}개 항목",
|
||||
"memorize": "암기",
|
||||
"loadingTextPairs": "텍스트 쌍 로딩 중...",
|
||||
"noTextPairs": "이 폴더에 텍스트 쌍이 없습니다",
|
||||
"addNewTextPair": "새 텍스트 쌍 추가",
|
||||
"add": "추가",
|
||||
"updateTextPair": "텍스트 쌍 수정",
|
||||
"update": "수정",
|
||||
"text1": "텍스트 1",
|
||||
"text2": "텍스트 2",
|
||||
"language1": "로캘 1",
|
||||
"language2": "로캘 2",
|
||||
"enterLanguageName": "언어 이름을 입력하세요",
|
||||
"edit": "편집",
|
||||
"delete": "삭제",
|
||||
"permissionDenied": "이 작업을 수행할 권한이 없습니다",
|
||||
"error": {
|
||||
"update": "이 항목을 수정할 권한이 없습니다.",
|
||||
"delete": "이 항목을 삭제할 권한이 없습니다.",
|
||||
"add": "이 폴더에 항목을 추가할 권한이 없습니다.",
|
||||
"rename": "이 폴더의 이름을 변경할 권한이 없습니다.",
|
||||
"deleteFolder": "이 폴더를 삭제할 권한이 없습니다."
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"title": "언어 배우기",
|
||||
"description": "세계의 거의 모든 언어(인공어 포함)를 배우는 데 도움이 되는 매우 유용한 웹사이트입니다.",
|
||||
"explore": "탐색",
|
||||
"fortune": {
|
||||
"quote": "Stay hungry, stay foolish.",
|
||||
"author": "— Steve Jobs"
|
||||
},
|
||||
"translator": {
|
||||
"name": "번역기",
|
||||
"description": "모든 언어로 번역하고 국제 음성 기호(IPA)로 주석 달기"
|
||||
},
|
||||
"textSpeaker": {
|
||||
"name": "텍스트 스피커",
|
||||
"description": "텍스트 인식 및 낭독, 반복 재생 및 속도 조절 지원"
|
||||
},
|
||||
"srtPlayer": {
|
||||
"name": "SRT 비디오 플레이어",
|
||||
"description": "SRT 자막 파일을 기반으로 문장별로 비디오를 재생하여 원어민 발음 모방"
|
||||
},
|
||||
"alphabet": {
|
||||
"name": "알파벳",
|
||||
"description": "알파벳부터 새로운 언어 학습 시작"
|
||||
},
|
||||
"memorize": {
|
||||
"name": "암기",
|
||||
"description": "언어 A에서 언어 B로, 언어 B에서 언어 A로, 받아쓰기 지원"
|
||||
},
|
||||
"dictionary": {
|
||||
"name": "사전",
|
||||
"description": "상세한 정의와 예문으로 단어 및 구문 검색"
|
||||
},
|
||||
"moreFeatures": {
|
||||
"name": "더 많은 기능",
|
||||
"description": "개발 중, 기대해주세요"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"title": "로그인",
|
||||
"signUpTitle": "회원가입",
|
||||
"signIn": "로그인",
|
||||
"signUp": "회원가입",
|
||||
"email": "이메일",
|
||||
"password": "비밀번호",
|
||||
"confirmPassword": "비밀번호 확인",
|
||||
"name": "이름",
|
||||
"username": "사용자명",
|
||||
"emailOrUsername": "이메일 또는 사용자명",
|
||||
"signInButton": "로그인",
|
||||
"signUpButton": "회원가입",
|
||||
"noAccount": "계정이 없으신가요?",
|
||||
"hasAccount": "이미 계정이 있으신가요?",
|
||||
"signInWithGitHub": "GitHub로 로그인",
|
||||
"signUpWithGitHub": "GitHub로 회원가입",
|
||||
"invalidEmail": "유효한 이메일 주소를 입력하세요",
|
||||
"passwordTooShort": "비밀번호는 최소 8자 이상이어야 합니다",
|
||||
"passwordsNotMatch": "비밀번호가 일치하지 않습니다",
|
||||
"nameRequired": "이름을 입력하세요",
|
||||
"usernameRequired": "사용자명을 입력하세요",
|
||||
"usernameTooShort": "사용자명은 최소 3자 이상이어야 합니다",
|
||||
"usernameInvalid": "사용자명은 문자, 숫자, 밑줄만 포함할 수 있습니다",
|
||||
"emailRequired": "이메일을 입력하세요",
|
||||
"identifierRequired": "이메일 또는 사용자명을 입력하세요",
|
||||
"passwordRequired": "비밀번호를 입력하세요",
|
||||
"confirmPasswordRequired": "비밀번호를 확인하세요",
|
||||
"loading": "로딩 중...",
|
||||
"confirm": "확인",
|
||||
"noAccountLink": "계정이 없으신가요? 회원가입",
|
||||
"hasAccountLink": "이미 계정이 있으신가요? 로그인",
|
||||
"usernamePlaceholder": "사용자명",
|
||||
"emailPlaceholder": "이메일 주소",
|
||||
"passwordPlaceholder": "비밀번호",
|
||||
"usernameOrEmailPlaceholder": "사용자명 또는 이메일",
|
||||
"loginFailed": "로그인 실패",
|
||||
"signUpFailed": "회원가입 실패",
|
||||
"fillAllFields": "모든 필드를 입력하세요",
|
||||
"enterCredentials": "사용자명과 비밀번호를 입력하세요",
|
||||
"forgotPassword": "비밀번호 찾기",
|
||||
"forgotPasswordHint": "이메일 주소를 입력하시면 비밀번호 재설정 링크를 보내드립니다.",
|
||||
"sendResetEmail": "재설정 이메일 보내기",
|
||||
"resetPasswordFailed": "재설정 이메일 전송 실패",
|
||||
"resetPasswordEmailSent": "재설정 이메일이 전송되었습니다",
|
||||
"resetPasswordEmailSentHint": "비밀번호 재설정 링크를 이메일로 보냈습니다. 받은 편지함을 확인해주세요.",
|
||||
"checkYourEmail": "이메일을 확인하세요",
|
||||
"backToLogin": "로그인으로 돌아가기",
|
||||
"resetPassword": "비밀번호 재설정",
|
||||
"newPassword": "새 비밀번호",
|
||||
"invalidToken": "유효하지 않거나 만료된 링크",
|
||||
"invalidTokenHint": "이 비밀번호 재설정 링크는 유효하지 않거나 만료되었습니다. 새로 요청해 주세요.",
|
||||
"requestNewToken": "새 재설정 링크 요청",
|
||||
"resetPasswordSuccess": "비밀번호 재설정 성공",
|
||||
"resetPasswordSuccessTitle": "비밀번호 재설정 완료",
|
||||
"resetPasswordSuccessHint": "비밀번호가 성공적으로 재설정되었습니다. 새 비밀번호로 로그인할 수 있습니다."
|
||||
},
|
||||
"memorize": {
|
||||
"folder_selector": {
|
||||
"selectFolder": "폴더 선택",
|
||||
"noFolders": "폴더를 찾을 수 없습니다",
|
||||
"folderInfo": "{id}. {name} ({count})"
|
||||
},
|
||||
"memorize": {
|
||||
"answer": "정답",
|
||||
"next": "다음",
|
||||
"reverse": "반대",
|
||||
"dictation": "받아쓰기",
|
||||
"noTextPairs": "사용 가능한 텍스트 쌍이 없습니다",
|
||||
"disorder": "무작위",
|
||||
"previous": "이전"
|
||||
},
|
||||
"page": {
|
||||
"unauthorized": "이 폴더에 접근할 권한이 없습니다"
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
"title": "learn-languages",
|
||||
"sourceCode": "GitHub",
|
||||
"sign_in": "로그인",
|
||||
"profile": "프로필",
|
||||
"folders": "폴더",
|
||||
"explore": "탐색",
|
||||
"favorites": "즐겨찾기"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "내 프로필",
|
||||
"email": "이메일: {email}",
|
||||
"logout": "로그아웃"
|
||||
},
|
||||
"srt_player": {
|
||||
"uploadVideo": "비디오 업로드",
|
||||
"uploadSubtitle": "자막 업로드",
|
||||
"pause": "일시정지",
|
||||
"play": "재생",
|
||||
"previous": "이전",
|
||||
"next": "다음",
|
||||
"restart": "다시 시작",
|
||||
"autoPause": "자동 일시정지 ({enabled})",
|
||||
"uploadVideoAndSubtitle": "비디오와 자막 파일을 업로드하세요",
|
||||
"uploadVideoFile": "비디오 파일을 업로드하세요",
|
||||
"uploadSubtitleFile": "자막 파일을 업로드하세요",
|
||||
"processingSubtitle": "자막 파일 처리 중...",
|
||||
"needBothFiles": "학습을 시작하려면 비디오와 자막 파일이 모두 필요합니다",
|
||||
"videoFile": "비디오 파일",
|
||||
"subtitleFile": "자막 파일",
|
||||
"uploaded": "업로드됨",
|
||||
"notUploaded": "업로드되지 않음",
|
||||
"upload": "업로드",
|
||||
"uploadVideoButton": "비디오 업로드",
|
||||
"uploadSubtitleButton": "자막 업로드",
|
||||
"subtitleUploaded": "자막 업로드됨 ({count}개 항목)",
|
||||
"subtitleNotUploaded": "자막 업로드되지 않음",
|
||||
"autoPauseStatus": "자동 일시정지: {enabled}",
|
||||
"on": "켜기",
|
||||
"off": "끄기",
|
||||
"videoUploadFailed": "비디오 업로드 실패",
|
||||
"subtitleUploadFailed": "자막 업로드 실패",
|
||||
"subtitleLoadSuccess": "자막 로드 성공",
|
||||
"subtitleLoadFailed": "자막 로드 실패"
|
||||
},
|
||||
"text_speaker": {
|
||||
"generateIPA": "IPA 생성",
|
||||
"viewSavedItems": "저장된 항목 보기",
|
||||
"confirmDeleteAll": "모든 것을 삭제하시겠습니까? (Y/N)"
|
||||
},
|
||||
"translator": {
|
||||
"detectLanguage": "언어 감지",
|
||||
"generateIPA": "IPA 생성",
|
||||
"translateInto": "번역할 언어",
|
||||
"chinese": "중국어",
|
||||
"english": "영어",
|
||||
"french": "프랑스어",
|
||||
"german": "독일어",
|
||||
"italian": "이탈리아어",
|
||||
"japanese": "일본어",
|
||||
"korean": "한국어",
|
||||
"portuguese": "포르투갈어",
|
||||
"russian": "러시아어",
|
||||
"spanish": "스페인어",
|
||||
"other": "기타",
|
||||
"translating": "번역 중...",
|
||||
"translate": "번역",
|
||||
"inputLanguage": "언어를 입력하세요.",
|
||||
"history": "기록",
|
||||
"enterLanguage": "언어 입력",
|
||||
"add_to_folder": {
|
||||
"notAuthenticated": "인증되지 않았습니다",
|
||||
"chooseFolder": "추가할 폴더 선택",
|
||||
"noFolders": "폴더를 찾을 수 없습니다",
|
||||
"folderInfo": "{id}. {name}",
|
||||
"close": "닫기",
|
||||
"success": "텍스트 쌍이 폴더에 추가됨",
|
||||
"error": "폴더에 텍스트 쌍 추가 실패"
|
||||
},
|
||||
"autoSave": "자동 저장"
|
||||
},
|
||||
"dictionary": {
|
||||
"title": "사전",
|
||||
"description": "상세한 정의와 예문으로 단어 및 구문 검색",
|
||||
"searchPlaceholder": "검색할 단어나 구문을 입력하세요...",
|
||||
"searching": "검색 중...",
|
||||
"search": "검색",
|
||||
"languageSettings": "언어 설정",
|
||||
"queryLanguage": "질의 언어",
|
||||
"queryLanguageHint": "검색할 단어/구문의 언어",
|
||||
"definitionLanguage": "정의 언어",
|
||||
"definitionLanguageHint": "정의를 표시할 언어",
|
||||
"otherLanguagePlaceholder": "또는 다른 언어 입력...",
|
||||
"other": "기타",
|
||||
"currentSettings": "현재 설정: 질의 {queryLang}, 정의 {definitionLang}",
|
||||
"relookup": "다시 검색",
|
||||
"saveToFolder": "폴더에 저장",
|
||||
"loading": "로딩 중...",
|
||||
"noResults": "검색 결과 없음",
|
||||
"tryOtherWords": "다른 단어나 구문을 시도하세요",
|
||||
"welcomeTitle": "사전에 오신 것을 환영합니다",
|
||||
"welcomeHint": "위의 검색 상자에 단어나 구문을 입력하여 검색을 시작하세요",
|
||||
"lookupFailed": "검색 실패, 나중에 다시 시도하세요",
|
||||
"relookupSuccess": "다시 검색 성공",
|
||||
"relookupFailed": "사전 다시 검색 실패",
|
||||
"pleaseLogin": "먼저 로그인하세요",
|
||||
"pleaseCreateFolder": "먼저 폴더를 생성하세요",
|
||||
"savedToFolder": "폴더에 저장됨: {folderName}",
|
||||
"saveFailed": "저장 실패, 나중에 다시 시도하세요",
|
||||
"definition": "정의",
|
||||
"example": "예문"
|
||||
},
|
||||
"explore": {
|
||||
"title": "탐색",
|
||||
"subtitle": "공개 폴더 발견",
|
||||
"searchPlaceholder": "공개 폴더 검색...",
|
||||
"loading": "로딩 중...",
|
||||
"noFolders": "공개 폴더를 찾을 수 없습니다",
|
||||
"folderInfo": "{userName} • {totalPairs} 쌍",
|
||||
"unknownUser": "알 수 없는 사용자",
|
||||
"favorite": "즐겨찾기",
|
||||
"unfavorite": "즐겨찾기 해제",
|
||||
"pleaseLogin": "먼저 로그인해주세요",
|
||||
"sortByFavorites": "즐겨찾기순 정렬",
|
||||
"sortByFavoritesActive": "즐겨찾기순 정렬 해제"
|
||||
},
|
||||
"exploreDetail": {
|
||||
"title": "폴더 상세",
|
||||
"createdBy": "생성자: {name}",
|
||||
"unknownUser": "알 수 없는 사용자",
|
||||
"totalPairs": "총 쌍",
|
||||
"favorites": "즐겨찾기",
|
||||
"createdAt": "생성일",
|
||||
"viewContent": "내용 보기",
|
||||
"favorite": "즐겨찾기",
|
||||
"unfavorite": "즐겨찾기 해제",
|
||||
"favorited": "즐겨찾기됨",
|
||||
"unfavorited": "즐겨찾기 해제됨",
|
||||
"pleaseLogin": "먼저 로그인해주세요"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "내 즐겨찾기",
|
||||
"subtitle": "즐겨찾기한 폴더",
|
||||
"loading": "로딩 중...",
|
||||
"noFavorites": "아직 즐겨찾기가 없습니다",
|
||||
"folderInfo": "{userName} • {totalPairs} 쌍",
|
||||
"unknownUser": "알 수 없는 사용자"
|
||||
},
|
||||
"user_profile": {
|
||||
"anonymous": "익명",
|
||||
"email": "이메일",
|
||||
"verified": "인증됨",
|
||||
"unverified": "미인증",
|
||||
"accountInfo": "계정 정보",
|
||||
"userId": "사용자 ID",
|
||||
"username": "사용자명",
|
||||
"displayName": "표시 이름",
|
||||
"notSet": "설정되지 않음",
|
||||
"memberSince": "가입일",
|
||||
"logout": "로그아웃",
|
||||
"folders": {
|
||||
"title": "폴더",
|
||||
"noFolders": "아직 폴더가 없습니다",
|
||||
"folderName": "폴더 이름",
|
||||
"totalPairs": "총 쌍",
|
||||
"createdAt": "생성일",
|
||||
"actions": "작업",
|
||||
"view": "보기"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,360 +0,0 @@
|
||||
{
|
||||
"alphabet": {
|
||||
"chooseCharacters": "ئۆگەنمەكچى بولغان ھەرپلەرنى تاللاڭ",
|
||||
"chooseAlphabetHint": "ئۆگىنىشنى باشلاش ئۈچۈن بىر ئېلىپبە تاللاڭ",
|
||||
"japanese": "ياپون يېزىقى",
|
||||
"english": "ئىنگلىز ئېلىپبەسى",
|
||||
"uyghur": "ئۇيغۇر ئېلىپبەسى",
|
||||
"esperanto": "ئېسپېرانتو ئېلىپبەسى",
|
||||
"loading": "يۈكلىنىۋاتىدۇ...",
|
||||
"loadFailed": "يۈكلەش مەغلۇپ بولدى، قايتا سىناڭ",
|
||||
"hideLetter": "ھەرپنى يوشۇر",
|
||||
"showLetter": "ھەرپنى كۆرسەت",
|
||||
"hideIPA": "IPA نى يوشۇر",
|
||||
"showIPA": "IPA نى كۆرسەت",
|
||||
"roman": "لاتىن يېزىقى",
|
||||
"letter": "ھەرپ",
|
||||
"random": "ئىختىيارىي ھالەت",
|
||||
"randomNext": "ئىختىيارىي كېيىنكى",
|
||||
"previousLetter": "ئالدىنقى ھەرپ",
|
||||
"nextLetter": "كېيىنكى ھەرپ",
|
||||
"keyboardHint": "سول/ئوڭ يا ئوق كۇنۇپكىلىرىنى ياكى بوشلۇق كۇنۇپكىسىنى ئىختىيارىي ئالماشتۇرۇش ئۈچۈن ئىشلىتىڭ، ESC قايتىش ئۈچۈن",
|
||||
"swipeHint": "سول/ئوڭ يا ئوق كۇنۇپكىلىرىنى ياكى سىيرىشنى ئىشلىتىپ يۆنىلىڭ، ESC قايتىش ئۈچۈن"
|
||||
},
|
||||
"folders": {
|
||||
"title": "قىسقۇچلار",
|
||||
"subtitle": "يىغىپ ساقلاشلىرىڭىزنى باشقۇرۇڭ",
|
||||
"newFolder": "يېڭى قىسقۇچ",
|
||||
"creating": "قۇرۇۋاتىدۇ...",
|
||||
"noFoldersYet": "تېخى قىسقۇچ يوق",
|
||||
"folderInfo": "كىملىك: {id} • {totalPairs} جۈپ",
|
||||
"enterFolderName": "قىسقۇچ ئاتىنى كىرگۈزۈڭ:",
|
||||
"confirmDelete": "ئۆچۈرۈش ئۈچۈن \"{name}\" نى كىرگۈزۈڭ:",
|
||||
"myFolders": "قىسقۇچلىرىم",
|
||||
"publicFolders": "ئاممىۋى قىسقۇچلار",
|
||||
"public": "ئاممىۋى",
|
||||
"private": "شەخسىي",
|
||||
"setPublic": "ئاممىۋى قىلىپ تەڭشە",
|
||||
"setPrivate": "شەخسىي قىلىپ تەڭشە",
|
||||
"publicFolderInfo": "{userName} • {totalPairs} جۈپ",
|
||||
"searchPlaceholder": "ئاممىۋى قىسقۇچلارنى ئىزدەڭ...",
|
||||
"loading": "يۈكلىنىۋاتىدۇ...",
|
||||
"noPublicFolders": "ئاممىۋى قىسقۇچ تېپىلمىدى",
|
||||
"unknownUser": "نامەلۇم ئىشلەتكۈچى",
|
||||
"enterNewName": "يېڭى ئات كىرگۈزۈڭ:",
|
||||
"favorite": "يىغىپ ساقلا",
|
||||
"unfavorite": "يىغىپ ساقلاشنى بىكار قىل",
|
||||
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ"
|
||||
},
|
||||
"folder_id": {
|
||||
"unauthorized": "بۇ قىسقۇچنىڭ ئىگىسى ئەمەسسىز",
|
||||
"back": "قايتىش",
|
||||
"textPairs": "تېكىست جۈپلىرى",
|
||||
"itemsCount": "{count} تۈر",
|
||||
"memorize": "يادلاش",
|
||||
"loadingTextPairs": "تېكىست جۈپلىرى يۈكلىنىۋاتىدۇ...",
|
||||
"noTextPairs": "بۇ قىسقۇچتا تېكىست جۈپى يوق",
|
||||
"addNewTextPair": "يېڭى تېكىست جۈپى قوشۇش",
|
||||
"add": "قوشۇش",
|
||||
"updateTextPair": "تېكىست جۈپىنى يېڭىلاش",
|
||||
"update": "يېڭىلاش",
|
||||
"text1": "تېكىست 1",
|
||||
"text2": "تېكىست 2",
|
||||
"language1": "تىل 1",
|
||||
"language2": "تىل 2",
|
||||
"enterLanguageName": "تىل ئاتىنى كىرگۈزۈڭ",
|
||||
"edit": "تەھرىرلەش",
|
||||
"delete": "ئۆچۈرۈش",
|
||||
"permissionDenied": "بۇ مەشغۇلاتنى ئېلىپ بېرىش ھوقۇقىڭىز يوق",
|
||||
"error": {
|
||||
"update": "بۇ تۈرنى يېڭىلاش ھوقۇقىڭىز يوق.",
|
||||
"delete": "بۇ تۈرنى ئۆچۈرۈش ھوقۇقىڭىز يوق.",
|
||||
"add": "بۇ قىسقۇچقا تۈر قوشۇش ھوقۇقىڭىز يوق.",
|
||||
"rename": "بۇ قىسقۇچنىڭ ئاتىنى ئۆزگەرتىش ھوقۇقىڭىز يوق.",
|
||||
"deleteFolder": "بۇ قىسقۇچنى ئۆچۈرۈش ھوقۇقىڭىز يوق."
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"title": "تىل ئۆگىنىش",
|
||||
"description": "بۇ دۇنيادىكى almost ھەر بىر تىلنى، جۈملىدىن سۈنئىي تىللارنى ئۆگىنىشىڭىزگە ياردەم بېرىدىغان ئىنتايىن قوللىنىشلىق تور بېكەت.",
|
||||
"explore": "ئىزدىنىش",
|
||||
"fortune": {
|
||||
"quote": "ئاچ قورساق، ئەخمەق بولۇپ تۇرۇڭ.",
|
||||
"author": "— Steve Jobs"
|
||||
},
|
||||
"translator": {
|
||||
"name": "تەرجىمان",
|
||||
"description": "ھەر قانداق تىلغا تەرجىمە قىلىڭ ۋە خەلقئارالىق فونېتىكىلىق ئېلىپبە (IPA) بىلەن ئىزاھلاڭ"
|
||||
},
|
||||
"textSpeaker": {
|
||||
"name": "تېكىست ئوقۇغۇچى",
|
||||
"description": "تېكىستنى تونۇپ ۋە ئۈنلۈك ئوقۇپ بېرىدۇ، دەۋرىي قويۇش ۋە سۈرئەت تەڭشەشنى قوللايدۇ"
|
||||
},
|
||||
"srtPlayer": {
|
||||
"name": "SRT ۋىدېئو قويغۇچ",
|
||||
"description": "SRT تر پودكاست ھۆججەتلىرىگە ئاساسەن ۋىدېئولارنى جۈمە بويىچە قويۇپ، ئانا تىللىقلارنىڭ تەلەپپۇزىنى دوراڭ"
|
||||
},
|
||||
"alphabet": {
|
||||
"name": "ئېلىپبە",
|
||||
"description": "يېڭى بىر تىلنى ئېلىپبەدىن باشلاپ ئۆگىنىڭ"
|
||||
},
|
||||
"memorize": {
|
||||
"name": "يادلاش",
|
||||
"description": "تىل A دىن تىل B گە، تىل B دىن تىل A غا، دىكتات قىلىشنى قوللايدۇ"
|
||||
},
|
||||
"dictionary": {
|
||||
"name": "لۇغەت",
|
||||
"description": "سۆزلەر ۋە ئىبارىلەرنى تەپسىلىي ئېنىقلىما ۋە مىساللار بىلەن ئىزدەڭ"
|
||||
},
|
||||
"moreFeatures": {
|
||||
"name": "تېخىمۇ كۆپ ئىقتىدارلار",
|
||||
"description": "تەرەققىيات ئاستىدا، دىققەت قىلىپ تۇرۇڭ"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"title": "كىرىش",
|
||||
"signUpTitle": "تىزىملىتىش",
|
||||
"signIn": "كىرىش",
|
||||
"signUp": "تىزىملىتىش",
|
||||
"email": "ئېلخەت",
|
||||
"password": "پارول",
|
||||
"confirmPassword": "پارولنى جەزىملەڭ",
|
||||
"name": "ئىسىم",
|
||||
"username": "ئىشلەتكۈچى ئاتى",
|
||||
"emailOrUsername": "ئېلخەت ياكى ئىشلەتكۈچى ئاتى",
|
||||
"signInButton": "كىرىش",
|
||||
"signUpButton": "تىزىملىتىش",
|
||||
"noAccount": "ھېساباتىڭىز يوقمۇ؟",
|
||||
"hasAccount": "ھېساباتىڭىز بارمۇ؟",
|
||||
"signInWithGitHub": "GitHub بىلەن كىرىش",
|
||||
"signUpWithGitHub": "GitHub بىلەن تىزىملىتىش",
|
||||
"invalidEmail": "ئۈنۈملۈك ئېلخەت ئادرېسى كىرگۈزۈڭ",
|
||||
"passwordTooShort": "پارول ئەڭ ئاز 8 ھەرپ بولۇشى كېرەك",
|
||||
"passwordsNotMatch": "پاروللار ماس كەلمەيدۇ",
|
||||
"nameRequired": "ئىسىمىڭىزنى كىرگۈزۈڭ",
|
||||
"usernameRequired": "ئىشلەتكۈچى ئاتىنى كىرگۈزۈڭ",
|
||||
"usernameTooShort": "ئىشلەتكۈچى ئاتى ئەڭ ئاز 3 ھەرپ بولۇشى كېرەك",
|
||||
"usernameInvalid": "ئىشلەتكۈچى ئاتى پەقەت ھەرپ، سان ۋە ئاستى سىزىقنى ئۆز ئىچىگە ئالىدۇ",
|
||||
"emailRequired": "ئېلخەت كىرگۈزۈڭ",
|
||||
"identifierRequired": "ئېلخەت ياكى ئىشلەتكۈچى ئاتىنى كىرگۈزۈڭ",
|
||||
"passwordRequired": "پارول كىرگۈزۈڭ",
|
||||
"confirmPasswordRequired": "پارولنى جەزىملەڭ",
|
||||
"loading": "يۈكلىنىۋاتىدۇ...",
|
||||
"confirm": "جەزىملەش",
|
||||
"noAccountLink": "ھېساباتىڭىز يوقمۇ؟ تىزىملىتىڭ",
|
||||
"hasAccountLink": "ھېساباتىڭىز بارمۇ؟ كىرىڭ",
|
||||
"usernamePlaceholder": "ئىشلەتكۈچى ئاتى",
|
||||
"emailPlaceholder": "ئېلخەت ئادرېسى",
|
||||
"passwordPlaceholder": "پارول",
|
||||
"usernameOrEmailPlaceholder": "ئىشلەتكۈچى ئاتى ياكى ئېلخەت",
|
||||
"loginFailed": "كىرىش مەغلۇپ بولدى",
|
||||
"signUpFailed": "تىزىملىتىش مەغلۇپ بولدى",
|
||||
"fillAllFields": "ھەممە بۆلەكلەرنى تولدۇرۇڭ",
|
||||
"enterCredentials": "ئىشلەتكۈچى ئاتى ۋە پارول كىرگۈزۈڭ",
|
||||
"forgotPassword": "پارولنى ئۇنتۇپ قالدىڭىزمۇ",
|
||||
"forgotPasswordHint": "ئېلخەت ئادرېسىڭىزنى كىرگۈزۈڭ، بىز سىزگە پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسى ئەۋەتىمىز.",
|
||||
"sendResetEmail": "ئەسلىگە قايتۇرۇش ئېلخېتى ئەۋەتىش",
|
||||
"resetPasswordFailed": "ئەسلىگە قايتۇرۇش ئېلخېتى ئەۋەتىش مەغلۇپ بولدى",
|
||||
"resetPasswordEmailSent": "ئەسلىگە قايتۇرۇش ئېلخېتى مۇۋەپپەقىيەتلىك ئەۋەتىلدى",
|
||||
"resetPasswordEmailSentHint": "پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسىنى ئېلخەت ئادرېسىڭىزغا ئەۋەتتۇق. ئېلخەت ساندۇقىڭىزنى تەكشۈرۈڭ.",
|
||||
"checkYourEmail": "ئېلخېتىڭىزنى تەكشۈرۈڭ",
|
||||
"backToLogin": "كىرىشكە قايتىش",
|
||||
"resetPassword": "پارولنى ئەسلىگە قايتۇرۇش",
|
||||
"newPassword": "يېڭى پارول",
|
||||
"invalidToken": "ئۇلانما ئىناۋەتسىز ياكى ۋاقتى ئۆتكەن",
|
||||
"invalidTokenHint": "بۇ پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسى ئىناۋەتسىز ياكى ۋاقتى ئۆتكەن. يېڭىدىن سوراڭ.",
|
||||
"requestNewToken": "يېڭى ئەسلىگە قايتۇرۇش ئۇلانمىسى سوراش",
|
||||
"resetPasswordSuccess": "پارول مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى",
|
||||
"resetPasswordSuccessTitle": "پارول ئەسلىگە قايتۇرۇش تاماملاندى",
|
||||
"resetPasswordSuccessHint": "پارولىڭىز مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى. يېڭى پارول بىلەن كىرسىڭىز بولىدۇ."
|
||||
},
|
||||
"memorize": {
|
||||
"folder_selector": {
|
||||
"selectFolder": "بىر قىسقۇچ تاللاڭ",
|
||||
"noFolders": "قىسقۇچ تېپىلمىدى",
|
||||
"folderInfo": "{id}. {name} ({count})"
|
||||
},
|
||||
"memorize": {
|
||||
"answer": "جاۋاب",
|
||||
"next": "كېيىنكى",
|
||||
"reverse": "تەتۈر",
|
||||
"dictation": "دىكتات",
|
||||
"noTextPairs": "تېكىست جۈپى يوق",
|
||||
"disorder": "قالايمىقانلاشتۇرۇش",
|
||||
"previous": "ئالدىنقى"
|
||||
},
|
||||
"page": {
|
||||
"unauthorized": "بۇ قىسقۇچنى زىيارەت قىلىش ھوقۇقىڭىز يوق"
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
"title": "تىل-ئۆگىنىش",
|
||||
"sourceCode": "GitHub",
|
||||
"sign_in": "كىرىش",
|
||||
"profile": "شەخسىي ئۇچۇر",
|
||||
"folders": "قىسقۇچلار",
|
||||
"explore": "ئىزدىنىش",
|
||||
"favorites": "يىغىپ ساقلانغانلار"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "شەخسىي ئۇچۇرۇم",
|
||||
"email": "ئېلخەت: {email}",
|
||||
"logout": "چىكىنىش"
|
||||
},
|
||||
"srt_player": {
|
||||
"uploadVideo": "ۋىدېئو يۈكلەش",
|
||||
"uploadSubtitle": "تر پودكاست يۈكلەش",
|
||||
"pause": "ۋاقىتلىق توختىتىش",
|
||||
"play": "قويۇش",
|
||||
"previous": "ئالدىنقى",
|
||||
"next": "كېيىنكى",
|
||||
"restart": "قايتا باشلاش",
|
||||
"autoPause": "ئاپتوماتىك توختىتىش ({enabled})",
|
||||
"uploadVideoAndSubtitle": "ۋىدېئو ۋە تر پودكاست ھۆججەتلىرىنى يۈكلەڭ",
|
||||
"uploadVideoFile": "ۋىدېئو ھۆججىتى يۈكلەڭ",
|
||||
"uploadSubtitleFile": "تر پودكاست ھۆججىتى يۈكلەڭ",
|
||||
"processingSubtitle": "تر پودكاست ھۆججىتى بىر تەرەپ قىلىنىۋاتىدۇ...",
|
||||
"needBothFiles": "ئۆگىنىشنى باشلاش ئۈچۈن ۋىدېئو ۋە تر پودكاست ھۆججەتلىرى كېرەك",
|
||||
"videoFile": "ۋىدېئو ھۆججىتى",
|
||||
"subtitleFile": "تر پودكاست ھۆججىتى",
|
||||
"uploaded": "يۈكلەندى",
|
||||
"notUploaded": "يۈكلەنمىدى",
|
||||
"upload": "يۈكلەش",
|
||||
"uploadVideoButton": "ۋىدېئو يۈكلەش",
|
||||
"uploadSubtitleButton": "تر پودكاست يۈكلەش",
|
||||
"subtitleUploaded": "تر پودكاست يۈكلەندى ({count} تۈر)",
|
||||
"subtitleNotUploaded": "تر پودكاست يۈكلەنمىدى",
|
||||
"autoPauseStatus": "ئاپتوماتىك توختىتىش: {enabled}",
|
||||
"on": "ئوچۇق",
|
||||
"off": "تاقاق",
|
||||
"videoUploadFailed": "ۋىدېئو يۈكلەش مەغلۇپ بولدى",
|
||||
"subtitleUploadFailed": "تر پودكاست يۈكلەش مەغلۇپ بولدى",
|
||||
"subtitleLoadSuccess": "تر پودكاست مۇۋەپپەقىيەتلىك يۈكلەندى",
|
||||
"subtitleLoadFailed": "تر پودكاست يۈكلەش مەغلۇپ بولدى"
|
||||
},
|
||||
"text_speaker": {
|
||||
"generateIPA": "IPA ھاسىل قىلىش",
|
||||
"viewSavedItems": "ساقلانغان تۈرلەرنى كۆرۈش",
|
||||
"confirmDeleteAll": "ھەممىنى ئۆچۈرەمسىز؟ (Y/N)"
|
||||
},
|
||||
"translator": {
|
||||
"detectLanguage": "تىلنى تونۇش",
|
||||
"generateIPA": "ipa ھاسىل قىلىش",
|
||||
"translateInto": "تەرجىمە قىلىش",
|
||||
"chinese": "خەنزۇچە",
|
||||
"english": "ئىنگلىزچە",
|
||||
"french": "فىرانسۇزچە",
|
||||
"german": "گېرمانچە",
|
||||
"italian": "ئىتاليانچە",
|
||||
"japanese": "ياپونچە",
|
||||
"korean": "كورېيەچە",
|
||||
"portuguese": "پورتۇگالچە",
|
||||
"russian": "رۇسچە",
|
||||
"spanish": "ئىسپانچە",
|
||||
"other": "باشقا",
|
||||
"translating": "تەرجىمە قىلىنىۋاتىدۇ...",
|
||||
"translate": "تەرجىمە قىلىش",
|
||||
"inputLanguage": "بىر تىل كىرگۈزۈڭ.",
|
||||
"history": "تارىخ",
|
||||
"enterLanguage": "تىل كىرگۈزۈڭ",
|
||||
"add_to_folder": {
|
||||
"notAuthenticated": "تىزىملىتىلمىدىڭىز",
|
||||
"chooseFolder": "قوشۇش ئۈچۈن قىسقۇچ تاللاڭ",
|
||||
"noFolders": "قىسقۇچ تېپىلمىدى",
|
||||
"folderInfo": "{id}. {name}",
|
||||
"close": "تاقاش",
|
||||
"success": "تېكىست جۈپى قىسقۇچقا قوشۇلدى",
|
||||
"error": "تېكىست جۈپىنى قىسقۇچقا قوشۇش مەغلۇپ بولدى"
|
||||
},
|
||||
"autoSave": "ئاپتوماتىك ساقلاش"
|
||||
},
|
||||
"dictionary": {
|
||||
"title": "لۇغەت",
|
||||
"description": "سۆزلەر ۋە ئىبارىلەرنى تەپسىلىي ئېنىقلىما ۋە مىساللار بىلەن ئىزدەڭ",
|
||||
"searchPlaceholder": "ئىزدەش ئۈچۈن سۆز ياكى ئىبارە كىرگۈزۈڭ...",
|
||||
"searching": "ئىزدەۋاتىدۇ...",
|
||||
"search": "ئىزدەش",
|
||||
"languageSettings": "تىل تەڭشەكلىرى",
|
||||
"queryLanguage": "سۈرۈشتۈرۈش تىلى",
|
||||
"queryLanguageHint": "ئىزدىمەكچى بولغان سۆز/ئىبارە قايسى تىلدا",
|
||||
"definitionLanguage": "ئېنىقلىما تىلى",
|
||||
"definitionLanguageHint": "ئېنىقلىمىلارنى قايسى تىلدا كۆرمەكچى",
|
||||
"otherLanguagePlaceholder": "ياكى باشقا تىل كىرگۈزۈڭ...",
|
||||
"other": "باشقا",
|
||||
"currentSettings": "نۆۋەتتىكى تەڭشەكلەر: سۈرۈشتۈرۈش {queryLang}، ئېنىقلىما {definitionLang}",
|
||||
"relookup": "قايتا ئىزدەش",
|
||||
"saveToFolder": "قىسقۇچقا ساقلاش",
|
||||
"loading": "يۈكلىنىۋاتىدۇ...",
|
||||
"noResults": "نەتىجە تېپىلمىدى",
|
||||
"tryOtherWords": "باشقا سۆز ياكى ئىبارىلەرنى سىناڭ",
|
||||
"welcomeTitle": "لۇغەتكە خۇش كەلدىڭىز",
|
||||
"welcomeHint": "ئىزدەشنى باشلاش ئۈچۈن يۇقىرىدىكى ئىزدەش رامكىسىغا سۆز ياكى ئىبارە كىرگۈزۈڭ",
|
||||
"lookupFailed": "ئىزدەش مەغلۇپ بولدى، كېيىن قايتا سىناڭ",
|
||||
"relookupSuccess": "مۇۋەپپەقىيەتلىك قايتا ئىزدەلدى",
|
||||
"relookupFailed": "لۇغەت قايتا ئىزدەش مەغلۇپ بولدى",
|
||||
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ",
|
||||
"pleaseCreateFolder": "ئاۋۋال بىر قىسقۇچ قۇرۇڭ",
|
||||
"savedToFolder": "قىسقۇچقا ساقلاندى: {folderName}",
|
||||
"saveFailed": "ساقلاش مەغلۇپ بولدى، كېيىن قايتا سىناڭ",
|
||||
"definition": "ئېنىقلىما",
|
||||
"example": "مىسال"
|
||||
},
|
||||
"explore": {
|
||||
"title": "ئىزدىنىش",
|
||||
"subtitle": "ئاممىۋى قىسقۇچلارنى بايقاڭ",
|
||||
"searchPlaceholder": "ئاممىۋى قىسقۇچلارنى ئىزدەڭ...",
|
||||
"loading": "يۈكلىنىۋاتىدۇ...",
|
||||
"noFolders": "ئاممىۋى قىسقۇچ تېپىلمىدى",
|
||||
"folderInfo": "{userName} • {totalPairs} جۈپ",
|
||||
"unknownUser": "نامەلۇم ئىشلەتكۈچى",
|
||||
"favorite": "يىغىپ ساقلا",
|
||||
"unfavorite": "يىغىپ ساقلاشنى بىكار قىل",
|
||||
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ",
|
||||
"sortByFavorites": "يىغىپ ساقلاش بويىچە تەرتىپلەش",
|
||||
"sortByFavoritesActive": "يىغىپ ساقلاش بويىچە تەرتىپلەشنى بىكار قىلىش"
|
||||
},
|
||||
"exploreDetail": {
|
||||
"title": "قىسقۇچ تەپسىلاتلىرى",
|
||||
"createdBy": "قۇرغۇچى: {name}",
|
||||
"unknownUser": "نامەلۇم ئىشلەتكۈچى",
|
||||
"totalPairs": "جەمئىي جۈپ",
|
||||
"favorites": "يىغىپ ساقلانغانلار",
|
||||
"createdAt": "قۇرۇلغان ۋاقتى",
|
||||
"viewContent": "مەزمۇننى كۆرۈش",
|
||||
"favorite": "يىغىپ ساقلا",
|
||||
"unfavorite": "يىغىپ ساقلاشنى بىكار قىل",
|
||||
"favorited": "يىغىپ ساقلاندى",
|
||||
"unfavorited": "يىغىپ ساقلاش بىكار قىلىندى",
|
||||
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "يىغىپ ساقلىغانلىرىم",
|
||||
"subtitle": "يىغىپ ساقلىغان قىسقۇچلىرىڭىز",
|
||||
"loading": "يۈكلىنىۋاتىدۇ...",
|
||||
"noFavorites": "تېخى يىغىپ ساقلانمىغان",
|
||||
"folderInfo": "{userName} • {totalPairs} جۈپ",
|
||||
"unknownUser": "نامەلۇم ئىشلەتكۈچى"
|
||||
},
|
||||
"user_profile": {
|
||||
"anonymous": "نامسىز",
|
||||
"email": "ئېلخەت",
|
||||
"verified": "دەلىللەنگەن",
|
||||
"unverified": "دەلىللەنمىگەن",
|
||||
"accountInfo": "ھېسابات ئۇچۇرلىرى",
|
||||
"userId": "ئىشلەتكۈچى كىملىكى",
|
||||
"username": "ئىشلەتكۈچى ئاتى",
|
||||
"displayName": "كۆرسىتىش ئاتى",
|
||||
"notSet": "تەڭشەلمىگەن",
|
||||
"memberSince": "ئەزا بولغاندىن بېرى",
|
||||
"logout": "چىكىنىش",
|
||||
"folders": {
|
||||
"title": "قىسقۇچلار",
|
||||
"noFolders": "تېخى قىسقۇچ يوق",
|
||||
"folderName": "قىسقۇچ ئاتى",
|
||||
"totalPairs": "جەمئىي جۈپ",
|
||||
"createdAt": "قۇرۇلغان ۋاقتى",
|
||||
"actions": "مەشغۇلاتلار",
|
||||
"view": "كۆرۈش"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,360 +0,0 @@
|
||||
{
|
||||
"alphabet": {
|
||||
"chooseCharacters": "请选择您想学习的字符",
|
||||
"chooseAlphabetHint": "选择一种语言的字母表开始学习",
|
||||
"japanese": "日语假名",
|
||||
"english": "英文字母",
|
||||
"uyghur": "维吾尔字母",
|
||||
"esperanto": "世界语字母",
|
||||
"loading": "加载中...",
|
||||
"loadFailed": "加载失败,请重试",
|
||||
"hideLetter": "隐藏字母",
|
||||
"showLetter": "显示字母",
|
||||
"hideIPA": "隐藏IPA",
|
||||
"showIPA": "显示IPA",
|
||||
"roman": "罗马音",
|
||||
"letter": "字母",
|
||||
"random": "随机模式",
|
||||
"randomNext": "随机下一个",
|
||||
"previousLetter": "上一个字母",
|
||||
"nextLetter": "下一个字母",
|
||||
"keyboardHint": "使用左右箭头键或空格键随机切换,ESC键返回",
|
||||
"swipeHint": "使用左右箭头键或滑动切换字母"
|
||||
},
|
||||
"folders": {
|
||||
"title": "文件夹",
|
||||
"subtitle": "管理您的集合",
|
||||
"newFolder": "新建文件夹",
|
||||
"creating": "创建中...",
|
||||
"noFoldersYet": "还没有文件夹",
|
||||
"folderInfo": "ID: {id} • {totalPairs} 个文本对",
|
||||
"enterFolderName": "输入文件夹名称:",
|
||||
"confirmDelete": "输入 \"{name}\" 以删除:",
|
||||
"myFolders": "我的文件夹",
|
||||
"publicFolders": "公开文件夹",
|
||||
"public": "公开",
|
||||
"private": "私有",
|
||||
"setPublic": "设为公开",
|
||||
"setPrivate": "设为私有",
|
||||
"publicFolderInfo": "{userName} • {totalPairs} 个文本对",
|
||||
"searchPlaceholder": "搜索公开文件夹...",
|
||||
"loading": "加载中...",
|
||||
"noPublicFolders": "没有找到公开文件夹",
|
||||
"unknownUser": "未知用户",
|
||||
"enterNewName": "输入新名称:",
|
||||
"favorite": "收藏",
|
||||
"unfavorite": "取消收藏",
|
||||
"pleaseLogin": "请先登录"
|
||||
},
|
||||
"folder_id": {
|
||||
"unauthorized": "您不是此文件夹的所有者",
|
||||
"back": "返回",
|
||||
"textPairs": "文本对",
|
||||
"itemsCount": "{count} 个项目",
|
||||
"memorize": "记忆",
|
||||
"loadingTextPairs": "加载文本对中...",
|
||||
"noTextPairs": "此文件夹中没有文本对",
|
||||
"addNewTextPair": "添加新文本对",
|
||||
"add": "添加",
|
||||
"updateTextPair": "更新文本对",
|
||||
"update": "更新",
|
||||
"text1": "文本1",
|
||||
"text2": "文本2",
|
||||
"language1": "语言1",
|
||||
"language2": "语言2",
|
||||
"enterLanguageName": "请输入语言名称",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"permissionDenied": "您没有权限执行此操作",
|
||||
"error": {
|
||||
"update": "您没有权限更新此项目",
|
||||
"delete": "您没有权限删除此项目",
|
||||
"add": "您没有权限向此文件夹添加项目",
|
||||
"rename": "您没有权限重命名此文件夹",
|
||||
"deleteFolder": "您没有权限删除此文件夹"
|
||||
}
|
||||
},
|
||||
"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,支持听写"
|
||||
},
|
||||
"dictionary": {
|
||||
"name": "词典",
|
||||
"description": "查询单词和短语,提供详细的释义和例句"
|
||||
},
|
||||
"moreFeatures": {
|
||||
"name": "更多功能",
|
||||
"description": "开发中,敬请期待"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"title": "登录",
|
||||
"signUpTitle": "注册",
|
||||
"signIn": "登录",
|
||||
"signUp": "注册",
|
||||
"email": "邮箱",
|
||||
"password": "密码",
|
||||
"confirmPassword": "确认密码",
|
||||
"name": "用户名",
|
||||
"username": "用户名",
|
||||
"emailOrUsername": "邮箱或用户名",
|
||||
"signInButton": "登录",
|
||||
"signUpButton": "注册",
|
||||
"noAccount": "还没有账户?",
|
||||
"hasAccount": "已有账户?",
|
||||
"signInWithGitHub": "使用 GitHub 登录",
|
||||
"signUpWithGitHub": "使用 GitHub 注册",
|
||||
"invalidEmail": "请输入有效的邮箱地址",
|
||||
"passwordTooShort": "密码至少需要8个字符",
|
||||
"passwordsNotMatch": "两次输入的密码不匹配",
|
||||
"nameRequired": "请输入用户名",
|
||||
"usernameRequired": "请输入用户名",
|
||||
"usernameTooShort": "用户名至少需要3个字符",
|
||||
"usernameInvalid": "用户名只能包含字母、数字和下划线",
|
||||
"emailRequired": "请输入邮箱",
|
||||
"identifierRequired": "请输入邮箱或用户名",
|
||||
"passwordRequired": "请输入密码",
|
||||
"confirmPasswordRequired": "请确认密码",
|
||||
"loading": "加载中...",
|
||||
"confirm": "确认",
|
||||
"noAccountLink": "没有账号?去注册",
|
||||
"hasAccountLink": "已有账号?去登录",
|
||||
"usernamePlaceholder": "用户名",
|
||||
"emailPlaceholder": "邮箱地址",
|
||||
"passwordPlaceholder": "密码",
|
||||
"usernameOrEmailPlaceholder": "用户名或邮箱地址",
|
||||
"loginFailed": "登录失败",
|
||||
"signUpFailed": "注册失败",
|
||||
"fillAllFields": "请填写所有字段",
|
||||
"enterCredentials": "请输入用户名和密码",
|
||||
"forgotPassword": "忘记密码",
|
||||
"forgotPasswordHint": "输入您的邮箱地址,我们将向您发送重置密码的链接。",
|
||||
"sendResetEmail": "发送重置邮件",
|
||||
"resetPasswordFailed": "发送重置邮件失败",
|
||||
"resetPasswordEmailSent": "重置邮件已发送",
|
||||
"resetPasswordEmailSentHint": "我们已向您的邮箱发送了密码重置链接,请查收。",
|
||||
"checkYourEmail": "请查收邮件",
|
||||
"backToLogin": "返回登录",
|
||||
"resetPassword": "重置密码",
|
||||
"newPassword": "新密码",
|
||||
"invalidToken": "链接无效或已过期",
|
||||
"invalidTokenHint": "此密码重置链接无效或已过期,请重新申请。",
|
||||
"requestNewToken": "重新申请重置链接",
|
||||
"resetPasswordSuccess": "密码重置成功",
|
||||
"resetPasswordSuccessTitle": "密码重置完成",
|
||||
"resetPasswordSuccessHint": "您的密码已成功重置,现在可以使用新密码登录了。"
|
||||
},
|
||||
"memorize": {
|
||||
"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": "文件夹",
|
||||
"explore": "探索",
|
||||
"favorites": "收藏"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "我的个人资料",
|
||||
"email": "邮箱:{email}",
|
||||
"logout": "退出登录"
|
||||
},
|
||||
"srt_player": {
|
||||
"upload": "上传",
|
||||
"uploadVideo": "上传视频",
|
||||
"uploadSubtitle": "上传字幕",
|
||||
"pause": "暂停",
|
||||
"play": "播放",
|
||||
"previous": "上句",
|
||||
"next": "下句",
|
||||
"restart": "句首",
|
||||
"autoPause": "自动暂停({enabled})",
|
||||
"uploadVideoAndSubtitle": "请上传视频和字幕文件",
|
||||
"uploadVideoFile": "请上传视频文件",
|
||||
"uploadSubtitleFile": "请上传字幕文件",
|
||||
"processingSubtitle": "字幕文件正在处理中...",
|
||||
"needBothFiles": "需要同时上传视频和字幕文件才能开始学习",
|
||||
"videoFile": "视频文件",
|
||||
"subtitleFile": "字幕文件",
|
||||
"uploaded": "已上传",
|
||||
"notUploaded": "未上传",
|
||||
"uploadVideoButton": "上传视频",
|
||||
"uploadSubtitleButton": "上传字幕",
|
||||
"subtitleUploaded": "字幕已上传 ({count} 条)",
|
||||
"subtitleNotUploaded": "字幕未上传",
|
||||
"autoPauseStatus": "自动暂停: {enabled}",
|
||||
"on": "开",
|
||||
"off": "关",
|
||||
"videoUploadFailed": "视频上传失败",
|
||||
"subtitleUploadFailed": "字幕上传失败",
|
||||
"subtitleLoadSuccess": "字幕加载成功",
|
||||
"subtitleLoadFailed": "字幕加载失败"
|
||||
},
|
||||
"text_speaker": {
|
||||
"generateIPA": "生成IPA",
|
||||
"viewSavedItems": "查看保存项",
|
||||
"confirmDeleteAll": "确定删光吗?(Y/N)"
|
||||
},
|
||||
"translator": {
|
||||
"detectLanguage": "检测语言",
|
||||
"generateIPA": "生成国际音标",
|
||||
"translateInto": "翻译为",
|
||||
"chinese": "中文",
|
||||
"english": "英文",
|
||||
"french": "法语",
|
||||
"german": "德语",
|
||||
"italian": "意大利语",
|
||||
"japanese": "日语",
|
||||
"korean": "韩语",
|
||||
"portuguese": "葡萄牙语",
|
||||
"russian": "俄语",
|
||||
"spanish": "西班牙语",
|
||||
"other": "其他",
|
||||
"translating": "翻译中...",
|
||||
"translate": "翻译",
|
||||
"inputLanguage": "请输入语言。",
|
||||
"history": "历史记录",
|
||||
"enterLanguage": "输入语言",
|
||||
"add_to_folder": {
|
||||
"notAuthenticated": "您未通过身份验证",
|
||||
"chooseFolder": "选择要添加到的文件夹",
|
||||
"noFolders": "未找到文件夹",
|
||||
"folderInfo": "{id}. {name}",
|
||||
"close": "关闭",
|
||||
"success": "文本对已添加到文件夹",
|
||||
"error": "添加文本对到文件夹失败"
|
||||
},
|
||||
"autoSave": "自动保存"
|
||||
},
|
||||
"dictionary": {
|
||||
"title": "词典",
|
||||
"description": "查询单词和短语,提供详细的释义和例句",
|
||||
"searchPlaceholder": "输入要查询的单词或短语...",
|
||||
"searching": "查询中...",
|
||||
"search": "查询",
|
||||
"languageSettings": "语言设置",
|
||||
"queryLanguage": "查询语言",
|
||||
"queryLanguageHint": "你要查询的单词/短语是什么语言",
|
||||
"definitionLanguage": "释义语言",
|
||||
"definitionLanguageHint": "你希望用什么语言查看释义",
|
||||
"otherLanguagePlaceholder": "或输入其他语言...",
|
||||
"other": "其他",
|
||||
"currentSettings": "当前设置:查询 {queryLang},释义 {definitionLang}",
|
||||
"relookup": "重新查询",
|
||||
"saveToFolder": "保存到文件夹",
|
||||
"loading": "加载中...",
|
||||
"noResults": "未找到结果",
|
||||
"tryOtherWords": "尝试其他单词或短语",
|
||||
"welcomeTitle": "欢迎使用词典",
|
||||
"welcomeHint": "在上方搜索框中输入单词或短语开始查询",
|
||||
"lookupFailed": "查询失败,请稍后重试",
|
||||
"relookupSuccess": "已重新查询",
|
||||
"relookupFailed": "词典重新查询失败",
|
||||
"pleaseLogin": "请先登录",
|
||||
"pleaseCreateFolder": "请先创建文件夹",
|
||||
"savedToFolder": "已保存到文件夹:{folderName}",
|
||||
"saveFailed": "保存失败,请稍后重试",
|
||||
"definition": "释义",
|
||||
"example": "例句"
|
||||
},
|
||||
"explore": {
|
||||
"title": "探索",
|
||||
"subtitle": "发现公开文件夹",
|
||||
"searchPlaceholder": "搜索公开文件夹...",
|
||||
"loading": "加载中...",
|
||||
"noFolders": "没有找到公开文件夹",
|
||||
"folderInfo": "{userName} • {totalPairs} 个文本对",
|
||||
"unknownUser": "未知用户",
|
||||
"favorite": "收藏",
|
||||
"unfavorite": "取消收藏",
|
||||
"pleaseLogin": "请先登录",
|
||||
"sortByFavorites": "按收藏数排序",
|
||||
"sortByFavoritesActive": "取消按收藏数排序"
|
||||
},
|
||||
"exploreDetail": {
|
||||
"title": "文件夹详情",
|
||||
"createdBy": "创建者:{name}",
|
||||
"unknownUser": "未知用户",
|
||||
"totalPairs": "词对数量",
|
||||
"favorites": "收藏数",
|
||||
"createdAt": "创建时间",
|
||||
"viewContent": "查看内容",
|
||||
"favorite": "收藏",
|
||||
"unfavorite": "取消收藏",
|
||||
"favorited": "已收藏",
|
||||
"unfavorited": "已取消收藏",
|
||||
"pleaseLogin": "请先登录"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "我的收藏",
|
||||
"subtitle": "收藏的公开文件夹",
|
||||
"loading": "加载中...",
|
||||
"noFavorites": "还没有收藏任何文件夹",
|
||||
"folderInfo": "{userName} • {totalPairs} 个文本对",
|
||||
"unknownUser": "未知用户"
|
||||
},
|
||||
"user_profile": {
|
||||
"anonymous": "匿名",
|
||||
"email": "邮箱",
|
||||
"verified": "已验证",
|
||||
"unverified": "未验证",
|
||||
"accountInfo": "账户信息",
|
||||
"userId": "用户ID",
|
||||
"username": "用户名",
|
||||
"displayName": "显示名称",
|
||||
"notSet": "未设置",
|
||||
"memberSince": "注册时间",
|
||||
"logout": "登出",
|
||||
"folders": {
|
||||
"title": "文件夹",
|
||||
"noFolders": "还没有文件夹",
|
||||
"folderName": "文件夹名称",
|
||||
"totalPairs": "文本对数量",
|
||||
"createdAt": "创建时间",
|
||||
"actions": "操作",
|
||||
"view": "查看"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
70
package.json
70
package.json
@@ -2,62 +2,36 @@
|
||||
"name": "learn-languages",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"type": "module",
|
||||
"license": "GPL-3.0-only",
|
||||
"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.4.2",
|
||||
"@prisma/client": "7.4.2",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-auth": "^1.4.10",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "16.1.1",
|
||||
"next-intl": "^4.7.0",
|
||||
"nodemailer": "^8.0.2",
|
||||
"openai": "^6.27.0",
|
||||
"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",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"unstorage": "^1.17.3",
|
||||
"winston": "^3.19.0",
|
||||
"zod": "^4.3.5",
|
||||
"zustand": "^5.0.11"
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"unstorage": "^1.17.2",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@better-auth/cli": "^1.4.10",
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.51.0",
|
||||
"@typescript-eslint/parser": "^8.51.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "16.1.1",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"prisma": "^7.4.2",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
6956
pnpm-lock.yaml
generated
6956
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,262 +0,0 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Visibility" AS ENUM ('PRIVATE', 'PUBLIC');
|
||||
|
||||
-- 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,
|
||||
"displayUsername" TEXT,
|
||||
"username" TEXT 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")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "pairs" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"language1" TEXT NOT NULL,
|
||||
"language2" TEXT 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,
|
||||
"visibility" "Visibility" NOT NULL DEFAULT 'PRIVATE',
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "folders_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "folder_favorites" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"folder_id" INTEGER NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "folder_favorites_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dictionary_lookups" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"user_id" TEXT,
|
||||
"text" TEXT NOT NULL,
|
||||
"query_lang" TEXT NOT NULL,
|
||||
"definition_lang" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"dictionary_item_id" INTEGER,
|
||||
"normalized_text" TEXT NOT NULL DEFAULT '',
|
||||
|
||||
CONSTRAINT "dictionary_lookups_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dictionary_items" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"frequency" INTEGER NOT NULL DEFAULT 1,
|
||||
"standard_form" TEXT NOT NULL,
|
||||
"query_lang" TEXT NOT NULL,
|
||||
"definition_lang" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "dictionary_items_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dictionary_entries" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"item_id" INTEGER NOT NULL,
|
||||
"ipa" TEXT,
|
||||
"definition" TEXT NOT NULL,
|
||||
"part_of_speech" TEXT,
|
||||
"example" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "dictionary_entries_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "translation_history" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"user_id" TEXT,
|
||||
"source_text" TEXT NOT NULL,
|
||||
"source_language" TEXT NOT NULL,
|
||||
"target_language" TEXT NOT NULL,
|
||||
"translated_text" TEXT NOT NULL,
|
||||
"source_ipa" TEXT,
|
||||
"target_ipa" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "translation_history_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_username_key" ON "user"("username");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "session_token_key" ON "session"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "session_userId_idx" ON "session"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "account_userId_idx" ON "account"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "verification_identifier_idx" ON "verification"("identifier");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "pairs_folder_id_idx" ON "pairs"("folder_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "pairs_folder_id_language1_language2_text1_text2_key" ON "pairs"("folder_id", "language1", "language2", "text1", "text2");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "folders_user_id_idx" ON "folders"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "folders_visibility_idx" ON "folders"("visibility");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "folder_favorites_user_id_idx" ON "folder_favorites"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "folder_favorites_folder_id_idx" ON "folder_favorites"("folder_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "folder_favorites_user_id_folder_id_key" ON "folder_favorites"("user_id", "folder_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_lookups_user_id_idx" ON "dictionary_lookups"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_lookups_created_at_idx" ON "dictionary_lookups"("created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_lookups_normalized_text_idx" ON "dictionary_lookups"("normalized_text");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_items_standard_form_idx" ON "dictionary_items"("standard_form");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_items_query_lang_definition_lang_idx" ON "dictionary_items"("query_lang", "definition_lang");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "dictionary_items_standard_form_query_lang_definition_lang_key" ON "dictionary_items"("standard_form", "query_lang", "definition_lang");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_entries_item_id_idx" ON "dictionary_entries"("item_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_entries_created_at_idx" ON "dictionary_entries"("created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "translation_history_user_id_idx" ON "translation_history"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "translation_history_created_at_idx" ON "translation_history"("created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "translation_history_source_text_target_language_idx" ON "translation_history"("source_text", "target_language");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "translation_history_translated_text_source_language_target__idx" ON "translation_history"("translated_text", "source_language", "target_language");
|
||||
|
||||
-- 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;
|
||||
|
||||
-- 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 "folder_favorites" ADD CONSTRAINT "folder_favorites_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "folder_favorites" ADD CONSTRAINT "folder_favorites_folder_id_fkey" FOREIGN KEY ("folder_id") REFERENCES "folders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_dictionary_item_id_fkey" FOREIGN KEY ("dictionary_item_id") REFERENCES "dictionary_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "dictionary_entries" ADD CONSTRAINT "dictionary_entries_item_id_fkey" FOREIGN KEY ("item_id") REFERENCES "dictionary_items"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "translation_history" ADD CONSTRAINT "translation_history_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL 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,200 +0,0 @@
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../generated/prisma"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id
|
||||
name String
|
||||
email String @unique
|
||||
emailVerified Boolean @default(false)
|
||||
image String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
displayUsername String?
|
||||
username String @unique
|
||||
accounts Account[]
|
||||
dictionaryLookUps DictionaryLookUp[]
|
||||
folders Folder[]
|
||||
folderFavorites FolderFavorite[]
|
||||
sessions Session[]
|
||||
translationHistories TranslationHistory[]
|
||||
|
||||
@@map("user")
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id
|
||||
expiresAt DateTime
|
||||
token String @unique
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@map("session")
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id
|
||||
accountId String
|
||||
providerId String
|
||||
userId String
|
||||
accessToken String?
|
||||
refreshToken String?
|
||||
idToken String?
|
||||
accessTokenExpiresAt DateTime?
|
||||
refreshTokenExpiresAt DateTime?
|
||||
scope String?
|
||||
password String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@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")
|
||||
}
|
||||
|
||||
model Pair {
|
||||
id Int @id @default(autoincrement())
|
||||
language1 String
|
||||
language2 String
|
||||
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, language1, language2, text1, text2])
|
||||
@@index([folderId])
|
||||
@@map("pairs")
|
||||
}
|
||||
|
||||
enum Visibility {
|
||||
PRIVATE
|
||||
PUBLIC
|
||||
}
|
||||
|
||||
model Folder {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
userId String @map("user_id")
|
||||
visibility Visibility @default(PRIVATE)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
pairs Pair[]
|
||||
favorites FolderFavorite[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([visibility])
|
||||
@@map("folders")
|
||||
}
|
||||
|
||||
model FolderFavorite {
|
||||
id Int @id @default(autoincrement())
|
||||
userId String @map("user_id")
|
||||
folderId Int @map("folder_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, folderId])
|
||||
@@index([userId])
|
||||
@@index([folderId])
|
||||
@@map("folder_favorites")
|
||||
}
|
||||
|
||||
model DictionaryLookUp {
|
||||
id Int @id @default(autoincrement())
|
||||
userId String? @map("user_id")
|
||||
text String
|
||||
queryLang String @map("query_lang")
|
||||
definitionLang String @map("definition_lang")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
dictionaryItemId Int? @map("dictionary_item_id")
|
||||
normalizedText String @default("") @map("normalized_text")
|
||||
dictionaryItem DictionaryItem? @relation(fields: [dictionaryItemId], references: [id])
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
|
||||
@@index([userId])
|
||||
@@index([createdAt])
|
||||
@@index([normalizedText])
|
||||
@@map("dictionary_lookups")
|
||||
}
|
||||
|
||||
model DictionaryItem {
|
||||
id Int @id @default(autoincrement())
|
||||
frequency Int @default(1)
|
||||
standardForm String @map("standard_form")
|
||||
queryLang String @map("query_lang")
|
||||
definitionLang String @map("definition_lang")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
entries DictionaryEntry[]
|
||||
lookups DictionaryLookUp[]
|
||||
|
||||
@@unique([standardForm, queryLang, definitionLang])
|
||||
@@index([standardForm])
|
||||
@@index([queryLang, definitionLang])
|
||||
@@map("dictionary_items")
|
||||
}
|
||||
|
||||
model DictionaryEntry {
|
||||
id Int @id @default(autoincrement())
|
||||
itemId Int @map("item_id")
|
||||
ipa String?
|
||||
definition String
|
||||
partOfSpeech String? @map("part_of_speech")
|
||||
example String
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
item DictionaryItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([itemId])
|
||||
@@index([createdAt])
|
||||
@@map("dictionary_entries")
|
||||
}
|
||||
|
||||
model TranslationHistory {
|
||||
id Int @id @default(autoincrement())
|
||||
userId String? @map("user_id")
|
||||
sourceText String @map("source_text")
|
||||
sourceLanguage String @map("source_language")
|
||||
targetLanguage String @map("target_language")
|
||||
translatedText String @map("translated_text")
|
||||
sourceIpa String? @map("source_ipa")
|
||||
targetIpa String? @map("target_ipa")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
|
||||
@@index([userId])
|
||||
@@index([createdAt])
|
||||
@@index([sourceText, targetLanguage])
|
||||
@@index([translatedText, sourceLanguage, targetLanguage])
|
||||
@@map("translation_history")
|
||||
}
|
||||
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,102 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardBody } from "@/design-system/base/card";
|
||||
import { Input } from "@/design-system/base/input";
|
||||
import { PrimaryButton } from "@/design-system/base/button";
|
||||
import { VStack } from "@/design-system/layout/stack";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const t = useTranslations("auth");
|
||||
const [email, setEmail] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
const handleResetRequest = async () => {
|
||||
if (!email) {
|
||||
toast.error(t("emailRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const { error } = await authClient.requestPasswordReset({
|
||||
email,
|
||||
redirectTo: "/reset-password",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message ?? t("resetPasswordFailed"));
|
||||
} else {
|
||||
setSent(true);
|
||||
toast.success(t("resetPasswordEmailSent"));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
if (sent) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<Card className="w-96">
|
||||
<CardBody>
|
||||
<VStack gap={4} align="center" justify="center">
|
||||
<h1 className="text-2xl font-bold text-center w-full">
|
||||
{t("checkYourEmail")}
|
||||
</h1>
|
||||
<p className="text-center text-gray-600">
|
||||
{t("resetPasswordEmailSentHint")}
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-primary-500 hover:underline"
|
||||
>
|
||||
{t("backToLogin")}
|
||||
</Link>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<Card className="w-96">
|
||||
<CardBody>
|
||||
<VStack gap={4} align="center" justify="center">
|
||||
<h1 className="text-3xl font-bold text-center w-full">
|
||||
{t("forgotPassword")}
|
||||
</h1>
|
||||
<p className="text-center text-gray-600 text-sm">
|
||||
{t("forgotPasswordHint")}
|
||||
</p>
|
||||
<VStack gap={0} align="center" justify="center" className="w-full">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder={t("emailPlaceholder")}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</VStack>
|
||||
<PrimaryButton
|
||||
onClick={handleResetRequest}
|
||||
loading={loading}
|
||||
fullWidth
|
||||
>
|
||||
{t("sendResetEmail")}
|
||||
</PrimaryButton>
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-center text-primary-500 hover:underline"
|
||||
>
|
||||
{t("backToLogin")}
|
||||
</Link>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardBody } from "@/design-system/base/card";
|
||||
import { Input } from "@/design-system/base/input";
|
||||
import { PrimaryButton } from "@/design-system/base/button";
|
||||
import { VStack } from "@/design-system/layout/stack";
|
||||
|
||||
export default function LoginPage() {
|
||||
const t = useTranslations("auth");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const redirectTo = searchParams.get("redirect");
|
||||
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && session?.user?.username && !redirectTo) {
|
||||
router.push("/folders");
|
||||
}
|
||||
}, [session, isPending, router, redirectTo]);
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!username || !password) {
|
||||
toast.error(t("enterCredentials"));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (username.includes("@")) {
|
||||
const { error } = await authClient.signIn.email({
|
||||
email: username,
|
||||
password: password,
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message ?? t("loginFailed"));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const { error } = await authClient.signIn.username({
|
||||
username: username,
|
||||
password: password,
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message ?? t("loginFailed"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
router.push(redirectTo ?? "/folders");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<Card className="w-96">
|
||||
<CardBody>
|
||||
<VStack gap={4} align="center" justify="center">
|
||||
<h1 className="text-3xl font-bold text-center w-full">{t("title")}</h1>
|
||||
|
||||
<VStack gap={0} align="center" justify="center" className="w-full">
|
||||
<Input
|
||||
placeholder={t("usernameOrEmailPlaceholder")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm text-gray-500 hover:text-primary-500 self-end"
|
||||
>
|
||||
{t("forgotPassword")}
|
||||
</Link>
|
||||
|
||||
<PrimaryButton
|
||||
onClick={handleLogin}
|
||||
loading={loading}
|
||||
fullWidth
|
||||
>
|
||||
{t("confirm")}
|
||||
</PrimaryButton>
|
||||
|
||||
<Link
|
||||
href={"/signup" + (redirectTo ? `?redirect=${redirectTo}` : "")}
|
||||
className="text-center text-primary-500 hover:underline"
|
||||
>
|
||||
{t("noAccountLink")}
|
||||
</Link>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function LogoutPage(
|
||||
props: {
|
||||
searchParams: Promise<{ [key: string]: string | undefined; }>;
|
||||
}
|
||||
) {
|
||||
const searchParams = await props.searchParams;
|
||||
const redirectTo = searchParams.redirect ?? null;
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers()
|
||||
});
|
||||
if (session) {
|
||||
await auth.api.signOut({
|
||||
headers: await headers()
|
||||
});
|
||||
redirect("/login" + (redirectTo ? `?redirect=${redirectTo}` : ""));
|
||||
} else {
|
||||
redirect("/profile");
|
||||
}
|
||||
return (<></>);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { auth } from "@/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export default async function ProfilePage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
redirect("/login?redirect=/profile");
|
||||
}
|
||||
|
||||
redirect(session.user.username ? `/users/${session.user.username}` : "/folders");
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardBody } from "@/design-system/base/card";
|
||||
import { Input } from "@/design-system/base/input";
|
||||
import { PrimaryButton } from "@/design-system/base/button";
|
||||
import { VStack } from "@/design-system/layout/stack";
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const t = useTranslations("auth");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get("token");
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
if (!password || !confirmPassword) {
|
||||
toast.error(t("fillAllFields"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
toast.error(t("passwordsNotMatch"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
toast.error(t("passwordTooShort"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
toast.error(t("invalidToken"));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const { error } = await authClient.resetPassword({
|
||||
newPassword: password,
|
||||
token,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message ?? t("resetPasswordFailed"));
|
||||
} else {
|
||||
setSuccess(true);
|
||||
toast.success(t("resetPasswordSuccess"));
|
||||
setTimeout(() => {
|
||||
router.push("/login");
|
||||
}, 2000);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<Card className="w-96">
|
||||
<CardBody>
|
||||
<VStack gap={4} align="center" justify="center">
|
||||
<h1 className="text-2xl font-bold text-center w-full">
|
||||
{t("resetPasswordSuccessTitle")}
|
||||
</h1>
|
||||
<p className="text-center text-gray-600">
|
||||
{t("resetPasswordSuccessHint")}
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-primary-500 hover:underline"
|
||||
>
|
||||
{t("backToLogin")}
|
||||
</Link>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<Card className="w-96">
|
||||
<CardBody>
|
||||
<VStack gap={4} align="center" justify="center">
|
||||
<h1 className="text-2xl font-bold text-center w-full">
|
||||
{t("invalidToken")}
|
||||
</h1>
|
||||
<p className="text-center text-gray-600">
|
||||
{t("invalidTokenHint")}
|
||||
</p>
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-primary-500 hover:underline"
|
||||
>
|
||||
{t("requestNewToken")}
|
||||
</Link>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<Card className="w-96">
|
||||
<CardBody>
|
||||
<VStack gap={4} align="center" justify="center">
|
||||
<h1 className="text-3xl font-bold text-center w-full">
|
||||
{t("resetPassword")}
|
||||
</h1>
|
||||
<VStack gap={0} align="center" justify="center" className="w-full">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t("newPassword")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t("confirmPassword")}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
</VStack>
|
||||
<PrimaryButton
|
||||
onClick={handleResetPassword}
|
||||
loading={loading}
|
||||
fullWidth
|
||||
>
|
||||
{t("resetPassword")}
|
||||
</PrimaryButton>
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-center text-primary-500 hover:underline"
|
||||
>
|
||||
{t("backToLogin")}
|
||||
</Link>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardBody } from "@/design-system/base/card";
|
||||
import { Input } from "@/design-system/base/input";
|
||||
import { PrimaryButton } from "@/design-system/base/button";
|
||||
import { VStack } from "@/design-system/layout/stack";
|
||||
|
||||
export default function SignUpPage() {
|
||||
const t = useTranslations("auth");
|
||||
const [username, setUsername] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const redirectTo = searchParams.get("redirect");
|
||||
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && session?.user?.username && !redirectTo) {
|
||||
router.push("/folders");
|
||||
}
|
||||
}, [session, isPending, router, redirectTo]);
|
||||
|
||||
const handleSignUp = async () => {
|
||||
if (!username || !email || !password) {
|
||||
toast.error(t("fillAllFields"));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const { error } = await authClient.signUp.email({
|
||||
email: email,
|
||||
name: username,
|
||||
username: username,
|
||||
password: password,
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message ?? t("signUpFailed"));
|
||||
return;
|
||||
}
|
||||
router.push(redirectTo ?? "/folders");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<Card className="w-96">
|
||||
<CardBody>
|
||||
<VStack gap={4} align="center" justify="center">
|
||||
<h1 className="text-3xl font-bold text-center w-full">{t("signUpTitle")}</h1>
|
||||
|
||||
<VStack gap={0} align="center" justify="center" className="w-full">
|
||||
<Input
|
||||
placeholder={t("usernamePlaceholder")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
placeholder={t("emailPlaceholder")}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
<PrimaryButton
|
||||
onClick={handleSignUp}
|
||||
loading={loading}
|
||||
fullWidth
|
||||
>
|
||||
{t("confirm")}
|
||||
</PrimaryButton>
|
||||
|
||||
<Link
|
||||
href={"/login" + (redirectTo ? `?redirect=${redirectTo}` : "")}
|
||||
className="text-center text-primary-500 hover:underline"
|
||||
>
|
||||
{t("hasAccountLink")}
|
||||
</Link>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { LightButton, LinkButton } from "@/design-system/base/button";
|
||||
import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action";
|
||||
import { repoGetFoldersWithTotalPairsByUserId } from "@/modules/folder/folder-repository";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
// import { LogoutButton } from "./LogoutButton";
|
||||
|
||||
interface UserPageProps {
|
||||
params: Promise<{ username: string; }>;
|
||||
}
|
||||
|
||||
export default async function UserPage({ params }: UserPageProps) {
|
||||
const { username } = await params;
|
||||
const t = await getTranslations("user_profile");
|
||||
|
||||
// Get current session
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
// Get user profile
|
||||
const result = await actionGetUserProfileByUsername({ username });
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const user = result.data;
|
||||
|
||||
// Get user's folders
|
||||
const folders = await repoGetFoldersWithTotalPairsByUserId(user.id);
|
||||
|
||||
// Check if viewing own profile
|
||||
const isOwnProfile = session?.user?.username === username || session?.user?.email === username;
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div></div>
|
||||
{isOwnProfile && <LinkButton href="/logout">{t("logout")}</LinkButton>}
|
||||
</div>
|
||||
<div className="flex items-center space-x-6">
|
||||
{/* Avatar */}
|
||||
{user.image ? (
|
||||
<div className="relative w-24 h-24 rounded-full border-4 border-primary-500 overflow-hidden">
|
||||
<Image
|
||||
src={user.image}
|
||||
alt={user.displayUsername || user.username || user.email}
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-24 h-24 rounded-full bg-primary-500 border-4 border-primary-500 flex items-center justify-center">
|
||||
<span className="text-3xl font-bold text-white">
|
||||
{(user.displayUsername || user.username || user.email)[0].toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Info */}
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">
|
||||
{user.displayUsername || user.username || t("anonymous")}
|
||||
</h1>
|
||||
{user.username && (
|
||||
<p className="text-gray-600 text-sm mb-1">
|
||||
@{user.username}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-gray-600 text-sm mb-1">
|
||||
{user.email}
|
||||
</p>
|
||||
<div className="flex items-center space-x-4 text-sm">
|
||||
<span className="text-gray-500">
|
||||
Joined: {new Date(user.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
{user.emailVerified && (
|
||||
<span className="flex items-center text-green-600">
|
||||
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.293 12.293a1 1 0 101.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Verified
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account Info */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t("accountInfo")}</h2>
|
||||
<dl className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">{t("userId")}</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 font-mono break-all">{user.id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">{t("username")}</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
{user.username || <span className="text-gray-400">{t("notSet")}</span>}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">{t("displayName")}</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
{user.displayUsername || <span className="text-gray-400">{t("notSet")}</span>}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">{t("memberSince")}</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Folders Section */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t("folders.title")}</h2>
|
||||
{folders.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-8">{t("folders.noFolders")}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t("folders.folderName")}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t("folders.totalPairs")}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t("folders.createdAt")}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t("folders.actions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{folders.map((folder) => (
|
||||
<tr key={folder.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">{folder.name}</div>
|
||||
<div className="text-sm text-gray-500">ID: {folder.id}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{folder.total}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(folder.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<Link href={`/folders/${folder.id}`}>
|
||||
<LinkButton>
|
||||
{t("folders.view")}
|
||||
</LinkButton>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
||||
import { IconClick, CircleToggleButton, CircleButton, PrimaryButton } from "@/design-system/base/button";
|
||||
import { IMAGES } from "@/config/images";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { Card } from "@/design-system/base/card";
|
||||
|
||||
interface AlphabetCardProps {
|
||||
alphabet: Letter[];
|
||||
alphabetType: SupportedAlphabets;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export 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 (
|
||||
<PageLayout className="relative">
|
||||
{/* 右上角返回按钮 - outside the white card */}
|
||||
<div className="flex justify-end mb-4">
|
||||
<IconClick
|
||||
size="lg"
|
||||
alt="close"
|
||||
src={IMAGES.close}
|
||||
onClick={onBack}
|
||||
className="bg-white rounded-full shadow-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 白色主卡片容器 */}
|
||||
<Card padding="xl">
|
||||
{/* 顶部进度指示器和显示选项按钮 */}
|
||||
<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">
|
||||
<CircleToggleButton
|
||||
selected={showLetter}
|
||||
onClick={() => setShowLetter(!showLetter)}
|
||||
>
|
||||
{t("letter")}
|
||||
</CircleToggleButton>
|
||||
{/* IPA 音标显示切换 */}
|
||||
<CircleToggleButton
|
||||
selected={showIPA}
|
||||
onClick={() => setShowIPA(!showIPA)}
|
||||
>
|
||||
IPA
|
||||
</CircleToggleButton>
|
||||
{/* 罗马音显示切换(仅日语显示) */}
|
||||
{hasRomanization && (
|
||||
<CircleToggleButton
|
||||
selected={showRoman}
|
||||
onClick={() => setShowRoman(!showRoman)}
|
||||
>
|
||||
{t("roman")}
|
||||
</CircleToggleButton>
|
||||
)}
|
||||
{/* 随机模式切换 */}
|
||||
<CircleToggleButton
|
||||
selected={isRandomMode}
|
||||
onClick={() => setIsRandomMode(!isRandomMode)}
|
||||
>
|
||||
{t("random")}
|
||||
</CircleToggleButton>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* IPA 音标显示 */}
|
||||
{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">
|
||||
{/* 上一个按钮 */}
|
||||
<CircleButton onClick={goToPrevious} aria-label="上一个字母">
|
||||
<ChevronLeft size={20} />
|
||||
</CircleButton>
|
||||
|
||||
{/* 中间区域:随机按钮 */}
|
||||
<div className="flex gap-2 items-center">
|
||||
{isRandomMode && (
|
||||
<PrimaryButton
|
||||
onClick={goToRandom}
|
||||
className="rounded-full px-4 py-2 text-sm"
|
||||
>
|
||||
{t("randomNext")}
|
||||
</PrimaryButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 下一个按钮 */}
|
||||
<CircleButton onClick={goToNext} aria-label="下一个字母">
|
||||
<ChevronRight size={20} />
|
||||
</CircleButton>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 底部操作提示文字 */}
|
||||
<div className="text-center mt-6 text-white text-sm">
|
||||
<p>
|
||||
{isRandomMode
|
||||
? "使用左右箭头键或空格键随机切换字母,ESC键返回"
|
||||
: "使用左右箭头键或滑动切换字母,ESC键返回"
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 全屏触摸事件监听层(用于滑动切换) */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { LightButton } from "@/design-system/base/button";
|
||||
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 (
|
||||
<PageLayout>
|
||||
{/* 页面标题 */}
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
|
||||
{t("chooseCharacters")}
|
||||
</h1>
|
||||
{/* 副标题说明 */}
|
||||
<p className="text-lg text-gray-600 text-center">
|
||||
{t("chooseAlphabetHint")}
|
||||
</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>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// 加载状态
|
||||
if (loadingState === "loading") {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="text-2xl text-gray-600 text-center">{t("loading")}</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
if (loadingState === "error") {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="text-2xl text-red-600 text-center">{t("loadFailed")}</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// 字母卡片界面
|
||||
if (loadingState === "success" && alphabetData) {
|
||||
return (
|
||||
<AlphabetCard
|
||||
alphabet={alphabetData}
|
||||
alphabetType={chosenAlphabet}
|
||||
onBack={() => {
|
||||
setChosenAlphabet(null);
|
||||
setAlphabetData(null);
|
||||
setLoadingState("idle");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useDictionaryStore } from "./stores/dictionaryStore";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { LightButton } from "@/design-system/base/button";
|
||||
import { Input } from "@/design-system/base/input";
|
||||
import { Plus, RefreshCw } from "lucide-react";
|
||||
import { DictionaryEntry } from "./DictionaryEntry";
|
||||
import { LanguageSelector } from "./LanguageSelector";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { actionGetFoldersByUserId, actionCreatePair } from "@/modules/folder/folder-action";
|
||||
import { TSharedFolder } from "@/shared/folder-type";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface DictionaryClientProps {
|
||||
initialFolders: TSharedFolder[];
|
||||
}
|
||||
|
||||
export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
|
||||
const t = useTranslations("dictionary");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const {
|
||||
query,
|
||||
queryLang,
|
||||
definitionLang,
|
||||
searchResult,
|
||||
isSearching,
|
||||
setQuery,
|
||||
setQueryLang,
|
||||
setDefinitionLang,
|
||||
search,
|
||||
relookup,
|
||||
syncFromUrl,
|
||||
} = useDictionaryStore();
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
const [folders, setFolders] = useState<TSharedFolder[]>(initialFolders);
|
||||
|
||||
useEffect(() => {
|
||||
const q = searchParams.get("q") || undefined;
|
||||
const ql = searchParams.get("ql") || undefined;
|
||||
const dl = searchParams.get("dl") || undefined;
|
||||
|
||||
syncFromUrl({ q, ql, dl });
|
||||
|
||||
if (q) {
|
||||
search();
|
||||
}
|
||||
}, [searchParams, syncFromUrl, search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user?.id) {
|
||||
actionGetFoldersByUserId(session.user.id).then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setFolders(result.data);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [session?.user?.id]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!query.trim()) return;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
ql: queryLang,
|
||||
dl: definitionLang,
|
||||
});
|
||||
|
||||
router.push(`/dictionary?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!session) {
|
||||
toast.error("Please login first");
|
||||
return;
|
||||
}
|
||||
if (folders.length === 0) {
|
||||
toast.error("Please create a folder first");
|
||||
return;
|
||||
}
|
||||
|
||||
const folderSelect = document.getElementById("folder-select") as HTMLSelectElement;
|
||||
const folderId = folderSelect?.value ? Number(folderSelect.value) : folders[0]?.id;
|
||||
|
||||
if (!searchResult?.entries?.length) return;
|
||||
|
||||
const definition = searchResult.entries
|
||||
.map((e) => e.definition)
|
||||
.join(" | ");
|
||||
|
||||
try {
|
||||
await actionCreatePair({
|
||||
text1: searchResult.standardForm,
|
||||
text2: definition,
|
||||
language1: queryLang,
|
||||
language2: definitionLang,
|
||||
ipa1: searchResult.entries[0]?.ipa,
|
||||
folderId: folderId,
|
||||
});
|
||||
|
||||
const folderName = folders.find((f) => f.id === folderId)?.name || "Unknown";
|
||||
toast.success(`Saved to ${folderName}`);
|
||||
} catch (error) {
|
||||
toast.error("Save failed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-gray-700 text-lg">
|
||||
{t("description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col sm:flex-row gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
name="searchQuery"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
variant="search"
|
||||
required
|
||||
containerClassName="flex-1"
|
||||
/>
|
||||
<LightButton
|
||||
type="submit"
|
||||
className="h-10 px-6 rounded-full whitespace-nowrap"
|
||||
loading={isSearching}
|
||||
>
|
||||
{t("search")}
|
||||
</LightButton>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 bg-white/20 rounded-lg p-4">
|
||||
<div className="mb-3">
|
||||
<span className="text-gray-800 font-semibold">{t("languageSettings")}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<LanguageSelector
|
||||
label={t("queryLanguage")}
|
||||
hint={t("queryLanguageHint")}
|
||||
value={queryLang}
|
||||
onChange={setQueryLang}
|
||||
/>
|
||||
|
||||
<LanguageSelector
|
||||
label={t("definitionLanguage")}
|
||||
hint={t("definitionLanguageHint")}
|
||||
value={definitionLang}
|
||||
onChange={setDefinitionLang}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
{isSearching ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-gray-200 border-t-primary-500 rounded-full animate-spin mx-auto mb-3"></div>
|
||||
<p className="text-gray-600">{t("searching")}</p>
|
||||
</div>
|
||||
) : query && !searchResult ? (
|
||||
<div className="text-center py-12 bg-white/20 rounded-lg">
|
||||
<p className="text-gray-800 text-xl">No results found</p>
|
||||
<p className="text-gray-600 mt-2">Try other words</p>
|
||||
</div>
|
||||
) : searchResult ? (
|
||||
<div className="bg-white rounded-lg p-6 shadow-lg">
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div className="flex-1">
|
||||
<h2 className="text-3xl font-bold text-gray-800 mb-2">
|
||||
{searchResult.standardForm}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{session && folders.length > 0 && (
|
||||
<select
|
||||
id="folder-select"
|
||||
className="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[#35786f]"
|
||||
>
|
||||
{folders.map((folder) => (
|
||||
<option key={folder.id} value={folder.id}>
|
||||
{folder.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<LightButton
|
||||
onClick={handleSave}
|
||||
className="w-10 h-10 shrink-0"
|
||||
title="Save to folder"
|
||||
>
|
||||
<Plus />
|
||||
</LightButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{searchResult.entries.map((entry, index) => (
|
||||
<div key={index} className="border-t border-gray-200 pt-4">
|
||||
<DictionaryEntry entry={entry} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-4 mt-4">
|
||||
<LightButton
|
||||
onClick={relookup}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm"
|
||||
loading={isSearching}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Re-lookup
|
||||
</LightButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-6xl mb-4">📚</div>
|
||||
<p className="text-gray-800 text-xl mb-2">{t("welcomeTitle")}</p>
|
||||
<p className="text-gray-600">{t("welcomeHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { TSharedEntry } from "@/shared/dictionary-type";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface DictionaryEntryProps {
|
||||
entry: TSharedEntry;
|
||||
}
|
||||
|
||||
export function DictionaryEntry({ entry }: DictionaryEntryProps) {
|
||||
const t = useTranslations("dictionary");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
{entry.ipa && (
|
||||
<span className="text-gray-600 text-lg">
|
||||
[{entry.ipa}]
|
||||
</span>
|
||||
)}
|
||||
{entry.partOfSpeech && (
|
||||
<span className="px-3 py-1 bg-[#35786f] text-white text-sm rounded-full">
|
||||
{entry.partOfSpeech}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
||||
{t("definition")}
|
||||
</h3>
|
||||
<p className="text-gray-800">{entry.definition}</p>
|
||||
</div>
|
||||
|
||||
{entry.example && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
||||
{t("example")}
|
||||
</h3>
|
||||
<p className="text-gray-700 pl-4 border-l-4 border-[#35786f]">
|
||||
{entry.example}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { LightButton } from "@/design-system/base/button";
|
||||
import { Input } from "@/design-system/base/input";
|
||||
import { POPULAR_LANGUAGES } from "./constants";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface LanguageSelectorProps {
|
||||
label: string;
|
||||
hint: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function LanguageSelector({ label, hint, value, onChange }: LanguageSelectorProps) {
|
||||
const t = useTranslations("dictionary");
|
||||
const [showCustomInput, setShowCustomInput] = useState(false);
|
||||
const [customLang, setCustomLang] = useState("");
|
||||
|
||||
const isPresetLanguage = POPULAR_LANGUAGES.some((lang) => lang.code === value);
|
||||
|
||||
const handlePresetSelect = (code: string) => {
|
||||
onChange(code);
|
||||
setShowCustomInput(false);
|
||||
setCustomLang("");
|
||||
};
|
||||
|
||||
const handleCustomToggle = () => {
|
||||
setShowCustomInput(!showCustomInput);
|
||||
if (!showCustomInput && customLang.trim()) {
|
||||
onChange(customLang.trim());
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomChange = (newValue: string) => {
|
||||
setCustomLang(newValue);
|
||||
if (newValue.trim()) {
|
||||
onChange(newValue.trim());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-gray-700 text-sm mb-2">
|
||||
{label} ({hint})
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{POPULAR_LANGUAGES.map((lang) => (
|
||||
<LightButton
|
||||
key={lang.code}
|
||||
type="button"
|
||||
selected={isPresetLanguage && value === lang.code}
|
||||
onClick={() => handlePresetSelect(lang.code)}
|
||||
className="text-sm px-3 py-1"
|
||||
>
|
||||
{lang.nativeName}
|
||||
</LightButton>
|
||||
))}
|
||||
<LightButton
|
||||
type="button"
|
||||
selected={!isPresetLanguage && !!value}
|
||||
onClick={handleCustomToggle}
|
||||
className="text-sm px-3 py-1"
|
||||
>
|
||||
{t("other")}
|
||||
</LightButton>
|
||||
</div>
|
||||
{(showCustomInput || (!isPresetLanguage && value)) && (
|
||||
<Input
|
||||
type="text"
|
||||
value={isPresetLanguage ? customLang : value}
|
||||
onChange={(e) => handleCustomChange(e.target.value)}
|
||||
placeholder={t("otherLanguagePlaceholder")}
|
||||
className="text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export const POPULAR_LANGUAGES = [
|
||||
{ code: "english", name: "英语", nativeName: "English" },
|
||||
{ code: "chinese", name: "中文", nativeName: "中文" },
|
||||
{ code: "japanese", name: "日语", nativeName: "日本語" },
|
||||
{ code: "korean", name: "韩语", nativeName: "한국어" },
|
||||
{ code: "italian", name: "意大利语", nativeName: "Italiano" },
|
||||
{ code: "uyghur", name: "维吾尔语", nativeName: "ئۇيغۇرچە" },
|
||||
] as const;
|
||||
@@ -1,20 +0,0 @@
|
||||
import { DictionaryClient } from "./DictionaryClient";
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { actionGetFoldersByUserId } from "@/modules/folder/folder-action";
|
||||
import { TSharedFolder } from "@/shared/folder-type";
|
||||
|
||||
export default async function DictionaryPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
let folders: TSharedFolder[] = [];
|
||||
|
||||
if (session?.user?.id) {
|
||||
const result = await actionGetFoldersByUserId(session.user.id as string);
|
||||
if (result.success && result.data) {
|
||||
folders = result.data;
|
||||
}
|
||||
}
|
||||
|
||||
return <DictionaryClient initialFolders={folders} />;
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import { TSharedItem } from "@/shared/dictionary-type";
|
||||
import { actionLookUpDictionary } from "@/modules/dictionary/dictionary-action";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const POPULAR_LANGUAGES_MAP: Record<string, string> = {
|
||||
english: "English",
|
||||
chinese: "中文",
|
||||
japanese: "日本語",
|
||||
korean: "한국어",
|
||||
italian: "Italiano",
|
||||
uyghur: "ئۇيغۇرچە",
|
||||
};
|
||||
|
||||
export function getNativeName(code: string): string {
|
||||
return POPULAR_LANGUAGES_MAP[code] || code;
|
||||
}
|
||||
|
||||
export interface DictionaryState {
|
||||
query: string;
|
||||
queryLang: string;
|
||||
definitionLang: string;
|
||||
searchResult: TSharedItem | null;
|
||||
isSearching: boolean;
|
||||
}
|
||||
|
||||
export interface DictionaryActions {
|
||||
setQuery: (query: string) => void;
|
||||
setQueryLang: (lang: string) => void;
|
||||
setDefinitionLang: (lang: string) => void;
|
||||
setSearchResult: (result: TSharedItem | null) => void;
|
||||
search: () => Promise<void>;
|
||||
relookup: () => Promise<void>;
|
||||
syncFromUrl: (params: { q?: string; ql?: string; dl?: string }) => void;
|
||||
}
|
||||
|
||||
export type DictionaryStore = DictionaryState & DictionaryActions;
|
||||
|
||||
const initialState: DictionaryState = {
|
||||
query: "",
|
||||
queryLang: "english",
|
||||
definitionLang: "chinese",
|
||||
searchResult: null,
|
||||
isSearching: false,
|
||||
};
|
||||
|
||||
export const useDictionaryStore = create<DictionaryStore>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
setQuery: (query) => set({ query }),
|
||||
|
||||
setQueryLang: (queryLang) => set({ queryLang }),
|
||||
|
||||
setDefinitionLang: (definitionLang) => set({ definitionLang }),
|
||||
|
||||
setSearchResult: (searchResult) => set({ searchResult }),
|
||||
|
||||
search: async () => {
|
||||
const { query, queryLang, definitionLang } = get();
|
||||
|
||||
if (!query.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
set({ isSearching: true });
|
||||
|
||||
try {
|
||||
const result = await actionLookUpDictionary({
|
||||
text: query,
|
||||
queryLang: getNativeName(queryLang),
|
||||
definitionLang: getNativeName(definitionLang),
|
||||
forceRelook: false,
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
set({ searchResult: result.data });
|
||||
} else {
|
||||
set({ searchResult: null });
|
||||
if (result.message) {
|
||||
toast.error(result.message);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
set({ searchResult: null });
|
||||
toast.error("Search failed");
|
||||
} finally {
|
||||
set({ isSearching: false });
|
||||
}
|
||||
},
|
||||
|
||||
relookup: async () => {
|
||||
const { query, queryLang, definitionLang } = get();
|
||||
|
||||
if (!query.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
set({ isSearching: true });
|
||||
|
||||
try {
|
||||
const result = await actionLookUpDictionary({
|
||||
text: query,
|
||||
queryLang: getNativeName(queryLang),
|
||||
definitionLang: getNativeName(definitionLang),
|
||||
forceRelook: true,
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
set({ searchResult: result.data });
|
||||
toast.success("Re-lookup successful");
|
||||
} else {
|
||||
if (result.message) {
|
||||
toast.error(result.message);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Re-lookup failed");
|
||||
} finally {
|
||||
set({ isSearching: false });
|
||||
}
|
||||
},
|
||||
|
||||
syncFromUrl: (params) => {
|
||||
const updates: Partial<DictionaryState> = {};
|
||||
|
||||
if (params.q !== undefined) {
|
||||
updates.query = params.q;
|
||||
}
|
||||
if (params.ql !== undefined) {
|
||||
updates.queryLang = params.ql;
|
||||
}
|
||||
if (params.dl !== undefined) {
|
||||
updates.definitionLang = params.dl;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
set(updates);
|
||||
}
|
||||
},
|
||||
}),
|
||||
{ name: 'dictionary-store' }
|
||||
)
|
||||
);
|
||||
@@ -1,201 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Folder as Fd,
|
||||
Heart,
|
||||
Search,
|
||||
ArrowUpDown,
|
||||
} from "lucide-react";
|
||||
import { CircleButton } from "@/design-system/base/button";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import {
|
||||
actionSearchPublicFolders,
|
||||
actionToggleFavorite,
|
||||
actionCheckFavorite,
|
||||
} from "@/modules/folder/folder-action";
|
||||
import { TPublicFolder } from "@/shared/folder-type";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
interface PublicFolderCardProps {
|
||||
folder: TPublicFolder;
|
||||
currentUserId?: string;
|
||||
onUpdateFavorite: (folderId: number, isFavorited: boolean, favoriteCount: number) => void;
|
||||
}
|
||||
|
||||
const PublicFolderCard = ({ folder, currentUserId, onUpdateFavorite }: PublicFolderCardProps) => {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("explore");
|
||||
const [isFavorited, setIsFavorited] = useState(false);
|
||||
const [favoriteCount, setFavoriteCount] = useState(folder.favoriteCount);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUserId) {
|
||||
actionCheckFavorite(folder.id).then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setIsFavorited(result.data.isFavorited);
|
||||
setFavoriteCount(result.data.favoriteCount);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [folder.id, currentUserId]);
|
||||
|
||||
const handleToggleFavorite = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!currentUserId) {
|
||||
toast.error(t("pleaseLogin"));
|
||||
return;
|
||||
}
|
||||
const result = await actionToggleFavorite(folder.id);
|
||||
if (result.success && result.data) {
|
||||
setIsFavorited(result.data.isFavorited);
|
||||
setFavoriteCount(result.data.favoriteCount);
|
||||
onUpdateFavorite(folder.id, result.data.isFavorited, result.data.favoriteCount);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group bg-white border border-gray-200 sm:border-2 rounded-lg p-3 sm:p-5 hover:border-primary-300 hover:shadow-md cursor-pointer transition-all overflow-hidden"
|
||||
onClick={() => {
|
||||
router.push(`/explore/${folder.id}`);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2 sm:mb-3">
|
||||
<div className="shrink-0 w-8 h-8 sm:w-10 sm:h-10 rounded-lg bg-primary-50 flex items-center justify-center text-primary-500">
|
||||
<Fd size={18} className="sm:hidden" />
|
||||
<Fd size={22} className="hidden sm:block" />
|
||||
</div>
|
||||
<CircleButton
|
||||
onClick={handleToggleFavorite}
|
||||
title={isFavorited ? t("unfavorite") : t("favorite")}
|
||||
>
|
||||
<Heart
|
||||
size={16}
|
||||
className={`sm:w-[18px] sm:h-[18px] sm:text-[18px] ${isFavorited ? "fill-red-500 text-red-500" : ""}`}
|
||||
/>
|
||||
</CircleButton>
|
||||
</div>
|
||||
|
||||
<h3 className="font-semibold text-gray-900 truncate text-sm sm:text-base mb-1 sm:mb-2">{folder.name}</h3>
|
||||
|
||||
<p className="text-xs sm:text-sm text-gray-500 mb-2 sm:mb-3 line-clamp-2">
|
||||
{t("folderInfo", {
|
||||
userName: folder.userName ?? folder.userUsername ?? t("unknownUser"),
|
||||
totalPairs: folder.totalPairs,
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-1 text-xs sm:text-sm text-gray-400">
|
||||
<Heart size={12} className="sm:w-3.5 sm:h-3.5" />
|
||||
<span>{favoriteCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ExploreClientProps {
|
||||
initialPublicFolders: TPublicFolder[];
|
||||
}
|
||||
|
||||
export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
|
||||
const t = useTranslations("explore");
|
||||
const router = useRouter();
|
||||
const [publicFolders, setPublicFolders] = useState<TPublicFolder[]>(initialPublicFolders);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [sortByFavorites, setSortByFavorites] = useState(false);
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
const currentUserId = session?.user?.id;
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.trim()) {
|
||||
setPublicFolders(initialPublicFolders);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const result = await actionSearchPublicFolders(searchQuery.trim());
|
||||
if (result.success && result.data) {
|
||||
setPublicFolders(result.data);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleToggleSort = () => {
|
||||
setSortByFavorites((prev) => !prev);
|
||||
};
|
||||
|
||||
const sortedFolders = sortByFavorites
|
||||
? [...publicFolders].sort((a, b) => b.favoriteCount - a.favoriteCount)
|
||||
: publicFolders;
|
||||
|
||||
const handleUpdateFavorite = (folderId: number, _isFavorited: boolean, favoriteCount: number) => {
|
||||
setPublicFolders((prev) =>
|
||||
prev.map((f) =>
|
||||
f.id === folderId ? { ...f, favoriteCount } : f
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<div className="relative flex-1">
|
||||
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<CircleButton
|
||||
onClick={handleToggleSort}
|
||||
title={sortByFavorites ? t("sortByFavoritesActive") : t("sortByFavorites")}
|
||||
className={sortByFavorites ? "bg-primary-100 text-primary-600 hover:bg-primary-200" : ""}
|
||||
>
|
||||
<ArrowUpDown size={18} />
|
||||
</CircleButton>
|
||||
<CircleButton onClick={handleSearch}>
|
||||
<Search size={18} />
|
||||
</CircleButton>
|
||||
</div>
|
||||
|
||||
{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("loading")}</p>
|
||||
</div>
|
||||
) : sortedFolders.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<Fd size={24} className="text-gray-400" />
|
||||
</div>
|
||||
<p className="text-sm">{t("noFolders")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{sortedFolders.map((folder) => (
|
||||
<PublicFolderCard
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
currentUserId={currentUserId}
|
||||
onUpdateFavorite={handleUpdateFavorite}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Folder as Fd, Heart, ExternalLink, ArrowLeft } from "lucide-react";
|
||||
import { CircleButton } from "@/design-system/base/button";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
actionToggleFavorite,
|
||||
actionCheckFavorite,
|
||||
} from "@/modules/folder/folder-action";
|
||||
import { ActionOutputPublicFolder } from "@/modules/folder/folder-action-dto";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
interface ExploreDetailClientProps {
|
||||
folder: ActionOutputPublicFolder;
|
||||
}
|
||||
|
||||
export function ExploreDetailClient({ folder }: ExploreDetailClientProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("exploreDetail");
|
||||
const [isFavorited, setIsFavorited] = useState(false);
|
||||
const [favoriteCount, setFavoriteCount] = useState(folder.favoriteCount);
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
const currentUserId = session?.user?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUserId) {
|
||||
actionCheckFavorite(folder.id).then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setIsFavorited(result.data.isFavorited);
|
||||
setFavoriteCount(result.data.favoriteCount);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [folder.id, currentUserId]);
|
||||
|
||||
const handleToggleFavorite = async () => {
|
||||
if (!currentUserId) {
|
||||
toast.error(t("pleaseLogin"));
|
||||
return;
|
||||
}
|
||||
const result = await actionToggleFavorite(folder.id);
|
||||
if (result.success && result.data) {
|
||||
setIsFavorited(result.data.isFavorited);
|
||||
setFavoriteCount(result.data.favoriteCount);
|
||||
toast.success(
|
||||
result.data.isFavorited ? t("favorited") : t("unfavorited")
|
||||
);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(new Date(date));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-3xl mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<CircleButton onClick={() => router.push("/explore")}>
|
||||
<ArrowLeft size={18} />
|
||||
</CircleButton>
|
||||
<h1 className="text-lg sm:text-xl font-semibold text-gray-900">
|
||||
{t("title")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5 sm:p-8 shadow-sm">
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 sm:w-16 sm:h-16 rounded-xl bg-primary-50 flex items-center justify-center text-primary-500">
|
||||
<Fd size={28} className="sm:w-8 sm:h-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-gray-900">
|
||||
{folder.name}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{t("createdBy", {
|
||||
name: folder.userName ?? folder.userUsername ?? t("unknownUser"),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<CircleButton
|
||||
onClick={handleToggleFavorite}
|
||||
title={isFavorited ? t("unfavorite") : t("favorite")}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Heart
|
||||
size={20}
|
||||
className={isFavorited ? "fill-red-500 text-red-500" : ""}
|
||||
/>
|
||||
</CircleButton>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 mb-6 py-4 border-y border-gray-100">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl sm:text-3xl font-bold text-primary-600">
|
||||
{folder.totalPairs}
|
||||
</div>
|
||||
<div className="text-xs sm:text-sm text-gray-500 mt-1">
|
||||
{t("totalPairs")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center border-x border-gray-100">
|
||||
<div className="text-2xl sm:text-3xl font-bold text-red-500 flex items-center justify-center gap-1">
|
||||
<Heart size={18} className={isFavorited ? "fill-red-500" : ""} />
|
||||
{favoriteCount}
|
||||
</div>
|
||||
<div className="text-xs sm:text-sm text-gray-500 mt-1">
|
||||
{t("favorites")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg sm:text-xl font-semibold text-gray-700">
|
||||
{formatDate(folder.createdAt)}
|
||||
</div>
|
||||
<div className="text-xs sm:text-sm text-gray-500 mt-1">
|
||||
{t("createdAt")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/folders/${folder.id}`}
|
||||
className="flex items-center justify-center gap-2 w-full py-3 px-4 bg-primary-500 hover:bg-primary-600 text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
<ExternalLink size={18} />
|
||||
{t("viewContent")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { ExploreDetailClient } from "./ExploreDetailClient";
|
||||
import { actionGetPublicFolderById } from "@/modules/folder/folder-action";
|
||||
|
||||
export default async function ExploreFolderPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
|
||||
if (!id) {
|
||||
redirect("/explore");
|
||||
}
|
||||
|
||||
const result = await actionGetPublicFolderById(Number(id));
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
redirect("/explore");
|
||||
}
|
||||
|
||||
return <ExploreDetailClient folder={result.data} />;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { ExploreClient } from "./ExploreClient";
|
||||
import { actionGetPublicFolders } from "@/modules/folder/folder-action";
|
||||
|
||||
export default async function ExplorePage() {
|
||||
const publicFoldersResult = await actionGetPublicFolders();
|
||||
const publicFolders = publicFoldersResult.success ? publicFoldersResult.data ?? [] : [];
|
||||
|
||||
return <ExploreClient initialPublicFolders={publicFolders} />;
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronRight,
|
||||
Folder as Fd,
|
||||
Heart,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { CardList } from "@/components/ui/CardList";
|
||||
import { actionGetUserFavorites, actionToggleFavorite } from "@/modules/folder/folder-action";
|
||||
|
||||
type UserFavorite = {
|
||||
id: number;
|
||||
folderId: number;
|
||||
folderName: string;
|
||||
folderCreatedAt: Date;
|
||||
folderTotalPairs: number;
|
||||
folderOwnerId: string;
|
||||
folderOwnerName: string | null;
|
||||
folderOwnerUsername: string | null;
|
||||
favoritedAt: Date;
|
||||
};
|
||||
|
||||
interface FavoriteCardProps {
|
||||
favorite: UserFavorite;
|
||||
onRemoveFavorite: (folderId: number) => void;
|
||||
}
|
||||
|
||||
const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("favorites");
|
||||
const [isRemoving, setIsRemoving] = useState(false);
|
||||
|
||||
const handleRemoveFavorite = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (isRemoving) return;
|
||||
|
||||
setIsRemoving(true);
|
||||
const result = await actionToggleFavorite(favorite.folderId);
|
||||
if (result.success) {
|
||||
onRemoveFavorite(favorite.folderId);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
setIsRemoving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex justify-between items-center group py-4 px-5 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
onClick={() => {
|
||||
router.push(`/explore/${favorite.folderId}`);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="shrink-0 text-primary-500">
|
||||
<Fd size={24} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{favorite.folderName}</h3>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
{t("folderInfo", {
|
||||
userName: favorite.folderOwnerName ?? favorite.folderOwnerUsername ?? t("unknownUser"),
|
||||
totalPairs: favorite.folderTotalPairs,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Heart
|
||||
size={18}
|
||||
className="fill-red-500 text-red-500 cursor-pointer hover:scale-110 transition-transform"
|
||||
onClick={handleRemoveFavorite}
|
||||
/>
|
||||
<ChevronRight size={20} className="text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface FavoritesClientProps {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export function FavoritesClient({ userId }: FavoritesClientProps) {
|
||||
const t = useTranslations("favorites");
|
||||
const [favorites, setFavorites] = useState<UserFavorite[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadFavorites();
|
||||
}, [userId]);
|
||||
|
||||
const loadFavorites = async () => {
|
||||
setLoading(true);
|
||||
const result = await actionGetUserFavorites();
|
||||
if (result.success && result.data) {
|
||||
setFavorites(result.data);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleRemoveFavorite = (folderId: number) => {
|
||||
setFavorites((prev) => prev.filter((f) => f.folderId !== folderId));
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<CardList>
|
||||
{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("loading")}</p>
|
||||
</div>
|
||||
) : favorites.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<Heart size={24} className="text-gray-400" />
|
||||
</div>
|
||||
<p className="text-sm">{t("noFavorites")}</p>
|
||||
</div>
|
||||
) : (
|
||||
favorites.map((favorite) => (
|
||||
<FavoriteCard
|
||||
key={favorite.id}
|
||||
favorite={favorite}
|
||||
onRemoveFavorite={handleRemoveFavorite}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</CardList>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { FavoritesClient } from "./FavoritesClient";
|
||||
|
||||
export default async function FavoritesPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
if (!session) {
|
||||
redirect("/login?redirect=/favorites");
|
||||
}
|
||||
|
||||
return <FavoritesClient userId={session.user.id} />;
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { Folder as Fd } from "lucide-react";
|
||||
import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { PrimaryButton } from "@/design-system/base/button";
|
||||
|
||||
interface FolderSelectorProps {
|
||||
folders: TSharedFolderWithTotalPairs[];
|
||||
}
|
||||
|
||||
const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
|
||||
const t = useTranslations("memorize.folder_selector");
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{folders.length === 0 ? (
|
||||
// 空状态 - 显示提示和跳转按钮
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-4">
|
||||
{t("noFolders")}
|
||||
</h1>
|
||||
<Link href="/folders">
|
||||
<PrimaryButton className="px-6 py-2">
|
||||
Go to Folders
|
||||
</PrimaryButton>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 页面标题 */}
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-6">
|
||||
{t("selectFolder")}
|
||||
</h1>
|
||||
{/* 文件夹列表 */}
|
||||
<div className="border border-gray-200 rounded-lg 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 items-center p-4 gap-3 hover:cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-100 last:border-b-0"
|
||||
>
|
||||
{/* 文件夹图标 */}
|
||||
<div className="shrink-0">
|
||||
<Fd className="text-gray-600" size="md" />
|
||||
</div>
|
||||
{/* 文件夹信息 */}
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-gray-900">
|
||||
{folder.name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{t("folderInfo", {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
count: folder.total,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* 右箭头 */}
|
||||
<div className="text-gray-400">
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export { FolderSelector };
|
||||
@@ -1,195 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { LinkButton, CircleToggleButton, LightButton } from "@/design-system/base/button";
|
||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
|
||||
import { useTranslations } from "next-intl";
|
||||
import localFont from "next/font/local";
|
||||
import { isNonNegativeInteger, SeededRandom } from "@/utils/random";
|
||||
import { TSharedPair } from "@/shared/folder-type";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
|
||||
const myFont = localFont({
|
||||
src: "../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf",
|
||||
});
|
||||
|
||||
interface MemorizeProps {
|
||||
textPairs: TSharedPair[];
|
||||
}
|
||||
|
||||
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 (
|
||||
<PageLayout>
|
||||
<p className="text-gray-700 text-center">{t("noTextPairs")}</p>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const handleIndexClick = () => {
|
||||
const newIndex = prompt("Input a index number.")?.trim();
|
||||
if (
|
||||
newIndex &&
|
||||
isNonNegativeInteger(newIndex) &&
|
||||
parseInt(newIndex) <= textPairs.length &&
|
||||
parseInt(newIndex) > 0
|
||||
) {
|
||||
setIndex(parseInt(newIndex) - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = async () => {
|
||||
if (show === "answer") {
|
||||
const newIndex = (index + 1) % getTextPairs().length;
|
||||
setIndex(newIndex);
|
||||
if (dictation) {
|
||||
const textPair = getTextPairs()[newIndex];
|
||||
const language = textPair[reverse ? "language2" : "language1"];
|
||||
const text = textPair[reverse ? "text2" : "text1"];
|
||||
|
||||
// 映射语言到 TTS 支持的格式
|
||||
const languageMap: Record<string, TTS_SUPPORTED_LANGUAGES> = {
|
||||
"chinese": "Chinese",
|
||||
"english": "English",
|
||||
"japanese": "Japanese",
|
||||
"korean": "Korean",
|
||||
"french": "French",
|
||||
"german": "German",
|
||||
"italian": "Italian",
|
||||
"portuguese": "Portuguese",
|
||||
"spanish": "Spanish",
|
||||
"russian": "Russian",
|
||||
};
|
||||
|
||||
const ttsLanguage = languageMap[language?.toLowerCase()] || "Auto";
|
||||
|
||||
getTTSUrl(text, ttsLanguage).then((url) => {
|
||||
load(url);
|
||||
play();
|
||||
});
|
||||
}
|
||||
}
|
||||
setShow(show === "question" ? "answer" : "question");
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
setIndex(
|
||||
(index - 1 + getTextPairs().length) % getTextPairs().length,
|
||||
);
|
||||
setShow("question");
|
||||
};
|
||||
|
||||
const toggleReverse = () => setReverse(!reverse);
|
||||
const toggleDictation = () => setDictation(!dictation);
|
||||
const toggleDisorder = () => setDisorder(!disorder);
|
||||
|
||||
const createText = (text: string) => {
|
||||
return (
|
||||
<div className="text-gray-900 text-xl md:text-2xl p-6 h-[20dvh] overflow-y-auto text-center">
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const [text1, text2] = reverse
|
||||
? [getTextPairs()[index].text2, getTextPairs()[index].text1]
|
||||
: [getTextPairs()[index].text1, getTextPairs()[index].text2];
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 进度指示器 */}
|
||||
<div className="flex justify-center mb-4">
|
||||
<LinkButton onClick={handleIndexClick} className="text-sm">
|
||||
{index + 1} / {getTextPairs().length}
|
||||
</LinkButton>
|
||||
</div>
|
||||
|
||||
{/* 文本显示区域 */}
|
||||
<div className={`h-[40dvh] ${myFont.className} mb-4`}>
|
||||
{(() => {
|
||||
if (dictation) {
|
||||
if (show === "question") {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-gray-400 text-4xl">?</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{createText(text1)}
|
||||
<div className="border-t border-gray-200"></div>
|
||||
{createText(text2)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (show === "question") {
|
||||
return createText(text1);
|
||||
} else {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{createText(text1)}
|
||||
<div className="border-t border-gray-200"></div>
|
||||
{createText(text2)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className="flex flex-row gap-2 items-center justify-center flex-wrap">
|
||||
<LightButton
|
||||
onClick={handleNext}
|
||||
className="px-4 py-2 rounded-full text-sm"
|
||||
>
|
||||
{show === "question" ? t("answer") : t("next")}
|
||||
</LightButton>
|
||||
<LightButton
|
||||
onClick={handlePrevious}
|
||||
className="px-4 py-2 rounded-full text-sm"
|
||||
>
|
||||
{t("previous")}
|
||||
</LightButton>
|
||||
<CircleToggleButton
|
||||
selected={reverse}
|
||||
onClick={toggleReverse}
|
||||
>
|
||||
{t("reverse")}
|
||||
</CircleToggleButton>
|
||||
<CircleToggleButton
|
||||
selected={dictation}
|
||||
onClick={toggleDictation}
|
||||
>
|
||||
{t("dictation")}
|
||||
</CircleToggleButton>
|
||||
<CircleToggleButton
|
||||
selected={disorder}
|
||||
onClick={toggleDisorder}
|
||||
>
|
||||
{t("disorder")}
|
||||
</CircleToggleButton>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export { Memorize };
|
||||
@@ -1,37 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { isNonNegativeInteger } from "@/utils/random";
|
||||
import { FolderSelector } from "./FolderSelector";
|
||||
import { Memorize } from "./Memorize";
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { actionGetFoldersWithTotalPairsByUserId, actionGetPairsByFolderId } from "@/modules/folder/folder-action";
|
||||
|
||||
export default async function MemorizePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ folder_id?: string; }>;
|
||||
}) {
|
||||
const tParam = (await searchParams).folder_id;
|
||||
|
||||
const t = await getTranslations("memorize.page");
|
||||
|
||||
const folder_id = tParam
|
||||
? isNonNegativeInteger(tParam)
|
||||
? parseInt(tParam)
|
||||
: null
|
||||
: null;
|
||||
|
||||
if (!folder_id) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) redirect("/login?redirect=/memorize");
|
||||
|
||||
return (
|
||||
<FolderSelector
|
||||
folders={(await actionGetFoldersWithTotalPairsByUserId(session.user.id)).data!}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <Memorize textPairs={(await actionGetPairsByFolderId(folder_id)).data!} />;
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Video, FileText, ChevronLeft, ChevronRight, RotateCcw, Pause, Play, Settings, Keyboard } from 'lucide-react';
|
||||
import { Button, LightButton } from '@/design-system/base/button';
|
||||
import { Range } from '@/design-system/base/range';
|
||||
import { HStack, VStack } from '@/design-system/layout/stack';
|
||||
import { useSrtPlayerStore } from '../stores/srtPlayerStore';
|
||||
import { useFileUpload } from '../hooks/useFileUpload';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function ControlPanel() {
|
||||
const t = useTranslations('srt_player');
|
||||
const { uploadVideo, uploadSubtitle } = useFileUpload();
|
||||
|
||||
const videoUrl = useSrtPlayerStore((state) => state.video.url);
|
||||
const subtitleUrl = useSrtPlayerStore((state) => state.subtitle.url);
|
||||
const subtitleData = useSrtPlayerStore((state) => state.subtitle.data);
|
||||
const currentIndex = useSrtPlayerStore((state) => state.subtitle.currentIndex);
|
||||
const isPlaying = useSrtPlayerStore((state) => state.video.isPlaying);
|
||||
const playbackRate = useSrtPlayerStore((state) => state.video.playbackRate);
|
||||
const autoPause = useSrtPlayerStore((state) => state.controls.autoPause);
|
||||
const showSettings = useSrtPlayerStore((state) => state.controls.showSettings);
|
||||
const showShortcuts = useSrtPlayerStore((state) => state.controls.showShortcuts);
|
||||
const settings = useSrtPlayerStore((state) => state.subtitle.settings);
|
||||
|
||||
const togglePlayPause = useSrtPlayerStore((state) => state.togglePlayPause);
|
||||
const nextSubtitle = useSrtPlayerStore((state) => state.nextSubtitle);
|
||||
const previousSubtitle = useSrtPlayerStore((state) => state.previousSubtitle);
|
||||
const restartSubtitle = useSrtPlayerStore((state) => state.restartSubtitle);
|
||||
const setPlaybackRate = useSrtPlayerStore((state) => state.setPlaybackRate);
|
||||
const toggleAutoPause = useSrtPlayerStore((state) => state.toggleAutoPause);
|
||||
const setVideoUrl = useSrtPlayerStore((state) => state.setVideoUrl);
|
||||
const setSubtitleUrl = useSrtPlayerStore((state) => state.setSubtitleUrl);
|
||||
const seek = useSrtPlayerStore((state) => state.seek);
|
||||
const toggleSettings = useSrtPlayerStore((state) => state.toggleSettings);
|
||||
const toggleShortcuts = useSrtPlayerStore((state) => state.toggleShortcuts);
|
||||
const updateSettings = useSrtPlayerStore((state) => state.updateSettings);
|
||||
|
||||
const canPlay = useMemo(() => !!videoUrl && !!subtitleUrl && subtitleData.length > 0, [videoUrl, subtitleUrl, subtitleData]);
|
||||
const currentProgress = currentIndex ?? 0;
|
||||
const totalProgress = Math.max(0, subtitleData.length - 1);
|
||||
|
||||
const handleVideoUpload = useCallback(() => {
|
||||
uploadVideo(setVideoUrl, (error) => {
|
||||
toast.error(t('videoUploadFailed') + ': ' + error.message);
|
||||
});
|
||||
}, [uploadVideo, setVideoUrl, t]);
|
||||
|
||||
const handleSubtitleUpload = useCallback(() => {
|
||||
uploadSubtitle((url) => {
|
||||
setSubtitleUrl(url);
|
||||
}, (error) => {
|
||||
toast.error(t('subtitleUploadFailed') + ': ' + error.message);
|
||||
});
|
||||
}, [uploadSubtitle, setSubtitleUrl, t]);
|
||||
|
||||
const handleSeek = useCallback((index: number) => {
|
||||
if (subtitleData[index]) {
|
||||
seek(subtitleData[index].start);
|
||||
}
|
||||
}, [subtitleData, seek]);
|
||||
|
||||
const handlePlaybackRateChange = useCallback(() => {
|
||||
const rates = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
|
||||
const currentIndexRate = rates.indexOf(playbackRate);
|
||||
const nextIndexRate = (currentIndexRate + 1) % rates.length;
|
||||
setPlaybackRate(rates[nextIndexRate]);
|
||||
}, [playbackRate, setPlaybackRate]);
|
||||
|
||||
return (
|
||||
<div className="p-3 bg-gray-50 border-t rounded-b-xl">
|
||||
<VStack gap={3}>
|
||||
<HStack gap={3}>
|
||||
<div
|
||||
className={`flex-1 p-2 rounded-lg border-2 transition-all ${
|
||||
videoUrl ? 'border-gray-800 bg-gray-100' : 'border-gray-300 bg-white'
|
||||
}`}
|
||||
>
|
||||
<HStack gap={2} justify="between">
|
||||
<HStack gap={2}>
|
||||
<Video className="w-5 h-5 text-gray-600" />
|
||||
<VStack gap={0}>
|
||||
<h3 className="font-semibold text-gray-800 text-sm">{t('videoFile')}</h3>
|
||||
<p className="text-xs text-gray-600">{videoUrl ? t('uploaded') : t('notUploaded')}</p>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<LightButton
|
||||
onClick={videoUrl ? undefined : handleVideoUpload}
|
||||
disabled={!!videoUrl}
|
||||
size="sm"
|
||||
>
|
||||
{videoUrl ? t('uploaded') : t('upload')}
|
||||
</LightButton>
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex-1 p-2 rounded-lg border-2 transition-all ${
|
||||
subtitleUrl ? 'border-gray-800 bg-gray-100' : 'border-gray-300 bg-white'
|
||||
}`}
|
||||
>
|
||||
<HStack gap={2} justify="between">
|
||||
<HStack gap={2}>
|
||||
<FileText className="w-5 h-5 text-gray-600" />
|
||||
<VStack gap={0}>
|
||||
<h3 className="font-semibold text-gray-800 text-sm">{t('subtitleFile')}</h3>
|
||||
<p className="text-xs text-gray-600">{subtitleUrl ? t('uploaded') : t('notUploaded')}</p>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<LightButton
|
||||
onClick={subtitleUrl ? undefined : handleSubtitleUpload}
|
||||
disabled={!!subtitleUrl}
|
||||
size="sm"
|
||||
>
|
||||
{subtitleUrl ? t('uploaded') : t('upload')}
|
||||
</LightButton>
|
||||
</HStack>
|
||||
</div>
|
||||
</HStack>
|
||||
|
||||
<VStack
|
||||
gap={4}
|
||||
className={!canPlay ? 'opacity-50 pointer-events-none' : ''}
|
||||
>
|
||||
<HStack gap={2} justify="center" wrap>
|
||||
<Button
|
||||
onClick={togglePlayPause}
|
||||
disabled={!canPlay}
|
||||
leftIcon={isPlaying ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||
>
|
||||
{isPlaying ? t('pause') : t('play')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={previousSubtitle}
|
||||
disabled={!canPlay}
|
||||
leftIcon={<ChevronLeft className="w-4 h-4" />}
|
||||
>
|
||||
{t('previous')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={nextSubtitle}
|
||||
disabled={!canPlay}
|
||||
rightIcon={<ChevronRight className="w-4 h-4" />}
|
||||
>
|
||||
{t('next')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={restartSubtitle}
|
||||
disabled={!canPlay}
|
||||
leftIcon={<RotateCcw className="w-4 h-4" />}
|
||||
>
|
||||
{t('restart')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handlePlaybackRateChange}
|
||||
disabled={!canPlay}
|
||||
>
|
||||
{playbackRate}x
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={toggleAutoPause}
|
||||
disabled={!canPlay}
|
||||
leftIcon={<Pause className="w-4 h-4" />}
|
||||
variant={autoPause ? 'primary' : 'secondary'}
|
||||
>
|
||||
{t('autoPause', { enabled: autoPause ? t('on') : t('off') })}
|
||||
</Button>
|
||||
|
||||
<LightButton
|
||||
onClick={toggleSettings}
|
||||
leftIcon={<Settings className="w-4 h-4" />}
|
||||
>
|
||||
{t('settings')}
|
||||
</LightButton>
|
||||
|
||||
<LightButton
|
||||
onClick={toggleShortcuts}
|
||||
leftIcon={<Keyboard className="w-4 h-4" />}
|
||||
>
|
||||
{t('shortcuts')}
|
||||
</LightButton>
|
||||
</HStack>
|
||||
|
||||
<VStack gap={2}>
|
||||
<Range
|
||||
value={currentProgress}
|
||||
min={0}
|
||||
max={totalProgress}
|
||||
onChange={handleSeek}
|
||||
disabled={!canPlay}
|
||||
/>
|
||||
|
||||
<HStack gap={4} justify="between" className="text-sm text-gray-600 px-2">
|
||||
<span>
|
||||
{currentIndex !== null ? `${currentIndex + 1}/${subtitleData.length}` : '0/0'}
|
||||
</span>
|
||||
|
||||
<HStack gap={4}>
|
||||
<span className="bg-gray-200 px-2 py-1 rounded text-xs">
|
||||
{playbackRate}x
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs ${
|
||||
autoPause ? 'bg-gray-800 text-white' : 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{t('autoPauseStatus', { enabled: autoPause ? t('on') : t('off') })}
|
||||
</span>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</VStack>
|
||||
|
||||
{showSettings && (
|
||||
<div className="p-3 bg-white rounded-lg border border-gray-200">
|
||||
<h3 className="font-semibold text-gray-800 mb-3">{t('subtitleSettings')}</h3>
|
||||
<VStack gap={3}>
|
||||
<HStack gap={2} className="w-full">
|
||||
<span className="text-sm text-gray-600 w-20">{t('fontSize')}</span>
|
||||
<Range
|
||||
value={settings.fontSize}
|
||||
min={12}
|
||||
max={48}
|
||||
onChange={(value) => updateSettings({ fontSize: value })}
|
||||
/>
|
||||
<span className="text-sm text-gray-600 w-12">{settings.fontSize}px</span>
|
||||
</HStack>
|
||||
|
||||
<HStack gap={2} className="w-full">
|
||||
<span className="text-sm text-gray-600 w-20">{t('textColor')}</span>
|
||||
<input
|
||||
type="color"
|
||||
value={settings.textColor}
|
||||
onChange={(e) => updateSettings({ textColor: e.target.value })}
|
||||
className="w-8 h-8 rounded cursor-pointer"
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<HStack gap={2} className="w-full">
|
||||
<span className="text-sm text-gray-600 w-20">{t('backgroundColor')}</span>
|
||||
<input
|
||||
type="color"
|
||||
value={settings.backgroundColor.replace(/rgba?\([^)]+\)/, '#000000')}
|
||||
onChange={(e) => updateSettings({ backgroundColor: e.target.value })}
|
||||
className="w-8 h-8 rounded cursor-pointer"
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<HStack gap={2} className="w-full">
|
||||
<span className="text-sm text-gray-600 w-20">{t('position')}</span>
|
||||
<HStack gap={2}>
|
||||
{(['top', 'center', 'bottom'] as const).map((pos) => (
|
||||
<Button
|
||||
key={pos}
|
||||
size="sm"
|
||||
variant={settings.position === pos ? 'primary' : 'secondary'}
|
||||
onClick={() => updateSettings({ position: pos })}
|
||||
>
|
||||
{t(pos)}
|
||||
</Button>
|
||||
))}
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<HStack gap={2} className="w-full">
|
||||
<span className="text-sm text-gray-600 w-20">{t('opacity')}</span>
|
||||
<Range
|
||||
value={settings.opacity}
|
||||
min={0.1}
|
||||
max={1}
|
||||
step={0.1}
|
||||
onChange={(value) => updateSettings({ opacity: value })}
|
||||
/>
|
||||
<span className="text-sm text-gray-600 w-12">{Math.round(settings.opacity * 100)}%</span>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showShortcuts && (
|
||||
<div className="p-3 bg-white rounded-lg border border-gray-200">
|
||||
<h3 className="font-semibold text-gray-800 mb-3">{t('keyboardShortcuts')}</h3>
|
||||
<VStack gap={2}>
|
||||
{[
|
||||
{ key: 'Space', desc: t('playPause') },
|
||||
{ key: 'N', desc: t('next') },
|
||||
{ key: 'P', desc: t('previous') },
|
||||
{ key: 'R', desc: t('restart') },
|
||||
{ key: 'A', desc: t('autoPauseToggle') },
|
||||
].map((shortcut) => (
|
||||
<HStack key={shortcut.key} gap={2} justify="between" className="w-full">
|
||||
<kbd className="px-2 py-1 bg-gray-100 rounded text-sm font-mono">{shortcut.key}</kbd>
|
||||
<span className="text-sm text-gray-600">{shortcut.desc}</span>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</div>
|
||||
)}
|
||||
</VStack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, forwardRef } from 'react';
|
||||
import { useSrtPlayerStore } from '../stores/srtPlayerStore';
|
||||
import { setVideoRef } from '../stores/srtPlayerStore';
|
||||
|
||||
export const VideoPlayerPanel = forwardRef<HTMLVideoElement>((_, ref) => {
|
||||
const localVideoRef = useRef<HTMLVideoElement>(null);
|
||||
const videoRef = (ref as React.RefObject<HTMLVideoElement>) || localVideoRef;
|
||||
|
||||
const videoUrl = useSrtPlayerStore((state) => state.video.url);
|
||||
const subtitleUrl = useSrtPlayerStore((state) => state.subtitle.url);
|
||||
const subtitleData = useSrtPlayerStore((state) => state.subtitle.data);
|
||||
const currentText = useSrtPlayerStore((state) => state.subtitle.currentText);
|
||||
const settings = useSrtPlayerStore((state) => state.subtitle.settings);
|
||||
|
||||
useEffect(() => {
|
||||
setVideoRef(videoRef);
|
||||
}, [videoRef]);
|
||||
|
||||
return (
|
||||
<div className="aspect-video bg-black relative rounded-md overflow-hidden">
|
||||
{(!videoUrl || !subtitleUrl || subtitleData.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">
|
||||
{!videoUrl && !subtitleUrl
|
||||
? '请上传视频和字幕文件'
|
||||
: !videoUrl
|
||||
? '请上传视频文件'
|
||||
: !subtitleUrl
|
||||
? '请上传字幕文件'
|
||||
: '正在处理字幕...'}
|
||||
</p>
|
||||
{(!videoUrl || !subtitleUrl) && (
|
||||
<p className="text-sm text-gray-300">需要同时上传视频和字幕文件才能播放</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{videoUrl && (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl}
|
||||
className="w-full h-full"
|
||||
playsInline
|
||||
/>
|
||||
)}
|
||||
|
||||
{subtitleUrl && subtitleData.length > 0 && currentText && (
|
||||
<div
|
||||
className="absolute px-4 py-2 text-center w-full"
|
||||
style={{
|
||||
bottom: settings.position === 'top' ? 'auto' : settings.position === 'center' ? '50%' : '0',
|
||||
top: settings.position === 'top' ? '0' : 'auto',
|
||||
transform: settings.position === 'center' ? 'translateY(-50%)' : 'none',
|
||||
backgroundColor: settings.backgroundColor,
|
||||
color: settings.textColor,
|
||||
fontSize: `${settings.fontSize}px`,
|
||||
fontFamily: settings.fontFamily,
|
||||
opacity: settings.opacity,
|
||||
}}
|
||||
>
|
||||
{currentText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
VideoPlayerPanel.displayName = 'VideoPlayerPanel';
|
||||
@@ -1,81 +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 {
|
||||
const maxSize = 1000 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
throw new Error(`文件大小超过限制 (${(file.size / 1024 / 1024).toFixed(2)}MB > 1000MB)`);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useSrtPlayerStore } from "../stores/srtPlayerStore";
|
||||
|
||||
export function useSrtPlayerShortcuts(enabled: boolean = true) {
|
||||
const togglePlayPause = useSrtPlayerStore((state) => state.togglePlayPause);
|
||||
const nextSubtitle = useSrtPlayerStore((state) => state.nextSubtitle);
|
||||
const previousSubtitle = useSrtPlayerStore((state) => state.previousSubtitle);
|
||||
const restartSubtitle = useSrtPlayerStore((state) => state.restartSubtitle);
|
||||
const toggleAutoPause = useSrtPlayerStore((state) => state.toggleAutoPause);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||
if (!enabled) return;
|
||||
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case ' ':
|
||||
event.preventDefault();
|
||||
togglePlayPause();
|
||||
break;
|
||||
case 'n':
|
||||
case 'N':
|
||||
event.preventDefault();
|
||||
nextSubtitle();
|
||||
break;
|
||||
case 'p':
|
||||
case 'P':
|
||||
event.preventDefault();
|
||||
previousSubtitle();
|
||||
break;
|
||||
case 'r':
|
||||
case 'R':
|
||||
event.preventDefault();
|
||||
restartSubtitle();
|
||||
break;
|
||||
case 'a':
|
||||
case 'A':
|
||||
event.preventDefault();
|
||||
toggleAutoPause();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [enabled, togglePlayPause, nextSubtitle, previousSubtitle, restartSubtitle, toggleAutoPause]);
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts(
|
||||
shortcuts: Array<{ key: string; action: () => void }>,
|
||||
isEnabled: boolean = true
|
||||
) {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||
if (!isEnabled) 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();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [shortcuts, isEnabled]);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { useSrtPlayerStore } from "../stores/srtPlayerStore";
|
||||
|
||||
export function useSubtitleSync() {
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastIndexRef = useRef<number | null>(null);
|
||||
|
||||
const subtitleData = useSrtPlayerStore((state) => state.subtitle.data);
|
||||
const isPlaying = useSrtPlayerStore((state) => state.video.isPlaying);
|
||||
const autoPause = useSrtPlayerStore((state) => state.controls.autoPause);
|
||||
const playbackRate = useSrtPlayerStore((state) => state.video.playbackRate);
|
||||
const currentTime = useSrtPlayerStore((state) => state.video.currentTime);
|
||||
|
||||
const setCurrentSubtitle = useSrtPlayerStore((state) => state.setCurrentSubtitle);
|
||||
const pause = useSrtPlayerStore((state) => state.pause);
|
||||
|
||||
const scheduleAutoPause = useCallback(() => {
|
||||
if (!autoPause || !isPlaying) {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTimeNow = useSrtPlayerStore.getState().video.currentTime;
|
||||
const currentIndexNow = useSrtPlayerStore.getState().subtitle.currentIndex;
|
||||
|
||||
if (currentIndexNow === null || !subtitleData[currentIndexNow]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subtitle = subtitleData[currentIndexNow];
|
||||
const timeUntilEnd = subtitle.end - currentTimeNow;
|
||||
|
||||
if (timeUntilEnd <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const advanceTime = 0.15;
|
||||
const realTimeUntilPause = (timeUntilEnd - advanceTime) / playbackRate;
|
||||
|
||||
if (realTimeUntilPause > 0) {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
pause();
|
||||
}, realTimeUntilPause * 1000);
|
||||
}
|
||||
}, [autoPause, isPlaying, subtitleData, playbackRate, pause]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!subtitleData || subtitleData.length === 0) {
|
||||
setCurrentSubtitle('', null);
|
||||
lastIndexRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let newIndex: number | null = null;
|
||||
|
||||
for (let i = 0; i < subtitleData.length; i++) {
|
||||
const subtitle = subtitleData[i];
|
||||
if (currentTime >= subtitle.start && currentTime <= subtitle.end) {
|
||||
newIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (newIndex !== lastIndexRef.current) {
|
||||
lastIndexRef.current = newIndex;
|
||||
if (newIndex !== null) {
|
||||
setCurrentSubtitle(subtitleData[newIndex].text, newIndex);
|
||||
} else {
|
||||
setCurrentSubtitle('', null);
|
||||
}
|
||||
}
|
||||
}, [subtitleData, currentTime, setCurrentSubtitle]);
|
||||
|
||||
useEffect(() => {
|
||||
scheduleAutoPause();
|
||||
}, [isPlaying, autoPause]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPlaying && autoPause) {
|
||||
scheduleAutoPause();
|
||||
}
|
||||
}, [playbackRate, currentTime]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, type RefObject } from 'react';
|
||||
import { useSrtPlayerStore } from '../stores/srtPlayerStore';
|
||||
|
||||
export function useVideoSync(videoRef: RefObject<HTMLVideoElement | null>) {
|
||||
const setCurrentTime = useSrtPlayerStore((state) => state.setCurrentTime);
|
||||
const setDuration = useSrtPlayerStore((state) => state.setDuration);
|
||||
const play = useSrtPlayerStore((state) => state.play);
|
||||
const pause = useSrtPlayerStore((state) => state.pause);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
setCurrentTime(video.currentTime);
|
||||
};
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
setDuration(video.duration);
|
||||
};
|
||||
|
||||
const handlePlay = () => {
|
||||
play();
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
pause();
|
||||
};
|
||||
|
||||
video.addEventListener('timeupdate', handleTimeUpdate);
|
||||
video.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
video.addEventListener('play', handlePlay);
|
||||
video.addEventListener('pause', handlePause);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('timeupdate', handleTimeUpdate);
|
||||
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
video.removeEventListener('play', handlePlay);
|
||||
video.removeEventListener('pause', handlePause);
|
||||
};
|
||||
}, [videoRef, setCurrentTime, setDuration, play, pause]);
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { LightButton } from "@/design-system/base/button";
|
||||
import { HStack } from "@/design-system/layout/stack";
|
||||
import { Video, FileText, ChevronLeft, ChevronRight, RotateCcw, Pause, Play } from "lucide-react";
|
||||
import { useVideoSync } from "./hooks/useVideoSync";
|
||||
import { useSubtitleSync } from "./hooks/useSubtitleSync";
|
||||
import { useSrtPlayerShortcuts } from "./hooks/useKeyboardShortcuts";
|
||||
import { loadSubtitle } from "./utils/subtitleParser";
|
||||
import { useSrtPlayerStore } from "./stores/srtPlayerStore";
|
||||
import { useFileUpload } from "./hooks/useFileUpload";
|
||||
import { setVideoRef } from "./stores/srtPlayerStore";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function SrtPlayerPage() {
|
||||
const t = useTranslations("home");
|
||||
const srtT = useTranslations("srt_player");
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const { uploadVideo, uploadSubtitle } = useFileUpload();
|
||||
|
||||
const subtitleUrl = useSrtPlayerStore((state) => state.subtitle.url);
|
||||
const setSubtitleData = useSrtPlayerStore((state) => state.setSubtitleData);
|
||||
const setSubtitleUrl = useSrtPlayerStore((state) => state.setSubtitleUrl);
|
||||
const setVideoUrl = useSrtPlayerStore((state) => state.setVideoUrl);
|
||||
|
||||
const videoUrl = useSrtPlayerStore((state) => state.video.url);
|
||||
const subtitleData = useSrtPlayerStore((state) => state.subtitle.data);
|
||||
const currentIndex = useSrtPlayerStore((state) => state.subtitle.currentIndex);
|
||||
const isPlaying = useSrtPlayerStore((state) => state.video.isPlaying);
|
||||
const playbackRate = useSrtPlayerStore((state) => state.video.playbackRate);
|
||||
const autoPause = useSrtPlayerStore((state) => state.controls.autoPause);
|
||||
|
||||
const togglePlayPause = useSrtPlayerStore((state) => state.togglePlayPause);
|
||||
const nextSubtitle = useSrtPlayerStore((state) => state.nextSubtitle);
|
||||
const previousSubtitle = useSrtPlayerStore((state) => state.previousSubtitle);
|
||||
const restartSubtitle = useSrtPlayerStore((state) => state.restartSubtitle);
|
||||
const setPlaybackRate = useSrtPlayerStore((state) => state.setPlaybackRate);
|
||||
const toggleAutoPause = useSrtPlayerStore((state) => state.toggleAutoPause);
|
||||
const seek = useSrtPlayerStore((state) => state.seek);
|
||||
|
||||
useVideoSync(videoRef);
|
||||
useSubtitleSync();
|
||||
useSrtPlayerShortcuts();
|
||||
|
||||
useEffect(() => {
|
||||
setVideoRef(videoRef);
|
||||
}, [videoRef]);
|
||||
|
||||
const canPlay = !!videoUrl && !!subtitleUrl && subtitleData.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (subtitleUrl) {
|
||||
loadSubtitle(subtitleUrl)
|
||||
.then((subtitleData) => {
|
||||
setSubtitleData(subtitleData);
|
||||
toast.success(srtT("subtitleLoadSuccess"));
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(srtT("subtitleLoadFailed") + ": " + error.message);
|
||||
});
|
||||
}
|
||||
}, [srtT, subtitleUrl, setSubtitleData]);
|
||||
|
||||
const handleVideoUpload = () => {
|
||||
uploadVideo((url) => {
|
||||
setVideoUrl(url);
|
||||
}, (error) => {
|
||||
toast.error(t('videoUploadFailed') + ': ' + error.message);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubtitleUpload = () => {
|
||||
uploadSubtitle((url) => {
|
||||
setSubtitleUrl(url);
|
||||
}, (error) => {
|
||||
toast.error(t('subtitleUploadFailed') + ': ' + error.message);
|
||||
});
|
||||
};
|
||||
|
||||
const handlePlaybackRateChange = () => {
|
||||
const rates = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
|
||||
const currentIndexRate = rates.indexOf(playbackRate);
|
||||
const nextIndexRate = (currentIndexRate + 1) % rates.length;
|
||||
setPlaybackRate(rates[nextIndexRate]);
|
||||
};
|
||||
|
||||
const currentSubtitle = currentIndex !== null ? subtitleData[currentIndex] : null;
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<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>
|
||||
|
||||
<video
|
||||
ref={videoRef}
|
||||
width="85%"
|
||||
className="mx-auto"
|
||||
playsInline
|
||||
/>
|
||||
|
||||
<div className="shadow rounded h-20 w-[85%] mx-auto flex-wrap flex items-begin justify-center">
|
||||
{currentSubtitle && currentSubtitle.text.split(" ").map((s, i) => (
|
||||
<Link
|
||||
key={i}
|
||||
href={`/dictionary?q=${s}`}
|
||||
className="px-1 h-fit hover:bg-gray-200 hover:cursor-pointer"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{s}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-4 flex items-center justify-center flex-wrap gap-2 w-[85%]">
|
||||
<div className="border-gray-200 border-2 flex items p-2 justify-between items-center rounded gap-8">
|
||||
<div className="flex items-center flex-col">
|
||||
<Video size={16} />
|
||||
<span className="text-sm">{srtT("videoFile")}</span>
|
||||
</div>
|
||||
<LightButton onClick={handleVideoUpload} disabled={!!videoUrl}>
|
||||
{videoUrl ? srtT("uploaded") : srtT("uploadVideoButton")}
|
||||
</LightButton>
|
||||
</div>
|
||||
<div className="border-gray-200 border-2 flex items p-2 justify-between items-center rounded gap-8">
|
||||
<div className="flex items-center flex-col">
|
||||
<FileText size={16} />
|
||||
<span className="text-sm">
|
||||
{subtitleData.length > 0 ? srtT("subtitleUploaded", { count: subtitleData.length }) : srtT("subtitleNotUploaded")}
|
||||
</span>
|
||||
</div>
|
||||
<LightButton onClick={handleSubtitleUpload} disabled={!!subtitleUrl}>
|
||||
{subtitleUrl ? srtT("uploaded") : srtT("uploadSubtitleButton")}
|
||||
</LightButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canPlay && (
|
||||
<HStack gap={2} className="mx-auto mt-4 w-[85%]" justify={"center"} wrap>
|
||||
{isPlaying ? (
|
||||
<LightButton onClick={togglePlayPause} leftIcon={<Pause className="w-4 h-4" />}>
|
||||
{srtT('pause')}
|
||||
</LightButton>
|
||||
) : (
|
||||
<LightButton onClick={togglePlayPause} leftIcon={<Play className="w-4 h-4" />}>
|
||||
{srtT('play')}
|
||||
</LightButton>
|
||||
)}
|
||||
<LightButton onClick={previousSubtitle} leftIcon={<ChevronLeft className="w-4 h-4" />}>
|
||||
{srtT('previous')}
|
||||
</LightButton>
|
||||
<LightButton onClick={nextSubtitle} rightIcon={<ChevronRight className="w-4 h-4" />}>
|
||||
{srtT('next')}
|
||||
</LightButton>
|
||||
<LightButton onClick={restartSubtitle} leftIcon={<RotateCcw className="w-4 h-4" />}>
|
||||
{srtT('restart')}
|
||||
</LightButton>
|
||||
<LightButton onClick={handlePlaybackRateChange}>
|
||||
{playbackRate}x
|
||||
</LightButton>
|
||||
<LightButton onClick={toggleAutoPause}>
|
||||
{srtT('autoPause', { enabled: autoPause ? srtT('on') : srtT('off') })}
|
||||
</LightButton>
|
||||
</HStack>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import { toast } from 'sonner';
|
||||
import type {
|
||||
SrtPlayerStore,
|
||||
VideoState,
|
||||
SubtitleState,
|
||||
ControlState,
|
||||
SubtitleSettings,
|
||||
SubtitleEntry,
|
||||
} from '../types';
|
||||
import type { RefObject } from 'react';
|
||||
|
||||
let videoRef: RefObject<HTMLVideoElement | null> | null;
|
||||
|
||||
export function setVideoRef(ref: RefObject<HTMLVideoElement | null> | null) {
|
||||
videoRef = ref;
|
||||
}
|
||||
|
||||
const initialVideoState: VideoState = {
|
||||
url: null,
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
playbackRate: 1.0,
|
||||
volume: 1.0,
|
||||
};
|
||||
|
||||
const initialSubtitleSettings: SubtitleSettings = {
|
||||
fontSize: 24,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
textColor: '#ffffff',
|
||||
position: 'bottom',
|
||||
fontFamily: 'sans-serif',
|
||||
opacity: 1,
|
||||
};
|
||||
|
||||
const initialSubtitleState: SubtitleState = {
|
||||
url: null,
|
||||
data: [],
|
||||
currentText: '',
|
||||
currentIndex: null,
|
||||
settings: initialSubtitleSettings,
|
||||
};
|
||||
|
||||
const initialControlState: ControlState = {
|
||||
autoPause: true,
|
||||
showShortcuts: false,
|
||||
showSettings: false,
|
||||
};
|
||||
|
||||
export const useSrtPlayerStore = create<SrtPlayerStore>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
video: initialVideoState,
|
||||
subtitle: initialSubtitleState,
|
||||
controls: initialControlState,
|
||||
|
||||
setVideoUrl: (url) =>
|
||||
set((state) => {
|
||||
if (videoRef?.current) {
|
||||
videoRef.current.src = url || '';
|
||||
videoRef.current.load();
|
||||
}
|
||||
return { video: { ...state.video, url } };
|
||||
}),
|
||||
|
||||
setPlaying: (playing) =>
|
||||
set((state) => ({ video: { ...state.video, isPlaying: playing } })),
|
||||
|
||||
setCurrentTime: (time) =>
|
||||
set((state) => ({ video: { ...state.video, currentTime: time } })),
|
||||
|
||||
setDuration: (duration) =>
|
||||
set((state) => ({ video: { ...state.video, duration } })),
|
||||
|
||||
setPlaybackRate: (rate) =>
|
||||
set((state) => {
|
||||
if (videoRef?.current) {
|
||||
videoRef.current.playbackRate = rate;
|
||||
}
|
||||
return { video: { ...state.video, playbackRate: rate } };
|
||||
}),
|
||||
|
||||
setVolume: (volume) =>
|
||||
set((state) => {
|
||||
if (videoRef?.current) {
|
||||
videoRef.current.volume = volume;
|
||||
}
|
||||
return { video: { ...state.video, volume } };
|
||||
}),
|
||||
|
||||
play: () => {
|
||||
const state = get();
|
||||
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);
|
||||
});
|
||||
set({ video: { ...state.video, isPlaying: true } });
|
||||
}
|
||||
},
|
||||
|
||||
pause: () => {
|
||||
if (videoRef?.current) {
|
||||
if (!videoRef.current.paused) {
|
||||
videoRef.current.pause();
|
||||
}
|
||||
set((state) => ({ video: { ...state.video, isPlaying: false } }));
|
||||
}
|
||||
},
|
||||
|
||||
togglePlayPause: () => {
|
||||
const state = get();
|
||||
if (state.video.isPlaying) {
|
||||
get().pause();
|
||||
} else {
|
||||
get().play();
|
||||
}
|
||||
},
|
||||
|
||||
seek: (time) => {
|
||||
if (videoRef?.current) {
|
||||
videoRef.current.currentTime = time;
|
||||
set((state) => ({ video: { ...state.video, currentTime: time } }));
|
||||
}
|
||||
},
|
||||
|
||||
restart: () => {
|
||||
const state = get();
|
||||
if (state.subtitle.currentIndex !== null) {
|
||||
const currentSubtitle = state.subtitle.data[state.subtitle.currentIndex];
|
||||
if (currentSubtitle) {
|
||||
get().seek(currentSubtitle.start);
|
||||
get().play();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
setSubtitleUrl: (url) =>
|
||||
set((state) => ({ subtitle: { ...state.subtitle, url } })),
|
||||
|
||||
setSubtitleData: (data) =>
|
||||
set((state) => ({ subtitle: { ...state.subtitle, data } })),
|
||||
|
||||
setCurrentSubtitle: (text, index) =>
|
||||
set((state) => ({
|
||||
subtitle: {
|
||||
...state.subtitle,
|
||||
currentText: text,
|
||||
currentIndex: index,
|
||||
},
|
||||
})),
|
||||
|
||||
updateSettings: (settings) =>
|
||||
set((state) => ({
|
||||
subtitle: {
|
||||
...state.subtitle,
|
||||
settings: { ...state.subtitle.settings, ...settings },
|
||||
},
|
||||
})),
|
||||
|
||||
nextSubtitle: () => {
|
||||
const state = get();
|
||||
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];
|
||||
get().seek(nextSubtitle.start);
|
||||
get().play();
|
||||
}
|
||||
},
|
||||
|
||||
previousSubtitle: () => {
|
||||
const state = get();
|
||||
if (state.subtitle.currentIndex !== null && state.subtitle.currentIndex > 0) {
|
||||
const prevIndex = state.subtitle.currentIndex - 1;
|
||||
const prevSubtitle = state.subtitle.data[prevIndex];
|
||||
get().seek(prevSubtitle.start);
|
||||
get().play();
|
||||
}
|
||||
},
|
||||
|
||||
restartSubtitle: () => {
|
||||
const state = get();
|
||||
if (state.subtitle.currentIndex !== null) {
|
||||
const currentSubtitle = state.subtitle.data[state.subtitle.currentIndex];
|
||||
get().seek(currentSubtitle.start);
|
||||
get().play();
|
||||
}
|
||||
},
|
||||
|
||||
toggleAutoPause: () =>
|
||||
set((state) => ({
|
||||
controls: { ...state.controls, autoPause: !state.controls.autoPause },
|
||||
})),
|
||||
|
||||
toggleShortcuts: () =>
|
||||
set((state) => ({
|
||||
controls: { ...state.controls, showShortcuts: !state.controls.showShortcuts },
|
||||
})),
|
||||
|
||||
toggleSettings: () =>
|
||||
set((state) => ({
|
||||
controls: { ...state.controls, showSettings: !state.controls.showSettings },
|
||||
})),
|
||||
}),
|
||||
{ name: 'srt-player-store' }
|
||||
)
|
||||
);
|
||||
@@ -1,132 +0,0 @@
|
||||
// ==================== Video Types ====================
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ==================== Subtitle Types ====================
|
||||
|
||||
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 SubtitleControls {
|
||||
next: () => void;
|
||||
previous: () => void;
|
||||
goToIndex: (index: number) => void;
|
||||
toggleAutoPause: () => void;
|
||||
}
|
||||
|
||||
// ==================== Controls Types ====================
|
||||
|
||||
export interface ControlState {
|
||||
autoPause: boolean;
|
||||
showShortcuts: boolean;
|
||||
showSettings: boolean;
|
||||
}
|
||||
|
||||
export interface ControlActions {
|
||||
toggleAutoPause: () => void;
|
||||
toggleShortcuts: () => void;
|
||||
toggleSettings: () => void;
|
||||
}
|
||||
|
||||
export interface KeyboardShortcut {
|
||||
key: string;
|
||||
description: string;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
// ==================== Store Types ====================
|
||||
|
||||
export interface SrtPlayerStore {
|
||||
// Video state
|
||||
video: VideoState;
|
||||
|
||||
// Subtitle state
|
||||
subtitle: SubtitleState;
|
||||
|
||||
// Controls state
|
||||
controls: ControlState;
|
||||
|
||||
// Video actions
|
||||
setVideoUrl: (url: string | null) => void;
|
||||
setPlaying: (playing: boolean) => void;
|
||||
setCurrentTime: (time: number) => void;
|
||||
setDuration: (duration: number) => void;
|
||||
setPlaybackRate: (rate: number) => void;
|
||||
setVolume: (volume: number) => void;
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
togglePlayPause: () => void;
|
||||
seek: (time: number) => void;
|
||||
restart: () => void;
|
||||
|
||||
// Subtitle actions
|
||||
setSubtitleUrl: (url: string | null) => void;
|
||||
setSubtitleData: (data: SubtitleEntry[]) => void;
|
||||
setCurrentSubtitle: (text: string, index: number | null) => void;
|
||||
updateSettings: (settings: Partial<SubtitleSettings>) => void;
|
||||
nextSubtitle: () => void;
|
||||
previousSubtitle: () => void;
|
||||
restartSubtitle: () => void;
|
||||
|
||||
// Controls actions
|
||||
toggleAutoPause: () => void;
|
||||
toggleShortcuts: () => void;
|
||||
toggleSettings: () => void;
|
||||
}
|
||||
|
||||
// ==================== Selectors ====================
|
||||
|
||||
export const selectors = {
|
||||
canPlay: (state: SrtPlayerStore) =>
|
||||
!!state.video.url &&
|
||||
!!state.subtitle.url &&
|
||||
state.subtitle.data.length > 0,
|
||||
|
||||
currentSubtitle: (state: SrtPlayerStore) =>
|
||||
state.subtitle.currentIndex !== null
|
||||
? state.subtitle.data[state.subtitle.currentIndex]
|
||||
: null,
|
||||
|
||||
progress: (state: SrtPlayerStore) => ({
|
||||
current: state.subtitle.currentIndex ?? 0,
|
||||
total: state.subtitle.data.length,
|
||||
}),
|
||||
};
|
||||
@@ -1,98 +0,0 @@
|
||||
import { SubtitleEntry } from "../types";
|
||||
|
||||
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 isWithin = currentTime >= subtitle.start && currentTime <= subtitle.end;
|
||||
|
||||
if (isWithin) return i;
|
||||
if (currentTime < subtitle.start) return i > 0 ? i - 1 : null;
|
||||
}
|
||||
return subtitles.length > 0 ? subtitles.length - 1 : 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('加载字幕失败', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { LightButton, PrimaryButton, IconClick } from "@/design-system/base/button";
|
||||
import { IMAGES } from "@/config/images";
|
||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRef, useState } from "react";
|
||||
import { actionTranslateText } from "@/modules/translator/translator-action";
|
||||
import { toast } from "sonner";
|
||||
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
|
||||
import { TSharedTranslationResult } from "@/shared/translator-type";
|
||||
|
||||
export default function TranslatorPage() {
|
||||
const t = useTranslations("translator");
|
||||
|
||||
const taref = useRef<HTMLTextAreaElement>(null);
|
||||
const [targetLanguage, setTargetLanguage] = useState<string>("Chinese");
|
||||
const [translationResult, setTranslationResult] = useState<TSharedTranslationResult | null>(null);
|
||||
const [needIpa, setNeedIpa] = useState(true);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [lastTranslation, setLastTranslation] = useState<{
|
||||
sourceText: string;
|
||||
targetLanguage: string;
|
||||
} | null>(null);
|
||||
const { load, play } = useAudioPlayer();
|
||||
const lastTTS = useRef({
|
||||
text: "",
|
||||
url: "",
|
||||
});
|
||||
|
||||
const tts = async (text: string, locale: string) => {
|
||||
if (lastTTS.current.text !== text) {
|
||||
try {
|
||||
// Map language name to TTS format
|
||||
let theLanguage = locale.toLowerCase().replace(/[^a-z]/g, '').replace(/^./, match => match.toUpperCase());
|
||||
|
||||
// Check if language is in TTS supported list
|
||||
const supportedLanguages: TTS_SUPPORTED_LANGUAGES[] = [
|
||||
"Auto", "Chinese", "English", "German", "Italian", "Portuguese",
|
||||
"Spanish", "Japanese", "Korean", "French", "Russian"
|
||||
];
|
||||
|
||||
if (!supportedLanguages.includes(theLanguage as TTS_SUPPORTED_LANGUAGES)) {
|
||||
theLanguage = "Auto";
|
||||
}
|
||||
|
||||
const url = await getTTSUrl(text, theLanguage as TTS_SUPPORTED_LANGUAGES);
|
||||
await load(url);
|
||||
await play();
|
||||
lastTTS.current.text = text;
|
||||
lastTTS.current.url = url;
|
||||
} catch (error) {
|
||||
toast.error("Failed to generate audio");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const translate = async () => {
|
||||
if (!taref.current || processing) return;
|
||||
|
||||
setProcessing(true);
|
||||
|
||||
const sourceText = taref.current.value;
|
||||
|
||||
// 判断是否需要强制重新翻译
|
||||
// 只有当源文本和目标语言都与上次相同时,才强制重新翻译
|
||||
const forceRetranslate =
|
||||
lastTranslation?.sourceText === sourceText &&
|
||||
lastTranslation?.targetLanguage === targetLanguage;
|
||||
|
||||
try {
|
||||
const result = await actionTranslateText({
|
||||
sourceText,
|
||||
targetLanguage,
|
||||
forceRetranslate,
|
||||
needIpa,
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
setTranslationResult(result.data);
|
||||
setLastTranslation({
|
||||
sourceText,
|
||||
targetLanguage,
|
||||
});
|
||||
} else {
|
||||
toast.error(result.message || "翻译失败,请重试");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("翻译失败,请重试");
|
||||
console.error("翻译错误:", error);
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-64px)] bg-white">
|
||||
{/* 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-lg 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">
|
||||
{translationResult?.sourceIpa || ""}
|
||||
</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, translationResult?.sourceLanguage || "");
|
||||
}}
|
||||
></IconClick>
|
||||
</div>
|
||||
</div>
|
||||
<div className="option1 w-full flex flex-row justify-between items-center">
|
||||
<span>{t("detectLanguage")}</span>
|
||||
<LightButton
|
||||
selected={needIpa}
|
||||
onClick={() => setNeedIpa((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-lg w-full h-64 p-2">
|
||||
<div className="h-2/3 w-full overflow-y-auto">{translationResult?.translatedText || ""}</div>
|
||||
<div className="ipa w-full h-1/6 overflow-y-auto text-gray-600">
|
||||
{translationResult?.targetIpa || ""}
|
||||
</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(translationResult?.translatedText || "");
|
||||
}}
|
||||
></IconClick>
|
||||
<IconClick
|
||||
src={IMAGES.play_arrow}
|
||||
alt="play"
|
||||
onClick={() => {
|
||||
if (!translationResult) return;
|
||||
tts(
|
||||
translationResult.translatedText,
|
||||
translationResult.targetLanguage,
|
||||
);
|
||||
}}
|
||||
></IconClick>
|
||||
</div>
|
||||
</div>
|
||||
<div className="option2 w-full flex gap-1 items-center flex-wrap">
|
||||
<span>{t("translateInto")}</span>
|
||||
<LightButton
|
||||
selected={targetLanguage === "Chinese"}
|
||||
onClick={() => setTargetLanguage("Chinese")}
|
||||
>
|
||||
{t("chinese")}
|
||||
</LightButton>
|
||||
<LightButton
|
||||
selected={targetLanguage === "English"}
|
||||
onClick={() => setTargetLanguage("English")}
|
||||
>
|
||||
{t("english")}
|
||||
</LightButton>
|
||||
<LightButton
|
||||
selected={targetLanguage === "Italian"}
|
||||
onClick={() => setTargetLanguage("Italian")}
|
||||
>
|
||||
{t("italian")}
|
||||
</LightButton>
|
||||
<LightButton
|
||||
selected={!["Chinese", "English", "Italian"].includes(targetLanguage)}
|
||||
onClick={() => {
|
||||
const newLang = prompt(t("enterLanguage"));
|
||||
if (newLang) {
|
||||
setTargetLanguage(newLang);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("other")}
|
||||
</LightButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TranslateButton Component */}
|
||||
<div className="w-screen flex justify-center items-center">
|
||||
<PrimaryButton
|
||||
onClick={translate}
|
||||
disabled={processing}
|
||||
size="lg"
|
||||
className="text-xl"
|
||||
>
|
||||
{t("translate")}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,17 @@
|
||||
import { LightButton } from "@/design-system/base/button";
|
||||
import { IconClick } from "@/design-system/base/button";
|
||||
import { IMAGES } from "@/config/images";
|
||||
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import IconClick from "@/components/IconClick";
|
||||
import IMAGES from "@/config/images";
|
||||
import { Letter, SupportedAlphabets } from "@/interfaces";
|
||||
import {
|
||||
Dispatch,
|
||||
KeyboardEvent,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function MemoryCard({
|
||||
export default function MemoryCard({
|
||||
alphabet,
|
||||
setChosenAlphabet,
|
||||
}: {
|
||||
@@ -20,35 +19,34 @@ export 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"
|
||||
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => e.preventDefault()}
|
||||
>
|
||||
<div className="m-4 p-4 w-full md:w-[60dvw] flex-col rounded-lg shadow border-gray-200 border flex justify-center items-center">
|
||||
<div className="m-4 p-4 w-full md:w-[60dvw] flex-col rounded-2xl shadow border-gray-200 border flex justify-center items-center">
|
||||
<div className="w-full flex justify-end items-center">
|
||||
<IconClick
|
||||
size="lg"
|
||||
size={32}
|
||||
alt="close"
|
||||
src={IMAGES.close}
|
||||
onClick={() => setChosenAlphabet(null)}
|
||||
@@ -64,13 +62,13 @@ export function MemoryCard({
|
||||
</div>
|
||||
<div className="flex flex-row mt-32 items-center justify-center gap-2">
|
||||
<IconClick
|
||||
size="lg"
|
||||
size={48}
|
||||
alt="refresh"
|
||||
src={IMAGES.refresh}
|
||||
onClick={refresh}
|
||||
></IconClick>
|
||||
<IconClick
|
||||
size="lg"
|
||||
size={48}
|
||||
alt="more"
|
||||
src={IMAGES.more_horiz}
|
||||
onClick={() => setMore(!more)}
|
||||
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,218 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronRight,
|
||||
Folder as Fd,
|
||||
FolderPen,
|
||||
FolderPlus,
|
||||
Globe,
|
||||
Lock,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { CircleButton, LightButton } from "@/design-system/base/button";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { CardList } from "@/components/ui/CardList";
|
||||
import {
|
||||
actionCreateFolder,
|
||||
actionDeleteFolderById,
|
||||
actionGetFoldersWithTotalPairsByUserId,
|
||||
actionRenameFolderById,
|
||||
actionSetFolderVisibility,
|
||||
} from "@/modules/folder/folder-action";
|
||||
import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
|
||||
|
||||
interface FolderCardProps {
|
||||
folder: TSharedFolderWithTotalPairs;
|
||||
onUpdateFolder: (folderId: number, updates: Partial<TSharedFolderWithTotalPairs>) => void;
|
||||
onDeleteFolder: (folderId: number) => void;
|
||||
}
|
||||
|
||||
const FolderCard = ({ folder, onUpdateFolder, onDeleteFolder }: FolderCardProps) => {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("folders");
|
||||
|
||||
const handleToggleVisibility = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const newVisibility = folder.visibility === "PUBLIC" ? "PRIVATE" : "PUBLIC";
|
||||
const result = await actionSetFolderVisibility(folder.id, newVisibility);
|
||||
if (result.success) {
|
||||
onUpdateFolder(folder.id, { visibility: newVisibility });
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRename = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const newName = prompt(t("enterNewName"))?.trim();
|
||||
if (newName && newName.length > 0) {
|
||||
const result = await actionRenameFolderById(folder.id, newName);
|
||||
if (result.success) {
|
||||
onUpdateFolder(folder.id, { name: newName });
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const confirm = prompt(t("confirmDelete", { name: folder.name }));
|
||||
if (confirm === folder.name) {
|
||||
const result = await actionDeleteFolderById(folder.id);
|
||||
if (result.success) {
|
||||
onDeleteFolder(folder.id);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex justify-between items-center group py-4 px-5 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
onClick={() => {
|
||||
router.push(`/folders/${folder.id}`);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="shrink-0 text-primary-500">
|
||||
<Fd size={24} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{folder.name}</h3>
|
||||
<span className="flex items-center gap-1 text-xs text-gray-400">
|
||||
{folder.visibility === "PUBLIC" ? (
|
||||
<Globe size={12} />
|
||||
) : (
|
||||
<Lock size={12} />
|
||||
)}
|
||||
{folder.visibility === "PUBLIC" ? t("public") : t("private")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
{t("folderInfo", {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
totalPairs: folder.total,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 ml-4">
|
||||
<CircleButton
|
||||
onClick={handleToggleVisibility}
|
||||
title={folder.visibility === "PUBLIC" ? t("setPrivate") : t("setPublic")}
|
||||
>
|
||||
{folder.visibility === "PUBLIC" ? (
|
||||
<Lock size={18} />
|
||||
) : (
|
||||
<Globe size={18} />
|
||||
)}
|
||||
</CircleButton>
|
||||
<CircleButton onClick={handleRename}>
|
||||
<FolderPen size={18} />
|
||||
</CircleButton>
|
||||
<CircleButton
|
||||
onClick={handleDelete}
|
||||
className="hover:text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</CircleButton>
|
||||
<ChevronRight size={20} className="text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface FoldersClientProps {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export function FoldersClient({ userId }: FoldersClientProps) {
|
||||
const t = useTranslations("folders");
|
||||
const router = useRouter();
|
||||
const [folders, setFolders] = useState<TSharedFolderWithTotalPairs[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadFolders = async () => {
|
||||
setLoading(true);
|
||||
const result = await actionGetFoldersWithTotalPairsByUserId(userId);
|
||||
if (result.success && result.data) {
|
||||
setFolders(result.data);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadFolders();
|
||||
}, [userId]);
|
||||
|
||||
const handleUpdateFolder = (folderId: number, updates: Partial<TSharedFolderWithTotalPairs>) => {
|
||||
setFolders((prev) =>
|
||||
prev.map((f) => (f.id === folderId ? { ...f, ...updates } : f))
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteFolder = (folderId: number) => {
|
||||
setFolders((prev) => prev.filter((f) => f.id !== folderId));
|
||||
};
|
||||
|
||||
const handleCreateFolder = async () => {
|
||||
const folderName = prompt(t("enterFolderName"));
|
||||
if (!folderName?.trim()) return;
|
||||
|
||||
const result = await actionCreateFolder(userId, folderName.trim());
|
||||
if (result.success) {
|
||||
loadFolders();
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<div className="mb-4">
|
||||
<LightButton onClick={handleCreateFolder}>
|
||||
<FolderPlus size={18} />
|
||||
{t("newFolder")}
|
||||
</LightButton>
|
||||
</div>
|
||||
|
||||
<CardList>
|
||||
{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("loading")}</p>
|
||||
</div>
|
||||
) : folders.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<Fd size={24} className="text-gray-400" />
|
||||
</div>
|
||||
<p className="text-sm">{t("noFoldersYet")}</p>
|
||||
</div>
|
||||
) : (
|
||||
folders.map((folder) => (
|
||||
<FolderCard
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
onUpdateFolder={handleUpdateFolder}
|
||||
onDeleteFolder={handleDeleteFolder}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</CardList>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { LightButton } from "@/design-system/base/button";
|
||||
import { Input } from "@/design-system/base/input";
|
||||
import { LocaleSelector } from "@/components/ui/LocaleSelector";
|
||||
import { X } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface AddTextPairModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onAdd: (
|
||||
text1: string,
|
||||
text2: string,
|
||||
language1: string,
|
||||
language2: string,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function AddTextPairModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onAdd,
|
||||
}: AddTextPairModalProps) {
|
||||
const t = useTranslations("folder_id");
|
||||
const input1Ref = useRef<HTMLInputElement>(null);
|
||||
const input2Ref = useRef<HTMLInputElement>(null);
|
||||
const [language1, setLanguage1] = useState("english");
|
||||
const [language2, setLanguage2] = useState("chinese");
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleAdd = () => {
|
||||
if (
|
||||
!input1Ref.current?.value ||
|
||||
!input2Ref.current?.value ||
|
||||
!language1 ||
|
||||
!language2
|
||||
)
|
||||
return;
|
||||
|
||||
const text1 = input1Ref.current.value;
|
||||
const text2 = input2Ref.current.value;
|
||||
|
||||
if (
|
||||
typeof text1 === "string" &&
|
||||
typeof text2 === "string" &&
|
||||
typeof language1 === "string" &&
|
||||
typeof language2 === "string" &&
|
||||
text1.trim() !== "" &&
|
||||
text2.trim() !== "" &&
|
||||
language1.trim() !== "" &&
|
||||
language2.trim() !== ""
|
||||
) {
|
||||
onAdd(text1, text2, language1, language2);
|
||||
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-md 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("language1")}
|
||||
<LocaleSelector value={language1} onChange={setLanguage1} />
|
||||
</div>
|
||||
<div>
|
||||
{t("language2")}
|
||||
<LocaleSelector value={language2} onChange={setLanguage2} />
|
||||
</div>
|
||||
</div>
|
||||
<LightButton onClick={handleAdd}>{t("add")}</LightButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, Plus } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { redirect, useRouter } from "next/navigation";
|
||||
import { AddTextPairModal } from "./AddTextPairModal";
|
||||
import { TextPairCard } from "./TextPairCard";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { PrimaryButton, CircleButton, LinkButton } from "@/design-system/base/button";
|
||||
import { CardList } from "@/components/ui/CardList";
|
||||
import { actionCreatePair, actionDeletePairById, actionGetPairsByFolderId } from "@/modules/folder/folder-action";
|
||||
import { TSharedPair } from "@/shared/folder-type";
|
||||
import { toast } from "sonner";
|
||||
|
||||
|
||||
export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnly: boolean; }) {
|
||||
const [textPairs, setTextPairs] = useState<TSharedPair[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [openAddModal, setAddModal] = useState(false);
|
||||
const router = useRouter();
|
||||
const t = useTranslations("folder_id");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTextPairs = async () => {
|
||||
setLoading(true);
|
||||
await actionGetPairsByFolderId(folderId)
|
||||
.then(result => {
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error(result.message || "Failed to load text pairs");
|
||||
}
|
||||
return result.data;
|
||||
}).then(setTextPairs)
|
||||
.catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
fetchTextPairs();
|
||||
}, [folderId]);
|
||||
|
||||
const refreshTextPairs = async () => {
|
||||
await actionGetPairsByFolderId(folderId)
|
||||
.then(result => {
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error(result.message || "Failed to refresh text pairs");
|
||||
}
|
||||
return result.data;
|
||||
}).then(setTextPairs)
|
||||
.catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 顶部导航和标题栏 */}
|
||||
<div className="mb-6">
|
||||
{/* 返回按钮 */}
|
||||
<LinkButton
|
||||
onClick={router.back}
|
||||
className="flex items-center gap-2 mb-4"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<span className="text-sm">{t("back")}</span>
|
||||
</LinkButton>
|
||||
|
||||
{/* 页面标题和操作按钮 */}
|
||||
<div className="flex items-center justify-between">
|
||||
{/* 标题区域 */}
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-1">
|
||||
{t("textPairs")}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t("itemsCount", { count: textPairs.length })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮区域 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<PrimaryButton
|
||||
onClick={() => {
|
||||
redirect(`/memorize?folder_id=${folderId}`);
|
||||
}}
|
||||
>
|
||||
{t("memorize")}
|
||||
</PrimaryButton>
|
||||
{!isReadOnly && (
|
||||
<CircleButton
|
||||
onClick={() => {
|
||||
setAddModal(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={18} className="text-gray-700" />
|
||||
</CircleButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文本对列表 */}
|
||||
<CardList>
|
||||
{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}
|
||||
isReadOnly={isReadOnly}
|
||||
onDel={() => {
|
||||
actionDeletePairById(textPair.id)
|
||||
.then(result => {
|
||||
if (!result.success) throw new Error(result.message || "Delete failed");
|
||||
}).then(refreshTextPairs)
|
||||
.catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||
});
|
||||
}}
|
||||
refreshTextPairs={refreshTextPairs}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardList>
|
||||
|
||||
{/* 添加文本对模态框 */}
|
||||
<AddTextPairModal
|
||||
isOpen={openAddModal}
|
||||
onClose={() => setAddModal(false)}
|
||||
onAdd={async (
|
||||
text1: string,
|
||||
text2: string,
|
||||
language1: string,
|
||||
language2: string,
|
||||
) => {
|
||||
await actionCreatePair({
|
||||
text1: text1,
|
||||
text2: text2,
|
||||
language1: language1,
|
||||
language2: language2,
|
||||
folderId: folderId,
|
||||
});
|
||||
refreshTextPairs();
|
||||
}}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user