Compare commits
141 Commits
4829ab9531
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| b8cb884e9e | |||
| 73d0b0d5fe | |||
| fe5e8533b5 | |||
| 12eb5c412a | |||
| 3635fbd256 | |||
| 058ecf7e39 | |||
| 6c7095ffb3 | |||
| 8ed9b011f4 | |||
| 2537b9fe75 | |||
| 5e24fa76a3 | |||
| 9d42a45bb1 | |||
| d5dde77ee9 | |||
| c4a9247cad | |||
| 56552863bf | |||
| 0af99b6b70 | |||
| eaf97b8279 | |||
| 76749549ff | |||
| fa6301538b | |||
| d4d5a53747 | |||
| ec265be26b | |||
| 804baa64b2 | |||
| a1e42127e6 | |||
| f1d706e20c | |||
| c7cdf40f2f | |||
| a55e763525 | |||
| 9715844eae | |||
| 504ecd259d | |||
| 06e90687f1 | |||
| b093ed2b4f | |||
| 37e221d8b8 | |||
| f1dcd5afaa | |||
| 66d17df59d | |||
| be3eb17490 | |||
| bd7eca1bd0 | |||
| 3bc804c5e8 | |||
| 4c64aa0a40 | |||
| 13e8789321 | |||
| f3b7f86413 | |||
| 6c4a73d857 | |||
| 7c70ec1028 | |||
| 5f24929116 | |||
| d8f0117359 | |||
| 2c84ab4370 | |||
| e17437a5ad | |||
| ff0954a413 | |||
| 573b1cb7e5 | |||
| 605c57f8bb | |||
| b69e168558 | |||
| 65aacc1582 | |||
| 572534a009 | |||
| 0d251a7e68 | |||
| e845c4abb7 | |||
| 881d9ca921 | |||
| db96b86e65 | |||
| 467232457a | |||
| af1b445072 | |||
| 560966f438 | |||
| 7695b2074d | |||
| c6840fb8d6 | |||
| a1a730b547 | |||
| 4b6a4735ee | |||
| 4a4ae6fb6a | |||
| 5ac9450897 | |||
| 41005a4aac | |||
| fcc20fc2e0 | |||
| bd5fc06cc5 | |||
| 71955a712a | |||
| a88dd2b91a | |||
| 4cbde97f41 | |||
| 7bf3fd9b17 | |||
| e8f5ce9751 | |||
| baf7265bf8 | |||
| bc0dab64c6 | |||
| cdfd676c0d | |||
| a2e579cb7b | |||
| 4eb44422d2 | |||
| 0bf3b718b2 | |||
| 22a0cf46fb | |||
| 98c771cab4 | |||
| 5d2ec4ac5c | |||
| 2bbb5008d2 | |||
| 4ed0f43164 | |||
| 1473a72a2f | |||
| b1a3add1d9 | |||
| f339e5e2f0 | |||
| 52ac68fed4 | |||
| 7c5fc40209 | |||
| 30fc4ed64d | |||
| d20c40cfb4 | |||
| 0e3d41829c | |||
| 72c6791d93 | |||
| cf3cb916b7 | |||
| adcb7920bd | |||
| 94d570557b | |||
| d4f786c990 | |||
| b30f9fb0c3 | |||
| 6389135156 | |||
| 97a21dfd2f | |||
| 5cf100c111 | |||
| a528b78e43 | |||
| f283695f8f | |||
| ff80556e8c | |||
| 89eb26a357 | |||
| f1d139d9da | |||
| 6d5a90407d | |||
| 49104d3aa6 | |||
| 68924a2c88 | |||
| 502c75fc01 | |||
| b69dcbb52c | |||
| f5bb1ca507 | |||
| b74e985770 | |||
| d2f9a58cca | |||
| fb9623af88 | |||
| 0c3dc037cb | |||
| 00d7aee32a | |||
| 4529c58aad | |||
| 99c58217c9 | |||
| e8bc064ad5 | |||
| 54e0eb452b | |||
| 5428c55094 | |||
| ffc1499232 | |||
| 0900ac26f7 | |||
| e6d6096636 | |||
| 89ef27eb57 | |||
| cb805e2199 | |||
| dd1d288d0d | |||
| 8f2b3eb0cc | |||
| f45645cc73 | |||
| d3eac5ccda | |||
| 664dac2f00 | |||
| 986be675b2 | |||
| aaa484ebee | |||
| a632e5f249 | |||
| 156b5aad34 | |||
| 75f1e529ac | |||
| 84837de999 | |||
| a9d0247294 | |||
| 4708828972 | |||
| 85085ba5ff | |||
| 2edfb0afb4 | |||
| 9d4d2c6299 |
39
.dockerignore
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
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
|
||||||
49
.drone.yml
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: learn-languages
|
||||||
|
|
||||||
|
platform:
|
||||||
|
os: linux
|
||||||
|
arch: amd64
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: build
|
||||||
|
image: plugins/docker
|
||||||
|
settings:
|
||||||
|
username:
|
||||||
|
from_secret: docker_username
|
||||||
|
password:
|
||||||
|
from_secret: docker_password
|
||||||
|
repo: registry.edian-studio.com/learn-languages
|
||||||
|
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
|
||||||
|
settings:
|
||||||
|
host:
|
||||||
|
from_secret: ssh_host
|
||||||
|
username:
|
||||||
|
from_secret: ssh_username
|
||||||
|
password:
|
||||||
|
from_secret: ssh_password
|
||||||
|
port: 22
|
||||||
|
script:
|
||||||
|
- cd ~/docker/learn-languages
|
||||||
|
- docker compose up -d --pull always --force-recreate
|
||||||
|
debug: true
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
branch:
|
||||||
|
- main
|
||||||
15
.env.example
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// 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=
|
||||||
15
.gitignore
vendored
@@ -40,6 +40,15 @@ yarn-error.log*
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
deploy.sh
|
.env
|
||||||
learn-languages.tar.gz
|
!.env.example
|
||||||
src/app/test
|
|
||||||
|
build.sh
|
||||||
|
|
||||||
|
test.ts
|
||||||
|
test.js
|
||||||
|
/generated/prisma
|
||||||
|
|
||||||
|
certificates
|
||||||
|
|
||||||
|
.claude
|
||||||
|
|||||||
13
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
128
CLAUDE.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
本文件为 Claude Code (claude.ai/code) 在此代码库中工作时提供指导。
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
|
||||||
|
这是一个基于 Next.js 16 构建的全栈语言学习平台,提供翻译工具、文本转语音、字幕播放、字母学习和记忆功能。平台支持 8 种语言,具有完整的国际化支持。
|
||||||
|
|
||||||
|
## 开发命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动开发服务器(启用 HTTPS)
|
||||||
|
pnpm run dev
|
||||||
|
|
||||||
|
# 构建生产版本(standalone 输出模式,用于 Docker)
|
||||||
|
pnpm run build
|
||||||
|
|
||||||
|
# 启动生产服务器
|
||||||
|
pnpm run start
|
||||||
|
|
||||||
|
# 代码检查
|
||||||
|
pnpm run lint
|
||||||
|
|
||||||
|
# 数据库操作
|
||||||
|
# 不要进行数据库操作,让用户操作数据库
|
||||||
|
```
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **Next.js 16** 使用 App Router 和 standalone 输出模式
|
||||||
|
- **React 19** 启用 React Compiler 进行优化
|
||||||
|
- **TypeScript** 严格模式和 ES2023 目标
|
||||||
|
- **Tailwind CSS v4** 样式框架
|
||||||
|
- **PostgreSQL** + **Prisma ORM**(自定义输出目录:`src/generated/prisma`)
|
||||||
|
- **better-auth** 身份验证(邮箱/密码 + OAuth)
|
||||||
|
- **next-intl** 国际化(支持:en-US, zh-CN, ja-JP, ko-KR, de-DE, fr-FR, it-IT, ug-CN)
|
||||||
|
- **阿里云千问 TTS** (qwen3-tts-flash) 文本转语音
|
||||||
|
- **pnpm** 包管理器
|
||||||
|
|
||||||
|
## 架构设计
|
||||||
|
|
||||||
|
### 路由结构
|
||||||
|
|
||||||
|
应用使用 Next.js App Router 和基于功能的组织方式:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/app/
|
||||||
|
├── (features)/ # 功能模块(translator, alphabet, memorize, dictionary, srt-player)
|
||||||
|
│ └── [locale]/ # 国际化路由
|
||||||
|
├── auth/ # 认证页面(sign-in, sign-up)
|
||||||
|
├── folders/ # 用户学习文件夹管理
|
||||||
|
├── users/[username]/# 用户资料页面(Server Component)
|
||||||
|
├── profile/ # 重定向到当前用户的资料页面
|
||||||
|
└── api/ # API 路由
|
||||||
|
```
|
||||||
|
|
||||||
|
### 后端架构模式
|
||||||
|
|
||||||
|
项目使用 **Action-Service-Repository 三层架构**:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/modules/{module}/
|
||||||
|
├── {module}-action.ts # Server Actions 层(表单处理、重定向)
|
||||||
|
├── {module}-action-dto.ts # Action 层 DTO(Zod 验证)
|
||||||
|
├── {module}-service.ts # Service 层(业务逻辑)
|
||||||
|
├── {module}-service-dto.ts # Service 层 DTO
|
||||||
|
├── {module}-repository.ts # Repository 层(数据库操作)
|
||||||
|
└── {module}-repository-dto.ts # Repository 层 DTO
|
||||||
|
```
|
||||||
|
|
||||||
|
各层职责:
|
||||||
|
- **Action 层**:处理表单数据、验证输入、调用 service 层、处理重定向和错误响应
|
||||||
|
- **Service 层**:实现业务逻辑、调用 better-auth API、协调多个 repository 操作
|
||||||
|
- **Repository 层**:直接使用 Prisma 进行数据库查询和操作
|
||||||
|
|
||||||
|
现有模块:
|
||||||
|
- `auth` - 认证和用户管理(支持用户名/邮箱登录)
|
||||||
|
- `folder` - 学习文件夹管理
|
||||||
|
- `dictionary` - 词典查询
|
||||||
|
- `translator` - 翻译服务
|
||||||
|
|
||||||
|
### 数据库 Schema
|
||||||
|
|
||||||
|
核心模型(见 [prisma/schema.prisma](prisma/schema.prisma)):
|
||||||
|
- **User**: 用户中心实体,包含认证信息
|
||||||
|
- **Folder**: 用户拥有的学习资料容器(级联删除 pairs)
|
||||||
|
- **Pair**: 语言对(翻译/词汇),支持 IPA,唯一约束为 (folderId, locale1, locale2, text1)
|
||||||
|
- **Session/Account**: better-auth 追踪
|
||||||
|
- **Verification**: 邮箱验证系统
|
||||||
|
|
||||||
|
### 核心模式
|
||||||
|
|
||||||
|
**Server Actions**: 数据库变更使用 `src/lib/actions/` 中的 Server Actions,配合类型安全的 Prisma 操作。
|
||||||
|
|
||||||
|
**基于功能的组件**: 每个功能在 `(features)/` 下有自己的路由组,带有 locale 前缀。
|
||||||
|
|
||||||
|
**国际化**: 所有面向用户的内容通过 next-intl 处理。消息文件在 `messages/` 目录。locale 自动检测并在路由中前缀。
|
||||||
|
|
||||||
|
**认证流程**: better-auth 使用客户端适配器 (`authClient`),通过 hooks 管理会话,受保护的路由使用条件渲染。
|
||||||
|
|
||||||
|
**LLM 集成**: 使用智谱 AI API 进行翻译和 IPA 生成。通过环境变量 `ZHIPU_API_KEY` 和 `ZHIPU_MODEL_NAME` 配置。
|
||||||
|
|
||||||
|
- **Standalone 输出**: 为 Docker 部署配置
|
||||||
|
- **React Compiler**: 在 `next.config.ts` 中启用以自动优化
|
||||||
|
- **HTTPS 开发**: 开发服务器使用 `--experimental-https` 标志
|
||||||
|
- **图片优化**: 通过 remote patterns 允许 GitHub 头像
|
||||||
|
|
||||||
|
## 代码组织
|
||||||
|
|
||||||
|
- `src/modules/`: 业务模块(auth, folder, dictionary, translator)
|
||||||
|
- `src/lib/actions/`: 数据库变更的 Server Actions(旧架构,正在迁移到 modules)
|
||||||
|
- `src/lib/server/`: 服务端工具(AI 集成、认证、翻译器)
|
||||||
|
- `src/lib/browser/`: 客户端工具
|
||||||
|
- `src/hooks/`: 自定义 React hooks(认证 hooks、会话管理)
|
||||||
|
- `src/i18n/`: 国际化配置
|
||||||
|
- `messages/`: 各支持语言的翻译文件
|
||||||
|
- `src/components/`: 可复用的 UI 组件(buttons, cards 等)
|
||||||
|
- `src/shared/`: 共享常量和类型定义
|
||||||
|
|
||||||
|
## 开发注意事项
|
||||||
|
|
||||||
|
- 使用 pnpm,而不是 npm 或 yarn
|
||||||
|
- 应用使用 TypeScript 严格模式 - 确保类型安全
|
||||||
|
- 所有面向用户的文本都需要国际化
|
||||||
|
- **优先使用 Server Components**,只在需要交互时使用 Client Components
|
||||||
|
- **新功能应遵循 action-service-repository 架构**
|
||||||
|
- Better-auth 处理会话管理 - 使用 authClient 适配器进行认证操作
|
||||||
|
- 使用 better-auth username 插件支持用户名登录
|
||||||
77
Dockerfile
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# syntax=docker.io/docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM node:24-alpine AS base
|
||||||
|
|
||||||
|
# Install dependencies only when needed
|
||||||
|
FROM base AS deps
|
||||||
|
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||||
|
RUN apk add --no-cache libc6-compat openssl
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies based on the preferred package manager
|
||||||
|
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
|
||||||
|
# RUN \
|
||||||
|
# if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
||||||
|
# elif [ -f package-lock.json ]; then npm ci; \
|
||||||
|
# elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
|
||||||
|
# else echo "Lockfile not found." && exit 1; \
|
||||||
|
# fi
|
||||||
|
|
||||||
|
RUN \
|
||||||
|
if [ -f 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
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Next.js collects completely anonymous telemetry data about general usage.
|
||||||
|
# Learn more here: https://nextjs.org/telemetry
|
||||||
|
# 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; \
|
||||||
|
else echo "Lockfile not found." && exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Production image, copy all the files and run next
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
# Uncomment the following line in case you want to disable telemetry during runtime.
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
|
# Automatically leverage output traces to reduce image size
|
||||||
|
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
# server.js is created by next build from the standalone output
|
||||||
|
# https://nextjs.org/docs/pages/api-reference/config/next-config-js/output
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
CMD ["node", "server.js"]
|
||||||
141
LICENSE
@@ -1,5 +1,5 @@
|
|||||||
GNU GENERAL PUBLIC LICENSE
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
Version 3, 29 June 2007
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
@@ -7,17 +7,15 @@
|
|||||||
|
|
||||||
Preamble
|
Preamble
|
||||||
|
|
||||||
The GNU General Public License is a free, copyleft license for
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
software and other kinds of works.
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
The licenses for most software and other practical works are designed
|
The licenses for most software and other practical works are designed
|
||||||
to take away your freedom to share and change the works. By contrast,
|
to take away your freedom to share and change the works. By contrast,
|
||||||
the GNU General Public License is intended to guarantee your freedom to
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
share and change all versions of a program--to make sure it remains free
|
share and change all versions of a program--to make sure it remains free
|
||||||
software for all its users. We, the Free Software Foundation, use the
|
software for all its users.
|
||||||
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
|
When we speak of free software, we are referring to freedom, not
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
|
|||||||
want it, that you can change the software or use pieces of it in new
|
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.
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
To protect your rights, we need to prevent others from denying you
|
Developers that use our General Public Licenses protect your rights
|
||||||
these rights or asking you to surrender the rights. Therefore, you have
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
certain responsibilities if you distribute copies of the software, or if
|
you this License which gives you legal permission to copy, distribute
|
||||||
you modify it: responsibilities to respect the freedom of others.
|
and/or modify the software.
|
||||||
|
|
||||||
For example, if you distribute copies of such a program, whether
|
A secondary benefit of defending all users' freedom is that
|
||||||
gratis or for a fee, you must pass on to the recipients the same
|
improvements made in alternate versions of the program, if they
|
||||||
freedoms that you received. You must make sure that they, too, receive
|
receive widespread use, become available for other developers to
|
||||||
or can get the source code. And you must show them these terms so they
|
incorporate. Many developers of free software are heartened and
|
||||||
know their rights.
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
Developers that use the GNU GPL protect your rights with two steps:
|
The GNU Affero General Public License is designed specifically to
|
||||||
(1) assert copyright on the software, and (2) offer you this License
|
ensure that, in such cases, the modified source code becomes available
|
||||||
giving you legal permission to copy, distribute and/or modify it.
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
For the developers' and authors' protection, the GPL clearly explains
|
An older license, called the Affero General Public License and
|
||||||
that there is no warranty for this free software. For both users' and
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
authors' sake, the GPL requires that modified versions be marked as
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
changed, so that their problems will not be attributed erroneously to
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
authors of previous versions.
|
this license.
|
||||||
|
|
||||||
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
|
The precise terms and conditions for copying, distribution and
|
||||||
modification follow.
|
modification follow.
|
||||||
@@ -72,7 +60,7 @@ modification follow.
|
|||||||
|
|
||||||
0. Definitions.
|
0. Definitions.
|
||||||
|
|
||||||
"This License" refers to version 3 of the GNU General Public License.
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
works, such as semiconductor masks.
|
works, such as semiconductor masks.
|
||||||
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
|
|||||||
the Program, the only way you could satisfy both those terms and this
|
the Program, the only way you could satisfy both those terms and this
|
||||||
License would be to refrain entirely from conveying the Program.
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
13. Use with the GNU Affero General Public License.
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, you have
|
Notwithstanding any other provision of this License, you have
|
||||||
permission to link or combine any covered work with a work licensed
|
permission to link or combine any covered work with a work licensed
|
||||||
under version 3 of the GNU Affero General Public License into a single
|
under version 3 of the GNU General Public License into a single
|
||||||
combined work, and to convey the resulting work. The terms of this
|
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,
|
License will continue to apply to the part which is the covered work,
|
||||||
but the special requirements of the GNU Affero General Public License,
|
but the work with which it is combined will remain governed by version
|
||||||
section 13, concerning interaction through a network will apply to the
|
3 of the GNU General Public License.
|
||||||
combination as such.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
the GNU General Public License from time to time. Such new versions will
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
be similar in spirit to the present version, but may differ in detail to
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
address new problems or concerns.
|
address new problems or concerns.
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
Each version is given a distinguishing version number. If the
|
||||||
Program specifies that a certain numbered version of the GNU General
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
Public License "or any later version" applies to it, you have the
|
Public License "or any later version" applies to it, you have the
|
||||||
option of following the terms and conditions either of that numbered
|
option of following the terms and conditions either of that numbered
|
||||||
version or of any later version published by the Free Software
|
version or of any later version published by the Free Software
|
||||||
Foundation. If the Program does not specify a version number of the
|
Foundation. If the Program does not specify a version number of the
|
||||||
GNU General Public License, you may choose any version ever published
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
by the Free Software Foundation.
|
by the Free Software Foundation.
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future
|
If the Program specifies that a proxy can decide which future
|
||||||
versions of the GNU General Public License can be used, that proxy's
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
public statement of acceptance of a version permanently authorizes you
|
public statement of acceptance of a version permanently authorizes you
|
||||||
to choose that version for the Program.
|
to choose that version for the Program.
|
||||||
|
|
||||||
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
|
|||||||
Copyright (C) <year> <name of author>
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
This program is free software: you can redistribute it and/or modify
|
||||||
it under the terms of the GNU General Public License as published by
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
(at your option) any later version.
|
(at your option) any later version.
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
This program is distributed in the hope that it will be useful,
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
GNU General Public License for more details.
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
You should have received a copy of the GNU Affero General Public License
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
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.
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
If the program does terminal interaction, make it output a short
|
If your software can interact with users remotely through a computer
|
||||||
notice like this when it starts in an interactive mode:
|
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
|
||||||
<program> Copyright (C) <year> <name of author>
|
interface could display a "Source" link that leads users to an archive
|
||||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
of the code. There are many ways you could offer source, and different
|
||||||
This is free software, and you are welcome to redistribute it
|
solutions will be better for different programs; see section 13 for the
|
||||||
under certain conditions; type `show c' for details.
|
specific requirements.
|
||||||
|
|
||||||
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,
|
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.
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
For more information on this, and how to apply and follow the GNU GPL, see
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
<https://www.gnu.org/licenses/>.
|
<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>.
|
|
||||||
|
|||||||
197
README.md
@@ -1,36 +1,189 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
# 多语言学习平台
|
||||||
|
|
||||||
## Getting Started
|
一个基于 Next.js 构建的全功能多语言学习平台,提供翻译、发音、字幕播放、字母学习等多种语言学习工具,帮助用户更高效地掌握新语言。
|
||||||
|
|
||||||
First, run the development server:
|
## ✨ 主要功能
|
||||||
|
|
||||||
```bash
|
- **智能翻译工具** - 支持多语言互译,包含国际音标(IPA)标注
|
||||||
npm run dev
|
- **文本语音合成** - 将文本转换为自然语音,提高发音学习效果
|
||||||
# or
|
- **SRT字幕播放器** - 结合视频字幕学习,支持多种字幕格式
|
||||||
yarn dev
|
- **字母学习模块** - 针对初学者的字母和发音基础学习
|
||||||
# or
|
- **记忆强化工具** - 通过科学记忆法巩固学习内容
|
||||||
pnpm dev
|
- **词典查询** - 查询单词和短语,提供详细释义和例句
|
||||||
# or
|
- **个人学习空间** - 用户可以创建、管理和组织自己的学习资料
|
||||||
bun dev
|
- **用户资料系统** - 支持用户名登录、个人资料页面展示
|
||||||
|
|
||||||
|
## 🛠 技术栈
|
||||||
|
|
||||||
|
### 前端框架
|
||||||
|
- **Next.js 16** - React 全栈框架,使用 App Router
|
||||||
|
- **React 19** - 用户界面构建
|
||||||
|
- **TypeScript** - 类型安全的 JavaScript
|
||||||
|
- **Tailwind CSS** - 实用优先的 CSS 框架
|
||||||
|
|
||||||
|
### 数据与后端
|
||||||
|
- **PostgreSQL** - 主数据库
|
||||||
|
- **Prisma** - 现代数据库工具包和 ORM
|
||||||
|
- **better-auth** - 安全的身份验证系统
|
||||||
|
|
||||||
|
### 国际化与辅助功能
|
||||||
|
- **next-intl** - 国际化解决方案
|
||||||
|
- **阿里云千问 TTS** - qwen3-tts-flash 语音合成
|
||||||
|
|
||||||
|
### 开发工具
|
||||||
|
- **ESLint** - 代码质量检查
|
||||||
|
- **pnpm** - 高效的包管理器
|
||||||
|
|
||||||
|
## 📁 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/ # Next.js App Router 路由
|
||||||
|
│ ├── (features)/ # 功能模块路由
|
||||||
|
│ ├── auth/ # 认证相关页面
|
||||||
|
│ ├── profile/ # 用户资料重定向
|
||||||
|
│ ├── users/[username]/ # 用户资料页面
|
||||||
|
│ ├── folders/ # 文件夹管理
|
||||||
|
│ └── api/ # API 路由
|
||||||
|
├── modules/ # 业务模块(action-service-repository 架构)
|
||||||
|
│ ├── auth/ # 认证模块
|
||||||
|
│ ├── folder/ # 文件夹模块
|
||||||
|
│ ├── dictionary/ # 词典模块
|
||||||
|
│ └── translator/ # 翻译模块
|
||||||
|
├── components/ # React 组件
|
||||||
|
│ ├── buttons/ # 按钮组件
|
||||||
|
│ ├── cards/ # 卡片组件
|
||||||
|
│ └── ...
|
||||||
|
├── lib/ # 工具函数和库
|
||||||
|
│ ├── actions/ # Server Actions
|
||||||
|
│ ├── browser/ # 浏览器端工具
|
||||||
|
│ └── server/ # 服务器端工具
|
||||||
|
├── hooks/ # 自定义 React Hooks
|
||||||
|
├── i18n/ # 国际化配置
|
||||||
|
├── shared/ # 共享常量和类型
|
||||||
|
└── config/ # 应用配置
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
## 🚀 快速开始
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
### 环境要求
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
- Node.js 23
|
||||||
|
- PostgreSQL 数据库
|
||||||
|
- pnpm (推荐) 或 npm
|
||||||
|
|
||||||
## Learn More
|
### 本地开发
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
1. 克隆项目
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd learn-languages
|
||||||
|
```
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
2. 安装依赖
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
3. 设置环境变量
|
||||||
|
|
||||||
## Deploy on Vercel
|
从项目提供的示例文件复制环境变量模板:
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
```bash
|
||||||
|
cp .env.example .env.local
|
||||||
|
```
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
然后编辑 `.env.local` 文件,配置所有必要的环境变量:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# LLM 集成(智谱 AI 用于翻译和 IPA 生成)
|
||||||
|
ZHIPU_API_KEY=your-zhipu-api-key
|
||||||
|
ZHIPU_MODEL_NAME=your-zhipu-model-name
|
||||||
|
|
||||||
|
# 阿里云千问 TTS(文本转语音)
|
||||||
|
DASHSCORE_API_KEY=your-dashscore-api-key
|
||||||
|
|
||||||
|
# 认证
|
||||||
|
BETTER_AUTH_SECRET=your-better-auth-secret
|
||||||
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
GITHUB_CLIENT_ID=your-github-client-id
|
||||||
|
GITHUB_CLIENT_SECRET=your-github-client-secret
|
||||||
|
|
||||||
|
# 数据库
|
||||||
|
DATABASE_URL=postgresql://username:password@localhost:5432/database_name
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:所有带 `your-` 前缀的值需要替换为你的实际配置。
|
||||||
|
|
||||||
|
4. 初始化数据库
|
||||||
|
```bash
|
||||||
|
pnpm prisma generate
|
||||||
|
pnpm prisma db push
|
||||||
|
```
|
||||||
|
|
||||||
|
5. 启动开发服务器
|
||||||
|
```bash
|
||||||
|
pnpm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 [http://localhost:3000](http://localhost:3000) 查看应用。
|
||||||
|
|
||||||
|
## 📚 API 文档
|
||||||
|
|
||||||
|
### 认证系统
|
||||||
|
|
||||||
|
应用使用 better-auth 提供安全的用户认证系统,支持:
|
||||||
|
- 邮箱/密码登录和注册
|
||||||
|
- **用户名登录**(可通过用户名或邮箱登录)
|
||||||
|
- GitHub OAuth 第三方登录
|
||||||
|
- 邮箱验证功能
|
||||||
|
|
||||||
|
### 后端架构
|
||||||
|
|
||||||
|
项目采用 **Action-Service-Repository 三层架构**:
|
||||||
|
- **Action 层**:处理 Server Actions、表单验证、重定向
|
||||||
|
- **Service 层**:业务逻辑、better-auth 集成
|
||||||
|
- **Repository 层**:Prisma 数据库操作
|
||||||
|
|
||||||
|
### 数据模型
|
||||||
|
|
||||||
|
核心数据模型包括:
|
||||||
|
- **User** - 用户信息(支持用户名、邮箱、头像)
|
||||||
|
- **Folder** - 学习资料文件夹
|
||||||
|
- **Pair** - 语言对(翻译对、词汇对等)
|
||||||
|
- **Session/Account** - 认证会话追踪
|
||||||
|
- **Verification** - 邮箱验证系统
|
||||||
|
|
||||||
|
详细模型定义请参考 [prisma/schema.prisma](./prisma/schema.prisma)
|
||||||
|
|
||||||
|
## 🌍 国际化
|
||||||
|
|
||||||
|
应用支持多语言,当前语言文件位于 `messages/` 目录。添加新语言:
|
||||||
|
|
||||||
|
1. 在 `messages/` 目录创建对应语言的 JSON 文件
|
||||||
|
2. 在 `src/i18n/config.ts` 中添加语言配置
|
||||||
|
|
||||||
|
## 🤝 贡献指南
|
||||||
|
|
||||||
|
我们欢迎各种形式的贡献!请遵循以下步骤:
|
||||||
|
|
||||||
|
1. Fork 项目
|
||||||
|
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
|
||||||
|
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||||
|
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||||
|
5. 打开 Pull Request
|
||||||
|
|
||||||
|
## 📄 许可证
|
||||||
|
|
||||||
|
本项目采用 AGPL-3.0 许可证 - 查看 [LICENSE](./LICENSE) 文件了解详情。
|
||||||
|
|
||||||
|
## 📞 支持
|
||||||
|
|
||||||
|
如果您遇到问题或有建议,请通过以下方式联系:
|
||||||
|
|
||||||
|
- 提交 [Issue](../../issues)
|
||||||
|
- 发送邮件至 [goddonebianu@outlook.com]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Happy Learning!** 🌟
|
||||||
|
|||||||
@@ -1,25 +1,16 @@
|
|||||||
import { dirname } from "path";
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
import { fileURLToPath } from "url";
|
import nextVitals from 'eslint-config-next/core-web-vitals'
|
||||||
import { FlatCompat } from "@eslint/eslintrc";
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const eslintConfig = defineConfig([
|
||||||
const __dirname = dirname(__filename);
|
...nextVitals,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
'.next/**',
|
||||||
|
'out/**',
|
||||||
|
'build/**',
|
||||||
|
'next-env.d.ts',
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
|
||||||
const compat = new FlatCompat({
|
export default eslintConfig
|
||||||
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;
|
|
||||||
258
messages/de-DE.json
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
{
|
||||||
|
"alphabet": {
|
||||||
|
"chooseCharacters": "Bitte wählen Sie die Zeichen aus, die Sie lernen möchten",
|
||||||
|
"japanese": "Japanische Kana",
|
||||||
|
"english": "Englisches Alphabet",
|
||||||
|
"uyghur": "Uigurisches Alphabet",
|
||||||
|
"esperanto": "Esperanto-Alphabet",
|
||||||
|
"loading": "Laden...",
|
||||||
|
"loadFailed": "Laden fehlgeschlagen, bitte versuchen Sie es erneut",
|
||||||
|
"hideLetter": "Zeichen ausblenden",
|
||||||
|
"showLetter": "Zeichen anzeigen",
|
||||||
|
"hideIPA": "IPA ausblenden",
|
||||||
|
"showIPA": "IPA anzeigen",
|
||||||
|
"roman": "Romanisierung",
|
||||||
|
"letter": "Zeichen",
|
||||||
|
"random": "Zufälliger Modus",
|
||||||
|
"randomNext": "Zufällig weiter"
|
||||||
|
},
|
||||||
|
"folders": {
|
||||||
|
"title": "Ordner",
|
||||||
|
"subtitle": "Verwalten Sie Ihre Sammlungen",
|
||||||
|
"newFolder": "Neuer Ordner",
|
||||||
|
"creating": "Erstellen...",
|
||||||
|
"noFoldersYet": "Noch keine Ordner",
|
||||||
|
"folderInfo": "ID: {id} • {totalPairs} Paare",
|
||||||
|
"enterFolderName": "Ordnernamen eingeben:",
|
||||||
|
"confirmDelete": "Geben Sie \"{name}\" ein, um zu löschen:"
|
||||||
|
},
|
||||||
|
"folder_id": {
|
||||||
|
"unauthorized": "Sie sind nicht der Eigentümer dieses Ordners",
|
||||||
|
"back": "Zurück",
|
||||||
|
"textPairs": "Textpaare",
|
||||||
|
"itemsCount": "{count} Elemente",
|
||||||
|
"memorize": "Einprägen",
|
||||||
|
"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 geben Sie den Sprachennamen ein",
|
||||||
|
"edit": "Bearbeiten",
|
||||||
|
"delete": "Löschen",
|
||||||
|
"permissionDenied": "Sie haben keine Berechtigung, diese Aktion auszuführen",
|
||||||
|
"error": {
|
||||||
|
"update": "Sie haben keine Berechtigung, dieses Element zu aktualisieren.",
|
||||||
|
"delete": "Sie haben keine Berechtigung, dieses Element zu löschen.",
|
||||||
|
"add": "Sie haben keine Berechtigung, Elemente 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": "Erkunden",
|
||||||
|
"fortune": {
|
||||||
|
"quote": "Bleib hungrig, bleiv dumm.",
|
||||||
|
"author": "— Steve Jobs"
|
||||||
|
},
|
||||||
|
"translator": {
|
||||||
|
"name": "Übersetzer",
|
||||||
|
"description": "In jede Sprache übersetzen und mit Internationalem Phonetischem Alphabet (IPA) annotieren"
|
||||||
|
},
|
||||||
|
"textSpeaker": {
|
||||||
|
"name": "Text-Sprecher",
|
||||||
|
"description": "Text erkennen und vorlesen, unterstützt Schleifenwiedergabe und Geschwindigkeitsanpassung"
|
||||||
|
},
|
||||||
|
"srtPlayer": {
|
||||||
|
"name": "SRT-Videoplayer",
|
||||||
|
"description": "Videos basierend auf SRT-Untertiteldateien satzweise abspielen, um die Aussprache von Muttersprachlern zu imitieren"
|
||||||
|
},
|
||||||
|
"alphabet": {
|
||||||
|
"name": "Alphabet",
|
||||||
|
"description": "Beginnen Sie mit dem Erlernen einer neuen Sprache mit dem Alphabet"
|
||||||
|
},
|
||||||
|
"memorize": {
|
||||||
|
"name": "Einprägen",
|
||||||
|
"description": "Sprache A zu Sprache B, Sprache B zu Sprache A, unterstützt Diktat"
|
||||||
|
},
|
||||||
|
"dictionary": {
|
||||||
|
"name": "Wörterbuch",
|
||||||
|
"description": "Wörter und Redewendungen nachschlagen mit detaillierten Definitionen und Beispielen"
|
||||||
|
},
|
||||||
|
"moreFeatures": {
|
||||||
|
"name": "Weitere Funktionen",
|
||||||
|
"description": "In Entwicklung, bleiben Sie dran"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"title": "Authentifizierung",
|
||||||
|
"signIn": "Anmelden",
|
||||||
|
"signUp": "Registrieren",
|
||||||
|
"email": "E-Mail",
|
||||||
|
"password": "Passwort",
|
||||||
|
"confirmPassword": "Passwort bestätigen",
|
||||||
|
"name": "Name",
|
||||||
|
"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": "Passwörter stimmen nicht überein",
|
||||||
|
"nameRequired": "Bitte geben Sie Ihren Namen ein",
|
||||||
|
"emailRequired": "Bitte geben Sie Ihre E-Mail ein",
|
||||||
|
"passwordRequired": "Bitte geben Sie Ihr Passwort ein",
|
||||||
|
"confirmPasswordRequired": "Bitte bestätigen Sie Ihr Passwort",
|
||||||
|
"loading": "Laden..."
|
||||||
|
},
|
||||||
|
"memorize": {
|
||||||
|
"folder_selector": {
|
||||||
|
"selectFolder": "Wählen Sie einen Ordner aus",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"autoPauseStatus": "Auto-Pause: {enabled}",
|
||||||
|
"on": "Ein",
|
||||||
|
"off": "Aus",
|
||||||
|
"videoUploadFailed": "Video-Upload fehlgeschlagen",
|
||||||
|
"subtitleUploadFailed": "Untertitel-Upload fehlgeschlagen"
|
||||||
|
},
|
||||||
|
"text_speaker": {
|
||||||
|
"generateIPA": "IPA generieren",
|
||||||
|
"viewSavedItems": "Gespeicherte Elemente 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": "Übersetzung läuft...",
|
||||||
|
"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 aus",
|
||||||
|
"noFolders": "Keine Ordner gefunden",
|
||||||
|
"folderInfo": "{id}. {name}",
|
||||||
|
"close": "Schließen",
|
||||||
|
"success": "Textpaar zum Ordner hinzugefügt",
|
||||||
|
"error": "Textpaar konnte nicht zum Ordner hinzugefügt werden"
|
||||||
|
},
|
||||||
|
"autoSave": "Automatisch speichern"
|
||||||
|
},
|
||||||
|
"dictionary": {
|
||||||
|
"title": "Wörterbuch",
|
||||||
|
"description": "Wörter und Ausdrücke mit detaillierten Definitionen und Beispielen nachschlagen",
|
||||||
|
"searchPlaceholder": "Wort oder Ausdruck zum Nachschlagen eingeben...",
|
||||||
|
"searching": "Suche...",
|
||||||
|
"search": "Suchen",
|
||||||
|
"languageSettings": "Spracheinstellungen",
|
||||||
|
"queryLanguage": "Abfragesprache",
|
||||||
|
"queryLanguageHint": "Welche Sprache hat das Wort/die Phrase, die Sie nachschlagen möchten",
|
||||||
|
"definitionLanguage": "Definitionssprache",
|
||||||
|
"definitionLanguageHint": "In welcher Sprache möchten Sie die Definitionen sehen",
|
||||||
|
"otherLanguagePlaceholder": "Oder eine andere Sprache eingeben...",
|
||||||
|
"currentSettings": "Aktuelle Einstellungen: Abfrage {queryLang}, Definition {definitionLang}",
|
||||||
|
"relookup": "Neu suchen",
|
||||||
|
"saveToFolder": "In Ordner speichern",
|
||||||
|
"loading": "Laden...",
|
||||||
|
"noResults": "Keine Ergebnisse gefunden",
|
||||||
|
"tryOtherWords": "Versuchen Sie andere Wörter oder Ausdrücke",
|
||||||
|
"welcomeTitle": "Willkommen beim Wörterbuch",
|
||||||
|
"welcomeHint": "Geben Sie oben im Suchfeld ein Wort oder einen Ausdruck ein, um zu suchen",
|
||||||
|
"lookupFailed": "Suche fehlgeschlagen, bitte später erneut versuchen",
|
||||||
|
"relookupSuccess": "Erfolgreich neu gesucht",
|
||||||
|
"relookupFailed": "Wörterbuch Neu-Suche fehlgeschlagen",
|
||||||
|
"pleaseLogin": "Bitte melden Sie sich zuerst an",
|
||||||
|
"pleaseCreateFolder": "Bitte erstellen Sie zuerst einen Ordner",
|
||||||
|
"savedToFolder": "Im Ordner gespeichert: {folderName}",
|
||||||
|
"saveFailed": "Speichern fehlgeschlagen, bitte später erneut versuchen"
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"folders": {
|
||||||
|
"title": "Ordner",
|
||||||
|
"noFolders": "Noch keine Ordner",
|
||||||
|
"folderName": "Ordnername",
|
||||||
|
"totalPairs": "Anzahl der Paare",
|
||||||
|
"createdAt": "Erstellt am",
|
||||||
|
"actions": "Aktionen",
|
||||||
|
"view": "Ansehen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
264
messages/en-US.json
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
{
|
||||||
|
"alphabet": {
|
||||||
|
"chooseCharacters": "Please select the characters you want to learn",
|
||||||
|
"japanese": "Japanese Kana",
|
||||||
|
"english": "English Alphabet",
|
||||||
|
"uyghur": "Uyghur Alphabet",
|
||||||
|
"esperanto": "Esperanto Alphabet",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"loadFailed": "Loading failed, please try again",
|
||||||
|
"hideLetter": "Hide Letter",
|
||||||
|
"showLetter": "Show Letter",
|
||||||
|
"hideIPA": "Hide IPA",
|
||||||
|
"showIPA": "Show IPA",
|
||||||
|
"roman": "Romanization",
|
||||||
|
"letter": "Letter",
|
||||||
|
"random": "Random Mode",
|
||||||
|
"randomNext": "Random Next"
|
||||||
|
},
|
||||||
|
"folders": {
|
||||||
|
"title": "Folders",
|
||||||
|
"subtitle": "Manage your collections",
|
||||||
|
"newFolder": "New Folder",
|
||||||
|
"creating": "Creating...",
|
||||||
|
"noFoldersYet": "No folders yet",
|
||||||
|
"folderInfo": "ID: {id} • {totalPairs} pairs",
|
||||||
|
"enterFolderName": "Enter folder name:",
|
||||||
|
"confirmDelete": "Type \"{name}\" to delete:"
|
||||||
|
},
|
||||||
|
"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": "Authentication",
|
||||||
|
"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..."
|
||||||
|
},
|
||||||
|
"memorize": {
|
||||||
|
"folder_selector": {
|
||||||
|
"selectFolder": "Select a folder",
|
||||||
|
"noFolders": "No folders found",
|
||||||
|
"folderInfo": "{id}. {name} ({count})"
|
||||||
|
},
|
||||||
|
"memorize": {
|
||||||
|
"answer": "Answer",
|
||||||
|
"next": "Next",
|
||||||
|
"reverse": "Reverse",
|
||||||
|
"dictation": "Dictation",
|
||||||
|
"noTextPairs": "No text pairs available",
|
||||||
|
"disorder": "Disorder",
|
||||||
|
"previous": "Previous"
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"unauthorized": "You are not authorized to access this folder"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"navbar": {
|
||||||
|
"title": "learn-languages",
|
||||||
|
"sourceCode": "GitHub",
|
||||||
|
"sign_in": "Sign In",
|
||||||
|
"profile": "Profile",
|
||||||
|
"folders": "Folders"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"myProfile": "My Profile",
|
||||||
|
"email": "Email: {email}",
|
||||||
|
"logout": "Logout"
|
||||||
|
},
|
||||||
|
"srt_player": {
|
||||||
|
"uploadVideo": "Upload Video",
|
||||||
|
"uploadSubtitle": "Upload Subtitle",
|
||||||
|
"pause": "Pause",
|
||||||
|
"play": "Play",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"restart": "Restart",
|
||||||
|
"autoPause": "Auto Pause ({enabled})",
|
||||||
|
"uploadVideoAndSubtitle": "Please upload video and subtitle files",
|
||||||
|
"uploadVideoFile": "Please upload video file",
|
||||||
|
"uploadSubtitleFile": "Please upload subtitle file",
|
||||||
|
"processingSubtitle": "Processing subtitle file...",
|
||||||
|
"needBothFiles": "Both video and subtitle files are required to start learning",
|
||||||
|
"videoFile": "Video File",
|
||||||
|
"subtitleFile": "Subtitle File",
|
||||||
|
"uploaded": "Uploaded",
|
||||||
|
"notUploaded": "Not Uploaded",
|
||||||
|
"upload": "Upload",
|
||||||
|
"autoPauseStatus": "Auto Pause: {enabled}",
|
||||||
|
"on": "On",
|
||||||
|
"off": "Off",
|
||||||
|
"videoUploadFailed": "Video upload failed",
|
||||||
|
"subtitleUploadFailed": "Subtitle upload failed"
|
||||||
|
},
|
||||||
|
"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...",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"folders": {
|
||||||
|
"title": "Folders",
|
||||||
|
"noFolders": "No folders yet",
|
||||||
|
"folderName": "Folder Name",
|
||||||
|
"totalPairs": "Total Pairs",
|
||||||
|
"createdAt": "Created At",
|
||||||
|
"actions": "Actions",
|
||||||
|
"view": "View"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
258
messages/fr-FR.json
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
{
|
||||||
|
"alphabet": {
|
||||||
|
"chooseCharacters": "Veuillez sélectionner les caractères que vous souhaitez 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"
|
||||||
|
},
|
||||||
|
"folders": {
|
||||||
|
"title": "Dossiers",
|
||||||
|
"subtitle": "Gérez vos collections",
|
||||||
|
"newFolder": "Nouveau dossier",
|
||||||
|
"creating": "Création...",
|
||||||
|
"noFoldersYet": "Aucun dossier pour le moment",
|
||||||
|
"folderInfo": "ID: {id} • {totalPairs} paires",
|
||||||
|
"enterFolderName": "Entrez le nom du dossier:",
|
||||||
|
"confirmDelete": "Tapez \"{name}\" pour supprimer:"
|
||||||
|
},
|
||||||
|
"folder_id": {
|
||||||
|
"unauthorized": "Vous n'êtes pas le propriétaire de ce dossier",
|
||||||
|
"back": "Retour",
|
||||||
|
"textPairs": "Paires de textes",
|
||||||
|
"itemsCount": "{count} éléments",
|
||||||
|
"memorize": "Mémoriser",
|
||||||
|
"loadingTextPairs": "Chargement des paires de textes...",
|
||||||
|
"noTextPairs": "Aucune paire de textes dans ce dossier",
|
||||||
|
"addNewTextPair": "Ajouter une nouvelle paire de textes",
|
||||||
|
"add": "Ajouter",
|
||||||
|
"updateTextPair": "Mettre à jour la paire de textes",
|
||||||
|
"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": "Stay hungry, stay foolish.",
|
||||||
|
"author": "— Steve Jobs"
|
||||||
|
},
|
||||||
|
"translator": {
|
||||||
|
"name": "Traducteur",
|
||||||
|
"description": "Traduire dans 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": "Commencer à apprendre une nouvelle langue par 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 phrases 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": "Authentification",
|
||||||
|
"signIn": "Se connecter",
|
||||||
|
"signUp": "S'inscrire",
|
||||||
|
"email": "E-mail",
|
||||||
|
"password": "Mot de passe",
|
||||||
|
"confirmPassword": "Confirmer le mot de passe",
|
||||||
|
"name": "Nom",
|
||||||
|
"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",
|
||||||
|
"emailRequired": "Veuillez entrer votre e-mail",
|
||||||
|
"passwordRequired": "Veuillez entrer votre mot de passe",
|
||||||
|
"confirmPasswordRequired": "Veuillez confirmer votre mot de passe",
|
||||||
|
"loading": "Chargement..."
|
||||||
|
},
|
||||||
|
"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 textes disponible",
|
||||||
|
"disorder": "Désordre",
|
||||||
|
"previous": "Précédent"
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"unauthorized": "Vous n'êtes pas autorisé à accéder à ce dossier"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"navbar": {
|
||||||
|
"title": "learn-languages",
|
||||||
|
"sourceCode": "GitHub",
|
||||||
|
"sign_in": "Se connecter",
|
||||||
|
"profile": "Profil",
|
||||||
|
"folders": "Dossiers"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"myProfile": "Mon profil",
|
||||||
|
"email": "E-mail: {email}",
|
||||||
|
"logout": "Se déconnecter"
|
||||||
|
},
|
||||||
|
"srt_player": {
|
||||||
|
"uploadVideo": "Télécharger une vidéo",
|
||||||
|
"uploadSubtitle": "Télécharger des sous-titres",
|
||||||
|
"pause": "Pause",
|
||||||
|
"play": "Lire",
|
||||||
|
"previous": "Précédent",
|
||||||
|
"next": "Suivant",
|
||||||
|
"restart": "Redémarrer",
|
||||||
|
"autoPause": "Pause automatique ({enabled})",
|
||||||
|
"uploadVideoAndSubtitle": "Veuillez télécharger des fichiers vidéo et de sous-titres",
|
||||||
|
"uploadVideoFile": "Veuillez télécharger un fichier vidéo",
|
||||||
|
"uploadSubtitleFile": "Veuillez télécharger un fichier de sous-titres",
|
||||||
|
"processingSubtitle": "Traitement du fichier de sous-titres...",
|
||||||
|
"needBothFiles": "Les fichiers vidéo et de sous-titres sont 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",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"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": "Entrer la langue",
|
||||||
|
"add_to_folder": {
|
||||||
|
"notAuthenticated": "Vous n'êtes pas authentifié",
|
||||||
|
"chooseFolder": "Choisir un dossier à ajouter",
|
||||||
|
"noFolders": "Aucun dossier trouvé",
|
||||||
|
"folderInfo": "{id}. {name}",
|
||||||
|
"close": "Fermer",
|
||||||
|
"success": "Paire de textes ajoutée au dossier",
|
||||||
|
"error": "Échec de l'ajout de la paire de textes au dossier"
|
||||||
|
},
|
||||||
|
"autoSave": "Sauvegarde automatique"
|
||||||
|
},
|
||||||
|
"dictionary": {
|
||||||
|
"title": "Dictionnaire",
|
||||||
|
"description": "Rechercher des mots et des phrases avec des définitions détaillées et des exemples",
|
||||||
|
"searchPlaceholder": "Entrez un mot ou une phrase à rechercher...",
|
||||||
|
"searching": "Recherche...",
|
||||||
|
"search": "Rechercher",
|
||||||
|
"languageSettings": "Paramètres linguistiques",
|
||||||
|
"queryLanguage": "Langue de requête",
|
||||||
|
"queryLanguageHint": "Quelle est la langue du mot/phrase que vous souhaitez rechercher",
|
||||||
|
"definitionLanguage": "Langue de définition",
|
||||||
|
"definitionLanguageHint": "Dans quelle langue souhaitez-vous voir les définitions",
|
||||||
|
"otherLanguagePlaceholder": "Ou entrez une autre langue...",
|
||||||
|
"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 phrases",
|
||||||
|
"welcomeTitle": "Bienvenue dans le dictionnaire",
|
||||||
|
"welcomeHint": "Entrez un mot ou une phrase dans la zone de recherche ci-dessus pour commencer",
|
||||||
|
"lookupFailed": "Recherche échouée, veuillez réessayer plus tard",
|
||||||
|
"relookupSuccess": "Recherche répétée avec succès",
|
||||||
|
"relookupFailed": "Nouvelle recherche de dictionnaire échouée",
|
||||||
|
"pleaseLogin": "Veuillez d'abord vous connecter",
|
||||||
|
"pleaseCreateFolder": "Veuillez d'abord créer un dossier",
|
||||||
|
"savedToFolder": "Enregistré dans le dossier : {folderName}",
|
||||||
|
"saveFailed": "Échec de l'enregistrement, veuillez réessayer plus tard"
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"folders": {
|
||||||
|
"title": "Dossiers",
|
||||||
|
"noFolders": "Aucun dossier pour le moment",
|
||||||
|
"folderName": "Nom du dossier",
|
||||||
|
"totalPairs": "Nombre de paires",
|
||||||
|
"createdAt": "Créé le",
|
||||||
|
"actions": "Actions",
|
||||||
|
"view": "Voir"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
258
messages/it-IT.json
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
{
|
||||||
|
"alphabet": {
|
||||||
|
"chooseCharacters": "Seleziona i caratteri che desideri 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": "Successivo casuale"
|
||||||
|
},
|
||||||
|
"folders": {
|
||||||
|
"title": "Cartelle",
|
||||||
|
"subtitle": "Gestisci le tue collezioni",
|
||||||
|
"newFolder": "Nuova cartella",
|
||||||
|
"creating": "Creazione...",
|
||||||
|
"noFoldersYet": "Nessuna cartella ancora",
|
||||||
|
"folderInfo": "ID: {id} • {totalPairs} coppie",
|
||||||
|
"enterFolderName": "Inserisci nome cartella:",
|
||||||
|
"confirmDelete": "Digita \"{name}\" per eliminare:"
|
||||||
|
},
|
||||||
|
"folder_id": {
|
||||||
|
"unauthorized": "Non sei il proprietario di questa cartella",
|
||||||
|
"back": "Indietro",
|
||||||
|
"textPairs": "Coppie di testi",
|
||||||
|
"itemsCount": "{count} elementi",
|
||||||
|
"memorize": "Memorizza",
|
||||||
|
"loadingTextPairs": "Caricamento coppie di testi...",
|
||||||
|
"noTextPairs": "Nessuna coppia di testi in questa cartella",
|
||||||
|
"addNewTextPair": "Aggiungi nuova coppia di testi",
|
||||||
|
"add": "Aggiungi",
|
||||||
|
"updateTextPair": "Aggiorna coppia di testi",
|
||||||
|
"update": "Aggiorna",
|
||||||
|
"text1": "Testo 1",
|
||||||
|
"text2": "Testo 2",
|
||||||
|
"language1": "Lingua 1",
|
||||||
|
"language2": "Lingua 2",
|
||||||
|
"enterLanguageName": "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": "Questo è un sito web molto utile che ti aiuta 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 di testo",
|
||||||
|
"description": "Riconosce e legge il testo ad alta voce, supporta la riproduzione in loop e la regolazione della velocità"
|
||||||
|
},
|
||||||
|
"srtPlayer": {
|
||||||
|
"name": "Lettore video SRT",
|
||||||
|
"description": "Riproduci video frase per frase basandoti su 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 verso lingua B, lingua B verso lingua A, supporta dettatura"
|
||||||
|
},
|
||||||
|
"dictionary": {
|
||||||
|
"name": "Dizionario",
|
||||||
|
"description": "Cerca parole e frasi con definizioni dettagliate ed esempi"
|
||||||
|
},
|
||||||
|
"moreFeatures": {
|
||||||
|
"name": "Altre funzionalità",
|
||||||
|
"description": "In sviluppo, rimani sintonizzato"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"title": "Autenticazione",
|
||||||
|
"signIn": "Accedi",
|
||||||
|
"signUp": "Registrati",
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Password",
|
||||||
|
"confirmPassword": "Conferma password",
|
||||||
|
"name": "Nome",
|
||||||
|
"signInButton": "Accedi",
|
||||||
|
"signUpButton": "Registrati",
|
||||||
|
"noAccount": "Non hai un account?",
|
||||||
|
"hasAccount": "Hai già un account?",
|
||||||
|
"signInWithGitHub": "Accedi con GitHub",
|
||||||
|
"signUpWithGitHub": "Registrati con GitHub",
|
||||||
|
"invalidEmail": "Inserisci un indirizzo email valido",
|
||||||
|
"passwordTooShort": "La password deve essere di almeno 8 caratteri",
|
||||||
|
"passwordsNotMatch": "Le password non corrispondono",
|
||||||
|
"nameRequired": "Inserisci il tuo nome",
|
||||||
|
"emailRequired": "Inserisci la tua email",
|
||||||
|
"passwordRequired": "Inserisci la tua password",
|
||||||
|
"confirmPasswordRequired": "Conferma la tua password",
|
||||||
|
"loading": "Caricamento..."
|
||||||
|
},
|
||||||
|
"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 testi disponibile",
|
||||||
|
"disorder": "Disordine",
|
||||||
|
"previous": "Precedente"
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"unauthorized": "Non sei autorizzato ad accedere a questa cartella"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"navbar": {
|
||||||
|
"title": "learn-languages",
|
||||||
|
"sourceCode": "GitHub",
|
||||||
|
"sign_in": "Accedi",
|
||||||
|
"profile": "Profilo",
|
||||||
|
"folders": "Cartelle"
|
||||||
|
},
|
||||||
|
"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": "Carica i file video e sottotitoli",
|
||||||
|
"uploadVideoFile": "Carica un file video",
|
||||||
|
"uploadSubtitleFile": "Carica un file di sottotitoli",
|
||||||
|
"processingSubtitle": "Elaborazione file sottotitoli...",
|
||||||
|
"needBothFiles": "Sono richiesti sia i file video che i sottotitoli per iniziare l'apprendimento",
|
||||||
|
"videoFile": "File video",
|
||||||
|
"subtitleFile": "File sottotitoli",
|
||||||
|
"uploaded": "Caricato",
|
||||||
|
"notUploaded": "Non caricato",
|
||||||
|
"upload": "Carica",
|
||||||
|
"autoPauseStatus": "Pausa automatica: {enabled}",
|
||||||
|
"on": "Attivo",
|
||||||
|
"off": "Disattivo",
|
||||||
|
"videoUploadFailed": "Caricamento video fallito",
|
||||||
|
"subtitleUploadFailed": "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 testi aggiunta alla cartella",
|
||||||
|
"error": "Impossibile aggiungere la coppia di testi 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 interrogazione",
|
||||||
|
"queryLanguageHint": "Quale è la lingua della parola/frase che vuoi cercare",
|
||||||
|
"definitionLanguage": "Lingua di definizione",
|
||||||
|
"definitionLanguageHint": "In quale lingua vuoi vedere le definizioni",
|
||||||
|
"otherLanguagePlaceholder": "Oppure inserisci un'altra lingua...",
|
||||||
|
"currentSettings": "Impostazioni attuali: Interrogazione {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",
|
||||||
|
"lookupFailed": "Ricerca fallita, riprova più tardi",
|
||||||
|
"relookupSuccess": "Ricerca ripetuta con successo",
|
||||||
|
"relookupFailed": "Nuova ricerca del dizionario fallita",
|
||||||
|
"pleaseLogin": "Accedi prima",
|
||||||
|
"pleaseCreateFolder": "Crea prima una cartella",
|
||||||
|
"savedToFolder": "Salvato nella cartella: {folderName}",
|
||||||
|
"saveFailed": "Salvataggio fallito, riprova più tardi"
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"folders": {
|
||||||
|
"title": "Cartelle",
|
||||||
|
"noFolders": "Nessuna cartella ancora",
|
||||||
|
"folderName": "Nome cartella",
|
||||||
|
"totalPairs": "Numero di coppie",
|
||||||
|
"createdAt": "Creato il",
|
||||||
|
"actions": "Azioni",
|
||||||
|
"view": "Visualizza"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
258
messages/ja-JP.json
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
{
|
||||||
|
"alphabet": {
|
||||||
|
"chooseCharacters": "学習したい文字を選択してください",
|
||||||
|
"japanese": "日本語仮名",
|
||||||
|
"english": "英語アルファベット",
|
||||||
|
"uyghur": "ウイグル文字",
|
||||||
|
"esperanto": "エスペラント文字",
|
||||||
|
"loading": "読み込み中...",
|
||||||
|
"loadFailed": "読み込みに失敗しました。もう一度お試しください",
|
||||||
|
"hideLetter": "文字を非表示",
|
||||||
|
"showLetter": "文字を表示",
|
||||||
|
"hideIPA": "IPAを非表示",
|
||||||
|
"showIPA": "IPAを表示",
|
||||||
|
"roman": "ローマ字",
|
||||||
|
"letter": "文字",
|
||||||
|
"random": "ランダムモード",
|
||||||
|
"randomNext": "ランダムで次へ"
|
||||||
|
},
|
||||||
|
"folders": {
|
||||||
|
"title": "フォルダー",
|
||||||
|
"subtitle": "コレクションを管理",
|
||||||
|
"newFolder": "新規フォルダー",
|
||||||
|
"creating": "作成中...",
|
||||||
|
"noFoldersYet": "フォルダーがありません",
|
||||||
|
"folderInfo": "ID: {id} • {totalPairs}組",
|
||||||
|
"enterFolderName": "フォルダー名を入力:",
|
||||||
|
"confirmDelete": "削除するには「{name}」と入力してください:"
|
||||||
|
},
|
||||||
|
"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": "— スティーブ・ジョブズ"
|
||||||
|
},
|
||||||
|
"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": "認証",
|
||||||
|
"signIn": "ログイン",
|
||||||
|
"signUp": "新規登録",
|
||||||
|
"email": "メールアドレス",
|
||||||
|
"password": "パスワード",
|
||||||
|
"confirmPassword": "パスワード(確認)",
|
||||||
|
"name": "名前",
|
||||||
|
"signInButton": "ログイン",
|
||||||
|
"signUpButton": "新規登録",
|
||||||
|
"noAccount": "アカウントをお持ちでないですか?",
|
||||||
|
"hasAccount": "すでにアカウントをお持ちですか?",
|
||||||
|
"signInWithGitHub": "GitHubでログイン",
|
||||||
|
"signUpWithGitHub": "GitHubで新規登録",
|
||||||
|
"invalidEmail": "有効なメールアドレスを入力してください",
|
||||||
|
"passwordTooShort": "パスワードは8文字以上である必要があります",
|
||||||
|
"passwordsNotMatch": "パスワードが一致しません",
|
||||||
|
"nameRequired": "名前を入力してください",
|
||||||
|
"emailRequired": "メールアドレスを入力してください",
|
||||||
|
"passwordRequired": "パスワードを入力してください",
|
||||||
|
"confirmPasswordRequired": "パスワード(確認)を入力してください",
|
||||||
|
"loading": "読み込み中..."
|
||||||
|
},
|
||||||
|
"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": "フォルダー"
|
||||||
|
},
|
||||||
|
"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": "アップロード",
|
||||||
|
"autoPauseStatus": "自動一時停止: {enabled}",
|
||||||
|
"on": "オン",
|
||||||
|
"off": "オフ",
|
||||||
|
"videoUploadFailed": "ビデオのアップロードに失敗しました",
|
||||||
|
"subtitleUploadFailed": "字幕のアップロードに失敗しました"
|
||||||
|
},
|
||||||
|
"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": "または他の言語を入力...",
|
||||||
|
"currentSettings": "現在の設定:クエリ {queryLang}、定義 {definitionLang}",
|
||||||
|
"relookup": "再検索",
|
||||||
|
"saveToFolder": "フォルダに保存",
|
||||||
|
"loading": "読み込み中...",
|
||||||
|
"noResults": "結果が見つかりません",
|
||||||
|
"tryOtherWords": "他の単語やフレーズを試してください",
|
||||||
|
"welcomeTitle": "辞書へようこそ",
|
||||||
|
"welcomeHint": "上の検索ボックスに単語やフレーズを入力して検索を開始",
|
||||||
|
"lookupFailed": "検索に失敗しました。後でもう一度お試しください",
|
||||||
|
"relookupSuccess": "再検索しました",
|
||||||
|
"relookupFailed": "辞書の再検索に失敗しました",
|
||||||
|
"pleaseLogin": "まずログインしてください",
|
||||||
|
"pleaseCreateFolder": "まずフォルダを作成してください",
|
||||||
|
"savedToFolder": "フォルダに保存しました:{folderName}",
|
||||||
|
"saveFailed": "保存に失敗しました。後でもう一度お試しください"
|
||||||
|
},
|
||||||
|
"user_profile": {
|
||||||
|
"anonymous": "匿名",
|
||||||
|
"email": "メールアドレス",
|
||||||
|
"verified": "認証済み",
|
||||||
|
"unverified": "未認証",
|
||||||
|
"accountInfo": "アカウント情報",
|
||||||
|
"userId": "ユーザーID",
|
||||||
|
"username": "ユーザー名",
|
||||||
|
"displayName": "表示名",
|
||||||
|
"notSet": "未設定",
|
||||||
|
"memberSince": "登録日",
|
||||||
|
"folders": {
|
||||||
|
"title": "フォルダー",
|
||||||
|
"noFolders": "フォルダーがありません",
|
||||||
|
"folderName": "フォルダー名",
|
||||||
|
"totalPairs": "テキストペア数",
|
||||||
|
"createdAt": "作成日",
|
||||||
|
"actions": "操作",
|
||||||
|
"view": "表示"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
258
messages/ko-KR.json
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
{
|
||||||
|
"alphabet": {
|
||||||
|
"chooseCharacters": "학습할 문자를 선택하세요",
|
||||||
|
"japanese": "일본어 가나",
|
||||||
|
"english": "영문 알파벳",
|
||||||
|
"uyghur": "위구르 문자",
|
||||||
|
"esperanto": "에스페란토 문자",
|
||||||
|
"loading": "로딩 중...",
|
||||||
|
"loadFailed": "로딩 실패, 다시 시도해 주세요",
|
||||||
|
"hideLetter": "문자 숨기기",
|
||||||
|
"showLetter": "문자 표시",
|
||||||
|
"hideIPA": "IPA 숨기기",
|
||||||
|
"showIPA": "IPA 표시",
|
||||||
|
"roman": "로마자 표기",
|
||||||
|
"letter": "문자",
|
||||||
|
"random": "무작위 모드",
|
||||||
|
"randomNext": "무작위 다음"
|
||||||
|
},
|
||||||
|
"folders": {
|
||||||
|
"title": "폴더",
|
||||||
|
"subtitle": "컬렉션 관리",
|
||||||
|
"newFolder": "새 폴더",
|
||||||
|
"creating": "생성 중...",
|
||||||
|
"noFoldersYet": "폴더가 없습니다",
|
||||||
|
"folderInfo": "ID: {id} • {totalPairs}쌍",
|
||||||
|
"enterFolderName": "폴더 이름 입력:",
|
||||||
|
"confirmDelete": "삭제하려면 \"{name}\"을(를) 입력하세요:"
|
||||||
|
},
|
||||||
|
"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": "— 스티브 잡스"
|
||||||
|
},
|
||||||
|
"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": "인증",
|
||||||
|
"signIn": "로그인",
|
||||||
|
"signUp": "회원가입",
|
||||||
|
"email": "이메일",
|
||||||
|
"password": "비밀번호",
|
||||||
|
"confirmPassword": "비밀번호 확인",
|
||||||
|
"name": "이름",
|
||||||
|
"signInButton": "로그인",
|
||||||
|
"signUpButton": "회원가입",
|
||||||
|
"noAccount": "계정이 없으신가요?",
|
||||||
|
"hasAccount": "이미 계정이 있으신가요?",
|
||||||
|
"signInWithGitHub": "GitHub로 로그인",
|
||||||
|
"signUpWithGitHub": "GitHub로 회원가입",
|
||||||
|
"invalidEmail": "유효한 이메일 주소를 입력하세요",
|
||||||
|
"passwordTooShort": "비밀번호는 최소 8자 이상이어야 합니다",
|
||||||
|
"passwordsNotMatch": "비밀번호가 일치하지 않습니다",
|
||||||
|
"nameRequired": "이름을 입력하세요",
|
||||||
|
"emailRequired": "이메일을 입력하세요",
|
||||||
|
"passwordRequired": "비밀번호를 입력하세요",
|
||||||
|
"confirmPasswordRequired": "비밀번호 확인을 입력하세요",
|
||||||
|
"loading": "로딩 중..."
|
||||||
|
},
|
||||||
|
"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": "폴더"
|
||||||
|
},
|
||||||
|
"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": "업로드",
|
||||||
|
"autoPauseStatus": "자동 일시정지: {enabled}",
|
||||||
|
"on": "켜기",
|
||||||
|
"off": "끄기",
|
||||||
|
"videoUploadFailed": "비디오 업로드 실패",
|
||||||
|
"subtitleUploadFailed": "자막 업로드 실패"
|
||||||
|
},
|
||||||
|
"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": "또는 다른 언어를 입력하세요...",
|
||||||
|
"currentSettings": "현재 설정: 쿼리 {queryLang}, 정의 {definitionLang}",
|
||||||
|
"relookup": "재검색",
|
||||||
|
"saveToFolder": "폴더에 저장",
|
||||||
|
"loading": "로드 중...",
|
||||||
|
"noResults": "결과를 찾을 수 없습니다",
|
||||||
|
"tryOtherWords": "다른 단어나 구문을 시도하세요",
|
||||||
|
"welcomeTitle": "사전에 오신 것을 환영합니다",
|
||||||
|
"welcomeHint": "위 검색 상자에 단어나 구문을 입력하여 검색을 시작하세요",
|
||||||
|
"lookupFailed": "검색 실패, 나중에 다시 시도하세요",
|
||||||
|
"relookupSuccess": "재검색했습니다",
|
||||||
|
"relookupFailed": "사전 재검색 실패",
|
||||||
|
"pleaseLogin": "먼저 로그인하세요",
|
||||||
|
"pleaseCreateFolder": "먼저 폴더를 만드세요",
|
||||||
|
"savedToFolder": "폴더에 저장됨: {folderName}",
|
||||||
|
"saveFailed": "저장 실패, 나중에 다시 시도하세요"
|
||||||
|
},
|
||||||
|
"user_profile": {
|
||||||
|
"anonymous": "익명",
|
||||||
|
"email": "이메일",
|
||||||
|
"verified": "인증됨",
|
||||||
|
"unverified": "미인증",
|
||||||
|
"accountInfo": "계정 정보",
|
||||||
|
"userId": "사용자 ID",
|
||||||
|
"username": "사용자명",
|
||||||
|
"displayName": "표시 이름",
|
||||||
|
"notSet": "설정되지 않음",
|
||||||
|
"memberSince": "가입일",
|
||||||
|
"folders": {
|
||||||
|
"title": "폴더",
|
||||||
|
"noFolders": "폴더가 없습니다",
|
||||||
|
"folderName": "폴더 이름",
|
||||||
|
"totalPairs": "텍스트 쌍 수",
|
||||||
|
"createdAt": "생성일",
|
||||||
|
"actions": "작업",
|
||||||
|
"view": "보기"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
258
messages/ug-CN.json
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
{
|
||||||
|
"alphabet": {
|
||||||
|
"chooseCharacters": "ئۆگىنەرلىك ھەرپلەرنى تاللاڭ",
|
||||||
|
"japanese": "ياپونىيە كانا",
|
||||||
|
"english": "ئىنگلىز ئېلىپبې",
|
||||||
|
"uyghur": "ئۇيغۇر ئېلىپبېسى",
|
||||||
|
"esperanto": "ئېسپېرانتو ئېلىپبېسى",
|
||||||
|
"loading": "چىقىرىۋېتىلىۋاتىدۇ...",
|
||||||
|
"loadFailed": "چىقىرىش مەغلۇب بولدى، قايتا سىناڭ",
|
||||||
|
"hideLetter": "ھەرپنى يوشۇرۇش",
|
||||||
|
"showLetter": "ھەرپنى كۆرسىتىش",
|
||||||
|
"hideIPA": "IPA نى يوشۇرۇش",
|
||||||
|
"showIPA": "IPA نى كۆرسىتىش",
|
||||||
|
"roman": "روماللاشتۇرۇش",
|
||||||
|
"letter": "ھەرپ",
|
||||||
|
"random": "ئىختىيارىي ھالەت",
|
||||||
|
"randomNext": "ئىختىيارىي كېيىنكى"
|
||||||
|
},
|
||||||
|
"folders": {
|
||||||
|
"title": "قىسقۇچلار",
|
||||||
|
"subtitle": "توپلىمىڭىزنى باشقۇرۇڭ",
|
||||||
|
"newFolder": "يېڭى قىسقۇچ",
|
||||||
|
"creating": "قۇرۇۋاتىدۇ...",
|
||||||
|
"noFoldersYet": "قىسقۇچ يوق",
|
||||||
|
"folderInfo": "كود: {id} • {totalPairs} جۈپ",
|
||||||
|
"enterFolderName": "قىسقۇچ نامىنى كىرگۈزۈڭ:",
|
||||||
|
"confirmDelete": "ئۆچۈرۈش ئۈچۈن «{name}» نى كىرگۈزۈڭ:"
|
||||||
|
},
|
||||||
|
"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": "— ستىۋ جوۋبس"
|
||||||
|
},
|
||||||
|
"translator": {
|
||||||
|
"name": "تەرجىمە",
|
||||||
|
"description": "خالىغان تىلغا تەرجىمە قىلىپ خەلقئارالىق فونېتىك ئېلىپبې (IPA) بىلەن ئىزاھاتلاش"
|
||||||
|
},
|
||||||
|
"textSpeaker": {
|
||||||
|
"name": "تېكىست ئوقۇغۇچى",
|
||||||
|
"description": "تېكىستنى پەرقلەندۈرۈپ ئوقىيدۇ، دەۋرىي ئوقۇش ۋە سۈرئەت تەڭشەشنى قوللايدۇ"
|
||||||
|
},
|
||||||
|
"srtPlayer": {
|
||||||
|
"name": "SRT سىن ئوپىراتورى",
|
||||||
|
"description": "SRT خەت ئاستى فايلى ئاساسىدا سىننى جۈملە-جۈملە قويۇپ، يەرلىك ئىخچام ئاۋازنى ئىمتىلايدۇ"
|
||||||
|
},
|
||||||
|
"alphabet": {
|
||||||
|
"name": "ئېلىپبې",
|
||||||
|
"description": "ئېلىپبېدىن يېڭى تىل ئۆگىنىشنى باشلاڭ"
|
||||||
|
},
|
||||||
|
"memorize": {
|
||||||
|
"name": "ئەستە ساقلاش",
|
||||||
|
"description": "تىل A دىن تىل غا، تىل B دىن تىل A غا، دىكتات قوللايدۇ"
|
||||||
|
},
|
||||||
|
"dictionary": {
|
||||||
|
"name": "لۇغەت",
|
||||||
|
"description": "سۆز ۋە سۆزنى ئىزدەپ، تەپسىلىي ئىزاھات ۋە مىساللار بىلەن تەمىنلەيدۇ"
|
||||||
|
},
|
||||||
|
"moreFeatures": {
|
||||||
|
"name": "تېخىمۇ كۆپ ئىقتىدار",
|
||||||
|
"description": "ئىشلەۋاتىدۇ، كۈتكۈن بولۇڭ"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"title": "دەلىللەش",
|
||||||
|
"signIn": "كىرىش",
|
||||||
|
"signUp": "تىزىملىتىش",
|
||||||
|
"email": "ئېلخەت",
|
||||||
|
"password": "ئىم",
|
||||||
|
"confirmPassword": "ئىمنى جەزملەش",
|
||||||
|
"name": "نام",
|
||||||
|
"signInButton": "كىرىش",
|
||||||
|
"signUpButton": "تىزىملىتىش",
|
||||||
|
"noAccount": "ھېساباتىڭىز يوقمۇ؟",
|
||||||
|
"hasAccount": "ھېساباتىڭىز بارمۇ؟",
|
||||||
|
"signInWithGitHub": "GitHub بىلەن كىرىڭ",
|
||||||
|
"signUpWithGitHub": "GitHub بىلەن تىزىملىتىڭ",
|
||||||
|
"invalidEmail": "ئىناۋەتلىك ئېلخەت ئادرېسى كىرگۈزۈڭ",
|
||||||
|
"passwordTooShort": "ئىم كەم دېگەندە 8 ھەرپتىن تۇرۇشى كېرەك",
|
||||||
|
"passwordsNotMatch": "ئىم ماس كەلمەيدۇ",
|
||||||
|
"nameRequired": "نامىڭىزنى كىرگۈزۈڭ",
|
||||||
|
"emailRequired": "ئېلخىتىڭىزنى كىرگۈزۈڭ",
|
||||||
|
"passwordRequired": "ئىمىڭىزنى كىرگۈزۈڭ",
|
||||||
|
"confirmPasswordRequired": "ئىمىڭىزنى جەزملەڭ",
|
||||||
|
"loading": "چىقىرىۋېتىلىۋاتىدۇ..."
|
||||||
|
},
|
||||||
|
"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": "قىسقۇچلار"
|
||||||
|
},
|
||||||
|
"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": "يۈكلەش",
|
||||||
|
"autoPauseStatus": "ئاپتوماتىك توختىتىش: {enabled}",
|
||||||
|
"on": "ئوچۇق",
|
||||||
|
"off": "تاقاق",
|
||||||
|
"videoUploadFailed": "سىن يۈكلەش مەغلۇب بولدى",
|
||||||
|
"subtitleUploadFailed": "خەت ئاستى يۈكلەش مەغلۇب بولدى"
|
||||||
|
},
|
||||||
|
"text_speaker": {
|
||||||
|
"generateIPA": "IPA ھاسىل قىلىش",
|
||||||
|
"viewSavedItems": "ساقلانغان تۈرلەرنى كۆرۈش",
|
||||||
|
"confirmDeleteAll": "ھەممىنى ئۆچۈرەمسىز؟ (H/Y)"
|
||||||
|
},
|
||||||
|
"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": "ياكى باشقا تىل كىرگۈزۈڭ...",
|
||||||
|
"currentSettings": "نۆۋەتتىكى تەڭشەك: سۈرەشتۈرۈش {queryLang}، ئىلمىيى {definitionLang}",
|
||||||
|
"relookup": "قايتا ئىزدە",
|
||||||
|
"saveToFolder": "قىسقۇچقا ساقلا",
|
||||||
|
"loading": "يۈكلىۋاتىدۇ...",
|
||||||
|
"noResults": "نەتىجە تېپىلمىدى",
|
||||||
|
"tryOtherWords": "باشقا سۆز ياكى ئىبارە سىناڭ",
|
||||||
|
"welcomeTitle": "لۇغەتكە مەرھەمەت",
|
||||||
|
"welcomeHint": "ئىزدەشنى باشلاش ئۈچۈن يۇقىرىدىكى ئىزدەش رامكىسىغا سۆز ياكى ئىبارە كىرگۈزۈڭ",
|
||||||
|
"lookupFailed": "ئىزدەش مەغلۇب بولدى، كېيىنرەك قايتا سىناڭ",
|
||||||
|
"relookupSuccess": "مۇۋەپپەقىيەتلىك قايتا ئىزدىدى",
|
||||||
|
"relookupFailed": "لۇغەت قايتا ئىزدىشى مەغلۇب بولدى",
|
||||||
|
"pleaseLogin": "ئاۋۋال تىزىملىتىڭ",
|
||||||
|
"pleaseCreateFolder": "ئاۋۋال قىسقۇچ قۇرۇڭ",
|
||||||
|
"savedToFolder": "قىسقۇچقا ساقلاندى: {folderName}",
|
||||||
|
"saveFailed": "ساقلاش مەغلۇب بولدى، كېيىنرەك قايتا سىناڭ"
|
||||||
|
},
|
||||||
|
"user_profile": {
|
||||||
|
"anonymous": "ئىسىمسىز",
|
||||||
|
"email": "ئېلخەت",
|
||||||
|
"verified": "دەلىللەندى",
|
||||||
|
"unverified": "دەلىتلەنمىدى",
|
||||||
|
"accountInfo": "ھېسابات ئۇچۇرى",
|
||||||
|
"userId": "ئىشلەتكۈچى كودى",
|
||||||
|
"username": "ئىشلەتكۈچى نامى",
|
||||||
|
"displayName": "كۆرسىتىلىدىغان نام",
|
||||||
|
"notSet": "تەڭشەلمىگەن",
|
||||||
|
"memberSince": "تىزىملاتقان ۋاقىت",
|
||||||
|
"folders": {
|
||||||
|
"title": "قىسقۇچلار",
|
||||||
|
"noFolders": "قىسقۇچ يوق",
|
||||||
|
"folderName": "قىسقۇچ نامى",
|
||||||
|
"totalPairs": "تېكىست جۈپ سانى",
|
||||||
|
"createdAt": "قۇرۇلغان ۋاقىت",
|
||||||
|
"actions": "مەشغۇلات",
|
||||||
|
"view": "كۆرۈش"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
264
messages/zh-CN.json
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
{
|
||||||
|
"alphabet": {
|
||||||
|
"chooseCharacters": "请选择您想学习的字符",
|
||||||
|
"japanese": "日语假名",
|
||||||
|
"english": "英文字母",
|
||||||
|
"uyghur": "维吾尔字母",
|
||||||
|
"esperanto": "世界语字母",
|
||||||
|
"loading": "加载中...",
|
||||||
|
"loadFailed": "加载失败,请重试",
|
||||||
|
"hideLetter": "隐藏字母",
|
||||||
|
"showLetter": "显示字母",
|
||||||
|
"hideIPA": "隐藏IPA",
|
||||||
|
"showIPA": "显示IPA",
|
||||||
|
"roman": "罗马音",
|
||||||
|
"letter": "字母",
|
||||||
|
"random": "随机模式",
|
||||||
|
"randomNext": "随机下一个"
|
||||||
|
},
|
||||||
|
"folders": {
|
||||||
|
"title": "文件夹",
|
||||||
|
"subtitle": "管理您的集合",
|
||||||
|
"newFolder": "新建文件夹",
|
||||||
|
"creating": "创建中...",
|
||||||
|
"noFoldersYet": "还没有文件夹",
|
||||||
|
"folderInfo": "ID: {id} • {totalPairs} 个文本对",
|
||||||
|
"enterFolderName": "输入文件夹名称:",
|
||||||
|
"confirmDelete": "输入 \"{name}\" 以删除:"
|
||||||
|
},
|
||||||
|
"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": "登录",
|
||||||
|
"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": "加载中..."
|
||||||
|
},
|
||||||
|
"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": "文件夹"
|
||||||
|
},
|
||||||
|
"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": "未上传",
|
||||||
|
"autoPauseStatus": "自动暂停: {enabled}",
|
||||||
|
"on": "开",
|
||||||
|
"off": "关",
|
||||||
|
"videoUploadFailed": "视频上传失败",
|
||||||
|
"subtitleUploadFailed": "字幕上传失败"
|
||||||
|
},
|
||||||
|
"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": "或输入其他语言...",
|
||||||
|
"currentSettings": "当前设置:查询 {queryLang},释义 {definitionLang}",
|
||||||
|
"relookup": "重新查询",
|
||||||
|
"saveToFolder": "保存到文件夹",
|
||||||
|
"loading": "加载中...",
|
||||||
|
"noResults": "未找到结果",
|
||||||
|
"tryOtherWords": "尝试其他单词或短语",
|
||||||
|
"welcomeTitle": "欢迎使用词典",
|
||||||
|
"welcomeHint": "在上方搜索框中输入单词或短语开始查询",
|
||||||
|
"lookupFailed": "查询失败,请稍后重试",
|
||||||
|
"relookupSuccess": "已重新查询",
|
||||||
|
"relookupFailed": "词典重新查询失败",
|
||||||
|
"pleaseLogin": "请先登录",
|
||||||
|
"pleaseCreateFolder": "请先创建文件夹",
|
||||||
|
"savedToFolder": "已保存到文件夹:{folderName}",
|
||||||
|
"saveFailed": "保存失败,请稍后重试"
|
||||||
|
},
|
||||||
|
"user_profile": {
|
||||||
|
"anonymous": "匿名",
|
||||||
|
"email": "邮箱",
|
||||||
|
"verified": "已验证",
|
||||||
|
"unverified": "未验证",
|
||||||
|
"accountInfo": "账户信息",
|
||||||
|
"userId": "用户ID",
|
||||||
|
"username": "用户名",
|
||||||
|
"displayName": "显示名称",
|
||||||
|
"notSet": "未设置",
|
||||||
|
"memberSince": "注册时间",
|
||||||
|
"folders": {
|
||||||
|
"title": "文件夹",
|
||||||
|
"noFolders": "还没有文件夹",
|
||||||
|
"folderName": "文件夹名称",
|
||||||
|
"totalPairs": "文本对数量",
|
||||||
|
"createdAt": "创建时间",
|
||||||
|
"actions": "操作",
|
||||||
|
"view": "查看"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,21 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
/* config options here */
|
||||||
|
output: "standalone",
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "avatars.githubusercontent.com",
|
||||||
|
port: "",
|
||||||
|
pathname: "/u/**",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
reactCompiler: true
|
||||||
|
// allowedDevOrigins: ["192.168.3.65", "192.168.3.66"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
const withNextIntl = createNextIntlPlugin();
|
||||||
|
export default withNextIntl(nextConfig);
|
||||||
|
|||||||
14959
package-lock.json
generated
60
package.json
@@ -2,29 +2,57 @@
|
|||||||
"name": "learn-languages",
|
"name": "learn-languages",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "GPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --experimental-https",
|
||||||
"build": "next build --turbopack",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@prisma/adapter-pg": "^7.2.0",
|
||||||
|
"@prisma/client": "^7.2.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"better-auth": "^1.4.10",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"edge-tts-universal": "^1.3.2",
|
"dotenv": "^17.2.3",
|
||||||
"next": "15.5.3",
|
"lucide-react": "^0.562.0",
|
||||||
"react": "19.1.0",
|
"next": "16.1.1",
|
||||||
"react-dom": "19.1.0"
|
"next-intl": "^4.7.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",
|
||||||
|
"zod": "^4.3.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@better-auth/cli": "^1.4.10",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@eslint/eslintrc": "^3.3.3",
|
||||||
"@types/node": "^20",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@types/react": "^19",
|
"@types/node": "^25.0.3",
|
||||||
"@types/react-dom": "^19",
|
"@types/react": "19.2.7",
|
||||||
"eslint": "^9",
|
"@types/react-dom": "19.2.3",
|
||||||
"eslint-config-next": "15.5.3",
|
"@typescript-eslint/eslint-plugin": "^8.51.0",
|
||||||
"tailwindcss": "^4",
|
"@typescript-eslint/parser": "^8.51.0",
|
||||||
"typescript": "^5"
|
"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.2.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"@types/react": "19.2.7",
|
||||||
|
"@types/react-dom": "19.2.3"
|
||||||
|
},
|
||||||
|
"ignoredBuiltDependencies": [
|
||||||
|
"@prisma/client"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6684
pnpm-lock.yaml
generated
Normal file
11
prisma.config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig, env } from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
migrations: {
|
||||||
|
path: "prisma/migrations",
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
url: env("DATABASE_URL"),
|
||||||
|
},
|
||||||
|
});
|
||||||
120
prisma/migrations/20251210105812_init/migration.sql
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "pairs" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"locale1" VARCHAR(10) NOT NULL,
|
||||||
|
"locale2" VARCHAR(10) NOT NULL,
|
||||||
|
"text1" TEXT NOT NULL,
|
||||||
|
"text2" TEXT NOT NULL,
|
||||||
|
"ipa1" TEXT,
|
||||||
|
"ipa2" TEXT,
|
||||||
|
"folder_id" INTEGER NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "pairs_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "folders" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"user_id" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "folders_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "user" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"image" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "session" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"ipAddress" TEXT,
|
||||||
|
"userAgent" TEXT,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "session_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "account" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"accountId" TEXT NOT NULL,
|
||||||
|
"providerId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"accessToken" TEXT,
|
||||||
|
"refreshToken" TEXT,
|
||||||
|
"idToken" TEXT,
|
||||||
|
"accessTokenExpiresAt" TIMESTAMP(3),
|
||||||
|
"refreshTokenExpiresAt" TIMESTAMP(3),
|
||||||
|
"scope" TEXT,
|
||||||
|
"password" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "account_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "verification" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"identifier" TEXT NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "verification_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "pairs_folder_id_idx" ON "pairs"("folder_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "pairs_folder_id_locale1_locale2_text1_key" ON "pairs"("folder_id", "locale1", "locale2", "text1");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "folders_user_id_idx" ON "folders"("user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "session_userId_idx" ON "session"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "session_token_key" ON "session"("token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "account_userId_idx" ON "account"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "verification_identifier_idx" ON "verification"("identifier");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "pairs" ADD CONSTRAINT "pairs_folder_id_fkey" FOREIGN KEY ("folder_id") REFERENCES "folders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "folders" ADD CONSTRAINT "folders_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
138
prisma/migrations/20260105081337_dictionary_add/migration.sql
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `ipa1` on the `pairs` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `ipa2` on the `pairs` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
-- 重命名并修改类型为 TEXT
|
||||||
|
ALTER TABLE "pairs"
|
||||||
|
RENAME COLUMN "locale1" TO "language1";
|
||||||
|
|
||||||
|
ALTER TABLE "pairs"
|
||||||
|
ALTER COLUMN "language1" SET DATA TYPE VARCHAR(20);
|
||||||
|
|
||||||
|
ALTER TABLE "pairs"
|
||||||
|
RENAME COLUMN "locale2" TO "language2";
|
||||||
|
|
||||||
|
ALTER TABLE "pairs"
|
||||||
|
ALTER COLUMN "language2" SET DATA TYPE VARCHAR(20);
|
||||||
|
|
||||||
|
-- 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_word_id" INTEGER,
|
||||||
|
"dictionary_phrase_id" INTEGER,
|
||||||
|
|
||||||
|
CONSTRAINT "dictionary_lookups_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "dictionary_words" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"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_words_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "dictionary_phrases" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"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_phrases_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "dictionary_word_entries" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"word_id" INTEGER NOT NULL,
|
||||||
|
"ipa" TEXT NOT NULL,
|
||||||
|
"definition" TEXT NOT NULL,
|
||||||
|
"part_of_speech" TEXT NOT NULL,
|
||||||
|
"example" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "dictionary_word_entries_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "dictionary_phrase_entries" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"phrase_id" INTEGER NOT NULL,
|
||||||
|
"definition" TEXT NOT NULL,
|
||||||
|
"example" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "dictionary_phrase_entries_pkey" PRIMARY KEY ("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_text_query_lang_definition_lang_idx" ON "dictionary_lookups"("text", "query_lang", "definition_lang");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "dictionary_words_standard_form_idx" ON "dictionary_words"("standard_form");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "dictionary_words_query_lang_definition_lang_idx" ON "dictionary_words"("query_lang", "definition_lang");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "dictionary_words_standard_form_query_lang_definition_lang_key" ON "dictionary_words"("standard_form", "query_lang", "definition_lang");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "dictionary_phrases_standard_form_idx" ON "dictionary_phrases"("standard_form");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "dictionary_phrases_query_lang_definition_lang_idx" ON "dictionary_phrases"("query_lang", "definition_lang");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "dictionary_phrases_standard_form_query_lang_definition_lang_key" ON "dictionary_phrases"("standard_form", "query_lang", "definition_lang");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "dictionary_word_entries_word_id_idx" ON "dictionary_word_entries"("word_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "dictionary_word_entries_created_at_idx" ON "dictionary_word_entries"("created_at");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "dictionary_phrase_entries_phrase_id_idx" ON "dictionary_phrase_entries"("phrase_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "dictionary_phrase_entries_created_at_idx" ON "dictionary_phrase_entries"("created_at");
|
||||||
|
|
||||||
|
-- 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_lookups" ADD CONSTRAINT "dictionary_lookups_dictionary_word_id_fkey" FOREIGN KEY ("dictionary_word_id") REFERENCES "dictionary_words"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_dictionary_phrase_id_fkey" FOREIGN KEY ("dictionary_phrase_id") REFERENCES "dictionary_phrases"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "dictionary_word_entries" ADD CONSTRAINT "dictionary_word_entries_word_id_fkey" FOREIGN KEY ("word_id") REFERENCES "dictionary_words"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "dictionary_phrase_entries" ADD CONSTRAINT "dictionary_phrase_entries_phrase_id_fkey" FOREIGN KEY ("phrase_id") REFERENCES "dictionary_phrases"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "dictionary_phrases_standard_form_query_lang_definition_lang_key";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "dictionary_words_standard_form_query_lang_definition_lang_key";
|
||||||
|
|
||||||
|
-- RenameIndex
|
||||||
|
ALTER INDEX "pairs_folder_id_locale1_locale2_text1_key" RENAME TO "pairs_folder_id_language1_language2_text1_key";
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "translation_history" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"user_id" TEXT,
|
||||||
|
"source_text" TEXT NOT NULL,
|
||||||
|
"source_language" VARCHAR(20) NOT NULL,
|
||||||
|
"target_language" VARCHAR(20) 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 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 "translation_history" ADD CONSTRAINT "translation_history_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[folder_id,language1,language2,text1,text2]` on the table `pairs` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "pairs_folder_id_language1_language2_text1_key";
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "pairs_folder_id_language1_language2_text1_text2_key" ON "pairs"("folder_id", "language1", "language2", "text1", "text2");
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "pairs" ALTER COLUMN "language1" SET DATA TYPE TEXT,
|
||||||
|
ALTER COLUMN "language2" SET DATA TYPE TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "translation_history" ALTER COLUMN "source_language" SET DATA TYPE TEXT,
|
||||||
|
ALTER COLUMN "target_language" SET DATA TYPE TEXT;
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `dictionary_phrase_id` on the `dictionary_lookups` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `dictionary_word_id` on the `dictionary_lookups` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the `dictionary_phrase_entries` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `dictionary_phrases` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `dictionary_word_entries` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `dictionary_words` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "dictionary_lookups" DROP CONSTRAINT "dictionary_lookups_dictionary_phrase_id_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "dictionary_lookups" DROP CONSTRAINT "dictionary_lookups_dictionary_word_id_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "dictionary_phrase_entries" DROP CONSTRAINT "dictionary_phrase_entries_phrase_id_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "dictionary_word_entries" DROP CONSTRAINT "dictionary_word_entries_word_id_fkey";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "dictionary_lookups_text_query_lang_definition_lang_idx";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "dictionary_lookups" DROP COLUMN "dictionary_phrase_id",
|
||||||
|
DROP COLUMN "dictionary_word_id",
|
||||||
|
ADD COLUMN "dictionary_item_id" INTEGER,
|
||||||
|
ADD COLUMN "normalized_text" TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "dictionary_phrase_entries";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "dictionary_phrases";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "dictionary_word_entries";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "dictionary_words";
|
||||||
|
|
||||||
|
-- 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")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 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 "dictionary_lookups_normalized_text_idx" ON "dictionary_lookups"("normalized_text");
|
||||||
|
|
||||||
|
-- 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_entries" ADD CONSTRAINT "dictionary_entries_item_id_fkey" FOREIGN KEY ("item_id") REFERENCES "dictionary_items"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[username]` on the table `user` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "user" ADD COLUMN "displayUsername" TEXT,
|
||||||
|
ADD COLUMN "username" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "user_username_key" ON "user"("username");
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
176
prisma/schema.prisma
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
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[]
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Folder {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String
|
||||||
|
userId String @map("user_id")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
pairs Pair[]
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@map("folders")
|
||||||
|
}
|
||||||
|
|
||||||
|
model 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")
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
2025.09.25 新增记忆字母表功能
|
|
||||||
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 更新了单词板,单词不再会重叠。
|
|
||||||
|
|
||||||
BIN
public/fonts/NotoNaskhArabic-VariableFont_wght.ttf
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M280-120q-33 0-56.5-23.5T200-200v-520h-40v-80h200v-40h240v40h200v80h-40v520q0 33-23.5 56.5T680-120H280Zm400-600H280v520h400v-520ZM360-280h80v-360h-80v360Zm160 0h80v-360h-80v360ZM280-720v520-520Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 319 B |
BIN
public/images/github-mark/github-mark-white.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
1
public/images/github-mark/github-mark-white.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 960 B |
BIN
public/images/github-mark/github-mark.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
1
public/images/github-mark/github-mark.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#24292f"/></svg>
|
||||||
|
After Width: | Height: | Size: 963 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M480-80q-82 0-155-31.5t-127.5-86Q143-252 111.5-325T80-480q0-83 31.5-155.5t86-127Q252-817 325-848.5T480-880q83 0 155.5 31.5t127 86q54.5 54.5 86 127T880-480q0 82-31.5 155t-86 127.5q-54.5 54.5-127 86T480-80Zm0-82q26-36 45-75t31-83H404q12 44 31 83t45 75Zm-104-16q-18-33-31.5-68.5T322-320H204q29 50 72.5 87t99.5 55Zm208 0q56-18 99.5-55t72.5-87H638q-9 38-22.5 73.5T584-178ZM170-400h136q-3-20-4.5-39.5T300-480q0-21 1.5-40.5T306-560H170q-5 20-7.5 39.5T160-480q0 21 2.5 40.5T170-400Zm216 0h188q3-20 4.5-39.5T580-480q0-21-1.5-40.5T574-560H386q-3 20-4.5 39.5T380-480q0 21 1.5 40.5T386-400Zm268 0h136q5-20 7.5-39.5T800-480q0-21-2.5-40.5T790-560H654q3 20 4.5 39.5T660-480q0 21-1.5 40.5T654-400Zm-16-240h118q-29-50-72.5-87T584-782q18 33 31.5 68.5T638-640Zm-234 0h152q-12-44-31-83t-45-75q-26 36-45 75t-31 83Zm-200 0h118q9-38 22.5-73.5T376-782q-56 18-99.5 55T204-640Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 976 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#FFFFFF"><path d="M480-80q-82 0-155-31.5t-127.5-86Q143-252 111.5-325T80-480q0-83 31.5-155.5t86-127Q252-817 325-848.5T480-880q83 0 155.5 31.5t127 86q54.5 54.5 86 127T880-480q0 82-31.5 155t-86 127.5q-54.5 54.5-127 86T480-80Zm0-82q26-36 45-75t31-83H404q12 44 31 83t45 75Zm-104-16q-18-33-31.5-68.5T322-320H204q29 50 72.5 87t99.5 55Zm208 0q56-18 99.5-55t72.5-87H638q-9 38-22.5 73.5T584-178ZM170-400h136q-3-20-4.5-39.5T300-480q0-21 1.5-40.5T306-560H170q-5 20-7.5 39.5T160-480q0 21 2.5 40.5T170-400Zm216 0h188q3-20 4.5-39.5T580-480q0-21-1.5-40.5T574-560H386q-3 20-4.5 39.5T380-480q0 21 1.5 40.5T386-400Zm268 0h136q5-20 7.5-39.5T800-480q0-21-2.5-40.5T790-560H654q3 20 4.5 39.5T660-480q0 21-1.5 40.5T654-400Zm-16-240h118q-29-50-72.5-87T584-782q18 33 31.5 68.5T638-640Zm-234 0h152q-12-44-31-83t-45-75q-26 36-45 75t-31 83Zm-200 0h118q9-38 22.5-73.5T376-782q-56 18-99.5 55T204-640Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 976 B |
36
public/images/logo.svg
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="180.425mm"
|
||||||
|
height="66.658363mm"
|
||||||
|
viewBox="0 0 180.425 66.658363"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<defs
|
||||||
|
id="defs1" />
|
||||||
|
<g
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-19.117989,-118.50376)">
|
||||||
|
<rect
|
||||||
|
style="fill:#00ccff;stroke-width:4.38923"
|
||||||
|
id="rect1"
|
||||||
|
width="180.42502"
|
||||||
|
height="66.658356"
|
||||||
|
x="19.117989"
|
||||||
|
y="118.50375" />
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:52.6706px;font-family:'Noto Serif';-inkscape-font-specification:'Noto Serif';text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#f2f2f2;stroke-width:4.38923"
|
||||||
|
x="29.942305"
|
||||||
|
y="167.45377"
|
||||||
|
id="text1"
|
||||||
|
transform="scale(0.98306332,1.0172285)"><tspan
|
||||||
|
id="tspan1"
|
||||||
|
style="fill:#f2f2f2;stroke-width:4.38923"
|
||||||
|
x="29.942305"
|
||||||
|
y="167.45377">Learn!</tspan></text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M840-680v480q0 33-23.5 56.5T760-120H200q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h480l160 160Zm-80 34L646-760H200v560h560v-446ZM480-240q50 0 85-35t35-85q0-50-35-85t-85-35q-50 0-85 35t-35 85q0 50 35 85t85 35ZM240-560h360v-160H240v160Zm-40-86v446-560 114Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 388 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M418-340q24 24 62 23.5t56-27.5l224-336-336 224q-27 18-28.5 55t22.5 61Zm62-460q59 0 113.5 16.5T696-734l-76 48q-33-17-68.5-25.5T480-720q-133 0-226.5 93.5T160-400q0 42 11.5 83t32.5 77h552q23-38 33.5-79t10.5-85q0-36-8.5-70T766-540l48-76q30 47 47.5 100T880-406q1 57-13 109t-41 99q-11 18-30 28t-40 10H204q-21 0-40-10t-30-28q-26-45-40-95.5T80-400q0-83 31.5-155.5t86-127Q252-737 325-768.5T480-800Zm7 313Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 521 B |
229
src/app/(features)/alphabet/AlphabetCard.tsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
src/app/(features)/alphabet/MemoryCard.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
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 {
|
||||||
|
Dispatch,
|
||||||
|
KeyboardEvent,
|
||||||
|
SetStateAction,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
export function MemoryCard({
|
||||||
|
alphabet,
|
||||||
|
setChosenAlphabet,
|
||||||
|
}: {
|
||||||
|
alphabet: Letter[];
|
||||||
|
setChosenAlphabet: Dispatch<SetStateAction<SupportedAlphabets | null>>;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("alphabet");
|
||||||
|
const [index, setIndex] = useState(() => alphabet.length > 0 ? Math.floor(Math.random() * alphabet.length) : 0);
|
||||||
|
const [more, setMore] = useState(false);
|
||||||
|
const [ipaDisplay, setIPADisplay] = useState(true);
|
||||||
|
const [letterDisplay, setLetterDisplay] = useState(true);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
if (alphabet.length > 0) {
|
||||||
|
setIndex(Math.floor(Math.random() * alphabet.length));
|
||||||
|
}
|
||||||
|
}, [alphabet.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeydown = (e: globalThis.KeyboardEvent) => {
|
||||||
|
if (e.key === " ") refresh();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeydown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeydown);
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const letter = alphabet[index] || { letter: "", letter_name_ipa: "", letter_sound_ipa: "" };
|
||||||
|
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="w-full flex justify-end items-center">
|
||||||
|
<IconClick
|
||||||
|
size="lg"
|
||||||
|
alt="close"
|
||||||
|
src={IMAGES.close}
|
||||||
|
onClick={() => setChosenAlphabet(null)}
|
||||||
|
></IconClick>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-12 justify-center items-center">
|
||||||
|
<span className="text-7xl md:text-9xl">
|
||||||
|
{letterDisplay ? letter.letter : ""}
|
||||||
|
</span>
|
||||||
|
<span className="text-5xl md:text-7xl text-gray-400">
|
||||||
|
{ipaDisplay ? letter.letter_sound_ipa : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row mt-32 items-center justify-center gap-2">
|
||||||
|
<IconClick
|
||||||
|
size="lg"
|
||||||
|
alt="refresh"
|
||||||
|
src={IMAGES.refresh}
|
||||||
|
onClick={refresh}
|
||||||
|
></IconClick>
|
||||||
|
<IconClick
|
||||||
|
size="lg"
|
||||||
|
alt="more"
|
||||||
|
src={IMAGES.more_horiz}
|
||||||
|
onClick={() => setMore(!more)}
|
||||||
|
></IconClick>
|
||||||
|
{more ? (
|
||||||
|
<>
|
||||||
|
<LightButton
|
||||||
|
className="w-20"
|
||||||
|
onClick={() => {
|
||||||
|
setLetterDisplay(!letterDisplay);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{letterDisplay ? t("hideLetter") : t("showLetter")}
|
||||||
|
</LightButton>
|
||||||
|
<LightButton
|
||||||
|
className="w-20"
|
||||||
|
onClick={() => {
|
||||||
|
setIPADisplay(!ipaDisplay);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ipaDisplay ? t("hideIPA") : t("showIPA")}
|
||||||
|
</LightButton>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
src/app/(features)/alphabet/page.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"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-gray-600 mb-8 text-lg">
|
||||||
|
选择一种语言的字母表开始学习
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 语言选择按钮网格 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* 日语假名选项 */}
|
||||||
|
<LightButton
|
||||||
|
onClick={() => setChosenAlphabet("japanese")}
|
||||||
|
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-2xl mb-2">あいうえお</span>
|
||||||
|
<span>{t("japanese")}</span>
|
||||||
|
</div>
|
||||||
|
</LightButton>
|
||||||
|
|
||||||
|
{/* 英语字母选项 */}
|
||||||
|
<LightButton
|
||||||
|
onClick={() => setChosenAlphabet("english")}
|
||||||
|
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-2xl mb-2">ABC</span>
|
||||||
|
<span>{t("english")}</span>
|
||||||
|
</div>
|
||||||
|
</LightButton>
|
||||||
|
|
||||||
|
{/* 维吾尔语字母选项 */}
|
||||||
|
<LightButton
|
||||||
|
onClick={() => setChosenAlphabet("uyghur")}
|
||||||
|
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-2xl mb-2">ئۇيغۇر</span>
|
||||||
|
<span>{t("uyghur")}</span>
|
||||||
|
</div>
|
||||||
|
</LightButton>
|
||||||
|
|
||||||
|
{/* 世界语字母选项 */}
|
||||||
|
<LightButton
|
||||||
|
onClick={() => setChosenAlphabet("esperanto")}
|
||||||
|
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-2xl mb-2">ABCĜĤ</span>
|
||||||
|
<span>{t("esperanto")}</span>
|
||||||
|
</div>
|
||||||
|
</LightButton>
|
||||||
|
</div>
|
||||||
|
</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;
|
||||||
|
}
|
||||||
45
src/app/(features)/dictionary/DictionaryEntry.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { TSharedEntry } from "@/shared/dictionary-type";
|
||||||
|
|
||||||
|
interface DictionaryEntryProps {
|
||||||
|
entry: TSharedEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DictionaryEntry({ entry }: DictionaryEntryProps) {
|
||||||
|
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">
|
||||||
|
释义
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-800">{entry.definition}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 例句 */}
|
||||||
|
{entry.example && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
||||||
|
例句
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-700 pl-4 border-l-4 border-[#35786f]">
|
||||||
|
{entry.example}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
src/app/(features)/dictionary/SearchForm.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { LightButton } from "@/design-system/base/button";
|
||||||
|
import { Input } from "@/design-system/base/input";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { POPULAR_LANGUAGES } from "./constants";
|
||||||
|
|
||||||
|
interface SearchFormProps {
|
||||||
|
defaultQueryLang?: string;
|
||||||
|
defaultDefinitionLang?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchForm({ defaultQueryLang = "english", defaultDefinitionLang = "chinese" }: SearchFormProps) {
|
||||||
|
const t = useTranslations("dictionary");
|
||||||
|
const [queryLang, setQueryLang] = useState(defaultQueryLang);
|
||||||
|
const [definitionLang, setDefinitionLang] = useState(defaultDefinitionLang);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
const searchQuery = formData.get("searchQuery") as string;
|
||||||
|
|
||||||
|
if (!searchQuery?.trim()) return;
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
q: searchQuery,
|
||||||
|
ql: queryLang,
|
||||||
|
dl: definitionLang,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push(`/dictionary?${params.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<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"
|
||||||
|
defaultValue=""
|
||||||
|
placeholder={t("searchPlaceholder")}
|
||||||
|
variant="search"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<LightButton
|
||||||
|
type="submit"
|
||||||
|
className="px-6 py-3 whitespace-nowrap text-center sm:min-w-30"
|
||||||
|
>
|
||||||
|
{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">
|
||||||
|
{/* 查询语言 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-gray-700 text-sm mb-2">
|
||||||
|
{t("queryLanguage")} ({t("queryLanguageHint")})
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{POPULAR_LANGUAGES.map((lang) => (
|
||||||
|
<LightButton
|
||||||
|
key={lang.code}
|
||||||
|
type="button"
|
||||||
|
selected={queryLang === lang.code}
|
||||||
|
onClick={() => setQueryLang(lang.code)}
|
||||||
|
className="text-sm px-3 py-1"
|
||||||
|
>
|
||||||
|
{lang.nativeName}
|
||||||
|
</LightButton>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 释义语言 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-gray-700 text-sm mb-2">
|
||||||
|
{t("definitionLanguage")} ({t("definitionLanguageHint")})
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{POPULAR_LANGUAGES.map((lang) => (
|
||||||
|
<LightButton
|
||||||
|
key={lang.code}
|
||||||
|
type="button"
|
||||||
|
selected={definitionLang === lang.code}
|
||||||
|
onClick={() => setDefinitionLang(lang.code)}
|
||||||
|
className="text-sm px-3 py-1"
|
||||||
|
>
|
||||||
|
{lang.nativeName}
|
||||||
|
</LightButton>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
src/app/(features)/dictionary/SearchResult.client.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Plus, RefreshCw } from "lucide-react";
|
||||||
|
import { CircleButton, LightButton } from "@/design-system/base/button";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { actionCreatePair } from "@/modules/folder/folder-aciton";
|
||||||
|
import { TSharedItem } from "@/shared/dictionary-type";
|
||||||
|
import { TSharedFolder } from "@/shared/folder-type";
|
||||||
|
import { actionLookUpDictionary } from "@/modules/dictionary/dictionary-action";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
type Session = {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
name?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
image?: string | null;
|
||||||
|
};
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
interface SaveButtonClientProps {
|
||||||
|
session: Session;
|
||||||
|
folders: TSharedFolder[];
|
||||||
|
searchResult: TSharedItem;
|
||||||
|
queryLang: string;
|
||||||
|
definitionLang: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SaveButtonClient({ session, folders, searchResult, queryLang, definitionLang }: SaveButtonClientProps) {
|
||||||
|
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;
|
||||||
|
|
||||||
|
const definition = searchResult.entries.reduce((p, e) => {
|
||||||
|
return { ...p, definition: p.definition + ' | ' + e.definition };
|
||||||
|
}).definition;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<CircleButton
|
||||||
|
onClick={handleSave}
|
||||||
|
className="w-10 h-10 shrink-0"
|
||||||
|
title="Save to folder"
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
</CircleButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReLookupButtonClientProps {
|
||||||
|
searchQuery: string;
|
||||||
|
queryLang: string;
|
||||||
|
definitionLang: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReLookupButtonClient({ searchQuery, queryLang, definitionLang }: ReLookupButtonClientProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleRelookup = async () => {
|
||||||
|
const getNativeName = (code: string): string => {
|
||||||
|
const popularLanguages: Record<string, string> = {
|
||||||
|
english: "English",
|
||||||
|
chinese: "中文",
|
||||||
|
japanese: "日本語",
|
||||||
|
korean: "한국어",
|
||||||
|
italian: "Italiano",
|
||||||
|
uyghur: "ئۇيغۇرچە",
|
||||||
|
};
|
||||||
|
return popularLanguages[code] || code;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await actionLookUpDictionary({
|
||||||
|
text: searchQuery,
|
||||||
|
queryLang: getNativeName(queryLang),
|
||||||
|
definitionLang: getNativeName(definitionLang),
|
||||||
|
forceRelook: true
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Re-lookup successful");
|
||||||
|
// 刷新页面以显示新结果
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Re-lookup failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LightButton
|
||||||
|
onClick={handleRelookup}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm"
|
||||||
|
leftIcon={<RefreshCw className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Re-lookup
|
||||||
|
</LightButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
src/app/(features)/dictionary/SearchResult.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { auth } from "@/auth";
|
||||||
|
import { DictionaryEntry } from "./DictionaryEntry";
|
||||||
|
import { TSharedItem } from "@/shared/dictionary-type";
|
||||||
|
import { SaveButtonClient, ReLookupButtonClient } from "./SearchResult.client";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { actionGetFoldersByUserId } from "@/modules/folder/folder-aciton";
|
||||||
|
import { TSharedFolder } from "@/shared/folder-type";
|
||||||
|
|
||||||
|
interface SearchResultProps {
|
||||||
|
searchResult: TSharedItem | null;
|
||||||
|
searchQuery: string;
|
||||||
|
queryLang: string;
|
||||||
|
definitionLang: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function SearchResult({
|
||||||
|
searchResult,
|
||||||
|
searchQuery,
|
||||||
|
queryLang,
|
||||||
|
definitionLang
|
||||||
|
}: SearchResultProps) {
|
||||||
|
// 获取用户会话和文件夹
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{!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>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
<SaveButtonClient
|
||||||
|
session={session}
|
||||||
|
folders={folders}
|
||||||
|
searchResult={searchResult}
|
||||||
|
queryLang={queryLang}
|
||||||
|
definitionLang={definitionLang}
|
||||||
|
/>
|
||||||
|
</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">
|
||||||
|
<ReLookupButtonClient
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
queryLang={queryLang}
|
||||||
|
definitionLang={definitionLang}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
src/app/(features)/dictionary/constants.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
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;
|
||||||
75
src/app/(features)/dictionary/page.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { PageLayout } from "@/components/ui/PageLayout";
|
||||||
|
import { SearchForm } from "./SearchForm";
|
||||||
|
import { SearchResult } from "./SearchResult";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { actionLookUpDictionary } from "@/modules/dictionary/dictionary-action";
|
||||||
|
import { TSharedItem } from "@/shared/dictionary-type";
|
||||||
|
|
||||||
|
interface DictionaryPageProps {
|
||||||
|
searchParams: Promise<{ q?: string; ql?: string; dl?: string; }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function DictionaryPage({ searchParams }: DictionaryPageProps) {
|
||||||
|
const t = await getTranslations("dictionary");
|
||||||
|
|
||||||
|
// 从 searchParams 获取搜索参数
|
||||||
|
const { q: searchQuery, ql: queryLang = "english", dl: definitionLang = "chinese" } = await searchParams;
|
||||||
|
|
||||||
|
// 如果有搜索查询,获取搜索结果
|
||||||
|
let searchResult: TSharedItem | undefined | null = null;
|
||||||
|
if (searchQuery) {
|
||||||
|
const getNativeName = (code: string): string => {
|
||||||
|
const popularLanguages: Record<string, string> = {
|
||||||
|
english: "English",
|
||||||
|
chinese: "中文",
|
||||||
|
japanese: "日本語",
|
||||||
|
korean: "한국어",
|
||||||
|
italian: "Italiano",
|
||||||
|
uyghur: "ئۇيغۇرچە",
|
||||||
|
};
|
||||||
|
return popularLanguages[code] || code;
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await actionLookUpDictionary({
|
||||||
|
text: searchQuery,
|
||||||
|
queryLang: getNativeName(queryLang),
|
||||||
|
definitionLang: getNativeName(definitionLang),
|
||||||
|
forceRelook: false
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
searchResult = result.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
{/* 搜索区域 */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<SearchForm
|
||||||
|
defaultQueryLang={queryLang}
|
||||||
|
defaultDefinitionLang={definitionLang}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 搜索结果区域 */}
|
||||||
|
<div>
|
||||||
|
{searchQuery && (
|
||||||
|
<SearchResult
|
||||||
|
searchResult={searchResult}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
queryLang={queryLang}
|
||||||
|
definitionLang={definitionLang}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!searchQuery && (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
src/app/(features)/memorize/FolderSelector.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"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 };
|
||||||
195
src/app/(features)/memorize/Memorize.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
"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 };
|
||||||
37
src/app/(features)/memorize/page.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
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-aciton";
|
||||||
|
|
||||||
|
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("/auth?redirect=/memorize");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FolderSelector
|
||||||
|
folders={(await actionGetFoldersWithTotalPairsByUserId(session.user.id)).data!}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Memorize textPairs={(await actionGetPairsByFolderId(folder_id)).data!} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export function SubtitleDisplay({ subtitle }: { subtitle: string }) {
|
||||||
|
const words = subtitle.match(/\b[\w']+(?:-[\w']+)*\b/g) || [];
|
||||||
|
let i = 0;
|
||||||
|
return (
|
||||||
|
<div className="w-full subtitle overflow-auto h-16 mt-2 wrap-break-word bg-black/50 font-sans text-white text-center text-2xl">
|
||||||
|
{words.map((v) => (
|
||||||
|
<span
|
||||||
|
onClick={() => {
|
||||||
|
window.open(
|
||||||
|
`https://www.youdao.com/result?word=${v}&lang=en`,
|
||||||
|
"_blank",
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
key={i++}
|
||||||
|
className="hover:bg-gray-700 hover:underline hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
{v + " "}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
220
src/app/(features)/srt-player/VideoPlayer/VideoPanel.tsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import { useState, useRef, forwardRef, useEffect, useCallback } from "react";
|
||||||
|
import { SubtitleDisplay } from "./SubtitleDisplay";
|
||||||
|
import { LightButton } from "@/design-system/base/button";
|
||||||
|
import { RangeInput } from "@/components/ui/RangeInput";
|
||||||
|
import { getIndex, parseSrt, getNearistIndex } from "../subtitle";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
type VideoPanelProps = {
|
||||||
|
videoUrl: string | null;
|
||||||
|
srtUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>(
|
||||||
|
({ videoUrl, srtUrl }, videoRef) => {
|
||||||
|
const t = useTranslations("srt_player");
|
||||||
|
videoRef = videoRef as React.RefObject<HTMLVideoElement>;
|
||||||
|
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||||
|
const [srtLength, setSrtLength] = useState<number>(0);
|
||||||
|
const [progress, setProgress] = useState<number>(-1);
|
||||||
|
const [autoPause, setAutoPause] = useState<boolean>(true);
|
||||||
|
const [spanText, setSpanText] = useState<string>("");
|
||||||
|
const [subtitle, setSubtitle] = useState<string>("");
|
||||||
|
const parsedSrtRef = useRef<
|
||||||
|
{ start: number; end: number; text: string; }[] | null
|
||||||
|
>(null);
|
||||||
|
const rafldRef = useRef<number>(0);
|
||||||
|
const ready = useRef({
|
||||||
|
vid: false,
|
||||||
|
sub: false,
|
||||||
|
all: function () {
|
||||||
|
return this.vid && this.sub;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const togglePlayPause = useCallback(() => {
|
||||||
|
if (!videoUrl) return;
|
||||||
|
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
if (video.paused || video.currentTime === 0) {
|
||||||
|
video.play();
|
||||||
|
} else {
|
||||||
|
video.pause();
|
||||||
|
}
|
||||||
|
setIsPlaying(!video.paused);
|
||||||
|
}, [videoRef, videoUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDownEvent = (e: globalThis.KeyboardEvent) => {
|
||||||
|
if (e.key === "n") {
|
||||||
|
next();
|
||||||
|
} else if (e.key === "p") {
|
||||||
|
previous();
|
||||||
|
} else if (e.key === " ") {
|
||||||
|
togglePlayPause();
|
||||||
|
} else if (e.key === "r") {
|
||||||
|
restart();
|
||||||
|
} else if (e.key === "a") {
|
||||||
|
handleAutoPauseToggle();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDownEvent);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDownEvent);
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cb = () => {
|
||||||
|
if (ready.current.all()) {
|
||||||
|
if (!parsedSrtRef.current) {
|
||||||
|
} else if (isPlaying) {
|
||||||
|
// 这里负责显示当前时间的字幕与自动暂停
|
||||||
|
const srt = parsedSrtRef.current;
|
||||||
|
const ct = videoRef.current?.currentTime as number;
|
||||||
|
const index = getIndex(srt, ct);
|
||||||
|
if (index !== null) {
|
||||||
|
setSubtitle(srt[index].text);
|
||||||
|
if (
|
||||||
|
autoPause &&
|
||||||
|
ct >= srt[index].end - 0.05 &&
|
||||||
|
ct < srt[index].end
|
||||||
|
) {
|
||||||
|
videoRef.current!.currentTime = srt[index].start;
|
||||||
|
togglePlayPause();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSubtitle("");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rafldRef.current = requestAnimationFrame(cb);
|
||||||
|
};
|
||||||
|
rafldRef.current = requestAnimationFrame(cb);
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(rafldRef.current);
|
||||||
|
};
|
||||||
|
}, [autoPause, isPlaying, togglePlayPause, videoRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (videoUrl && videoRef.current) {
|
||||||
|
videoRef.current.src = videoUrl;
|
||||||
|
videoRef.current.load();
|
||||||
|
setIsPlaying(false);
|
||||||
|
ready.current["vid"] = true;
|
||||||
|
}
|
||||||
|
}, [videoRef, videoUrl]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (srtUrl) {
|
||||||
|
fetch(srtUrl)
|
||||||
|
.then((response) => response.text())
|
||||||
|
.then((data) => {
|
||||||
|
parsedSrtRef.current = parseSrt(data);
|
||||||
|
setSrtLength(parsedSrtRef.current.length);
|
||||||
|
ready.current["sub"] = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [srtUrl]);
|
||||||
|
|
||||||
|
const timeUpdate = () => {
|
||||||
|
if (!parsedSrtRef.current || !videoRef.current) return;
|
||||||
|
const index = getIndex(
|
||||||
|
parsedSrtRef.current,
|
||||||
|
videoRef.current.currentTime,
|
||||||
|
);
|
||||||
|
if (!index) return;
|
||||||
|
setSpanText(`${index + 1}/${parsedSrtRef.current.length}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (videoRef.current && parsedSrtRef.current) {
|
||||||
|
const newProgress = parseInt(e.target.value);
|
||||||
|
videoRef.current.currentTime =
|
||||||
|
parsedSrtRef.current[newProgress]?.start || 0;
|
||||||
|
setProgress(newProgress);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAutoPauseToggle = () => {
|
||||||
|
setAutoPause(!autoPause);
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = () => {
|
||||||
|
if (!parsedSrtRef.current || !videoRef.current) return;
|
||||||
|
const i = getNearistIndex(
|
||||||
|
parsedSrtRef.current,
|
||||||
|
videoRef.current.currentTime,
|
||||||
|
);
|
||||||
|
if (i != null && i + 1 < parsedSrtRef.current.length) {
|
||||||
|
videoRef.current.currentTime = parsedSrtRef.current[i + 1].start;
|
||||||
|
videoRef.current.play();
|
||||||
|
setIsPlaying(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const previous = () => {
|
||||||
|
if (!parsedSrtRef.current || !videoRef.current) return;
|
||||||
|
const i = getNearistIndex(
|
||||||
|
parsedSrtRef.current,
|
||||||
|
videoRef.current.currentTime,
|
||||||
|
);
|
||||||
|
if (i != null && i - 1 >= 0) {
|
||||||
|
videoRef.current.currentTime = parsedSrtRef.current[i - 1].start;
|
||||||
|
videoRef.current.play();
|
||||||
|
setIsPlaying(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const restart = () => {
|
||||||
|
if (!parsedSrtRef.current || !videoRef.current) return;
|
||||||
|
const i = getNearistIndex(
|
||||||
|
parsedSrtRef.current,
|
||||||
|
videoRef.current.currentTime,
|
||||||
|
);
|
||||||
|
if (i != null && i >= 0) {
|
||||||
|
videoRef.current.currentTime = parsedSrtRef.current[i].start;
|
||||||
|
videoRef.current.play();
|
||||||
|
setIsPlaying(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col">
|
||||||
|
<video
|
||||||
|
className="bg-gray-200"
|
||||||
|
ref={videoRef}
|
||||||
|
onTimeUpdate={timeUpdate}
|
||||||
|
></video>
|
||||||
|
<SubtitleDisplay subtitle={subtitle}></SubtitleDisplay>
|
||||||
|
<div className="buttons flex mt-2 gap-2 flex-wrap">
|
||||||
|
<LightButton onClick={togglePlayPause}>
|
||||||
|
{isPlaying ? t("pause") : t("play")}
|
||||||
|
</LightButton>
|
||||||
|
<LightButton onClick={previous}>{t("previous")}</LightButton>
|
||||||
|
<LightButton onClick={next}>{t("next")}</LightButton>
|
||||||
|
<LightButton onClick={restart}>{t("restart")}</LightButton>
|
||||||
|
<LightButton onClick={handleAutoPauseToggle}>
|
||||||
|
{t("autoPause", { enabled: autoPause ? "Yes" : "No" })}
|
||||||
|
</LightButton>
|
||||||
|
</div>
|
||||||
|
<RangeInput
|
||||||
|
className="seekbar"
|
||||||
|
min={0}
|
||||||
|
max={srtLength}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (videoRef.current && parsedSrtRef.current) {
|
||||||
|
videoRef.current.currentTime = parsedSrtRef.current[value]?.start || 0;
|
||||||
|
setProgress(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
value={progress}
|
||||||
|
/>
|
||||||
|
<span>{spanText}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
VideoPanel.displayName = "VideoPanel";
|
||||||
|
|
||||||
|
export { VideoPanel };
|
||||||
48
src/app/(features)/srt-player/components/atoms/FileInput.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useRef } from "react";
|
||||||
|
import { Button } from "@/design-system/base/button";
|
||||||
|
import { FileInputProps } from "../../types/controls";
|
||||||
|
|
||||||
|
interface FileInputComponentProps extends FileInputProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileInput({ accept, onFileSelect, disabled, className, children }: FileInputComponentProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleClick = React.useCallback(() => {
|
||||||
|
if (!disabled && inputRef.current) {
|
||||||
|
inputRef.current.click();
|
||||||
|
}
|
||||||
|
}, [disabled]);
|
||||||
|
|
||||||
|
const handleChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
onFileSelect(file);
|
||||||
|
}
|
||||||
|
}, [onFileSelect]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={disabled}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={disabled}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { LightButton } from "@/design-system/base/button";
|
||||||
|
import { PlayButtonProps } from "../../types/player";
|
||||||
|
|
||||||
|
export function PlayButton({ isPlaying, onToggle, disabled, className }: PlayButtonProps) {
|
||||||
|
const t = useTranslations("srt_player");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LightButton
|
||||||
|
onClick={disabled ? undefined : onToggle}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`px-4 py-2 ${className || ''}`}
|
||||||
|
>
|
||||||
|
{isPlaying ? t("pause") : t("play")}
|
||||||
|
</LightButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/app/(features)/srt-player/components/atoms/SeekBar.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { SeekBarProps } from "../../types/player";
|
||||||
|
import { RangeInput } from "@/components/ui/RangeInput";
|
||||||
|
|
||||||
|
export function SeekBar({ value, max, onChange, disabled, className }: SeekBarProps) {
|
||||||
|
return (
|
||||||
|
<RangeInput
|
||||||
|
value={value}
|
||||||
|
max={max}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={disabled}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { LightButton } from "@/design-system/base/button";
|
||||||
|
import { SpeedControlProps } from "../../types/player";
|
||||||
|
import { getPlaybackRateOptions, getPlaybackRateLabel } from "../../utils/timeUtils";
|
||||||
|
|
||||||
|
export function SpeedControl({ playbackRate, onPlaybackRateChange, disabled, className }: SpeedControlProps) {
|
||||||
|
const speedOptions = getPlaybackRateOptions();
|
||||||
|
|
||||||
|
const handleSpeedChange = React.useCallback(() => {
|
||||||
|
const currentIndex = speedOptions.indexOf(playbackRate);
|
||||||
|
const nextIndex = (currentIndex + 1) % speedOptions.length;
|
||||||
|
onPlaybackRateChange(speedOptions[nextIndex]);
|
||||||
|
}, [playbackRate, onPlaybackRateChange, speedOptions]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LightButton
|
||||||
|
onClick={disabled ? undefined : handleSpeedChange}
|
||||||
|
className={`${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className || ''}`}
|
||||||
|
>
|
||||||
|
{getPlaybackRateLabel(playbackRate)}
|
||||||
|
</LightButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { SubtitleTextProps } from "../../types/subtitle";
|
||||||
|
|
||||||
|
export function SubtitleText({ text, onWordClick, style, className }: SubtitleTextProps) {
|
||||||
|
const handleWordClick = React.useCallback((word: string) => {
|
||||||
|
onWordClick?.(word);
|
||||||
|
}, [onWordClick]);
|
||||||
|
|
||||||
|
// 将文本分割成单词,保持标点符号
|
||||||
|
const renderTextWithClickableWords = () => {
|
||||||
|
if (!text) return null;
|
||||||
|
|
||||||
|
// 匹配单词和标点符号
|
||||||
|
const parts = text.match(/[\w']+|[^\w\s]+|\s+/g) || [];
|
||||||
|
|
||||||
|
return parts.map((part, index) => {
|
||||||
|
// 如果是单词(字母和撇号组成)
|
||||||
|
if (/^[\w']+$/.test(part)) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
onClick={() => handleWordClick(part)}
|
||||||
|
className="hover:bg-gray-700 hover:underline hover:cursor-pointer rounded px-1 transition-colors"
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 如果是空格或其他字符,直接渲染
|
||||||
|
return <span key={index}>{part}</span>;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`overflow-auto h-16 mt-2 wrap-break-words font-sans text-white text-center text-2xl ${className || ''}`}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{renderTextWithClickableWords()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { forwardRef } from "react";
|
||||||
|
import { VideoElementProps } from "../../types/player";
|
||||||
|
|
||||||
|
const VideoElement = forwardRef<HTMLVideoElement, VideoElementProps>(
|
||||||
|
({ src, onTimeUpdate, onLoadedMetadata, onPlay, onPause, onEnded, className }, ref) => {
|
||||||
|
const handleTimeUpdate = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||||
|
const video = event.currentTarget;
|
||||||
|
onTimeUpdate?.(video.currentTime);
|
||||||
|
}, [onTimeUpdate]);
|
||||||
|
|
||||||
|
const handleLoadedMetadata = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||||
|
const video = event.currentTarget;
|
||||||
|
onLoadedMetadata?.(video.duration);
|
||||||
|
}, [onLoadedMetadata]);
|
||||||
|
|
||||||
|
const handlePlay = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||||
|
onPlay?.();
|
||||||
|
}, [onPlay]);
|
||||||
|
|
||||||
|
const handlePause = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||||
|
onPause?.();
|
||||||
|
}, [onPause]);
|
||||||
|
|
||||||
|
const handleEnded = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||||
|
onEnded?.();
|
||||||
|
}, [onEnded]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
ref={ref}
|
||||||
|
src={src}
|
||||||
|
onTimeUpdate={handleTimeUpdate}
|
||||||
|
onLoadedMetadata={handleLoadedMetadata}
|
||||||
|
onPlay={handlePlay}
|
||||||
|
onPause={handlePause}
|
||||||
|
onEnded={handleEnded}
|
||||||
|
className={`bg-gray-200 w-full ${className || ""}`}
|
||||||
|
playsInline
|
||||||
|
controls={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
VideoElement.displayName = "VideoElement";
|
||||||
|
|
||||||
|
export { VideoElement };
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { ChevronLeft, ChevronRight, RotateCcw, Pause } from "lucide-react";
|
||||||
|
import { LightButton } from "@/design-system/base/button";
|
||||||
|
import { ControlBarProps } from "../../types/controls";
|
||||||
|
import { PlayButton } from "../atoms/PlayButton";
|
||||||
|
import { SpeedControl } from "../atoms/SpeedControl";
|
||||||
|
|
||||||
|
export function ControlBar({
|
||||||
|
isPlaying,
|
||||||
|
onPlayPause,
|
||||||
|
onPrevious,
|
||||||
|
onNext,
|
||||||
|
onRestart,
|
||||||
|
playbackRate,
|
||||||
|
onPlaybackRateChange,
|
||||||
|
autoPause,
|
||||||
|
onAutoPauseToggle,
|
||||||
|
disabled,
|
||||||
|
className
|
||||||
|
}: ControlBarProps) {
|
||||||
|
const t = useTranslations("srt_player");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-wrap gap-2 justify-center ${className || ''}`}>
|
||||||
|
<PlayButton
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
onToggle={onPlayPause}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LightButton
|
||||||
|
onClick={disabled ? undefined : onPrevious}
|
||||||
|
disabled={disabled}
|
||||||
|
className="flex items-center px-3 py-2"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4 mr-2" />
|
||||||
|
{t("previous")}
|
||||||
|
</LightButton>
|
||||||
|
|
||||||
|
<LightButton
|
||||||
|
onClick={disabled ? undefined : onNext}
|
||||||
|
disabled={disabled}
|
||||||
|
className="flex items-center px-3 py-2"
|
||||||
|
>
|
||||||
|
{t("next")}
|
||||||
|
<ChevronRight className="w-4 h-4 ml-2" />
|
||||||
|
</LightButton>
|
||||||
|
|
||||||
|
<LightButton
|
||||||
|
onClick={disabled ? undefined : onRestart}
|
||||||
|
disabled={disabled}
|
||||||
|
className="flex items-center px-3 py-2"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4 mr-2" />
|
||||||
|
{t("restart")}
|
||||||
|
</LightButton>
|
||||||
|
|
||||||
|
<SpeedControl
|
||||||
|
playbackRate={playbackRate}
|
||||||
|
onPlaybackRateChange={onPlaybackRateChange}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LightButton
|
||||||
|
onClick={disabled ? undefined : onAutoPauseToggle}
|
||||||
|
disabled={disabled}
|
||||||
|
className="flex items-center px-3 py-2"
|
||||||
|
>
|
||||||
|
<Pause className="w-4 h-4 mr-2" />
|
||||||
|
{t("autoPause", { enabled: autoPause ? t("on") : t("off") })}
|
||||||
|
</LightButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { SubtitleDisplayProps } from "../../types/subtitle";
|
||||||
|
import { SubtitleText } from "../atoms/SubtitleText";
|
||||||
|
|
||||||
|
export function SubtitleArea({ subtitle, onWordClick, settings, className }: SubtitleDisplayProps) {
|
||||||
|
const handleWordClick = React.useCallback((word: string) => {
|
||||||
|
// 打开有道词典页面查询单词
|
||||||
|
window.open(
|
||||||
|
`https://www.youdao.com/result?word=${encodeURIComponent(word)}&lang=en`,
|
||||||
|
"_blank"
|
||||||
|
);
|
||||||
|
onWordClick?.(word);
|
||||||
|
}, [onWordClick]);
|
||||||
|
|
||||||
|
const subtitleStyle = React.useMemo(() => {
|
||||||
|
if (!settings) return { backgroundColor: 'rgba(0, 0, 0, 0.5)' };
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor: settings.backgroundColor,
|
||||||
|
color: settings.textColor,
|
||||||
|
fontSize: `${settings.fontSize}px`,
|
||||||
|
fontFamily: settings.fontFamily,
|
||||||
|
opacity: settings.opacity,
|
||||||
|
};
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SubtitleText
|
||||||
|
text={subtitle}
|
||||||
|
onWordClick={handleWordClick}
|
||||||
|
style={subtitleStyle}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Video, FileText } from "lucide-react";
|
||||||
|
import { LightButton } from "@/design-system/base/button";
|
||||||
|
import { FileUploadProps } from "../../types/controls";
|
||||||
|
import { useFileUpload } from "../../hooks/useFileUpload";
|
||||||
|
|
||||||
|
export function UploadZone({ onVideoUpload, onSubtitleUpload, className }: FileUploadProps) {
|
||||||
|
const t = useTranslations("srt_player");
|
||||||
|
const { uploadVideo, uploadSubtitle } = useFileUpload();
|
||||||
|
|
||||||
|
const handleVideoUpload = React.useCallback(() => {
|
||||||
|
uploadVideo(onVideoUpload, (error) => {
|
||||||
|
toast.error(t("videoUploadFailed") + ": " + error.message);
|
||||||
|
});
|
||||||
|
}, [uploadVideo, onVideoUpload, t]);
|
||||||
|
|
||||||
|
const handleSubtitleUpload = React.useCallback(() => {
|
||||||
|
uploadSubtitle(onSubtitleUpload, (error) => {
|
||||||
|
toast.error(t("subtitleUploadFailed") + ": " + error.message);
|
||||||
|
});
|
||||||
|
}, [uploadSubtitle, onSubtitleUpload, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex gap-3 ${className || ''}`}>
|
||||||
|
<LightButton
|
||||||
|
onClick={handleVideoUpload}
|
||||||
|
className="flex-1 py-2 px-3 text-sm"
|
||||||
|
>
|
||||||
|
<Video className="w-4 h-4 mr-2" />
|
||||||
|
{t("uploadVideo")}
|
||||||
|
</LightButton>
|
||||||
|
|
||||||
|
<LightButton
|
||||||
|
onClick={handleSubtitleUpload}
|
||||||
|
className="flex-1 py-2 px-3 text-sm"
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4 mr-2" />
|
||||||
|
{t("uploadSubtitle")}
|
||||||
|
</LightButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { forwardRef } from "react";
|
||||||
|
import { VideoElementProps } from "../../types/player";
|
||||||
|
import { VideoElement } from "../atoms/VideoElement";
|
||||||
|
|
||||||
|
interface VideoPlayerComponentProps extends VideoElementProps {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerComponentProps>(
|
||||||
|
({
|
||||||
|
src,
|
||||||
|
onTimeUpdate,
|
||||||
|
onLoadedMetadata,
|
||||||
|
onPlay,
|
||||||
|
onPause,
|
||||||
|
onEnded,
|
||||||
|
className,
|
||||||
|
children
|
||||||
|
}, ref) => {
|
||||||
|
return (
|
||||||
|
<div className={`w-full flex flex-col ${className || ''}`}>
|
||||||
|
<VideoElement
|
||||||
|
ref={ref}
|
||||||
|
src={src}
|
||||||
|
onTimeUpdate={onTimeUpdate}
|
||||||
|
onLoadedMetadata={onLoadedMetadata}
|
||||||
|
onPlay={onPlay}
|
||||||
|
onPause={onPause}
|
||||||
|
onEnded={onEnded}
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
VideoPlayer.displayName = "VideoPlayer";
|
||||||
|
|
||||||
|
export { VideoPlayer };
|
||||||
85
src/app/(features)/srt-player/hooks/useFileUpload.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
|
export function useFileUpload() {
|
||||||
|
const uploadFile = useCallback((
|
||||||
|
file: File,
|
||||||
|
onSuccess: (url: string) => void,
|
||||||
|
onError?: (error: Error) => void
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
// 验证文件大小(限制为100MB)
|
||||||
|
const maxSize = 100 * 1024 * 1024; // 100MB
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
throw new Error(`文件大小超过限制 (${(file.size / 1024 / 1024).toFixed(2)}MB > 100MB)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
onSuccess(url);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : '文件上传失败';
|
||||||
|
onError?.(new Error(errorMessage));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const uploadVideo = useCallback((
|
||||||
|
onVideoUpload: (url: string) => void,
|
||||||
|
onError?: (error: Error) => void
|
||||||
|
) => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = 'video/*';
|
||||||
|
|
||||||
|
input.onchange = (e) => {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
|
if (file) {
|
||||||
|
// 验证文件类型
|
||||||
|
if (!file.type.startsWith('video/')) {
|
||||||
|
onError?.(new Error('请选择有效的视频文件'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uploadFile(file, onVideoUpload, onError);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
input.onerror = () => {
|
||||||
|
onError?.(new Error('文件选择失败'));
|
||||||
|
};
|
||||||
|
|
||||||
|
input.click();
|
||||||
|
}, [uploadFile]);
|
||||||
|
|
||||||
|
const uploadSubtitle = useCallback((
|
||||||
|
onSubtitleUpload: (url: string) => void,
|
||||||
|
onError?: (error: Error) => void
|
||||||
|
) => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = '.srt';
|
||||||
|
|
||||||
|
input.onchange = (e) => {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
|
if (file) {
|
||||||
|
// 验证文件扩展名
|
||||||
|
if (!file.name.toLowerCase().endsWith('.srt')) {
|
||||||
|
onError?.(new Error('请选择.srt格式的字幕文件'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uploadFile(file, onSubtitleUpload, onError);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
input.onerror = () => {
|
||||||
|
onError?.(new Error('文件选择失败'));
|
||||||
|
};
|
||||||
|
|
||||||
|
input.click();
|
||||||
|
}, [uploadFile]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
uploadVideo,
|
||||||
|
uploadSubtitle,
|
||||||
|
uploadFile,
|
||||||
|
};
|
||||||
|
}
|
||||||
68
src/app/(features)/srt-player/hooks/useKeyboardShortcuts.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect } from "react";
|
||||||
|
import { KeyboardShortcut } from "../types/controls";
|
||||||
|
|
||||||
|
export function useKeyboardShortcuts(
|
||||||
|
shortcuts: KeyboardShortcut[],
|
||||||
|
enabled: boolean = true
|
||||||
|
) {
|
||||||
|
const handleKeyDown = useCallback((event: globalThis.KeyboardEvent) => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
// 防止在输入框中触发快捷键
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortcut = shortcuts.find(s => s.key === event.key);
|
||||||
|
if (shortcut) {
|
||||||
|
event.preventDefault();
|
||||||
|
shortcut.action();
|
||||||
|
}
|
||||||
|
}, [shortcuts, enabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [handleKeyDown]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSrtPlayerShortcuts(
|
||||||
|
playPause: () => void,
|
||||||
|
next: () => void,
|
||||||
|
previous: () => void,
|
||||||
|
restart: () => void,
|
||||||
|
toggleAutoPause: () => void
|
||||||
|
): KeyboardShortcut[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: ' ',
|
||||||
|
description: '播放/暂停',
|
||||||
|
action: playPause,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'n',
|
||||||
|
description: '下一句',
|
||||||
|
action: next,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'p',
|
||||||
|
description: '上一句',
|
||||||
|
action: previous,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'r',
|
||||||
|
description: '句首',
|
||||||
|
action: restart,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'a',
|
||||||
|
description: '切换自动暂停',
|
||||||
|
action: toggleAutoPause,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
306
src/app/(features)/srt-player/hooks/useSrtPlayer.ts
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useReducer, useCallback, useRef, useEffect } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { VideoState, VideoControls } from "../types/player";
|
||||||
|
import { SubtitleState, SubtitleEntry } from "../types/subtitle";
|
||||||
|
import { ControlState, ControlActions } from "../types/controls";
|
||||||
|
|
||||||
|
export interface SrtPlayerState {
|
||||||
|
video: VideoState;
|
||||||
|
subtitle: SubtitleState;
|
||||||
|
controls: ControlState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SrtPlayerActions extends VideoControls, ControlActions {
|
||||||
|
setVideoUrl: (url: string | null) => void;
|
||||||
|
setSubtitleUrl: (url: string | null) => void;
|
||||||
|
nextSubtitle: () => void;
|
||||||
|
previousSubtitle: () => void;
|
||||||
|
restartSubtitle: () => void;
|
||||||
|
setSubtitleSettings: (settings: Partial<SubtitleState['settings']>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: SrtPlayerState = {
|
||||||
|
video: {
|
||||||
|
url: null,
|
||||||
|
isPlaying: false,
|
||||||
|
currentTime: 0,
|
||||||
|
duration: 0,
|
||||||
|
playbackRate: 1.0,
|
||||||
|
volume: 1.0,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
url: null,
|
||||||
|
data: [],
|
||||||
|
currentText: "",
|
||||||
|
currentIndex: null,
|
||||||
|
settings: {
|
||||||
|
fontSize: 24,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
textColor: "#ffffff",
|
||||||
|
position: "bottom",
|
||||||
|
fontFamily: "sans-serif",
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
controls: {
|
||||||
|
autoPause: true,
|
||||||
|
showShortcuts: false,
|
||||||
|
showSettings: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type SrtPlayerAction =
|
||||||
|
| { type: "SET_VIDEO_URL"; payload: string | null }
|
||||||
|
| { type: "SET_PLAYING"; payload: boolean }
|
||||||
|
| { type: "SET_CURRENT_TIME"; payload: number }
|
||||||
|
| { type: "SET_DURATION"; payload: number }
|
||||||
|
| { type: "SET_PLAYBACK_RATE"; payload: number }
|
||||||
|
| { type: "SET_VOLUME"; payload: number }
|
||||||
|
| { type: "SET_SUBTITLE_URL"; payload: string | null }
|
||||||
|
| { type: "SET_SUBTITLE_DATA"; payload: SubtitleEntry[] }
|
||||||
|
| { type: "SET_CURRENT_SUBTITLE"; payload: { text: string; index: number | null } }
|
||||||
|
| { type: "SET_SUBTITLE_SETTINGS"; payload: Partial<SubtitleState['settings']> }
|
||||||
|
| { type: "TOGGLE_AUTO_PAUSE" }
|
||||||
|
| { type: "TOGGLE_SHORTCUTS" }
|
||||||
|
| { type: "TOGGLE_SETTINGS" };
|
||||||
|
|
||||||
|
function srtPlayerReducer(state: SrtPlayerState, action: SrtPlayerAction): SrtPlayerState {
|
||||||
|
switch (action.type) {
|
||||||
|
case "SET_VIDEO_URL":
|
||||||
|
return { ...state, video: { ...state.video, url: action.payload } };
|
||||||
|
case "SET_PLAYING":
|
||||||
|
return { ...state, video: { ...state.video, isPlaying: action.payload } };
|
||||||
|
case "SET_CURRENT_TIME":
|
||||||
|
return { ...state, video: { ...state.video, currentTime: action.payload } };
|
||||||
|
case "SET_DURATION":
|
||||||
|
return { ...state, video: { ...state.video, duration: action.payload } };
|
||||||
|
case "SET_PLAYBACK_RATE":
|
||||||
|
return { ...state, video: { ...state.video, playbackRate: action.payload } };
|
||||||
|
case "SET_VOLUME":
|
||||||
|
return { ...state, video: { ...state.video, volume: action.payload } };
|
||||||
|
case "SET_SUBTITLE_URL":
|
||||||
|
return { ...state, subtitle: { ...state.subtitle, url: action.payload } };
|
||||||
|
case "SET_SUBTITLE_DATA":
|
||||||
|
return { ...state, subtitle: { ...state.subtitle, data: action.payload } };
|
||||||
|
case "SET_CURRENT_SUBTITLE":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
subtitle: {
|
||||||
|
...state.subtitle,
|
||||||
|
currentText: action.payload.text,
|
||||||
|
currentIndex: action.payload.index,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "SET_SUBTITLE_SETTINGS":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
subtitle: {
|
||||||
|
...state.subtitle,
|
||||||
|
settings: { ...state.subtitle.settings, ...action.payload },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "TOGGLE_AUTO_PAUSE":
|
||||||
|
return { ...state, controls: { ...state.controls, autoPause: !state.controls.autoPause } };
|
||||||
|
case "TOGGLE_SHORTCUTS":
|
||||||
|
return { ...state, controls: { ...state.controls, showShortcuts: !state.controls.showShortcuts } };
|
||||||
|
case "TOGGLE_SETTINGS":
|
||||||
|
return { ...state, controls: { ...state.controls, showSettings: !state.controls.showSettings } };
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSrtPlayer() {
|
||||||
|
const [state, dispatch] = useReducer(srtPlayerReducer, initialState);
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
|
||||||
|
// Video controls
|
||||||
|
const play = useCallback(() => {
|
||||||
|
// 检查是否同时有视频和字幕
|
||||||
|
if (!state.video.url || !state.subtitle.url || state.subtitle.data.length === 0) {
|
||||||
|
toast.error("请先上传视频和字幕文件");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.play().catch(error => {
|
||||||
|
toast.error("视频播放失败: " + error.message);
|
||||||
|
});
|
||||||
|
dispatch({ type: "SET_PLAYING", payload: true });
|
||||||
|
}
|
||||||
|
}, [state.video.url, state.subtitle.url, state.subtitle.data.length, dispatch]);
|
||||||
|
|
||||||
|
const pause = useCallback(() => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.pause();
|
||||||
|
dispatch({ type: "SET_PLAYING", payload: false });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const togglePlayPause = useCallback(() => {
|
||||||
|
if (state.video.isPlaying) {
|
||||||
|
pause();
|
||||||
|
} else {
|
||||||
|
play();
|
||||||
|
}
|
||||||
|
}, [state.video.isPlaying, play, pause]);
|
||||||
|
|
||||||
|
const seek = useCallback((time: number) => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.currentTime = time;
|
||||||
|
dispatch({ type: "SET_CURRENT_TIME", payload: time });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setPlaybackRate = useCallback((rate: number) => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.playbackRate = rate;
|
||||||
|
dispatch({ type: "SET_PLAYBACK_RATE", payload: rate });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setVolume = useCallback((volume: number) => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.volume = volume;
|
||||||
|
dispatch({ type: "SET_VOLUME", payload: volume });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const restart = useCallback(() => {
|
||||||
|
if (videoRef.current && state.subtitle.currentIndex !== null) {
|
||||||
|
const currentSubtitle = state.subtitle.data[state.subtitle.currentIndex];
|
||||||
|
if (currentSubtitle) {
|
||||||
|
seek(currentSubtitle.start);
|
||||||
|
play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [state.subtitle.currentIndex, state.subtitle.data, seek, play]);
|
||||||
|
|
||||||
|
// URL setters
|
||||||
|
const setVideoUrl = useCallback((url: string | null) => {
|
||||||
|
dispatch({ type: "SET_VIDEO_URL", payload: url });
|
||||||
|
if (url && videoRef.current) {
|
||||||
|
videoRef.current.src = url;
|
||||||
|
videoRef.current.load();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setSubtitleUrl = useCallback((url: string | null) => {
|
||||||
|
dispatch({ type: "SET_SUBTITLE_URL", payload: url });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Subtitle controls
|
||||||
|
const nextSubtitle = useCallback(() => {
|
||||||
|
if (state.subtitle.currentIndex !== null &&
|
||||||
|
state.subtitle.currentIndex + 1 < state.subtitle.data.length) {
|
||||||
|
const nextIndex = state.subtitle.currentIndex + 1;
|
||||||
|
const nextSubtitle = state.subtitle.data[nextIndex];
|
||||||
|
seek(nextSubtitle.start);
|
||||||
|
play();
|
||||||
|
}
|
||||||
|
}, [state.subtitle.currentIndex, state.subtitle.data, seek, play]);
|
||||||
|
|
||||||
|
const previousSubtitle = useCallback(() => {
|
||||||
|
if (state.subtitle.currentIndex !== null && state.subtitle.currentIndex > 0) {
|
||||||
|
const prevIndex = state.subtitle.currentIndex - 1;
|
||||||
|
const prevSubtitle = state.subtitle.data[prevIndex];
|
||||||
|
seek(prevSubtitle.start);
|
||||||
|
play();
|
||||||
|
}
|
||||||
|
}, [state.subtitle.currentIndex, state.subtitle.data, seek, play]);
|
||||||
|
|
||||||
|
const restartSubtitle = useCallback(() => {
|
||||||
|
if (state.subtitle.currentIndex !== null) {
|
||||||
|
const currentSubtitle = state.subtitle.data[state.subtitle.currentIndex];
|
||||||
|
seek(currentSubtitle.start);
|
||||||
|
play();
|
||||||
|
}
|
||||||
|
}, [state.subtitle.currentIndex, state.subtitle.data, seek, play]);
|
||||||
|
|
||||||
|
const setSubtitleSettings = useCallback((settings: Partial<SubtitleState['settings']>) => {
|
||||||
|
dispatch({ type: "SET_SUBTITLE_SETTINGS", payload: settings });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Control actions
|
||||||
|
const toggleAutoPause = useCallback(() => {
|
||||||
|
dispatch({ type: "TOGGLE_AUTO_PAUSE" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleShortcuts = useCallback(() => {
|
||||||
|
dispatch({ type: "TOGGLE_SHORTCUTS" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleSettings = useCallback(() => {
|
||||||
|
dispatch({ type: "TOGGLE_SETTINGS" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Video event handlers
|
||||||
|
const handleTimeUpdate = useCallback(() => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
dispatch({ type: "SET_CURRENT_TIME", payload: videoRef.current.currentTime });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLoadedMetadata = useCallback(() => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
dispatch({ type: "SET_DURATION", payload: videoRef.current.duration });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePlay = useCallback(() => {
|
||||||
|
dispatch({ type: "SET_PLAYING", payload: true });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePause = useCallback(() => {
|
||||||
|
dispatch({ type: "SET_PLAYING", payload: false });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Set subtitle data
|
||||||
|
const setSubtitleData = useCallback((data: SubtitleEntry[]) => {
|
||||||
|
dispatch({ type: "SET_SUBTITLE_DATA", payload: data });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Set current subtitle
|
||||||
|
const setCurrentSubtitle = useCallback((text: string, index: number | null) => {
|
||||||
|
dispatch({ type: "SET_CURRENT_SUBTITLE", payload: { text, index } });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const actions: SrtPlayerActions = {
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
togglePlayPause,
|
||||||
|
seek,
|
||||||
|
setPlaybackRate,
|
||||||
|
setVolume,
|
||||||
|
restart,
|
||||||
|
setVideoUrl,
|
||||||
|
setSubtitleUrl,
|
||||||
|
nextSubtitle,
|
||||||
|
previousSubtitle,
|
||||||
|
restartSubtitle,
|
||||||
|
setSubtitleSettings,
|
||||||
|
toggleAutoPause,
|
||||||
|
toggleShortcuts,
|
||||||
|
toggleSettings,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
actions,
|
||||||
|
videoRef,
|
||||||
|
videoEventHandlers: {
|
||||||
|
onTimeUpdate: handleTimeUpdate,
|
||||||
|
onLoadedMetadata: handleLoadedMetadata,
|
||||||
|
onPlay: handlePlay,
|
||||||
|
onPause: handlePause,
|
||||||
|
},
|
||||||
|
subtitleActions: {
|
||||||
|
setSubtitleData,
|
||||||
|
setCurrentSubtitle,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseSrtPlayerReturn = ReturnType<typeof useSrtPlayer>;
|
||||||
110
src/app/(features)/srt-player/hooks/useSubtitleSync.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
import { SubtitleEntry } from "../types/subtitle";
|
||||||
|
|
||||||
|
export function useSubtitleSync(
|
||||||
|
subtitles: SubtitleEntry[],
|
||||||
|
currentTime: number,
|
||||||
|
isPlaying: boolean,
|
||||||
|
autoPause: boolean,
|
||||||
|
onSubtitleChange: (subtitle: SubtitleEntry | null) => void,
|
||||||
|
onAutoPauseTrigger?: (subtitle: SubtitleEntry) => void
|
||||||
|
) {
|
||||||
|
const lastSubtitleRef = useRef<SubtitleEntry | null>(null);
|
||||||
|
const rafIdRef = useRef<number>(0);
|
||||||
|
|
||||||
|
// 获取当前时间对应的字幕
|
||||||
|
const getCurrentSubtitle = useCallback((time: number): SubtitleEntry | null => {
|
||||||
|
return subtitles.find(subtitle => time >= subtitle.start && time <= subtitle.end) || null;
|
||||||
|
}, [subtitles]);
|
||||||
|
|
||||||
|
// 获取最近的字幕索引
|
||||||
|
const getNearestIndex = useCallback((time: number): number | null => {
|
||||||
|
if (subtitles.length === 0) return null;
|
||||||
|
|
||||||
|
// 如果时间早于第一个字幕开始时间
|
||||||
|
if (time < subtitles[0].start) return null;
|
||||||
|
|
||||||
|
// 如果时间晚于最后一个字幕结束时间
|
||||||
|
if (time > subtitles[subtitles.length - 1].end) return subtitles.length - 1;
|
||||||
|
|
||||||
|
// 二分查找找到当前时间对应的字幕
|
||||||
|
let left = 0;
|
||||||
|
let right = subtitles.length - 1;
|
||||||
|
|
||||||
|
while (left <= right) {
|
||||||
|
const mid = Math.floor((left + right) / 2);
|
||||||
|
const subtitle = subtitles[mid];
|
||||||
|
|
||||||
|
if (time >= subtitle.start && time <= subtitle.end) {
|
||||||
|
return mid;
|
||||||
|
} else if (time < subtitle.start) {
|
||||||
|
right = mid - 1;
|
||||||
|
} else {
|
||||||
|
left = mid + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有找到完全匹配的字幕,返回最近的字幕索引
|
||||||
|
return right >= 0 ? right : null;
|
||||||
|
}, [subtitles]);
|
||||||
|
|
||||||
|
// 检查是否需要自动暂停
|
||||||
|
const shouldAutoPause = useCallback((subtitle: SubtitleEntry, time: number): boolean => {
|
||||||
|
return autoPause &&
|
||||||
|
time >= subtitle.end - 0.2 && // 增加时间窗口,确保自动暂停更可靠
|
||||||
|
time < subtitle.end;
|
||||||
|
}, [autoPause]);
|
||||||
|
|
||||||
|
// 启动/停止同步循环
|
||||||
|
useEffect(() => {
|
||||||
|
const syncSubtitles = () => {
|
||||||
|
const currentSubtitle = getCurrentSubtitle(currentTime);
|
||||||
|
|
||||||
|
// 检查字幕是否发生变化
|
||||||
|
if (currentSubtitle !== lastSubtitleRef.current) {
|
||||||
|
const previousSubtitle = lastSubtitleRef.current;
|
||||||
|
lastSubtitleRef.current = currentSubtitle;
|
||||||
|
|
||||||
|
// 只有当有当前字幕时才调用onSubtitleChange
|
||||||
|
// 在字幕间隙时保持之前的字幕索引,避免进度条跳到0
|
||||||
|
if (currentSubtitle) {
|
||||||
|
onSubtitleChange(currentSubtitle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否需要自动暂停
|
||||||
|
// 每次都检查,不只在字幕变化时检查
|
||||||
|
if (currentSubtitle && shouldAutoPause(currentSubtitle, currentTime)) {
|
||||||
|
onAutoPauseTrigger?.(currentSubtitle);
|
||||||
|
} else if (!currentSubtitle && lastSubtitleRef.current && shouldAutoPause(lastSubtitleRef.current, currentTime)) {
|
||||||
|
// 在字幕结束时,如果前一个字幕需要自动暂停,也要触发
|
||||||
|
onAutoPauseTrigger?.(lastSubtitleRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
rafIdRef.current = requestAnimationFrame(syncSubtitles);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (subtitles.length > 0) {
|
||||||
|
rafIdRef.current = requestAnimationFrame(syncSubtitles);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (rafIdRef.current) {
|
||||||
|
cancelAnimationFrame(rafIdRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [subtitles.length, currentTime, getCurrentSubtitle, onSubtitleChange, shouldAutoPause, onAutoPauseTrigger]);
|
||||||
|
|
||||||
|
// 重置最后字幕引用
|
||||||
|
useEffect(() => {
|
||||||
|
lastSubtitleRef.current = null;
|
||||||
|
}, [subtitles]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
getCurrentSubtitle,
|
||||||
|
getNearestIndex,
|
||||||
|
shouldAutoPause,
|
||||||
|
};
|
||||||
|
}
|
||||||
274
src/app/(features)/srt-player/page.tsx
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Video, FileText } from "lucide-react";
|
||||||
|
import { PageLayout } from "@/components/ui/PageLayout";
|
||||||
|
import { useSrtPlayer } from "./hooks/useSrtPlayer";
|
||||||
|
import { useSubtitleSync } from "./hooks/useSubtitleSync";
|
||||||
|
import { useKeyboardShortcuts, createSrtPlayerShortcuts } from "./hooks/useKeyboardShortcuts";
|
||||||
|
import { useFileUpload } from "./hooks/useFileUpload";
|
||||||
|
import { loadSubtitle } from "./utils/subtitleParser";
|
||||||
|
import { VideoPlayer } from "./components/compounds/VideoPlayer";
|
||||||
|
import { SubtitleArea } from "./components/compounds/SubtitleArea";
|
||||||
|
import { ControlBar } from "./components/compounds/ControlBar";
|
||||||
|
import { UploadZone } from "./components/compounds/UploadZone";
|
||||||
|
import { SeekBar } from "./components/atoms/SeekBar";
|
||||||
|
import { LightButton } from "@/design-system/base/button";
|
||||||
|
|
||||||
|
export default function SrtPlayerPage() {
|
||||||
|
const t = useTranslations("home");
|
||||||
|
const srtT = useTranslations("srt_player");
|
||||||
|
const { uploadVideo, uploadSubtitle } = useFileUpload();
|
||||||
|
const {
|
||||||
|
state,
|
||||||
|
actions,
|
||||||
|
videoRef,
|
||||||
|
videoEventHandlers,
|
||||||
|
subtitleActions
|
||||||
|
} = useSrtPlayer();
|
||||||
|
|
||||||
|
// 字幕同步
|
||||||
|
useSubtitleSync(
|
||||||
|
state.subtitle.data,
|
||||||
|
state.video.currentTime,
|
||||||
|
state.video.isPlaying,
|
||||||
|
state.controls.autoPause,
|
||||||
|
(subtitle) => {
|
||||||
|
if (subtitle) {
|
||||||
|
subtitleActions.setCurrentSubtitle(subtitle.text, subtitle.index);
|
||||||
|
} else {
|
||||||
|
subtitleActions.setCurrentSubtitle("", null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(subtitle) => {
|
||||||
|
// 自动暂停逻辑
|
||||||
|
actions.seek(subtitle.start);
|
||||||
|
actions.pause();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 键盘快捷键
|
||||||
|
const shortcuts = React.useMemo(() =>
|
||||||
|
createSrtPlayerShortcuts(
|
||||||
|
actions.togglePlayPause,
|
||||||
|
actions.nextSubtitle,
|
||||||
|
actions.previousSubtitle,
|
||||||
|
actions.restartSubtitle,
|
||||||
|
actions.toggleAutoPause
|
||||||
|
), [
|
||||||
|
actions.togglePlayPause,
|
||||||
|
actions.nextSubtitle,
|
||||||
|
actions.previousSubtitle,
|
||||||
|
actions.restartSubtitle,
|
||||||
|
actions.toggleAutoPause
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
useKeyboardShortcuts(shortcuts);
|
||||||
|
|
||||||
|
// 处理字幕文件加载
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (state.subtitle.url) {
|
||||||
|
loadSubtitle(state.subtitle.url)
|
||||||
|
.then(subtitleData => {
|
||||||
|
subtitleActions.setSubtitleData(subtitleData);
|
||||||
|
toast.success(srtT("subtitleLoadSuccess"));
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
toast.error(srtT("subtitleLoadFailed") + ": " + error.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [srtT, state.subtitle.url, subtitleActions]);
|
||||||
|
|
||||||
|
// 处理进度条变化
|
||||||
|
const handleSeek = React.useCallback((index: number) => {
|
||||||
|
if (state.subtitle.data[index]) {
|
||||||
|
actions.seek(state.subtitle.data[index].start);
|
||||||
|
}
|
||||||
|
}, [state.subtitle.data, actions]);
|
||||||
|
|
||||||
|
// 处理视频上传
|
||||||
|
const handleVideoUpload = React.useCallback(() => {
|
||||||
|
uploadVideo(actions.setVideoUrl, (error) => {
|
||||||
|
toast.error(srtT("videoUploadFailed") + ": " + error.message);
|
||||||
|
});
|
||||||
|
}, [uploadVideo, actions.setVideoUrl, srtT]);
|
||||||
|
|
||||||
|
// 处理字幕上传
|
||||||
|
const handleSubtitleUpload = React.useCallback(() => {
|
||||||
|
uploadSubtitle(actions.setSubtitleUrl, (error) => {
|
||||||
|
toast.error(srtT("subtitleUploadFailed") + ": " + error.message);
|
||||||
|
});
|
||||||
|
}, [uploadSubtitle, actions.setSubtitleUrl, srtT]);
|
||||||
|
|
||||||
|
// 检查是否可以播放
|
||||||
|
const canPlay = state.video.url && state.subtitle.url && state.subtitle.data.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 视频播放器区域 */}
|
||||||
|
<div className="aspect-video bg-black relative rounded-md overflow-hidden">
|
||||||
|
{(!state.video.url || !state.subtitle.url || state.subtitle.data.length === 0) && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-75 z-10">
|
||||||
|
<div className="text-center text-white">
|
||||||
|
<p className="text-lg mb-2">
|
||||||
|
{!state.video.url && !state.subtitle.url
|
||||||
|
? srtT("uploadVideoAndSubtitle")
|
||||||
|
: !state.video.url
|
||||||
|
? srtT("uploadVideoFile")
|
||||||
|
: !state.subtitle.url
|
||||||
|
? srtT("uploadSubtitleFile")
|
||||||
|
: srtT("processingSubtitle")
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
{(!state.video.url || !state.subtitle.url) && (
|
||||||
|
<p className="text-sm text-gray-300">
|
||||||
|
{srtT("needBothFiles")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.video.url && (
|
||||||
|
<VideoPlayer
|
||||||
|
ref={videoRef}
|
||||||
|
src={state.video.url}
|
||||||
|
{...videoEventHandlers}
|
||||||
|
className="w-full h-full"
|
||||||
|
>
|
||||||
|
{state.subtitle.url && state.subtitle.data.length > 0 && (
|
||||||
|
<SubtitleArea
|
||||||
|
subtitle={state.subtitle.currentText}
|
||||||
|
settings={state.subtitle.settings}
|
||||||
|
className="absolute bottom-0 left-0 right-0 px-4 py-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</VideoPlayer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 控制面板 */}
|
||||||
|
<div className="p-3 bg-gray-50 border-t rounded-b-xl">
|
||||||
|
{/* 上传区域和状态指示器 */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className={`flex-1 p-2 rounded-lg border-2 transition-all ${state.video.url
|
||||||
|
? 'border-gray-800 bg-gray-100'
|
||||||
|
: 'border-gray-300 bg-white'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Video className="w-5 h-5 text-gray-600" />
|
||||||
|
<div className="text-left">
|
||||||
|
<h3 className="font-semibold text-gray-800 text-sm">{srtT("videoFile")}</h3>
|
||||||
|
<p className="text-xs text-gray-600">
|
||||||
|
{state.video.url ? srtT("uploaded") : srtT("notUploaded")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<LightButton
|
||||||
|
onClick={state.video.url ? undefined : handleVideoUpload}
|
||||||
|
disabled={!!state.video.url}
|
||||||
|
className="px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
{state.video.url ? srtT("uploaded") : srtT("upload")}
|
||||||
|
</LightButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`flex-1 p-2 rounded-lg border-2 transition-all ${state.subtitle.url
|
||||||
|
? 'border-gray-800 bg-gray-100'
|
||||||
|
: 'border-gray-300 bg-white'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="w-5 h-5 text-gray-600" />
|
||||||
|
<div className="text-left">
|
||||||
|
<h3 className="font-semibold text-gray-800 text-sm">{srtT("subtitleFile")}</h3>
|
||||||
|
<p className="text-xs text-gray-600">
|
||||||
|
{state.subtitle.url ? srtT("uploaded") : srtT("notUploaded")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<LightButton
|
||||||
|
onClick={state.subtitle.url ? undefined : handleSubtitleUpload}
|
||||||
|
disabled={!!state.subtitle.url}
|
||||||
|
className="px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
{state.subtitle.url ? srtT("uploaded") : srtT("upload")}
|
||||||
|
</LightButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 控制按钮和进度条 */}
|
||||||
|
<div className={`space-y-4 ${canPlay ? '' : 'opacity-50 pointer-events-none'}`}>
|
||||||
|
{/* 控制按钮 */}
|
||||||
|
<ControlBar
|
||||||
|
isPlaying={state.video.isPlaying}
|
||||||
|
onPlayPause={actions.togglePlayPause}
|
||||||
|
onPrevious={actions.previousSubtitle}
|
||||||
|
onNext={actions.nextSubtitle}
|
||||||
|
onRestart={actions.restartSubtitle}
|
||||||
|
playbackRate={state.video.playbackRate}
|
||||||
|
onPlaybackRateChange={actions.setPlaybackRate}
|
||||||
|
autoPause={state.controls.autoPause}
|
||||||
|
onAutoPauseToggle={actions.toggleAutoPause}
|
||||||
|
disabled={!canPlay}
|
||||||
|
className="justify-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 进度条 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<SeekBar
|
||||||
|
value={state.subtitle.currentIndex ?? 0}
|
||||||
|
max={Math.max(0, state.subtitle.data.length - 1)}
|
||||||
|
onChange={handleSeek}
|
||||||
|
disabled={!canPlay}
|
||||||
|
className="h-3"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 字幕进度显示 */}
|
||||||
|
<div className="flex justify-between items-center text-sm text-gray-600 px-2">
|
||||||
|
<span>
|
||||||
|
{state.subtitle.currentIndex !== null ?
|
||||||
|
`${state.subtitle.currentIndex + 1}/${state.subtitle.data.length}` :
|
||||||
|
'0/0'
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* 播放速度显示 */}
|
||||||
|
<span className="bg-gray-200 px-2 py-1 rounded text-xs">
|
||||||
|
{state.video.playbackRate}x
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* 自动暂停状态 */}
|
||||||
|
<span className={`px-2 py-1 rounded text-xs ${state.controls.autoPause
|
||||||
|
? 'bg-gray-800 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{srtT("autoPauseStatus", { enabled: state.controls.autoPause ? srtT("on") : srtT("off") })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/app/(features)/srt-player/subtitle.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
export function parseSrt(data: string) {
|
||||||
|
const lines = data.split(/\r?\n/);
|
||||||
|
const result = [];
|
||||||
|
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() });
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNearistIndex(
|
||||||
|
srt: { start: number; end: number; text: string }[],
|
||||||
|
ct: number,
|
||||||
|
) {
|
||||||
|
for (let i = 0; i < srt.length; i++) {
|
||||||
|
const s = srt[i];
|
||||||
|
const l = ct - s.start >= 0;
|
||||||
|
const r = ct - s.end >= 0;
|
||||||
|
if (!(l || r)) return i - 1;
|
||||||
|
if (l && !r) return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIndex(
|
||||||
|
srt: { start: number; end: number; text: string }[],
|
||||||
|
ct: number,
|
||||||
|
) {
|
||||||
|
for (let i = 0; i < srt.length; i++) {
|
||||||
|
if (ct >= srt[i].start && ct <= srt[i].end) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSubtitle(
|
||||||
|
srt: { start: number; end: number; text: string }[],
|
||||||
|
currentTime: number,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
srt.find((sub) => currentTime >= sub.start && currentTime <= sub.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),
|
||||||
|
);
|
||||||
|
}
|
||||||
65
src/app/(features)/srt-player/types/controls.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
export interface ControlState {
|
||||||
|
autoPause: boolean;
|
||||||
|
showShortcuts: boolean;
|
||||||
|
showSettings: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ControlActions {
|
||||||
|
toggleAutoPause: () => void;
|
||||||
|
toggleShortcuts: () => void;
|
||||||
|
toggleSettings: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ControlBarProps {
|
||||||
|
isPlaying: boolean;
|
||||||
|
onPlayPause: () => void;
|
||||||
|
onPrevious: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onRestart: () => void;
|
||||||
|
playbackRate: number;
|
||||||
|
onPlaybackRateChange: (rate: number) => void;
|
||||||
|
autoPause: boolean;
|
||||||
|
onAutoPauseToggle: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavigationButtonProps {
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoPauseToggleProps {
|
||||||
|
enabled: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyboardShortcut {
|
||||||
|
key: string;
|
||||||
|
description: string;
|
||||||
|
action: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShortcutHintProps {
|
||||||
|
shortcuts: KeyboardShortcut[];
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileUploadProps {
|
||||||
|
onVideoUpload: (url: string) => void;
|
||||||
|
onSubtitleUpload: (url: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileInputProps {
|
||||||
|
accept: string;
|
||||||
|
onFileSelect: (file: File) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
57
src/app/(features)/srt-player/types/player.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
export interface VideoState {
|
||||||
|
url: string | null;
|
||||||
|
isPlaying: boolean;
|
||||||
|
currentTime: number;
|
||||||
|
duration: number;
|
||||||
|
playbackRate: number;
|
||||||
|
volume: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoControls {
|
||||||
|
play: () => void;
|
||||||
|
pause: () => void;
|
||||||
|
togglePlayPause: () => void;
|
||||||
|
seek: (time: number) => void;
|
||||||
|
setPlaybackRate: (rate: number) => void;
|
||||||
|
setVolume: (volume: number) => void;
|
||||||
|
restart: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoElementProps {
|
||||||
|
src?: string;
|
||||||
|
onTimeUpdate?: (time: number) => void;
|
||||||
|
onLoadedMetadata?: (duration: number) => void;
|
||||||
|
onPlay?: () => void;
|
||||||
|
onPause?: () => void;
|
||||||
|
onEnded?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayButtonProps {
|
||||||
|
isPlaying: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeekBarProps {
|
||||||
|
value: number;
|
||||||
|
max: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeedControlProps {
|
||||||
|
playbackRate: number;
|
||||||
|
onPlaybackRateChange: (rate: number) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VolumeControlProps {
|
||||||
|
volume: number;
|
||||||
|
onVolumeChange: (volume: number) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
59
src/app/(features)/srt-player/types/subtitle.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
export interface SubtitleEntry {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
text: string;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubtitleState {
|
||||||
|
url: string | null;
|
||||||
|
data: SubtitleEntry[];
|
||||||
|
currentText: string;
|
||||||
|
currentIndex: number | null;
|
||||||
|
settings: SubtitleSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubtitleSettings {
|
||||||
|
fontSize: number;
|
||||||
|
backgroundColor: string;
|
||||||
|
textColor: string;
|
||||||
|
position: 'top' | 'center' | 'bottom';
|
||||||
|
fontFamily: string;
|
||||||
|
opacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubtitleDisplayProps {
|
||||||
|
subtitle: string;
|
||||||
|
onWordClick?: (word: string) => void;
|
||||||
|
settings?: SubtitleSettings;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubtitleTextProps {
|
||||||
|
text: string;
|
||||||
|
onWordClick?: (word: string) => void;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubtitleSettingsProps {
|
||||||
|
settings: SubtitleSettings;
|
||||||
|
onSettingsChange: (settings: SubtitleSettings) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubtitleControls {
|
||||||
|
next: () => void;
|
||||||
|
previous: () => void;
|
||||||
|
goToIndex: (index: number) => void;
|
||||||
|
toggleAutoPause: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubtitleSyncProps {
|
||||||
|
subtitles: SubtitleEntry[];
|
||||||
|
currentTime: number;
|
||||||
|
isPlaying: boolean;
|
||||||
|
autoPause: boolean;
|
||||||
|
onSubtitleChange: (subtitle: SubtitleEntry | null) => void;
|
||||||
|
onAutoPauseTrigger?: (subtitle: SubtitleEntry) => void;
|
||||||
|
}
|
||||||
99
src/app/(features)/srt-player/utils/subtitleParser.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { SubtitleEntry } from "../types/subtitle";
|
||||||
|
|
||||||
|
export function parseSrt(data: string): SubtitleEntry[] {
|
||||||
|
const lines = data.split(/\r?\n/);
|
||||||
|
const result: SubtitleEntry[] = [];
|
||||||
|
const re = new RegExp(
|
||||||
|
"(\\d{2}:\\d{2}:\\d{2},\\d{3})\\s*-->\\s*(\\d{2}:\\d{2}:\\d{2},\\d{3})",
|
||||||
|
);
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < lines.length) {
|
||||||
|
if (!lines[i].trim()) {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
if (i >= lines.length) break;
|
||||||
|
|
||||||
|
const timeMatch = lines[i].match(re);
|
||||||
|
if (!timeMatch) {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = toSeconds(timeMatch[1]);
|
||||||
|
const end = toSeconds(timeMatch[2]);
|
||||||
|
i++;
|
||||||
|
|
||||||
|
let text = "";
|
||||||
|
while (i < lines.length && lines[i].trim()) {
|
||||||
|
text += lines[i] + "\n";
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
text: text.trim(),
|
||||||
|
index: result.length,
|
||||||
|
});
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSubtitleIndex(
|
||||||
|
subtitles: SubtitleEntry[],
|
||||||
|
currentTime: number,
|
||||||
|
): number | null {
|
||||||
|
for (let i = 0; i < subtitles.length; i++) {
|
||||||
|
if (currentTime >= subtitles[i].start && currentTime <= subtitles[i].end) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNearestIndex(
|
||||||
|
subtitles: SubtitleEntry[],
|
||||||
|
currentTime: number,
|
||||||
|
): number | null {
|
||||||
|
for (let i = 0; i < subtitles.length; i++) {
|
||||||
|
const subtitle = subtitles[i];
|
||||||
|
const isBefore = currentTime - subtitle.start >= 0;
|
||||||
|
const isAfter = currentTime - subtitle.end >= 0;
|
||||||
|
|
||||||
|
if (!isBefore || !isAfter) return i - 1;
|
||||||
|
if (isBefore && !isAfter) return i;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentSubtitle(
|
||||||
|
subtitles: SubtitleEntry[],
|
||||||
|
currentTime: number,
|
||||||
|
): SubtitleEntry | null {
|
||||||
|
return subtitles.find((subtitle) =>
|
||||||
|
currentTime >= subtitle.start && currentTime <= subtitle.end
|
||||||
|
) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSeconds(timeStr: string): number {
|
||||||
|
const [h, m, s] = timeStr.replace(",", ".").split(":");
|
||||||
|
return parseFloat(
|
||||||
|
(parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s)).toFixed(3),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadSubtitle(url: string): Promise<SubtitleEntry[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
const data = await response.text();
|
||||||
|
return parseSrt(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载字幕失败', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/app/(features)/srt-player/utils/timeUtils.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
export function formatTime(seconds: number): string {
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timeToSeconds(timeStr: string): number {
|
||||||
|
const parts = timeStr.split(':');
|
||||||
|
|
||||||
|
if (parts.length === 3) {
|
||||||
|
// HH:MM:SS format
|
||||||
|
const [h, m, s] = parts;
|
||||||
|
return parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s);
|
||||||
|
} else if (parts.length === 2) {
|
||||||
|
// MM:SS format
|
||||||
|
const [m, s] = parts;
|
||||||
|
return parseInt(m) * 60 + parseFloat(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function secondsToTime(seconds: number): string {
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
const ms = Math.floor((seconds % 1) * 1000);
|
||||||
|
|
||||||
|
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')},${ms.toString().padStart(3, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampTime(time: number, min: number = 0, max: number = Infinity): number {
|
||||||
|
return Math.min(Math.max(time, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlaybackRateOptions(): number[] {
|
||||||
|
return [0.5, 0.7, 1.0, 1.2, 1.5, 2.0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlaybackRateLabel(rate: number): string {
|
||||||
|
return `${rate}x`;
|
||||||
|
}
|
||||||
116
src/app/(features)/text-speaker/SaveList.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import z from "zod";
|
||||||
|
import {
|
||||||
|
TextSpeakerArraySchema,
|
||||||
|
TextSpeakerItemSchema,
|
||||||
|
} from "@/lib/interfaces";
|
||||||
|
import { IconClick } from "@/design-system/base/button";
|
||||||
|
import { IMAGES } from "@/config/images";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
|
||||||
|
|
||||||
|
interface TextCardProps {
|
||||||
|
item: z.infer<typeof TextSpeakerItemSchema>;
|
||||||
|
handleUse: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
|
||||||
|
handleDel: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
|
||||||
|
}
|
||||||
|
function TextCard({ item, handleUse, handleDel }: TextCardProps) {
|
||||||
|
const onUseClick = () => {
|
||||||
|
handleUse(item);
|
||||||
|
};
|
||||||
|
const onDelClick = () => {
|
||||||
|
handleDel(item);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="p-2 border-b border-gray-200 rounded-lg bg-gray-100 m-2 grid grid-cols-8">
|
||||||
|
<div className="col-span-7" onClick={onUseClick}>
|
||||||
|
<div className="max-h-26 hover:cursor-pointer text-3xl overflow-y-auto">
|
||||||
|
{item.text}
|
||||||
|
</div>
|
||||||
|
<div className="max-h-16 overflow-y-auto text-xl text-gray-600 whitespace-nowrap overflow-x-auto">
|
||||||
|
{item.ipa}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center items-center border-gray-300 border-l-2 m-2">
|
||||||
|
<IconClick
|
||||||
|
src={IMAGES.delete}
|
||||||
|
alt="delete"
|
||||||
|
onClick={onDelClick}
|
||||||
|
className="place-self-center"
|
||||||
|
size="lg"
|
||||||
|
></IconClick>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SaveListProps {
|
||||||
|
show?: boolean;
|
||||||
|
handleUse: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
|
||||||
|
}
|
||||||
|
export function SaveList({ show = false, handleUse }: SaveListProps) {
|
||||||
|
const t = useTranslations("text_speaker");
|
||||||
|
const { get: getFromLocalStorage, set: setIntoLocalStorage } =
|
||||||
|
getLocalStorageOperator<typeof TextSpeakerArraySchema>(
|
||||||
|
"text-speaker",
|
||||||
|
TextSpeakerArraySchema,
|
||||||
|
);
|
||||||
|
const [data, setData] = useState(getFromLocalStorage());
|
||||||
|
const handleDel = (item: z.infer<typeof TextSpeakerItemSchema>) => {
|
||||||
|
const current_data = getFromLocalStorage();
|
||||||
|
|
||||||
|
current_data.splice(
|
||||||
|
current_data.findIndex((v) => v.text === item.text),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
setIntoLocalStorage(current_data);
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
|
const refresh = () => {
|
||||||
|
setData(getFromLocalStorage());
|
||||||
|
};
|
||||||
|
const handleDeleteAll = () => {
|
||||||
|
const yesorno = prompt(t("confirmDeleteAll"))?.trim();
|
||||||
|
if (yesorno && (yesorno === "Y" || yesorno === "y")) {
|
||||||
|
setIntoLocalStorage([]);
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (show)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="my-4 p-2 mx-4 md:mx-32 border border-gray-200 rounded-lg"
|
||||||
|
style={{ fontFamily: "Times New Roman, serif" }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-row justify-center gap-8 items-center">
|
||||||
|
<IconClick
|
||||||
|
src={IMAGES.refresh}
|
||||||
|
alt="refresh"
|
||||||
|
onClick={refresh}
|
||||||
|
size="lg"
|
||||||
|
className=""
|
||||||
|
></IconClick>
|
||||||
|
<IconClick
|
||||||
|
src={IMAGES.delete}
|
||||||
|
alt="delete"
|
||||||
|
onClick={handleDeleteAll}
|
||||||
|
size="lg"
|
||||||
|
className=""
|
||||||
|
></IconClick>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
{data.map((v) => (
|
||||||
|
<TextCard
|
||||||
|
item={v}
|
||||||
|
key={crypto.randomUUID()}
|
||||||
|
handleUse={handleUse}
|
||||||
|
handleDel={handleDel}
|
||||||
|
></TextCard>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
else return <></>;
|
||||||
|
}
|
||||||
347
src/app/(features)/text-speaker/page.tsx
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { LightButton } from "@/design-system/base/button";
|
||||||
|
import { IconClick } from "@/design-system/base/button";
|
||||||
|
import { IMAGES } from "@/config/images";
|
||||||
|
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||||
|
import {
|
||||||
|
TextSpeakerArraySchema,
|
||||||
|
TextSpeakerItemSchema,
|
||||||
|
} from "@/lib/interfaces";
|
||||||
|
import { ChangeEvent, useEffect, useRef, useState } from "react";
|
||||||
|
import z from "zod";
|
||||||
|
import { SaveList } from "./SaveList";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
|
||||||
|
import { genIPA, genLanguage } from "@/modules/translator/translator-action";
|
||||||
|
import { PageLayout } from "@/components/ui/PageLayout";
|
||||||
|
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
|
||||||
|
|
||||||
|
export default function TextSpeakerPage() {
|
||||||
|
const t = useTranslations("text_speaker");
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const [showSpeedAdjust, setShowSpeedAdjust] = useState(false);
|
||||||
|
const [showSaveList, setShowSaveList] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [ipaEnabled, setIPAEnabled] = useState(false);
|
||||||
|
const [speed, setSpeed] = useState(1);
|
||||||
|
const [pause, setPause] = useState(true);
|
||||||
|
const [autopause, setAutopause] = useState(true);
|
||||||
|
const textRef = useRef("");
|
||||||
|
const [language, setLanguage] = useState<string | null>(null);
|
||||||
|
const [ipa, setIPA] = useState<string>("");
|
||||||
|
const objurlRef = useRef<string | null>(null);
|
||||||
|
const [processing, setProcessing] = useState(false);
|
||||||
|
const { play, stop, load, audioRef } = useAudioPlayer();
|
||||||
|
|
||||||
|
const { get: getFromLocalStorage, set: setIntoLocalStorage } =
|
||||||
|
getLocalStorageOperator<typeof TextSpeakerArraySchema>(
|
||||||
|
"text-speaker",
|
||||||
|
TextSpeakerArraySchema,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio) return;
|
||||||
|
|
||||||
|
const handleEnded = () => {
|
||||||
|
if (autopause) {
|
||||||
|
setPause(true);
|
||||||
|
} else {
|
||||||
|
load(objurlRef.current!);
|
||||||
|
play();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
audio.addEventListener("ended", handleEnded);
|
||||||
|
return () => {
|
||||||
|
audio.removeEventListener("ended", handleEnded);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [audioRef, autopause]);
|
||||||
|
|
||||||
|
const speak = async () => {
|
||||||
|
if (processing) return;
|
||||||
|
setProcessing(true);
|
||||||
|
|
||||||
|
if (ipa.length === 0 && ipaEnabled && textRef.current.length !== 0) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
text: textRef.current,
|
||||||
|
});
|
||||||
|
fetch(`/api/ipa?${params}`)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
setIPA(data.ipa);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("生成 IPA 失败", e);
|
||||||
|
setIPA("");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pause) {
|
||||||
|
// 如果没在读
|
||||||
|
if (textRef.current.length === 0) {
|
||||||
|
// 没文本咋读
|
||||||
|
} else {
|
||||||
|
setPause(false);
|
||||||
|
|
||||||
|
if (objurlRef.current) {
|
||||||
|
// 之前有播放
|
||||||
|
load(objurlRef.current);
|
||||||
|
play();
|
||||||
|
} else {
|
||||||
|
// 第一次播放
|
||||||
|
try {
|
||||||
|
let theLanguage = language;
|
||||||
|
if (!theLanguage) {
|
||||||
|
const tmp_language = await genLanguage(textRef.current.slice(0, 30));
|
||||||
|
setLanguage(tmp_language);
|
||||||
|
theLanguage = tmp_language;
|
||||||
|
}
|
||||||
|
|
||||||
|
theLanguage = theLanguage.toLowerCase().replace(/[^a-z]/g, '').replace(/^./, match => match.toUpperCase());
|
||||||
|
|
||||||
|
// 检查语言是否在 TTS 支持列表中
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
objurlRef.current = await getTTSUrl(
|
||||||
|
textRef.current,
|
||||||
|
theLanguage as TTS_SUPPORTED_LANGUAGES
|
||||||
|
);
|
||||||
|
load(objurlRef.current);
|
||||||
|
play();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("播放音频失败", e);
|
||||||
|
setPause(true);
|
||||||
|
setLanguage(null);
|
||||||
|
setProcessing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果在读就暂停
|
||||||
|
setPause(true);
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
setProcessing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
textRef.current = e.target.value.trim();
|
||||||
|
setLanguage(null);
|
||||||
|
setIPA("");
|
||||||
|
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
||||||
|
objurlRef.current = null;
|
||||||
|
stop();
|
||||||
|
setPause(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const letMeSetSpeed = (new_speed: number) => {
|
||||||
|
return () => {
|
||||||
|
setSpeed(new_speed);
|
||||||
|
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
||||||
|
objurlRef.current = null;
|
||||||
|
stop();
|
||||||
|
setPause(true);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUseItem = (item: z.infer<typeof TextSpeakerItemSchema>) => {
|
||||||
|
if (textareaRef.current) textareaRef.current.value = item.text;
|
||||||
|
textRef.current = item.text;
|
||||||
|
setLanguage(item.language);
|
||||||
|
setIPA(item.ipa || "");
|
||||||
|
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
||||||
|
objurlRef.current = null;
|
||||||
|
stop();
|
||||||
|
setPause(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (saving) return;
|
||||||
|
if (textRef.current.length === 0) return;
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let theLanguage = language;
|
||||||
|
if (!theLanguage) {
|
||||||
|
const tmp_language = await genLanguage(textRef.current.slice(0, 30));
|
||||||
|
setLanguage(tmp_language);
|
||||||
|
theLanguage = tmp_language;
|
||||||
|
}
|
||||||
|
|
||||||
|
let theIPA = ipa;
|
||||||
|
if (ipa.length === 0 && ipaEnabled) {
|
||||||
|
const tmp_ipa = await genIPA(textRef.current);
|
||||||
|
setIPA(tmp_ipa);
|
||||||
|
theIPA = tmp_ipa;
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = getFromLocalStorage();
|
||||||
|
const oldIndex = save.findIndex((v) => v.text === textRef.current);
|
||||||
|
if (oldIndex !== -1) {
|
||||||
|
const oldItem = save[oldIndex];
|
||||||
|
if (theIPA) {
|
||||||
|
if (!oldItem.ipa || oldItem.ipa !== theIPA) {
|
||||||
|
oldItem.ipa = theIPA;
|
||||||
|
setIntoLocalStorage(save);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (theIPA.length === 0) {
|
||||||
|
save.push({
|
||||||
|
text: textRef.current,
|
||||||
|
language: theLanguage as string,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
save.push({
|
||||||
|
text: textRef.current,
|
||||||
|
language: theLanguage as string,
|
||||||
|
ipa: theIPA,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setIntoLocalStorage(save);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("保存到本地存储失败", e);
|
||||||
|
setLanguage(null);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout className="items-start py-4">
|
||||||
|
{/* 文本输入区域 */}
|
||||||
|
<div
|
||||||
|
className="border border-gray-200 rounded-lg"
|
||||||
|
style={{ fontFamily: "Times New Roman, serif" }}
|
||||||
|
>
|
||||||
|
{/* 文本输入框 */}
|
||||||
|
<textarea
|
||||||
|
className="text-2xl resize-none focus:outline-0 min-h-64 w-full border-gray-200 border-b p-4"
|
||||||
|
onChange={handleInputChange}
|
||||||
|
ref={textareaRef}
|
||||||
|
></textarea>
|
||||||
|
{/* IPA 显示区域 */}
|
||||||
|
{(ipa.length !== 0 && (
|
||||||
|
<div className="overflow-auto text-gray-600 h-18 border-gray-200 border-b px-4">
|
||||||
|
{ipa}
|
||||||
|
</div>
|
||||||
|
)) || <div className="h-18"></div>}
|
||||||
|
|
||||||
|
{/* 控制按钮区域 */}
|
||||||
|
<div className="p-4 relative w-full flex flex-row flex-wrap gap-2 justify-center items-center">
|
||||||
|
{/* 速度调节面板 */}
|
||||||
|
{showSpeedAdjust && (
|
||||||
|
<div className="bg-white p-6 rounded-lg border-gray-200 border-2 shadow-2xl absolute left-1/2 -translate-x-1/2 -translate-y-full -top-4 flex flex-row flex-wrap gap-2 justify-center items-center z-10">
|
||||||
|
<IconClick
|
||||||
|
size="lg"
|
||||||
|
onClick={letMeSetSpeed(0.5)}
|
||||||
|
src={IMAGES.speed_0_5x}
|
||||||
|
alt="0.5x"
|
||||||
|
className={speed === 0.5 ? "bg-gray-200" : ""}
|
||||||
|
></IconClick>
|
||||||
|
<IconClick
|
||||||
|
size="lg"
|
||||||
|
onClick={letMeSetSpeed(0.7)}
|
||||||
|
src={IMAGES.speed_0_7x}
|
||||||
|
alt="0.7x"
|
||||||
|
className={speed === 0.7 ? "bg-gray-200" : ""}
|
||||||
|
></IconClick>
|
||||||
|
<IconClick
|
||||||
|
size="lg"
|
||||||
|
onClick={letMeSetSpeed(1)}
|
||||||
|
src={IMAGES.speed_1x}
|
||||||
|
alt="1x"
|
||||||
|
className={speed === 1 ? "bg-gray-200" : ""}
|
||||||
|
></IconClick>
|
||||||
|
<IconClick
|
||||||
|
size="lg"
|
||||||
|
onClick={letMeSetSpeed(1.2)}
|
||||||
|
src={IMAGES.speed_1_2_x}
|
||||||
|
alt="1.2x"
|
||||||
|
className={speed === 1.2 ? "bg-gray-200" : ""}
|
||||||
|
></IconClick>
|
||||||
|
<IconClick
|
||||||
|
size="lg"
|
||||||
|
onClick={letMeSetSpeed(1.5)}
|
||||||
|
src={IMAGES.speed_1_5x}
|
||||||
|
alt="1.5x"
|
||||||
|
className={speed === 1.5 ? "bg-gray-200" : ""}
|
||||||
|
></IconClick>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 播放/暂停按钮 */}
|
||||||
|
<IconClick
|
||||||
|
size="lg"
|
||||||
|
onClick={speak}
|
||||||
|
src={pause ? IMAGES.play_arrow : IMAGES.pause}
|
||||||
|
alt="playorpause"
|
||||||
|
className={`${processing ? "bg-gray-200" : ""}`}
|
||||||
|
></IconClick>
|
||||||
|
{/* 自动暂停按钮 */}
|
||||||
|
<IconClick
|
||||||
|
size="lg"
|
||||||
|
onClick={() => {
|
||||||
|
setAutopause(!autopause);
|
||||||
|
if (objurlRef) {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
setPause(true);
|
||||||
|
}}
|
||||||
|
src={autopause ? IMAGES.autoplay : IMAGES.autopause}
|
||||||
|
alt="autoplayorpause"
|
||||||
|
></IconClick>
|
||||||
|
{/* 速度调节按钮 */}
|
||||||
|
<IconClick
|
||||||
|
size="lg"
|
||||||
|
onClick={() => setShowSpeedAdjust(!showSpeedAdjust)}
|
||||||
|
src={IMAGES.speed}
|
||||||
|
alt="speed"
|
||||||
|
className={`${showSpeedAdjust ? "bg-gray-200" : ""}`}
|
||||||
|
></IconClick>
|
||||||
|
{/* 保存按钮 */}
|
||||||
|
<IconClick
|
||||||
|
size="lg"
|
||||||
|
onClick={save}
|
||||||
|
src={IMAGES.save}
|
||||||
|
alt="save"
|
||||||
|
className={`${saving ? "bg-gray-200" : ""}`}
|
||||||
|
></IconClick>
|
||||||
|
{/* 功能开关按钮 */}
|
||||||
|
<div className="w-full flex flex-row flex-wrap gap-2 justify-center items-center">
|
||||||
|
<LightButton
|
||||||
|
selected={ipaEnabled}
|
||||||
|
onClick={() => setIPAEnabled(!ipaEnabled)}
|
||||||
|
>
|
||||||
|
{t("generateIPA")}
|
||||||
|
</LightButton>
|
||||||
|
<LightButton
|
||||||
|
onClick={() => {
|
||||||
|
setShowSaveList(!showSaveList);
|
||||||
|
}}
|
||||||
|
selected={showSaveList}
|
||||||
|
>
|
||||||
|
{t("viewSavedItems")}
|
||||||
|
</LightButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 保存列表 */}
|
||||||
|
{showSaveList && (
|
||||||
|
<div className="mt-4 border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<SaveList show={showSaveList} handleUse={handleUseItem}></SaveList>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
223
src/app/(features)/translator/page.tsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
"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,46 +0,0 @@
|
|||||||
import Button from "@/components/Button";
|
|
||||||
import IconClick from "@/components/IconClick";
|
|
||||||
import IMAGES from "@/config/images";
|
|
||||||
import { Letter, SupportedAlphabets } from "@/interfaces";
|
|
||||||
import { Dispatch, SetStateAction, useRef, useState } from "react";
|
|
||||||
|
|
||||||
export default function MemoryCard(
|
|
||||||
{
|
|
||||||
alphabet,
|
|
||||||
language,
|
|
||||||
setChosenAlphabet
|
|
||||||
}: {
|
|
||||||
alphabet: Letter[],
|
|
||||||
language: string,
|
|
||||||
setChosenAlphabet: Dispatch<SetStateAction<SupportedAlphabets | null>>
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
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 letter = alphabet[index];
|
|
||||||
return (
|
|
||||||
<div className="w-full 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={32} alt="close" src={IMAGES.close} onClick={() => setChosenAlphabet(null)}></IconClick>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-12 justify-center items-center">
|
|
||||||
<span className="text-7xl md:text-9xl">{letterDisplay ? letter.letter : ''}</span>
|
|
||||||
<span className="text-5xl md:text-7xl text-gray-400">{ipaDisplay ? letter.letter_sound_ipa : ''}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row mt-32 items-center justify-center gap-2">
|
|
||||||
<IconClick size={48} alt="refresh" src={IMAGES.refresh} onClick={() => setIndex(Math.floor(Math.random() * alphabet.length))}></IconClick>
|
|
||||||
<IconClick size={48} alt="more" src={IMAGES.more_horiz} onClick={() => setMore(!more)}></IconClick>
|
|
||||||
{
|
|
||||||
more ? (<>
|
|
||||||
<Button className="w-20" label={letterDisplay ? '隐藏字母' : '显示字母'} onClick={() => { setLetterDisplay(!letterDisplay) }}></Button>
|
|
||||||
<Button className="w-20" label={ipaDisplay ? '隐藏IPA' : '显示IPA'} onClick={() => { setIPADisplay(!ipaDisplay) }}></Button>
|
|
||||||
</>) : (<></>)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Button from "@/components/Button";
|
|
||||||
import { Letter, SupportedAlphabets } from "@/interfaces";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import MemoryCard from "./MemoryCard";
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
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">请选择您想学习的字符</span>
|
|
||||||
<div className="flex gap-1 flex-wrap">
|
|
||||||
<Button label="日语假名" onClick={() => setChosenAlphabet('japanese')}></Button>
|
|
||||||
<Button label="英文字母" onClick={() => setChosenAlphabet('english')}></Button>
|
|
||||||
<Button label="维吾尔字母" onClick={() => setChosenAlphabet('uyghur')}></Button>
|
|
||||||
<Button label="世界语字母" onClick={() => setChosenAlphabet('esperanto')}></Button>
|
|
||||||
</div>
|
|
||||||
</div>);
|
|
||||||
if (loadingState === 'loading') {
|
|
||||||
return '加载中...';
|
|
||||||
}
|
|
||||||
if (loadingState === 'error') {
|
|
||||||
return '加载失败,请重试';
|
|
||||||
}
|
|
||||||
if (loadingState === 'success' && alphabetData[chosenAlphabet]) {
|
|
||||||
return (<MemoryCard
|
|
||||||
language={chosenAlphabet}
|
|
||||||
alphabet={alphabetData[chosenAlphabet]}
|
|
||||||
setChosenAlphabet={setChosenAlphabet}>
|
|
||||||
</MemoryCard>);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
4
src/app/api/auth/[...all]/route.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { auth } from "@/auth";
|
||||||
|
import { toNextJsHandler } from "better-auth/next-js";
|
||||||
|
|
||||||
|
export const { POST, GET } = toNextJsHandler(auth);
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { callZhipuAPI } 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) {
|
|
||||||
console.error('API 错误:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "服务器内部错误", message: "请稍后重试" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { callZhipuAPI } from "@/utils";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
|
|
||||||
async function getLocale(text: string) {
|
|
||||||
console.log(`get locale of ${text}`);
|
|
||||||
const messages = [
|
|
||||||
{
|
|
||||||
role: 'user', content: `
|
|
||||||
请推断以下文本的的locale,以JSON格式返回
|
|
||||||
[${text}]
|
|
||||||
结果如:
|
|
||||||
{
|
|
||||||
"locale": "zh-CN"
|
|
||||||
}
|
|
||||||
注意:
|
|
||||||
直接返回json文本,
|
|
||||||
locale如果可能有多个,选取最可能的一个,其中使用符号"-",
|
|
||||||
locale如果推断失败,就返回{"locale": "en-US"}
|
|
||||||
`
|
|
||||||
}];
|
|
||||||
try {
|
|
||||||
const response = await callZhipuAPI(messages);
|
|
||||||
let to_parse = response.choices[0].message.content.trim() as string;
|
|
||||||
if (to_parse.startsWith('`')) to_parse = to_parse.slice(7, to_parse.length - 3);
|
|
||||||
if (to_parse.length === 0) throw Error('ai啥也每说');
|
|
||||||
return JSON.parse(to_parse);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const searchParams = request.nextUrl.searchParams;
|
|
||||||
const text = searchParams.get('text');
|
|
||||||
|
|
||||||
if (!text) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "查询参数错误", message: "text参数是必需的" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const textInfo = await getLocale(text.slice(0, 30));
|
|
||||||
if (!textInfo) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "服务暂时不可用", message: "LLM API 请求失败" },
|
|
||||||
{ status: 503 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(textInfo, { status: 200 });
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('API 错误:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "服务器内部错误", message: "请稍后重试" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const url = request.url;
|
|
||||||
return NextResponse.json({
|
|
||||||
message: "Hello World",
|
|
||||||
url: url
|
|
||||||
}, { status: 200 });
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { callZhipuAPI } from "@/utils";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
|
|
||||||
async function getTextinfo(text: string) {
|
|
||||||
console.log(`get textinfo of ${text}`);
|
|
||||||
const messages = [
|
|
||||||
{
|
|
||||||
role: 'user', content: `
|
|
||||||
请推断以下文本的语言、locale,生成宽式国际音标(IPA),以JSON格式返回
|
|
||||||
[${text}]
|
|
||||||
结果如:
|
|
||||||
{
|
|
||||||
"text": "你好。",
|
|
||||||
"lang": "mandarin",
|
|
||||||
"ipa": "[ni˨˩˦ xɑʊ˨˩˦]",
|
|
||||||
"locale": "zh-CN"
|
|
||||||
}
|
|
||||||
注意:
|
|
||||||
直接返回json文本,
|
|
||||||
ipa一定要加[],
|
|
||||||
lang的值是小写字母的英文的语言名称,
|
|
||||||
locale如果可能有多个,选取最可能的一个,其中使用符号"-"
|
|
||||||
`
|
|
||||||
}];
|
|
||||||
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 getTextinfo(text);
|
|
||||||
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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { callZhipuAPI } from "@/utils";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
|
|
||||||
async function translate(text: string, target_lang: string) {
|
|
||||||
console.log(`translate "${text}" into ${target_lang}`);
|
|
||||||
const messages = [
|
|
||||||
{
|
|
||||||
role: 'user', content: `
|
|
||||||
请推断以下文本的语言、locale,并翻译到目标语言[${target_lang}],同样需要locale信息,以JSON格式返回
|
|
||||||
[${text}]
|
|
||||||
结果如:
|
|
||||||
{
|
|
||||||
"source_locale": "zh-CN",
|
|
||||||
"target_locale": "de-DE",
|
|
||||||
"target_text": "Halo"
|
|
||||||
}
|
|
||||||
注意:
|
|
||||||
直接返回json文本,
|
|
||||||
locale如果可能有多个,选取最可能的一个,其中使用符号"-",
|
|
||||||
locale如果推断失败,就当作是en-US
|
|
||||||
`
|
|
||||||
}];
|
|
||||||
try {
|
|
||||||
const response = await callZhipuAPI(messages);
|
|
||||||
let to_parse = response.choices[0].message.content.trim() as string;
|
|
||||||
if (to_parse.startsWith('`')) to_parse = to_parse.slice(7, to_parse.length - 3);
|
|
||||||
if (to_parse.length === 0) throw Error('ai啥也每说');
|
|
||||||
return JSON.parse(to_parse);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const searchParams = request.nextUrl.searchParams;
|
|
||||||
const text = searchParams.get('text');
|
|
||||||
const target_lang = searchParams.get('target');
|
|
||||||
|
|
||||||
if (!text || !target_lang) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "查询参数错误", message: "text参数, target参数是必需的" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const textInfo = await translate(text, target_lang);
|
|
||||||
if (!textInfo) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "服务暂时不可用", message: "LLM API 请求失败" },
|
|
||||||
{ status: 503 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return NextResponse.json(textInfo, { status: 200 });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('API 错误:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "服务器内部错误", message: "请稍后重试" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
285
src/app/auth/AuthForm.tsx
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useActionState, startTransition } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { PageLayout } from "@/components/ui/PageLayout";
|
||||||
|
import { Input } from "@/design-system/base/input";
|
||||||
|
import { LightButton, LinkButton } from "@/design-system/base/button";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { actionSignIn, actionSignUp, ActionOutputAuth } from "@/modules/auth/auth-action";
|
||||||
|
|
||||||
|
interface AuthFormProps {
|
||||||
|
redirectTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthForm({ redirectTo }: AuthFormProps) {
|
||||||
|
const t = useTranslations("auth");
|
||||||
|
const [mode, setMode] = useState<'signin' | 'signup'>('signin');
|
||||||
|
const [clearSignIn, setClearSignIn] = useState(false);
|
||||||
|
const [clearSignUp, setClearSignUp] = useState(false);
|
||||||
|
|
||||||
|
const [signInState, signInActionForm, isSignInPending] = useActionState(
|
||||||
|
async (_prevState: ActionOutputAuth | undefined, formData: FormData) => {
|
||||||
|
if (clearSignIn) {
|
||||||
|
setClearSignIn(false);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return actionSignIn(undefined, formData);
|
||||||
|
},
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
const [signUpState, signUpActionForm, isSignUpPending] = useActionState(
|
||||||
|
async (_prevState: ActionOutputAuth | undefined, formData: FormData) => {
|
||||||
|
if (clearSignUp) {
|
||||||
|
setClearSignUp(false);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return actionSignUp(undefined, formData);
|
||||||
|
},
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const validateForm = (formData: FormData): boolean => {
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
|
const identifier = formData.get("identifier") as string;
|
||||||
|
const email = formData.get("email") as string;
|
||||||
|
const username = formData.get("username") as string;
|
||||||
|
const password = formData.get("password") as string;
|
||||||
|
const confirmPassword = formData.get("confirmPassword") as string;
|
||||||
|
|
||||||
|
// 登录模式验证
|
||||||
|
if (mode === 'signin') {
|
||||||
|
if (!identifier) {
|
||||||
|
newErrors.identifier = t("identifierRequired");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 注册模式验证
|
||||||
|
if (!email) {
|
||||||
|
newErrors.email = t("emailRequired");
|
||||||
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
newErrors.email = t("invalidEmail");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
newErrors.username = t("usernameRequired");
|
||||||
|
} else if (username.length < 3) {
|
||||||
|
newErrors.username = t("usernameTooShort");
|
||||||
|
} else if (!/^[a-zA-Z0-9_]+$/.test(username)) {
|
||||||
|
newErrors.username = t("usernameInvalid");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
newErrors.password = t("passwordRequired");
|
||||||
|
} else if (password.length < 8) {
|
||||||
|
newErrors.password = t("passwordTooShort");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'signup') {
|
||||||
|
if (!confirmPassword) {
|
||||||
|
newErrors.confirmPassword = t("confirmPasswordRequired");
|
||||||
|
} else if (password !== confirmPassword) {
|
||||||
|
newErrors.confirmPassword = t("passwordsNotMatch");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
|
||||||
|
// 基本客户端验证
|
||||||
|
if (!validateForm(formData)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加 redirectTo 到 formData
|
||||||
|
if (redirectTo) {
|
||||||
|
formData.append("redirectTo", redirectTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 startTransition 包装 action 调用
|
||||||
|
startTransition(() => {
|
||||||
|
// 根据模式调用相应的 action
|
||||||
|
if (mode === 'signin') {
|
||||||
|
signInActionForm(formData);
|
||||||
|
} else {
|
||||||
|
signUpActionForm(formData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGitHubSignIn = async () => {
|
||||||
|
await authClient.signIn.social({
|
||||||
|
provider: "github",
|
||||||
|
callbackURL: redirectTo || "/"
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentError = mode === 'signin' ? signInState : signUpState;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-800 mb-2">{t(mode === 'signin' ? 'signIn' : 'signUp')}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 服务器端错误提示 */}
|
||||||
|
{currentError?.message && (
|
||||||
|
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
|
||||||
|
{currentError.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 登录/注册表单 */}
|
||||||
|
<form onSubmit={handleFormSubmit} className="space-y-4">
|
||||||
|
{/* 邮箱/用户名输入(登录模式)或 用户名输入(注册模式) */}
|
||||||
|
{mode === 'signin' ? (
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
name="identifier"
|
||||||
|
placeholder={t("emailOrUsername")}
|
||||||
|
className="w-full px-3 py-2"
|
||||||
|
/>
|
||||||
|
{errors.identifier && (
|
||||||
|
<p className="text-red-500 text-sm mt-1">{errors.identifier}</p>
|
||||||
|
)}
|
||||||
|
{currentError?.errors?.email && (
|
||||||
|
<p className="text-red-500 text-sm mt-1">{currentError.errors.email[0]}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 用户名输入(仅注册模式) */}
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
placeholder={t("username")}
|
||||||
|
className="w-full px-3 py-2"
|
||||||
|
/>
|
||||||
|
{errors.username && (
|
||||||
|
<p className="text-red-500 text-sm mt-1">{errors.username}</p>
|
||||||
|
)}
|
||||||
|
{currentError?.errors?.username && (
|
||||||
|
<p className="text-red-500 text-sm mt-1">{currentError.errors.username[0]}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 邮箱输入(仅注册模式) */}
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
placeholder={t("email")}
|
||||||
|
className="w-full px-3 py-2"
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="text-red-500 text-sm mt-1">{errors.email}</p>
|
||||||
|
)}
|
||||||
|
{currentError?.errors?.email && (
|
||||||
|
<p className="text-red-500 text-sm mt-1">{currentError.errors.email[0]}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 密码输入 */}
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
placeholder={t("password")}
|
||||||
|
className="w-full px-3 py-2"
|
||||||
|
/>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="text-red-500 text-sm mt-1">{errors.password}</p>
|
||||||
|
)}
|
||||||
|
{currentError?.errors?.password && (
|
||||||
|
<p className="text-red-500 text-sm mt-1">{currentError.errors.password[0]}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 确认密码输入(仅注册模式显示) */}
|
||||||
|
{mode === 'signup' && (
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
name="confirmPassword"
|
||||||
|
placeholder={t("confirmPassword")}
|
||||||
|
className="w-full px-3 py-2"
|
||||||
|
/>
|
||||||
|
{errors.confirmPassword && (
|
||||||
|
<p className="text-red-500 text-sm mt-1">{errors.confirmPassword}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 提交按钮 */}
|
||||||
|
<LightButton
|
||||||
|
type="submit"
|
||||||
|
className={`w-full py-2 ${isSignInPending || isSignUpPending ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
{isSignInPending || isSignUpPending
|
||||||
|
? t("loading")
|
||||||
|
: t(mode === 'signin' ? 'signInButton' : 'signUpButton')
|
||||||
|
}
|
||||||
|
</LightButton>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* 第三方登录区域 */}
|
||||||
|
<div className="mt-6">
|
||||||
|
{/* 分隔线 */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-gray-300"></div>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-sm">
|
||||||
|
<span className="px-2 bg-white text-gray-500">或</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* GitHub 登录按钮 */}
|
||||||
|
<LightButton
|
||||||
|
onClick={handleGitHubSignIn}
|
||||||
|
className="w-full mt-4 py-2 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||||
|
</svg>
|
||||||
|
{t(mode === 'signin' ? 'signInWithGitHub' : 'signUpWithGitHub')}
|
||||||
|
</LightButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 模式切换链接 */}
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<LinkButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setMode(mode === 'signin' ? 'signup' : 'signin');
|
||||||
|
setErrors({});
|
||||||
|
// 清除服务器端错误状态
|
||||||
|
if (mode === 'signin') {
|
||||||
|
setClearSignIn(true);
|
||||||
|
} else {
|
||||||
|
setClearSignUp(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mode === 'signin'
|
||||||
|
? `${t("noAccount")} ${t("signUp")}`
|
||||||
|
: `${t("hasAccount")} ${t("signIn")}`
|
||||||
|
}
|
||||||
|
</LinkButton>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
src/app/auth/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { auth } from "@/auth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { AuthForm } from "./AuthForm";
|
||||||
|
|
||||||
|
export default async function AuthPage(
|
||||||
|
props: {
|
||||||
|
searchParams: Promise<{ [key: string]: string | string[] | undefined; }>;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const searchParams = await props.searchParams;
|
||||||
|
const redirectTo = searchParams.redirect as string | undefined;
|
||||||
|
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (session) {
|
||||||
|
redirect(redirectTo || '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AuthForm redirectTo={redirectTo} />;
|
||||||
|
}
|
||||||
192
src/app/folders/FoldersClient.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChevronRight,
|
||||||
|
Folder as Fd,
|
||||||
|
FolderPen,
|
||||||
|
FolderPlus,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { CircleButton, DashedButton } 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 } from "@/modules/folder/folder-aciton";
|
||||||
|
import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
|
||||||
|
|
||||||
|
interface FolderProps {
|
||||||
|
folder: TSharedFolderWithTotalPairs;
|
||||||
|
refresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FolderCard = ({ folder, refresh }: FolderProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const t = useTranslations("folders");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex justify-between items-center group p-4 border-b border-gray-100 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
router.push(`/folders/${folder.id}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 flex-1">
|
||||||
|
<div className="shrink-0">
|
||||||
|
<Fd className="text-gray-600" size="md" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-medium text-gray-900">{folder.name}</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{t("folderInfo", {
|
||||||
|
id: folder.id,
|
||||||
|
name: folder.name,
|
||||||
|
totalPairs: folder.total,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CircleButton
|
||||||
|
onClick={(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const newName = prompt("Input a new name.")?.trim();
|
||||||
|
if (newName && newName.length > 0) {
|
||||||
|
actionRenameFolderById(folder.id, newName)
|
||||||
|
.then(result => {
|
||||||
|
if (result.success) {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
toast.error(result.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FolderPen size={16} />
|
||||||
|
</CircleButton>
|
||||||
|
<CircleButton
|
||||||
|
onClick={(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const confirm = prompt(t("confirmDelete", { name: folder.name }));
|
||||||
|
if (confirm === folder.name) {
|
||||||
|
actionDeleteFolderById(folder.id)
|
||||||
|
.then(result => {
|
||||||
|
if (result.success) {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
toast.error(result.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-gray-400 hover:text-red-500 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</CircleButton>
|
||||||
|
<ChevronRight size={18} className="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FoldersClient({ userId }: { userId: string; }) {
|
||||||
|
const t = useTranslations("folders");
|
||||||
|
const [folders, setFolders] = useState<TSharedFolderWithTotalPairs[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
actionGetFoldersWithTotalPairsByUserId(userId)
|
||||||
|
.then((folders) => {
|
||||||
|
if (folders.success && folders.data) {
|
||||||
|
setFolders(folders.data);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
const updateFolders = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
await actionGetFoldersWithTotalPairsByUserId(userId)
|
||||||
|
.then(async result => {
|
||||||
|
if (!result.success) toast.error(result.message);
|
||||||
|
else await actionGetFoldersWithTotalPairsByUserId(userId)
|
||||||
|
.then((folders) => {
|
||||||
|
if (folders.success && folders.data) {
|
||||||
|
setFolders(folders.data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||||
|
|
||||||
|
{/* 新建文件夹按钮 */}
|
||||||
|
<DashedButton
|
||||||
|
onClick={async () => {
|
||||||
|
const folderName = prompt(t("enterFolderName"));
|
||||||
|
if (!folderName) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await actionCreateFolder(userId, folderName)
|
||||||
|
.then(result => {
|
||||||
|
if (result.success) {
|
||||||
|
updateFolders();
|
||||||
|
} else {
|
||||||
|
toast.error(result.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<FolderPlus size={18} />
|
||||||
|
<span>{loading ? t("creating") : t("newFolder")}</span>
|
||||||
|
</DashedButton>
|
||||||
|
|
||||||
|
{/* 文件夹列表 */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<CardList>
|
||||||
|
{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">
|
||||||
|
<FolderPlus size="md" className="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm">{t("noFoldersYet")}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// 文件夹卡片列表
|
||||||
|
<div className="rounded-md border border-gray-200 overflow-hidden">
|
||||||
|
{folders
|
||||||
|
.toSorted((a, b) => a.id - b.id)
|
||||||
|
.map((folder) => (
|
||||||
|
<FolderCard
|
||||||
|
key={folder.id}
|
||||||
|
folder={folder}
|
||||||
|
refresh={updateFolders}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardList>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
src/app/folders/[folder_id]/AddTextPairModal.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
src/app/folders/[folder_id]/InFolder.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"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, IconButton, LinkButton } from "@/design-system/base/button";
|
||||||
|
import { CardList } from "@/components/ui/CardList";
|
||||||
|
import { actionCreatePair, actionDeletePairById, actionGetPairsByFolderId } from "@/modules/folder/folder-aciton";
|
||||||
|
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 result.message;
|
||||||
|
return result.data;
|
||||||
|
}).then(setTextPairs)
|
||||||
|
.catch(toast.error)
|
||||||
|
.finally(() => {
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
fetchTextPairs();
|
||||||
|
}, [folderId]);
|
||||||
|
|
||||||
|
const refreshTextPairs = async () => {
|
||||||
|
await actionGetPairsByFolderId(folderId)
|
||||||
|
.then(result => {
|
||||||
|
if (!result.success || !result.data) throw result.message;
|
||||||
|
return result.data;
|
||||||
|
}).then(setTextPairs)
|
||||||
|
.catch(toast.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 && (
|
||||||
|
<IconButton
|
||||||
|
onClick={() => {
|
||||||
|
setAddModal(true);
|
||||||
|
}}
|
||||||
|
icon={<Plus size={18} className="text-gray-700" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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 result.message;
|
||||||
|
}).then(refreshTextPairs)
|
||||||
|
.catch(toast.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>
|
||||||
|
);
|
||||||
|
};
|
||||||
86
src/app/folders/[folder_id]/TextPairCard.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { Edit, Trash2 } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { CircleButton } from "@/design-system/base/button";
|
||||||
|
import { UpdateTextPairModal } from "./UpdateTextPairModal";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { TSharedPair } from "@/shared/folder-type";
|
||||||
|
import { actionUpdatePairById } from "@/modules/folder/folder-aciton";
|
||||||
|
import { ActionInputUpdatePairById } from "@/modules/folder/folder-action-dto";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface TextPairCardProps {
|
||||||
|
textPair: TSharedPair;
|
||||||
|
isReadOnly: boolean;
|
||||||
|
onDel: () => void;
|
||||||
|
refreshTextPairs: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextPairCard({
|
||||||
|
textPair,
|
||||||
|
isReadOnly,
|
||||||
|
onDel,
|
||||||
|
refreshTextPairs,
|
||||||
|
}: TextPairCardProps) {
|
||||||
|
const [openUpdateModal, setOpenUpdateModal] = useState(false);
|
||||||
|
const t = useTranslations("folder_id");
|
||||||
|
return (
|
||||||
|
<div className="group border-b border-gray-100 hover:bg-gray-50 transition-colors">
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||||
|
<span className="px-2 py-1 bg-gray-100 rounded-md">
|
||||||
|
{textPair.language1.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span>→</span>
|
||||||
|
<span className="px-2 py-1 bg-gray-100 rounded-md">
|
||||||
|
{textPair.language2.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 opacity-50 group-hover:opacity-100 transition-opacity">
|
||||||
|
{!isReadOnly && (
|
||||||
|
<>
|
||||||
|
<CircleButton
|
||||||
|
onClick={() => setOpenUpdateModal(true)}
|
||||||
|
title={t("edit")}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<Edit size={14} />
|
||||||
|
</CircleButton>
|
||||||
|
<CircleButton
|
||||||
|
onClick={onDel}
|
||||||
|
title={t("delete")}
|
||||||
|
className="text-gray-400 hover:text-red-500 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</CircleButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-900 grid grid-cols-2 gap-4 w-3/4">
|
||||||
|
<div>
|
||||||
|
{textPair.text1.length > 30
|
||||||
|
? textPair.text1.substring(0, 30) + "..."
|
||||||
|
: textPair.text1}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{textPair.text2.length > 30
|
||||||
|
? textPair.text2.substring(0, 30) + "..."
|
||||||
|
: textPair.text2}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<UpdateTextPairModal
|
||||||
|
isOpen={openUpdateModal}
|
||||||
|
onClose={() => setOpenUpdateModal(false)}
|
||||||
|
onUpdate={async (id: number, data: ActionInputUpdatePairById) => {
|
||||||
|
await actionUpdatePairById(id, data).then(result => result.success ? toast.success(result.message) : toast.error(result.message));
|
||||||
|
setOpenUpdateModal(false);
|
||||||
|
refreshTextPairs();
|
||||||
|
}}
|
||||||
|
textPair={textPair}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
src/app/folders/[folder_id]/UpdateTextPairModal.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
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";
|
||||||
|
import { TSharedPair } from "@/shared/folder-type";
|
||||||
|
import { ActionInputUpdatePairById } from "@/modules/folder/folder-action-dto";
|
||||||
|
|
||||||
|
interface UpdateTextPairModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
textPair: TSharedPair;
|
||||||
|
onUpdate: (id: number, tp: ActionInputUpdatePairById) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UpdateTextPairModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onUpdate,
|
||||||
|
textPair,
|
||||||
|
}: UpdateTextPairModalProps) {
|
||||||
|
const t = useTranslations("folder_id");
|
||||||
|
const input1Ref = useRef<HTMLInputElement>(null);
|
||||||
|
const input2Ref = useRef<HTMLInputElement>(null);
|
||||||
|
const [language1, setLanguage1] = useState(textPair.language1);
|
||||||
|
const [language2, setLanguage2] = useState(textPair.language2);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleUpdate = () => {
|
||||||
|
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() !== ""
|
||||||
|
) {
|
||||||
|
onUpdate(textPair.id, { text1, text2, language1, language2 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 backdrop-blur flex items-center justify-center z-50"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleUpdate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="bg-white rounded-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("updateTextPair")}
|
||||||
|
</h2>
|
||||||
|
<X onClick={onClose} className="hover:cursor-pointer"></X>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
{t("text1")}
|
||||||
|
<Input
|
||||||
|
defaultValue={textPair.text1}
|
||||||
|
ref={input1Ref}
|
||||||
|
className="w-full"
|
||||||
|
></Input>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t("text2")}
|
||||||
|
<Input
|
||||||
|
defaultValue={textPair.text2}
|
||||||
|
ref={input2Ref}
|
||||||
|
className="w-full"
|
||||||
|
></Input>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t("language1")}
|
||||||
|
<LocaleSelector value={language1} onChange={setLanguage1} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t("language2")}
|
||||||
|
<LocaleSelector value={language2} onChange={setLanguage2} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<LightButton onClick={handleUpdate}>{t("update")}</LightButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src/app/folders/[folder_id]/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { InFolder } from "./InFolder";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { actionGetUserIdByFolderId } from "@/modules/folder/folder-aciton";
|
||||||
|
|
||||||
|
export default async function FoldersPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ folder_id: number; }>;
|
||||||
|
}) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
const { folder_id } = await params;
|
||||||
|
const t = await getTranslations("folder_id");
|
||||||
|
|
||||||
|
if (!folder_id) {
|
||||||
|
redirect("/folders");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow non-authenticated users to view folders (read-only mode)
|
||||||
|
const folderUserId = (await actionGetUserIdByFolderId(Number(folder_id))).data;
|
||||||
|
const isOwner = session?.user?.id === folderUserId;
|
||||||
|
const isReadOnly = !isOwner;
|
||||||
|
|
||||||
|
return <InFolder folderId={Number(folder_id)} isReadOnly={isReadOnly} />;
|
||||||
|
}
|
||||||