Compare commits
83 Commits
cb805e2199
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
README.md
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
certificates
|
||||||
64
.drone.yml
@@ -7,35 +7,27 @@ platform:
|
|||||||
os: linux
|
os: linux
|
||||||
arch: amd64
|
arch: amd64
|
||||||
|
|
||||||
volumes:
|
|
||||||
- name: debian-dist
|
|
||||||
host:
|
|
||||||
path: /home/debian/dist
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: build
|
- name: build
|
||||||
image: node:23-alpine
|
image: plugins/docker
|
||||||
commands:
|
settings:
|
||||||
- npm ci
|
username:
|
||||||
- npm run build
|
from_secret: docker_username
|
||||||
|
password:
|
||||||
- name: package
|
from_secret: docker_password
|
||||||
image: node:23-alpine
|
repo: registry.edian-studio.com/learn-languages
|
||||||
|
registry: registry.edian-studio.com
|
||||||
|
tags:
|
||||||
|
- latest
|
||||||
|
|
||||||
|
- name: database migrate
|
||||||
|
image: node:24-alpine
|
||||||
environment:
|
environment:
|
||||||
ZHIPU_API_KEY:
|
DATABASE_URL:
|
||||||
from_secret: zhipu_api_key
|
from_secret: database_url
|
||||||
commands:
|
commands:
|
||||||
- apk add zip
|
- npm i --no-save prisma@7 @prisma/client@7 "@prisma/adapter-pg"
|
||||||
- mkdir -p .next/standalone/.next
|
- npx prisma migrate deploy
|
||||||
- cp -r public .next/standalone
|
|
||||||
- cp -r .next/static .next/standalone/.next
|
|
||||||
- cd .next/standalone
|
|
||||||
- echo "$ZHIPU_API_KEY" > zhipu_api_key.txt
|
|
||||||
- rm -f /dist/learn-languages.zip
|
|
||||||
- zip -r /dist/learn-languages.zip .
|
|
||||||
volumes:
|
|
||||||
- name: debian-dist
|
|
||||||
path: /dist
|
|
||||||
|
|
||||||
- name: deploy
|
- name: deploy
|
||||||
image: appleboy/drone-ssh
|
image: appleboy/drone-ssh
|
||||||
@@ -48,26 +40,10 @@ steps:
|
|||||||
from_secret: ssh_password
|
from_secret: ssh_password
|
||||||
port: 22
|
port: 22
|
||||||
script:
|
script:
|
||||||
- cd ~/
|
- cd ~/docker/learn-languages
|
||||||
- rm -rf learn-languages
|
- docker compose up -d --pull always --force-recreate
|
||||||
- mkdir learn-languages
|
|
||||||
- unzip -d learn-languages dist/learn-languages.zip
|
|
||||||
- cd learn-languages
|
|
||||||
- npm i
|
|
||||||
- |
|
|
||||||
if pm2 list | grep -q learn-languages; then
|
|
||||||
echo "进程 learn-languages 已在pm2中运行,正在重启..."
|
|
||||||
ZHIPU_API_KEY=`cat zhipu_api_key.txt` PORT=3030 pm2 restart "learn-languages"
|
|
||||||
else
|
|
||||||
echo "进程 learn-languages 未在pm2中运行,正在启动..."
|
|
||||||
ZHIPU_API_KEY=`cat zhipu_api_key.txt` PORT=3030 pm2 start "./server.js" --name "learn-languages"
|
|
||||||
fi
|
|
||||||
- pm2 save
|
|
||||||
- cd ~/
|
|
||||||
- rm -rf dist
|
|
||||||
- mkdir dist
|
|
||||||
debug: true
|
debug: true
|
||||||
|
|
||||||
trigger:
|
trigger:
|
||||||
branch:
|
branch:
|
||||||
- main
|
- main
|
||||||
|
|||||||
12
.env.example
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// LLM
|
||||||
|
ZHIPU_API_KEY=
|
||||||
|
ZHIPU_MODEL_NAME=
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
BETTER_AUTH_SECRET=
|
||||||
|
BETTER_AUTH_URL=
|
||||||
|
GITHUB_CLIENT_ID=
|
||||||
|
GITHUB_CLIENT_SECRET=
|
||||||
|
|
||||||
|
// Database
|
||||||
|
DATABASE_URL=
|
||||||
10
.gitignore
vendored
@@ -40,4 +40,12 @@ yarn-error.log*
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
.env
|
.env
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
build.sh
|
||||||
|
|
||||||
|
test.ts
|
||||||
|
/generated/prisma
|
||||||
|
|
||||||
|
certificates
|
||||||
10
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
|
"editor.defaultFormatter": null,
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.defaultFormatter": "vscode.typescript-language-features"
|
||||||
|
},
|
||||||
|
"[typescriptreact]": {
|
||||||
|
"editor.defaultFormatter": "vscode.typescript-language-features"
|
||||||
|
}
|
||||||
|
}
|
||||||
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>.
|
|
||||||
|
|||||||
170
README.md
@@ -1,36 +1,162 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
# 多语言学习平台
|
||||||
|
|
||||||
## Getting Started
|
一个基于 Next.js 构建的全功能多语言学习平台,提供翻译、发音、字幕播放、字母学习等多种语言学习工具,帮助用户更高效地掌握新语言。
|
||||||
|
|
||||||
First, run the development server:
|
## ✨ 主要功能
|
||||||
|
|
||||||
```bash
|
- **智能翻译工具** - 支持多语言互译,包含国际音标(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** - 国际化解决方案
|
||||||
|
- **edge-tts-universal** - 跨平台文本转语音
|
||||||
|
|
||||||
|
### 开发工具
|
||||||
|
- **ESLint** - 代码质量检查
|
||||||
|
- **pnpm** - 高效的包管理器
|
||||||
|
|
||||||
|
## 📁 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/ # Next.js App Router 路由
|
||||||
|
│ ├── (features)/ # 功能模块路由
|
||||||
|
│ ├── api/ # API 路由
|
||||||
|
│ └── auth/ # 认证相关页面
|
||||||
|
├── components/ # React 组件
|
||||||
|
│ ├── buttons/ # 按钮组件
|
||||||
|
│ ├── cards/ # 卡片组件
|
||||||
|
│ └── ...
|
||||||
|
├── lib/ # 工具函数和库
|
||||||
|
│ ├── actions/ # Server Actions
|
||||||
|
│ ├── browser/ # 浏览器端工具
|
||||||
|
│ └── server/ # 服务器端工具
|
||||||
|
├── hooks/ # 自定义 React Hooks
|
||||||
|
├── i18n/ # 国际化配置
|
||||||
|
└── config/ # 应用配置
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
## 🚀 快速开始
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
### 环境要求
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
- Node.js 24
|
||||||
|
- PostgreSQL 数据库
|
||||||
|
- pnpm (推荐) 或 npm
|
||||||
|
|
||||||
## Learn More
|
### 本地开发
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
1. 克隆项目
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd learn-languages
|
||||||
|
```
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
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
|
||||||
|
ZHIPU_API_KEY=your-zhipu-api-key
|
||||||
|
ZHIPU_MODEL_NAME=your-zhipu-model-name
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
BETTER_AUTH_SECRET=your-better-auth-secret
|
||||||
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
GITHUB_CLIENT_ID=your-github-client-id
|
||||||
|
GITHUB_CLIENT_SECRET=your-github-client-secret
|
||||||
|
|
||||||
|
// Database
|
||||||
|
DATABASE_URL=postgresql://username:password@localhost:5432/database_name
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:所有带 `your-` 前缀的值需要替换为你的实际配置。
|
||||||
|
|
||||||
|
4. 初始化数据库
|
||||||
|
```bash
|
||||||
|
pnpm prisma generate
|
||||||
|
pnpm prisma db push
|
||||||
|
```
|
||||||
|
|
||||||
|
5. 启动开发服务器
|
||||||
|
```bash
|
||||||
|
pnpm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 [http://localhost:3000](http://localhost:3000) 查看应用。
|
||||||
|
|
||||||
|
## 📚 API 文档
|
||||||
|
|
||||||
|
### 认证系统
|
||||||
|
|
||||||
|
应用使用 better-auth 提供安全的用户认证系统,支持邮箱/密码登录和第三方登录。
|
||||||
|
|
||||||
|
### 数据模型
|
||||||
|
|
||||||
|
核心数据模型包括:
|
||||||
|
- **User** - 用户信息
|
||||||
|
- **Folder** - 学习资料文件夹
|
||||||
|
- **Pair** - 语言对(翻译对、词汇对等)
|
||||||
|
|
||||||
|
详细模型定义请参考 [prisma/schema.prisma](./prisma/schema.prisma)
|
||||||
|
|
||||||
|
## 🌍 国际化
|
||||||
|
|
||||||
|
应用支持多语言,当前语言文件位于 `messages/` 目录。添加新语言:
|
||||||
|
|
||||||
|
1. 在 `messages/` 目录创建对应语言的 JSON 文件
|
||||||
|
2. 在 `src/i18n/config.ts` 中添加语言配置
|
||||||
|
|
||||||
|
## 🤝 贡献指南
|
||||||
|
|
||||||
|
我们欢迎各种形式的贡献!请遵循以下步骤:
|
||||||
|
|
||||||
|
1. Fork 项目
|
||||||
|
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
|
||||||
|
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||||
|
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||||
|
5. 打开 Pull Request
|
||||||
|
|
||||||
|
## 📄 许可证
|
||||||
|
|
||||||
|
本项目采用 AGPL-3.0 许可证 - 查看 [LICENSE](./LICENSE) 文件了解详情。
|
||||||
|
|
||||||
|
## 📞 支持
|
||||||
|
|
||||||
|
如果您遇到问题或有建议,请通过以下方式联系:
|
||||||
|
|
||||||
|
- 提交 [Issue](../../issues)
|
||||||
|
- 发送邮件至 [goddonebianu@outlook.com]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Happy Learning!** 🌟
|
||||||
|
|||||||
@@ -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 eslintConfig = defineConfig([
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
...nextVitals,
|
||||||
const __dirname = dirname(__filename);
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
const compat = new FlatCompat({
|
// Default ignores of eslint-config-next:
|
||||||
baseDirectory: __dirname,
|
'.next/**',
|
||||||
});
|
'out/**',
|
||||||
|
'build/**',
|
||||||
const eslintConfig = [
|
'next-env.d.ts',
|
||||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
]),
|
||||||
{
|
])
|
||||||
ignores: [
|
|
||||||
"node_modules/**",
|
export default eslintConfig
|
||||||
".next/**",
|
|
||||||
"out/**",
|
|
||||||
"build/**",
|
|
||||||
"next-env.d.ts",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default eslintConfig;
|
|
||||||
220
messages/en-US.json
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
{
|
||||||
|
"alphabet": {
|
||||||
|
"chooseCharacters": "Please select the characters you want to learn",
|
||||||
|
"japanese": "Japanese Kana",
|
||||||
|
"english": "English Alphabet",
|
||||||
|
"uyghur": "Uyghur Alphabet",
|
||||||
|
"esperanto": "Esperanto Alphabet",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"loadFailed": "Loading failed, please try again",
|
||||||
|
"hideLetter": "Hide Letter",
|
||||||
|
"showLetter": "Show Letter",
|
||||||
|
"hideIPA": "Hide IPA",
|
||||||
|
"showIPA": "Show IPA",
|
||||||
|
"roman": "Romanization",
|
||||||
|
"letter": "Letter",
|
||||||
|
"random": "Random Mode",
|
||||||
|
"randomNext": "Random Next"
|
||||||
|
},
|
||||||
|
"folders": {
|
||||||
|
"title": "Folders",
|
||||||
|
"subtitle": "Manage your collections",
|
||||||
|
"newFolder": "New Folder",
|
||||||
|
"creating": "Creating...",
|
||||||
|
"noFoldersYet": "No folders yet",
|
||||||
|
"folderInfo": "{id}. {name} ({totalPairs})",
|
||||||
|
"enterFolderName": "Enter folder name:",
|
||||||
|
"confirmDelete": "Type \"{name}\" to delete:",
|
||||||
|
"createFolderSuccess": "Folder created successfully",
|
||||||
|
"deleteFolderSuccess": "Folder deleted successfully",
|
||||||
|
"createFolderError": "Failed to create folder",
|
||||||
|
"deleteFolderError": "Failed to delete folder"
|
||||||
|
},
|
||||||
|
"folder_id": {
|
||||||
|
"unauthorized": "You are not the owner of this folder",
|
||||||
|
"back": "Back",
|
||||||
|
"textPairs": "Text Pairs",
|
||||||
|
"itemsCount": "{count} items",
|
||||||
|
"memorize": "Memorize",
|
||||||
|
"loadingTextPairs": "Loading text pairs...",
|
||||||
|
"noTextPairs": "No text pairs in this folder",
|
||||||
|
"addNewTextPair": "Add New Text Pair",
|
||||||
|
"add": "Add",
|
||||||
|
"updateTextPair": "Update Text Pair",
|
||||||
|
"update": "Update",
|
||||||
|
"text1": "Text 1",
|
||||||
|
"text2": "Text 2",
|
||||||
|
"locale1": "Locale 1",
|
||||||
|
"locale2": "Locale 2",
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"title": "Learn Languages",
|
||||||
|
"description": "Here is a very useful website to help you learn almost every language in the world, including constructed ones.",
|
||||||
|
"explore": "Explore",
|
||||||
|
"fortune": {
|
||||||
|
"quote": "Stay hungry, stay foolish.",
|
||||||
|
"author": "— Steve Jobs"
|
||||||
|
},
|
||||||
|
"translator": {
|
||||||
|
"name": "Translator",
|
||||||
|
"description": "Translate to any language and annotate with International Phonetic Alphabet (IPA)"
|
||||||
|
},
|
||||||
|
"textSpeaker": {
|
||||||
|
"name": "Text Speaker",
|
||||||
|
"description": "Recognize and read text aloud, supports loop playback and speed adjustment"
|
||||||
|
},
|
||||||
|
"srtPlayer": {
|
||||||
|
"name": "SRT Video Player",
|
||||||
|
"description": "Play videos sentence by sentence based on SRT subtitle files to mimic native speaker pronunciation"
|
||||||
|
},
|
||||||
|
"alphabet": {
|
||||||
|
"name": "Alphabet",
|
||||||
|
"description": "Start learning a new language from the alphabet"
|
||||||
|
},
|
||||||
|
"memorize": {
|
||||||
|
"name": "Memorize",
|
||||||
|
"description": "Language A to Language B, Language B to Language A, supports dictation"
|
||||||
|
},
|
||||||
|
"moreFeatures": {
|
||||||
|
"name": "More Features",
|
||||||
|
"description": "Under development, stay tuned"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"loading": "Loading...",
|
||||||
|
"githubLogin": "GitHub Login"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"title": "Authentication",
|
||||||
|
"signIn": "Sign In",
|
||||||
|
"signUp": "Sign Up",
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Password",
|
||||||
|
"confirmPassword": "Confirm Password",
|
||||||
|
"name": "Name",
|
||||||
|
"signInButton": "Sign In",
|
||||||
|
"signUpButton": "Sign Up",
|
||||||
|
"noAccount": "Don't have an account?",
|
||||||
|
"hasAccount": "Already have an account?",
|
||||||
|
"signInWithGitHub": "Sign In with GitHub",
|
||||||
|
"signUpWithGitHub": "Sign Up with GitHub",
|
||||||
|
"invalidEmail": "Please enter a valid email address",
|
||||||
|
"passwordTooShort": "Password must be at least 8 characters",
|
||||||
|
"passwordsNotMatch": "Passwords do not match",
|
||||||
|
"signInFailed": "Sign in failed, please check your email and password",
|
||||||
|
"signUpFailed": "Sign up failed, please try again later",
|
||||||
|
"nameRequired": "Please enter your name",
|
||||||
|
"emailRequired": "Please enter your email",
|
||||||
|
"passwordRequired": "Please enter your password",
|
||||||
|
"confirmPasswordRequired": "Please confirm your password",
|
||||||
|
"loading": "Loading..."
|
||||||
|
},
|
||||||
|
"memorize": {
|
||||||
|
"folder_selector": {
|
||||||
|
"selectFolder": "Select a folder",
|
||||||
|
"noFolders": "No folders found",
|
||||||
|
"folderInfo": "{id}. {name} ({count})"
|
||||||
|
},
|
||||||
|
"memorize": {
|
||||||
|
"answer": "Answer",
|
||||||
|
"next": "Next",
|
||||||
|
"reverse": "Reverse",
|
||||||
|
"dictation": "Dictation",
|
||||||
|
"noTextPairs": "No text pairs available",
|
||||||
|
"disorder": "Disorder",
|
||||||
|
"previous": "Previous"
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"unauthorized": "You are not authorized to access this folder"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"navbar": {
|
||||||
|
"title": "learn-languages",
|
||||||
|
"sourceCode": "GitHub",
|
||||||
|
"sign_in": "Sign In",
|
||||||
|
"profile": "Profile",
|
||||||
|
"folders": "Folders"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"myProfile": "My Profile",
|
||||||
|
"email": "Email: {email}",
|
||||||
|
"logout": "Logout"
|
||||||
|
},
|
||||||
|
"srt_player": {
|
||||||
|
"uploadVideo": "Upload Video",
|
||||||
|
"uploadSubtitle": "Upload Subtitle",
|
||||||
|
"pause": "Pause",
|
||||||
|
"play": "Play",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"restart": "Restart",
|
||||||
|
"autoPause": "Auto Pause ({enabled})",
|
||||||
|
"playbackSpeed": "Playback Speed",
|
||||||
|
"subtitleSettings": "Subtitle Settings",
|
||||||
|
"fontSize": "Font Size",
|
||||||
|
"backgroundColor": "Background Color",
|
||||||
|
"textColor": "Text Color",
|
||||||
|
"fontFamily": "Font Family",
|
||||||
|
"opacity": "Opacity",
|
||||||
|
"position": "Position",
|
||||||
|
"top": "Top",
|
||||||
|
"center": "Center",
|
||||||
|
"bottom": "Bottom",
|
||||||
|
"keyboardShortcuts": "Keyboard Shortcuts",
|
||||||
|
"uploadVideoAndSubtitle": "Please upload video and subtitle files",
|
||||||
|
"uploadVideoFile": "Please upload video file",
|
||||||
|
"uploadSubtitleFile": "Please upload subtitle file",
|
||||||
|
"processingSubtitle": "Processing subtitle file...",
|
||||||
|
"needBothFiles": "Both video and subtitle files are required to start learning",
|
||||||
|
"videoFile": "Video File",
|
||||||
|
"subtitleFile": "Subtitle File",
|
||||||
|
"uploaded": "Uploaded",
|
||||||
|
"notUploaded": "Not Uploaded",
|
||||||
|
"upload": "Upload",
|
||||||
|
"autoPauseStatus": "Auto Pause: {enabled}",
|
||||||
|
"on": "On",
|
||||||
|
"off": "Off",
|
||||||
|
"videoUploadFailed": "Video upload failed",
|
||||||
|
"subtitleUploadFailed": "Subtitle upload failed",
|
||||||
|
"subtitleLoadSuccess": "Subtitle file loaded successfully",
|
||||||
|
"subtitleLoadFailed": "Subtitle file loading failed",
|
||||||
|
"shortcuts": {
|
||||||
|
"playPause": "Play/Pause",
|
||||||
|
"next": "Next",
|
||||||
|
"previous": "Previous",
|
||||||
|
"restart": "Restart",
|
||||||
|
"autoPause": "Toggle Auto Pause"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text_speaker": {
|
||||||
|
"generateIPA": "Generate IPA",
|
||||||
|
"viewSavedItems": "View Saved Items",
|
||||||
|
"confirmDeleteAll": "Are you sure you want to delete everything? (Y/N)"
|
||||||
|
},
|
||||||
|
"translator": {
|
||||||
|
"detectLanguage": "detect language",
|
||||||
|
"generateIPA": "generate ipa",
|
||||||
|
"translateInto": "translate into",
|
||||||
|
"chinese": "Chinese",
|
||||||
|
"english": "English",
|
||||||
|
"italian": "Italian",
|
||||||
|
"other": "Other",
|
||||||
|
"translating": "translating...",
|
||||||
|
"translate": "translate",
|
||||||
|
"inputLanguage": "Input a language.",
|
||||||
|
"history": "History",
|
||||||
|
"enterLanguage": "Enter language",
|
||||||
|
"add_to_folder": {
|
||||||
|
"notAuthenticated": "You are not authenticated",
|
||||||
|
"chooseFolder": "Choose a Folder to Add to",
|
||||||
|
"noFolders": "No folders found",
|
||||||
|
"folderInfo": "{id}. {name}",
|
||||||
|
"close": "Close",
|
||||||
|
"success": "Text pair added to folder",
|
||||||
|
"error": "Failed to add text pair to folder"
|
||||||
|
},
|
||||||
|
"autoSave": "Auto Save"
|
||||||
|
}
|
||||||
|
}
|
||||||
223
messages/zh-CN.json
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
{
|
||||||
|
"alphabet": {
|
||||||
|
"chooseCharacters": "请选择您想学习的字符",
|
||||||
|
"japanese": "日语假名",
|
||||||
|
"english": "英文字母",
|
||||||
|
"uyghur": "维吾尔字母",
|
||||||
|
"esperanto": "世界语字母",
|
||||||
|
"loading": "加载中...",
|
||||||
|
"loadFailed": "加载失败,请重试",
|
||||||
|
"hideLetter": "隐藏字母",
|
||||||
|
"showLetter": "显示字母",
|
||||||
|
"hideIPA": "隐藏IPA",
|
||||||
|
"showIPA": "显示IPA",
|
||||||
|
"roman": "罗马音",
|
||||||
|
"letter": "字母",
|
||||||
|
"random": "随机模式",
|
||||||
|
"randomNext": "随机下一个"
|
||||||
|
},
|
||||||
|
"folders": {
|
||||||
|
"title": "文件夹",
|
||||||
|
"subtitle": "管理您的集合",
|
||||||
|
"newFolder": "新建文件夹",
|
||||||
|
"creating": "创建中...",
|
||||||
|
"noFoldersYet": "还没有文件夹",
|
||||||
|
"folderInfo": "{id}. {name} ({totalPairs})",
|
||||||
|
"enterFolderName": "输入文件夹名称:",
|
||||||
|
"confirmDelete": "输入 \"{name}\" 以删除:",
|
||||||
|
"createFolderSuccess": "文件夹创建成功",
|
||||||
|
"deleteFolderSuccess": "文件夹删除成功",
|
||||||
|
"createFolderError": "创建文件夹失败",
|
||||||
|
"deleteFolderError": "删除文件夹失败"
|
||||||
|
},
|
||||||
|
"folder_id": {
|
||||||
|
"unauthorized": "您不是此文件夹的所有者",
|
||||||
|
"back": "返回",
|
||||||
|
"textPairs": "文本对",
|
||||||
|
"itemsCount": "{count} 个项目",
|
||||||
|
"memorize": "记忆",
|
||||||
|
"loadingTextPairs": "加载文本对中...",
|
||||||
|
"noTextPairs": "此文件夹中没有文本对",
|
||||||
|
"addNewTextPair": "添加新文本对",
|
||||||
|
"add": "添加",
|
||||||
|
"updateTextPair": "更新文本对",
|
||||||
|
"update": "更新",
|
||||||
|
"text1": "文本1",
|
||||||
|
"text2": "文本2",
|
||||||
|
"locale1": "语言1",
|
||||||
|
"locale2": "语言2",
|
||||||
|
"edit": "编辑",
|
||||||
|
"delete": "删除"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"title": "学语言",
|
||||||
|
"description": "这里是一个非常有用的网站,可以帮助您学习世界上几乎每一种语言,包括人造语言。",
|
||||||
|
"explore": "探索网站",
|
||||||
|
"fortune": {
|
||||||
|
"quote": "求知若饥,虚心若愚。",
|
||||||
|
"author": "—— 史蒂夫·乔布斯"
|
||||||
|
},
|
||||||
|
"translator": {
|
||||||
|
"name": "翻译器",
|
||||||
|
"description": "翻译到任何语言,并标注国际音标(IPA)"
|
||||||
|
},
|
||||||
|
"textSpeaker": {
|
||||||
|
"name": "朗读器",
|
||||||
|
"description": "识别并朗读文本,支持循环朗读、朗读速度调节"
|
||||||
|
},
|
||||||
|
"srtPlayer": {
|
||||||
|
"name": "逐句放视频",
|
||||||
|
"description": "基于SRT字幕文件,逐句播放视频以模仿母语者的发音"
|
||||||
|
},
|
||||||
|
"alphabet": {
|
||||||
|
"name": "字母表",
|
||||||
|
"description": "从字母表开始新语言的学习"
|
||||||
|
},
|
||||||
|
"memorize": {
|
||||||
|
"name": "记忆",
|
||||||
|
"description": "语言A到语言B,语言B到语言A,支持听写"
|
||||||
|
},
|
||||||
|
"moreFeatures": {
|
||||||
|
"name": "更多功能",
|
||||||
|
"description": "开发中,敬请期待"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"loading": "加载中...",
|
||||||
|
"githubLogin": "GitHub登录"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"title": "登录",
|
||||||
|
"signIn": "登录",
|
||||||
|
"signUp": "注册",
|
||||||
|
"email": "邮箱",
|
||||||
|
"password": "密码",
|
||||||
|
"confirmPassword": "确认密码",
|
||||||
|
"name": "用户名",
|
||||||
|
"signInButton": "登录",
|
||||||
|
"signUpButton": "注册",
|
||||||
|
"noAccount": "还没有账户?",
|
||||||
|
"hasAccount": "已有账户?",
|
||||||
|
"signInWithGitHub": "使用GitHub登录",
|
||||||
|
"signUpWithGitHub": "使用GitHub注册",
|
||||||
|
"invalidEmail": "请输入有效的邮箱地址",
|
||||||
|
"passwordTooShort": "密码至少需要8个字符",
|
||||||
|
"passwordsNotMatch": "两次输入的密码不匹配",
|
||||||
|
"signInFailed": "登录失败,请检查您的邮箱和密码",
|
||||||
|
"signUpFailed": "注册失败,请稍后再试",
|
||||||
|
"nameRequired": "请输入用户名",
|
||||||
|
"emailRequired": "请输入邮箱",
|
||||||
|
"passwordRequired": "请输入密码",
|
||||||
|
"confirmPasswordRequired": "请确认密码"
|
||||||
|
},
|
||||||
|
"memorize": {
|
||||||
|
"choose": {
|
||||||
|
"back": "返回",
|
||||||
|
"choose": "选择"
|
||||||
|
},
|
||||||
|
"folder_selector": {
|
||||||
|
"selectFolder": "选择文件夹",
|
||||||
|
"noFolders": "未找到文件夹",
|
||||||
|
"folderInfo": "{id}. {name} ({count})"
|
||||||
|
},
|
||||||
|
"memorize": {
|
||||||
|
"answer": "答案",
|
||||||
|
"next": "下一个",
|
||||||
|
"reverse": "反向",
|
||||||
|
"dictation": "听写",
|
||||||
|
"noTextPairs": "没有可用的文本对",
|
||||||
|
"disorder": "乱序",
|
||||||
|
"previous": "上一个"
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"unauthorized": "您无权访问该文件夹"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"navbar": {
|
||||||
|
"title": "学语言",
|
||||||
|
"sourceCode": "源码",
|
||||||
|
"sign_in": "登录",
|
||||||
|
"profile": "个人资料",
|
||||||
|
"folders": "文件夹"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"myProfile": "我的个人资料",
|
||||||
|
"email": "邮箱:{email}",
|
||||||
|
"logout": "退出登录"
|
||||||
|
},
|
||||||
|
"srt_player": {
|
||||||
|
"upload": "上传",
|
||||||
|
"uploadVideo": "上传视频",
|
||||||
|
"uploadSubtitle": "上传字幕",
|
||||||
|
"pause": "暂停",
|
||||||
|
"play": "播放",
|
||||||
|
"previous": "上句",
|
||||||
|
"next": "下句",
|
||||||
|
"restart": "句首",
|
||||||
|
"autoPause": "自动暂停({enabled})",
|
||||||
|
"playbackSpeed": "播放速度",
|
||||||
|
"subtitleSettings": "字幕设置",
|
||||||
|
"fontSize": "字体大小",
|
||||||
|
"backgroundColor": "背景颜色",
|
||||||
|
"textColor": "文字颜色",
|
||||||
|
"fontFamily": "字体",
|
||||||
|
"opacity": "透明度",
|
||||||
|
"position": "位置",
|
||||||
|
"top": "顶部",
|
||||||
|
"center": "居中",
|
||||||
|
"bottom": "底部",
|
||||||
|
"keyboardShortcuts": "键盘快捷键",
|
||||||
|
"uploadVideoAndSubtitle": "请上传视频和字幕文件",
|
||||||
|
"uploadVideoFile": "请上传视频文件",
|
||||||
|
"uploadSubtitleFile": "请上传字幕文件",
|
||||||
|
"processingSubtitle": "字幕文件正在处理中...",
|
||||||
|
"needBothFiles": "需要同时上传视频和字幕文件才能开始学习",
|
||||||
|
"videoFile": "视频文件",
|
||||||
|
"subtitleFile": "字幕文件",
|
||||||
|
"uploaded": "已上传",
|
||||||
|
"notUploaded": "未上传",
|
||||||
|
"autoPauseStatus": "自动暂停: {enabled}",
|
||||||
|
"on": "开",
|
||||||
|
"off": "关",
|
||||||
|
"videoUploadFailed": "视频上传失败",
|
||||||
|
"subtitleUploadFailed": "字幕上传失败",
|
||||||
|
"subtitleLoadSuccess": "字幕文件加载成功",
|
||||||
|
"subtitleLoadFailed": "字幕文件加载失败",
|
||||||
|
"shortcuts": {
|
||||||
|
"playPause": "播放/暂停",
|
||||||
|
"next": "下一句",
|
||||||
|
"previous": "上一句",
|
||||||
|
"restart": "句首",
|
||||||
|
"autoPause": "切换自动暂停"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text_speaker": {
|
||||||
|
"generateIPA": "生成IPA",
|
||||||
|
"viewSavedItems": "查看保存项",
|
||||||
|
"confirmDeleteAll": "确定删光吗?(Y/N)"
|
||||||
|
},
|
||||||
|
"translator": {
|
||||||
|
"detectLanguage": "检测语言",
|
||||||
|
"generateIPA": "生成国际音标",
|
||||||
|
"translateInto": "翻译为",
|
||||||
|
"chinese": "中文",
|
||||||
|
"english": "英文",
|
||||||
|
"italian": "意大利语",
|
||||||
|
"other": "其他",
|
||||||
|
"translating": "翻译中...",
|
||||||
|
"translate": "翻译",
|
||||||
|
"inputLanguage": "请输入语言。",
|
||||||
|
"history": "历史记录",
|
||||||
|
"enterLanguage": "输入语言",
|
||||||
|
"add_to_folder": {
|
||||||
|
"notAuthenticated": "您未通过身份验证",
|
||||||
|
"chooseFolder": "选择要添加到的文件夹",
|
||||||
|
"noFolders": "未找到文件夹",
|
||||||
|
"folderInfo": "{id}. {name}",
|
||||||
|
"close": "关闭",
|
||||||
|
"success": "文本对已添加到文件夹",
|
||||||
|
"error": "添加文本对到文件夹失败"
|
||||||
|
},
|
||||||
|
"autoSave": "自动保存"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +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",
|
output: "standalone",
|
||||||
allowedDevOrigins: ["192.168.3.65"]
|
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);
|
||||||
|
|||||||
15540
package-lock.json
generated
61
package.json
@@ -3,31 +3,54 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-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": {
|
||||||
"clsx": "^2.1.1",
|
"@prisma/adapter-pg": "^7.1.0",
|
||||||
"edge-tts-universal": "^1.3.2",
|
"@prisma/client": "^7.1.0",
|
||||||
"motion": "^12.23.24",
|
"bcryptjs": "^3.0.3",
|
||||||
"next": "15.5.3",
|
"better-auth": "^1.4.6",
|
||||||
"rc-modal-sheet": "^1.0.2",
|
"dotenv": "^17.2.3",
|
||||||
"react": "19.1.0",
|
"edge-tts-universal": "^1.3.3",
|
||||||
"react-dom": "19.1.0",
|
"lucide-react": "^0.561.0",
|
||||||
"zod": "^3.25.76"
|
"next": "16.0.10",
|
||||||
|
"next-intl": "^4.5.8",
|
||||||
|
"pg": "^8.16.3",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"unstorage": "^1.17.3",
|
||||||
|
"zod": "^4.1.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@better-auth/cli": "^1.4.6",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@eslint/eslintrc": "^3.3.3",
|
||||||
"@types/node": "^20",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@types/react": "^19",
|
"@types/node": "^25.0.1",
|
||||||
"@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.49.0",
|
||||||
"tailwindcss": "^4",
|
"@typescript-eslint/parser": "^8.49.0",
|
||||||
"typescript": "^5"
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-config-next": "16.0.10",
|
||||||
|
"eslint-plugin-react": "^7.37.5",
|
||||||
|
"prisma": "^7.1.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"@types/react": "19.2.7",
|
||||||
|
"@types/react-dom": "19.2.3"
|
||||||
|
},
|
||||||
|
"ignoredBuiltDependencies": [
|
||||||
|
"@prisma/client"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6607
pnpm-lock.yaml
generated
Normal file
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;
|
||||||
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"
|
||||||
106
prisma/schema.prisma
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
output = "../generated/prisma"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
|
|
||||||
|
model Pair {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
locale1 String @db.VarChar(10)
|
||||||
|
locale2 String @db.VarChar(10)
|
||||||
|
text1 String
|
||||||
|
text2 String
|
||||||
|
ipa1 String?
|
||||||
|
ipa2 String?
|
||||||
|
folderId Int @map("folder_id")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([folderId, locale1, locale2, text1])
|
||||||
|
@@index([folderId])
|
||||||
|
@@map("pairs")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Folder {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String
|
||||||
|
userId String @map("user_id")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
pairs Pair[]
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@map("folders")
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id
|
||||||
|
name String
|
||||||
|
email String
|
||||||
|
emailVerified Boolean @default(false)
|
||||||
|
image String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
sessions Session[]
|
||||||
|
accounts Account[]
|
||||||
|
folders Folder[]
|
||||||
|
|
||||||
|
@@unique([email])
|
||||||
|
@@map("user")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Session {
|
||||||
|
id String @id
|
||||||
|
expiresAt DateTime
|
||||||
|
token String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
ipAddress String?
|
||||||
|
userAgent String?
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([token])
|
||||||
|
@@index([userId])
|
||||||
|
@@map("session")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Account {
|
||||||
|
id String @id
|
||||||
|
accountId String
|
||||||
|
providerId String
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
accessToken String?
|
||||||
|
refreshToken String?
|
||||||
|
idToken String?
|
||||||
|
accessTokenExpiresAt DateTime?
|
||||||
|
refreshTokenExpiresAt DateTime?
|
||||||
|
scope String?
|
||||||
|
password String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@map("account")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Verification {
|
||||||
|
id String @id
|
||||||
|
identifier String
|
||||||
|
value String
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([identifier])
|
||||||
|
@@map("verification")
|
||||||
|
}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
2025.10.12 添加朗读器本地保存功能
|
|
||||||
2025.10.09 新增记忆字母表功能
|
|
||||||
2025.10.08 加快了TTS的生成速度,将IPA生成设置为可选项
|
|
||||||
2025.10.07 新增文本朗读器,优化了视频播放器UI
|
|
||||||
2025.10.06 更新了主页面UI,移除IPA生成与文本朗读功能,新增全语言翻译器
|
|
||||||
2025.10.05 新增IPA生成与文本朗读功能
|
|
||||||
2025.09.25 优化了主界面UI
|
|
||||||
2025.09.19 更新了单词板,单词不再会重叠
|
|
||||||
|
|
||||||
BIN
public/fonts/NotoNaskhArabic-VariableFont_wght.ttf
Normal file
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 |
258
src/app/(features)/alphabet/AlphabetCard.tsx
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
||||||
|
import IconClick from "@/components/ui/buttons/IconClick";
|
||||||
|
import IMAGES from "@/config/images";
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
|
interface AlphabetCardProps {
|
||||||
|
alphabet: Letter[];
|
||||||
|
alphabetType: SupportedAlphabets;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AlphabetCard({ alphabet, alphabetType, onBack }: AlphabetCardProps) {
|
||||||
|
const t = useTranslations("alphabet");
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
const [showIPA, setShowIPA] = useState(true);
|
||||||
|
const [showLetter, setShowLetter] = useState(true);
|
||||||
|
const [showRoman, setShowRoman] = useState(false);
|
||||||
|
const [isRandomMode, setIsRandomMode] = useState(false);
|
||||||
|
|
||||||
|
// 只有日语假名显示罗马音按钮
|
||||||
|
const hasRomanization = alphabetType === "japanese";
|
||||||
|
|
||||||
|
const currentLetter = alphabet[currentIndex];
|
||||||
|
|
||||||
|
|
||||||
|
const goToNext = useCallback(() => {
|
||||||
|
if (isRandomMode) {
|
||||||
|
setCurrentIndex(Math.floor(Math.random() * alphabet.length));
|
||||||
|
} else {
|
||||||
|
setCurrentIndex((prev) => (prev === alphabet.length - 1 ? 0 : prev + 1));
|
||||||
|
}
|
||||||
|
}, [alphabet.length, isRandomMode]);
|
||||||
|
|
||||||
|
const goToPrevious = useCallback(() => {
|
||||||
|
if (isRandomMode) {
|
||||||
|
setCurrentIndex(Math.floor(Math.random() * alphabet.length));
|
||||||
|
} else {
|
||||||
|
setCurrentIndex((prev) => (prev === 0 ? alphabet.length - 1 : prev - 1));
|
||||||
|
}
|
||||||
|
}, [alphabet.length, isRandomMode]);
|
||||||
|
|
||||||
|
const goToRandom = useCallback(() => {
|
||||||
|
setCurrentIndex(Math.floor(Math.random() * alphabet.length));
|
||||||
|
}, [alphabet.length]);
|
||||||
|
|
||||||
|
// 键盘快捷键支持
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: globalThis.KeyboardEvent) => {
|
||||||
|
if (e.key === "ArrowLeft") {
|
||||||
|
goToPrevious();
|
||||||
|
} else if (e.key === "ArrowRight") {
|
||||||
|
goToNext();
|
||||||
|
} else if (e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
goToRandom();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
onBack();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [goToPrevious, goToNext, goToRandom, onBack]);
|
||||||
|
|
||||||
|
// 触摸滑动支持
|
||||||
|
const [touchStart, setTouchStart] = useState<number | null>(null);
|
||||||
|
const [touchEnd, setTouchEnd] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const minSwipeDistance = 50;
|
||||||
|
|
||||||
|
const onTouchStart = (e: React.TouchEvent) => {
|
||||||
|
setTouchEnd(null);
|
||||||
|
setTouchStart(e.targetTouches[0].clientX);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTouchMove = (e: React.TouchEvent) => {
|
||||||
|
setTouchEnd(e.targetTouches[0].clientX);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTouchEnd = () => {
|
||||||
|
if (!touchStart || !touchEnd) return;
|
||||||
|
|
||||||
|
const distance = touchStart - touchEnd;
|
||||||
|
const isLeftSwipe = distance > minSwipeDistance;
|
||||||
|
const isRightSwipe = distance < -minSwipeDistance;
|
||||||
|
|
||||||
|
if (isLeftSwipe) {
|
||||||
|
goToNext();
|
||||||
|
}
|
||||||
|
if (isRightSwipe) {
|
||||||
|
goToPrevious();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4 py-8">
|
||||||
|
<div className="w-full max-w-2xl">
|
||||||
|
{/* 返回按钮 */}
|
||||||
|
<div className="flex justify-end mb-4">
|
||||||
|
<IconClick
|
||||||
|
size={32}
|
||||||
|
alt="close"
|
||||||
|
src={IMAGES.close}
|
||||||
|
onClick={onBack}
|
||||||
|
className="bg-white rounded-full shadow-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 主卡片 */}
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl p-8 md:p-12">
|
||||||
|
{/* 进度指示器 */}
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{currentIndex + 1} / {alphabet.length}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLetter(!showLetter)}
|
||||||
|
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
||||||
|
showLetter
|
||||||
|
? "bg-[#35786f] text-white"
|
||||||
|
: "bg-gray-200 text-gray-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("letter")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowIPA(!showIPA)}
|
||||||
|
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
||||||
|
showIPA
|
||||||
|
? "bg-[#35786f] text-white"
|
||||||
|
: "bg-gray-200 text-gray-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
IPA
|
||||||
|
</button>
|
||||||
|
{hasRomanization && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRoman(!showRoman)}
|
||||||
|
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
||||||
|
showRoman
|
||||||
|
? "bg-[#35786f] text-white"
|
||||||
|
: "bg-gray-200 text-gray-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("roman")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsRandomMode(!isRandomMode)}
|
||||||
|
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
||||||
|
isRandomMode
|
||||||
|
? "bg-[#35786f] text-white"
|
||||||
|
: "bg-gray-200 text-gray-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("random")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 字母显示区域 */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
{showLetter ? (
|
||||||
|
<div className="text-6xl md:text-8xl font-bold text-gray-800 mb-4">
|
||||||
|
{currentLetter.letter}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-6xl md:text-8xl font-bold text-gray-300 mb-4 h-20 md:h-24 flex items-center justify-center">
|
||||||
|
<span className="text-2xl md:text-3xl text-gray-400">?</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showIPA && (
|
||||||
|
<div className="text-2xl md:text-3xl text-gray-600 mb-2">
|
||||||
|
{currentLetter.letter_sound_ipa}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showRoman && hasRomanization && currentLetter.roman_letter && (
|
||||||
|
<div className="text-lg md:text-xl text-gray-500">
|
||||||
|
{currentLetter.roman_letter}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 导航控制 */}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<button
|
||||||
|
onClick={goToPrevious}
|
||||||
|
className="p-3 rounded-full bg-gray-100 hover:bg-gray-200 transition-colors"
|
||||||
|
aria-label="上一个字母"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={24} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
{isRandomMode ? (
|
||||||
|
<button
|
||||||
|
onClick={goToRandom}
|
||||||
|
className="px-4 py-2 rounded-full bg-[#35786f] text-white text-sm font-medium hover:bg-[#2d5f58] transition-colors"
|
||||||
|
>
|
||||||
|
{t("randomNext")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-1 flex-wrap max-w-xs justify-center">
|
||||||
|
{alphabet.slice(0, 20).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`h-2 rounded-full transition-all ${
|
||||||
|
index === currentIndex
|
||||||
|
? "w-8 bg-[#35786f]"
|
||||||
|
: "w-2 bg-gray-300"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{alphabet.length > 20 && (
|
||||||
|
<div className="text-xs text-gray-500 flex items-center">...</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={goToNext}
|
||||||
|
className="p-3 rounded-full bg-gray-100 hover:bg-gray-200 transition-colors"
|
||||||
|
aria-label="下一个字母"
|
||||||
|
>
|
||||||
|
<ChevronRight size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作提示 */}
|
||||||
|
<div className="text-center mt-6 text-white text-sm">
|
||||||
|
<p>
|
||||||
|
{isRandomMode
|
||||||
|
? "使用左右箭头键或空格键随机切换字母,ESC键返回"
|
||||||
|
: "使用左右箭头键或滑动切换字母,ESC键返回"
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 触摸事件处理 */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 pointer-events-none"
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
|
onTouchMove={onTouchMove}
|
||||||
|
onTouchEnd={onTouchEnd}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
src/app/(features)/alphabet/MemoryCard.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import LightButton from "@/components/ui/buttons/LightButton";
|
||||||
|
import IconClick from "@/components/ui/buttons/IconClick";
|
||||||
|
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 default 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-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={refresh}
|
||||||
|
></IconClick>
|
||||||
|
<IconClick
|
||||||
|
size={48}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
src/app/(features)/alphabet/page.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
||||||
|
import Container from "@/components/ui/Container";
|
||||||
|
import LightButton from "@/components/ui/buttons/LightButton";
|
||||||
|
import AlphabetCard from "./AlphabetCard";
|
||||||
|
|
||||||
|
export default function Alphabet() {
|
||||||
|
const t = useTranslations("alphabet");
|
||||||
|
const [chosenAlphabet, setChosenAlphabet] = useState<SupportedAlphabets | null>(null);
|
||||||
|
const [alphabetData, setAlphabetData] = useState<Letter[] | null>(null);
|
||||||
|
const [loadingState, setLoadingState] = useState<"idle" | "loading" | "success" | "error">("idle");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadAlphabetData = async () => {
|
||||||
|
if (chosenAlphabet && !alphabetData) {
|
||||||
|
try {
|
||||||
|
setLoadingState("loading");
|
||||||
|
|
||||||
|
const res = await fetch("/alphabets/" + chosenAlphabet + ".json");
|
||||||
|
if (!res.ok) throw new Error("Network response was not ok");
|
||||||
|
|
||||||
|
const obj = await res.json();
|
||||||
|
setAlphabetData(obj as Letter[]);
|
||||||
|
setLoadingState("success");
|
||||||
|
} catch (error) {
|
||||||
|
setLoadingState("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadAlphabetData();
|
||||||
|
}, [chosenAlphabet, alphabetData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loadingState === "error") {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setLoadingState("idle");
|
||||||
|
setChosenAlphabet(null);
|
||||||
|
setAlphabetData(null);
|
||||||
|
}, 2000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [loadingState]);
|
||||||
|
|
||||||
|
// 语言选择界面
|
||||||
|
if (!chosenAlphabet) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex flex-col items-center justify-center px-4">
|
||||||
|
<Container className="p-8 max-w-2xl w-full text-center">
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
|
||||||
|
{t("chooseCharacters")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 mb-8 text-lg">
|
||||||
|
选择一种语言的字母表开始学习
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<LightButton
|
||||||
|
onClick={() => setChosenAlphabet("japanese")}
|
||||||
|
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-2xl mb-2">あいうえお</span>
|
||||||
|
<span>{t("japanese")}</span>
|
||||||
|
</div>
|
||||||
|
</LightButton>
|
||||||
|
|
||||||
|
<LightButton
|
||||||
|
onClick={() => setChosenAlphabet("english")}
|
||||||
|
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-2xl mb-2">ABC</span>
|
||||||
|
<span>{t("english")}</span>
|
||||||
|
</div>
|
||||||
|
</LightButton>
|
||||||
|
|
||||||
|
<LightButton
|
||||||
|
onClick={() => setChosenAlphabet("uyghur")}
|
||||||
|
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-2xl mb-2">ئۇيغۇر</span>
|
||||||
|
<span>{t("uyghur")}</span>
|
||||||
|
</div>
|
||||||
|
</LightButton>
|
||||||
|
|
||||||
|
<LightButton
|
||||||
|
onClick={() => setChosenAlphabet("esperanto")}
|
||||||
|
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-2xl mb-2">ABCĜĤ</span>
|
||||||
|
<span>{t("esperanto")}</span>
|
||||||
|
</div>
|
||||||
|
</LightButton>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载状态
|
||||||
|
if (loadingState === "loading") {
|
||||||
|
return (
|
||||||
|
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center">
|
||||||
|
<Container className="p-8 text-center">
|
||||||
|
<div className="text-2xl text-gray-600">{t("loading")}</div>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 错误状态
|
||||||
|
if (loadingState === "error") {
|
||||||
|
return (
|
||||||
|
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center">
|
||||||
|
<Container className="p-8 text-center">
|
||||||
|
<div className="text-2xl text-red-600">{t("loadFailed")}</div>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字母卡片界面
|
||||||
|
if (loadingState === "success" && alphabetData) {
|
||||||
|
return (
|
||||||
|
<AlphabetCard
|
||||||
|
alphabet={alphabetData}
|
||||||
|
alphabetType={chosenAlphabet}
|
||||||
|
onBack={() => {
|
||||||
|
setChosenAlphabet(null);
|
||||||
|
setAlphabetData(null);
|
||||||
|
setLoadingState("idle");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
64
src/app/(features)/memorize/FolderSelector.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Container from "@/components/ui/Container";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Center } from "@/components/common/Center";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Folder } from "../../../../generated/prisma/browser";
|
||||||
|
import { Folder as Fd } from "lucide-react";
|
||||||
|
|
||||||
|
interface FolderSelectorProps {
|
||||||
|
folders: (Folder & { total: number })[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
|
||||||
|
const t = useTranslations("memorize.folder_selector");
|
||||||
|
const router = useRouter();
|
||||||
|
return (
|
||||||
|
<Center>
|
||||||
|
<Container className="p-6 gap-4 flex flex-col">
|
||||||
|
{(folders.length === 0 && (
|
||||||
|
<h1 className="text-2xl text-gray-900 font-light">
|
||||||
|
{t("noFolders")}
|
||||||
|
<Link className="text-blue-900 border-b" href={"/folders"}>
|
||||||
|
folders
|
||||||
|
</Link>
|
||||||
|
</h1>
|
||||||
|
)) || (
|
||||||
|
<>
|
||||||
|
<h1 className="text-2xl text-gray-900 font-light">
|
||||||
|
{t("selectFolder")}
|
||||||
|
</h1>
|
||||||
|
<div className="text-gray-900 border border-gray-200 rounded-2xl max-h-96 overflow-y-auto">
|
||||||
|
{folders
|
||||||
|
.toSorted((a, b) => a.id - b.id)
|
||||||
|
.map((folder) => (
|
||||||
|
<div
|
||||||
|
key={folder.id}
|
||||||
|
onClick={() =>
|
||||||
|
router.push(`/memorize?folder_id=${folder.id}`)
|
||||||
|
}
|
||||||
|
className="flex flex-row justify-center items-center group p-2 gap-2 hover:cursor-pointer hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<Fd />
|
||||||
|
<div className="flex-1 flex gap-2">
|
||||||
|
<span className="group-hover:text-blue-500">
|
||||||
|
{t("folderInfo", {
|
||||||
|
id: folder.id,
|
||||||
|
name: folder.name,
|
||||||
|
count: folder.total,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FolderSelector;
|
||||||
173
src/app/(features)/memorize/Memorize.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import LightButton from "@/components/ui/buttons/LightButton";
|
||||||
|
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||||
|
import { getTTSAudioUrl } from "@/lib/browser/tts";
|
||||||
|
import { VOICES } from "@/config/locales";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import localFont from "next/font/local";
|
||||||
|
import { isNonNegativeInteger, SeededRandom } from "@/lib/utils";
|
||||||
|
import { Pair } from "../../../../generated/prisma/browser";
|
||||||
|
|
||||||
|
const myFont = localFont({
|
||||||
|
src: "../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf",
|
||||||
|
});
|
||||||
|
|
||||||
|
interface MemorizeProps {
|
||||||
|
textPairs: Pair[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
|
||||||
|
const t = useTranslations("memorize.memorize");
|
||||||
|
const [reverse, setReverse] = useState(false);
|
||||||
|
const [dictation, setDictation] = useState(false);
|
||||||
|
const [disorder, setDisorder] = useState(false);
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
const [show, setShow] = useState<"question" | "answer">("question");
|
||||||
|
const { load, play } = useAudioPlayer();
|
||||||
|
|
||||||
|
if (textPairs.length === 0) {
|
||||||
|
return <p>{t("noTextPairs")}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rng = new SeededRandom(textPairs[0].folderId);
|
||||||
|
const disorderedTextPairs = textPairs.toSorted(() => rng.next() - 0.5);
|
||||||
|
|
||||||
|
textPairs.sort((a, b) => a.id - b.id);
|
||||||
|
|
||||||
|
const getTextPairs = () => disorder ? disorderedTextPairs : textPairs;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{(getTextPairs().length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="text-center">
|
||||||
|
<div
|
||||||
|
className="text-sm text-gray-500"
|
||||||
|
onClick={() => {
|
||||||
|
const newIndex = prompt("Input a index number.")?.trim();
|
||||||
|
if (
|
||||||
|
newIndex &&
|
||||||
|
isNonNegativeInteger(newIndex) &&
|
||||||
|
parseInt(newIndex) <= textPairs.length &&
|
||||||
|
parseInt(newIndex) > 0
|
||||||
|
) {
|
||||||
|
setIndex(parseInt(newIndex) - 1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
{"/" + getTextPairs().length}
|
||||||
|
</div>
|
||||||
|
<div className={`h-[40dvh] md:px-16 px-4 ${myFont.className}`}>
|
||||||
|
{(() => {
|
||||||
|
const createText = (text: string) => {
|
||||||
|
return (
|
||||||
|
<div className="text-gray-900 text-xl border-y border-y-gray-200 p-4 md:text-3xl h-[20dvh] overflow-y-auto">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [text1, text2] = reverse
|
||||||
|
? [getTextPairs()[index].text2, getTextPairs()[index].text1]
|
||||||
|
: [getTextPairs()[index].text1, getTextPairs()[index].text2];
|
||||||
|
|
||||||
|
if (dictation) {
|
||||||
|
// dictation
|
||||||
|
if (show === "question") {
|
||||||
|
return createText("");
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{createText(text1)}
|
||||||
|
{createText(text2)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// non-dictation
|
||||||
|
if (show === "question") {
|
||||||
|
return createText(text1);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{createText(text1)}
|
||||||
|
{createText(text2)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row gap-2 items-center justify-center flex-wrap">
|
||||||
|
<LightButton
|
||||||
|
className="w-20"
|
||||||
|
onClick={async () => {
|
||||||
|
if (show === "answer") {
|
||||||
|
const newIndex = (index + 1) % getTextPairs().length;
|
||||||
|
setIndex(newIndex);
|
||||||
|
if (dictation)
|
||||||
|
getTTSAudioUrl(
|
||||||
|
getTextPairs()[newIndex][reverse ? "text2" : "text1"],
|
||||||
|
VOICES.find(
|
||||||
|
(v) =>
|
||||||
|
v.locale ===
|
||||||
|
getTextPairs()[newIndex][
|
||||||
|
reverse ? "locale2" : "locale1"
|
||||||
|
],
|
||||||
|
)!.short_name,
|
||||||
|
).then((url) => {
|
||||||
|
load(url);
|
||||||
|
play();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setShow(show === "question" ? "answer" : "question");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{show === "question" ? t("answer") : t("next")}
|
||||||
|
</LightButton>
|
||||||
|
<LightButton
|
||||||
|
onClick={() => {
|
||||||
|
setIndex(
|
||||||
|
(index - 1 + getTextPairs().length) % getTextPairs().length,
|
||||||
|
);
|
||||||
|
setShow("question");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("previous")}
|
||||||
|
</LightButton>
|
||||||
|
<LightButton
|
||||||
|
onClick={() => {
|
||||||
|
setReverse(!reverse);
|
||||||
|
}}
|
||||||
|
selected={reverse}
|
||||||
|
>
|
||||||
|
{t("reverse")}
|
||||||
|
</LightButton>
|
||||||
|
<LightButton
|
||||||
|
onClick={() => {
|
||||||
|
setDictation(!dictation);
|
||||||
|
}}
|
||||||
|
selected={dictation}
|
||||||
|
>
|
||||||
|
{t("dictation")}
|
||||||
|
</LightButton>
|
||||||
|
<LightButton
|
||||||
|
onClick={() => {
|
||||||
|
setDisorder(!disorder);
|
||||||
|
}}
|
||||||
|
selected={disorder}
|
||||||
|
>
|
||||||
|
{t("disorder")}
|
||||||
|
</LightButton>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)) || <p>{t("noTextPairs")}</p>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Memorize;
|
||||||
53
src/app/(features)/memorize/page.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import {
|
||||||
|
getFoldersWithTotalPairsByUserId,
|
||||||
|
getUserIdByFolderId,
|
||||||
|
} from "@/lib/server/services/folderService";
|
||||||
|
import { isNonNegativeInteger } from "@/lib/utils";
|
||||||
|
import FolderSelector from "./FolderSelector";
|
||||||
|
import Memorize from "./Memorize";
|
||||||
|
import { getPairsByFolderId } from "@/lib/server/services/pairService";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
export default async function MemorizePage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ folder_id?: string; }>;
|
||||||
|
}) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
const tParam = (await searchParams).folder_id;
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
redirect(
|
||||||
|
`/auth?redirect=/memorize${(await searchParams).folder_id
|
||||||
|
? `?folder_id=${tParam}`
|
||||||
|
: ""
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = await getTranslations("memorize.page");
|
||||||
|
|
||||||
|
const folder_id = tParam
|
||||||
|
? isNonNegativeInteger(tParam)
|
||||||
|
? parseInt(tParam)
|
||||||
|
: null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!folder_id) {
|
||||||
|
return (
|
||||||
|
<FolderSelector
|
||||||
|
folders={await getFoldersWithTotalPairsByUserId(session.user.id)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const owner = await getUserIdByFolderId(folder_id);
|
||||||
|
if (owner !== session.user.id) {
|
||||||
|
return <p>{t("unauthorized")}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Memorize textPairs={await getPairsByFolderId(folder_id)} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export default function SubtitleDisplay({ subtitle }: { subtitle: string }) {
|
||||||
|
const words = subtitle.match(/\b[\w']+(?:-[\w']+)*\b/g) || [];
|
||||||
|
let i = 0;
|
||||||
|
return (
|
||||||
|
<div className="w-full subtitle overflow-auto h-16 mt-2 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
216
src/app/(features)/srt-player/VideoPlayer/VideoPanel.tsx
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import { useState, useRef, forwardRef, useEffect, useCallback } from "react";
|
||||||
|
import SubtitleDisplay from "./SubtitleDisplay";
|
||||||
|
import LightButton from "@/components/ui/buttons/LightButton";
|
||||||
|
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>
|
||||||
|
<input
|
||||||
|
className="seekbar"
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={srtLength}
|
||||||
|
onChange={handleSeek}
|
||||||
|
step={1}
|
||||||
|
value={progress}
|
||||||
|
></input>
|
||||||
|
<span>{spanText}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
VideoPanel.displayName = "VideoPanel";
|
||||||
|
|
||||||
|
export default VideoPanel;
|
||||||
45
src/app/(features)/srt-player/components/atoms/FileInput.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useRef } from "react";
|
||||||
|
import { FileInputProps } from "../../types/controls";
|
||||||
|
|
||||||
|
interface FileInputComponentProps extends FileInputProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FileInput({ accept, onFileSelect, disabled, className, children }: FileInputComponentProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleClick = React.useCallback(() => {
|
||||||
|
if (!disabled && inputRef.current) {
|
||||||
|
inputRef.current.click();
|
||||||
|
}
|
||||||
|
}, [disabled]);
|
||||||
|
|
||||||
|
const handleChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
onFileSelect(file);
|
||||||
|
}
|
||||||
|
}, [onFileSelect]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={disabled}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`px-2 py-1 rounded shadow font-bold hover:cursor-pointer hover:bg-gray-200 text-gray-800 bg-white ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className || ''}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import LightButton from "@/components/ui/buttons/LightButton";
|
||||||
|
import { PlayButtonProps } from "../../types/player";
|
||||||
|
|
||||||
|
export default function PlayButton({ isPlaying, onToggle, disabled, className }: PlayButtonProps) {
|
||||||
|
const t = useTranslations("srt_player");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LightButton
|
||||||
|
onClick={disabled ? undefined : onToggle}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`px-4 py-2 ${className || ''}`}
|
||||||
|
>
|
||||||
|
{isPlaying ? t("pause") : t("play")}
|
||||||
|
</LightButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
src/app/(features)/srt-player/components/atoms/SeekBar.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { SeekBarProps } from "../../types/player";
|
||||||
|
|
||||||
|
export default function SeekBar({ value, max, onChange, disabled, className }: SeekBarProps) {
|
||||||
|
const handleChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newValue = parseInt(event.target.value);
|
||||||
|
onChange(newValue);
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={max}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className || ''}`}
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(to right, #374151 0%, #374151 ${(value / max) * 100}%, #e5e7eb ${(value / max) * 100}%, #e5e7eb 100%)`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import LightButton from "@/components/ui/buttons/LightButton";
|
||||||
|
import { SpeedControlProps } from "../../types/player";
|
||||||
|
import { getPlaybackRateOptions, getPlaybackRateLabel } from "../../utils/timeUtils";
|
||||||
|
|
||||||
|
export default function SpeedControl({ playbackRate, onPlaybackRateChange, disabled, className }: SpeedControlProps) {
|
||||||
|
const speedOptions = getPlaybackRateOptions();
|
||||||
|
|
||||||
|
const handleSpeedChange = React.useCallback(() => {
|
||||||
|
const currentIndex = speedOptions.indexOf(playbackRate);
|
||||||
|
const nextIndex = (currentIndex + 1) % speedOptions.length;
|
||||||
|
onPlaybackRateChange(speedOptions[nextIndex]);
|
||||||
|
}, [playbackRate, onPlaybackRateChange, speedOptions]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LightButton
|
||||||
|
onClick={disabled ? undefined : handleSpeedChange}
|
||||||
|
className={`${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className || ''}`}
|
||||||
|
>
|
||||||
|
{getPlaybackRateLabel(playbackRate)}
|
||||||
|
</LightButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { SubtitleTextProps } from "../../types/subtitle";
|
||||||
|
|
||||||
|
export default function SubtitleText({ text, onWordClick, style, className }: SubtitleTextProps) {
|
||||||
|
const handleWordClick = React.useCallback((word: string) => {
|
||||||
|
onWordClick?.(word);
|
||||||
|
}, [onWordClick]);
|
||||||
|
|
||||||
|
// 将文本分割成单词,保持标点符号
|
||||||
|
const renderTextWithClickableWords = () => {
|
||||||
|
if (!text) return null;
|
||||||
|
|
||||||
|
// 匹配单词和标点符号
|
||||||
|
const parts = text.match(/[\w']+|[^\w\s]+|\s+/g) || [];
|
||||||
|
|
||||||
|
return parts.map((part, index) => {
|
||||||
|
// 如果是单词(字母和撇号组成)
|
||||||
|
if (/^[\w']+$/.test(part)) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
onClick={() => handleWordClick(part)}
|
||||||
|
className="hover:bg-gray-700 hover:underline hover:cursor-pointer rounded px-1 transition-colors"
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 如果是空格或其他字符,直接渲染
|
||||||
|
return <span key={index}>{part}</span>;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`overflow-auto h-16 mt-2 wrap-break-words font-sans text-white text-center text-2xl ${className || ''}`}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{renderTextWithClickableWords()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { forwardRef } from "react";
|
||||||
|
import { VideoElementProps } from "../../types/player";
|
||||||
|
|
||||||
|
const VideoElement = forwardRef<HTMLVideoElement, VideoElementProps>(
|
||||||
|
({ src, onTimeUpdate, onLoadedMetadata, onPlay, onPause, onEnded, className }, ref) => {
|
||||||
|
const handleTimeUpdate = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||||
|
const video = event.currentTarget;
|
||||||
|
onTimeUpdate?.(video.currentTime);
|
||||||
|
}, [onTimeUpdate]);
|
||||||
|
|
||||||
|
const handleLoadedMetadata = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||||
|
const video = event.currentTarget;
|
||||||
|
onLoadedMetadata?.(video.duration);
|
||||||
|
}, [onLoadedMetadata]);
|
||||||
|
|
||||||
|
const handlePlay = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||||
|
onPlay?.();
|
||||||
|
}, [onPlay]);
|
||||||
|
|
||||||
|
const handlePause = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||||
|
onPause?.();
|
||||||
|
}, [onPause]);
|
||||||
|
|
||||||
|
const handleEnded = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||||
|
onEnded?.();
|
||||||
|
}, [onEnded]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
ref={ref}
|
||||||
|
src={src}
|
||||||
|
onTimeUpdate={handleTimeUpdate}
|
||||||
|
onLoadedMetadata={handleLoadedMetadata}
|
||||||
|
onPlay={handlePlay}
|
||||||
|
onPause={handlePause}
|
||||||
|
onEnded={handleEnded}
|
||||||
|
className={`bg-gray-200 w-full ${className || ""}`}
|
||||||
|
playsInline
|
||||||
|
controls={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
VideoElement.displayName = "VideoElement";
|
||||||
|
|
||||||
|
export default VideoElement;
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { ChevronLeft, ChevronRight, RotateCcw, Pause } from "lucide-react";
|
||||||
|
import DarkButton from "@/components/ui/buttons/DarkButton";
|
||||||
|
import { ControlBarProps } from "../../types/controls";
|
||||||
|
import PlayButton from "../atoms/PlayButton";
|
||||||
|
import SpeedControl from "../atoms/SpeedControl";
|
||||||
|
|
||||||
|
export default function ControlBar({
|
||||||
|
isPlaying,
|
||||||
|
onPlayPause,
|
||||||
|
onPrevious,
|
||||||
|
onNext,
|
||||||
|
onRestart,
|
||||||
|
playbackRate,
|
||||||
|
onPlaybackRateChange,
|
||||||
|
autoPause,
|
||||||
|
onAutoPauseToggle,
|
||||||
|
disabled,
|
||||||
|
className
|
||||||
|
}: ControlBarProps) {
|
||||||
|
const t = useTranslations("srt_player");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-wrap gap-2 justify-center ${className || ''}`}>
|
||||||
|
<PlayButton
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
onToggle={onPlayPause}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DarkButton
|
||||||
|
onClick={disabled ? undefined : onPrevious}
|
||||||
|
disabled={disabled}
|
||||||
|
className="flex items-center px-3 py-2"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4 mr-2" />
|
||||||
|
{t("previous")}
|
||||||
|
</DarkButton>
|
||||||
|
|
||||||
|
<DarkButton
|
||||||
|
onClick={disabled ? undefined : onNext}
|
||||||
|
disabled={disabled}
|
||||||
|
className="flex items-center px-3 py-2"
|
||||||
|
>
|
||||||
|
{t("next")}
|
||||||
|
<ChevronRight className="w-4 h-4 ml-2" />
|
||||||
|
</DarkButton>
|
||||||
|
|
||||||
|
<DarkButton
|
||||||
|
onClick={disabled ? undefined : onRestart}
|
||||||
|
disabled={disabled}
|
||||||
|
className="flex items-center px-3 py-2"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4 mr-2" />
|
||||||
|
{t("restart")}
|
||||||
|
</DarkButton>
|
||||||
|
|
||||||
|
<SpeedControl
|
||||||
|
playbackRate={playbackRate}
|
||||||
|
onPlaybackRateChange={onPlaybackRateChange}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DarkButton
|
||||||
|
onClick={disabled ? undefined : onAutoPauseToggle}
|
||||||
|
disabled={disabled}
|
||||||
|
className="flex items-center px-3 py-2"
|
||||||
|
>
|
||||||
|
<Pause className="w-4 h-4 mr-2" />
|
||||||
|
{t("autoPause", { enabled: autoPause ? t("on") : t("off") })}
|
||||||
|
</DarkButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { SubtitleDisplayProps } from "../../types/subtitle";
|
||||||
|
import SubtitleText from "../atoms/SubtitleText";
|
||||||
|
|
||||||
|
export default function SubtitleArea({ subtitle, onWordClick, settings, className }: SubtitleDisplayProps) {
|
||||||
|
const handleWordClick = React.useCallback((word: string) => {
|
||||||
|
// 打开有道词典页面查询单词
|
||||||
|
window.open(
|
||||||
|
`https://www.youdao.com/result?word=${encodeURIComponent(word)}&lang=en`,
|
||||||
|
"_blank"
|
||||||
|
);
|
||||||
|
onWordClick?.(word);
|
||||||
|
}, [onWordClick]);
|
||||||
|
|
||||||
|
const subtitleStyle = React.useMemo(() => {
|
||||||
|
if (!settings) return { backgroundColor: 'rgba(0, 0, 0, 0.5)' };
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor: settings.backgroundColor,
|
||||||
|
color: settings.textColor,
|
||||||
|
fontSize: `${settings.fontSize}px`,
|
||||||
|
fontFamily: settings.fontFamily,
|
||||||
|
opacity: settings.opacity,
|
||||||
|
};
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SubtitleText
|
||||||
|
text={subtitle}
|
||||||
|
onWordClick={handleWordClick}
|
||||||
|
style={subtitleStyle}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Video, FileText } from "lucide-react";
|
||||||
|
import DarkButton from "@/components/ui/buttons/DarkButton";
|
||||||
|
import { FileUploadProps } from "../../types/controls";
|
||||||
|
import { useFileUpload } from "../../hooks/useFileUpload";
|
||||||
|
|
||||||
|
export default function UploadZone({ onVideoUpload, onSubtitleUpload, className }: FileUploadProps) {
|
||||||
|
const t = useTranslations("srt_player");
|
||||||
|
const { uploadVideo, uploadSubtitle } = useFileUpload();
|
||||||
|
|
||||||
|
const handleVideoUpload = React.useCallback(() => {
|
||||||
|
uploadVideo(onVideoUpload, (error) => {
|
||||||
|
toast.error(t("videoUploadFailed") + ": " + error.message);
|
||||||
|
});
|
||||||
|
}, [uploadVideo, onVideoUpload, t]);
|
||||||
|
|
||||||
|
const handleSubtitleUpload = React.useCallback(() => {
|
||||||
|
uploadSubtitle(onSubtitleUpload, (error) => {
|
||||||
|
toast.error(t("subtitleUploadFailed") + ": " + error.message);
|
||||||
|
});
|
||||||
|
}, [uploadSubtitle, onSubtitleUpload, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex gap-3 ${className || ''}`}>
|
||||||
|
<DarkButton
|
||||||
|
onClick={handleVideoUpload}
|
||||||
|
className="flex-1 py-2 px-3 text-sm"
|
||||||
|
>
|
||||||
|
<Video className="w-4 h-4 mr-2" />
|
||||||
|
{t("uploadVideo")}
|
||||||
|
</DarkButton>
|
||||||
|
|
||||||
|
<DarkButton
|
||||||
|
onClick={handleSubtitleUpload}
|
||||||
|
className="flex-1 py-2 px-3 text-sm"
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4 mr-2" />
|
||||||
|
{t("uploadSubtitle")}
|
||||||
|
</DarkButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { forwardRef } from "react";
|
||||||
|
import { VideoElementProps } from "../../types/player";
|
||||||
|
import VideoElement from "../atoms/VideoElement";
|
||||||
|
|
||||||
|
interface VideoPlayerComponentProps extends VideoElementProps {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerComponentProps>(
|
||||||
|
({
|
||||||
|
src,
|
||||||
|
onTimeUpdate,
|
||||||
|
onLoadedMetadata,
|
||||||
|
onPlay,
|
||||||
|
onPause,
|
||||||
|
onEnded,
|
||||||
|
className,
|
||||||
|
children
|
||||||
|
}, ref) => {
|
||||||
|
return (
|
||||||
|
<div className={`w-full flex flex-col ${className || ''}`}>
|
||||||
|
<VideoElement
|
||||||
|
ref={ref}
|
||||||
|
src={src}
|
||||||
|
onTimeUpdate={onTimeUpdate}
|
||||||
|
onLoadedMetadata={onLoadedMetadata}
|
||||||
|
onPlay={onPlay}
|
||||||
|
onPause={onPause}
|
||||||
|
onEnded={onEnded}
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
VideoPlayer.displayName = "VideoPlayer";
|
||||||
|
|
||||||
|
export default VideoPlayer;
|
||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
280
src/app/(features)/srt-player/page.tsx
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Video, FileText } from "lucide-react";
|
||||||
|
import { useSrtPlayer } from "./hooks/useSrtPlayer";
|
||||||
|
import { useSubtitleSync } from "./hooks/useSubtitleSync";
|
||||||
|
import { useKeyboardShortcuts, createSrtPlayerShortcuts } from "./hooks/useKeyboardShortcuts";
|
||||||
|
import { useFileUpload } from "./hooks/useFileUpload";
|
||||||
|
import { loadSubtitle } from "./utils/subtitleParser";
|
||||||
|
import VideoPlayer from "./components/compounds/VideoPlayer";
|
||||||
|
import SubtitleArea from "./components/compounds/SubtitleArea";
|
||||||
|
import ControlBar from "./components/compounds/ControlBar";
|
||||||
|
import UploadZone from "./components/compounds/UploadZone";
|
||||||
|
import SeekBar from "./components/atoms/SeekBar";
|
||||||
|
import DarkButton from "@/components/ui/buttons/DarkButton";
|
||||||
|
|
||||||
|
export default function SrtPlayerPage() {
|
||||||
|
const t = useTranslations("home");
|
||||||
|
const srtT = useTranslations("srt_player");
|
||||||
|
const { uploadVideo, uploadSubtitle } = useFileUpload();
|
||||||
|
const {
|
||||||
|
state,
|
||||||
|
actions,
|
||||||
|
videoRef,
|
||||||
|
videoEventHandlers,
|
||||||
|
subtitleActions
|
||||||
|
} = useSrtPlayer();
|
||||||
|
|
||||||
|
// 字幕同步
|
||||||
|
useSubtitleSync(
|
||||||
|
state.subtitle.data,
|
||||||
|
state.video.currentTime,
|
||||||
|
state.video.isPlaying,
|
||||||
|
state.controls.autoPause,
|
||||||
|
(subtitle) => {
|
||||||
|
if (subtitle) {
|
||||||
|
subtitleActions.setCurrentSubtitle(subtitle.text, subtitle.index);
|
||||||
|
} else {
|
||||||
|
subtitleActions.setCurrentSubtitle("", null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(subtitle) => {
|
||||||
|
// 自动暂停逻辑
|
||||||
|
actions.seek(subtitle.start);
|
||||||
|
actions.pause();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 键盘快捷键
|
||||||
|
const shortcuts = React.useMemo(() =>
|
||||||
|
createSrtPlayerShortcuts(
|
||||||
|
actions.togglePlayPause,
|
||||||
|
actions.nextSubtitle,
|
||||||
|
actions.previousSubtitle,
|
||||||
|
actions.restartSubtitle,
|
||||||
|
actions.toggleAutoPause
|
||||||
|
), [
|
||||||
|
actions.togglePlayPause,
|
||||||
|
actions.nextSubtitle,
|
||||||
|
actions.previousSubtitle,
|
||||||
|
actions.restartSubtitle,
|
||||||
|
actions.toggleAutoPause
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
useKeyboardShortcuts(shortcuts);
|
||||||
|
|
||||||
|
// 处理字幕文件加载
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (state.subtitle.url) {
|
||||||
|
loadSubtitle(state.subtitle.url)
|
||||||
|
.then(subtitleData => {
|
||||||
|
subtitleActions.setSubtitleData(subtitleData);
|
||||||
|
toast.success(srtT("subtitleLoadSuccess"));
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
toast.error(srtT("subtitleLoadFailed") + ": " + error.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [srtT, state.subtitle.url, subtitleActions]);
|
||||||
|
|
||||||
|
// 处理进度条变化
|
||||||
|
const handleSeek = React.useCallback((index: number) => {
|
||||||
|
if (state.subtitle.data[index]) {
|
||||||
|
actions.seek(state.subtitle.data[index].start);
|
||||||
|
}
|
||||||
|
}, [state.subtitle.data, actions]);
|
||||||
|
|
||||||
|
// 处理视频上传
|
||||||
|
const handleVideoUpload = React.useCallback(() => {
|
||||||
|
uploadVideo(actions.setVideoUrl, (error) => {
|
||||||
|
toast.error(srtT("videoUploadFailed") + ": " + error.message);
|
||||||
|
});
|
||||||
|
}, [uploadVideo, actions.setVideoUrl, srtT]);
|
||||||
|
|
||||||
|
// 处理字幕上传
|
||||||
|
const handleSubtitleUpload = React.useCallback(() => {
|
||||||
|
uploadSubtitle(actions.setSubtitleUrl, (error) => {
|
||||||
|
toast.error(srtT("subtitleUploadFailed") + ": " + error.message);
|
||||||
|
});
|
||||||
|
}, [uploadSubtitle, actions.setSubtitleUrl, srtT]);
|
||||||
|
|
||||||
|
// 检查是否可以播放
|
||||||
|
const canPlay = state.video.url && state.subtitle.url && state.subtitle.data.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
{/* 标题区域 */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-4xl font-bold text-gray-800 mb-2">
|
||||||
|
{t("srtPlayer.name")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-gray-600">
|
||||||
|
{t("srtPlayer.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 主要内容区域 */}
|
||||||
|
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
|
||||||
|
{/* 视频播放器区域 */}
|
||||||
|
<div className="aspect-video bg-black relative">
|
||||||
|
{(!state.video.url || !state.subtitle.url || state.subtitle.data.length === 0) && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-75 z-10">
|
||||||
|
<div className="text-center text-white">
|
||||||
|
<p className="text-lg mb-2">
|
||||||
|
{!state.video.url && !state.subtitle.url
|
||||||
|
? srtT("uploadVideoAndSubtitle")
|
||||||
|
: !state.video.url
|
||||||
|
? srtT("uploadVideoFile")
|
||||||
|
: !state.subtitle.url
|
||||||
|
? srtT("uploadSubtitleFile")
|
||||||
|
: srtT("processingSubtitle")
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
{(!state.video.url || !state.subtitle.url) && (
|
||||||
|
<p className="text-sm text-gray-300">
|
||||||
|
{srtT("needBothFiles")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.video.url && (
|
||||||
|
<VideoPlayer
|
||||||
|
ref={videoRef}
|
||||||
|
src={state.video.url}
|
||||||
|
{...videoEventHandlers}
|
||||||
|
className="w-full h-full"
|
||||||
|
>
|
||||||
|
{state.subtitle.url && state.subtitle.data.length > 0 && (
|
||||||
|
<SubtitleArea
|
||||||
|
subtitle={state.subtitle.currentText}
|
||||||
|
settings={state.subtitle.settings}
|
||||||
|
className="absolute bottom-0 left-0 right-0 px-4 py-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</VideoPlayer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 控制面板 */}
|
||||||
|
<div className="p-3 bg-gray-50 border-t">
|
||||||
|
{/* 上传区域和状态指示器 */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className={`flex-1 p-2 rounded-lg border-2 transition-all ${state.video.url
|
||||||
|
? 'border-gray-800 bg-gray-100'
|
||||||
|
: 'border-gray-300 bg-white'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Video className="w-5 h-5 text-gray-600" />
|
||||||
|
<div className="text-left">
|
||||||
|
<h3 className="font-semibold text-gray-800 text-sm">{srtT("videoFile")}</h3>
|
||||||
|
<p className="text-xs text-gray-600">
|
||||||
|
{state.video.url ? srtT("uploaded") : srtT("notUploaded")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DarkButton
|
||||||
|
onClick={state.video.url ? undefined : handleVideoUpload}
|
||||||
|
disabled={!!state.video.url}
|
||||||
|
className="px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
{state.video.url ? srtT("uploaded") : srtT("upload")}
|
||||||
|
</DarkButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`flex-1 p-2 rounded-lg border-2 transition-all ${state.subtitle.url
|
||||||
|
? 'border-gray-800 bg-gray-100'
|
||||||
|
: 'border-gray-300 bg-white'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="w-5 h-5 text-gray-600" />
|
||||||
|
<div className="text-left">
|
||||||
|
<h3 className="font-semibold text-gray-800 text-sm">{srtT("subtitleFile")}</h3>
|
||||||
|
<p className="text-xs text-gray-600">
|
||||||
|
{state.subtitle.url ? srtT("uploaded") : srtT("notUploaded")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DarkButton
|
||||||
|
onClick={state.subtitle.url ? undefined : handleSubtitleUpload}
|
||||||
|
disabled={!!state.subtitle.url}
|
||||||
|
className="px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
{state.subtitle.url ? srtT("uploaded") : srtT("upload")}
|
||||||
|
</DarkButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 控制按钮和进度条 */}
|
||||||
|
<div className={`space-y-4 ${canPlay ? '' : 'opacity-50 pointer-events-none'}`}>
|
||||||
|
{/* 控制按钮 */}
|
||||||
|
<ControlBar
|
||||||
|
isPlaying={state.video.isPlaying}
|
||||||
|
onPlayPause={actions.togglePlayPause}
|
||||||
|
onPrevious={actions.previousSubtitle}
|
||||||
|
onNext={actions.nextSubtitle}
|
||||||
|
onRestart={actions.restartSubtitle}
|
||||||
|
playbackRate={state.video.playbackRate}
|
||||||
|
onPlaybackRateChange={actions.setPlaybackRate}
|
||||||
|
autoPause={state.controls.autoPause}
|
||||||
|
onAutoPauseToggle={actions.toggleAutoPause}
|
||||||
|
disabled={!canPlay}
|
||||||
|
className="justify-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 进度条 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<SeekBar
|
||||||
|
value={state.subtitle.currentIndex ?? 0}
|
||||||
|
max={Math.max(0, state.subtitle.data.length - 1)}
|
||||||
|
onChange={handleSeek}
|
||||||
|
disabled={!canPlay}
|
||||||
|
className="h-3"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 字幕进度显示 */}
|
||||||
|
<div className="flex justify-between items-center text-sm text-gray-600 px-2">
|
||||||
|
<span>
|
||||||
|
{state.subtitle.currentIndex !== null ?
|
||||||
|
`${state.subtitle.currentIndex + 1}/${state.subtitle.data.length}` :
|
||||||
|
'0/0'
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* 播放速度显示 */}
|
||||||
|
<span className="bg-gray-200 px-2 py-1 rounded text-xs">
|
||||||
|
{state.video.playbackRate}x
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* 自动暂停状态 */}
|
||||||
|
<span className={`px-2 py-1 rounded text-xs ${state.controls.autoPause
|
||||||
|
? 'bg-gray-800 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{srtT("autoPauseStatus", { enabled: state.controls.autoPause ? srtT("on") : srtT("off") })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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('Failed to load subtitle:', 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 "@/components/ui/buttons/IconClick";
|
||||||
|
import IMAGES from "@/config/images";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
|
||||||
|
|
||||||
|
interface TextCardProps {
|
||||||
|
item: z.infer<typeof TextSpeakerItemSchema>;
|
||||||
|
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-2xl 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={42}
|
||||||
|
></IconClick>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SaveListProps {
|
||||||
|
show?: boolean;
|
||||||
|
handleUse: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
|
||||||
|
}
|
||||||
|
export default 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-2xl"
|
||||||
|
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={48}
|
||||||
|
className=""
|
||||||
|
></IconClick>
|
||||||
|
<IconClick
|
||||||
|
src={IMAGES.delete}
|
||||||
|
alt="delete"
|
||||||
|
onClick={handleDeleteAll}
|
||||||
|
size={48}
|
||||||
|
className=""
|
||||||
|
></IconClick>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
{data.map((v) => (
|
||||||
|
<TextCard
|
||||||
|
item={v}
|
||||||
|
key={crypto.randomUUID()}
|
||||||
|
handleUse={handleUse}
|
||||||
|
handleDel={handleDel}
|
||||||
|
></TextCard>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
else return <></>;
|
||||||
|
}
|
||||||
337
src/app/(features)/text-speaker/page.tsx
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import LightButton from "@/components/ui/buttons/LightButton";
|
||||||
|
import IconClick from "@/components/ui/buttons/IconClick";
|
||||||
|
import IMAGES from "@/config/images";
|
||||||
|
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||||
|
import {
|
||||||
|
TextSpeakerArraySchema,
|
||||||
|
TextSpeakerItemSchema,
|
||||||
|
} from "@/lib/interfaces";
|
||||||
|
import { ChangeEvent, useEffect, useRef, useState } from "react";
|
||||||
|
import z from "zod";
|
||||||
|
import SaveList from "./SaveList";
|
||||||
|
|
||||||
|
import { VOICES } from "@/config/locales";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
|
||||||
|
import { getTTSAudioUrl } from "@/lib/browser/tts";
|
||||||
|
import { genIPA, genLocale } from "@/lib/server/translatorActions";
|
||||||
|
|
||||||
|
export default function 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 [locale, setLocale] = 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(e);
|
||||||
|
setIPA("");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pause) {
|
||||||
|
// 如果没在读
|
||||||
|
if (textRef.current.length === 0) {
|
||||||
|
// 没文本咋读
|
||||||
|
} else {
|
||||||
|
setPause(false);
|
||||||
|
|
||||||
|
if (objurlRef.current) {
|
||||||
|
// 之前有播放
|
||||||
|
load(objurlRef.current);
|
||||||
|
play();
|
||||||
|
} else {
|
||||||
|
// 第一次播放
|
||||||
|
try {
|
||||||
|
let theLocale = locale;
|
||||||
|
if (!theLocale) {
|
||||||
|
console.log("downloading text info");
|
||||||
|
const tmp_locale = await genLocale(textRef.current.slice(0, 30));
|
||||||
|
setLocale(tmp_locale);
|
||||||
|
theLocale = tmp_locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
const voice = VOICES.find((v) => v.locale.startsWith(theLocale));
|
||||||
|
if (!voice) throw "Voice not found.";
|
||||||
|
|
||||||
|
objurlRef.current = await getTTSAudioUrl(
|
||||||
|
textRef.current,
|
||||||
|
voice.short_name,
|
||||||
|
(() => {
|
||||||
|
if (speed === 1) return {};
|
||||||
|
else if (speed < 1)
|
||||||
|
return {
|
||||||
|
rate: `-${100 - speed * 100}%`,
|
||||||
|
};
|
||||||
|
else
|
||||||
|
return {
|
||||||
|
rate: `+${speed * 100 - 100}%`,
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
load(objurlRef.current);
|
||||||
|
play();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
|
||||||
|
setPause(true);
|
||||||
|
setLocale(null);
|
||||||
|
|
||||||
|
setProcessing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果在读就暂停
|
||||||
|
setPause(true);
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
setProcessing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
textRef.current = e.target.value.trim();
|
||||||
|
setLocale(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;
|
||||||
|
setLocale(item.locale);
|
||||||
|
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 theLocale = locale;
|
||||||
|
if (!theLocale) {
|
||||||
|
console.log("downloading text info");
|
||||||
|
const tmp_locale = await genLocale(textRef.current.slice(0, 30));
|
||||||
|
setLocale(tmp_locale);
|
||||||
|
theLocale = tmp_locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
let theIPA = ipa;
|
||||||
|
if (ipa.length === 0 && ipaEnabled) {
|
||||||
|
const 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,
|
||||||
|
locale: theLocale,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
save.push({
|
||||||
|
text: textRef.current,
|
||||||
|
locale: theLocale,
|
||||||
|
ipa: theIPA,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setIntoLocalStorage(save);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setLocale(null);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="my-4 p-4 mx-4 md:mx-32 border border-gray-200 rounded-2xl"
|
||||||
|
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"
|
||||||
|
onChange={handleInputChange}
|
||||||
|
ref={textareaRef}
|
||||||
|
></textarea>
|
||||||
|
{(ipa.length !== 0 && (
|
||||||
|
<div className="overflow-auto text-gray-600 h-18 border-gray-200 border-b">
|
||||||
|
{ipa}
|
||||||
|
</div>
|
||||||
|
)) || <div className="h-18"></div>}
|
||||||
|
<div className="mt-8 relative w-full flex flex-row flex-wrap gap-2 justify-center items-center">
|
||||||
|
{showSpeedAdjust && (
|
||||||
|
<div className="bg-white p-6 rounded-2xl 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">
|
||||||
|
<IconClick
|
||||||
|
size={45}
|
||||||
|
onClick={letMeSetSpeed(0.5)}
|
||||||
|
src={IMAGES.speed_0_5x}
|
||||||
|
alt="0.5x"
|
||||||
|
className={speed === 0.5 ? "bg-gray-200" : ""}
|
||||||
|
></IconClick>
|
||||||
|
<IconClick
|
||||||
|
size={45}
|
||||||
|
onClick={letMeSetSpeed(0.7)}
|
||||||
|
src={IMAGES.speed_0_7x}
|
||||||
|
alt="0.7x"
|
||||||
|
className={speed === 0.7 ? "bg-gray-200" : ""}
|
||||||
|
></IconClick>
|
||||||
|
<IconClick
|
||||||
|
size={45}
|
||||||
|
onClick={letMeSetSpeed(1)}
|
||||||
|
src={IMAGES.speed_1x}
|
||||||
|
alt="1x"
|
||||||
|
className={speed === 1 ? "bg-gray-200" : ""}
|
||||||
|
></IconClick>
|
||||||
|
<IconClick
|
||||||
|
size={45}
|
||||||
|
onClick={letMeSetSpeed(1.2)}
|
||||||
|
src={IMAGES.speed_1_2_x}
|
||||||
|
alt="1.2x"
|
||||||
|
className={speed === 1.2 ? "bg-gray-200" : ""}
|
||||||
|
></IconClick>
|
||||||
|
<IconClick
|
||||||
|
size={45}
|
||||||
|
onClick={letMeSetSpeed(1.5)}
|
||||||
|
src={IMAGES.speed_1_5x}
|
||||||
|
alt="1.5x"
|
||||||
|
className={speed === 1.5 ? "bg-gray-200" : ""}
|
||||||
|
></IconClick>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<IconClick
|
||||||
|
size={45}
|
||||||
|
onClick={speak}
|
||||||
|
src={pause ? IMAGES.play_arrow : IMAGES.pause}
|
||||||
|
alt="playorpause"
|
||||||
|
className={`${processing ? "bg-gray-200" : ""}`}
|
||||||
|
></IconClick>
|
||||||
|
<IconClick
|
||||||
|
size={45}
|
||||||
|
onClick={() => {
|
||||||
|
setAutopause(!autopause);
|
||||||
|
if (objurlRef) {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
setPause(true);
|
||||||
|
}}
|
||||||
|
src={autopause ? IMAGES.autoplay : IMAGES.autopause}
|
||||||
|
alt="autoplayorpause"
|
||||||
|
></IconClick>
|
||||||
|
<IconClick
|
||||||
|
size={45}
|
||||||
|
onClick={() => setShowSpeedAdjust(!showSpeedAdjust)}
|
||||||
|
src={IMAGES.speed}
|
||||||
|
alt="speed"
|
||||||
|
className={`${showSpeedAdjust ? "bg-gray-200" : ""}`}
|
||||||
|
></IconClick>
|
||||||
|
<IconClick
|
||||||
|
size={45}
|
||||||
|
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>
|
||||||
|
<SaveList show={showSaveList} handleUse={handleUseItem}></SaveList>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
src/app/(features)/translator/AddToFolder.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import LightButton from "@/components/ui/buttons/LightButton";
|
||||||
|
import Container from "@/components/ui/Container";
|
||||||
|
import { TranslationHistorySchema } from "@/lib/interfaces";
|
||||||
|
import { Dispatch, useEffect, useState } from "react";
|
||||||
|
import z from "zod";
|
||||||
|
import { Folder } from "../../../../generated/prisma/browser";
|
||||||
|
import { getFoldersByUserId } from "@/lib/server/services/folderService";
|
||||||
|
import { Folder as Fd } from "lucide-react";
|
||||||
|
import { createPair } from "@/lib/server/services/pairService";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
|
||||||
|
interface AddToFolderProps {
|
||||||
|
item: z.infer<typeof TranslationHistorySchema>;
|
||||||
|
setShow: Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddToFolder: React.FC<AddToFolderProps> = ({ item, setShow }) => {
|
||||||
|
const { data: session } = authClient.useSession();
|
||||||
|
const [folders, setFolders] = useState<Folder[]>([]);
|
||||||
|
const t = useTranslations("translator.add_to_folder");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session) return;
|
||||||
|
const userId = session.user.id;
|
||||||
|
getFoldersByUserId(userId)
|
||||||
|
.then(setFolders)
|
||||||
|
.then(() => setLoading(false));
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return (
|
||||||
|
<div className="fixed left-0 top-0 z-50 w-screen h-screen bg-black/50 flex justify-center items-center">
|
||||||
|
<Container className="p-6">
|
||||||
|
<div>{t("notAuthenticated")}</div>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="fixed left-0 top-0 z-50 w-screen h-screen bg-black/50 flex justify-center items-center">
|
||||||
|
<Container className="p-6">
|
||||||
|
<h1>{t("chooseFolder")}</h1>
|
||||||
|
<div className="border border-gray-200 rounded-2xl">
|
||||||
|
{(loading && <span>...</span>) ||
|
||||||
|
(folders.length > 0 &&
|
||||||
|
folders.map((folder) => (
|
||||||
|
<button
|
||||||
|
key={folder.id}
|
||||||
|
className="p-2 flex items-center justify-start hover:bg-gray-50 gap-2 hover:cursor-pointer w-full border-b border-gray-200"
|
||||||
|
onClick={() => {
|
||||||
|
createPair({
|
||||||
|
text1: item.text1,
|
||||||
|
text2: item.text2,
|
||||||
|
locale1: item.locale1,
|
||||||
|
locale2: item.locale2,
|
||||||
|
folder: {
|
||||||
|
connect: {
|
||||||
|
id: folder.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success(t("success"));
|
||||||
|
setShow(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error(t("error"));
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Fd />
|
||||||
|
{t("folderInfo", { id: folder.id, name: folder.name })}
|
||||||
|
</button>
|
||||||
|
))) || <div>{t("noFolders")}</div>}
|
||||||
|
</div>
|
||||||
|
<LightButton onClick={() => setShow(false)}>{t("close")}</LightButton>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddToFolder;
|
||||||
57
src/app/(features)/translator/FolderSelector.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import Container from "@/components/ui/Container";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Folder } from "../../../../generated/prisma/browser";
|
||||||
|
import { getFoldersByUserId } from "@/lib/server/services/folderService";
|
||||||
|
import LightButton from "@/components/ui/buttons/LightButton";
|
||||||
|
import { Folder as Fd } from "lucide-react";
|
||||||
|
|
||||||
|
interface FolderSelectorProps {
|
||||||
|
setSelectedFolderId: (id: number) => void;
|
||||||
|
userId: string;
|
||||||
|
cancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FolderSelector: React.FC<FolderSelectorProps> = ({
|
||||||
|
setSelectedFolderId,
|
||||||
|
userId,
|
||||||
|
cancel,
|
||||||
|
}) => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [folders, setFolders] = useState<Folder[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getFoldersByUserId(userId)
|
||||||
|
.then(setFolders)
|
||||||
|
.then(() => setLoading(false));
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`bg-black/50 fixed inset-0 z-50 flex justify-center items-center`}
|
||||||
|
>
|
||||||
|
<Container className="p-6">
|
||||||
|
{(loading && <p>Loading...</p>) ||
|
||||||
|
(folders.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h1>Select a Folder</h1>
|
||||||
|
<div className="m-2 border-gray-200 border rounded-2xl max-h-96 overflow-y-auto">
|
||||||
|
{folders.map((folder) => (
|
||||||
|
<button
|
||||||
|
className="p-2 w-full flex hover:bg-gray-50 gap-2"
|
||||||
|
key={folder.id}
|
||||||
|
onClick={() => setSelectedFolderId(folder.id)}
|
||||||
|
>
|
||||||
|
<Fd />
|
||||||
|
{folder.id}. {folder.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)) || <p>No folders found</p>}
|
||||||
|
<LightButton onClick={cancel}>Cancel</LightButton>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FolderSelector;
|
||||||
379
src/app/(features)/translator/page.tsx
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import LightButton from "@/components/ui/buttons/LightButton";
|
||||||
|
import IconClick from "@/components/ui/buttons/IconClick";
|
||||||
|
import IMAGES from "@/config/images";
|
||||||
|
import { VOICES } from "@/config/locales";
|
||||||
|
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||||
|
import { TranslationHistorySchema } from "@/lib/interfaces";
|
||||||
|
import { tlsoPush, tlso } from "@/lib/browser/localStorageOperators";
|
||||||
|
import { getTTSAudioUrl } from "@/lib/browser/tts";
|
||||||
|
import { Plus, Trash } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import z from "zod";
|
||||||
|
import AddToFolder from "./AddToFolder";
|
||||||
|
import {
|
||||||
|
genIPA,
|
||||||
|
genLocale,
|
||||||
|
genTranslation,
|
||||||
|
} from "@/lib/server/translatorActions";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import FolderSelector from "./FolderSelector";
|
||||||
|
import { createPair } from "@/lib/server/services/pairService";
|
||||||
|
import { shallowEqual } from "@/lib/utils";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
|
||||||
|
export default function TranslatorPage() {
|
||||||
|
const t = useTranslations("translator");
|
||||||
|
|
||||||
|
const taref = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const [lang, setLang] = useState<string>("chinese");
|
||||||
|
const [tresult, setTresult] = useState<string>("");
|
||||||
|
const [genIpa, setGenIpa] = useState(true);
|
||||||
|
const [ipaTexts, setIpaTexts] = useState(["", ""]);
|
||||||
|
const [processing, setProcessing] = useState(false);
|
||||||
|
const { load, play } = useAudioPlayer();
|
||||||
|
const [history, setHistory] = useState<
|
||||||
|
z.infer<typeof TranslationHistorySchema>[]
|
||||||
|
>([]);
|
||||||
|
const [showAddToFolder, setShowAddToFolder] = useState(false);
|
||||||
|
const [addToFolderItem, setAddToFolderItem] = useState<z.infer<
|
||||||
|
typeof TranslationHistorySchema
|
||||||
|
> | null>(null);
|
||||||
|
const lastTTS = useRef({
|
||||||
|
text: "",
|
||||||
|
url: "",
|
||||||
|
});
|
||||||
|
const [autoSave, setAutoSave] = useState(false);
|
||||||
|
const [autoSaveFolderId, setAutoSaveFolderId] = useState<number | null>(null);
|
||||||
|
const { data: session } = authClient.useSession();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHistory(tlso.get());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const tts = async (text: string, locale: string) => {
|
||||||
|
if (lastTTS.current.text !== text) {
|
||||||
|
const shortName = VOICES.find((v) => v.locale === locale)?.short_name;
|
||||||
|
if (!shortName) {
|
||||||
|
toast.error("Voice not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const url = await getTTSAudioUrl(text, shortName);
|
||||||
|
await load(url);
|
||||||
|
lastTTS.current.text = text;
|
||||||
|
lastTTS.current.url = url;
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to generate audio");
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await play();
|
||||||
|
};
|
||||||
|
|
||||||
|
const translate = async () => {
|
||||||
|
if (!taref.current) return;
|
||||||
|
if (processing) return;
|
||||||
|
|
||||||
|
setProcessing(true);
|
||||||
|
|
||||||
|
const text1 = taref.current.value;
|
||||||
|
|
||||||
|
const llmres: {
|
||||||
|
text1: string | null;
|
||||||
|
text2: string | null;
|
||||||
|
locale1: string | null;
|
||||||
|
locale2: string | null;
|
||||||
|
ipa1: string | null;
|
||||||
|
ipa2: string | null;
|
||||||
|
} = {
|
||||||
|
text1: text1,
|
||||||
|
text2: null,
|
||||||
|
locale1: null,
|
||||||
|
locale2: null,
|
||||||
|
ipa1: null,
|
||||||
|
ipa2: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
let historyUpdated = false;
|
||||||
|
|
||||||
|
// 检查更新历史记录
|
||||||
|
const checkUpdateLocalStorage = () => {
|
||||||
|
if (historyUpdated) return;
|
||||||
|
if (llmres.text1 && llmres.text2 && llmres.locale1 && llmres.locale2) {
|
||||||
|
setHistory(
|
||||||
|
tlsoPush({
|
||||||
|
text1: llmres.text1,
|
||||||
|
text2: llmres.text2,
|
||||||
|
locale1: llmres.locale1,
|
||||||
|
locale2: llmres.locale2,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (autoSave && autoSaveFolderId) {
|
||||||
|
createPair({
|
||||||
|
text1: llmres.text1,
|
||||||
|
text2: llmres.text2,
|
||||||
|
locale1: llmres.locale1,
|
||||||
|
locale2: llmres.locale2,
|
||||||
|
folder: {
|
||||||
|
connect: {
|
||||||
|
id: autoSaveFolderId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success(
|
||||||
|
llmres.text1 + "保存到文件夹" + autoSaveFolderId + "成功",
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
toast.error(
|
||||||
|
llmres.text1 +
|
||||||
|
"保存到文件夹" +
|
||||||
|
autoSaveFolderId +
|
||||||
|
"失败:" +
|
||||||
|
error.message,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
historyUpdated = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// 更新局部翻译状态
|
||||||
|
const updateState = (stateName: keyof typeof llmres, value: string) => {
|
||||||
|
llmres[stateName] = value;
|
||||||
|
checkUpdateLocalStorage();
|
||||||
|
};
|
||||||
|
|
||||||
|
genTranslation(text1, lang)
|
||||||
|
.then(async (text2) => {
|
||||||
|
updateState("text2", text2);
|
||||||
|
setTresult(text2);
|
||||||
|
// 生成两个locale
|
||||||
|
genLocale(text1).then((locale) => {
|
||||||
|
updateState("locale1", locale);
|
||||||
|
});
|
||||||
|
genLocale(text2).then((locale) => {
|
||||||
|
updateState("locale2", locale);
|
||||||
|
});
|
||||||
|
// 生成俩IPA
|
||||||
|
if (genIpa) {
|
||||||
|
genIPA(text1).then((ipa1) => {
|
||||||
|
setIpaTexts((prev) => [ipa1, prev[1]]);
|
||||||
|
updateState("ipa1", ipa1);
|
||||||
|
});
|
||||||
|
genIPA(text2).then((ipa2) => {
|
||||||
|
setIpaTexts((prev) => [prev[0], ipa2]);
|
||||||
|
updateState("ipa2", ipa2);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Translation failed");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setProcessing(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* TCard Component */}
|
||||||
|
<div className="w-screen flex flex-col md:flex-row md:justify-between gap-2 p-2">
|
||||||
|
{/* Card Component - Left Side */}
|
||||||
|
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
|
||||||
|
{/* ICard1 Component */}
|
||||||
|
<div className="border border-gray-200 rounded-2xl w-full h-64 p-2">
|
||||||
|
<textarea
|
||||||
|
className="resize-none h-8/12 w-full focus:outline-0"
|
||||||
|
ref={taref}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.ctrlKey && e.key === "Enter") translate();
|
||||||
|
}}
|
||||||
|
></textarea>
|
||||||
|
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
|
||||||
|
{ipaTexts[0]}
|
||||||
|
</div>
|
||||||
|
<div className="h-2/12 w-full flex justify-end items-center">
|
||||||
|
<IconClick
|
||||||
|
src={IMAGES.copy_all}
|
||||||
|
alt="copy"
|
||||||
|
onClick={async () => {
|
||||||
|
await navigator.clipboard.writeText(
|
||||||
|
taref.current?.value || "",
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
></IconClick>
|
||||||
|
<IconClick
|
||||||
|
src={IMAGES.play_arrow}
|
||||||
|
alt="play"
|
||||||
|
onClick={() => {
|
||||||
|
const t = taref.current?.value;
|
||||||
|
if (!t) return;
|
||||||
|
tts(t, tlso.get().find((v) => v.text1 === t)?.locale1 || "");
|
||||||
|
}}
|
||||||
|
></IconClick>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="option1 w-full flex flex-row justify-between items-center">
|
||||||
|
<span>{t("detectLanguage")}</span>
|
||||||
|
<LightButton
|
||||||
|
selected={genIpa}
|
||||||
|
onClick={() => setGenIpa((prev) => !prev)}
|
||||||
|
>
|
||||||
|
{t("generateIPA")}
|
||||||
|
</LightButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card Component - Right Side */}
|
||||||
|
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
|
||||||
|
{/* ICard2 Component */}
|
||||||
|
<div className="bg-gray-100 rounded-2xl w-full h-64 p-2">
|
||||||
|
<div className="h-2/3 w-full overflow-y-auto">{tresult}</div>
|
||||||
|
<div className="ipa w-full h-1/6 overflow-y-auto text-gray-600">
|
||||||
|
{ipaTexts[1]}
|
||||||
|
</div>
|
||||||
|
<div className="h-1/6 w-full flex justify-end items-center">
|
||||||
|
<IconClick
|
||||||
|
src={IMAGES.copy_all}
|
||||||
|
alt="copy"
|
||||||
|
onClick={async () => {
|
||||||
|
await navigator.clipboard.writeText(tresult);
|
||||||
|
}}
|
||||||
|
></IconClick>
|
||||||
|
<IconClick
|
||||||
|
src={IMAGES.play_arrow}
|
||||||
|
alt="play"
|
||||||
|
onClick={() => {
|
||||||
|
tts(
|
||||||
|
tresult,
|
||||||
|
tlso.get().find((v) => v.text2 === tresult)?.locale2 || "",
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
></IconClick>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="option2 w-full flex gap-1 items-center flex-wrap">
|
||||||
|
<span>{t("translateInto")}</span>
|
||||||
|
<LightButton
|
||||||
|
selected={lang === "chinese"}
|
||||||
|
onClick={() => setLang("chinese")}
|
||||||
|
>
|
||||||
|
{t("chinese")}
|
||||||
|
</LightButton>
|
||||||
|
<LightButton
|
||||||
|
selected={lang === "english"}
|
||||||
|
onClick={() => setLang("english")}
|
||||||
|
>
|
||||||
|
{t("english")}
|
||||||
|
</LightButton>
|
||||||
|
<LightButton
|
||||||
|
selected={lang === "italian"}
|
||||||
|
onClick={() => setLang("italian")}
|
||||||
|
>
|
||||||
|
{t("italian")}
|
||||||
|
</LightButton>
|
||||||
|
<LightButton
|
||||||
|
selected={!["chinese", "english", "italian"].includes(lang)}
|
||||||
|
onClick={() => {
|
||||||
|
const newLang = prompt(t("enterLanguage"));
|
||||||
|
if (newLang) {
|
||||||
|
setLang(newLang);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("other")}
|
||||||
|
</LightButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TranslateButton Component */}
|
||||||
|
<div className="w-screen flex justify-center items-center">
|
||||||
|
<button
|
||||||
|
className={`duration-150 ease-in text-xl font-extrabold border rounded-4xl p-3 border-gray-200 h-16 ${processing ? "bg-gray-200" : "bg-white hover:bg-gray-200 hover:cursor-pointer"}`}
|
||||||
|
onClick={translate}
|
||||||
|
>
|
||||||
|
{t("translate")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AutoSave Component */}
|
||||||
|
<div className="w-screen flex justify-center items-center">
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoSave}
|
||||||
|
onChange={(e) => {
|
||||||
|
const checked = e.target.checked;
|
||||||
|
if (checked === true && !session) {
|
||||||
|
toast.warning("Please login to enable auto-save");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (checked === false) setAutoSaveFolderId(null);
|
||||||
|
setAutoSave(checked);
|
||||||
|
}}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
{t("autoSave")}
|
||||||
|
{autoSaveFolderId ? ` (${autoSaveFolderId})` : ""}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{history.length > 0 && (
|
||||||
|
<div className="m-6 flex flex-col items-center">
|
||||||
|
<h1 className="text-2xl font-light">{t("history")}</h1>
|
||||||
|
<div className="border border-gray-200 rounded-2xl m-4">
|
||||||
|
{history.toReversed().map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="border-b border-gray-200 p-2 group hover:bg-gray-50 flex gap-2 flex-row justify-between items-start"
|
||||||
|
>
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
<p className="text-sm font-light">{item.text1}</p>
|
||||||
|
<p className="text-sm font-light">{item.text2}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowAddToFolder(true);
|
||||||
|
setAddToFolderItem(item);
|
||||||
|
}}
|
||||||
|
className="hover:bg-gray-200 hover:cursor-pointer rounded-4xl border border-gray-200 w-8 h-8 flex justify-center items-center"
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setHistory(
|
||||||
|
tlso.set(
|
||||||
|
tlso.get().filter((v) => !shallowEqual(v, item)),
|
||||||
|
) || [],
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="hover:bg-gray-200 hover:cursor-pointer rounded-4xl border border-gray-200 w-8 h-8 flex justify-center items-center"
|
||||||
|
>
|
||||||
|
<Trash />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{showAddToFolder && (
|
||||||
|
<AddToFolder setShow={setShowAddToFolder} item={addToFolderItem!} />
|
||||||
|
)}
|
||||||
|
{autoSave && !autoSaveFolderId && (
|
||||||
|
<FolderSelector
|
||||||
|
userId={session!.user.id as string}
|
||||||
|
cancel={() => setAutoSave(false)}
|
||||||
|
setSelectedFolderId={(id) => setAutoSaveFolderId(id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import Button from "@/components/Button";
|
|
||||||
import IconClick from "@/components/IconClick";
|
|
||||||
import IMAGES from "@/config/images";
|
|
||||||
import { Letter, SupportedAlphabets } from "@/interfaces";
|
|
||||||
import { Dispatch, KeyboardEvent, SetStateAction, useEffect, 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);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeydown = (e: globalThis.KeyboardEvent) => {
|
|
||||||
if (e.key === ' ') refresh();
|
|
||||||
}
|
|
||||||
document.addEventListener('keydown', handleKeydown);
|
|
||||||
return () => document.removeEventListener('keydown', handleKeydown);
|
|
||||||
});
|
|
||||||
|
|
||||||
const letter = alphabet[index];
|
|
||||||
const refresh = () => {
|
|
||||||
setIndex(Math.floor(Math.random() * alphabet.length));
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="w-full flex justify-center items-center" onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => e.preventDefault()}>
|
|
||||||
<div className="m-4 p-4 w-full md:w-[60dvw] flex-col rounded-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={refresh}></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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
246
src/app/auth/AuthForm.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useActionState, startTransition } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { signInAction, signUpAction, SignUpState } from "@/lib/actions/auth";
|
||||||
|
import Container from "@/components/ui/Container";
|
||||||
|
import Input from "@/components/ui/Input";
|
||||||
|
import LightButton from "@/components/ui/buttons/LightButton";
|
||||||
|
import DarkButton from "@/components/ui/buttons/DarkButton";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
|
||||||
|
interface AuthFormProps {
|
||||||
|
redirectTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuthForm({ redirectTo }: AuthFormProps) {
|
||||||
|
const t = useTranslations("auth");
|
||||||
|
const [mode, setMode] = useState<'signin' | 'signup'>('signin');
|
||||||
|
const [clearSignIn, setClearSignIn] = useState(false);
|
||||||
|
const [clearSignUp, setClearSignUp] = useState(false);
|
||||||
|
|
||||||
|
const [signInState, signInActionForm, isSignInPending] = useActionState(
|
||||||
|
async (prevState: SignUpState | undefined, formData: FormData) => {
|
||||||
|
if (clearSignIn) {
|
||||||
|
setClearSignIn(false);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return signInAction(prevState || {}, formData);
|
||||||
|
},
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
const [signUpState, signUpActionForm, isSignUpPending] = useActionState(
|
||||||
|
async (prevState: SignUpState | undefined, formData: FormData) => {
|
||||||
|
if (clearSignUp) {
|
||||||
|
setClearSignUp(false);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return signUpAction(prevState || {}, formData);
|
||||||
|
},
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const validateForm = (formData: FormData): boolean => {
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
|
const email = formData.get("email") as string;
|
||||||
|
const password = formData.get("password") as string;
|
||||||
|
const name = formData.get("name") as string;
|
||||||
|
const confirmPassword = formData.get("confirmPassword") as string;
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
newErrors.email = t("emailRequired");
|
||||||
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
newErrors.email = t("invalidEmail");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
newErrors.password = t("passwordRequired");
|
||||||
|
} else if (password.length < 8) {
|
||||||
|
newErrors.password = t("passwordTooShort");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'signup') {
|
||||||
|
if (!name) {
|
||||||
|
newErrors.name = t("nameRequired");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirmPassword) {
|
||||||
|
newErrors.confirmPassword = t("confirmPasswordRequired");
|
||||||
|
} else if (password !== confirmPassword) {
|
||||||
|
newErrors.confirmPassword = t("passwordsNotMatch");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
|
||||||
|
// 基本客户端验证
|
||||||
|
if (!validateForm(formData)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加 redirectTo 到 formData
|
||||||
|
if (redirectTo) {
|
||||||
|
formData.append("redirectTo", redirectTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 startTransition 包装 action 调用
|
||||||
|
startTransition(() => {
|
||||||
|
// 根据模式调用相应的 action
|
||||||
|
if (mode === 'signin') {
|
||||||
|
signInActionForm(formData);
|
||||||
|
} else {
|
||||||
|
signUpActionForm(formData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGitHubSignIn = async () => {
|
||||||
|
await authClient.signIn.social({
|
||||||
|
provider: "github",
|
||||||
|
callbackURL: redirectTo || "/"
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentError = mode === 'signin' ? signInState : signUpState;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4">
|
||||||
|
<Container className="p-8 max-w-md w-full">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-800 mb-2">{t(mode === 'signin' ? 'signIn' : 'signUp')}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentError?.message && (
|
||||||
|
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
|
||||||
|
{currentError.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleFormSubmit} className="space-y-4">
|
||||||
|
{mode === 'signup' && (
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
placeholder={t("name")}
|
||||||
|
className="w-full px-3 py-2"
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<p className="text-red-500 text-sm mt-1">{errors.name}</p>
|
||||||
|
)}
|
||||||
|
{currentError?.errors?.username && (
|
||||||
|
<p className="text-red-500 text-sm mt-1">{currentError.errors.username[0]}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
placeholder={t("email")}
|
||||||
|
className="w-full px-3 py-2"
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="text-red-500 text-sm mt-1">{errors.email}</p>
|
||||||
|
)}
|
||||||
|
{currentError?.errors?.email && (
|
||||||
|
<p className="text-red-500 text-sm mt-1">{currentError.errors.email[0]}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
placeholder={t("password")}
|
||||||
|
className="w-full px-3 py-2"
|
||||||
|
/>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="text-red-500 text-sm mt-1">{errors.password}</p>
|
||||||
|
)}
|
||||||
|
{currentError?.errors?.password && (
|
||||||
|
<p className="text-red-500 text-sm mt-1">{currentError.errors.password[0]}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'signup' && (
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
name="confirmPassword"
|
||||||
|
placeholder={t("confirmPassword")}
|
||||||
|
className="w-full px-3 py-2"
|
||||||
|
/>
|
||||||
|
{errors.confirmPassword && (
|
||||||
|
<p className="text-red-500 text-sm mt-1">{errors.confirmPassword}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DarkButton
|
||||||
|
type="submit"
|
||||||
|
className={`w-full py-2 ${isSignInPending || isSignUpPending ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
{isSignInPending || isSignUpPending
|
||||||
|
? t("loading")
|
||||||
|
: t(mode === 'signin' ? 'signInButton' : 'signUpButton')
|
||||||
|
}
|
||||||
|
</DarkButton>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-gray-300"></div>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-sm">
|
||||||
|
<span className="px-2 bg-white text-gray-500">或</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LightButton
|
||||||
|
onClick={handleGitHubSignIn}
|
||||||
|
className="w-full mt-4 py-2 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||||
|
</svg>
|
||||||
|
{t(mode === 'signin' ? 'signInWithGitHub' : 'signUpWithGitHub')}
|
||||||
|
</LightButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setMode(mode === 'signin' ? 'signup' : 'signin');
|
||||||
|
setErrors({});
|
||||||
|
// 清除服务器端错误状态
|
||||||
|
if (mode === 'signin') {
|
||||||
|
setClearSignIn(true);
|
||||||
|
} else {
|
||||||
|
setClearSignUp(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-[#35786f] hover:underline"
|
||||||
|
>
|
||||||
|
{mode === 'signin'
|
||||||
|
? `${t("noAccount")} ${t("signUp")}`
|
||||||
|
: `${t("hasAccount")} ${t("signIn")}`
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
src/app/auth/page.tsx
Normal file
@@ -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} />;
|
||||||
|
}
|
||||||
171
src/app/folders/FoldersClient.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChevronRight,
|
||||||
|
Folder as Fd,
|
||||||
|
FolderPen,
|
||||||
|
FolderPlus,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Center } from "@/components/common/Center";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Folder } from "../../../generated/prisma/browser";
|
||||||
|
import {
|
||||||
|
createFolder,
|
||||||
|
deleteFolderById,
|
||||||
|
getFoldersWithTotalPairsByUserId,
|
||||||
|
renameFolderById,
|
||||||
|
} from "@/lib/server/services/folderService";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface FolderProps {
|
||||||
|
folder: Folder & { total: number };
|
||||||
|
refresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FolderCard = ({ folder, refresh }: FolderProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const t = useTranslations("folders");
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex justify-between items-center group p-4 border-b border-gray-100 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
router.push(`/folders/${folder.id}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 flex-1">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-linear-to-br from-blue-50 to-blue-100 flex items-center justify-center group-hover:from-blue-100 group-hover:to-blue-200 transition-colors">
|
||||||
|
<Fd></Fd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-medium text-gray-900">
|
||||||
|
{t("folderInfo", {
|
||||||
|
id: folder.id,
|
||||||
|
name: folder.name,
|
||||||
|
totalPairs: folder.total,
|
||||||
|
})}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-gray-400">#{folder.id}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 opacity-50 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const newName = prompt("Input a new name.")?.trim();
|
||||||
|
if (newName && newName.length > 0) {
|
||||||
|
renameFolderById(folder.id, newName).then(refresh);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-2 text-gray-400 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<FolderPen size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const confirm = prompt(t("confirmDelete", { name: folder.name }));
|
||||||
|
if (confirm === folder.name) {
|
||||||
|
deleteFolderById(folder.id).then(refresh);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
<ChevronRight size={18} className="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FoldersClient({ userId }: { userId: string }) {
|
||||||
|
const t = useTranslations("folders");
|
||||||
|
const [folders, setFolders] = useState<(Folder & { total: number })[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
getFoldersWithTotalPairsByUserId(userId)
|
||||||
|
.then((folders) => {
|
||||||
|
setFolders(folders);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
toast.error("加载出错,请重试。");
|
||||||
|
});
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
const updateFolders = async () => {
|
||||||
|
try {
|
||||||
|
const updatedFolders = await getFoldersWithTotalPairsByUserId(userId);
|
||||||
|
setFolders(updatedFolders);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Center>
|
||||||
|
<div className="w-full max-w-2xl mx-auto bg-white border border-gray-200 rounded-2xl p-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-light text-gray-900">{t("title")}</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">{t("subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const folderName = prompt(t("enterFolderName"));
|
||||||
|
if (!folderName) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await createFolder({
|
||||||
|
name: folderName,
|
||||||
|
user: { connect: { id: userId } },
|
||||||
|
});
|
||||||
|
await updateFolders();
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full p-3 border-2 border-dashed border-gray-300 rounded-xl text-gray-500 hover:border-gray-400 hover:text-gray-600 transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<FolderPlus size={18} />
|
||||||
|
<span>{loading ? t("creating") : t("newFolder")}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="mt-4 max-h-96 overflow-y-auto">
|
||||||
|
{folders.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-gray-400">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-3 rounded-lg bg-gray-100 flex items-center justify-center">
|
||||||
|
<FolderPlus size={24} className="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm">{t("noFoldersYet")}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-gray-200 overflow-hidden">
|
||||||
|
{folders
|
||||||
|
.toSorted((a, b) => a.id - b.id)
|
||||||
|
.map((folder) => (
|
||||||
|
<FolderCard
|
||||||
|
key={folder.id}
|
||||||
|
folder={folder}
|
||||||
|
refresh={updateFolders}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
src/app/folders/[folder_id]/AddTextPairModal.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import LightButton from "@/components/ui/buttons/LightButton";
|
||||||
|
import Input from "@/components/ui/Input";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
interface AddTextPairModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onAdd: (
|
||||||
|
text1: string,
|
||||||
|
text2: string,
|
||||||
|
locale1: string,
|
||||||
|
locale2: string,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddTextPairModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onAdd,
|
||||||
|
}: AddTextPairModalProps) {
|
||||||
|
const t = useTranslations("folder_id");
|
||||||
|
const input1Ref = useRef<HTMLInputElement>(null);
|
||||||
|
const input2Ref = useRef<HTMLInputElement>(null);
|
||||||
|
const input3Ref = useRef<HTMLInputElement>(null);
|
||||||
|
const input4Ref = useRef<HTMLInputElement>(null);
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
if (
|
||||||
|
!input1Ref.current?.value ||
|
||||||
|
!input2Ref.current?.value ||
|
||||||
|
!input3Ref.current?.value ||
|
||||||
|
!input4Ref.current?.value
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const text1 = input1Ref.current.value;
|
||||||
|
const text2 = input2Ref.current.value;
|
||||||
|
const locale1 = input3Ref.current.value;
|
||||||
|
const locale2 = input4Ref.current.value;
|
||||||
|
if (
|
||||||
|
typeof text1 === "string" &&
|
||||||
|
typeof text2 === "string" &&
|
||||||
|
typeof locale1 === "string" &&
|
||||||
|
typeof locale2 === "string" &&
|
||||||
|
text1.trim() !== "" &&
|
||||||
|
text2.trim() !== "" &&
|
||||||
|
locale1.trim() !== "" &&
|
||||||
|
locale2.trim() !== ""
|
||||||
|
) {
|
||||||
|
onAdd(text1, text2, locale1, locale2);
|
||||||
|
input1Ref.current.value = "";
|
||||||
|
input2Ref.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 backdrop-blur flex items-center justify-center z-50"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAdd();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="bg-white rounded-xl p-6 w-full max-w-md mx-4">
|
||||||
|
<div className="flex">
|
||||||
|
<h2 className="flex-1 text-xl font-light mb-4 text-center">
|
||||||
|
{t("addNewTextPair")}
|
||||||
|
</h2>
|
||||||
|
<X onClick={onClose} className="hover:cursor-pointer"></X>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
{t("text1")}
|
||||||
|
<Input ref={input1Ref} className="w-full"></Input>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t("text2")}
|
||||||
|
<Input ref={input2Ref} className="w-full"></Input>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t("locale1")}
|
||||||
|
<Input
|
||||||
|
ref={input3Ref}
|
||||||
|
className="w-full"
|
||||||
|
placeholder="en-US"
|
||||||
|
></Input>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t("locale2")}
|
||||||
|
<Input
|
||||||
|
ref={input4Ref}
|
||||||
|
className="w-full"
|
||||||
|
placeholder="zh-CN"
|
||||||
|
></Input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<LightButton onClick={handleAdd}>{t("add")}</LightButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
156
src/app/folders/[folder_id]/InFolder.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowLeft, Plus } from "lucide-react";
|
||||||
|
import { Center } from "@/components/common/Center";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { redirect, useRouter } from "next/navigation";
|
||||||
|
import Container from "@/components/ui/Container";
|
||||||
|
import {
|
||||||
|
createPair,
|
||||||
|
deletePairById,
|
||||||
|
getPairsByFolderId,
|
||||||
|
} from "@/lib/server/services/pairService";
|
||||||
|
import AddTextPairModal from "./AddTextPairModal";
|
||||||
|
import TextPairCard from "./TextPairCard";
|
||||||
|
import LightButton from "@/components/ui/buttons/LightButton";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
export interface TextPair {
|
||||||
|
id: number;
|
||||||
|
text1: string;
|
||||||
|
text2: string;
|
||||||
|
locale1: string;
|
||||||
|
locale2: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InFolder({ folderId }: { folderId: number }) {
|
||||||
|
const [textPairs, setTextPairs] = useState<TextPair[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [openAddModal, setAddModal] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const t = useTranslations("folder_id");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchTextPairs = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await getPairsByFolderId(folderId);
|
||||||
|
setTextPairs(data as TextPair[]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch text pairs:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchTextPairs();
|
||||||
|
}, [folderId]);
|
||||||
|
|
||||||
|
const refreshTextPairs = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getPairsByFolderId(folderId);
|
||||||
|
setTextPairs(data as TextPair[]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch text pairs:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Center>
|
||||||
|
<Container className="p-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<button
|
||||||
|
onClick={router.back}
|
||||||
|
className="flex items-center gap-2 text-gray-500 hover:text-gray-700 transition-colors mb-4"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
<span className="text-sm">{t("back")}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-light text-gray-900">
|
||||||
|
{t("textPairs")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{t("itemsCount", { count: textPairs.length })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<LightButton
|
||||||
|
onClick={() => {
|
||||||
|
redirect(`/memorize?folder_id=${folderId}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("memorize")}
|
||||||
|
</LightButton>
|
||||||
|
<button
|
||||||
|
className="p-2 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
setAddModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus
|
||||||
|
size={18}
|
||||||
|
className="text-gray-600 hover:cursor-pointer"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-96 overflow-y-auto rounded-xl border border-gray-200 overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
|
||||||
|
<p className="text-sm text-gray-500">{t("loadingTextPairs")}</p>
|
||||||
|
</div>
|
||||||
|
) : textPairs.length === 0 ? (
|
||||||
|
<div className="p-12 text-center">
|
||||||
|
<p className="text-sm text-gray-500 mb-2">{t("noTextPairs")}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{textPairs
|
||||||
|
.toSorted((a, b) => a.id - b.id)
|
||||||
|
.map((textPair) => (
|
||||||
|
<TextPairCard
|
||||||
|
key={textPair.id}
|
||||||
|
textPair={textPair}
|
||||||
|
onDel={() => {
|
||||||
|
deletePairById(textPair.id);
|
||||||
|
refreshTextPairs();
|
||||||
|
}}
|
||||||
|
refreshTextPairs={refreshTextPairs}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
<AddTextPairModal
|
||||||
|
isOpen={openAddModal}
|
||||||
|
onClose={() => setAddModal(false)}
|
||||||
|
onAdd={async (
|
||||||
|
text1: string,
|
||||||
|
text2: string,
|
||||||
|
locale1: string,
|
||||||
|
locale2: string,
|
||||||
|
) => {
|
||||||
|
await createPair({
|
||||||
|
text1: text1,
|
||||||
|
text2: text2,
|
||||||
|
locale1: locale1,
|
||||||
|
locale2: locale2,
|
||||||
|
folder: {
|
||||||
|
connect: {
|
||||||
|
id: folderId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
refreshTextPairs();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
src/app/folders/[folder_id]/TextPairCard.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { Edit, Trash2 } from "lucide-react";
|
||||||
|
import { TextPair } from "./InFolder";
|
||||||
|
import { updatePairById } from "@/lib/server/services/pairService";
|
||||||
|
import { useState } from "react";
|
||||||
|
import UpdateTextPairModal from "./UpdateTextPairModal";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { PairUpdateInput } from "../../../../generated/prisma/models";
|
||||||
|
|
||||||
|
interface TextPairCardProps {
|
||||||
|
textPair: TextPair;
|
||||||
|
onDel: () => void;
|
||||||
|
refreshTextPairs: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TextPairCard({
|
||||||
|
textPair,
|
||||||
|
onDel,
|
||||||
|
refreshTextPairs,
|
||||||
|
}: TextPairCardProps) {
|
||||||
|
const [openUpdateModal, setOpenUpdateModal] = useState(false);
|
||||||
|
const t = useTranslations("folder_id");
|
||||||
|
return (
|
||||||
|
<div className="group border-b border-gray-100 hover:bg-gray-50 transition-colors">
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||||
|
<span className="px-2 py-1 bg-gray-100 rounded-md">
|
||||||
|
{textPair.locale1.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span>→</span>
|
||||||
|
<span className="px-2 py-1 bg-gray-100 rounded-md">
|
||||||
|
{textPair.locale2.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 opacity-50 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
className="p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-md transition-colors"
|
||||||
|
onClick={() => setOpenUpdateModal(true)}
|
||||||
|
title={t("edit")}
|
||||||
|
>
|
||||||
|
<Edit size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-md transition-colors"
|
||||||
|
onClick={onDel}
|
||||||
|
title={t("delete")}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-900 grid grid-cols-2 gap-4 w-3/4">
|
||||||
|
<div>
|
||||||
|
{textPair.text1.length > 30
|
||||||
|
? textPair.text1.substring(0, 30) + "..."
|
||||||
|
: textPair.text1}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{textPair.text2.length > 30
|
||||||
|
? textPair.text2.substring(0, 30) + "..."
|
||||||
|
: textPair.text2}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<UpdateTextPairModal
|
||||||
|
isOpen={openUpdateModal}
|
||||||
|
onClose={() => setOpenUpdateModal(false)}
|
||||||
|
onUpdate={async (id: number, data: PairUpdateInput) => {
|
||||||
|
await updatePairById(id, data);
|
||||||
|
setOpenUpdateModal(false);
|
||||||
|
refreshTextPairs();
|
||||||
|
}}
|
||||||
|
textPair={textPair}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
src/app/folders/[folder_id]/UpdateTextPairModal.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import LightButton from "@/components/ui/buttons/LightButton";
|
||||||
|
import Input from "@/components/ui/Input";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { PairUpdateInput } from "../../../../generated/prisma/models";
|
||||||
|
import { TextPair } from "./InFolder";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
interface UpdateTextPairModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
textPair: TextPair;
|
||||||
|
onUpdate: (id: number, tp: PairUpdateInput) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UpdateTextPairModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onUpdate,
|
||||||
|
textPair,
|
||||||
|
}: UpdateTextPairModalProps) {
|
||||||
|
const t = useTranslations("folder_id");
|
||||||
|
const input1Ref = useRef<HTMLInputElement>(null);
|
||||||
|
const input2Ref = useRef<HTMLInputElement>(null);
|
||||||
|
const input3Ref = useRef<HTMLInputElement>(null);
|
||||||
|
const input4Ref = useRef<HTMLInputElement>(null);
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleUpdate = () => {
|
||||||
|
if (
|
||||||
|
!input1Ref.current?.value ||
|
||||||
|
!input2Ref.current?.value ||
|
||||||
|
!input3Ref.current?.value ||
|
||||||
|
!input4Ref.current?.value
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const text1 = input1Ref.current.value;
|
||||||
|
const text2 = input2Ref.current.value;
|
||||||
|
const locale1 = input3Ref.current.value;
|
||||||
|
const locale2 = input4Ref.current.value;
|
||||||
|
if (
|
||||||
|
typeof text1 === "string" &&
|
||||||
|
typeof text2 === "string" &&
|
||||||
|
typeof locale1 === "string" &&
|
||||||
|
typeof locale2 === "string" &&
|
||||||
|
text1.trim() !== "" &&
|
||||||
|
text2.trim() !== "" &&
|
||||||
|
locale1.trim() !== "" &&
|
||||||
|
locale2.trim() !== ""
|
||||||
|
) {
|
||||||
|
onUpdate(textPair.id, { text1, text2, locale1, locale2 });
|
||||||
|
input1Ref.current.value = "";
|
||||||
|
input2Ref.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 backdrop-blur flex items-center justify-center z-50"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleUpdate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="bg-white rounded-xl p-6 w-full max-w-md mx-4">
|
||||||
|
<div className="flex">
|
||||||
|
<h2 className="flex-1 text-xl font-light mb-4 text-center">
|
||||||
|
{t("updateTextPair")}
|
||||||
|
</h2>
|
||||||
|
<X onClick={onClose} className="hover:cursor-pointer"></X>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
{t("text1")}
|
||||||
|
<Input
|
||||||
|
defaultValue={textPair.text1}
|
||||||
|
ref={input1Ref}
|
||||||
|
className="w-full"
|
||||||
|
></Input>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t("text2")}
|
||||||
|
<Input
|
||||||
|
defaultValue={textPair.text2}
|
||||||
|
ref={input2Ref}
|
||||||
|
className="w-full"
|
||||||
|
></Input>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t("locale1")}
|
||||||
|
<Input
|
||||||
|
defaultValue={textPair.locale1}
|
||||||
|
ref={input3Ref}
|
||||||
|
className="w-full"
|
||||||
|
></Input>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t("locale2")}
|
||||||
|
<Input
|
||||||
|
defaultValue={textPair.locale2}
|
||||||
|
ref={input4Ref}
|
||||||
|
className="w-full"
|
||||||
|
></Input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<LightButton onClick={handleUpdate}>{t("update")}</LightButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/app/folders/[folder_id]/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import InFolder from "./InFolder";
|
||||||
|
import { getUserIdByFolderId } from "@/lib/server/services/folderService";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
export default async function FoldersPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ folder_id: number; }>;
|
||||||
|
}) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
const { folder_id } = await params;
|
||||||
|
const t = await getTranslations("folder_id");
|
||||||
|
|
||||||
|
if (!folder_id) {
|
||||||
|
redirect("/folders");
|
||||||
|
}
|
||||||
|
if (!session) redirect(`/auth?redirect=/folders/${folder_id}`);
|
||||||
|
if ((await getUserIdByFolderId(Number(folder_id))) !== session.user.id) {
|
||||||
|
return <p>{t("unauthorized")}</p>;
|
||||||
|
}
|
||||||
|
return <InFolder folderId={Number(folder_id)} />;
|
||||||
|
}
|
||||||
12
src/app/folders/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { auth } from "@/auth";
|
||||||
|
import FoldersClient from "./FoldersClient";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
export default async function FoldersPage() {
|
||||||
|
const session = await auth.api.getSession(
|
||||||
|
{ headers: await headers() }
|
||||||
|
);
|
||||||
|
if (!session) redirect(`/auth?redirect=/folders`);
|
||||||
|
return <FoldersClient userId={session.user.id} />;
|
||||||
|
}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--background: #ffffff;
|
||||||
--foreground: #171717;
|
--foreground: #171717;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* @media (prefers-color-scheme: dark) {
|
/* @media (prefers-color-scheme: dark) {
|
||||||
@@ -20,13 +20,11 @@
|
|||||||
} */
|
} */
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
|
font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.code-block {
|
.code-block {
|
||||||
font-family: var(--font-geist-mono), monospace;
|
font-family: var(--font-geist-mono), monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
@source '../../node_modules/rc-modal-sheet/**/*.js'
|
|
||||||
@@ -1,70 +1,33 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import type { Viewport } from 'next'
|
import type { Viewport } from "next";
|
||||||
import Link from "next/link";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
import Image from "next/image";
|
import { Navbar } from "@/components/layout/Navbar";
|
||||||
|
import { Toaster } from "sonner";
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
width: 'device-width',
|
width: "device-width",
|
||||||
initialScale: 1.0
|
initialScale: 1.0,
|
||||||
}
|
};
|
||||||
|
|
||||||
const geistSans = Geist({
|
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Learn Languages",
|
title: "Learn Languages",
|
||||||
description: "A Website to Learn Languages",
|
description: "A Website to Learn Languages",
|
||||||
};
|
};
|
||||||
|
|
||||||
function MyLink(
|
export default async function RootLayout({
|
||||||
{ href, label }: { href: string, label: string }
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<Link className="font-bold" href={href}>{label}</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
function Navbar() {
|
|
||||||
return (
|
|
||||||
<div className="flex justify-between items-center w-full h-16 px-8 bg-[#35786f] text-white">
|
|
||||||
<Link href={'/'} className="text-xl flex">
|
|
||||||
<Image
|
|
||||||
src={'/favicon.ico'}
|
|
||||||
alt="logo"
|
|
||||||
width="32"
|
|
||||||
height="32"
|
|
||||||
className="rounded-4xl">
|
|
||||||
</Image>
|
|
||||||
<span className="font-bold">学语言</span>
|
|
||||||
</Link>
|
|
||||||
<div className="flex gap-4 text-xl">
|
|
||||||
<MyLink href="/changelog.txt" label="关于"></MyLink>
|
|
||||||
<MyLink href="https://github.com/GoddoNebianU/learn-languages" label="源码"></MyLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RootLayout({
|
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body
|
<body className={`antialiased`}>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
<NextIntlClientProvider>
|
||||||
>
|
<Navbar></Navbar>
|
||||||
<Navbar></Navbar>
|
{children}
|
||||||
{children}
|
<Toaster />
|
||||||
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
153
src/app/page.tsx
@@ -1,99 +1,84 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
function TopArea() {
|
interface LinkAreaProps {
|
||||||
return (
|
href: string;
|
||||||
<div className="bg-[#35786f] text-white w-full min-h-[75dvh] flex justify-center items-center">
|
name: string;
|
||||||
<div className="mb-16 mx-16 md:mx-0 md:max-w-[60dvw]">
|
description: string;
|
||||||
<h1 className="text-6xl md:text-9xl mb-8">Learn Languages</h1>
|
color: string;
|
||||||
<p className="text-2xl md:text-5xl">Here is a very useful website to help you learn almost every language in the world, including constructed ones.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LinkAreaProps {
|
function LinkArea({ href, name, description, color }: LinkAreaProps) {
|
||||||
href: string,
|
|
||||||
name: string,
|
|
||||||
description: string,
|
|
||||||
color: string
|
|
||||||
}
|
|
||||||
function LinkArea(
|
|
||||||
{ href, name, description, color }: LinkAreaProps
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
<Link href={href}
|
<Link
|
||||||
|
href={href}
|
||||||
style={{ backgroundColor: color }}
|
style={{ backgroundColor: color }}
|
||||||
className={`h-32 md:h-64 flex justify-center items-center`}>
|
className={`h-32 md:h-64 flex md:justify-center items-center`}
|
||||||
|
>
|
||||||
<div className="text-white m-8">
|
<div className="text-white m-8">
|
||||||
<h1 className="text-4xl">{name}</h1>
|
<h1 className="md:text-4xl text-3xl">{name}</h1>
|
||||||
<p className="text-xl">{description}</p>
|
<p className="md:text-xl">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LinkGrid() {
|
export default async function HomePage() {
|
||||||
return (
|
const t = await getTranslations("home");
|
||||||
<div className="w-full grid grid-cols-1 grid-rows-6 md:grid-cols-3">
|
|
||||||
|
|
||||||
<LinkArea
|
|
||||||
href="/translator"
|
|
||||||
name="翻译器"
|
|
||||||
description="翻译到任何语言,并标注国际音标(IPA)"
|
|
||||||
color="#a56068"></LinkArea>
|
|
||||||
<LinkArea
|
|
||||||
href="/text-speaker"
|
|
||||||
name="朗读器"
|
|
||||||
description="识别并朗读文本,支持循环朗读、朗读速度调节"
|
|
||||||
color="#578aad"></LinkArea>
|
|
||||||
{/* <LinkArea
|
|
||||||
href="/word-board"
|
|
||||||
name="词墙"
|
|
||||||
description="将单词固定到一片区域,高效便捷地记忆单词"
|
|
||||||
color="#e9b353"></LinkArea> */}
|
|
||||||
<LinkArea
|
|
||||||
href="/srt-player"
|
|
||||||
name="逐句视频播放器"
|
|
||||||
description="基于SRT字幕文件,逐句播放视频以模仿母语者的发音"
|
|
||||||
color="#3c988d"></LinkArea>
|
|
||||||
<LinkArea
|
|
||||||
href="/alphabet"
|
|
||||||
name="记忆字母表"
|
|
||||||
description="从字母表开始新语言的学习"
|
|
||||||
color="#dd7486"></LinkArea>
|
|
||||||
<LinkArea
|
|
||||||
href="#"
|
|
||||||
name="更多功能"
|
|
||||||
description="开发中,敬请期待"
|
|
||||||
color="#cab48a"></LinkArea>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Fortune() {
|
|
||||||
return (
|
|
||||||
<div className="w-full flex justify-center font-serif items-center flex-col min-h-64 h-[25vdh]">
|
|
||||||
<p className="text-3xl">Stay hungry, stay foolish.</p>
|
|
||||||
<cite className="text-[#e9b353] text-xl">—— Steve Jobs</cite>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Explore() {
|
|
||||||
return (
|
|
||||||
<div className="bg-[#bbbbbb] w-full flex justify-center items-center flex-col h-52">
|
|
||||||
<span className="text-[100px] text-white">探索网站</span>
|
|
||||||
<div className="w-0 h-0 border-l-[40px] border-r-[40px] border-t-[30px] border-l-transparent border-r-transparent border-t-white"></div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TopArea></TopArea>
|
<div className="bg-[#35786f] text-white w-full min-h-[75dvh] flex justify-center items-center">
|
||||||
<Fortune></Fortune>
|
<div className="mb-16 mx-16 md:mx-0 md:max-w-[60dvw]">
|
||||||
<Explore></Explore>
|
<h1 className="text-6xl md:text-9xl mb-8 font-extrabold">
|
||||||
<LinkGrid></LinkGrid>
|
{t("title")}
|
||||||
</>);
|
</h1>
|
||||||
|
<p className="text-2xl md:text-5xl font-medium">{t("description")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex justify-center font-serif items-center flex-col min-h-64 h-[25vdh]">
|
||||||
|
<p className="text-3xl">{t("fortune.quote")}</p>
|
||||||
|
<cite className="text-[#e9b353] text-xl">{t("fortune.author")}</cite>
|
||||||
|
</div>
|
||||||
|
<div className="bg-[#bbbbbb] w-full flex justify-center items-center flex-col h-32">
|
||||||
|
<div className="w-0 h-0 border-l-40 border-r-40 border-t-30 border-l-transparent border-r-transparent border-t-white"></div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full grid grid-cols-1 grid-rows-6 md:grid-cols-3"><LinkArea
|
||||||
|
href="/translator"
|
||||||
|
name={t("translator.name")}
|
||||||
|
description={t("translator.description")}
|
||||||
|
color="#a56068"
|
||||||
|
></LinkArea>
|
||||||
|
<LinkArea
|
||||||
|
href="/text-speaker"
|
||||||
|
name={t("textSpeaker.name")}
|
||||||
|
description={t("textSpeaker.description")}
|
||||||
|
color="#578aad"
|
||||||
|
></LinkArea>
|
||||||
|
<LinkArea
|
||||||
|
href="/srt-player"
|
||||||
|
name={t("srtPlayer.name")}
|
||||||
|
description={t("srtPlayer.description")}
|
||||||
|
color="#3c988d"
|
||||||
|
></LinkArea>
|
||||||
|
<LinkArea
|
||||||
|
href="/alphabet"
|
||||||
|
name={t("alphabet.name")}
|
||||||
|
description={t("alphabet.description")}
|
||||||
|
color="#dd7486"
|
||||||
|
></LinkArea>
|
||||||
|
<LinkArea
|
||||||
|
href="/memorize"
|
||||||
|
name={t("memorize.name")}
|
||||||
|
description={t("memorize.description")}
|
||||||
|
color="#cc9988"
|
||||||
|
></LinkArea>
|
||||||
|
<LinkArea
|
||||||
|
href="#"
|
||||||
|
name={t("moreFeatures.name")}
|
||||||
|
description={t("moreFeatures.description")}
|
||||||
|
color="#cab48a"
|
||||||
|
></LinkArea>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
20
src/app/profile/LogoutButton.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import LightButton from "@/components/ui/buttons/LightButton";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
export default function LogoutButton() {
|
||||||
|
const t = useTranslations("profile");
|
||||||
|
const router = useRouter();
|
||||||
|
return <LightButton onClick={async () => {
|
||||||
|
authClient.signOut({
|
||||||
|
fetchOptions: {
|
||||||
|
onSuccess: () => {
|
||||||
|
router.push("/auth?redirect=/profile");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}> {t("logout")}</LightButton >;
|
||||||
|
}
|
||||||
40
src/app/profile/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import { Center } from "@/components/common/Center";
|
||||||
|
import Container from "@/components/ui/Container";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import LogoutButton from "./LogoutButton";
|
||||||
|
|
||||||
|
export default async function ProfilePage() {
|
||||||
|
const t = await getTranslations("profile");
|
||||||
|
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
redirect("/auth?redirect=/profile");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(JSON.stringify(session, null, 2));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Center>
|
||||||
|
<Container className="p-6">
|
||||||
|
<h1>{t("myProfile")}</h1>
|
||||||
|
{session.user.image && (
|
||||||
|
<Image
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
alt="User Avatar"
|
||||||
|
src={session.user.image as string}
|
||||||
|
className="rounded-4xl"
|
||||||
|
></Image>
|
||||||
|
)}
|
||||||
|
<p>{session.user.name}</p>
|
||||||
|
<p>{t("email", { email: session.user.email })}</p>
|
||||||
|
<LogoutButton />
|
||||||
|
</Container>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import Button from "@/components/Button";
|
|
||||||
import { useRef, useState } from "react";
|
|
||||||
|
|
||||||
export default function UploadArea(
|
|
||||||
{
|
|
||||||
setVideoUrl,
|
|
||||||
setSrtUrl
|
|
||||||
}: {
|
|
||||||
setVideoUrl: (url: string | null) => void;
|
|
||||||
setSrtUrl: (url: string | null) => void;
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
|
||||||
const [SrtFile, setSrtFile] = useState<File | null>(null);
|
|
||||||
|
|
||||||
const uploadVideo = () => {
|
|
||||||
const input = inputRef.current;
|
|
||||||
if (input) {
|
|
||||||
input.setAttribute('accept', 'video/*');
|
|
||||||
input.click();
|
|
||||||
input.onchange = () => {
|
|
||||||
const file = input.files?.[0];
|
|
||||||
if (file) {
|
|
||||||
setVideoFile(file);
|
|
||||||
setVideoUrl(URL.createObjectURL(file));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const uploadSRT = () => {
|
|
||||||
const input = inputRef.current;
|
|
||||||
if (input) {
|
|
||||||
input.setAttribute('accept', '.srt');
|
|
||||||
input.click();
|
|
||||||
input.onchange = () => {
|
|
||||||
const file = input.files?.[0];
|
|
||||||
if (file) {
|
|
||||||
setSrtFile(file);
|
|
||||||
setSrtUrl(URL.createObjectURL(file));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="w-full flex flex-col gap-2 m-2">
|
|
||||||
<Button label="上传视频" onClick={uploadVideo} />
|
|
||||||
<Button label="上传字幕" onClick={uploadSRT} />
|
|
||||||
<input type="file" className="hidden" ref={inputRef} />
|
|
||||||
</div >
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { inspect } from "@/utils";
|
|
||||||
|
|
||||||
export default function SubtitleDisplay({ subtitle }: { subtitle: string }) {
|
|
||||||
const words = subtitle.match(/\b[\w']+(?:-[\w']+)*\b/g) || [];
|
|
||||||
let i = 0;
|
|
||||||
return (
|
|
||||||
<div className="w-full subtitle overflow-auto h-16 mt-2 break-words bg-black/50 font-sans text-white text-center text-2xl">
|
|
||||||
{
|
|
||||||
words.map((v) => (
|
|
||||||
<span
|
|
||||||
onClick={inspect(v)}
|
|
||||||
key={i++}
|
|
||||||
className="hover:bg-gray-700 hover:underline hover:cursor-pointer"
|
|
||||||
>
|
|
||||||
{v + ' '}
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
import { useState, useRef, forwardRef, useEffect, KeyboardEvent, useCallback } from "react";
|
|
||||||
import SubtitleDisplay from "./SubtitleDisplay";
|
|
||||||
import Button from "@/components/Button";
|
|
||||||
import { getIndex, parseSrt, getNearistIndex } from "../subtitle";
|
|
||||||
|
|
||||||
type VideoPanelProps = {
|
|
||||||
videoUrl: string | null;
|
|
||||||
srtUrl: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>((
|
|
||||||
{ videoUrl, srtUrl }, videoRef
|
|
||||||
) => {
|
|
||||||
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">
|
|
||||||
<Button label={isPlaying ? '暂停' : '播放'} onClick={togglePlayPause}></Button>
|
|
||||||
<Button label="上句" onClick={previous}></Button>
|
|
||||||
<Button label="下句" onClick={next}></Button>
|
|
||||||
<Button label="句首" onClick={restart}></Button>
|
|
||||||
<Button label={`自动暂停(${autoPause ? '是' : '否'})`} onClick={handleAutoPauseToggle}></Button>
|
|
||||||
</div>
|
|
||||||
<input className="seekbar" type="range" min={0} max={srtLength} onChange={handleSeek} step={1} value={progress}></input>
|
|
||||||
<span>{spanText}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
VideoPanel.displayName = 'VideoPanel';
|
|
||||||
|
|
||||||
export default VideoPanel;
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { KeyboardEvent, useRef, useState } from "react";
|
|
||||||
import UploadArea from "./UploadArea";
|
|
||||||
import VideoPanel from "./VideoPlayer/VideoPanel";
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
|
||||||
|
|
||||||
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
|
||||||
const [srtUrl, setSrtUrl] = useState<string | null>(null);
|
|
||||||
return (
|
|
||||||
<div className="flex w-screen pt-8 items-center justify-center" onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => e.preventDefault()}>
|
|
||||||
<div className="w-[80vw] md:w-[45vw] flex items-center flex-col">
|
|
||||||
<VideoPanel
|
|
||||||
videoUrl={videoUrl}
|
|
||||||
srtUrl={srtUrl}
|
|
||||||
ref={videoRef} />
|
|
||||||
<UploadArea
|
|
||||||
setVideoUrl={setVideoUrl}
|
|
||||||
setSrtUrl={setSrtUrl} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
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));
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { getTextSpeakerData, setTextSpeakerData } from "@/utils";
|
|
||||||
import { useState } from "react";
|
|
||||||
import z from "zod";
|
|
||||||
import { TextSpeakerItemSchema } from "@/interfaces";
|
|
||||||
import IconClick from "@/components/IconClick";
|
|
||||||
import IMAGES from "@/config/images";
|
|
||||||
|
|
||||||
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-1 border-gray-200 rounded-2xl 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={42}>
|
|
||||||
</IconClick>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SaveListProps {
|
|
||||||
show?: boolean;
|
|
||||||
handleUse: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
|
|
||||||
}
|
|
||||||
export default function SaveList({
|
|
||||||
show = false,
|
|
||||||
handleUse
|
|
||||||
}: SaveListProps) {
|
|
||||||
const [data, setData] = useState(getTextSpeakerData());
|
|
||||||
const handleDel = (item: z.infer<typeof TextSpeakerItemSchema>) => {
|
|
||||||
const current_data = getTextSpeakerData();
|
|
||||||
current_data.splice(
|
|
||||||
current_data.findIndex(v => v.text === item.text), 1
|
|
||||||
);
|
|
||||||
setTextSpeakerData(current_data);
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
const refresh = () => {
|
|
||||||
setData(getTextSpeakerData());
|
|
||||||
}
|
|
||||||
const handleDeleteAll = () => {
|
|
||||||
const yesorno = prompt('确定删光吗?(Y/N)')?.trim();
|
|
||||||
if (yesorno && (yesorno === 'Y' || yesorno === 'y')) {
|
|
||||||
setTextSpeakerData([]);
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (show) return (
|
|
||||||
<div className="my-4 p-2 mx-4 md:mx-32 border-1 border-gray-200 rounded-2xl" 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={48}
|
|
||||||
className=""></IconClick>
|
|
||||||
<IconClick
|
|
||||||
src={IMAGES.delete}
|
|
||||||
alt="delete"
|
|
||||||
onClick={handleDeleteAll}
|
|
||||||
size={48}
|
|
||||||
className=""></IconClick>
|
|
||||||
</div>
|
|
||||||
<ul>
|
|
||||||
{data.map(v =>
|
|
||||||
<TextCard item={v} key={crypto.randomUUID()} handleUse={handleUse} handleDel={handleDel}></TextCard>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
); else return (<></>);
|
|
||||||
}
|
|
||||||
@@ -1,301 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Button from "@/components/Button";
|
|
||||||
import IconClick from "@/components/IconClick";
|
|
||||||
import IMAGES from "@/config/images";
|
|
||||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
|
||||||
import { getTextSpeakerData, getTTSAudioUrl, setTextSpeakerData } from "@/utils";
|
|
||||||
import { ChangeEvent, useEffect, useRef, useState } from "react";
|
|
||||||
import SaveList from "./SaveList";
|
|
||||||
import { TextSpeakerItemSchema } from "@/interfaces";
|
|
||||||
import z from "zod";
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
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 [locale, setLocale] = useState<string | null>(null);
|
|
||||||
const [ipa, setIPA] = useState<string>('');
|
|
||||||
const objurlRef = useRef<string | null>(null);
|
|
||||||
const [processing, setProcessing] = useState(false);
|
|
||||||
const [voicesData, setVoicesData] = useState<{
|
|
||||||
locale: string,
|
|
||||||
short_name: string
|
|
||||||
}[] | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const { playAudio, stopAudio, audioRef } = useAudioPlayer();
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('/list_of_voices.json')
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(setVoicesData)
|
|
||||||
.catch(() => setVoicesData(null))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
useEffect(() => {
|
|
||||||
const audio = audioRef.current;
|
|
||||||
if (!audio) return;
|
|
||||||
|
|
||||||
const handleEnded = () => {
|
|
||||||
if (autopause) {
|
|
||||||
setPause(true);
|
|
||||||
} else {
|
|
||||||
playAudio(objurlRef.current!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
audio.addEventListener('ended', handleEnded);
|
|
||||||
return () => {
|
|
||||||
audio.removeEventListener('ended', handleEnded);
|
|
||||||
};
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [audioRef, autopause]);
|
|
||||||
|
|
||||||
|
|
||||||
if (loading) return <div>加载中...</div>;
|
|
||||||
if (!voicesData) return <div>加载失败</div>;
|
|
||||||
|
|
||||||
|
|
||||||
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(e);
|
|
||||||
setIPA('');
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pause) {
|
|
||||||
// 如果没在读
|
|
||||||
if (textRef.current.length === 0) {
|
|
||||||
// 没文本咋读
|
|
||||||
} else {
|
|
||||||
setPause(false);
|
|
||||||
|
|
||||||
if (objurlRef.current) {
|
|
||||||
// 之前有播放
|
|
||||||
playAudio(objurlRef.current);
|
|
||||||
} else {
|
|
||||||
// 第一次播放
|
|
||||||
try {
|
|
||||||
let theLocale = locale;
|
|
||||||
if (!theLocale) {
|
|
||||||
console.log('downloading text info');
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
text: textRef.current.slice(0, 30)
|
|
||||||
});
|
|
||||||
const textinfo = await (await fetch(`/api/locale?${params}`)).json();
|
|
||||||
setLocale(textinfo.locale);
|
|
||||||
theLocale = textinfo.locale as string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const voice = voicesData.find(v => v.locale.startsWith(theLocale));
|
|
||||||
if (!voice) throw 'Voice not found.';
|
|
||||||
|
|
||||||
objurlRef.current = await getTTSAudioUrl(
|
|
||||||
textRef.current,
|
|
||||||
voice.short_name,
|
|
||||||
(() => {
|
|
||||||
if (speed === 1) return {};
|
|
||||||
else if (speed < 1) return {
|
|
||||||
rate: `-${100 - speed * 100}%`
|
|
||||||
}; else return {
|
|
||||||
rate: `+${speed * 100 - 100}%`
|
|
||||||
};
|
|
||||||
})()
|
|
||||||
);
|
|
||||||
playAudio(objurlRef.current);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
|
|
||||||
setPause(true);
|
|
||||||
setLocale(null);
|
|
||||||
|
|
||||||
setProcessing(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 如果在读就暂停
|
|
||||||
setPause(true);
|
|
||||||
stopAudio();
|
|
||||||
}
|
|
||||||
|
|
||||||
setProcessing(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
|
||||||
textRef.current = e.target.value.trim();
|
|
||||||
setLocale(null);
|
|
||||||
setIPA('');
|
|
||||||
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
|
||||||
objurlRef.current = null;
|
|
||||||
stopAudio();
|
|
||||||
setPause(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const letMeSetSpeed = (new_speed: number) => {
|
|
||||||
return () => {
|
|
||||||
setSpeed(new_speed);
|
|
||||||
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
|
||||||
objurlRef.current = null;
|
|
||||||
stopAudio();
|
|
||||||
setPause(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUseItem = (item: z.infer<typeof TextSpeakerItemSchema>) => {
|
|
||||||
if (textareaRef.current) textareaRef.current.value = item.text;
|
|
||||||
textRef.current = item.text;
|
|
||||||
setLocale(item.locale);
|
|
||||||
setIPA(item.ipa || '');
|
|
||||||
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
|
||||||
objurlRef.current = null;
|
|
||||||
stopAudio();
|
|
||||||
setPause(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const save = async () => {
|
|
||||||
if (saving) return;
|
|
||||||
if (textRef.current.length === 0) return;
|
|
||||||
|
|
||||||
setSaving(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
let theLocale = locale;
|
|
||||||
if (!theLocale) {
|
|
||||||
console.log('downloading text info');
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
text: textRef.current.slice(0, 30)
|
|
||||||
});
|
|
||||||
const textinfo = await (await fetch(`/api/locale?${params}`)).json();
|
|
||||||
setLocale(textinfo.locale);
|
|
||||||
theLocale = textinfo.locale as string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let theIPA = ipa;
|
|
||||||
if (ipa.length === 0 && ipaEnabled) {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
text: textRef.current
|
|
||||||
});
|
|
||||||
const tmp = await (await fetch(`/api/ipa?${params}`)).json();
|
|
||||||
setIPA(tmp.ipa);
|
|
||||||
theIPA = tmp.ipa;
|
|
||||||
}
|
|
||||||
|
|
||||||
const save = getTextSpeakerData();
|
|
||||||
const oldIndex = save.findIndex(v => v.text === textRef.current);
|
|
||||||
if (oldIndex !== -1) {
|
|
||||||
const oldItem = save[oldIndex];
|
|
||||||
if (theIPA) {
|
|
||||||
if ((!oldItem.ipa || (oldItem.ipa !== theIPA))) {
|
|
||||||
oldItem.ipa = theIPA;
|
|
||||||
setTextSpeakerData(save);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (theIPA.length === 0) {
|
|
||||||
save.push({
|
|
||||||
text: textRef.current,
|
|
||||||
locale: theLocale
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
save.push({
|
|
||||||
text: textRef.current,
|
|
||||||
locale: theLocale,
|
|
||||||
ipa: theIPA
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setTextSpeakerData(save);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
setLocale(null);
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (<>
|
|
||||||
<div className="my-4 p-4 mx-4 md:mx-32 border-1 border-gray-200 rounded-2xl" 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"
|
|
||||||
onChange={handleInputChange}
|
|
||||||
ref={textareaRef}>
|
|
||||||
</textarea>
|
|
||||||
{
|
|
||||||
ipa.length !== 0 && (<div className="overflow-auto text-gray-600 h-18 border-gray-200 border-b">
|
|
||||||
{ipa}
|
|
||||||
</div>) || (<div className="h-18"></div>)
|
|
||||||
}
|
|
||||||
<div className="mt-8 relative w-full flex flex-row flex-wrap gap-2 justify-center items-center">
|
|
||||||
{showSpeedAdjust && (
|
|
||||||
<div className="bg-white p-6 rounded-2xl 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">
|
|
||||||
<IconClick size={45} onClick={letMeSetSpeed(0.5)}
|
|
||||||
src={IMAGES.speed_0_5x}
|
|
||||||
alt="0.5x"
|
|
||||||
className={speed === 0.5 ? 'bg-gray-200' : ''}
|
|
||||||
></IconClick>
|
|
||||||
<IconClick size={45} onClick={letMeSetSpeed(0.7)}
|
|
||||||
src={IMAGES.speed_0_7x}
|
|
||||||
alt="0.7x"
|
|
||||||
className={speed === 0.7 ? 'bg-gray-200' : ''}
|
|
||||||
></IconClick>
|
|
||||||
<IconClick size={45} onClick={letMeSetSpeed(1)}
|
|
||||||
src={IMAGES.speed_1x}
|
|
||||||
alt="1x"
|
|
||||||
className={speed === 1 ? 'bg-gray-200' : ''}
|
|
||||||
></IconClick>
|
|
||||||
<IconClick size={45} onClick={letMeSetSpeed(1.2)}
|
|
||||||
src={IMAGES.speed_1_2_x}
|
|
||||||
alt="1.2x"
|
|
||||||
className={speed === 1.2 ? 'bg-gray-200' : ''}
|
|
||||||
></IconClick>
|
|
||||||
<IconClick size={45} onClick={letMeSetSpeed(1.5)}
|
|
||||||
src={IMAGES.speed_1_5x}
|
|
||||||
alt="1.5x"
|
|
||||||
className={speed === 1.5 ? 'bg-gray-200' : ''}
|
|
||||||
></IconClick>
|
|
||||||
</div>)}
|
|
||||||
<IconClick size={45} onClick={speak} src={
|
|
||||||
pause ? IMAGES.play_arrow : IMAGES.pause
|
|
||||||
} alt="playorpause" className={`${processing ? 'bg-gray-200' : ''}`}></IconClick>
|
|
||||||
<IconClick size={45} onClick={() => {
|
|
||||||
setAutopause(!autopause); if (objurlRef) { stopAudio(); } setPause(true);
|
|
||||||
}} src={
|
|
||||||
autopause ? IMAGES.autoplay : IMAGES.autopause
|
|
||||||
} alt="autoplayorpause"
|
|
||||||
></IconClick>
|
|
||||||
<IconClick size={45} onClick={() => setShowSpeedAdjust(!showSpeedAdjust)}
|
|
||||||
src={IMAGES.speed}
|
|
||||||
alt="speed"
|
|
||||||
className={`${showSpeedAdjust ? 'bg-gray-200' : ''}`}></IconClick>
|
|
||||||
<IconClick size={45} 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">
|
|
||||||
<Button label="生成IPA"
|
|
||||||
selected={ipaEnabled}
|
|
||||||
onClick={() => setIPAEnabled(!ipaEnabled)}>
|
|
||||||
</Button>
|
|
||||||
<Button label="查看保存项"
|
|
||||||
onClick={() => { setShowSaveList(!showSaveList) }}
|
|
||||||
selected={showSaveList}>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<SaveList show={showSaveList} handleUse={handleUseItem}></SaveList>
|
|
||||||
</>);
|
|
||||||
}
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { ChangeEvent, useEffect, useState } from "react";
|
|
||||||
import Button from "@/components/Button";
|
|
||||||
import IconClick from "@/components/IconClick";
|
|
||||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
|
||||||
import IMAGES from "@/config/images";
|
|
||||||
import { getTTSAudioUrl } from "@/utils";
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
const [ipaEnabled, setIPAEnabled] = useState(true);
|
|
||||||
const [voicesData, setVoicesData] = useState<{
|
|
||||||
locale: string,
|
|
||||||
short_name: string
|
|
||||||
}[] | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [targetLang, setTargetLang] = useState('Italian');
|
|
||||||
|
|
||||||
const [sourceText, setSourceText] = useState('');
|
|
||||||
const [targetText, setTargetText] = useState('');
|
|
||||||
const [sourceIPA, setSourceIPA] = useState('');
|
|
||||||
const [targetIPA, setTargetIPA] = useState('');
|
|
||||||
const [sourceLocale, setSourceLocale] = useState<string | null>(null);
|
|
||||||
const [targetLocale, setTargetLocale] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [translating, setTranslating] = useState(false);
|
|
||||||
const { playAudio } = useAudioPlayer();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('/list_of_voices.json')
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(setVoicesData)
|
|
||||||
.catch(() => setVoicesData(null))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
if (loading) return <div>加载中...</div>;
|
|
||||||
if (!voicesData) return <div>加载失败</div>;
|
|
||||||
|
|
||||||
const tl = ['English', 'Italian', 'Japanese'];
|
|
||||||
|
|
||||||
const inputLanguage = () => {
|
|
||||||
const lang = prompt('Input a language.')?.trim();
|
|
||||||
if (lang) {
|
|
||||||
setTargetLang(lang);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const translate = () => {
|
|
||||||
if (translating) return;
|
|
||||||
if (sourceText.length === 0) return;
|
|
||||||
|
|
||||||
setTranslating(true);
|
|
||||||
|
|
||||||
setTargetText('');
|
|
||||||
setSourceLocale(null);
|
|
||||||
setTargetLocale(null);
|
|
||||||
setSourceIPA('');
|
|
||||||
setTargetIPA('');
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
text: sourceText,
|
|
||||||
target: targetLang
|
|
||||||
})
|
|
||||||
fetch(`/api/translate?${params}`)
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(obj => {
|
|
||||||
setSourceLocale(obj.source_locale);
|
|
||||||
setTargetLocale(obj.target_locale);
|
|
||||||
setTargetText(obj.target_text);
|
|
||||||
|
|
||||||
if (ipaEnabled) {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
text: sourceText
|
|
||||||
});
|
|
||||||
fetch(`/api/ipa?${params}`)
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
|
||||||
setSourceIPA(data.ipa);
|
|
||||||
}).catch(e => {
|
|
||||||
console.error(e);
|
|
||||||
setSourceIPA('');
|
|
||||||
})
|
|
||||||
const params2 = new URLSearchParams({
|
|
||||||
text: obj.target_text
|
|
||||||
});
|
|
||||||
fetch(`/api/ipa?${params2}`)
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
|
||||||
setTargetIPA(data.ipa);
|
|
||||||
}).catch(e => {
|
|
||||||
console.error(e);
|
|
||||||
setTargetIPA('');
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}).catch(r => {
|
|
||||||
console.error(r);
|
|
||||||
setSourceLocale('');
|
|
||||||
setTargetLocale('');
|
|
||||||
setTargetText('');
|
|
||||||
}).finally(() => setTranslating(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
|
||||||
setSourceText(e.target.value.trim());
|
|
||||||
setTargetText('');
|
|
||||||
setSourceLocale(null);
|
|
||||||
setTargetLocale(null);
|
|
||||||
setSourceIPA('');
|
|
||||||
setTargetIPA('');
|
|
||||||
}
|
|
||||||
|
|
||||||
const readSource = async () => {
|
|
||||||
if (sourceText.length === 0) return;
|
|
||||||
|
|
||||||
if (sourceIPA.length === 0 && ipaEnabled) {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
text: sourceText
|
|
||||||
});
|
|
||||||
fetch(`/api/ipa?${params}`)
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
|
||||||
setSourceIPA(data.ipa);
|
|
||||||
}).catch(e => {
|
|
||||||
console.error(e);
|
|
||||||
setSourceIPA('');
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sourceLocale) {
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
text: sourceText.slice(0, 30)
|
|
||||||
});
|
|
||||||
const res = await fetch(`/api/locale?${params}`);
|
|
||||||
const info = await res.json();
|
|
||||||
setSourceLocale(info.locale);
|
|
||||||
|
|
||||||
const voice = voicesData.find(v => v.locale.startsWith(info.locale));
|
|
||||||
if (!voice) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = await getTTSAudioUrl(sourceText, voice.short_name);
|
|
||||||
await playAudio(url);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
setSourceLocale(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const voice = voicesData.find(v => v.locale.startsWith(sourceLocale!));
|
|
||||||
if (!voice) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = await getTTSAudioUrl(sourceText, voice.short_name);
|
|
||||||
await playAudio(url);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const readTarget = async () => {
|
|
||||||
if (targetText.length === 0) return;
|
|
||||||
|
|
||||||
if (targetIPA.length === 0 && ipaEnabled) {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
text: targetText
|
|
||||||
});
|
|
||||||
fetch(`/api/ipa?${params}`)
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
|
||||||
setTargetIPA(data.ipa);
|
|
||||||
}).catch(e => {
|
|
||||||
console.error(e);
|
|
||||||
setTargetIPA('');
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const voice = voicesData.find(v => v.locale.startsWith(targetLocale!));
|
|
||||||
if (!voice) return;
|
|
||||||
|
|
||||||
const url = await getTTSAudioUrl(targetText, voice.short_name);
|
|
||||||
await playAudio(url);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="w-screen flex flex-col md:flex-row md:justify-between gap-2 p-2">
|
|
||||||
<div className="card1 w-full md:w-1/2 flex flex-col-reverse gap-2">
|
|
||||||
<div className="textarea1 border-1 border-gray-200 rounded-2xl w-full h-64 p-2">
|
|
||||||
<textarea onChange={handleInputChange} className="resize-none h-8/12 w-full focus:outline-0"></textarea>
|
|
||||||
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
|
|
||||||
{sourceIPA}
|
|
||||||
</div>
|
|
||||||
<div className="h-2/12 w-full flex justify-end items-center">
|
|
||||||
<IconClick onClick={async () => {
|
|
||||||
if (sourceText.length !== 0)
|
|
||||||
await navigator.clipboard.writeText(sourceText);
|
|
||||||
}} src={IMAGES.copy_all} alt="copy"></IconClick>
|
|
||||||
<IconClick onClick={readSource} src={IMAGES.play_arrow} alt="play"></IconClick>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="option1 w-full flex flex-row justify-between items-center">
|
|
||||||
<span>detect language</span>
|
|
||||||
<Button label="generate ipa" selected={ipaEnabled} onClick={() => setIPAEnabled(!ipaEnabled)}></Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="card2 w-full md:w-1/2 flex flex-col-reverse gap-2">
|
|
||||||
<div className="textarea2 bg-gray-100 rounded-2xl w-full h-64 p-2">
|
|
||||||
<div className="h-8/12 w-full">{
|
|
||||||
targetText
|
|
||||||
}</div>
|
|
||||||
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
|
|
||||||
{targetIPA}
|
|
||||||
</div>
|
|
||||||
<div className="h-2/12 w-full flex justify-end items-center">
|
|
||||||
<IconClick onClick={async () => {
|
|
||||||
if (targetText.length !== 0)
|
|
||||||
await navigator.clipboard.writeText(targetText);
|
|
||||||
}} src={IMAGES.copy_all} alt="copy"></IconClick>
|
|
||||||
<IconClick onClick={readTarget} src={IMAGES.play_arrow} alt="play"></IconClick>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="option2 w-full flex gap-1 items-center flex-wrap">
|
|
||||||
<span>translate into</span>
|
|
||||||
<Button onClick={() => { setTargetLang('English') }} label="English" selected={targetLang === 'English'}></Button>
|
|
||||||
<Button onClick={() => { setTargetLang('Italian') }} label="Italian" selected={targetLang === 'Italian'}></Button>
|
|
||||||
<Button onClick={() => { setTargetLang('Japanese') }} label="Japanese" selected={targetLang === 'Japanese'}></Button>
|
|
||||||
<Button onClick={inputLanguage} label={'Other' + (tl.includes(targetLang) ? '' : ': ' + targetLang)} selected={!(tl.includes(targetLang))}></Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="button-area w-screen flex justify-center items-center">
|
|
||||||
<button onClick={translate} className={`duration-150 ease-in text-xl font-extrabold border rounded-4xl p-3 border-gray-200 h-16 ${translating ? 'bg-gray-200' : 'bg-white hover:bg-gray-200 hover:cursor-pointer'}`}>
|
|
||||||
{translating ? 'translating...' : 'translate'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { BOARD_WIDTH, TEXT_WIDTH, BOARD_HEIGHT, TEXT_SIZE } from "@/config/word-board-config";
|
|
||||||
import { Word } from "@/interfaces";
|
|
||||||
import { Dispatch, SetStateAction } from "react";
|
|
||||||
|
|
||||||
export default function WordBoard(
|
|
||||||
{ words, selectWord }: {
|
|
||||||
words: [
|
|
||||||
{
|
|
||||||
word: string,
|
|
||||||
x: number,
|
|
||||||
y: number
|
|
||||||
}
|
|
||||||
],
|
|
||||||
setWords: Dispatch<SetStateAction<Word[]>>,
|
|
||||||
selectWord: (word: string) => void
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
function DraggableWord({ word }: { word: Word }) {
|
|
||||||
return (<span
|
|
||||||
style={{
|
|
||||||
left: `${Math.floor(word.x * (BOARD_WIDTH - TEXT_WIDTH * word.word.length))}px`,
|
|
||||||
top: `${Math.floor(word.y * (BOARD_HEIGHT - TEXT_SIZE))}px`,
|
|
||||||
fontSize: `${TEXT_SIZE}px`
|
|
||||||
}}
|
|
||||||
className="select-none cursor-pointer absolute code-block border-amber-100 border-1"
|
|
||||||
// onClick={inspect(word.word)}>{word.word}</span>))
|
|
||||||
onClick={() => { selectWord(word.word); }}>{word.word}</span>);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
width: `${BOARD_WIDTH}px`,
|
|
||||||
height: `${BOARD_HEIGHT}px`
|
|
||||||
}} className="relative rounded bg-white">
|
|
||||||
{words.map(
|
|
||||||
(v: {
|
|
||||||
word: string,
|
|
||||||
x: number,
|
|
||||||
y: number
|
|
||||||
}, i: number) => {
|
|
||||||
return (<DraggableWord word={v} key={i}></DraggableWord>)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
'use client';
|
|
||||||
import WordBoard from "@/app/word-board/WordBoard";
|
|
||||||
import Button from "../../components/Button";
|
|
||||||
import { KeyboardEvent, useRef, useState } from "react";
|
|
||||||
import { Word } from "@/interfaces";
|
|
||||||
import { BOARD_WIDTH, TEXT_WIDTH, BOARD_HEIGHT, TEXT_SIZE } from "@/config/word-board-config";
|
|
||||||
import { inspect } from "@/utils";
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const inputFileRef = useRef<HTMLInputElement>(null);
|
|
||||||
const initialWords =
|
|
||||||
[
|
|
||||||
// 'apple',
|
|
||||||
// 'banana',
|
|
||||||
// 'cannon',
|
|
||||||
// 'desktop',
|
|
||||||
// 'kernel',
|
|
||||||
// 'system',
|
|
||||||
// 'programming',
|
|
||||||
// 'owe'
|
|
||||||
] as Array<string>;
|
|
||||||
const [words, setWords] = useState(
|
|
||||||
initialWords.map((v: string) => ({
|
|
||||||
'word': v,
|
|
||||||
'x': Math.random(),
|
|
||||||
'y': Math.random()
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
const generateNewWord = (word: string) => {
|
|
||||||
const isOK = (w: Word) => {
|
|
||||||
if (words.length === 0) return true;
|
|
||||||
const tf = (ww: Word) => ({
|
|
||||||
word: ww.word,
|
|
||||||
x: Math.floor(ww.x * (BOARD_WIDTH - TEXT_WIDTH * ww.word.length)),
|
|
||||||
y: Math.floor(ww.y * (BOARD_HEIGHT - TEXT_SIZE))
|
|
||||||
} as Word);
|
|
||||||
const tfd_words = words.map(tf);
|
|
||||||
const tfd_w = tf(w);
|
|
||||||
for (const www of tfd_words) {
|
|
||||||
const p1 = {
|
|
||||||
x: (www.x + www.x + TEXT_WIDTH * www.word.length) / 2,
|
|
||||||
y: (www.y + www.y + TEXT_SIZE) / 2
|
|
||||||
}
|
|
||||||
const p2 = {
|
|
||||||
x: (tfd_w.x + tfd_w.x + TEXT_WIDTH * tfd_w.word.length) / 2,
|
|
||||||
y: (tfd_w.y + tfd_w.y + TEXT_SIZE) / 2
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
Math.abs(p1.x - p2.x) < (TEXT_WIDTH * (www.word.length + tfd_w.word.length)) / 2 &&
|
|
||||||
Math.abs(p1.y - p2.y) < TEXT_SIZE
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
let new_word;
|
|
||||||
let count = 0;
|
|
||||||
do {
|
|
||||||
new_word = {
|
|
||||||
word: word,
|
|
||||||
x: Math.random(),
|
|
||||||
y: Math.random()
|
|
||||||
};
|
|
||||||
if (++count > 1000) return null;
|
|
||||||
} while (!isOK(new_word));
|
|
||||||
return new_word as Word;
|
|
||||||
}
|
|
||||||
const insertWord = () => {
|
|
||||||
if (!inputRef.current) return;
|
|
||||||
const word = inputRef.current.value.trim();
|
|
||||||
if (word === '') return;
|
|
||||||
const new_word = generateNewWord(word);
|
|
||||||
if (!new_word) return;
|
|
||||||
setWords([...words, new_word]);
|
|
||||||
inputRef.current.value = '';
|
|
||||||
}
|
|
||||||
const deleteWord = () => {
|
|
||||||
if (!inputRef.current) return;
|
|
||||||
const word = inputRef.current.value.trim();
|
|
||||||
if (word === '') return;
|
|
||||||
setWords(words.filter((v) => v.word !== word));
|
|
||||||
inputRef.current.value = '';
|
|
||||||
};
|
|
||||||
const importWords = () => {
|
|
||||||
inputFileRef.current?.click();
|
|
||||||
}
|
|
||||||
const exportWords = () => {
|
|
||||||
const blob = new Blob([JSON.stringify(words)], {
|
|
||||||
type: 'application/json'
|
|
||||||
});
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `${Date.now()}.json`;
|
|
||||||
a.style.display = 'none';
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
const handleFileChange = () => {
|
|
||||||
const files = inputFileRef.current?.files;
|
|
||||||
if (files && files.length > 0) {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => {
|
|
||||||
if (reader.result && typeof reader.result === 'string')
|
|
||||||
setWords(JSON.parse(reader.result) as [Word]);
|
|
||||||
}
|
|
||||||
reader.readAsText(files[0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const deleteAll = () => {
|
|
||||||
setWords([] as Array<Word>);
|
|
||||||
}
|
|
||||||
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
|
||||||
// e.preventDefault();
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
insertWord();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const selectWord = (word: string) => {
|
|
||||||
if (!inputRef.current) return;
|
|
||||||
inputRef.current.value = word;
|
|
||||||
}
|
|
||||||
const searchWord = () => {
|
|
||||||
if (!inputRef.current) return;
|
|
||||||
const word = inputRef.current.value.trim();
|
|
||||||
if (word === '') return;
|
|
||||||
inspect(word)();
|
|
||||||
inputRef.current.value = '';
|
|
||||||
}
|
|
||||||
// const readWordAloud = () => {
|
|
||||||
// playFromUrl('https://fanyi.baidu.com/gettts?lan=uk&text=disclose&spd=3')
|
|
||||||
// return;
|
|
||||||
// if (!inputRef.current) return;
|
|
||||||
// const word = inputRef.current.value.trim();
|
|
||||||
// if (word === '') return;
|
|
||||||
// inspect(word)();
|
|
||||||
// inputRef.current.value = '';
|
|
||||||
// }
|
|
||||||
return (
|
|
||||||
<div className="flex w-screen h-screen justify-center items-center">
|
|
||||||
<div onKeyDown={handleKeyDown} className="p-5 bg-gray-200 rounded shadow-2xl">
|
|
||||||
<WordBoard selectWord={selectWord} words={words as [Word]} setWords={setWords} />
|
|
||||||
<div className="flex justify-center rounded mt-3 gap-1">
|
|
||||||
<input ref={inputRef} placeholder="word to operate" type="text" className="focus:outline-none border-b-2 border-black" />
|
|
||||||
<Button label="插入" onClick={insertWord}></Button>
|
|
||||||
<Button label="删除" onClick={deleteWord}></Button>
|
|
||||||
<Button label="搜索" onClick={searchWord}></Button>
|
|
||||||
<Button label="导入" onClick={importWords}></Button>
|
|
||||||
<Button label="导出" onClick={exportWords}></Button>
|
|
||||||
<Button label="删光" onClick={deleteAll}></Button>
|
|
||||||
{/* <Button label="朗读" onClick={readWordAloud}></Button> */}
|
|
||||||
</div>
|
|
||||||
<input type="file" ref={inputFileRef} className="hidden" accept="application/json" onChange={handleFileChange}></input>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
20
src/auth.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { betterAuth } from "better-auth";
|
||||||
|
import { prismaAdapter } from "better-auth/adapters/prisma";
|
||||||
|
import { nextCookies } from "better-auth/next-js";
|
||||||
|
import prisma from "./lib/db";
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
database: prismaAdapter(prisma, {
|
||||||
|
provider: "postgresql"
|
||||||
|
}),
|
||||||
|
emailAndPassword: {
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
socialProviders: {
|
||||||
|
github: {
|
||||||
|
clientId: process.env.GITHUB_CLIENT_ID as string,
|
||||||
|
clientSecret: process.env.GITHUB_CLIENT_SECRET as string
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [nextCookies()]
|
||||||
|
});
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
export default function Button({
|
|
||||||
label,
|
|
||||||
onClick,
|
|
||||||
className = '',
|
|
||||||
selected = false
|
|
||||||
}: {
|
|
||||||
label:
|
|
||||||
string,
|
|
||||||
onClick?: () => void,
|
|
||||||
className?: string,
|
|
||||||
selected?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
className={`px-2 py-1 rounded shadow-2xs font-bold hover:bg-gray-300 hover:cursor-pointer ${selected ? 'bg-gray-300' : "bg-white"} ${className}`}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import Image from "next/image";
|
|
||||||
|
|
||||||
|
|
||||||
interface IconClickProps {
|
|
||||||
src: string;
|
|
||||||
alt: string;
|
|
||||||
onClick?: () => void;
|
|
||||||
className?: string;
|
|
||||||
size?: number
|
|
||||||
}
|
|
||||||
export default function IconClick(
|
|
||||||
{ src, alt, onClick = () => { }, className = '', size = 32 }: IconClickProps) {
|
|
||||||
return (<>
|
|
||||||
<div onClick={onClick} className={`hover:cursor-pointer hover:bg-gray-200 rounded-3xl w-[${size}px] h-[${size}px] flex justify-center items-center ${className}`}>
|
|
||||||
<Image
|
|
||||||
src={src}
|
|
||||||
width={size - 5}
|
|
||||||
height={size - 5}
|
|
||||||
alt={alt}
|
|
||||||
></Image>
|
|
||||||
</div>
|
|
||||||
</>);
|
|
||||||
}
|
|
||||||
46
src/components/LanguageSettings.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import IMAGES from "@/config/images";
|
||||||
|
import IconClick from "./ui/buttons/IconClick";
|
||||||
|
import { useState } from "react";
|
||||||
|
import GhostButton from "./ui/buttons/GhostButton";
|
||||||
|
|
||||||
|
export default function LanguageSettings() {
|
||||||
|
const [showLanguageMenu, setShowLanguageMenu] = useState(false);
|
||||||
|
const handleLanguageClick = () => {
|
||||||
|
setShowLanguageMenu((prev) => !prev);
|
||||||
|
};
|
||||||
|
const setLocale = async (locale: string) => {
|
||||||
|
document.cookie = `locale=${locale}`;
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<IconClick
|
||||||
|
src={IMAGES.language_white}
|
||||||
|
alt="language"
|
||||||
|
disableOnHoverBgChange={true}
|
||||||
|
onClick={handleLanguageClick}
|
||||||
|
></IconClick>
|
||||||
|
<div className="relative">
|
||||||
|
{showLanguageMenu && (
|
||||||
|
<div>
|
||||||
|
<div className="absolute top-10 right-0 rounded-md shadow-md flex flex-col gap-2">
|
||||||
|
<GhostButton
|
||||||
|
className="w-full bg-[#35786f]"
|
||||||
|
onClick={() => setLocale("en-US")}
|
||||||
|
>
|
||||||
|
English
|
||||||
|
</GhostButton>
|
||||||
|
<GhostButton
|
||||||
|
className="w-full bg-[#35786f]"
|
||||||
|
onClick={() => setLocale("zh-CN")}
|
||||||
|
>
|
||||||
|
中文
|
||||||
|
</GhostButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div></>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
src/components/common/Center.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const Center = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-[calc(100dvh-64px)]">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||