forked from akai/readest
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e77c377839 | |||
| c2d75deab4 | |||
| e7579e5867 | |||
| fcc933a243 | |||
| bbc457a9a5 | |||
| f8bd4a0e2c | |||
| 3ada880155 | |||
| 66866372ea | |||
| b321986c40 | |||
| 829a698ece | |||
| 978a512ebf | |||
| aca8c93efb | |||
| 8cb81b3795 | |||
| 91716df5e0 | |||
| f5f07e9392 | |||
| 7e385db4ed | |||
| 1b99915663 | |||
| 0a213d6a0e | |||
| f9e9c90386 | |||
| 8b2888af34 | |||
| ffb00f4e0d | |||
| 77da5423a6 | |||
| ee201d4571 | |||
| e48cfc407c | |||
| d49be1f9ca | |||
| c46cf5b0bf | |||
| 3078b09ba4 | |||
| 05cb53ede4 | |||
| bd6ef7fe8e | |||
| b33d85f439 | |||
| 95a69d6bdf | |||
| ed9a0c6f20 | |||
| 71b075dfa8 | |||
| 937832a8ee | |||
| 881a54f155 | |||
| 2dd0a20925 | |||
| b7ae3f29d9 | |||
| 609ddb776b | |||
| 7847360588 | |||
| 8e7240bb9b | |||
| effbc08561 | |||
| 6b057ebc14 | |||
| 26b64adca6 | |||
| 9577f6a026 | |||
| 25da9210e2 |
@@ -6,7 +6,7 @@ labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Does your feature request involve difficulty completing a task? Please describe.**
|
||||
**Does your feature request involve difficulty for you to complete a task? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I think it takes too many steps to [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
|
||||
@@ -2,6 +2,12 @@ name: Release Readest
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
test_ubuntu_arm:
|
||||
description: 'Run only ubuntu-22.04-arm'
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
@@ -59,6 +65,9 @@ jobs:
|
||||
- os: ubuntu-22.04
|
||||
arch: x86_64
|
||||
rust_target: x86_64-unknown-linux-gnu
|
||||
- os: ubuntu-22.04-arm
|
||||
arch: aarch64
|
||||
rust_target: aarch64-unknown-linux-gnu
|
||||
- os: macos-latest
|
||||
arch: aarch64
|
||||
rust_target: x86_64-apple-darwin,aarch64-apple-darwin
|
||||
@@ -73,7 +82,8 @@ jobs:
|
||||
args: '--target aarch64-pc-windows-msvc --bundles nsis'
|
||||
|
||||
runs-on: ${{ matrix.config.os }}
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 30
|
||||
if: ${{ github.event.inputs.test_arm_only }} != 'true' || matrix.config.os == 'ubuntu-22.04-arm'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -118,7 +128,7 @@ jobs:
|
||||
run: cp .env.local apps/readest-app/.env.local
|
||||
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: matrix.config.os == 'ubuntu-22.04'
|
||||
if: contains(matrix.config.os, 'ubuntu')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pkg-config libfontconfig-dev libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
Generated
+13
@@ -30,6 +30,7 @@ dependencies = [
|
||||
"tauri-plugin-os",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-sign-in-with-apple",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-window-state",
|
||||
@@ -5336,6 +5337,18 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-sign-in-with-apple"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9402eb8a70fdff5127fccc1f1666ebb06b5efc10e0eb656d8a70c798e26142e"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-single-instance"
|
||||
version = "2.2.1"
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
[![Website][badge-website]][link-website]
|
||||
[![Web App][badge-web-app]][link-web-readest]
|
||||
[![OS][badge-platforms]][link-website]
|
||||
<br>
|
||||
[![][badge-hellogithub]][link-hellogithub]
|
||||
[![][badge-discord]][link-discord]
|
||||
<br>
|
||||
[![AGPL Licence][badge-license]](LICENSE)
|
||||
[![Latest release][badge-release]][link-gh-releases]
|
||||
[![Last commit][badge-last-commit]][link-gh-commits]
|
||||
@@ -63,7 +63,7 @@
|
||||
| **Feature** | **Description** | **Priority** |
|
||||
| ------------------------------- | ------------------------------------------------------------------------------------------ | ------------ |
|
||||
| **Library Management** | Organize, sort, and manage your entire ebook library. | 🛠 |
|
||||
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🔄 |
|
||||
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🛠 |
|
||||
| **Sync with Koreader** | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | 🔄 |
|
||||
| **Keyboard Navigation** | Implement vimium-style keybindings for book navigation. | 🔄 |
|
||||
| **Support OPDS/Calibre** | Integrate OPDS/Calibre to access online libraries and catalogs. | 🔄 |
|
||||
@@ -150,6 +150,8 @@ For Windows targets, “Build Tools for Visual Studio 2022” (or a higher editi
|
||||
|
||||
```bash
|
||||
pnpm tauri dev
|
||||
# or start development for the Web version only
|
||||
pnpm dev-web
|
||||
```
|
||||
|
||||
For Android:
|
||||
@@ -159,6 +161,8 @@ For Android:
|
||||
pnpm tauri android init
|
||||
|
||||
pnpm tauri android dev
|
||||
# or if you want to dev on a real device
|
||||
pnpm tauri android dev --host
|
||||
```
|
||||
|
||||
For iOS:
|
||||
@@ -168,12 +172,16 @@ For iOS:
|
||||
pnpm tauri ios init
|
||||
|
||||
pnpm tauri ios dev
|
||||
# or if you want to dev on a real device
|
||||
pnpm tauri ios dev --host
|
||||
```
|
||||
|
||||
### 5. Build for Production
|
||||
|
||||
```bash
|
||||
pnpm tauri build
|
||||
pnpm tauri android build
|
||||
pnpm tauri ios build
|
||||
```
|
||||
|
||||
### 6. Setup dev environment with Nix
|
||||
@@ -228,7 +236,7 @@ The following fonts are utilized in this software, either bundled within the app
|
||||
[badge-web-app]: https://img.shields.io/badge/read%20online-web.readest.com-orange
|
||||
[badge-license]: https://img.shields.io/github/license/readest/readest?color=teal
|
||||
[badge-release]: https://img.shields.io/github/release/readest/readest?color=green
|
||||
[badge-platforms]: https://img.shields.io/badge/platforms-macOS%2C%20Windows%2C%20Linux%2C%20Web%2C%20PWA-green
|
||||
[badge-platforms]: https://img.shields.io/badge/platforms-macOS%2C%20Windows%2C%20Linux%2C%20Android%2C%20iOS%2C%20Web%2C%20PWA-green
|
||||
[badge-last-commit]: https://img.shields.io/github/last-commit/readest/readest?color=green
|
||||
[badge-commit-activity]: https://img.shields.io/github/commit-activity/m/readest/readest
|
||||
[badge-discord]: https://img.shields.io/discord/1314226120886976544?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square
|
||||
|
||||
@@ -6,3 +6,10 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY
|
||||
|
||||
DEEPL_PRO_API_KEY=YOUR_DEEPL_PRO_API_KEY
|
||||
DEEPL_FREE_API_KEY=YOUR_DEEPL_FREE_API_KEY
|
||||
|
||||
R2_TOKEN_VALUE=YOUR_R2_TOKEN_VALUE
|
||||
R2_ACCESS_KEY_ID=YOUR_R2_ACCESS_KEY_ID
|
||||
R2_SECRET_ACCESS_KEY=YOUR_R2_SECRET_ACCESS_KEY
|
||||
R2_BUCKET_NAME=YOUR_R2_BUCKET_NAME
|
||||
R2_ACCOUNT_ID=YOUR_R2_ACCOUNT_ID
|
||||
R2_REGION=YOUR_R2_REGION
|
||||
|
||||
@@ -40,6 +40,10 @@ tauri.*.conf.json
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# open-next
|
||||
.open-next
|
||||
.wrangler
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// @ts-nocheck
|
||||
// default open-next.config.ts file created by @opennextjs/cloudflare
|
||||
|
||||
import type { OpenNextConfig } from '@opennextjs/aws/types/open-next.js';
|
||||
import cache from '@opennextjs/cloudflare/kv-cache';
|
||||
|
||||
const config: OpenNextConfig = {
|
||||
default: {
|
||||
override: {
|
||||
wrapper: 'cloudflare-node',
|
||||
converter: 'edge',
|
||||
// set `incrementalCache` to "dummy" to disable KV cache
|
||||
incrementalCache: async () => cache,
|
||||
tagCache: 'dummy',
|
||||
queue: 'dummy',
|
||||
},
|
||||
},
|
||||
|
||||
middleware: {
|
||||
external: true,
|
||||
override: {
|
||||
wrapper: 'cloudflare-edge',
|
||||
converter: 'edge',
|
||||
proxyExternalRequest: 'fetch',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@readest/readest-app",
|
||||
"version": "0.9.10",
|
||||
"version": "0.9.15",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "dotenv -e .env.tauri -- next dev",
|
||||
@@ -26,7 +26,13 @@
|
||||
"build-macos-universial": "dotenv -e .env.tauri.local -e .env.apple-nonstore.local -- tauri build -t universal-apple-darwin --bundles dmg",
|
||||
"build-macos-universial-appstore": "dotenv -e .env.tauri.local -e .env.apple-appstore.local -- tauri build -t universal-apple-darwin --bundles app --config src-tauri/tauri.appstore.conf.json",
|
||||
"build-macos-universial-appstore-dev": "dotenv -e .env.tauri.local -e .env.apple-appstore-dev.local -- tauri build -t universal-apple-darwin --bundles app --config src-tauri/tauri.appstore-dev.conf.json",
|
||||
"release-macos-universial-appstore": "dotenv -e .env.tauri.local -e .env.apple-appstore.local -- bash scripts/release-mac-appstore.sh"
|
||||
"build-ios": "dotenv -e .env.ios-appstore-dev.local -- tauri ios build",
|
||||
"build-ios-appstore": "dotenv -e .env.ios-appstore.local -- tauri ios build --export-method app-store-connect",
|
||||
"release-macos-universial-appstore": "dotenv -e .env.tauri.local -e .env.apple-appstore.local -- bash scripts/release-mac-appstore.sh",
|
||||
"release-ios-appstore": "dotenv -e .env.ios-appstore.local -- bash scripts/release-ios-appstore.sh",
|
||||
"preview": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare && wrangler dev",
|
||||
"deploy": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare && wrangler deploy",
|
||||
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.735.0",
|
||||
|
||||
@@ -75,7 +75,6 @@
|
||||
"Notebook": "Notizbuch",
|
||||
"Notes": "Notizen",
|
||||
"Open": "Öffnen",
|
||||
"Open Book": "Buch öffnen",
|
||||
"Original Text": "Originaltext",
|
||||
"Page": "Seite",
|
||||
"Paging Animation": "Blätter-Animation",
|
||||
@@ -155,5 +154,49 @@
|
||||
"Word Spacing": "Wortabstand",
|
||||
"Letter Spacing": "Buchstabenabstand",
|
||||
"Text Indent": "Texteinzug",
|
||||
"Paragraph Margin": "Absatzrand"
|
||||
"Paragraph Margin": "Absatzrand",
|
||||
"Override Book Layout": "Buch-Layout überschreiben",
|
||||
"Add to Group": "Zur Gruppe hinzufügen",
|
||||
"Untitled Group": "Unbenannte Gruppe",
|
||||
"Group Books": "Bücher gruppieren",
|
||||
"Remove From Group": "Aus Gruppe entfernen",
|
||||
"Create New Group": "Neue Gruppe erstellen",
|
||||
"Deselect Book": "Buch abwählen",
|
||||
"Download Book": "Buch herunterladen",
|
||||
"Deselect Group": "Gruppe abwählen",
|
||||
"Select Group": "Gruppe auswählen",
|
||||
"Keep Screen Awake": "Bildschirm aktiv halten",
|
||||
"Email address": "E-Mail-Adresse",
|
||||
"Your Password": "Ihr Passwort",
|
||||
"Your email address": "Ihre E-Mail-Adresse",
|
||||
"Your password": "Ihr Passwort",
|
||||
"Sign in": "Anmelden",
|
||||
"Signing in...": "Anmeldung...",
|
||||
"Sign in with {{provider}}": "Mit {{provider}} anmelden",
|
||||
"Already have an account? Sign in": "Haben Sie bereits ein Konto? Anmelden",
|
||||
"Create a Password": "Passwort erstellen",
|
||||
"Sign up": "Registrieren",
|
||||
"Signing up...": "Registrierung...",
|
||||
"Don’t have an account? Sign up": "Kein Konto? Registrieren",
|
||||
"Check your email for the confirmation link": "Überprüfen Sie Ihre E-Mails auf den Bestätigungslink",
|
||||
"Signing in ...": "Anmeldung ...",
|
||||
"Send a magic link email": "Magic-Link-E-Mail senden",
|
||||
"Check your email for the magic link": "Überprüfen Sie Ihre E-Mails auf den Magic-Link",
|
||||
"Send reset password instructions": "Anweisungen zum Zurücksetzen des Passworts senden",
|
||||
"Sending reset instructions ...": "Sende Anweisungen zum Zurücksetzen ...",
|
||||
"Forgot your password?": "Passwort vergessen?",
|
||||
"Check your email for the password reset link": "Überprüfen Sie Ihre E-Mails auf den Link zum Zurücksetzen des Passworts",
|
||||
"New Password": "Neues Passwort",
|
||||
"Your new password": "Ihr neues Passwort",
|
||||
"Update password": "Passwort aktualisieren",
|
||||
"Updating password ...": "Passwort wird aktualisiert ...",
|
||||
"Your password has been updated": "Ihr Passwort wurde aktualisiert",
|
||||
"Phone number": "Telefonnummer",
|
||||
"Your phone number": "Ihre Telefonnummer",
|
||||
"Token": "Token",
|
||||
"Your OTP token": "Ihr OTP-Token",
|
||||
"Verify token": "Token überprüfen",
|
||||
"Sign in with Google": "Mit Google anmelden",
|
||||
"Sign in with Apple": "Mit Apple anmelden",
|
||||
"Sign in with GitHub": "Mit GitHub anmelden"
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@
|
||||
"Notebook": "Σημειωματάριο",
|
||||
"Notes": "Σημειώσεις",
|
||||
"Open": "Άνοιγμα",
|
||||
"Open Book": "Άνοιγμα βιβλίου",
|
||||
"Original Text": "Αρχικό κείμενο",
|
||||
"Page": "Σελίδα",
|
||||
"Paging Animation": "Κινούμενα σχέδια σελιδοποίησης",
|
||||
@@ -155,5 +154,49 @@
|
||||
"Word Spacing": "Διάκενο λέξεων",
|
||||
"Letter Spacing": "Διάκενο γραμμάτων",
|
||||
"Text Indent": "Εσοχή κειμένου",
|
||||
"Paragraph Margin": "Περιθώριο παραγράφου"
|
||||
"Paragraph Margin": "Περιθώριο παραγράφου",
|
||||
"Override Book Layout": "Παράκαμψη διάταξης βιβλίου",
|
||||
"Add to Group": "Προσθήκη στην ομάδα",
|
||||
"Untitled Group": "Ομάδα χωρίς τίτλο",
|
||||
"Group Books": "Ομαδοποίηση βιβλίων",
|
||||
"Remove From Group": "Αφαίρεση από την ομάδα",
|
||||
"Create New Group": "Δημιουργία νέας ομάδας",
|
||||
"Deselect Book": "Αποεπιλογή βιβλίου",
|
||||
"Download Book": "Λήψη βιβλίου",
|
||||
"Deselect Group": "Αποεπιλογή ομάδας",
|
||||
"Select Group": "Επιλογή ομάδας",
|
||||
"Keep Screen Awake": "Διατήρηση ενεργού οθόνης",
|
||||
"Email address": "Διεύθυνση email",
|
||||
"Your Password": "Ο κωδικός σας",
|
||||
"Your email address": "Η διεύθυνση email σας",
|
||||
"Your password": "Ο κωδικός πρόσβασής σας",
|
||||
"Sign in": "Σύνδεση",
|
||||
"Signing in...": "Σύνδεση...",
|
||||
"Sign in with {{provider}}": "Σύνδεση με {{provider}}",
|
||||
"Already have an account? Sign in": "Έχετε ήδη λογαριασμό; Συνδεθείτε",
|
||||
"Create a Password": "Δημιουργία κωδικού πρόσβασης",
|
||||
"Sign up": "Εγγραφή",
|
||||
"Signing up...": "Εγγραφή...",
|
||||
"Don’t have an account? Sign up": "Δεν έχετε λογαριασμό; Εγγραφείτε",
|
||||
"Check your email for the confirmation link": "Ελέγξτε το email σας για τον σύνδεσμο επιβεβαίωσης",
|
||||
"Signing in ...": "Σύνδεση ...",
|
||||
"Send a magic link email": "Αποστολή email με μαγικό σύνδεσμο",
|
||||
"Check your email for the magic link": "Ελέγξτε το email σας για τον μαγικό σύνδεσμο",
|
||||
"Send reset password instructions": "Αποστολή οδηγιών επαναφοράς κωδικού πρόσβασης",
|
||||
"Sending reset instructions ...": "Αποστολή οδηγιών επαναφοράς ...",
|
||||
"Forgot your password?": "Ξεχάσατε τον κωδικό σας;",
|
||||
"Check your email for the password reset link": "Ελέγξτε το email σας για τον σύνδεσμο επαναφοράς κωδικού",
|
||||
"New Password": "Νέος κωδικός πρόσβασης",
|
||||
"Your new password": "Ο νέος σας κωδικός",
|
||||
"Update password": "Ενημέρωση κωδικού πρόσβασης",
|
||||
"Updating password ...": "Ενημέρωση κωδικού ...",
|
||||
"Your password has been updated": "Ο κωδικός πρόσβασής σας ενημερώθηκε",
|
||||
"Phone number": "Αριθμός τηλεφώνου",
|
||||
"Your phone number": "Ο αριθμός τηλεφώνου σας",
|
||||
"Token": "Κωδικός επαλήθευσης",
|
||||
"Your OTP token": "Ο κωδικός OTP σας",
|
||||
"Verify token": "Επαλήθευση κωδικού",
|
||||
"Sign in with Google": "Σύνδεση με Google",
|
||||
"Sign in with Apple": "Σύνδεση με Apple",
|
||||
"Sign in with GitHub": "Σύνδεση με GitHub"
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@
|
||||
"Notebook": "Cuaderno",
|
||||
"Notes": "Notas",
|
||||
"Open": "Abrir",
|
||||
"Open Book": "Abrir libro",
|
||||
"Original Text": "Texto original",
|
||||
"Page": "Página",
|
||||
"Paging Animation": "Animación de paginación",
|
||||
@@ -155,5 +154,49 @@
|
||||
"Word Spacing": "Espaciado de palabras",
|
||||
"Letter Spacing": "Espaciado de letras",
|
||||
"Text Indent": "Sangría de texto",
|
||||
"Paragraph Margin": "Margen de párrafo"
|
||||
"Paragraph Margin": "Margen de párrafo",
|
||||
"Override Book Layout": "Sobrescribir diseño del libro",
|
||||
"Add to Group": "Añadir a grupo",
|
||||
"Untitled Group": "Grupo sin título",
|
||||
"Group Books": "Agrupar libros",
|
||||
"Remove From Group": "Eliminar del grupo",
|
||||
"Create New Group": "Crear nuevo grupo",
|
||||
"Deselect Book": "Deseleccionar libro",
|
||||
"Download Book": "Descargar libro",
|
||||
"Deselect Group": "Deseleccionar grupo",
|
||||
"Select Group": "Seleccionar grupo",
|
||||
"Keep Screen Awake": "Mantener pantalla activa",
|
||||
"Email address": "Dirección de correo electrónico",
|
||||
"Your Password": "Tu contraseña",
|
||||
"Your email address": "Tu dirección de correo electrónico",
|
||||
"Your password": "Tu contraseña",
|
||||
"Sign in": "Iniciar sesión",
|
||||
"Signing in...": "Iniciando sesión...",
|
||||
"Sign in with {{provider}}": "Iniciar sesión con {{provider}}",
|
||||
"Already have an account? Sign in": "¿Ya tienes una cuenta? Inicia sesión",
|
||||
"Create a Password": "Crear una contraseña",
|
||||
"Sign up": "Registrarse",
|
||||
"Signing up...": "Registrándose...",
|
||||
"Don’t have an account? Sign up": "¿No tienes una cuenta? Regístrate",
|
||||
"Check your email for the confirmation link": "Revisa tu correo electrónico para el enlace de confirmación",
|
||||
"Signing in ...": "Iniciando sesión ...",
|
||||
"Send a magic link email": "Enviar un enlace mágico por correo",
|
||||
"Check your email for the magic link": "Revisa tu correo electrónico para el enlace mágico",
|
||||
"Send reset password instructions": "Enviar instrucciones para restablecer la contraseña",
|
||||
"Sending reset instructions ...": "Enviando instrucciones de restablecimiento ...",
|
||||
"Forgot your password?": "¿Olvidaste tu contraseña?",
|
||||
"Check your email for the password reset link": "Revisa tu correo electrónico para el enlace de restablecimiento de contraseña",
|
||||
"New Password": "Nueva contraseña",
|
||||
"Your new password": "Tu nueva contraseña",
|
||||
"Update password": "Actualizar contraseña",
|
||||
"Updating password ...": "Actualizando contraseña ...",
|
||||
"Your password has been updated": "Tu contraseña ha sido actualizada",
|
||||
"Phone number": "Número de teléfono",
|
||||
"Your phone number": "Tu número de teléfono",
|
||||
"Token": "Código de verificación",
|
||||
"Your OTP token": "Tu código OTP",
|
||||
"Verify token": "Verificar código",
|
||||
"Sign in with Google": "Iniciar sesión con Google",
|
||||
"Sign in with Apple": "Iniciar sesión con Apple",
|
||||
"Sign in with GitHub": "Iniciar sesión con GitHub"
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@
|
||||
"Notebook": "Carnet de notes",
|
||||
"Notes": "Notes",
|
||||
"Open": "Ouvrir",
|
||||
"Open Book": "Ouvrir le livre",
|
||||
"Original Text": "Texte original",
|
||||
"Page": "Page",
|
||||
"Paging Animation": "Animation de page",
|
||||
@@ -155,5 +154,49 @@
|
||||
"Word Spacing": "Espacement des mots",
|
||||
"Letter Spacing": "Espacement des lettres",
|
||||
"Text Indent": "Retrait du texte",
|
||||
"Paragraph Margin": "Marge de paragraphe"
|
||||
"Paragraph Margin": "Marge de paragraphe",
|
||||
"Override Book Layout": "Remplacer la mise en page du livre",
|
||||
"Add to Group": "Ajouter au groupe",
|
||||
"Untitled Group": "Groupe sans titre",
|
||||
"Group Books": "Regrouper les livres",
|
||||
"Remove From Group": "Retirer du groupe",
|
||||
"Create New Group": "Créer un nouveau groupe",
|
||||
"Deselect Book": "Désélectionner le livre",
|
||||
"Download Book": "Télécharger le livre",
|
||||
"Deselect Group": "Désélectionner le groupe",
|
||||
"Select Group": "Sélectionner le groupe",
|
||||
"Keep Screen Awake": "Maintenir l'écran allumé",
|
||||
"Email address": "Adresse e-mail",
|
||||
"Your Password": "Votre mot de passe",
|
||||
"Your email address": "Votre adresse e-mail",
|
||||
"Your password": "Votre mot de passe",
|
||||
"Sign in": "Se connecter",
|
||||
"Signing in...": "Connexion en cours...",
|
||||
"Sign in with {{provider}}": "Se connecter avec {{provider}}",
|
||||
"Already have an account? Sign in": "Vous avez déjà un compte ? Connectez-vous",
|
||||
"Create a Password": "Créer un mot de passe",
|
||||
"Sign up": "S'inscrire",
|
||||
"Signing up...": "Inscription en cours...",
|
||||
"Don’t have an account? Sign up": "Vous n'avez pas de compte ? Inscrivez-vous",
|
||||
"Check your email for the confirmation link": "Vérifiez votre e-mail pour le lien de confirmation",
|
||||
"Signing in ...": "Connexion en cours ...",
|
||||
"Send a magic link email": "Envoyer un e-mail avec un lien magique",
|
||||
"Check your email for the magic link": "Vérifiez votre e-mail pour le lien magique",
|
||||
"Send reset password instructions": "Envoyer les instructions de réinitialisation du mot de passe",
|
||||
"Sending reset instructions ...": "Envoi des instructions de réinitialisation ...",
|
||||
"Forgot your password?": "Mot de passe oublié ?",
|
||||
"Check your email for the password reset link": "Vérifiez votre e-mail pour le lien de réinitialisation du mot de passe",
|
||||
"New Password": "Nouveau mot de passe",
|
||||
"Your new password": "Votre nouveau mot de passe",
|
||||
"Update password": "Mettre à jour le mot de passe",
|
||||
"Updating password ...": "Mise à jour du mot de passe ...",
|
||||
"Your password has been updated": "Votre mot de passe a été mis à jour",
|
||||
"Phone number": "Numéro de téléphone",
|
||||
"Your phone number": "Votre numéro de téléphone",
|
||||
"Token": "Code de vérification",
|
||||
"Your OTP token": "Votre code OTP",
|
||||
"Verify token": "Vérifier le code",
|
||||
"Sign in with Google": "Se connecter avec Google",
|
||||
"Sign in with Apple": "Se connecter avec Apple",
|
||||
"Sign in with GitHub": "Se connecter avec GitHub"
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@
|
||||
"Notebook": "नोटबुक",
|
||||
"Notes": "नोट्स",
|
||||
"Open": "खोलें",
|
||||
"Open Book": "पुस्तक खोलें",
|
||||
"Original Text": "मूल पाठ",
|
||||
"Page": "पृष्ठ",
|
||||
"Paging Animation": "पेजिंग एनीमेशन",
|
||||
@@ -155,5 +154,49 @@
|
||||
"Word Spacing": "शब्द स्पेसिंग",
|
||||
"Letter Spacing": "अक्षर स्पेसिंग",
|
||||
"Text Indent": "टेक्स्ट इंडेंट",
|
||||
"Paragraph Margin": "पैराग्राफ मार्जिन"
|
||||
"Paragraph Margin": "पैराग्राफ मार्जिन",
|
||||
"Override Book Layout": "पुस्तक लेआउट ओवरराइड करें",
|
||||
"Add to Group": "समूह में जोड़ें",
|
||||
"Untitled Group": "अभिनामित समूह",
|
||||
"Group Books": "समूह पुस्तकें",
|
||||
"Remove From Group": "समूह से हटाएं",
|
||||
"Create New Group": "नया समूह बनाएं",
|
||||
"Deselect Book": "पुस्तक का चयन रद्द करें",
|
||||
"Download Book": "पुस्तक डाउनलोड करें",
|
||||
"Deselect Group": "समूह का चयन रद्द करें",
|
||||
"Select Group": "समूह चुनें",
|
||||
"Keep Screen Awake": "स्क्रीन जागरूक रखें",
|
||||
"Email address": "ईमेल पता",
|
||||
"Your Password": "आपका पासवर्ड",
|
||||
"Your email address": "आपका ईमेल पता",
|
||||
"Your password": "आपका पासवर्ड",
|
||||
"Sign in": "साइन इन करें",
|
||||
"Signing in...": "साइन इन हो रहा है...",
|
||||
"Sign in with {{provider}}": "{{provider}} के साथ साइन इन करें",
|
||||
"Already have an account? Sign in": "पहले से ही एक खाता है? साइन इन करें",
|
||||
"Create a Password": "पासवर्ड बनाएं",
|
||||
"Sign up": "साइन अप करें",
|
||||
"Signing up...": "साइन अप हो रहा है...",
|
||||
"Don’t have an account? Sign up": "कोई खाता नहीं है? साइन अप करें",
|
||||
"Check your email for the confirmation link": "पुष्टि लिंक के लिए अपना ईमेल जांचें",
|
||||
"Signing in ...": "साइन इन हो रहा है ...",
|
||||
"Send a magic link email": "जादू लिंक ईमेल भेजें",
|
||||
"Check your email for the magic link": "जादू लिंक के लिए अपना ईमेल जांचें",
|
||||
"Send reset password instructions": "पासवर्ड रीसेट निर्देश भेजें",
|
||||
"Sending reset instructions ...": "रीसेट निर्देश भेजे जा रहे हैं ...",
|
||||
"Forgot your password?": "पासवर्ड भूल गए?",
|
||||
"Check your email for the password reset link": "पासवर्ड रीसेट लिंक के लिए अपना ईमेल जांचें",
|
||||
"New Password": "नया पासवर्ड",
|
||||
"Your new password": "आपका नया पासवर्ड",
|
||||
"Update password": "पासवर्ड अपडेट करें",
|
||||
"Updating password ...": "पासवर्ड अपडेट किया जा रहा है ...",
|
||||
"Your password has been updated": "आपका पासवर्ड अपडेट कर दिया गया है",
|
||||
"Phone number": "फोन नंबर",
|
||||
"Your phone number": "आपका फोन नंबर",
|
||||
"Token": "टोकन",
|
||||
"Your OTP token": "आपका ओटीपी टोकन",
|
||||
"Verify token": "टोकन सत्यापित करें",
|
||||
"Sign in with Google": "Google के साथ साइन इन करें",
|
||||
"Sign in with Apple": "Apple के साथ साइन इन करें",
|
||||
"Sign in with GitHub": "GitHub के साथ साइन इन करें"
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@
|
||||
"Notebook": "Buku Catatan",
|
||||
"Notes": "Catatan",
|
||||
"Open": "Buka",
|
||||
"Open Book": "Buka Buku",
|
||||
"Original Text": "Teks Asli",
|
||||
"Page": "Halaman",
|
||||
"Paging Animation": "Animasi Halaman",
|
||||
@@ -155,5 +154,49 @@
|
||||
"Word Spacing": "Spasi Kata",
|
||||
"Letter Spacing": "Spasi Huruf",
|
||||
"Text Indent": "Indentasi Teks",
|
||||
"Paragraph Margin": "Margin Paragraf"
|
||||
"Paragraph Margin": "Margin Paragraf",
|
||||
"Override Book Layout": "Ganti Tata Letak Buku",
|
||||
"Add to Group": "Tambahkan ke Grup",
|
||||
"Untitled Group": "Grup Tanpa Judul",
|
||||
"Group Books": "Grupkan Buku",
|
||||
"Remove From Group": "Hapus dari Grup",
|
||||
"Create New Group": "Buat Grup Baru",
|
||||
"Deselect Book": "Batalkan Pilihan Buku",
|
||||
"Download Book": "Unduh Buku",
|
||||
"Deselect Group": "Batalkan Pilihan Grup",
|
||||
"Select Group": "Pilih Grup",
|
||||
"Keep Screen Awake": "Biarkan Layar Tetap Hidup",
|
||||
"Email address": "Alamat email",
|
||||
"Your Password": "Kata sandi Anda",
|
||||
"Your email address": "Alamat email Anda",
|
||||
"Your password": "Kata sandi Anda",
|
||||
"Sign in": "Masuk",
|
||||
"Signing in...": "Sedang masuk...",
|
||||
"Sign in with {{provider}}": "Masuk dengan {{provider}}",
|
||||
"Already have an account? Sign in": "Sudah punya akun? Masuk",
|
||||
"Create a Password": "Buat kata sandi",
|
||||
"Sign up": "Daftar",
|
||||
"Signing up...": "Sedang mendaftar...",
|
||||
"Don’t have an account? Sign up": "Belum punya akun? Daftar",
|
||||
"Check your email for the confirmation link": "Periksa email Anda untuk tautan konfirmasi",
|
||||
"Signing in ...": "Sedang masuk ...",
|
||||
"Send a magic link email": "Kirim email tautan ajaib",
|
||||
"Check your email for the magic link": "Periksa email Anda untuk tautan ajaib",
|
||||
"Send reset password instructions": "Kirim instruksi reset kata sandi",
|
||||
"Sending reset instructions ...": "Mengirim instruksi reset ...",
|
||||
"Forgot your password?": "Lupa kata sandi Anda?",
|
||||
"Check your email for the password reset link": "Periksa email Anda untuk tautan reset kata sandi",
|
||||
"New Password": "Kata sandi baru",
|
||||
"Your new password": "Kata sandi baru Anda",
|
||||
"Update password": "Perbarui kata sandi",
|
||||
"Updating password ...": "Memperbarui kata sandi ...",
|
||||
"Your password has been updated": "Kata sandi Anda telah diperbarui",
|
||||
"Phone number": "Nomor telepon",
|
||||
"Your phone number": "Nomor telepon Anda",
|
||||
"Token": "Token",
|
||||
"Your OTP token": "Token OTP Anda",
|
||||
"Verify token": "Verifikasi token",
|
||||
"Sign in with Google": "Masuk dengan Google",
|
||||
"Sign in with Apple": "Masuk dengan Apple",
|
||||
"Sign in with GitHub": "Masuk dengan GitHub"
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@
|
||||
"Notebook": "Quaderno",
|
||||
"Notes": "Note",
|
||||
"Open": "Apri",
|
||||
"Open Book": "Apri libro",
|
||||
"Original Text": "Testo originale",
|
||||
"Page": "Pagina",
|
||||
"Paging Animation": "Animazione cambio pagina",
|
||||
@@ -155,5 +154,49 @@
|
||||
"Word Spacing": "Spaziatura parole",
|
||||
"Letter Spacing": "Spaziatura lettere",
|
||||
"Text Indent": "Rientro testo",
|
||||
"Paragraph Margin": "Margine paragrafo"
|
||||
"Paragraph Margin": "Margine paragrafo",
|
||||
"Override Book Layout": "Sovrascrivi layout libro",
|
||||
"Add to Group": "Aggiungi al gruppo",
|
||||
"Untitled Group": "Gruppo senza titolo",
|
||||
"Group Books": "Raggruppa libri",
|
||||
"Remove From Group": "Rimuovi dal",
|
||||
"Create New Group": "Crea nuovo gruppo",
|
||||
"Deselect Book": "Deseleziona libro",
|
||||
"Download Book": "Scarica libro",
|
||||
"Deselect Group": "Deseleziona gruppo",
|
||||
"Select Group": "Seleziona gruppo",
|
||||
"Keep Screen Awake": "Mantieni schermo attivo",
|
||||
"Email address": "Indirizzo email",
|
||||
"Your Password": "La tua password",
|
||||
"Your email address": "Il tuo indirizzo email",
|
||||
"Your password": "La tua password",
|
||||
"Sign in": "Accedi",
|
||||
"Signing in...": "Accesso in corso...",
|
||||
"Sign in with {{provider}}": "Accedi con {{provider}}",
|
||||
"Already have an account? Sign in": "Hai già un account? Accedi",
|
||||
"Create a Password": "Crea una password",
|
||||
"Sign up": "Registrati",
|
||||
"Signing up...": "Registrazione in corso...",
|
||||
"Don’t have an account? Sign up": "Non hai un account? Registrati",
|
||||
"Check your email for the confirmation link": "Controlla la tua email per il link di conferma",
|
||||
"Signing in ...": "Accesso in corso ...",
|
||||
"Send a magic link email": "Invia un'email con link magico",
|
||||
"Check your email for the magic link": "Controlla la tua email per il link magico",
|
||||
"Send reset password instructions": "Invia istruzioni per reimpostare la password",
|
||||
"Sending reset instructions ...": "Invio istruzioni di reimpostazione ...",
|
||||
"Forgot your password?": "Hai dimenticato la password?",
|
||||
"Check your email for the password reset link": "Controlla la tua email per il link di reimpostazione della password",
|
||||
"New Password": "Nuova password",
|
||||
"Your new password": "La tua nuova password",
|
||||
"Update password": "Aggiorna password",
|
||||
"Updating password ...": "Aggiornamento password ...",
|
||||
"Your password has been updated": "La tua password è stata aggiornata",
|
||||
"Phone number": "Numero di telefono",
|
||||
"Your phone number": "Il tuo numero di telefono",
|
||||
"Token": "Token",
|
||||
"Your OTP token": "Il tuo token OTP",
|
||||
"Verify token": "Verifica token",
|
||||
"Sign in with Google": "Accedi con Google",
|
||||
"Sign in with Apple": "Accedi con Apple",
|
||||
"Sign in with GitHub": "Accedi con GitHub"
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@
|
||||
"Notebook": "ノート",
|
||||
"Notes": "メモ",
|
||||
"Open": "開く",
|
||||
"Open Book": "書籍を開く",
|
||||
"Original Text": "原文",
|
||||
"Page": "ページ",
|
||||
"Paging Animation": "ページめくりアニメーション",
|
||||
@@ -155,5 +154,49 @@
|
||||
"Word Spacing": "単語間隔",
|
||||
"Letter Spacing": "文字間隔",
|
||||
"Text Indent": "テキストインデント",
|
||||
"Paragraph Margin": "段落マージン"
|
||||
"Paragraph Margin": "段落マージン",
|
||||
"Override Book Layout": "書籍のレイアウトを上書き",
|
||||
"Add to Group": "グループに追加",
|
||||
"Untitled Group": "無題のグループ",
|
||||
"Group Books": "書籍をグループ化",
|
||||
"Remove From Group": "グループから削除",
|
||||
"Create New Group": "新しいグループを作成",
|
||||
"Deselect Book": "書籍の選択を解除",
|
||||
"Download Book": "書籍をダウンロード",
|
||||
"Deselect Group": "グループの選択を解除",
|
||||
"Select Group": "グループを選択",
|
||||
"Keep Screen Awake": "画面をオンのままにする",
|
||||
"Email address": "メールアドレス",
|
||||
"Your Password": "あなたのパスワード",
|
||||
"Your email address": "あなたのメールアドレス",
|
||||
"Your password": "あなたのパスワード",
|
||||
"Sign in": "サインイン",
|
||||
"Signing in...": "サインイン中...",
|
||||
"Sign in with {{provider}}": "{{provider}}でサインイン",
|
||||
"Already have an account? Sign in": "すでにアカウントがありますか? サインイン",
|
||||
"Create a Password": "パスワードを作成",
|
||||
"Sign up": "サインアップ",
|
||||
"Signing up...": "サインアップ中...",
|
||||
"Don’t have an account? Sign up": "アカウントをお持ちでないですか? サインアップ",
|
||||
"Check your email for the confirmation link": "確認リンクのためにメールを確認してください",
|
||||
"Signing in ...": "サインイン中 ...",
|
||||
"Send a magic link email": "マジックリンクメールを送信",
|
||||
"Check your email for the magic link": "マジックリンクのためにメールを確認してください",
|
||||
"Send reset password instructions": "パスワードリセットの指示を送信",
|
||||
"Sending reset instructions ...": "リセット指示を送信中 ...",
|
||||
"Forgot your password?": "パスワードを忘れましたか?",
|
||||
"Check your email for the password reset link": "パスワードリセットリンクのためにメールを確認してください",
|
||||
"New Password": "新しいパスワード",
|
||||
"Your new password": "あなたの新しいパスワード",
|
||||
"Update password": "パスワードを更新",
|
||||
"Updating password ...": "パスワード更新中 ...",
|
||||
"Your password has been updated": "あなたのパスワードは更新されました",
|
||||
"Phone number": "電話番号",
|
||||
"Your phone number": "あなたの電話番号",
|
||||
"Token": "トークン",
|
||||
"Your OTP token": "あなたのOTPトークン",
|
||||
"Verify token": "トークンを確認",
|
||||
"Sign in with Google": "Googleでサインイン",
|
||||
"Sign in with Apple": "Appleでサインイン",
|
||||
"Sign in with GitHub": "GitHubでサインイン"
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@
|
||||
"Notebook": "노트북",
|
||||
"Notes": "메모",
|
||||
"Open": "열기",
|
||||
"Open Book": "책 열기",
|
||||
"Original Text": "원본 텍스트",
|
||||
"Page": "페이지",
|
||||
"Paging Animation": "페이지 넘김 애니메이션",
|
||||
@@ -155,5 +154,49 @@
|
||||
"Word Spacing": "단어 간격",
|
||||
"Letter Spacing": "글자 간격",
|
||||
"Text Indent": "들여쓰기",
|
||||
"Paragraph Margin": "단락 여백"
|
||||
"Paragraph Margin": "단락 여백",
|
||||
"Override Book Layout": "책 레이아웃 덮어쓰기",
|
||||
"Add to Group": "그룹에 추가",
|
||||
"Untitled Group": "제목 없는 그룹",
|
||||
"Group Books": "책 그룹화",
|
||||
"Remove From Group": "그룹에서 제거",
|
||||
"Create New Group": "새 그룹 만들기",
|
||||
"Deselect Book": "책 선택 해제",
|
||||
"Download Book": "책 다운로드",
|
||||
"Deselect Group": "그룹 선택 해제",
|
||||
"Select Group": "그룹 선택",
|
||||
"Keep Screen Awake": "화면 켜짐 유지",
|
||||
"Email address": "이메일 주소",
|
||||
"Your Password": "비밀번호",
|
||||
"Your email address": "당신의 이메일 주소",
|
||||
"Your password": "당신의 비밀번호",
|
||||
"Sign in": "로그인",
|
||||
"Signing in...": "로그인 중...",
|
||||
"Sign in with {{provider}}": "{{provider}}로 로그인",
|
||||
"Already have an account? Sign in": "이미 계정이 있습니까? 로그인",
|
||||
"Create a Password": "비밀번호 만들기",
|
||||
"Sign up": "회원가입",
|
||||
"Signing up...": "회원가입 중...",
|
||||
"Don’t have an account? Sign up": "계정이 없습니까? 회원가입",
|
||||
"Check your email for the confirmation link": "확인 링크를 위해 이메일을 확인하세요",
|
||||
"Signing in ...": "로그인 중 ...",
|
||||
"Send a magic link email": "마법의 링크 이메일 보내기",
|
||||
"Check your email for the magic link": "마법의 링크를 위해 이메일을 확인하세요",
|
||||
"Send reset password instructions": "비밀번호 재설정 지침 보내기",
|
||||
"Sending reset instructions ...": "재설정 지침 보내는 중 ...",
|
||||
"Forgot your password?": "비밀번호를 잊으셨나요?",
|
||||
"Check your email for the password reset link": "비밀번호 재설정 링크를 위해 이메일을 확인하세요",
|
||||
"New Password": "새로운 비밀번호",
|
||||
"Your new password": "당신의 새로운 비밀번호",
|
||||
"Update password": "비밀번호 업데이트",
|
||||
"Updating password ...": "비밀번호 업데이트 중 ...",
|
||||
"Your password has been updated": "비밀번호가 업데이트되었습니다",
|
||||
"Phone number": "전화번호",
|
||||
"Your phone number": "당신의 전화번호",
|
||||
"Token": "토큰",
|
||||
"Your OTP token": "당신의 OTP 토큰",
|
||||
"Verify token": "토큰 확인",
|
||||
"Sign in with Google": "Google로 로그인",
|
||||
"Sign in with Apple": "Apple로 로그인",
|
||||
"Sign in with GitHub": "GitHub로 로그인"
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@
|
||||
"Notebook": "Notatnik",
|
||||
"Notes": "Notatki",
|
||||
"Open": "Otwórz",
|
||||
"Open Book": "Otwórz książkę",
|
||||
"Original Text": "Tekst oryginalny",
|
||||
"Page": "Strona",
|
||||
"Paging Animation": "Animacja przewracania stron",
|
||||
@@ -155,5 +154,49 @@
|
||||
"Word Spacing": "Odstęp między wyrazami",
|
||||
"Letter Spacing": "Odstęp między literami",
|
||||
"Text Indent": "Wcięcie tekstu",
|
||||
"Paragraph Margin": "Margines akapitu"
|
||||
"Paragraph Margin": "Margines akapitu",
|
||||
"Override Book Layout": "Nadpisz układ książki",
|
||||
"Add to Group": "Dodaj do grupy",
|
||||
"Untitled Group": "Grupa bez tytułu",
|
||||
"Group Books": "Grupuj książki",
|
||||
"Remove From Group": "Usuń z grupy",
|
||||
"Create New Group": "Utwórz nową grupę",
|
||||
"Deselect Book": "Odznacz książkę",
|
||||
"Download Book": "Pobierz książkę",
|
||||
"Deselect Group": "Odznacz grupę",
|
||||
"Select Group": "Wybierz grupę",
|
||||
"Keep Screen Awake": "Nie wygaszaj ekranu",
|
||||
"Email address": "Adres email",
|
||||
"Your Password": "Twoje hasło",
|
||||
"Your email address": "Twój adres email",
|
||||
"Your password": "Twoje hasło",
|
||||
"Sign in": "Zaloguj się",
|
||||
"Signing in...": "Logowanie...",
|
||||
"Sign in with {{provider}}": "Zaloguj się za pomocą {{provider}}",
|
||||
"Already have an account? Sign in": "Masz już konto? Zaloguj się",
|
||||
"Create a Password": "Utwórz hasło",
|
||||
"Sign up": "Zarejestruj się",
|
||||
"Signing up...": "Rejestracja...",
|
||||
"Don’t have an account? Sign up": "Nie masz konta? Zarejestruj się",
|
||||
"Check your email for the confirmation link": "Sprawdź swój email, aby uzyskać link potwierdzający",
|
||||
"Signing in ...": "Logowanie ...",
|
||||
"Send a magic link email": "Wyślij link magiczny na email",
|
||||
"Check your email for the magic link": "Sprawdź swój email, aby uzyskać link magiczny",
|
||||
"Send reset password instructions": "Wyślij instrukcje resetowania hasła",
|
||||
"Sending reset instructions ...": "Wysyłanie instrukcji resetowania hasła ...",
|
||||
"Forgot your password?": "Zapomniałeś hasła?",
|
||||
"Check your email for the password reset link": "Sprawdź swój email, aby uzyskać link resetujący hasło",
|
||||
"New Password": "Nowe hasło",
|
||||
"Your new password": "Twoje nowe hasło",
|
||||
"Update password": "Zaktualizuj hasło",
|
||||
"Updating password ...": "Aktualizacja hasła ...",
|
||||
"Your password has been updated": "Twoje hasło zostało zaktualizowane",
|
||||
"Phone number": "Numer telefonu",
|
||||
"Your phone number": "Twój numer telefonu",
|
||||
"Token": "Token",
|
||||
"Your OTP token": "Twój OTP token",
|
||||
"Verify token": "Weryfikacja tokena",
|
||||
"Sign in with Google": "Zaloguj się za pomocą Google",
|
||||
"Sign in with Apple": "Zaloguj się za pomocą Apple",
|
||||
"Sign in with GitHub": "Zaloguj się za pomocą GitHub"
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@
|
||||
"Notebook": "Caderno",
|
||||
"Notes": "Notas",
|
||||
"Open": "Abrir",
|
||||
"Open Book": "Abrir Livro",
|
||||
"Original Text": "Texto Original",
|
||||
"Page": "Página",
|
||||
"Paging Animation": "Animação de Página",
|
||||
@@ -155,5 +154,49 @@
|
||||
"Word Spacing": "Espaçamento de Palavra",
|
||||
"Letter Spacing": "Espaçamento de Letra",
|
||||
"Text Indent": "Recuo de Texto",
|
||||
"Paragraph Margin": "Margem de Parágrafo"
|
||||
"Paragraph Margin": "Margem de Parágrafo",
|
||||
"Override Book Layout": "Sobrescrever Layout do Livro",
|
||||
"Add to Group": "Adicionar ao Grupo",
|
||||
"Untitled Group": "Grupo Sem Título",
|
||||
"Group Books": "Agrupar Livros",
|
||||
"Remove From Group": "Remover do Grupo",
|
||||
"Create New Group": "Criar Novo Grupo",
|
||||
"Deselect Book": "Desmarcar Livro",
|
||||
"Download Book": "Baixar Livro",
|
||||
"Deselect Group": "Desmarcar Grupo",
|
||||
"Select Group": "Selecionar Grupo",
|
||||
"Keep Screen Awake": "Manter a Tela Ativa",
|
||||
"Email address": "Endereço de e-mail",
|
||||
"Your Password": "Sua senha",
|
||||
"Your email address": "Seu endereço de e-mail",
|
||||
"Your password": "Sua senha",
|
||||
"Sign in": "Entrar",
|
||||
"Signing in...": "Entrando...",
|
||||
"Sign in with {{provider}}": "Entrar com {{provider}}",
|
||||
"Already have an account? Sign in": "Já tem uma conta? Entrar",
|
||||
"Create a Password": "Criar uma senha",
|
||||
"Sign up": "Cadastrar-se",
|
||||
"Signing up...": "Cadastrando...",
|
||||
"Don’t have an account? Sign up": "Não tem uma conta? Cadastre-se",
|
||||
"Check your email for the confirmation link": "Verifique seu e-mail para o link de confirmação",
|
||||
"Signing in ...": "Entrando ...",
|
||||
"Send a magic link email": "Enviar um link mágico por e-mail",
|
||||
"Check your email for the magic link": "Verifique seu e-mail para o link mágico",
|
||||
"Send reset password instructions": "Enviar instruções para redefinir a senha",
|
||||
"Sending reset instructions ...": "Enviando instruções para redefinir a senha ...",
|
||||
"Forgot your password?": "Esqueceu sua senha?",
|
||||
"Check your email for the password reset link": "Verifique seu e-mail para o link de redefinição de senha",
|
||||
"New Password": "Nova senha",
|
||||
"Your new password": "Sua nova senha",
|
||||
"Update password": "Atualizar senha",
|
||||
"Updating password ...": "Atualizando senha ...",
|
||||
"Your password has been updated": "Sua senha foi atualizada",
|
||||
"Phone number": "Número de telefone",
|
||||
"Your phone number": "Seu número de telefone",
|
||||
"Token": "Token",
|
||||
"Your OTP token": "Seu token OTP",
|
||||
"Verify token": "Verificar token",
|
||||
"Sign in with Google": "Entrar com o Google",
|
||||
"Sign in with Apple": "Entrar com o Apple",
|
||||
"Sign in with GitHub": "Entrar com o GitHub"
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@
|
||||
"Notebook": "Блокнот",
|
||||
"Notes": "Заметки",
|
||||
"Open": "Открыть",
|
||||
"Open Book": "Открыть книгу",
|
||||
"Original Text": "Оригинальный текст",
|
||||
"Page": "Страница",
|
||||
"Paging Animation": "Анимация перелистывания",
|
||||
@@ -155,5 +154,49 @@
|
||||
"Word Spacing": "Межсловный интервал",
|
||||
"Letter Spacing": "Межбуквенный интервал",
|
||||
"Text Indent": "Отступ текста",
|
||||
"Paragraph Margin": "Отступ абзаца"
|
||||
"Paragraph Margin": "Отступ абзаца",
|
||||
"Override Book Layout": "Переопределить макет книги",
|
||||
"Add to Group": "Добавить в группу",
|
||||
"Untitled Group": "Группа без названия",
|
||||
"Group Books": "Группировать книги",
|
||||
"Remove From Group": "Удалить из группы",
|
||||
"Create New Group": "Создать новую группу",
|
||||
"Deselect Book": "Отменить выбор книги",
|
||||
"Download Book": "Скачать книгу",
|
||||
"Deselect Group": "Отменить выбор группы",
|
||||
"Select Group": "Выбрать группу",
|
||||
"Keep Screen Awake": "Не выключать экран",
|
||||
"Email address": "Адрес электронной почты",
|
||||
"Your Password": "Ваш пароль",
|
||||
"Your email address": "Ваш адрес электронной почты",
|
||||
"Your password": "Ваш пароль",
|
||||
"Sign in": "Войти",
|
||||
"Signing in...": "Вход...",
|
||||
"Sign in with {{provider}}": "Войти с помощью {{provider}}",
|
||||
"Already have an account? Sign in": "Уже есть аккаунт? Войдите",
|
||||
"Create a Password": "Создать пароль",
|
||||
"Sign up": "Зарегистрироваться",
|
||||
"Signing up...": "Регистрация...",
|
||||
"Don’t have an account? Sign up": "Нет аккаунта? Зарегистрируйтесь",
|
||||
"Check your email for the confirmation link": "Проверьте свою почту для подтверждающей ссылки",
|
||||
"Signing in ...": "Вход ...",
|
||||
"Send a magic link email": "Отправить магическую ссылку по электронной почте",
|
||||
"Check your email for the magic link": "Проверьте свою почту для магической ссылки",
|
||||
"Send reset password instructions": "Отправить инструкции по сбросу пароля",
|
||||
"Sending reset instructions ...": "Отправка инструкций по сбросу пароля ...",
|
||||
"Forgot your password?": "Забыли пароль?",
|
||||
"Check your email for the password reset link": "Проверьте свою почту для ссылки для сброса пароля",
|
||||
"New Password": "Новый пароль",
|
||||
"Your new password": "Ваш новый пароль",
|
||||
"Update password": "Обновить пароль",
|
||||
"Updating password ...": "Обновление пароля ...",
|
||||
"Your password has been updated": "Ваш пароль был обновлен",
|
||||
"Phone number": "Номер телефона",
|
||||
"Your phone number": "Ваш номер телефона",
|
||||
"Token": "Токен",
|
||||
"Your OTP token": "Ваш OTP токен",
|
||||
"Verify token": "Проверить токен",
|
||||
"Sign in with Google": "Войти через Google",
|
||||
"Sign in with Apple": "Войти через Apple",
|
||||
"Sign in with GitHub": "Войти через GitHub"
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@
|
||||
"Notebook": "Not Defteri",
|
||||
"Notes": "Notlar",
|
||||
"Open": "Aç",
|
||||
"Open Book": "Kitabı Aç",
|
||||
"Original Text": "Orijinal Metin",
|
||||
"Page": "Sayfa",
|
||||
"Paging Animation": "Sayfa Çevirme Animasyonu",
|
||||
@@ -155,5 +154,49 @@
|
||||
"Word Spacing": "Kelime Aralığı",
|
||||
"Letter Spacing": "Harf Aralığı",
|
||||
"Text Indent": "Metin Girintisi",
|
||||
"Paragraph Margin": "Paragraf Kenarı"
|
||||
"Paragraph Margin": "Paragraf Kenarı",
|
||||
"Override Book Layout": "Kitap Düzenini Geçersiz Kıl",
|
||||
"Add to Group": "Gruba Ekle",
|
||||
"Untitled Group": "Başlıksız Grup",
|
||||
"Group Books": "Kitapları Grupla",
|
||||
"Remove From Group": "Grubu Kaldır",
|
||||
"Create New Group": "Yeni Grup Oluştur",
|
||||
"Deselect Book": "Kitabı Seçme",
|
||||
"Download Book": "Kitabı İndir",
|
||||
"Deselect Group": "Grubu Seçme",
|
||||
"Select Group": "Grup Seç",
|
||||
"Keep Screen Awake": "Ekranı Açık Tut",
|
||||
"Email address": "E-posta adresi",
|
||||
"Your Password": "Şifreniz",
|
||||
"Your email address": "E-posta adresiniz",
|
||||
"Your password": "Şifreniz",
|
||||
"Sign in": "Giriş yap",
|
||||
"Signing in...": "Giriş yapılıyor...",
|
||||
"Sign in with {{provider}}": "{{provider}} ile giriş yap",
|
||||
"Already have an account? Sign in": "Hesabınız var mı? Giriş yapın",
|
||||
"Create a Password": "Şifre oluştur",
|
||||
"Sign up": "Kaydol",
|
||||
"Signing up...": "Kaydolunuyor...",
|
||||
"Don’t have an account? Sign up": "Hesabınız yok mu? Kaydolun",
|
||||
"Check your email for the confirmation link": "Onay bağlantısı için e-posta adresinizi kontrol edin",
|
||||
"Signing in ...": "Giriş yapılıyor...",
|
||||
"Send a magic link email": "Sihirli bağlantı e-postası gönder",
|
||||
"Check your email for the magic link": "Sihirli bağlantı için e-posta adresinizi kontrol edin",
|
||||
"Send reset password instructions": "Şifre sıfırlama talimatları gönder",
|
||||
"Sending reset instructions ...": "Şifre sıfırlama talimatları gönderiliyor...",
|
||||
"Forgot your password?": "Şifrenizi mi unuttunuz?",
|
||||
"Check your email for the password reset link": "Şifre sıfırlama bağlantısı için e-posta adresinizi kontrol edin",
|
||||
"New Password": "Yeni şifre",
|
||||
"Your new password": "Yeni şifreniz",
|
||||
"Update password": "Şifreyi güncelle",
|
||||
"Updating password ...": "Şifre güncelleniyor...",
|
||||
"Your password has been updated": "Şifreniz güncellendi",
|
||||
"Phone number": "Telefon numarası",
|
||||
"Your phone number": "Telefon numaranız",
|
||||
"Token": "Token",
|
||||
"Your OTP token": "OTP token'ınız",
|
||||
"Verify token": "Token'ı doğrula",
|
||||
"Sign in with Google": "Google ile giriş yap",
|
||||
"Sign in with Apple": "Apple ile giriş yap",
|
||||
"Sign in with GitHub": "GitHub ile giriş yap"
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@
|
||||
"Notebook": "Нотатник",
|
||||
"Notes": "Нотатки",
|
||||
"Open": "Відкрити",
|
||||
"Open Book": "Відкрити книгу",
|
||||
"Original Text": "Оригінальний текст",
|
||||
"Page": "Сторінка",
|
||||
"Paging Animation": "Анімація гортання",
|
||||
@@ -155,5 +154,49 @@
|
||||
"Word Spacing": "Міжслівний інтервал",
|
||||
"Letter Spacing": "Міжбуквений інтервал",
|
||||
"Text Indent": "Відступ тексту",
|
||||
"Paragraph Margin": "Відступ абзацу"
|
||||
"Paragraph Margin": "Відступ абзацу",
|
||||
"Override Book Layout": "Перевизначити макет книги",
|
||||
"Add to Group": "Додати до групи",
|
||||
"Untitled Group": "Група без назви",
|
||||
"Group Books": "Групувати книги",
|
||||
"Remove From Group": "Видалити з групи",
|
||||
"Create New Group": "Створити нову групу",
|
||||
"Deselect Book": "Скасувати вибір книги",
|
||||
"Download Book": "Завантажити книгу",
|
||||
"Deselect Group": "Скасувати вибір групи",
|
||||
"Select Group": "Вибрати групу",
|
||||
"Keep Screen Awake": "Не вимикати екран",
|
||||
"Email address": "Адреса електронної пошти",
|
||||
"Your Password": "Ваш пароль",
|
||||
"Your email address": "Ваша електронна адреса",
|
||||
"Your password": "Ваш пароль",
|
||||
"Sign in": "Увійти",
|
||||
"Signing in...": "Вхід...",
|
||||
"Sign in with {{provider}}": "Увійти через {{provider}}",
|
||||
"Already have an account? Sign in": "Уже є акаунт? Увійти",
|
||||
"Create a Password": "Створити пароль",
|
||||
"Sign up": "Зареєструватися",
|
||||
"Signing up...": "Реєстрація...",
|
||||
"Don’t have an account? Sign up": "Немає акаунту? Зареєструйтеся",
|
||||
"Check your email for the confirmation link": "Перевірте свою електронну пошту для підтвердження посилання",
|
||||
"Signing in ...": "Увійти...",
|
||||
"Send a magic link email": "Відправити магічне посилання на електронну пошту",
|
||||
"Check your email for the magic link": "Перевірте свою електронну пошту для магічного посилання",
|
||||
"Send reset password instructions": "Надіслати інструкції для скидання пароля",
|
||||
"Sending reset instructions ...": "Надсилаються інструкції для скидання пароля...",
|
||||
"Forgot your password?": "Забули пароль?",
|
||||
"Check your email for the password reset link": "Перевірте свою електронну пошту для посилання на скидання пароля",
|
||||
"New Password": "Новий пароль",
|
||||
"Your new password": "Ваш новий пароль",
|
||||
"Update password": "Оновити пароль",
|
||||
"Updating password ...": "Оновлення пароля...",
|
||||
"Your password has been updated": "Ваш пароль оновлено",
|
||||
"Phone number": "Номер телефону",
|
||||
"Your phone number": "Ваш номер телефону",
|
||||
"Token": "Токен",
|
||||
"Your OTP token": "Ваш OTP токен",
|
||||
"Verify token": "Перевірити токен",
|
||||
"Sign in with Google": "Увійти через Google",
|
||||
"Sign in with Apple": "Увійти через Apple",
|
||||
"Sign in with GitHub": "Увійти через GitHub"
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@
|
||||
"Notebook": "Sổ ghi chép",
|
||||
"Notes": "Ghi chú",
|
||||
"Open": "Mở",
|
||||
"Open Book": "Mở sách",
|
||||
"Original Text": "Văn bản gốc",
|
||||
"Page": "Trang",
|
||||
"Paging Animation": "Hiệu ứng lật trang",
|
||||
@@ -155,5 +154,49 @@
|
||||
"Word Spacing": "Khoảng cách từ",
|
||||
"Letter Spacing": "Khoảng cách chữ",
|
||||
"Text Indent": "Thụt lề",
|
||||
"Paragraph Margin": "Lề đoạn"
|
||||
"Paragraph Margin": "Lề đoạn",
|
||||
"Override Book Layout": "Ghi đè bố cục sách",
|
||||
"Add to Group": "Thêm vào nhóm",
|
||||
"Untitled Group": "Nhóm không có tiêu đề",
|
||||
"Group Books": "Nhóm sách",
|
||||
"Remove From Group": "Xóa khỏi nhóm",
|
||||
"Create New Group": "Tạo nhóm mới",
|
||||
"Deselect Book": "Bỏ chọn sách",
|
||||
"Download Book": "Tải sách",
|
||||
"Deselect Group": "Bỏ chọn nhóm",
|
||||
"Select Group": "Chọn nhóm",
|
||||
"Keep Screen Awake": "Giữ màn hình luôn bật",
|
||||
"Email address": "Địa chỉ email",
|
||||
"Your Password": "Mật khẩu của bạn",
|
||||
"Your email address": "Địa chỉ email của bạn",
|
||||
"Your password": "Mật khẩu của bạn",
|
||||
"Sign in": "Đăng nhập",
|
||||
"Signing in...": "Đang đăng nhập...",
|
||||
"Sign in with {{provider}}": "Đăng nhập với {{provider}}",
|
||||
"Already have an account? Sign in": "Đã có tài khoản? Đăng nhập",
|
||||
"Create a Password": "Tạo mật khẩu",
|
||||
"Sign up": "Đăng ký",
|
||||
"Signing up...": "Đang đăng ký...",
|
||||
"Don’t have an account? Sign up": "Chưa có tài khoản? Đăng ký",
|
||||
"Check your email for the confirmation link": "Kiểm tra email của bạn để nhận liên kết xác nhận",
|
||||
"Signing in ...": "Đang đăng nhập ...",
|
||||
"Send a magic link email": "Gửi email liên kết ma thuật",
|
||||
"Check your email for the magic link": "Kiểm tra email của bạn để nhận liên kết ma thuật",
|
||||
"Send reset password instructions": "Gửi hướng dẫn đặt lại mật khẩu",
|
||||
"Sending reset instructions ...": "Đang gửi hướng dẫn đặt lại mật khẩu ...",
|
||||
"Forgot your password?": "Quên mật khẩu?",
|
||||
"Check your email for the password reset link": "Kiểm tra email của bạn để nhận liên kết đặt lại mật khẩu",
|
||||
"New Password": "Mật khẩu mới",
|
||||
"Your new password": "Mật khẩu mới của bạn",
|
||||
"Update password": "Cập nhật mật khẩu",
|
||||
"Updating password ...": "Đang cập nhật mật khẩu ...",
|
||||
"Your password has been updated": "Mật khẩu của bạn đã được cập nhật",
|
||||
"Phone number": "Số điện thoại",
|
||||
"Your phone number": "Số điện thoại của bạn",
|
||||
"Token": "Mã token",
|
||||
"Your OTP token": "Mã OTP của bạn",
|
||||
"Verify token": "Xác minh mã token",
|
||||
"Sign in with Google": "Đăng nhập với Google",
|
||||
"Sign in with Apple": "Đăng nhập với Apple",
|
||||
"Sign in with GitHub": "Đăng nhập với GitHub"
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@
|
||||
"Notebook": "笔记本",
|
||||
"Notes": "笔记",
|
||||
"Open": "打开",
|
||||
"Open Book": "打开书籍",
|
||||
"Original Text": "原文",
|
||||
"Page": "页面",
|
||||
"Paging Animation": "翻页动画",
|
||||
@@ -155,5 +154,49 @@
|
||||
"Word Spacing": "词间距",
|
||||
"Letter Spacing": "字间距",
|
||||
"Text Indent": "首行缩进",
|
||||
"Paragraph Margin": "段间距"
|
||||
"Paragraph Margin": "段间距",
|
||||
"Override Book Layout": "覆盖版面布局",
|
||||
"Add to Group": "添加到分组",
|
||||
"Untitled Group": "无标题分组",
|
||||
"Group Books": "分组书籍",
|
||||
"Remove From Group": "从分组中移除",
|
||||
"Create New Group": "创建新分组",
|
||||
"Deselect Book": "取消选择书籍",
|
||||
"Download Book": "下载书籍",
|
||||
"Deselect Group": "取消选择分组",
|
||||
"Select Group": "选择分组",
|
||||
"Keep Screen Awake": "保持屏幕常亮",
|
||||
"Email address": "电子邮件地址",
|
||||
"Your Password": "您的密码",
|
||||
"Your email address": "您的电子邮件地址",
|
||||
"Your password": "您的密码",
|
||||
"Sign in": "登录",
|
||||
"Signing in...": "正在登录...",
|
||||
"Sign in with {{provider}}": "使用 {{provider}} 登录",
|
||||
"Already have an account? Sign in": "已经有账号?登录",
|
||||
"Create a Password": "创建密码",
|
||||
"Sign up": "注册",
|
||||
"Signing up...": "正在注册...",
|
||||
"Don’t have an account? Sign up": "没有账号?注册",
|
||||
"Check your email for the confirmation link": "检查您的电子邮件以获取确认链接",
|
||||
"Signing in ...": "正在登录...",
|
||||
"Send a magic link email": "发送魔法链接邮件",
|
||||
"Check your email for the magic link": "检查您的电子邮件以获取魔法链接",
|
||||
"Send reset password instructions": "发送重置密码说明",
|
||||
"Sending reset instructions ...": "正在发送重置密码说明...",
|
||||
"Forgot your password?": "忘记密码?",
|
||||
"Check your email for the password reset link": "检查您的电子邮件以获取密码重置链接",
|
||||
"New Password": "新密码",
|
||||
"Your new password": "您的新密码",
|
||||
"Update password": "更新密码",
|
||||
"Updating password ...": "正在更新密码...",
|
||||
"Your password has been updated": "您的密码已更新",
|
||||
"Phone number": "电话号码",
|
||||
"Your phone number": "您的电话号码",
|
||||
"Token": "令牌",
|
||||
"Your OTP token": "您的 OTP 令牌",
|
||||
"Verify token": "验证令牌",
|
||||
"Sign in with Google": "使用 Google 登录",
|
||||
"Sign in with Apple": "使用 Apple 登录",
|
||||
"Sign in with GitHub": "使用 GitHub 登录"
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@
|
||||
"Notebook": "筆記本",
|
||||
"Notes": "筆記",
|
||||
"Open": "打開",
|
||||
"Open Book": "打開書籍",
|
||||
"Original Text": "原文",
|
||||
"Page": "頁面",
|
||||
"Paging Animation": "翻頁動畫",
|
||||
@@ -155,5 +154,49 @@
|
||||
"Word Spacing": "字間距",
|
||||
"Letter Spacing": "字間距",
|
||||
"Text Indent": "首行縮進",
|
||||
"Paragraph Margin": "段間距"
|
||||
"Paragraph Margin": "段間距",
|
||||
"Override Book Layout": "覆蓋版面佈局",
|
||||
"Add to Group": "添加到組",
|
||||
"Untitled Group": "無標題組",
|
||||
"Group Books": "分組書籍",
|
||||
"Remove From Group": "從組中移除",
|
||||
"Create New Group": "創建新組",
|
||||
"Deselect Book": "取消選擇書籍",
|
||||
"Download Book": "下載書籍",
|
||||
"Deselect Group": "取消選擇組",
|
||||
"Select Group": "選擇組",
|
||||
"Keep Screen Awake": "保持屏幕常亮",
|
||||
"Email address": "電子郵件地址",
|
||||
"Your Password": "您的密碼",
|
||||
"Your email address": "您的電子郵件地址",
|
||||
"Your password": "您的密碼",
|
||||
"Sign in": "登入",
|
||||
"Signing in...": "正在登入...",
|
||||
"Sign in with {{provider}}": "使用 {{provider}} 登入",
|
||||
"Already have an account? Sign in": "已經有帳號?登入",
|
||||
"Create a Password": "創建密碼",
|
||||
"Sign up": "註冊",
|
||||
"Signing up...": "正在註冊...",
|
||||
"Don’t have an account? Sign up": "沒有帳號?註冊",
|
||||
"Check your email for the confirmation link": "檢查您的電子郵件以獲取確認連結",
|
||||
"Signing in ...": "正在登入...",
|
||||
"Send a magic link email": "發送魔法連結郵件",
|
||||
"Check your email for the magic link": "檢查您的電子郵件以獲取魔法連結",
|
||||
"Send reset password instructions": "發送重設密碼指示",
|
||||
"Sending reset instructions ...": "正在發送重設密碼指示...",
|
||||
"Forgot your password?": "忘記密碼?",
|
||||
"Check your email for the password reset link": "檢查您的電子郵件以獲取密碼重設連結",
|
||||
"New Password": "新密碼",
|
||||
"Your new password": "您的新密碼",
|
||||
"Update password": "更新密碼",
|
||||
"Updating password ...": "正在更新密碼...",
|
||||
"Your password has been updated": "您的密碼已更新",
|
||||
"Phone number": "電話號碼",
|
||||
"Your phone number": "您的電話號碼",
|
||||
"Token": "令牌",
|
||||
"Your OTP token": "您的 OTP 令牌",
|
||||
"Verify token": "驗證令牌",
|
||||
"Sign in with Google": "使用 Google 登入",
|
||||
"Sign in with Apple": "使用 Apple 登入",
|
||||
"Sign in with GitHub": "使用 GitHub 登入"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
{
|
||||
"releases": {
|
||||
"0.9.15": {
|
||||
"date": "2025-02-21",
|
||||
"notes": [
|
||||
"Fix hardcoded font color from calibre",
|
||||
"Fix scroll to current TOC item when toggling sidebar",
|
||||
"Various fixes and enhancements on iOS platform"
|
||||
]
|
||||
},
|
||||
"0.9.13": {
|
||||
"date": "2025-02-19",
|
||||
"notes": [
|
||||
"Add option to keep screen awake when reading",
|
||||
"Add shortcuts to notetaking UI",
|
||||
"UX enhancements of grid view in mobile platforms"
|
||||
]
|
||||
},
|
||||
"0.9.11": {
|
||||
"date": "2025-02-14",
|
||||
"notes": [
|
||||
"Organize the library with customizable groups/folders",
|
||||
"Alpha release for Android and iOS platforms",
|
||||
"Various fixes and enhancements on Android and iOS platforms"
|
||||
]
|
||||
},
|
||||
"0.9.10": {
|
||||
"date": "2025-02-08",
|
||||
"notes": [
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
pnpm tauri ios build --export-method app-store-connect
|
||||
|
||||
BUNDLE_DIR=src-tauri/gen/apple/build/arm64
|
||||
IPA_BUNDLE=$BUNDLE_DIR/Readest.ipa
|
||||
|
||||
xcrun altool --upload-app --type ios --file $IPA_BUNDLE --apiKey $APPLE_API_KEY --apiIssuer $APPLE_API_ISSUER
|
||||
@@ -44,6 +44,7 @@ tauri-plugin-process = "2"
|
||||
tauri-plugin-oauth = "2"
|
||||
tauri-plugin-opener = "2.2.2"
|
||||
tauri-plugin-deep-link = "2"
|
||||
tauri-plugin-sign-in-with-apple = "1.0.2"
|
||||
[patch.crates-io]
|
||||
tauri = { path = "../../../packages/tauri/crates/tauri" }
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
"process:allow-restart",
|
||||
"oauth:allow-start",
|
||||
"oauth:allow-cancel",
|
||||
"sign-in-with-apple:default",
|
||||
"opener:default",
|
||||
"deep-link:default"
|
||||
]
|
||||
|
||||
+15
-5
@@ -1,5 +1,6 @@
|
||||
package com.bilingify.readest
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.WindowInsets
|
||||
@@ -10,14 +11,23 @@ class MainActivity : TauriActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
hideSystemUI()
|
||||
|
||||
hideSystemUI()
|
||||
}
|
||||
|
||||
private fun hideSystemUI() {
|
||||
window.decorView.windowInsetsController?.let { controller ->
|
||||
controller.hide(WindowInsets.Type.systemBars())
|
||||
controller.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
window.decorView.windowInsetsController?.let { controller ->
|
||||
controller.hide(WindowInsets.Type.systemBars())
|
||||
controller.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
}
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
window.decorView.systemUiVisibility = (
|
||||
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
or View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.readest" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="android:windowActionBar">false</item>
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
<item name="android:windowFullscreen">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 720 KiB After Width: | Height: | Size: 611 KiB |
@@ -129,6 +129,9 @@ pub fn run() {
|
||||
#[cfg(target_os = "macos")]
|
||||
let builder = builder.plugin(traffic_light_plugin::init());
|
||||
|
||||
#[cfg(target_os = "ios")]
|
||||
let builder = builder.plugin(tauri_plugin_sign_in_with_apple::init());
|
||||
|
||||
builder
|
||||
.setup(|#[allow(unused_variables)] app| {
|
||||
#[cfg(desktop)]
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"connect-src": "'self' blob: data: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com wss://speech.platform.bing.com https://*.cloudflarestorage.com",
|
||||
"img-src": "'self' blob: data: asset: http://asset.localhost https://*",
|
||||
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com",
|
||||
"font-src": "'self' data: asset: http://asset.localhost tauri: https://fonts.gstatic.com https://db.onlinewebfonts.com https://cdn.jsdelivr.net",
|
||||
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://fonts.gstatic.com https://db.onlinewebfonts.com https://cdn.jsdelivr.net",
|
||||
"frame-src": "'self' blob: asset: http://asset.localhost",
|
||||
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' blob: asset: http://asset.localhost https://*.sentry.io https://*.posthog.com"
|
||||
},
|
||||
|
||||
@@ -15,13 +15,13 @@ import { supabase } from '@/utils/supabase';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { onOpenUrl, getCurrent } from '@tauri-apps/plugin-deep-link';
|
||||
import { onOpenUrl } from '@tauri-apps/plugin-deep-link';
|
||||
import { start, cancel, onUrl, onInvalidUrl } from '@fabianlars/tauri-plugin-oauth';
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import { tauriHandleOnWindowFocus } from '@/utils/window';
|
||||
import { handleAuthCallback } from '@/helpers/auth';
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
import { getAppleIdAuth, Scope } from './utils/appleIdAuth';
|
||||
|
||||
type OAuthProvider = 'google' | 'apple' | 'azure' | 'github';
|
||||
|
||||
@@ -37,6 +37,9 @@ interface ProviderLoginProp {
|
||||
label: string;
|
||||
}
|
||||
|
||||
const WEB_AUTH_CALLBACK = 'https://web.readest.com/auth/callback';
|
||||
const DEEPLINK_CALLBACK = 'readest://auth/callback';
|
||||
|
||||
const ProviderLogin: React.FC<ProviderLoginProp> = ({ provider, handleSignIn, Icon, label }) => {
|
||||
return (
|
||||
<button
|
||||
@@ -53,22 +56,42 @@ const ProviderLogin: React.FC<ProviderLoginProp> = ({ provider, handleSignIn, Ic
|
||||
};
|
||||
|
||||
export default function AuthPage() {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const { login } = useAuth();
|
||||
const { envConfig } = useEnv();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { isDarkMode } = useTheme();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
const [port, setPort] = useState<number | null>(null);
|
||||
const isOAuthServerRunning = useRef(false);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const osPlatform = getOSPlatform();
|
||||
|
||||
const getTauriRedirectTo = () => {
|
||||
return process.env.NODE_ENV === 'production'
|
||||
? ['android', 'ios'].includes(osPlatform)
|
||||
? 'https://web.readest.com/auth/callback'
|
||||
: 'readest://auth/callback'
|
||||
: `http://localhost:${port}`;
|
||||
return process.env.NODE_ENV === 'production' || appService?.isMobile
|
||||
? appService?.isMobile
|
||||
? WEB_AUTH_CALLBACK
|
||||
: DEEPLINK_CALLBACK
|
||||
: `http://localhost:${port}`; // only for development env on Desktop
|
||||
};
|
||||
|
||||
const tauriSignInApple = async () => {
|
||||
if (!supabase) {
|
||||
throw new Error('No backend connected');
|
||||
}
|
||||
supabase.auth.signOut();
|
||||
const request = {
|
||||
scope: ['fullName', 'email'] as Scope[],
|
||||
};
|
||||
const appleAuthResponse = await getAppleIdAuth(request);
|
||||
if (appleAuthResponse.identityToken) {
|
||||
const { error } = await supabase.auth.signInWithIdToken({
|
||||
provider: 'apple',
|
||||
token: appleAuthResponse.identityToken,
|
||||
});
|
||||
if (error) {
|
||||
console.error('Authentication error:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const tauriSignIn = async (provider: OAuthProvider) => {
|
||||
@@ -88,22 +111,7 @@ export default function AuthPage() {
|
||||
console.error('Authentication error:', error);
|
||||
return;
|
||||
}
|
||||
openUrl(data.url);
|
||||
|
||||
// FIXME: For Android we need a better way to trigger the deeplink redirect
|
||||
if (isTauriAppPlatform() && osPlatform === 'android') {
|
||||
tauriHandleOnWindowFocus(async () => {
|
||||
const urls = await getCurrent();
|
||||
if (urls && urls.length > 0) {
|
||||
urls.forEach((url) => {
|
||||
handleOAuthUrl(url);
|
||||
});
|
||||
}
|
||||
});
|
||||
setTimeout(() => {
|
||||
router.back();
|
||||
}, 5000);
|
||||
}
|
||||
await openUrl(data.url);
|
||||
};
|
||||
|
||||
const handleOAuthUrl = async (url: string) => {
|
||||
@@ -123,7 +131,7 @@ export default function AuthPage() {
|
||||
|
||||
const startTauriOAuth = async () => {
|
||||
try {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (process.env.NODE_ENV === 'production' || appService?.isMobile) {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
const currentWindow = getCurrentWindow();
|
||||
currentWindow.listen('single-instance', ({ event, payload }) => {
|
||||
@@ -172,6 +180,68 @@ export default function AuthPage() {
|
||||
router.back();
|
||||
};
|
||||
|
||||
const getAuthLocalization = () => {
|
||||
return {
|
||||
variables: {
|
||||
sign_in: {
|
||||
email_label: _('Email address'),
|
||||
password_label: _('Your Password'),
|
||||
email_input_placeholder: _('Your email address'),
|
||||
password_input_placeholder: _('Your password'),
|
||||
button_label: _('Sign in'),
|
||||
loading_button_label: _('Signing in...'),
|
||||
social_provider_text: _('Sign in with {{provider}}'),
|
||||
link_text: _('Already have an account? Sign in'),
|
||||
},
|
||||
sign_up: {
|
||||
email_label: _('Email address'),
|
||||
password_label: _('Create a Password'),
|
||||
email_input_placeholder: _('Your email address'),
|
||||
password_input_placeholder: _('Your password'),
|
||||
button_label: _('Sign up'),
|
||||
loading_button_label: _('Signing up...'),
|
||||
social_provider_text: _('Sign in with {{provider}}'),
|
||||
link_text: _('Don’t have an account? Sign up'),
|
||||
confirmation_text: _('Check your email for the confirmation link'),
|
||||
},
|
||||
magic_link: {
|
||||
email_input_label: _('Email address'),
|
||||
email_input_placeholder: _('Your email address'),
|
||||
button_label: _('Sign in'),
|
||||
loading_button_label: _('Signing in ...'),
|
||||
link_text: _('Send a magic link email'),
|
||||
confirmation_text: _('Check your email for the magic link'),
|
||||
},
|
||||
forgotten_password: {
|
||||
email_label: _('Email address'),
|
||||
password_label: _('Your Password'),
|
||||
email_input_placeholder: _('Your email address'),
|
||||
button_label: _('Send reset password instructions'),
|
||||
loading_button_label: _('Sending reset instructions ...'),
|
||||
link_text: _('Forgot your password?'),
|
||||
confirmation_text: _('Check your email for the password reset link'),
|
||||
},
|
||||
update_password: {
|
||||
password_label: _('New Password'),
|
||||
password_input_placeholder: _('Your new password'),
|
||||
button_label: _('Update password'),
|
||||
loading_button_label: _('Updating password ...'),
|
||||
confirmation_text: _('Your password has been updated'),
|
||||
},
|
||||
verify_otp: {
|
||||
email_input_label: _('Email address'),
|
||||
email_input_placeholder: _('Your email address'),
|
||||
phone_input_label: _('Phone number'),
|
||||
phone_input_placeholder: _('Your phone number'),
|
||||
token_input_label: _('Token'),
|
||||
token_input_placeholder: _('Your OTP token'),
|
||||
button_label: _('Verify token'),
|
||||
loading_button_label: _('Signing in ...'),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauriAppPlatform()) return;
|
||||
if (isOAuthServerRunning.current) return;
|
||||
@@ -212,10 +282,20 @@ export default function AuthPage() {
|
||||
// For tauri app production, use deeplink to handle the OAuth callback
|
||||
// For web app, use the built-in OAuth callback page /auth/callback
|
||||
return isTauriAppPlatform() ? (
|
||||
<div className='flex pt-11'>
|
||||
<div
|
||||
className={clsx(
|
||||
'mt-6 flex',
|
||||
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
|
||||
appService?.hasTrafficLight && 'pt-11',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={handleGoBack}
|
||||
className='btn btn-ghost fixed left-3 top-11 h-8 min-h-8 w-8 p-0'
|
||||
className={clsx(
|
||||
'btn btn-ghost fixed left-4 h-8 min-h-8 w-8 p-0',
|
||||
appService?.hasSafeAreaInset && 'top-[calc(env(safe-area-inset-top)+16px)]',
|
||||
appService?.hasTrafficLight && 'top-11',
|
||||
)}
|
||||
>
|
||||
<IoArrowBack className='text-base-content' />
|
||||
</button>
|
||||
@@ -224,19 +304,19 @@ export default function AuthPage() {
|
||||
provider='google'
|
||||
handleSignIn={tauriSignIn}
|
||||
Icon={FcGoogle}
|
||||
label='Sign in with Google'
|
||||
label={_('Sign in with Google')}
|
||||
/>
|
||||
<ProviderLogin
|
||||
provider='apple'
|
||||
handleSignIn={tauriSignIn}
|
||||
handleSignIn={appService?.isIOSApp ? tauriSignInApple : tauriSignIn}
|
||||
Icon={FaApple}
|
||||
label='Sign in with Apple'
|
||||
label={_('Sign in with Apple')}
|
||||
/>
|
||||
<ProviderLogin
|
||||
provider='github'
|
||||
handleSignIn={tauriSignIn}
|
||||
Icon={FaGithub}
|
||||
label='Sign in with GitHub'
|
||||
label={_('Sign in with GitHub')}
|
||||
/>
|
||||
<hr className='my-3 mt-6 w-64 border-t border-gray-200' />
|
||||
<Auth
|
||||
@@ -246,6 +326,7 @@ export default function AuthPage() {
|
||||
magicLink={true}
|
||||
providers={[]}
|
||||
redirectTo={getTauriRedirectTo()}
|
||||
localization={getAuthLocalization()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -253,7 +334,7 @@ export default function AuthPage() {
|
||||
<div style={{ maxWidth: '420px', margin: 'auto', padding: '2rem', paddingTop: '4rem' }}>
|
||||
<button
|
||||
onClick={handleGoBack}
|
||||
className='btn btn-ghost fixed left-10 top-6 h-8 min-h-8 w-8 p-0'
|
||||
className='btn btn-ghost fixed left-6 top-6 h-8 min-h-8 w-8 p-0'
|
||||
>
|
||||
<IoArrowBack className='text-base-content' />
|
||||
</button>
|
||||
@@ -264,6 +345,7 @@ export default function AuthPage() {
|
||||
magicLink={true}
|
||||
providers={['google', 'apple', 'github']}
|
||||
redirectTo='/auth/callback'
|
||||
localization={getAuthLocalization()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export type Scope = 'fullName' | 'email';
|
||||
export interface AppleIDAuthorizationRequest {
|
||||
scope: Scope[];
|
||||
nonce?: string;
|
||||
state?: string;
|
||||
}
|
||||
|
||||
export interface AppleIDAuthorizationResponse {
|
||||
// usually not null
|
||||
userIdentifier: string | null;
|
||||
|
||||
givenName: string | null;
|
||||
familyName: string | null;
|
||||
email: string | null;
|
||||
|
||||
authorizationCode: string;
|
||||
identityToken: string | null;
|
||||
state: string | null;
|
||||
}
|
||||
|
||||
export async function getAppleIdAuth(
|
||||
request: AppleIDAuthorizationRequest,
|
||||
): Promise<AppleIDAuthorizationResponse> {
|
||||
const result = await invoke<AppleIDAuthorizationResponse>(
|
||||
'plugin:sign-in-with-apple|get_apple_id_credential',
|
||||
{
|
||||
payload: request,
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import clsx from 'clsx';
|
||||
import Image from 'next/image';
|
||||
import { Book } from '@/types/book';
|
||||
import { formatAuthors, formatTitle } from '@/utils/book';
|
||||
|
||||
interface BookCoverProps {
|
||||
book: Book;
|
||||
isPreview?: boolean;
|
||||
}
|
||||
|
||||
const BookCover: React.FC<BookCoverProps> = ({ book, isPreview }) => {
|
||||
return (
|
||||
<div className='relative flex h-full w-full'>
|
||||
<Image
|
||||
src={book.coverImageUrl!}
|
||||
alt={book.title}
|
||||
fill={true}
|
||||
className='object-cover'
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('invisible');
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
'invisible absolute inset-0 rounded-none p-2',
|
||||
'text-neutral-content text-center font-serif font-medium',
|
||||
isPreview && 'bg-base-200/50',
|
||||
)}
|
||||
>
|
||||
<div className='flex h-1/2 items-center justify-center'>
|
||||
<span
|
||||
className={clsx(
|
||||
isPreview ? 'line-clamp-2' : 'line-clamp-3',
|
||||
isPreview ? 'text-[0.5em]' : 'text-lg',
|
||||
)}
|
||||
>
|
||||
{formatTitle(book.title)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='h-1/6'></div>
|
||||
<div className='flex h-1/3 items-center justify-center'>
|
||||
<span
|
||||
className={clsx(
|
||||
'text-neutral-content/50 line-clamp-1',
|
||||
isPreview ? 'text-[0.4em]' : 'text-base',
|
||||
)}
|
||||
>
|
||||
{formatAuthors(book.author)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookCover;
|
||||
@@ -1,5 +1,4 @@
|
||||
import clsx from 'clsx';
|
||||
import Image from 'next/image';
|
||||
import { MdCheckCircle, MdCheckCircleOutline } from 'react-icons/md';
|
||||
import { CiCircleMore } from 'react-icons/ci';
|
||||
import { LiaCloudUploadAltSolid, LiaCloudDownloadAltSolid } from 'react-icons/lia';
|
||||
@@ -7,83 +6,57 @@ import { LiaCloudUploadAltSolid, LiaCloudDownloadAltSolid } from 'react-icons/li
|
||||
import { Book } from '@/types/book';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { formatAuthors, formatTitle } from '@/utils/book';
|
||||
import ReadingProgress from './ReadingProgress';
|
||||
import BookCover from './BookCover';
|
||||
|
||||
interface BookItemProps {
|
||||
book: Book;
|
||||
isSelectMode: boolean;
|
||||
selectedBooks: string[];
|
||||
clickedBookHash: string | null;
|
||||
transferProgress: number | null;
|
||||
handleBookClick: (book: Book) => void;
|
||||
handleBookUpload: (book: Book) => void;
|
||||
handleBookDownload: (book: Book) => void;
|
||||
showBookDetailsModal: (book: Book) => void;
|
||||
bookContextMenuHandler: (book: Book, e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
const BookItem: React.FC<BookItemProps> = ({
|
||||
book,
|
||||
isSelectMode,
|
||||
selectedBooks,
|
||||
clickedBookHash,
|
||||
transferProgress,
|
||||
handleBookClick,
|
||||
handleBookUpload,
|
||||
handleBookDownload,
|
||||
showBookDetailsModal,
|
||||
bookContextMenuHandler,
|
||||
}) => {
|
||||
const iconSize15 = useResponsiveSize(15);
|
||||
const { appService } = useEnv();
|
||||
|
||||
const stopEvent = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx('book-item', appService?.hasContextMenu ? 'cursor-pointer' : '')}
|
||||
onContextMenu={bookContextMenuHandler.bind(null, book)}
|
||||
className={clsx(
|
||||
'book-item flex h-full flex-col',
|
||||
appService?.hasContextMenu ? 'cursor-pointer' : '',
|
||||
)}
|
||||
>
|
||||
<div className='bg-base-100 shadow-md' onClick={() => handleBookClick(book)}>
|
||||
<div className='relative aspect-[28/41]'>
|
||||
<Image
|
||||
src={book.coverImageUrl!}
|
||||
alt={book.title}
|
||||
fill={true}
|
||||
className='object-cover'
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('invisible');
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
'invisible absolute inset-0 rounded-none p-2',
|
||||
'text-neutral-content text-center font-serif font-medium',
|
||||
<div className='bg-base-100 relative flex aspect-[28/41] items-center justify-center overflow-hidden shadow-md'>
|
||||
<BookCover book={book} />
|
||||
{selectedBooks.includes(book.hash) && (
|
||||
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
|
||||
)}
|
||||
{isSelectMode && (
|
||||
<div className='absolute bottom-1 right-1'>
|
||||
{selectedBooks.includes(book.hash) ? (
|
||||
<MdCheckCircle className='fill-blue-500' />
|
||||
) : (
|
||||
<MdCheckCircleOutline className='fill-gray-300 drop-shadow-sm' />
|
||||
)}
|
||||
>
|
||||
<div className='flex h-1/2 items-center justify-center'>
|
||||
<span className='line-clamp-3 text-lg'>{formatTitle(book.title)}</span>
|
||||
</div>
|
||||
<div className='h-1/6'></div>
|
||||
<div className='flex h-1/3 items-center justify-center'>
|
||||
<span className='text-neutral-content/50 line-clamp-1 text-base'>
|
||||
{formatAuthors(book.author)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{(selectedBooks.includes(book.hash) || clickedBookHash === book.hash) && (
|
||||
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
|
||||
)}
|
||||
{isSelectMode && (
|
||||
<div className='absolute bottom-1 right-1'>
|
||||
{selectedBooks.includes(book.hash) ? (
|
||||
<MdCheckCircle className='fill-blue-500' />
|
||||
) : (
|
||||
<MdCheckCircleOutline className='fill-gray-300 drop-shadow-sm' />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={clsx('flex w-full flex-col p-0 pt-2')}>
|
||||
<div className='min-w-0 flex-1'>
|
||||
@@ -113,6 +86,11 @@ const BookItem: React.FC<BookItemProps> = ({
|
||||
) : (
|
||||
<button
|
||||
className='show-detail-button opacity-0 group-hover:opacity-100'
|
||||
onPointerDown={(e) => stopEvent(e)}
|
||||
onPointerUp={(e) => stopEvent(e)}
|
||||
onPointerMove={(e) => stopEvent(e)}
|
||||
onPointerCancel={(e) => stopEvent(e)}
|
||||
onPointerLeave={(e) => stopEvent(e)}
|
||||
onClick={() => {
|
||||
if (!book.uploadedAt) {
|
||||
handleBookUpload(book);
|
||||
@@ -129,6 +107,11 @@ const BookItem: React.FC<BookItemProps> = ({
|
||||
)}
|
||||
<button
|
||||
className='show-detail-button opacity-0 group-hover:opacity-100'
|
||||
onPointerDown={(e) => stopEvent(e)}
|
||||
onPointerUp={(e) => stopEvent(e)}
|
||||
onPointerMove={(e) => stopEvent(e)}
|
||||
onPointerCancel={(e) => stopEvent(e)}
|
||||
onPointerLeave={(e) => stopEvent(e)}
|
||||
onClick={() => showBookDetailsModal(book)}
|
||||
>
|
||||
<CiCircleMore size={iconSize15} />
|
||||
|
||||
@@ -2,51 +2,20 @@ import clsx from 'clsx';
|
||||
import * as React from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { MdDelete, MdOpenInNew } from 'react-icons/md';
|
||||
import { MdDelete, MdOpenInNew, MdOutlineCancel } from 'react-icons/md';
|
||||
import { LuFolderPlus } from 'react-icons/lu';
|
||||
import { PiPlus } from 'react-icons/pi';
|
||||
import { Menu, MenuItem } from '@tauri-apps/api/menu';
|
||||
import { revealItemInDir } from '@tauri-apps/plugin-opener';
|
||||
import { Book, BooksGroup } from '@/types/book';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { navigateToReader } from '@/utils/nav';
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
import { getFilename } from '@/utils/book';
|
||||
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
|
||||
import { navigateToLibrary, navigateToReader } from '@/utils/nav';
|
||||
import { isMd5 } from '@/utils/md5';
|
||||
|
||||
import Alert from '@/components/Alert';
|
||||
import Spinner from '@/components/Spinner';
|
||||
import BookItem from './BookItem';
|
||||
import GroupItem from './GroupItem';
|
||||
|
||||
type BookshelfItem = Book | BooksGroup;
|
||||
|
||||
const UNGROUPED_NAME = 'ungrouped';
|
||||
|
||||
const generateBookshelfItems = (books: Book[]): BookshelfItem[] => {
|
||||
const groups: BooksGroup[] = books.reduce((acc: BooksGroup[], book: Book) => {
|
||||
if (book.deletedAt) return acc;
|
||||
book.group = book.group || UNGROUPED_NAME;
|
||||
const groupIndex = acc.findIndex((group) => group.name === book.group);
|
||||
const booksGroup = acc[acc.findIndex((group) => group.name === book.group)];
|
||||
if (booksGroup) {
|
||||
booksGroup.books.push(book);
|
||||
booksGroup.updatedAt = Math.max(acc[groupIndex]!.updatedAt, book.updatedAt);
|
||||
} else {
|
||||
acc.push({
|
||||
name: book.group,
|
||||
books: [book],
|
||||
updatedAt: book.updatedAt,
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
const ungroupedBooks: Book[] = groups.find((group) => group.name === UNGROUPED_NAME)?.books || [];
|
||||
const groupedBooks: BooksGroup[] = groups.filter((group) => group.name !== UNGROUPED_NAME);
|
||||
return [...ungroupedBooks, ...groupedBooks].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
};
|
||||
import BookshelfItem, { generateBookshelfItems } from './BookshelfItem';
|
||||
import GroupingModal from './GroupingModal';
|
||||
|
||||
interface BookshelfProps {
|
||||
libraryBooks: Book[];
|
||||
@@ -55,6 +24,7 @@ interface BookshelfProps {
|
||||
handleBookUpload: (book: Book) => void;
|
||||
handleBookDownload: (book: Book) => void;
|
||||
handleBookDelete: (book: Book) => void;
|
||||
handleSetSelectMode: (selectMode: boolean) => void;
|
||||
handleShowDetailsBook: (book: Book) => void;
|
||||
booksTransferProgress: { [key: string]: number | null };
|
||||
}
|
||||
@@ -66,46 +36,33 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
handleBookUpload,
|
||||
handleBookDownload,
|
||||
handleBookDelete,
|
||||
handleSetSelectMode,
|
||||
handleShowDetailsBook,
|
||||
booksTransferProgress,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const { updateBook } = useLibraryStore();
|
||||
const { appService } = useEnv();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedBooks, setSelectedBooks] = useState<string[]>([]);
|
||||
const [showSelectModeActions, setShowSelectModeActions] = useState(false);
|
||||
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
|
||||
const [clickedImage, setClickedImage] = useState<string | null>(null);
|
||||
const [showGroupingModal, setShowGroupingModal] = useState(false);
|
||||
const [navBooksGroup, setNavBooksGroup] = useState<BooksGroup | null>(null);
|
||||
const [importBookUrl] = useState(searchParams?.get('url') || '');
|
||||
const isImportingBook = useRef(false);
|
||||
|
||||
const makeBookAvailable = async (book: Book) => {
|
||||
if (book.uploadedAt && !book.downloadedAt) {
|
||||
let available = false;
|
||||
try {
|
||||
await handleBookDownload(book);
|
||||
updateBook(envConfig, book);
|
||||
available = true;
|
||||
} finally {
|
||||
return available;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const showBookDetailsModal = async (book: Book) => {
|
||||
if (await makeBookAvailable(book)) {
|
||||
handleShowDetailsBook(book);
|
||||
}
|
||||
};
|
||||
|
||||
const { setLibrary } = useLibraryStore();
|
||||
const allBookshelfItems = generateBookshelfItems(libraryBooks);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedBooks([]);
|
||||
if (isSelectMode) {
|
||||
setShowSelectModeActions(true);
|
||||
} else {
|
||||
setSelectedBooks([]);
|
||||
setShowSelectModeActions(false);
|
||||
}
|
||||
}, [isSelectMode]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -127,24 +84,27 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [importBookUrl, appService]);
|
||||
|
||||
const bookshelfItems = generateBookshelfItems(libraryBooks);
|
||||
|
||||
const handleBookClick = async (book: Book) => {
|
||||
if (!(await makeBookAvailable(book))) return;
|
||||
|
||||
if (isSelectMode) {
|
||||
toggleSelection(book.hash);
|
||||
useEffect(() => {
|
||||
const group = searchParams?.get('group') || '';
|
||||
if (group) {
|
||||
const booksGroup = allBookshelfItems.find(
|
||||
(item) => 'name' in item && item.id === group,
|
||||
) as BooksGroup;
|
||||
if (booksGroup) {
|
||||
setNavBooksGroup(booksGroup);
|
||||
} else {
|
||||
navigateToLibrary(router);
|
||||
}
|
||||
} else {
|
||||
setClickedImage(book.hash);
|
||||
setTimeout(() => setClickedImage(null), 300);
|
||||
setTimeout(() => setLoading(true), 200);
|
||||
navigateToReader(router, [book.hash]);
|
||||
setNavBooksGroup(null);
|
||||
navigateToLibrary(router);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams, libraryBooks, showGroupingModal]);
|
||||
|
||||
const toggleSelection = (id: string) => {
|
||||
setSelectedBooks((prev) =>
|
||||
prev.includes(id) ? prev.filter((bookId) => bookId !== id) : [...prev, id],
|
||||
prev.includes(id) ? prev.filter((selectedId) => selectedId !== id) : [...prev, id],
|
||||
);
|
||||
};
|
||||
|
||||
@@ -154,98 +114,62 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
for (const selectedBook of selectedBooks) {
|
||||
const book = libraryBooks.find((b) => b.hash === selectedBook);
|
||||
if (book) {
|
||||
await handleBookDelete(book);
|
||||
selectedBooks.forEach((id) => {
|
||||
for (const book of libraryBooks.filter((book) => book.hash === id || book.groupId === id)) {
|
||||
if (book && !book.deletedAt) {
|
||||
handleBookDelete(book);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
setSelectedBooks([]);
|
||||
setShowDeleteAlert(false);
|
||||
setShowSelectModeActions(true);
|
||||
};
|
||||
|
||||
const deleteSelectedBooks = () => {
|
||||
setShowSelectModeActions(false);
|
||||
setShowDeleteAlert(true);
|
||||
};
|
||||
|
||||
const bookContextMenuHandler = async (book: Book, e: React.MouseEvent) => {
|
||||
if (!appService?.hasContextMenu) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const osPlatform = getOSPlatform();
|
||||
const fileRevealLabel =
|
||||
FILE_REVEAL_LABELS[osPlatform as FILE_REVEAL_PLATFORMS] || FILE_REVEAL_LABELS.default;
|
||||
const openBookMenuItem = await MenuItem.new({
|
||||
text: isSelectMode ? _('Select Book') : _('Open Book'),
|
||||
action: async () => {
|
||||
handleBookClick(book);
|
||||
},
|
||||
});
|
||||
const showBookInFinderMenuItem = await MenuItem.new({
|
||||
text: _(fileRevealLabel),
|
||||
action: async () => {
|
||||
const folder = `${settings.localBooksDir}/${getFilename(book)}`;
|
||||
revealItemInDir(folder);
|
||||
},
|
||||
});
|
||||
const showBookDetailsMenuItem = await MenuItem.new({
|
||||
text: _('Show Book Details'),
|
||||
action: async () => {
|
||||
showBookDetailsModal(book);
|
||||
},
|
||||
});
|
||||
const uploadBookMenuItem = await MenuItem.new({
|
||||
text: _('Upload Book'),
|
||||
action: async () => {
|
||||
handleBookUpload(book);
|
||||
},
|
||||
});
|
||||
const deleteBookMenuItem = await MenuItem.new({
|
||||
text: _('Delete'),
|
||||
action: async () => {
|
||||
await handleBookDelete(book);
|
||||
},
|
||||
});
|
||||
const menu = await Menu.new();
|
||||
menu.append(openBookMenuItem);
|
||||
menu.append(showBookDetailsMenuItem);
|
||||
menu.append(showBookInFinderMenuItem);
|
||||
menu.append(uploadBookMenuItem);
|
||||
menu.append(deleteBookMenuItem);
|
||||
menu.popup();
|
||||
const groupSelectedBooks = () => {
|
||||
setShowSelectModeActions(false);
|
||||
setShowGroupingModal(true);
|
||||
};
|
||||
|
||||
const currentBookshelfItems = navBooksGroup ? navBooksGroup.books : allBookshelfItems;
|
||||
|
||||
return (
|
||||
<div className='bookshelf'>
|
||||
<div className='grid grid-cols-3 gap-0 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8'>
|
||||
{bookshelfItems.map((item, index) => (
|
||||
<div
|
||||
<div
|
||||
className={clsx(
|
||||
'transform-wrapper grid flex-1 gap-x-4 sm:gap-x-0',
|
||||
'grid-cols-3 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8',
|
||||
)}
|
||||
>
|
||||
{currentBookshelfItems.map((item, index) => (
|
||||
<BookshelfItem
|
||||
key={`library-item-${index}`}
|
||||
className='hover:bg-base-300/50 group flex h-full flex-col p-4'
|
||||
>
|
||||
<div className='flex-grow'>
|
||||
{'format' in item ? (
|
||||
<BookItem
|
||||
book={item}
|
||||
isSelectMode={isSelectMode}
|
||||
selectedBooks={selectedBooks}
|
||||
clickedBookHash={clickedImage}
|
||||
handleBookClick={handleBookClick}
|
||||
handleBookUpload={handleBookUpload}
|
||||
handleBookDownload={handleBookDownload}
|
||||
showBookDetailsModal={showBookDetailsModal}
|
||||
bookContextMenuHandler={bookContextMenuHandler}
|
||||
transferProgress={booksTransferProgress[item.hash] || null}
|
||||
/>
|
||||
) : (
|
||||
<GroupItem group={item} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
item={item}
|
||||
isSelectMode={isSelectMode}
|
||||
selectedBooks={selectedBooks}
|
||||
setLoading={setLoading}
|
||||
toggleSelection={toggleSelection}
|
||||
handleBookUpload={handleBookUpload}
|
||||
handleBookDownload={handleBookDownload}
|
||||
handleBookDelete={handleBookDelete}
|
||||
handleSetSelectMode={handleSetSelectMode}
|
||||
handleShowDetailsBook={handleShowDetailsBook}
|
||||
transferProgress={
|
||||
'hash' in item ? booksTransferProgress[(item as Book).hash] || null : null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{bookshelfItems.length > 0 && (
|
||||
{!navBooksGroup && allBookshelfItems.length > 0 && (
|
||||
<div
|
||||
className='border-1 bg-base-100 hover:bg-base-300/50 m-4 flex aspect-[28/41] items-center justify-center'
|
||||
className={clsx(
|
||||
'border-1 bg-base-100 hover:bg-base-300/50 flex items-center justify-center',
|
||||
'mx-0 my-4 aspect-[28/41] sm:mx-4',
|
||||
)}
|
||||
role='button'
|
||||
onClick={handleImportBooks}
|
||||
>
|
||||
@@ -253,35 +177,94 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedBooks.length > 0 && (
|
||||
<div
|
||||
className={clsx(
|
||||
'text-base-content bg-base-300 fixed bottom-4 left-1/2 flex',
|
||||
'-translate-x-1/2 transform space-x-4 rounded-lg p-4 shadow-lg',
|
||||
)}
|
||||
>
|
||||
<button onClick={openSelectedBooks} className='flex items-center space-x-2'>
|
||||
<MdOpenInNew />
|
||||
<span>{_('Open')}</span>
|
||||
</button>
|
||||
<button onClick={deleteSelectedBooks} className='flex items-center space-x-2'>
|
||||
<MdDelete className='fill-red-500' />
|
||||
<span className='text-red-500'>{_('Delete')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{loading && (
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center'>
|
||||
<Spinner loading />
|
||||
</div>
|
||||
)}
|
||||
<div className='fixed bottom-0 left-0 right-0 z-40 pb-[calc(env(safe-area-inset-bottom)+16px)]'>
|
||||
{isSelectMode && showSelectModeActions && (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center justify-center shadow-lg',
|
||||
'text-base-content bg-base-300 text-sm',
|
||||
'mx-auto w-fit space-x-6 rounded-lg p-4',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={openSelectedBooks}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center',
|
||||
(!selectedBooks.length || !selectedBooks.every((id) => isMd5(id))) &&
|
||||
'btn-disabled opacity-50',
|
||||
)}
|
||||
>
|
||||
<MdOpenInNew />
|
||||
<div>{_('Open')}</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={groupSelectedBooks}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center',
|
||||
!selectedBooks.length && 'btn-disabled opacity-50',
|
||||
)}
|
||||
>
|
||||
<LuFolderPlus />
|
||||
<div>{_('Add to Group')}</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={deleteSelectedBooks}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center',
|
||||
!selectedBooks.length && 'btn-disabled opacity-50',
|
||||
)}
|
||||
>
|
||||
<MdDelete className='fill-red-500' />
|
||||
<div className='text-red-500'>{_('Delete')}</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSetSelectMode(false)}
|
||||
className={clsx('flex flex-col items-center justify-center')}
|
||||
>
|
||||
<MdOutlineCancel />
|
||||
<div>{_('Cancel')}</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showGroupingModal && (
|
||||
<div>
|
||||
<GroupingModal
|
||||
libraryBooks={libraryBooks}
|
||||
selectedBooks={selectedBooks}
|
||||
onCancel={() => {
|
||||
setShowGroupingModal(false);
|
||||
setShowSelectModeActions(true);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
setShowGroupingModal(false);
|
||||
handleSetSelectMode(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showDeleteAlert && (
|
||||
<Alert
|
||||
title={_('Confirm Deletion')}
|
||||
message={_('Are you sure to delete the selected books?')}
|
||||
onClickCancel={() => setShowDeleteAlert(false)}
|
||||
onClickConfirm={confirmDelete}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
'fixed bottom-0 left-0 right-0 z-50 flex justify-center',
|
||||
'pb-[calc(env(safe-area-inset-bottom)+16px)]',
|
||||
)}
|
||||
>
|
||||
<Alert
|
||||
title={_('Confirm Deletion')}
|
||||
message={_('Are you sure to delete the selected books?')}
|
||||
onCancel={() => {
|
||||
setShowDeleteAlert(false);
|
||||
setShowSelectModeActions(true);
|
||||
}}
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import clsx from 'clsx';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { navigateToLibrary, navigateToReader } from '@/utils/nav';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useLongPress } from '@/hooks/useLongPress';
|
||||
import { Menu, MenuItem } from '@tauri-apps/api/menu';
|
||||
import { revealItemInDir } from '@tauri-apps/plugin-opener';
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
import { getFilename } from '@/utils/book';
|
||||
import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants';
|
||||
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
|
||||
import { Book, BookGroupType, BooksGroup } from '@/types/book';
|
||||
import BookItem from './BookItem';
|
||||
import GroupItem from './GroupItem';
|
||||
|
||||
export type BookshelfItem = Book | BooksGroup;
|
||||
|
||||
export const generateBookshelfItems = (books: Book[]): (Book | BooksGroup)[] => {
|
||||
const groups: BooksGroup[] = books.reduce((acc: BooksGroup[], book: Book) => {
|
||||
if (book.deletedAt) return acc;
|
||||
book.groupId = book.groupId || BOOK_UNGROUPED_ID;
|
||||
book.groupName = book.groupName || BOOK_UNGROUPED_NAME;
|
||||
const groupIndex = acc.findIndex((group) => group.id === book.groupId);
|
||||
const booksGroup = acc[acc.findIndex((group) => group.id === book.groupId)];
|
||||
if (booksGroup) {
|
||||
booksGroup.books.push(book);
|
||||
booksGroup.updatedAt = Math.max(acc[groupIndex]!.updatedAt, book.updatedAt);
|
||||
} else {
|
||||
acc.push({
|
||||
id: book.groupId,
|
||||
name: book.groupName,
|
||||
books: [book],
|
||||
updatedAt: book.updatedAt,
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
groups.forEach((group) => {
|
||||
group.books.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
});
|
||||
const ungroupedBooks: Book[] =
|
||||
groups.find((group) => group.name === BOOK_UNGROUPED_NAME)?.books || [];
|
||||
const groupedBooks: BooksGroup[] = groups.filter((group) => group.name !== BOOK_UNGROUPED_NAME);
|
||||
return [...ungroupedBooks, ...groupedBooks].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
};
|
||||
|
||||
export const generateGroupsList = (items: Book[]): BookGroupType[] => {
|
||||
return items
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
.reduce((acc: BookGroupType[], item: Book) => {
|
||||
if (item.deletedAt) return acc;
|
||||
if (
|
||||
item.groupId &&
|
||||
item.groupName &&
|
||||
item.groupId !== BOOK_UNGROUPED_ID &&
|
||||
item.groupName !== BOOK_UNGROUPED_NAME &&
|
||||
!acc.find((group) => group.id === item.groupId)
|
||||
) {
|
||||
acc.push({ id: item.groupId, name: item.groupName });
|
||||
}
|
||||
return acc;
|
||||
}, []) as BookGroupType[];
|
||||
};
|
||||
|
||||
interface BookshelfItemProps {
|
||||
item: BookshelfItem;
|
||||
isSelectMode: boolean;
|
||||
selectedBooks: string[];
|
||||
transferProgress: number | null;
|
||||
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
toggleSelection: (hash: string) => void;
|
||||
handleBookUpload: (book: Book) => void;
|
||||
handleBookDownload: (book: Book) => void;
|
||||
handleBookDelete: (book: Book) => void;
|
||||
handleSetSelectMode: (selectMode: boolean) => void;
|
||||
handleShowDetailsBook: (book: Book) => void;
|
||||
}
|
||||
|
||||
const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
item,
|
||||
isSelectMode,
|
||||
selectedBooks,
|
||||
transferProgress,
|
||||
setLoading,
|
||||
toggleSelection,
|
||||
handleBookUpload,
|
||||
handleBookDownload,
|
||||
handleBookDelete,
|
||||
handleSetSelectMode,
|
||||
handleShowDetailsBook,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const { updateBook } = useLibraryStore();
|
||||
|
||||
const showBookDetailsModal = async (book: Book) => {
|
||||
if (await makeBookAvailable(book)) {
|
||||
handleShowDetailsBook(book);
|
||||
}
|
||||
};
|
||||
|
||||
const makeBookAvailable = async (book: Book) => {
|
||||
if (book.uploadedAt && !book.downloadedAt) {
|
||||
let available = false;
|
||||
const loadingTimeout = setTimeout(() => setLoading(true), 200);
|
||||
try {
|
||||
await handleBookDownload(book);
|
||||
updateBook(envConfig, book);
|
||||
available = true;
|
||||
} finally {
|
||||
if (loadingTimeout) clearTimeout(loadingTimeout);
|
||||
setLoading(false);
|
||||
return available;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleBookClick = async (book: Book) => {
|
||||
if (isSelectMode) {
|
||||
toggleSelection(book.hash);
|
||||
} else {
|
||||
if (!(await makeBookAvailable(book))) return;
|
||||
navigateToReader(router, [book.hash]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGroupClick = (group: BooksGroup) => {
|
||||
if (isSelectMode) {
|
||||
toggleSelection(group.id);
|
||||
} else {
|
||||
navigateToLibrary(router, `group=${group.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const bookContextMenuHandler = async (book: Book) => {
|
||||
if (!appService?.hasContextMenu) return;
|
||||
const osPlatform = getOSPlatform();
|
||||
const fileRevealLabel =
|
||||
FILE_REVEAL_LABELS[osPlatform as FILE_REVEAL_PLATFORMS] || FILE_REVEAL_LABELS.default;
|
||||
const selectBookMenuItem = await MenuItem.new({
|
||||
text: selectedBooks.includes(book.hash) ? _('Deselect Book') : _('Select Book'),
|
||||
action: async () => {
|
||||
if (!isSelectMode) handleSetSelectMode(true);
|
||||
toggleSelection(book.hash);
|
||||
},
|
||||
});
|
||||
const showBookInFinderMenuItem = await MenuItem.new({
|
||||
text: _(fileRevealLabel),
|
||||
action: async () => {
|
||||
const folder = `${settings.localBooksDir}/${getFilename(book)}`;
|
||||
revealItemInDir(folder);
|
||||
},
|
||||
});
|
||||
const showBookDetailsMenuItem = await MenuItem.new({
|
||||
text: _('Show Book Details'),
|
||||
action: async () => {
|
||||
showBookDetailsModal(book);
|
||||
},
|
||||
});
|
||||
const downloadBookMenuItem = await MenuItem.new({
|
||||
text: _('Download Book'),
|
||||
action: async () => {
|
||||
handleBookDownload(book);
|
||||
},
|
||||
});
|
||||
const uploadBookMenuItem = await MenuItem.new({
|
||||
text: _('Upload Book'),
|
||||
action: async () => {
|
||||
handleBookUpload(book);
|
||||
},
|
||||
});
|
||||
const deleteBookMenuItem = await MenuItem.new({
|
||||
text: _('Delete'),
|
||||
action: async () => {
|
||||
await handleBookDelete(book);
|
||||
},
|
||||
});
|
||||
const menu = await Menu.new();
|
||||
menu.append(selectBookMenuItem);
|
||||
menu.append(showBookDetailsMenuItem);
|
||||
menu.append(showBookInFinderMenuItem);
|
||||
if (book.uploadedAt && !book.downloadedAt) {
|
||||
menu.append(downloadBookMenuItem);
|
||||
}
|
||||
if (!book.uploadedAt && book.downloadedAt) {
|
||||
menu.append(uploadBookMenuItem);
|
||||
}
|
||||
menu.append(deleteBookMenuItem);
|
||||
menu.popup();
|
||||
};
|
||||
|
||||
const groupContextMenuHandler = async (group: BooksGroup) => {
|
||||
if (!appService?.hasContextMenu) return;
|
||||
const selectGroupMenuItem = await MenuItem.new({
|
||||
text: selectedBooks.includes(group.id) ? _('Deselect Group') : _('Select Group'),
|
||||
action: async () => {
|
||||
if (!isSelectMode) handleSetSelectMode(true);
|
||||
toggleSelection(group.id);
|
||||
},
|
||||
});
|
||||
const deleteGroupMenuItem = await MenuItem.new({
|
||||
text: _('Delete'),
|
||||
action: async () => {
|
||||
for (const book of group.books) {
|
||||
await handleBookDelete(book);
|
||||
}
|
||||
},
|
||||
});
|
||||
const menu = await Menu.new();
|
||||
menu.append(selectGroupMenuItem);
|
||||
menu.append(deleteGroupMenuItem);
|
||||
menu.popup();
|
||||
};
|
||||
|
||||
const { pressing, handlers } = useLongPress({
|
||||
onLongPress: () => {
|
||||
if (!isSelectMode) {
|
||||
handleSetSelectMode(true);
|
||||
}
|
||||
if ('format' in item) {
|
||||
toggleSelection((item as Book).hash);
|
||||
} else {
|
||||
toggleSelection((item as BooksGroup).id);
|
||||
}
|
||||
},
|
||||
onTap: () => {
|
||||
if ('format' in item) {
|
||||
handleBookClick(item as Book);
|
||||
} else {
|
||||
handleGroupClick(item as BooksGroup);
|
||||
}
|
||||
},
|
||||
onContextMenu: () => {
|
||||
if ('format' in item) {
|
||||
bookContextMenuHandler(item as Book);
|
||||
} else {
|
||||
groupContextMenuHandler(item as BooksGroup);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'sm:hover:bg-base-300/50 group flex h-full flex-col px-0 py-4 sm:px-4',
|
||||
pressing ? 'scale-95' : 'scale-100',
|
||||
)}
|
||||
style={{
|
||||
transition: 'transform 0.2s',
|
||||
}}
|
||||
{...handlers}
|
||||
>
|
||||
<div className='flex-grow'>
|
||||
{'format' in item ? (
|
||||
<BookItem
|
||||
book={item}
|
||||
isSelectMode={isSelectMode}
|
||||
selectedBooks={selectedBooks}
|
||||
transferProgress={transferProgress}
|
||||
handleBookUpload={handleBookUpload}
|
||||
handleBookDownload={handleBookDownload}
|
||||
showBookDetailsModal={showBookDetailsModal}
|
||||
/>
|
||||
) : (
|
||||
<GroupItem group={item} isSelectMode={isSelectMode} selectedBooks={selectedBooks} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookshelfItem;
|
||||
@@ -1,36 +1,50 @@
|
||||
import Image from 'next/image';
|
||||
import clsx from 'clsx';
|
||||
import { MdCheckCircle, MdCheckCircleOutline } from 'react-icons/md';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { BooksGroup } from '@/types/book';
|
||||
import { formatAuthors, formatTitle } from '@/utils/book';
|
||||
import ReadingProgress from './ReadingProgress';
|
||||
import BookCover from './BookCover';
|
||||
|
||||
interface GroupItemProps {
|
||||
group: BooksGroup;
|
||||
isSelectMode: boolean;
|
||||
selectedBooks: string[];
|
||||
}
|
||||
|
||||
const GroupItem: React.FC<GroupItemProps> = ({ group }) => {
|
||||
const GroupItem: React.FC<GroupItemProps> = ({ group, isSelectMode, selectedBooks }) => {
|
||||
const { appService } = useEnv();
|
||||
return (
|
||||
<div>
|
||||
{group.books.map((book) => (
|
||||
<div key={book.hash} className='card bg-base-100 w-full shadow-md'>
|
||||
<figure>
|
||||
<Image
|
||||
width={10}
|
||||
height={10}
|
||||
src={book.coverImageUrl!}
|
||||
alt={book.title || ''}
|
||||
className='h-48 w-full object-cover'
|
||||
/>
|
||||
</figure>
|
||||
<div className='card-body p-4'>
|
||||
<h3 className='card-title line-clamp-2 text-sm'>{formatTitle(book.title)}</h3>
|
||||
<p className='text-neutral-content line-clamp-1 text-xs'>
|
||||
{formatAuthors(book.author)}
|
||||
</p>
|
||||
<ReadingProgress book={book} />
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'group-item flex h-full flex-col',
|
||||
appService?.hasContextMenu ? 'cursor-pointer' : '',
|
||||
)}
|
||||
>
|
||||
<div className='bg-base-100 relative flex aspect-[28/41] items-center justify-center overflow-hidden p-2 shadow-md'>
|
||||
<div className='grid w-full grid-cols-2 grid-rows-2 gap-1 overflow-hidden'>
|
||||
{group.books.slice(0, 4).map((book) => (
|
||||
<div key={book.hash} className='relative aspect-[28/41] h-full w-full'>
|
||||
<BookCover book={book} isPreview />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<h2 className='mb-2 text-lg font-bold'>{group.name}</h2>
|
||||
{selectedBooks.includes(group.id) && (
|
||||
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
|
||||
)}
|
||||
{isSelectMode && (
|
||||
<div className='absolute bottom-1 right-1'>
|
||||
{selectedBooks.includes(group.id) ? (
|
||||
<MdCheckCircle className='fill-blue-500' />
|
||||
) : (
|
||||
<MdCheckCircleOutline className='fill-gray-300 drop-shadow-sm' />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='min-w-0 flex-1 pt-2'>
|
||||
<h4 className='block overflow-hidden text-ellipsis whitespace-nowrap text-xs font-semibold'>
|
||||
{group.name}
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { MdCheck } from 'react-icons/md';
|
||||
import { HiOutlineFolder, HiOutlineFolderAdd, HiOutlineFolderRemove } from 'react-icons/hi';
|
||||
|
||||
import { Book, BookGroupType } from '@/types/book';
|
||||
import { isMd5, md5Fingerprint } from '@/utils/md5';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants';
|
||||
import { generateGroupsList } from './BookshelfItem';
|
||||
|
||||
interface GroupingModalProps {
|
||||
libraryBooks: Book[];
|
||||
selectedBooks: string[];
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
const GroupingModal: React.FC<GroupingModalProps> = ({
|
||||
libraryBooks,
|
||||
selectedBooks,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { setLibrary } = useLibraryStore();
|
||||
const groupsList = generateGroupsList(libraryBooks);
|
||||
|
||||
const [showInput, setShowInput] = useState(false);
|
||||
const [editGroupName, setEditGroupName] = useState(_('Untitled Group'));
|
||||
const [selectedGroup, setSelectedGroup] = useState<BookGroupType | null>(null);
|
||||
const [newGroups, setNewGroups] = useState<BookGroupType[]>([]);
|
||||
const [allGroups, setAllGroups] = useState<BookGroupType[]>(groupsList);
|
||||
const editorRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const iconSize = useResponsiveSize(16);
|
||||
|
||||
const isSelectedBooksHasGroup =
|
||||
selectedBooks.some((hash) => !isMd5(hash)) ||
|
||||
selectedBooks
|
||||
.map((hash) => libraryBooks.find((book) => book.hash === hash)?.groupId)
|
||||
.some((group) => group && group !== BOOK_UNGROUPED_NAME);
|
||||
|
||||
const handleCreateGroup = () => {
|
||||
setShowInput(true);
|
||||
};
|
||||
|
||||
const handleRemoveFromGroup = () => {
|
||||
selectedBooks.forEach((id) => {
|
||||
for (const book of libraryBooks.filter((book) => book.hash === id || book.groupId === id)) {
|
||||
if (
|
||||
book &&
|
||||
book.groupId &&
|
||||
book.groupName &&
|
||||
book.groupId !== BOOK_UNGROUPED_ID &&
|
||||
book.groupName !== BOOK_UNGROUPED_NAME
|
||||
) {
|
||||
book.groupId = undefined;
|
||||
book.groupName = undefined;
|
||||
book.updatedAt = Date.now();
|
||||
}
|
||||
}
|
||||
});
|
||||
setLibrary(libraryBooks);
|
||||
appService?.saveLibraryBooks(libraryBooks);
|
||||
onConfirm();
|
||||
};
|
||||
|
||||
const handleConfirmCreateGroup = () => {
|
||||
const groupName = editGroupName.trim();
|
||||
if (groupName) {
|
||||
const newGroup = { id: md5Fingerprint(groupName), name: groupName };
|
||||
const existingGroupIndex = newGroups.findIndex((group) => group.name === groupName);
|
||||
if (existingGroupIndex > -1) {
|
||||
newGroups.splice(existingGroupIndex, 1);
|
||||
}
|
||||
newGroups.push(newGroup);
|
||||
setSelectedGroup(newGroup);
|
||||
setNewGroups(newGroups);
|
||||
for (const newGroup of newGroups) {
|
||||
const existingGroupIndex = groupsList.findIndex((group) => group.id === newGroup.id);
|
||||
if (existingGroupIndex > -1) {
|
||||
groupsList.splice(existingGroupIndex, 1);
|
||||
}
|
||||
groupsList.unshift(newGroup);
|
||||
}
|
||||
setAllGroups(groupsList);
|
||||
const untitledGroupPattern = new RegExp(`^${_('Untitled Group')}\\s*(\\d+)?$`);
|
||||
const untitledGroupNumbers = groupsList
|
||||
.map((group) => {
|
||||
const match = group.name.match(untitledGroupPattern);
|
||||
return match ? parseInt(match[1] || '0', 10) : null;
|
||||
})
|
||||
.filter((num) => num !== null);
|
||||
|
||||
const nextNumber =
|
||||
untitledGroupNumbers.length > 0 ? Math.max(...untitledGroupNumbers) + 1 : 1;
|
||||
setEditGroupName(`${_('Untitled Group')} ${nextNumber}`);
|
||||
setShowInput(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleSelectGroup = (group: BookGroupType) => {
|
||||
setSelectedGroup((prevGroup) => (prevGroup?.id === group.id ? null : group));
|
||||
};
|
||||
|
||||
const handleConfirmGrouping = () => {
|
||||
selectedBooks.forEach((id) => {
|
||||
for (const book of libraryBooks.filter((book) => book.hash === id || book.groupId === id)) {
|
||||
if (book && selectedGroup) {
|
||||
book.groupId = selectedGroup.id;
|
||||
book.groupName = selectedGroup.name;
|
||||
book.updatedAt = Date.now();
|
||||
}
|
||||
}
|
||||
});
|
||||
setLibrary(libraryBooks);
|
||||
appService?.saveLibraryBooks(libraryBooks);
|
||||
onConfirm();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.select();
|
||||
}
|
||||
}, [showInput]);
|
||||
|
||||
useEffect(() => {
|
||||
const groupIds = selectedBooks
|
||||
.map((id) => libraryBooks.find((book) => book.hash === id || book.groupId === id)?.groupId)
|
||||
.filter((groupId) => groupId);
|
||||
if (Array.from(new Set(groupIds)).length === 1) {
|
||||
setSelectedGroup(groupsList.find((group) => group.id === groupIds[0]) || null);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedBooks]);
|
||||
|
||||
return (
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50'>
|
||||
<div
|
||||
className={clsx(
|
||||
'modal-box bg-base-100 overflow-y-auto rounded-2xl shadow-xl',
|
||||
'max-h-[85%] w-[95%] min-w-64 max-w-[440px] p-6 sm:w-[70%]',
|
||||
)}
|
||||
>
|
||||
<h2 className='text-center text-lg font-bold'>{_('Group Books')}</h2>
|
||||
<div className={clsx('mt-4 grid grid-cols-1 gap-2 text-base md:grid-cols-2')}>
|
||||
{isSelectedBooksHasGroup && (
|
||||
<div
|
||||
onClick={handleRemoveFromGroup}
|
||||
role='button'
|
||||
className='flex items-center space-x-2 p-2 text-blue-500'
|
||||
>
|
||||
<HiOutlineFolderRemove size={iconSize} />
|
||||
<span>{_('Remove From Group')}</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
onClick={handleCreateGroup}
|
||||
role='button'
|
||||
className='flex items-center space-x-2 p-2 text-blue-500'
|
||||
>
|
||||
<HiOutlineFolderAdd size={iconSize} />
|
||||
<span>{_('Create New Group')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{showInput && (
|
||||
<div className='mt-4 flex items-center gap-2'>
|
||||
<input
|
||||
type='text'
|
||||
autoFocus
|
||||
ref={editorRef}
|
||||
value={editGroupName}
|
||||
onChange={(e) => setEditGroupName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleConfirmCreateGroup();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className='input input-ghost w-full border-0 px-2 text-base !outline-none sm:text-sm'
|
||||
/>
|
||||
<button
|
||||
className={clsx(
|
||||
'btn btn-ghost settings-content hover:bg-transparent',
|
||||
'flex h-[1.3em] min-h-[1.3em] items-end p-0',
|
||||
editorRef.current && editorRef.current.value ? '' : 'btn-disabled !bg-opacity-0',
|
||||
)}
|
||||
onClick={() => handleConfirmCreateGroup()}
|
||||
>
|
||||
<div className='pr-1 align-bottom text-base text-blue-500 sm:text-sm'>
|
||||
{_('Save')}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<ul className='groups-list mt-4 grid grid-cols-2 gap-2'>
|
||||
{allGroups.map((group, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className={clsx(
|
||||
'hover:bg-base-300 text-base-content flex w-full',
|
||||
'items-center justify-between rounded-md px-2 py-2',
|
||||
)}
|
||||
onClick={() => handleToggleSelectGroup(group)}
|
||||
>
|
||||
<div className='flex min-w-0 items-center'>
|
||||
<span style={{ minWidth: `${iconSize}px` }}>
|
||||
<HiOutlineFolder size={iconSize} />
|
||||
</span>
|
||||
<span
|
||||
className={clsx('mx-2 flex-1 truncate text-base sm:text-sm')}
|
||||
style={{ minWidth: 0 }}
|
||||
>
|
||||
{group.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className='text-neutral-content flex shrink-0 text-sm'>
|
||||
{selectedGroup && selectedGroup.id == group.id && (
|
||||
<MdCheck className='fill-blue-500' size={iconSize} />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</ul>
|
||||
<div className='mt-6 flex justify-end gap-x-8 p-2'>
|
||||
<button onClick={onCancel} className='flex items-center'>
|
||||
{_('Cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmGrouping}
|
||||
className={clsx(
|
||||
'flex items-center text-blue-500',
|
||||
!selectedGroup && 'btn-disabled opacity-50',
|
||||
)}
|
||||
>
|
||||
{_('Confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupingModal;
|
||||
@@ -1,13 +1,16 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { FaSearch } from 'react-icons/fa';
|
||||
import { PiPlus } from 'react-icons/pi';
|
||||
import { PiSelectionAllDuotone } from 'react-icons/pi';
|
||||
import { MdOutlineMenu } from 'react-icons/md';
|
||||
import { MdArrowBackIosNew } from 'react-icons/md';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { navigateToLibrary } from '@/utils/nav';
|
||||
import useTrafficLight from '@/hooks/useTrafficLight';
|
||||
import WindowButtons from '@/components/WindowButtons';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
@@ -26,10 +29,13 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
onToggleSelectMode,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { appService } = useEnv();
|
||||
const { isTrafficLightVisible } = useTrafficLight();
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const iconSize16 = useResponsiveSize(16);
|
||||
const iconSize20 = useResponsiveSize(20);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -43,32 +49,47 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
};
|
||||
}, [onToggleSelectMode]);
|
||||
|
||||
const windowButtonVisible = appService?.appPlatform !== 'web' && !isTrafficLightVisible;
|
||||
const windowButtonVisible = appService?.hasWindowBar && !isTrafficLightVisible;
|
||||
const isInGroupView = !!searchParams?.get('group');
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={headerRef}
|
||||
className={clsx(
|
||||
'titlebar z-10 h-11 w-full py-2 pr-6',
|
||||
'titlebar z-10 flex h-11 w-full items-center py-2 pr-4 sm:pr-6',
|
||||
appService?.hasSafeAreaInset && 'mt-[env(safe-area-inset-top)]',
|
||||
isTrafficLightVisible ? 'pl-16' : 'pl-2',
|
||||
isTrafficLightVisible ? 'pl-16' : 'pl-0 sm:pl-2',
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center justify-between space-x-6 sm:space-x-12'>
|
||||
<div className='flex w-full items-center justify-between space-x-6 sm:space-x-12'>
|
||||
<div className='exclude-title-bar-mousedown relative flex w-full items-center pl-4'>
|
||||
<span className='absolute left-8 text-gray-500'>
|
||||
<FaSearch className='h-4 w-4' />
|
||||
</span>
|
||||
<input
|
||||
type='text'
|
||||
placeholder={_('Search Books...')}
|
||||
spellCheck='false'
|
||||
className={clsx(
|
||||
'input rounded-badge bg-base-300/50 h-7 w-full pl-10 pr-10',
|
||||
'font-sans text-sm font-light',
|
||||
'border-none focus:outline-none focus:ring-0',
|
||||
)}
|
||||
/>
|
||||
{isInGroupView && (
|
||||
<button
|
||||
onClick={() => {
|
||||
navigateToLibrary(router);
|
||||
}}
|
||||
className='ml-[-6px] mr-4 flex h-7 min-h-7 w-7 items-center p-0'
|
||||
>
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Go Back')}>
|
||||
<MdArrowBackIosNew size={iconSize20} />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<div className='relative flex h-7 w-full items-center'>
|
||||
<span className='absolute left-3 text-gray-500'>
|
||||
<FaSearch className='h-4 w-4' />
|
||||
</span>
|
||||
<input
|
||||
type='text'
|
||||
placeholder={_('Search Books...')}
|
||||
spellCheck='false'
|
||||
className={clsx(
|
||||
'input rounded-badge bg-base-300/50 h-7 w-full pl-10 pr-10',
|
||||
'font-sans text-sm font-light',
|
||||
'border-none focus:outline-none focus:ring-0',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='absolute right-4 flex items-center space-x-2 text-gray-500 sm:space-x-4'>
|
||||
<span className='mx-2 h-6 w-[1px] bg-gray-400'></span>
|
||||
<Dropdown
|
||||
@@ -107,12 +128,14 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
>
|
||||
<SettingsMenu />
|
||||
</Dropdown>
|
||||
<WindowButtons
|
||||
headerRef={headerRef}
|
||||
showMinimize={windowButtonVisible}
|
||||
showMaximize={windowButtonVisible}
|
||||
showClose={windowButtonVisible}
|
||||
/>
|
||||
{appService?.hasWindowBar && (
|
||||
<WindowButtons
|
||||
headerRef={headerRef}
|
||||
showMinimize={windowButtonVisible}
|
||||
showMaximize={windowButtonVisible}
|
||||
showClose={windowButtonVisible}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,6 +31,7 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
const [quotas, setQuotas] = React.useState<QuotaType[]>([]);
|
||||
const [isAutoUpload, setIsAutoUpload] = useState(settings.autoUpload);
|
||||
const [isAutoCheckUpdates, setIsAutoCheckUpdates] = useState(settings.autoCheckUpdates);
|
||||
const [isScreenWakeLock, setIsScreenWakeLock] = useState(settings.screenWakeLock);
|
||||
|
||||
const showAboutReadest = () => {
|
||||
setAboutDialogVisible(true);
|
||||
@@ -77,6 +78,13 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
setIsAutoCheckUpdates(settings.autoCheckUpdates);
|
||||
};
|
||||
|
||||
const toggleScreenWakeLock = () => {
|
||||
settings.screenWakeLock = !settings.screenWakeLock;
|
||||
setSettings(settings);
|
||||
saveSettings(envConfig, settings);
|
||||
setIsScreenWakeLock(settings.screenWakeLock);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !token) return;
|
||||
const storagPlan = getStoragePlanData(token);
|
||||
@@ -146,6 +154,11 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
onClick={toggleAutoCheckUpdates}
|
||||
/>
|
||||
)}
|
||||
<MenuItem
|
||||
label={_('Keep Screen Awake')}
|
||||
icon={isScreenWakeLock ? <MdCheck className='text-base-content' /> : undefined}
|
||||
onClick={toggleScreenWakeLock}
|
||||
/>
|
||||
<hr className='border-base-200 my-1' />
|
||||
<MenuItem label={_('Reload Page')} onClick={handleReloadPage} />
|
||||
<hr className='border-base-200 my-1' />
|
||||
|
||||
@@ -74,6 +74,8 @@ export const useBooksSync = ({ onSyncStart, onSyncEnd }: UseBooksSyncProps) => {
|
||||
|
||||
const updateLibrary = async () => {
|
||||
if (!syncedBooks?.length) return;
|
||||
// Process old books first so that when we update the library the order is preserved
|
||||
syncedBooks.sort((a, b) => a.updatedAt - b.updatedAt);
|
||||
|
||||
const processOldBook = async (oldBook: Book) => {
|
||||
const matchingBook = syncedBooks.find((newBook) => newBook.hash === oldBook.hash);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import clsx from 'clsx';
|
||||
import * as React from 'react';
|
||||
import { useState, useRef, useEffect, Suspense } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Book } from '@/types/book';
|
||||
@@ -33,6 +33,7 @@ import Spinner from '@/components/Spinner';
|
||||
import LibraryHeader from './components/LibraryHeader';
|
||||
import Bookshelf from './components/Bookshelf';
|
||||
import BookDetailModal from '@/components/BookDetailModal';
|
||||
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
|
||||
|
||||
const LibraryPage = () => {
|
||||
const router = useRouter();
|
||||
@@ -65,6 +66,7 @@ const LibraryPage = () => {
|
||||
});
|
||||
|
||||
usePullToRefresh(containerRef, pullLibrary);
|
||||
useScreenWakeLock(settings.screenWakeLock);
|
||||
|
||||
useEffect(() => {
|
||||
updateAppTheme('base-200');
|
||||
@@ -251,7 +253,7 @@ const LibraryPage = () => {
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
if (err.message.includes('Not authenticated')) {
|
||||
if (err.message.includes('Not authenticated') && settings.keepLogin) {
|
||||
navigateToLogin(router);
|
||||
return;
|
||||
} else if (err.message.includes('Insufficient storage quota')) {
|
||||
@@ -321,8 +323,7 @@ const LibraryPage = () => {
|
||||
let files;
|
||||
|
||||
if (isTauriAppPlatform()) {
|
||||
const { type } = await import('@tauri-apps/plugin-os');
|
||||
if (['android', 'ios'].includes(type())) {
|
||||
if (appService?.isMobile) {
|
||||
files = (await selectFilesWeb()) as [File];
|
||||
} else {
|
||||
files = (await selectFilesTauri()) as [string];
|
||||
@@ -337,6 +338,10 @@ const LibraryPage = () => {
|
||||
setIsSelectMode(!isSelectMode);
|
||||
};
|
||||
|
||||
const handleSetSelectMode = (selectMode: boolean) => {
|
||||
setIsSelectMode(selectMode);
|
||||
};
|
||||
|
||||
const handleShowDetailsBook = (book: Book) => {
|
||||
setShowDetailsBook(book);
|
||||
};
|
||||
@@ -376,26 +381,25 @@ const LibraryPage = () => {
|
||||
</div>
|
||||
)}
|
||||
{libraryLoaded &&
|
||||
(libraryBooks.length > 0 ? (
|
||||
(libraryBooks.some((book) => !book.deletedAt) ? (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={clsx(
|
||||
'mt-12 flex-grow overflow-auto px-2',
|
||||
'mt-12 flex-grow overflow-auto px-4 sm:px-2',
|
||||
appService?.hasSafeAreaInset && 'mt-[calc(48px+env(safe-area-inset-top))]',
|
||||
)}
|
||||
>
|
||||
<Suspense>
|
||||
<Bookshelf
|
||||
libraryBooks={libraryBooks}
|
||||
isSelectMode={isSelectMode}
|
||||
handleImportBooks={handleImportBooks}
|
||||
handleBookUpload={handleBookUpload}
|
||||
handleBookDownload={handleBookDownload}
|
||||
handleBookDelete={handleBookDelete}
|
||||
handleShowDetailsBook={handleShowDetailsBook}
|
||||
booksTransferProgress={booksTransferProgress}
|
||||
/>
|
||||
</Suspense>
|
||||
<Bookshelf
|
||||
libraryBooks={libraryBooks}
|
||||
isSelectMode={isSelectMode}
|
||||
handleImportBooks={handleImportBooks}
|
||||
handleBookUpload={handleBookUpload}
|
||||
handleBookDownload={handleBookDownload}
|
||||
handleBookDelete={handleBookDelete}
|
||||
handleSetSelectMode={handleSetSelectMode}
|
||||
handleShowDetailsBook={handleShowDetailsBook}
|
||||
booksTransferProgress={booksTransferProgress}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className='hero h-screen items-center justify-center'>
|
||||
|
||||
@@ -4,8 +4,9 @@ import { BookDoc } from '@/libs/document';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useFoliateEvents } from '../hooks/useFoliateEvents';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import { getFootnoteStyles, getStyles } from '@/utils/style';
|
||||
import { getPopupPosition, getPosition, Position } from '@/utils/sel';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { FoliateView } from '@/types/view';
|
||||
import { FootnoteHandler } from 'foliate-js/footnotes.js';
|
||||
import Popup from '@/components/Popup';
|
||||
@@ -48,7 +49,9 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
|
||||
const backgroundColor = getComputedStyle(popupContainer).backgroundColor;
|
||||
popupTheme.bg = backgroundColor;
|
||||
}
|
||||
renderer.setStyles?.(getStyles(viewSettings, popupTheme));
|
||||
const mainStyles = getStyles(viewSettings, popupTheme);
|
||||
const footnoteStyles = getFootnoteStyles();
|
||||
renderer.setStyles?.(`${mainStyles}\n${footnoteStyles}`);
|
||||
};
|
||||
|
||||
const handleRender = (e: Event) => {
|
||||
@@ -104,10 +107,43 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
|
||||
setShowPopup(false);
|
||||
};
|
||||
|
||||
const handleFootnotePopupEvent = (event: CustomEvent) => {
|
||||
const { element, footnote } = event.detail;
|
||||
const gridFrame = document.querySelector(`#gridcell-${bookKey}`);
|
||||
if (!gridFrame) return;
|
||||
const rect = gridFrame.getBoundingClientRect();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const triangPos = getPosition(element, rect, viewSettings.vertical);
|
||||
const popupPos = getPopupPosition(
|
||||
triangPos,
|
||||
rect,
|
||||
viewSettings.vertical ? popupHeight : popupWidth,
|
||||
viewSettings.vertical ? popupWidth : popupHeight,
|
||||
popupPadding,
|
||||
);
|
||||
if (footnoteRef.current) {
|
||||
const elem = document.createElement('p');
|
||||
elem.textContent = footnote;
|
||||
elem.setAttribute('style', `padding: 16px; hanging-punctuation: allow-end last;`);
|
||||
footnoteRef.current.replaceChildren(elem);
|
||||
setShowPopup(true);
|
||||
setTrianglePosition(triangPos);
|
||||
setPopupPosition(popupPos);
|
||||
}
|
||||
};
|
||||
|
||||
useFoliateEvents(view, {
|
||||
onLinkClick: docLinkHandler,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
eventDispatcher.on('footnote-popup', handleFootnotePopupEvent);
|
||||
return () => {
|
||||
eventDispatcher.off('footnote-popup', handleFootnotePopupEvent);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (footnoteViewRef.current) {
|
||||
footnoteRef.current?.replaceChildren(footnoteViewRef.current);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useEnv } from '@/context/EnvContext';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
|
||||
import { AboutWindow } from '@/components/AboutWindow';
|
||||
import { Toast } from '@/components/Toast';
|
||||
import ReaderContent from './ReaderContent';
|
||||
@@ -19,6 +20,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
|
||||
const isInitiating = useRef(false);
|
||||
|
||||
const { updateAppTheme } = useTheme();
|
||||
useScreenWakeLock(settings.screenWakeLock);
|
||||
|
||||
useEffect(() => {
|
||||
updateAppTheme('base-100');
|
||||
|
||||
@@ -31,7 +31,7 @@ import DeepLPopup from './DeepLPopup';
|
||||
|
||||
const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig } = useEnv();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const { getConfig, saveConfig, getBookData, updateBooknotes } = useBookDataStore();
|
||||
const { getProgress, getView, getViewsById, getViewSettings } = useReaderStore();
|
||||
@@ -143,7 +143,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
|
||||
// Disable the default context menu on mobile devices,
|
||||
// although it should but doesn't work on iOS
|
||||
if (osPlatform === 'android' || osPlatform === 'ios') {
|
||||
if (appService?.isMobile) {
|
||||
detail.doc?.addEventListener('contextmenu', (event: Event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
@@ -3,7 +3,9 @@ import React, { useEffect, useRef } from 'react';
|
||||
import { useNotebookStore } from '@/store/notebookStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { TextSelection } from '@/utils/sel';
|
||||
import { md5Fingerprint } from '@/utils/md5';
|
||||
import { BookNote } from '@/types/book';
|
||||
import useShortcuts from '@/hooks/useShortcuts';
|
||||
|
||||
interface NoteEditorProps {
|
||||
onSave: (selection: TextSelection, note: string) => void;
|
||||
@@ -12,7 +14,14 @@ interface NoteEditorProps {
|
||||
|
||||
const NoteEditor: React.FC<NoteEditorProps> = ({ onSave, onEdit }) => {
|
||||
const _ = useTranslation();
|
||||
const { notebookNewAnnotation, notebookEditAnnotation } = useNotebookStore();
|
||||
const {
|
||||
notebookNewAnnotation,
|
||||
notebookEditAnnotation,
|
||||
setNotebookNewAnnotation,
|
||||
setNotebookEditAnnotation,
|
||||
saveNotebookAnnotationDraft,
|
||||
getNotebookAnnotationDraft,
|
||||
} = useNotebookStore();
|
||||
const editorRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [note, setNote] = React.useState('');
|
||||
|
||||
@@ -20,13 +29,30 @@ const NoteEditor: React.FC<NoteEditorProps> = ({ onSave, onEdit }) => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
}, [editorRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (notebookEditAnnotation) {
|
||||
setNote(notebookEditAnnotation.note);
|
||||
if (editorRef.current) {
|
||||
editorRef.current.value = notebookEditAnnotation.note;
|
||||
editorRef.current.focus();
|
||||
adjustHeight();
|
||||
}
|
||||
} else if (notebookNewAnnotation) {
|
||||
const noteText = getAnnotationText();
|
||||
if (noteText) {
|
||||
const draftNote = getNotebookAnnotationDraft(md5Fingerprint(noteText)) || '';
|
||||
setNote(draftNote);
|
||||
if (editorRef.current) {
|
||||
editorRef.current.value = draftNote;
|
||||
editorRef.current.focus();
|
||||
adjustHeight();
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [notebookEditAnnotation]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [notebookNewAnnotation, notebookEditAnnotation]);
|
||||
|
||||
const adjustHeight = () => {
|
||||
if (editorRef.current) {
|
||||
@@ -35,11 +61,24 @@ const NoteEditor: React.FC<NoteEditorProps> = ({ onSave, onEdit }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const getAnnotationText = () => {
|
||||
return notebookEditAnnotation?.text || notebookNewAnnotation?.text || '';
|
||||
};
|
||||
|
||||
const handleOnChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
adjustHeight();
|
||||
setNote(e.currentTarget.value);
|
||||
};
|
||||
|
||||
const handleOnBlur = () => {
|
||||
if (editorRef.current && editorRef.current.value) {
|
||||
const noteText = getAnnotationText();
|
||||
if (noteText) {
|
||||
saveNotebookAnnotationDraft(md5Fingerprint(noteText), editorRef.current.value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveNote = () => {
|
||||
if (editorRef.current && notebookNewAnnotation) {
|
||||
onSave(notebookNewAnnotation, editorRef.current.value);
|
||||
@@ -49,41 +88,60 @@ const NoteEditor: React.FC<NoteEditorProps> = ({ onSave, onEdit }) => {
|
||||
}
|
||||
};
|
||||
|
||||
useShortcuts({
|
||||
onSaveNote: () => {
|
||||
if (editorRef.current && editorRef.current.value) {
|
||||
handleSaveNote();
|
||||
}
|
||||
},
|
||||
onCloseNote: () => {
|
||||
if (notebookNewAnnotation) {
|
||||
setNotebookNewAnnotation(null);
|
||||
}
|
||||
if (notebookEditAnnotation) {
|
||||
setNotebookEditAnnotation(null);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='note-editor-container bg-base-100 mt-2 rounded-md p-2'>
|
||||
<div className='content note-editor-container bg-base-100 mt-2 rounded-md p-2'>
|
||||
<div className='flex w-full justify-between space-x-2'>
|
||||
<div className='settings-content relative w-full'>
|
||||
<div className='relative w-full'>
|
||||
<textarea
|
||||
className={clsx(
|
||||
'note-editor textarea textarea-ghost min-h-[1em] resize-none !outline-none',
|
||||
'inset-0 w-full rounded-none border-0 bg-transparent p-0 leading-normal',
|
||||
'text-sm',
|
||||
'inset-0 w-full rounded-none border-0 bg-transparent p-0',
|
||||
'content font-size-sm',
|
||||
)}
|
||||
ref={editorRef}
|
||||
value={note}
|
||||
rows={1}
|
||||
spellCheck={false}
|
||||
onChange={handleChange}
|
||||
onChange={handleOnChange}
|
||||
onBlur={handleOnBlur}
|
||||
placeholder={_('Add your notes here...')}
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-start pt-2'>
|
||||
<div className='mr-2 min-h-full self-stretch border-l-2 border-gray-300'></div>
|
||||
<div className='content font-size-sm line-clamp-3 py-2'>
|
||||
<span className='content font-size-xs inline text-gray-500'>{getAnnotationText()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex justify-end p-2'>
|
||||
<button
|
||||
className={clsx(
|
||||
'btn btn-ghost settings-content hover:bg-transparent',
|
||||
'content btn btn-ghost font-size-sm hover:bg-transparent',
|
||||
'flex h-[1.3em] min-h-[1.3em] items-end p-0',
|
||||
editorRef.current && editorRef.current.value ? '' : 'btn-disabled !bg-opacity-0',
|
||||
)}
|
||||
onClick={handleSaveNote}
|
||||
>
|
||||
<div className='pr-1 align-bottom text-xs text-blue-400'>{_('Save')}</div>
|
||||
<div className='font-size-sm pr-1 align-bottom text-blue-500'>{_('Save')}</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className='flex items-start pt-2'>
|
||||
<div className='mr-2 min-h-full self-stretch border-l-2 border-gray-300'></div>
|
||||
<div className='note-citation settings-content line-clamp-3 text-sm'>
|
||||
{notebookNewAnnotation?.text || notebookEditAnnotation?.text}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -77,6 +77,8 @@ const Notebook: React.FC = ({}) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setNotebookVisible(false);
|
||||
setNotebookNewAnnotation(null);
|
||||
setNotebookEditAnnotation(null);
|
||||
};
|
||||
|
||||
const handleDragMove = (e: MouseEvent) => {
|
||||
@@ -150,7 +152,10 @@ const Notebook: React.FC = ({}) => {
|
||||
)}
|
||||
<div
|
||||
className={clsx(
|
||||
'notebook-container bg-base-200 right-0 z-20 h-full min-w-60 select-none',
|
||||
'notebook-container bg-base-200 right-0 z-20 min-w-60 select-none',
|
||||
'font-sans text-base font-normal sm:text-sm',
|
||||
appService?.isIOSApp ? 'h-[100vh]' : 'h-full',
|
||||
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
|
||||
appService?.hasRoundedWindow && 'rounded-window-top-right rounded-window-bottom-right',
|
||||
!isNotebookPinned && 'shadow-2xl',
|
||||
)}
|
||||
@@ -178,7 +183,11 @@ const Notebook: React.FC = ({}) => {
|
||||
handleTogglePin={handleTogglePin}
|
||||
/>
|
||||
<div className='max-h-[calc(100vh-44px)] overflow-y-auto px-3'>
|
||||
<div>{excerptNotes.length > 0 && <p className='pt-1 text-sm'>{_('Excerpts')}</p>}</div>
|
||||
<div>
|
||||
{excerptNotes.length > 0 && (
|
||||
<p className='content font-size-base pt-1'>{_('Excerpts')}</p>
|
||||
)}
|
||||
</div>
|
||||
<ul className=''>
|
||||
{excerptNotes.map((item, index) => (
|
||||
<li key={`${index}-${item.id}`} className='my-2'>
|
||||
@@ -187,7 +196,7 @@ const Notebook: React.FC = ({}) => {
|
||||
className='collapse-arrow border-base-300 bg-base-100 collapse border'
|
||||
>
|
||||
<div
|
||||
className='collapse-title h-9 min-h-9 p-2 pr-8 text-sm font-medium'
|
||||
className='collapse-title font-size-sm h-9 min-h-9 p-2 pr-8 font-medium'
|
||||
style={
|
||||
{
|
||||
'--top-override': '1.2rem',
|
||||
@@ -197,11 +206,11 @@ const Notebook: React.FC = ({}) => {
|
||||
>
|
||||
<p className='line-clamp-1'>{item.text || `Excerpt ${index + 1}`}</p>
|
||||
</div>
|
||||
<div className='collapse-content select-text px-3 pb-0 text-xs'>
|
||||
<div className='collapse-content font-size-xs select-text px-3 pb-0'>
|
||||
<p className='hyphens-auto text-justify'>{item.text}</p>
|
||||
<div className='flex justify-end'>
|
||||
<div
|
||||
className='cursor-pointer align-bottom text-xs text-red-400'
|
||||
className='font-size-xs cursor-pointer align-bottom text-red-500 hover:text-red-600'
|
||||
onClick={handleEditNote.bind(null, item, true)}
|
||||
>
|
||||
{_('Delete')}
|
||||
@@ -214,13 +223,13 @@ const Notebook: React.FC = ({}) => {
|
||||
</ul>
|
||||
<div>
|
||||
{(notebookNewAnnotation || annotationNotes.length > 0) && (
|
||||
<p className='pt-1 text-sm'>{_('Notes')}</p>
|
||||
<p className='content font-size-base pt-1'>{_('Notes')}</p>
|
||||
)}
|
||||
</div>
|
||||
{(notebookNewAnnotation || notebookEditAnnotation) && (
|
||||
<NoteEditor onSave={handleSaveNote} onEdit={(item) => handleEditNote(item, false)} />
|
||||
)}
|
||||
<ul className=''>
|
||||
<ul>
|
||||
{annotationNotes.map((item, index) => (
|
||||
<BooknoteItem key={`${index}-${item.cfi}`} bookKey={sideBarBookKey} item={item} />
|
||||
))}
|
||||
|
||||
@@ -21,7 +21,7 @@ const DialogMenu: React.FC<DialogMenuProps> = ({ toggleDropdown }) => {
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
className='dropdown-content dropdown-right no-triangle border-base-200 z-20 mt-1 w-44 border shadow-2xl'
|
||||
className='dropdown-content dropdown-right no-triangle border-base-200 z-20 mt-1 border shadow-2xl'
|
||||
>
|
||||
<button
|
||||
className='hover:bg-base-200 text-base-content flex w-full items-center justify-between rounded-md p-2'
|
||||
@@ -37,7 +37,7 @@ const DialogMenu: React.FC<DialogMenuProps> = ({ toggleDropdown }) => {
|
||||
isFontLayoutSettingsGlobal ? _('Apply to All Books') : _('Apply to This Book')
|
||||
}
|
||||
>
|
||||
<span className='ml-2'>{_('Global Settings')}</span>
|
||||
<span className='ml-2 whitespace-nowrap'>{_('Global Settings')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -35,6 +35,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const [maxInlineSize, setMaxInlineSize] = useState(viewSettings.maxInlineSize!);
|
||||
const [maxBlockSize, setMaxBlockSize] = useState(viewSettings.maxBlockSize!);
|
||||
const [writingMode, setWritingMode] = useState(viewSettings.writingMode!);
|
||||
const [overrideLayout, setOverrideLayout] = useState(viewSettings.overrideLayout!);
|
||||
|
||||
useEffect(() => {
|
||||
viewSettings.paragraphMargin = paragraphMargin;
|
||||
@@ -189,6 +190,17 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [writingMode]);
|
||||
|
||||
useEffect(() => {
|
||||
viewSettings.overrideLayout = overrideLayout;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.overrideLayout = overrideLayout;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [overrideLayout]);
|
||||
|
||||
const langCode = getBookLangCode(bookData.bookDoc?.metadata?.language);
|
||||
const isCJKBook = langCode === 'zh' || langCode === 'ja' || langCode === 'ko';
|
||||
|
||||
@@ -297,6 +309,15 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
onChange={() => setHyphenation(!hyphenation)}
|
||||
/>
|
||||
</div>
|
||||
<div className='config-item config-item-bottom'>
|
||||
<span className=''>{_('Override Book Layout')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={overrideLayout}
|
||||
onChange={() => setOverrideLayout(!overrideLayout)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -135,7 +135,10 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
>
|
||||
<div className='relative p-1'>
|
||||
<textarea
|
||||
className='textarea textarea-ghost h-48 w-full border-0 p-3 text-base !outline-none sm:text-sm'
|
||||
className={clsx(
|
||||
'textarea textarea-ghost h-48 w-full border-0 p-3 text-base !outline-none sm:text-sm',
|
||||
'placeholder:text-base-content/70',
|
||||
)}
|
||||
placeholder={_('Enter your custom CSS here...')}
|
||||
spellCheck='false'
|
||||
value={draftStylesheet}
|
||||
|
||||
@@ -64,7 +64,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
|
||||
isOpen={true}
|
||||
onClose={handleClose}
|
||||
className='modal-open'
|
||||
boxClassName='sm:w-1/2 sm:min-w-[480px]'
|
||||
boxClassName='sm:w-1/2 sm:min-w-[480px] sm:h-[65%]'
|
||||
header={
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<button
|
||||
|
||||
@@ -65,7 +65,7 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
|
||||
<li
|
||||
ref={viewRef}
|
||||
className={clsx(
|
||||
'border-base-300 group relative my-2 cursor-pointer rounded-lg p-2 text-sm',
|
||||
'border-base-300 content group relative my-2 cursor-pointer rounded-lg p-2',
|
||||
isCurrent ? 'bg-base-300/85 hover:bg-base-300' : 'hover:bg-base-300/55 bg-base-100',
|
||||
'transition-all duration-300 ease-in-out',
|
||||
)}
|
||||
@@ -81,16 +81,16 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{item.note && <span className='line-clamp-3 font-normal'>{item.note}</span>}
|
||||
{item.note && <span className='content font-size-sm font-normal'>{item.note}</span>}
|
||||
<div className='flex items-start'>
|
||||
{item.note && (
|
||||
<div className='my-1 mr-2 min-h-full self-stretch border-l-2 border-gray-300'></div>
|
||||
)}
|
||||
<div className='line-clamp-3'>
|
||||
<div className={clsx('content font-size-sm line-clamp-3', item.note && 'py-2')}>
|
||||
<span
|
||||
className={clsx(
|
||||
'inline',
|
||||
item.note && 'text-xs text-gray-500',
|
||||
item.note && 'content font-size-xs text-gray-500',
|
||||
(item.style === 'underline' || item.style === 'squiggly') &&
|
||||
'underline decoration-2',
|
||||
item.style === 'highlight' && `bg-${item.color}-500 bg-opacity-40`,
|
||||
@@ -120,15 +120,16 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
|
||||
{item.note && (
|
||||
<button
|
||||
className={clsx(
|
||||
'btn btn-ghost settings-content hover:bg-transparent',
|
||||
'btn btn-ghost content settings-content hover:bg-transparent',
|
||||
'flex h-4 min-h-4 items-end p-0',
|
||||
)}
|
||||
onClick={editNote.bind(null, item)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'align-bottom text-blue-400',
|
||||
'align-bottom text-blue-500',
|
||||
'transition duration-300 ease-in-out',
|
||||
'content font-size-sm',
|
||||
'opacity-0 group-hover:opacity-100',
|
||||
'hover:text-blue-600',
|
||||
)}
|
||||
@@ -139,15 +140,16 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
|
||||
)}
|
||||
<button
|
||||
className={clsx(
|
||||
'btn btn-ghost settings-content hover:bg-transparent',
|
||||
'btn btn-ghost content settings-content hover:bg-transparent',
|
||||
'flex h-4 min-h-4 items-end p-0',
|
||||
)}
|
||||
onClick={deleteNote.bind(null, item)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'align-bottom text-red-400',
|
||||
'align-bottom text-red-500',
|
||||
'transition duration-300 ease-in-out',
|
||||
'content font-size-sm',
|
||||
'opacity-0 group-hover:opacity-100',
|
||||
'hover:text-red-600',
|
||||
)}
|
||||
|
||||
@@ -51,7 +51,7 @@ const BooknoteView: React.FC<{
|
||||
<ul role='tree' className='px-2'>
|
||||
{sortedGroups.map((group) => (
|
||||
<li key={group.href} className='p-2'>
|
||||
<h3 className='line-clamp-1 font-normal'>{group.label}</h3>
|
||||
<h3 className='content font-size-base line-clamp-1 font-normal'>{group.label}</h3>
|
||||
<ul>
|
||||
{group.booknotes.map((item, index) => (
|
||||
<BooknoteItem key={`${index}-${item.cfi}`} bookKey={bookKey} item={item} />
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { findParentPath } from '@/utils/toc';
|
||||
import { getContentMd5 } from '@/utils/misc';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { BookProgress } from '@/types/book';
|
||||
|
||||
const createExpanderIcon = (isExpanded: boolean) => {
|
||||
return (
|
||||
@@ -119,8 +120,7 @@ const TOCView: React.FC<{
|
||||
setExpandedItems(parentPath.filter(Boolean) as string[]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!progress || eventDispatcher.dispatchSync('tts-is-speaking')) return;
|
||||
const scrollToProgress = (progress: BookProgress) => {
|
||||
const { sectionHref: currentHref } = progress;
|
||||
const hrefMd5 = currentHref ? getContentMd5(currentHref) : '';
|
||||
const currentItem = viewRef.current?.querySelector(`[data-href="${hrefMd5}"]`);
|
||||
@@ -135,6 +135,28 @@ const TOCView: React.FC<{
|
||||
if (currentHref) {
|
||||
expandParents(toc, currentHref);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver(() => {
|
||||
const progress = getProgress(bookKey);
|
||||
if (progress && viewRef.current) {
|
||||
scrollToProgress(progress);
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
if (viewRef.current) {
|
||||
observer.observe(viewRef.current, { childList: true, subtree: true });
|
||||
}
|
||||
return () => observer.disconnect();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [viewRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!progress || eventDispatcher.dispatchSync('tts-is-speaking')) return;
|
||||
scrollToProgress(progress);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [toc, progress, sideBarBookKey]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -94,7 +94,7 @@ const TTSControl = () => {
|
||||
setShowIndicator(true);
|
||||
|
||||
try {
|
||||
if (getOSPlatform() === 'ios') {
|
||||
if (getOSPlatform() === 'ios' || appService?.isIOSApp) {
|
||||
unblockAudio();
|
||||
}
|
||||
const ttsController = new TTSController(view);
|
||||
|
||||
@@ -1,41 +1,60 @@
|
||||
import { useEffect } from 'react';
|
||||
import { FoliateView } from '@/types/view';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { tauriGetWindowLogicalPosition } from '@/utils/window';
|
||||
|
||||
export const useClickEvent = (
|
||||
bookKey: string,
|
||||
viewRef: React.MutableRefObject<FoliateView | null>,
|
||||
containerRef: React.RefObject<HTMLDivElement>,
|
||||
) => {
|
||||
const { appService } = useEnv();
|
||||
const { getViewSettings } = useReaderStore();
|
||||
const { hoveredBookKey, setHoveredBookKey } = useReaderStore();
|
||||
const handleTurnPage = (msg: MessageEvent | React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
const handleTurnPage = async (
|
||||
msg: MessageEvent | React.MouseEvent<HTMLDivElement, MouseEvent>,
|
||||
) => {
|
||||
if (msg instanceof MessageEvent) {
|
||||
if (msg.data && msg.data.bookKey === bookKey) {
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
if (msg.data.type === 'iframe-single-click') {
|
||||
if (viewSettings.disableClick!) {
|
||||
return;
|
||||
}
|
||||
const viewElement = containerRef.current;
|
||||
if (viewElement) {
|
||||
const rect = viewElement.getBoundingClientRect();
|
||||
const { screenX, screenY } = msg.data;
|
||||
const consumed = eventDispatcher.dispatchSync('iframe-single-click', {
|
||||
screenX,
|
||||
screenY,
|
||||
});
|
||||
const { screenX } = msg.data;
|
||||
const viewRect = viewElement.getBoundingClientRect();
|
||||
let windowStartX;
|
||||
// Currently for tauri APP the window.screenX is always 0
|
||||
if (isTauriAppPlatform()) {
|
||||
if (appService?.isMobile) {
|
||||
windowStartX = 0;
|
||||
} else {
|
||||
const windowPosition = await tauriGetWindowLogicalPosition();
|
||||
windowStartX = windowPosition.x;
|
||||
}
|
||||
} else {
|
||||
windowStartX = window.screenX;
|
||||
}
|
||||
const viewStartX = windowStartX + viewRect.left;
|
||||
const viewCenterX = viewStartX + viewRect.width / 2;
|
||||
const consumed = eventDispatcher.dispatchSync('iframe-single-click');
|
||||
if (!consumed) {
|
||||
const centerStartX = rect.left + rect.width * 0.375;
|
||||
const centerEndX = rect.left + rect.width * 0.625;
|
||||
const centerStartX = viewStartX + viewRect.width * 0.375;
|
||||
const centerEndX = viewStartX + viewRect.width * 0.625;
|
||||
if (screenX >= centerStartX && screenX <= centerEndX) {
|
||||
// toggle visibility of the header bar and the footer bar
|
||||
setHoveredBookKey(hoveredBookKey ? null : bookKey);
|
||||
} else if (screenX >= rect.left + rect.width / 2) {
|
||||
viewRef.current?.goRight();
|
||||
} else if (screenX < rect.left + rect.width / 2) {
|
||||
viewRef.current?.goLeft();
|
||||
} else {
|
||||
if (hoveredBookKey) {
|
||||
setHoveredBookKey(null);
|
||||
}
|
||||
if (!viewSettings.disableClick! && screenX >= viewCenterX) {
|
||||
viewRef.current?.goRight();
|
||||
} else if (!viewSettings.disableClick! && screenX < viewCenterX) {
|
||||
viewRef.current?.goLeft();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,6 +133,13 @@ export const useTouchEvent = (
|
||||
if (touch) {
|
||||
touchEnd = touch;
|
||||
}
|
||||
if (hoveredBookKey && touchEnd) {
|
||||
const deltaY = touchEnd.screenY - touchStart.screenY;
|
||||
const deltaX = touchEnd.screenX - touchStart.screenX;
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 10) {
|
||||
setHoveredBookKey(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onTouchEnd = (e: IframeTouchEvent) => {
|
||||
@@ -136,6 +162,10 @@ export const useTouchEvent = (
|
||||
) {
|
||||
// swipe up to toggle the header bar and the footer bar
|
||||
setHoveredBookKey(hoveredBookKey ? null : bookKey);
|
||||
} else {
|
||||
if (hoveredBookKey) {
|
||||
setHoveredBookKey(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
const doubleClickThreshold = 250;
|
||||
const longHoldThreshold = 500;
|
||||
import {
|
||||
DISABLE_DOUBLE_CLICK_ON_MOBILE,
|
||||
DOUBLE_CLICK_INTERVAL_THRESHOLD_MS,
|
||||
LONG_HOLD_THRESHOLD,
|
||||
} from '@/services/constants';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
|
||||
const doubleClickEnabled =
|
||||
!DISABLE_DOUBLE_CLICK_ON_MOBILE || !['android', 'ios'].includes(getOSPlatform());
|
||||
|
||||
let lastClickTime = 0;
|
||||
let longHoldTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
@@ -25,7 +34,8 @@ export const handleKeydown = (bookKey: string, event: KeyboardEvent) => {
|
||||
export const handleMousedown = (bookKey: string, event: MouseEvent) => {
|
||||
longHoldTimeout = setTimeout(() => {
|
||||
longHoldTimeout = null;
|
||||
}, longHoldThreshold);
|
||||
}, LONG_HOLD_THRESHOLD);
|
||||
|
||||
window.postMessage(
|
||||
{
|
||||
type: 'iframe-mousedown',
|
||||
@@ -86,7 +96,7 @@ export const handleWheel = (bookKey: string, event: WheelEvent) => {
|
||||
export const handleClick = (bookKey: string, event: MouseEvent) => {
|
||||
const now = Date.now();
|
||||
|
||||
if (now - lastClickTime < doubleClickThreshold) {
|
||||
if (doubleClickEnabled && now - lastClickTime < DOUBLE_CLICK_INTERVAL_THRESHOLD_MS) {
|
||||
lastClickTime = now;
|
||||
window.postMessage(
|
||||
{
|
||||
@@ -106,35 +116,51 @@ export const handleClick = (bookKey: string, event: MouseEvent) => {
|
||||
|
||||
lastClickTime = now;
|
||||
|
||||
setTimeout(() => {
|
||||
if (Date.now() - lastClickTime >= doubleClickThreshold) {
|
||||
let element: HTMLElement | null = event.target as HTMLElement;
|
||||
while (element) {
|
||||
if (['sup', 'a', 'audio', 'video'].includes(element.tagName.toLowerCase())) {
|
||||
return;
|
||||
}
|
||||
element = element.parentElement;
|
||||
}
|
||||
|
||||
if (!longHoldTimeout) {
|
||||
const postSingleClick = () => {
|
||||
let element: HTMLElement | null = event.target as HTMLElement;
|
||||
while (element) {
|
||||
if (['sup', 'a', 'audio', 'video'].includes(element.tagName.toLowerCase())) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.postMessage(
|
||||
{
|
||||
type: 'iframe-single-click',
|
||||
if (element.classList.contains('js_readerFooterNote')) {
|
||||
eventDispatcher.dispatch('footnote-popup', {
|
||||
bookKey,
|
||||
screenX: event.screenX,
|
||||
screenY: event.screenY,
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
offsetX: event.offsetX,
|
||||
offsetY: event.offsetY,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
element,
|
||||
footnote: element.getAttribute('data-wr-footernote') || '',
|
||||
});
|
||||
return;
|
||||
}
|
||||
element = element.parentElement;
|
||||
}
|
||||
}, doubleClickThreshold);
|
||||
|
||||
// if long hold is detected, we don't want to send single click event
|
||||
if (!longHoldTimeout) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.postMessage(
|
||||
{
|
||||
type: 'iframe-single-click',
|
||||
bookKey,
|
||||
screenX: event.screenX,
|
||||
screenY: event.screenY,
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
offsetX: event.offsetX,
|
||||
offsetY: event.offsetY,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
};
|
||||
if (doubleClickEnabled) {
|
||||
setTimeout(() => {
|
||||
if (Date.now() - lastClickTime >= DOUBLE_CLICK_INTERVAL_THRESHOLD_MS) {
|
||||
postSingleClick();
|
||||
}
|
||||
}, DOUBLE_CLICK_INTERVAL_THRESHOLD_MS);
|
||||
} else {
|
||||
postSingleClick();
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEv = (bookKey: string, event: TouchEvent, type: string) => {
|
||||
|
||||
@@ -33,7 +33,7 @@ export const AboutWindow = () => {
|
||||
isOpen={false}
|
||||
title={_('About Readest')}
|
||||
onClose={() => setAboutDialogVisible(false)}
|
||||
boxClassName='sm:!w-96'
|
||||
boxClassName='sm:!w-96 sm:h-auto'
|
||||
>
|
||||
<div className='about-content flex h-full flex-col items-center justify-center'>
|
||||
<div className='flex flex-col items-center px-8'>
|
||||
@@ -73,7 +73,7 @@ export const AboutWindow = () => {
|
||||
. You are free to use, modify, and distribute this software under the terms of the
|
||||
AGPL v3 license. Please see the license for more details.
|
||||
</p>
|
||||
<p className='text-neutral-content mt-2 text-xs'>
|
||||
<p className='text-neutral-content my-2 text-xs'>
|
||||
Source code is available at{' '}
|
||||
<a
|
||||
href='https://github.com/readest/readest'
|
||||
|
||||
@@ -1,49 +1,51 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
|
||||
const Alert: React.FC<{
|
||||
title: string;
|
||||
message: string;
|
||||
onClickCancel: () => void;
|
||||
onClickConfirm: () => void;
|
||||
}> = ({ title, message, onClickCancel, onClickConfirm }) => {
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}> = ({ title, message, onCancel, onConfirm }) => {
|
||||
const _ = useTranslation();
|
||||
return (
|
||||
<div
|
||||
role='alert'
|
||||
className={clsx(
|
||||
'alert fixed bottom-4 left-1/2 z-[100] flex -translate-x-1/2 transform items-center justify-between',
|
||||
'bg-base-300 rounded-lg p-4 shadow-2xl',
|
||||
'w-full max-w-[90vw] sm:max-w-[70vw] md:max-w-[50vw] lg:max-w-[40vw] xl:max-w-[40vw]',
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
fill='none'
|
||||
viewBox='0 0 24 24'
|
||||
className='stroke-info h-6 w-6 shrink-0'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='2'
|
||||
d='M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
|
||||
></path>
|
||||
</svg>
|
||||
<div className=''>
|
||||
<h3 className='font-bold'>{title}</h3>
|
||||
<div className='text-xs'>{message}</div>
|
||||
<div className={clsx('z-[100] flex justify-center px-4')}>
|
||||
<div
|
||||
role='alert'
|
||||
className={clsx(
|
||||
'alert flex items-center justify-between',
|
||||
'bg-base-300 rounded-lg border-none p-4 shadow-2xl',
|
||||
'w-full max-w-[90vw] sm:max-w-[70vw] md:max-w-[50vw] lg:max-w-[40vw] xl:max-w-[40vw]',
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
fill='none'
|
||||
viewBox='0 0 24 24'
|
||||
className='stroke-info h-6 w-6 shrink-0'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='2'
|
||||
d='M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
|
||||
></path>
|
||||
</svg>
|
||||
<div className=''>
|
||||
<h3 className='font-sm text-base'>{title}</h3>
|
||||
<div className='text-xs'>{message}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-wrap items-center justify-center gap-2'>
|
||||
<button className='btn btn-sm' onClick={onCancel}>
|
||||
{_('Cancel')}
|
||||
</button>
|
||||
<button className='btn btn-sm btn-warning' onClick={onConfirm}>
|
||||
{_('Confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex space-x-2'>
|
||||
<button className='btn btn-sm' onClick={onClickCancel}>
|
||||
{_('Cancel')}
|
||||
</button>
|
||||
<button className='btn btn-sm btn-warning' onClick={onClickConfirm}>
|
||||
{_('Confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -39,10 +39,13 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
|
||||
const loadingTimeout = setTimeout(() => setLoading(true), 300);
|
||||
const fetchBookDetails = async () => {
|
||||
const appService = await envConfig.getAppService();
|
||||
const details = await appService.fetchBookDetails(book, settings);
|
||||
setBookMeta(details);
|
||||
setLoading(false);
|
||||
if (loadingTimeout) clearTimeout(loadingTimeout);
|
||||
try {
|
||||
const details = await appService.fetchBookDetails(book, settings);
|
||||
setBookMeta(details);
|
||||
} finally {
|
||||
if (loadingTimeout) clearTimeout(loadingTimeout);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchBookDetails();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -80,7 +83,7 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
className='!bg-[rgba(0,0,0,0.5)]'
|
||||
boxClassName='sm:min-w-[480px]'
|
||||
boxClassName='sm:min-w-[480px] sm:h-auto'
|
||||
contentClassName='!px-6 !py-2'
|
||||
>
|
||||
<div className='flex w-full select-text items-center justify-center'>
|
||||
@@ -193,14 +196,21 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
|
||||
</div>
|
||||
</Dialog>
|
||||
{showDeleteAlert && (
|
||||
<Alert
|
||||
title={_('Confirm Deletion')}
|
||||
message={_('Are you sure to delete the selected books?')}
|
||||
onClickCancel={() => {
|
||||
setShowDeleteAlert(false);
|
||||
}}
|
||||
onClickConfirm={confirmDelete}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
'fixed bottom-0 left-0 right-0 z-50 flex justify-center',
|
||||
'pb-[calc(env(safe-area-inset-bottom)+16px)]',
|
||||
)}
|
||||
>
|
||||
<Alert
|
||||
title={_('Confirm Deletion')}
|
||||
message={_('Are you sure to delete the selected books?')}
|
||||
onCancel={() => {
|
||||
setShowDeleteAlert(false);
|
||||
}}
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -54,9 +54,9 @@ const Dialog: React.FC<DialogProps> = ({
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'modal-box settings-content flex h-full max-h-full w-full max-w-full flex-col rounded-none p-0 sm:rounded-2xl',
|
||||
'sm:h-[60%] sm:w-[65%] sm:max-w-[600px]',
|
||||
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
|
||||
'modal-box settings-content flex flex-col rounded-none p-0 sm:rounded-2xl',
|
||||
'h-full max-h-full w-full max-w-full sm:w-[65%] sm:max-w-[600px]',
|
||||
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)] sm:pt-0',
|
||||
boxClassName,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -43,7 +43,14 @@ const MenuItem: React.FC<MenuItemProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
{shortcut && (
|
||||
<span className='text-neutral-content hidden shrink-0 text-sm sm:flex'>{shortcut}</span>
|
||||
<kbd
|
||||
className={clsx(
|
||||
'border-base-300/40 bg-base-300/75 text-neutral-content hidden rounded-md border shadow-sm sm:flex',
|
||||
'shrink-0 px-1.5 py-0.5 text-xs font-medium',
|
||||
)}
|
||||
>
|
||||
{shortcut}
|
||||
</kbd>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -21,7 +21,7 @@ const Popup = ({
|
||||
}) => (
|
||||
<div>
|
||||
<div
|
||||
className={`triangle text-base-200 absolute z-40 ${triangleClassName}`}
|
||||
className={`triangle text-base-300 absolute z-40 ${triangleClassName}`}
|
||||
style={{
|
||||
left:
|
||||
trianglePosition?.dir === 'left'
|
||||
@@ -67,7 +67,7 @@ const Popup = ({
|
||||
/>
|
||||
<div
|
||||
id='popup-container'
|
||||
className={`bg-base-200 absolute z-30 rounded-lg font-sans shadow-xl ${className}`}
|
||||
className={`bg-base-300 absolute z-30 rounded-lg font-sans shadow-xl ${className}`}
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
|
||||
@@ -9,7 +10,10 @@ const Spinner: React.FC<{
|
||||
|
||||
return (
|
||||
<div
|
||||
className='absolute left-1/2 top-4 -translate-x-1/2 transform pt-16 text-center'
|
||||
className={clsx(
|
||||
'absolute left-1/2 -translate-x-1/2 transform text-center',
|
||||
'top-4 pt-[calc(env(safe-area-inset-top)+64px)]',
|
||||
)}
|
||||
role='status'
|
||||
>
|
||||
<span className='loading loading-dots loading-lg'></span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
|
||||
export type ToastType = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
@@ -12,9 +12,9 @@ export const Toast = () => {
|
||||
const toastDismissTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const toastClassMap = {
|
||||
info: 'toast-info toast-center toast-middle',
|
||||
success: 'toast-success toast-top toast-end pt-11',
|
||||
warning: 'toast-warning toast-top toast-end pt-11',
|
||||
error: 'toast-error toast-top toast-end pt-11',
|
||||
success: 'toast-success toast-top toast-end',
|
||||
warning: 'toast-warning toast-top toast-end',
|
||||
error: 'toast-error toast-top toast-end',
|
||||
};
|
||||
const alertClassMap = {
|
||||
info: 'alert-primary',
|
||||
@@ -32,10 +32,10 @@ export const Toast = () => {
|
||||
}, [toastMessage]);
|
||||
|
||||
const handleShowToast = async (event: CustomEvent) => {
|
||||
const { message, type = 'info', timeout = 5000, className = '' } = event.detail;
|
||||
const { message, type = 'info', timeout, className = '' } = event.detail;
|
||||
setToastMessage(message);
|
||||
toastType.current = type;
|
||||
toastTimeout.current = timeout;
|
||||
if (timeout) toastTimeout.current = timeout;
|
||||
messageClass.current = className;
|
||||
};
|
||||
|
||||
@@ -52,6 +52,8 @@ export const Toast = () => {
|
||||
className={clsx(
|
||||
'toast toast-center toast-middle w-auto max-w-screen-sm',
|
||||
toastClassMap[toastType.current],
|
||||
toastClassMap[toastType.current].includes('toast-top') &&
|
||||
'pt-[calc(44px+env(safe-area-inset-top))]',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@@ -62,7 +64,8 @@ export const Toast = () => {
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'max-h-[50vh] min-w-[30vw] overflow-scroll whitespace-normal break-words text-center',
|
||||
'max-h-[50vh] min-w-[30vw] max-w-80',
|
||||
'overflow-scroll whitespace-normal break-words text-center',
|
||||
messageClass.current,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface ShortcutConfig {
|
||||
onZoomIn: string[];
|
||||
onZoomOut: string[];
|
||||
onResetZoom: string[];
|
||||
onSaveNote: string[];
|
||||
onCloseNote: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_SHORTCUTS: ShortcutConfig = {
|
||||
@@ -36,6 +38,8 @@ const DEFAULT_SHORTCUTS: ShortcutConfig = {
|
||||
onZoomIn: ['ctrl+=', 'cmd+=', 'shift+='],
|
||||
onZoomOut: ['ctrl+-', 'cmd+-', 'shift+-'],
|
||||
onResetZoom: ['ctrl+0', 'cmd+0'],
|
||||
onSaveNote: ['ctrl+Enter'],
|
||||
onCloseNote: ['Escape']
|
||||
};
|
||||
|
||||
// Load shortcuts from localStorage or fallback to defaults
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface UseLongPressOptions {
|
||||
onTap?: () => void;
|
||||
onLongPress?: () => void;
|
||||
onContextMenu?: () => void;
|
||||
onCancel?: () => void;
|
||||
threshold?: number;
|
||||
moveThreshold?: number;
|
||||
}
|
||||
|
||||
interface UseLongPressResult {
|
||||
pressing: boolean;
|
||||
handlers: {
|
||||
onPointerDown: (e: React.PointerEvent) => void;
|
||||
onPointerUp: (e: React.PointerEvent) => void;
|
||||
onPointerMove: (e: React.PointerEvent) => void;
|
||||
onPointerCancel: (e: React.PointerEvent) => void;
|
||||
onPointerLeave: (e: React.PointerEvent) => void;
|
||||
onContextMenu: (e: React.MouseEvent) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export const useLongPress = ({
|
||||
onTap,
|
||||
onLongPress,
|
||||
onContextMenu,
|
||||
onCancel,
|
||||
threshold = 500,
|
||||
moveThreshold = 10,
|
||||
}: UseLongPressOptions): UseLongPressResult => {
|
||||
const [pressing, setPressing] = useState(false);
|
||||
const timerRef = useRef<NodeJS.Timeout>();
|
||||
const startPosRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const pointerId = useRef<number | null>(null);
|
||||
const isLongPressTriggered = useRef(false);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setPressing(false);
|
||||
isLongPressTriggered.current = false;
|
||||
startPosRef.current = null;
|
||||
pointerId.current = null;
|
||||
clearTimeout(timerRef.current);
|
||||
}, []);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
pointerId.current = e.pointerId;
|
||||
startPosRef.current = { x: e.clientX, y: e.clientY };
|
||||
isLongPressTriggered.current = false;
|
||||
setPressing(true);
|
||||
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (startPosRef.current) {
|
||||
isLongPressTriggered.current = true;
|
||||
onLongPress?.();
|
||||
setPressing(false);
|
||||
}
|
||||
}, threshold);
|
||||
},
|
||||
[onLongPress, threshold],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (e.pointerId !== pointerId.current || !startPosRef.current) return;
|
||||
|
||||
const deltaX = Math.abs(e.clientX - startPosRef.current.x);
|
||||
const deltaY = Math.abs(e.clientY - startPosRef.current.y);
|
||||
|
||||
if (deltaX > moveThreshold || deltaY > moveThreshold) {
|
||||
onCancel?.();
|
||||
reset();
|
||||
}
|
||||
},
|
||||
[moveThreshold, onCancel, reset],
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (e.pointerId !== pointerId.current) return;
|
||||
|
||||
if (!isLongPressTriggered.current && startPosRef.current) {
|
||||
const deltaX = Math.abs(e.clientX - startPosRef.current.x);
|
||||
const deltaY = Math.abs(e.clientY - startPosRef.current.y);
|
||||
|
||||
if (deltaX <= moveThreshold && deltaY <= moveThreshold) {
|
||||
onTap?.();
|
||||
}
|
||||
}
|
||||
|
||||
reset();
|
||||
},
|
||||
[onTap, moveThreshold, reset],
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (e.pointerId !== pointerId.current) return;
|
||||
onCancel?.();
|
||||
reset();
|
||||
},
|
||||
[onCancel, reset],
|
||||
);
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (onContextMenu) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu();
|
||||
}
|
||||
},
|
||||
[onContextMenu],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimeout(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
pressing,
|
||||
handlers: {
|
||||
onPointerDown: handlePointerDown,
|
||||
onPointerUp: handlePointerUp,
|
||||
onPointerMove: handlePointerMove,
|
||||
onPointerCancel: handleCancel,
|
||||
onPointerLeave: handleCancel,
|
||||
onContextMenu: handleContextMenu,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -20,6 +20,9 @@ export const usePullToRefresh = (ref: React.RefObject<HTMLDivElement>, onTrigger
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
if (el.scrollTop > 0) return;
|
||||
|
||||
const initialX = startEvent.touches[0]!.clientX;
|
||||
const initialY = startEvent.touches[0]!.clientY;
|
||||
|
||||
el.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
@@ -29,10 +32,12 @@ export const usePullToRefresh = (ref: React.RefObject<HTMLDivElement>, onTrigger
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const currentX = moveEvent.touches[0]!.clientX;
|
||||
const currentY = moveEvent.touches[0]!.clientY;
|
||||
const dx = currentX - initialX;
|
||||
const dy = currentY - initialY;
|
||||
if (dy < 0 || Math.abs(dx) * 2 > Math.abs(dy)) return;
|
||||
|
||||
if (dy < 0) return;
|
||||
const parentEl = el.parentNode as HTMLDivElement;
|
||||
if (dy > TRIGGER_THRESHOLD) {
|
||||
flipArrow(parentEl);
|
||||
@@ -42,7 +47,10 @@ export const usePullToRefresh = (ref: React.RefObject<HTMLDivElement>, onTrigger
|
||||
removePullIndicator(parentEl);
|
||||
}
|
||||
|
||||
el.style.transform = `translateY(${appr(dy)}px)`;
|
||||
const wrapper = el.querySelector('.transform-wrapper') as HTMLElement;
|
||||
if (wrapper) {
|
||||
wrapper.style.transform = `translate3d(0, ${appr(dy)}px, 0)`;
|
||||
}
|
||||
}
|
||||
|
||||
function addPullIndicator(el: HTMLDivElement) {
|
||||
@@ -82,7 +90,10 @@ export const usePullToRefresh = (ref: React.RefObject<HTMLDivElement>, onTrigger
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
el.style.transform = 'translateY(0)';
|
||||
const wrapper = el.querySelector('.transform-wrapper') as HTMLElement;
|
||||
if (wrapper) {
|
||||
wrapper.style.transform = 'translateY(0)';
|
||||
}
|
||||
removePullIndicator(el.parentNode as HTMLDivElement);
|
||||
|
||||
el.style.transition = 'transform 0.2s';
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export const useScreenWakeLock = (lock: boolean) => {
|
||||
const wakeLockRef = useRef<WakeLockSentinel | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const requestWakeLock = async () => {
|
||||
try {
|
||||
if ('wakeLock' in navigator) {
|
||||
wakeLockRef.current = await navigator.wakeLock.request('screen');
|
||||
|
||||
wakeLockRef.current.addEventListener('release', () => {
|
||||
wakeLockRef.current = null;
|
||||
});
|
||||
|
||||
console.log('Wake lock acquired');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to acquire wake lock:', err);
|
||||
}
|
||||
};
|
||||
|
||||
if (lock) {
|
||||
requestWakeLock();
|
||||
} else if (wakeLockRef.current) {
|
||||
wakeLockRef.current.release();
|
||||
wakeLockRef.current = null;
|
||||
console.log('Wake lock released');
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (wakeLockRef.current) {
|
||||
wakeLockRef.current.release();
|
||||
wakeLockRef.current = null;
|
||||
console.log('Wake lock released');
|
||||
}
|
||||
};
|
||||
}, [lock]);
|
||||
};
|
||||
@@ -63,8 +63,8 @@ const useShortcuts = (actions: KeyActionHandlers, dependencies: React.Dependency
|
||||
handler &&
|
||||
shortcutList?.some((shortcut) =>
|
||||
isShortcutMatch(shortcut, key, ctrlKey, altKey, metaKey, shiftKey),
|
||||
)
|
||||
) {
|
||||
)
|
||||
) {
|
||||
handler();
|
||||
return true;
|
||||
}
|
||||
@@ -76,16 +76,23 @@ const useShortcuts = (actions: KeyActionHandlers, dependencies: React.Dependency
|
||||
// Check if the focus is on an input, textarea, or contenteditable element
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
const isInteractiveElement =
|
||||
activeElement.tagName === 'INPUT' ||
|
||||
(activeElement.tagName === 'INPUT' ||
|
||||
activeElement.tagName === 'TEXTAREA' ||
|
||||
activeElement.isContentEditable;
|
||||
activeElement.isContentEditable);
|
||||
|
||||
if (isInteractiveElement) {
|
||||
const isNoteEditor = (activeElement.tagName === 'TEXTAREA' && activeElement.classList.contains('note-editor'))
|
||||
|
||||
if (isInteractiveElement && !isNoteEditor) {
|
||||
return; // Skip handling if the user is typing in an input, textarea, or contenteditable
|
||||
}
|
||||
|
||||
if (event instanceof KeyboardEvent) {
|
||||
const { key, ctrlKey, altKey, metaKey, shiftKey } = event;
|
||||
|
||||
if (isNoteEditor && !((key === "Enter" && ctrlKey) || (key == "Escape"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handled = processKeyEvent(key.toLowerCase(), ctrlKey, altKey, metaKey, shiftKey);
|
||||
if (handled) event.preventDefault();
|
||||
} else if (
|
||||
|
||||
@@ -125,7 +125,7 @@ export function useSync(bookKey?: string) {
|
||||
} catch (err: unknown) {
|
||||
console.error(err);
|
||||
if (err instanceof Error) {
|
||||
if (err.message.includes('Not authenticated')) {
|
||||
if (err.message.includes('Not authenticated') && settings.keepLogin) {
|
||||
navigateToLogin(router);
|
||||
}
|
||||
setSyncError(err.message || `Error pulling ${type}`);
|
||||
|
||||
@@ -29,20 +29,22 @@ import {
|
||||
CLOUD_BOOKS_SUBDIR,
|
||||
DEFAULT_MOBILE_VIEW_SETTINGS,
|
||||
DEFAULT_SYSTEM_SETTINGS,
|
||||
DEFAULT_CJK_VIEW_SETTINGS,
|
||||
} from './constants';
|
||||
import { isWebAppPlatform } from './environment';
|
||||
import { getOSPlatform, isValidURL } from '@/utils/misc';
|
||||
import { getOSPlatform, isCJKEnv, isValidURL } from '@/utils/misc';
|
||||
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
|
||||
import { downloadFile, uploadFile, deleteFile, createProgressHandler } from '@/libs/storage';
|
||||
import { ProgressHandler } from '@/utils/transfer';
|
||||
import { BOOK_FILE_NOT_FOUND_ERROR } from './errors';
|
||||
|
||||
export abstract class BaseAppService implements AppService {
|
||||
osPlatform: string = getOSPlatform();
|
||||
isMobile: boolean = ['android', 'ios'].includes(getOSPlatform());
|
||||
localBooksDir: string = '';
|
||||
abstract fs: FileSystem;
|
||||
abstract appPlatform: AppPlatform;
|
||||
abstract isAppDataSandbox: boolean;
|
||||
abstract isMobile: boolean;
|
||||
abstract isAndroidApp: boolean;
|
||||
abstract isIOSApp: boolean;
|
||||
abstract hasTrafficLight: boolean;
|
||||
@@ -83,6 +85,7 @@ export abstract class BaseAppService implements AppService {
|
||||
...DEFAULT_BOOK_STYLE,
|
||||
...DEFAULT_BOOK_FONT,
|
||||
...(this.isMobile ? DEFAULT_MOBILE_VIEW_SETTINGS : {}),
|
||||
...(isCJKEnv() ? DEFAULT_CJK_VIEW_SETTINGS : {}),
|
||||
...DEFAULT_VIEW_CONFIG,
|
||||
...DEFAULT_TTS_CONFIG,
|
||||
...settings.globalViewSettings,
|
||||
@@ -98,6 +101,7 @@ export abstract class BaseAppService implements AppService {
|
||||
...DEFAULT_BOOK_STYLE,
|
||||
...DEFAULT_BOOK_FONT,
|
||||
...(this.isMobile ? DEFAULT_MOBILE_VIEW_SETTINGS : {}),
|
||||
...(isCJKEnv() ? DEFAULT_CJK_VIEW_SETTINGS : {}),
|
||||
...DEFAULT_VIEW_CONFIG,
|
||||
...DEFAULT_TTS_CONFIG,
|
||||
},
|
||||
@@ -335,7 +339,7 @@ export abstract class BaseAppService implements AppService {
|
||||
} else if (book.url) {
|
||||
file = await new RemoteFile(book.url).open();
|
||||
} else {
|
||||
throw new Error('Book file not found');
|
||||
throw new Error(BOOK_FILE_NOT_FOUND_ERROR);
|
||||
}
|
||||
return { book, file, config: await this.loadBookConfig(book, settings) };
|
||||
}
|
||||
@@ -354,6 +358,10 @@ export abstract class BaseAppService implements AppService {
|
||||
}
|
||||
|
||||
async fetchBookDetails(book: Book, settings: SystemSettings) {
|
||||
const fp = getFilename(book);
|
||||
if (!(await this.fs.exists(fp, 'Books')) && book.uploadedAt) {
|
||||
await this.downloadBook(book);
|
||||
}
|
||||
const { file } = (await this.loadBookContent(book, settings)) as BookContent;
|
||||
const bookDoc = (await new DocumentLoader(file).open()).book as BookDoc;
|
||||
return bookDoc.metadata;
|
||||
|
||||
@@ -15,11 +15,14 @@ export const CLOUD_BOOKS_SUBDIR = 'Readest/Books';
|
||||
|
||||
export const SUPPORTED_FILE_EXTS = ['epub', 'mobi', 'azw', 'azw3', 'fb2', 'zip', 'cbz', 'pdf'];
|
||||
export const FILE_ACCEPT_FORMATS = SUPPORTED_FILE_EXTS.map((ext) => `.${ext}`).join(', ');
|
||||
export const BOOK_UNGROUPED_NAME = '';
|
||||
export const BOOK_UNGROUPED_ID = '';
|
||||
|
||||
export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
|
||||
keepLogin: false,
|
||||
autoUpload: true,
|
||||
autoCheckUpdates: true,
|
||||
screenWakeLock: true,
|
||||
|
||||
lastSyncedAtBooks: 0,
|
||||
lastSyncedAtConfigs: 0,
|
||||
@@ -77,11 +80,18 @@ export const DEFAULT_BOOK_STYLE: BookStyle = {
|
||||
invert: false,
|
||||
theme: 'light',
|
||||
overrideFont: false,
|
||||
overrideLayout: false,
|
||||
userStylesheet: '',
|
||||
};
|
||||
|
||||
export const DEFAULT_MOBILE_VIEW_SETTINGS: Partial<ViewSettings> = {
|
||||
fullJustification: false,
|
||||
animated: true,
|
||||
};
|
||||
|
||||
export const DEFAULT_CJK_VIEW_SETTINGS: Partial<ViewSettings> = {
|
||||
fullJustification: true,
|
||||
textIndent: 2,
|
||||
};
|
||||
|
||||
export const DEFAULT_VIEW_CONFIG: ViewConfig = {
|
||||
@@ -392,3 +402,7 @@ export const DEFAULT_STORAGE_QUOTA: UserStorageQuota = {
|
||||
plus: 2 * 1024 * 1024 * 1024,
|
||||
pro: 10 * 1024 * 1024 * 1024,
|
||||
};
|
||||
|
||||
export const DOUBLE_CLICK_INTERVAL_THRESHOLD_MS = 250;
|
||||
export const DISABLE_DOUBLE_CLICK_ON_MOBILE = true;
|
||||
export const LONG_HOLD_THRESHOLD = 500;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const BOOK_FILE_NOT_FOUND_ERROR = 'Book file not found';
|
||||
@@ -118,6 +118,7 @@ export class NativeAppService extends BaseAppService {
|
||||
fs = nativeFileSystem;
|
||||
appPlatform = 'tauri' as AppPlatform;
|
||||
isAppDataSandbox = ['android', 'ios'].includes(OS_TYPE);
|
||||
isMobile = ['android', 'ios'].includes(OS_TYPE);
|
||||
isAndroidApp = OS_TYPE === 'android';
|
||||
isIOSApp = OS_TYPE === 'ios';
|
||||
hasTrafficLight = OS_TYPE === 'macos';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Book } from '@/types/book';
|
||||
import { ToastType, FileSystem, BaseDir, AppPlatform } from '@/types/system';
|
||||
import { getCoverFilename } from '@/utils/book';
|
||||
import { isValidURL } from '@/utils/misc';
|
||||
import { getOSPlatform, isValidURL } from '@/utils/misc';
|
||||
|
||||
import { isPWA } from './environment';
|
||||
import { BaseAppService } from './appService';
|
||||
@@ -180,6 +180,7 @@ export class WebAppService extends BaseAppService {
|
||||
fs = indexedDBFileSystem;
|
||||
appPlatform = 'web' as AppPlatform;
|
||||
isAppDataSandbox = false;
|
||||
isMobile = ['android', 'ios'].includes(getOSPlatform());
|
||||
isAndroidApp = false;
|
||||
isIOSApp = false;
|
||||
hasTrafficLight = false;
|
||||
|
||||
@@ -8,6 +8,7 @@ interface NotebookState {
|
||||
isNotebookPinned: boolean;
|
||||
notebookNewAnnotation: TextSelection | null;
|
||||
notebookEditAnnotation: BookNote | null;
|
||||
notebookAnnotationDrafts: { [key: string]: string };
|
||||
toggleNotebook: () => void;
|
||||
toggleNotebookPin: () => void;
|
||||
setNotebookWidth: (width: string) => void;
|
||||
@@ -15,14 +16,17 @@ interface NotebookState {
|
||||
setNotebookPin: (pinned: boolean) => void;
|
||||
setNotebookNewAnnotation: (selection: TextSelection | null) => void;
|
||||
setNotebookEditAnnotation: (note: BookNote | null) => void;
|
||||
saveNotebookAnnotationDraft: (key: string, note: string) => void;
|
||||
getNotebookAnnotationDraft: (key: string) => string | undefined;
|
||||
}
|
||||
|
||||
export const useNotebookStore = create<NotebookState>((set) => ({
|
||||
export const useNotebookStore = create<NotebookState>((set, get) => ({
|
||||
notebookWidth: '',
|
||||
isNotebookVisible: false,
|
||||
isNotebookPinned: false,
|
||||
notebookNewAnnotation: null,
|
||||
notebookEditAnnotation: null,
|
||||
notebookAnnotationDrafts: {},
|
||||
setNotebookWidth: (width: string) => set({ notebookWidth: width }),
|
||||
toggleNotebook: () => set((state) => ({ isNotebookVisible: !state.isNotebookVisible })),
|
||||
toggleNotebookPin: () => set((state) => ({ isNotebookPinned: !state.isNotebookPinned })),
|
||||
@@ -31,4 +35,9 @@ export const useNotebookStore = create<NotebookState>((set) => ({
|
||||
setNotebookNewAnnotation: (selection: TextSelection | null) =>
|
||||
set({ notebookNewAnnotation: selection }),
|
||||
setNotebookEditAnnotation: (note: BookNote | null) => set({ notebookEditAnnotation: note }),
|
||||
saveNotebookAnnotationDraft: (key: string, note: string) =>
|
||||
set((state) => ({
|
||||
notebookAnnotationDrafts: { ...state.notebookAnnotationDrafts, [key]: note },
|
||||
})),
|
||||
getNotebookAnnotationDraft: (key: string) => get().notebookAnnotationDrafts[key],
|
||||
}));
|
||||
|
||||
@@ -230,3 +230,34 @@ foliate-view {
|
||||
.pull-indicator.flip svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.content {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.content {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 481px) and (max-width: 1024px) {
|
||||
.content {
|
||||
font-size: 18.4px;
|
||||
}
|
||||
}
|
||||
|
||||
.content.font-size-base {
|
||||
font-size: 1em !important;
|
||||
line-height: 1.5em !important;
|
||||
}
|
||||
|
||||
.content.font-size-sm {
|
||||
font-size: 0.875em !important;
|
||||
line-height: 1.25em !important;
|
||||
}
|
||||
|
||||
.content.font-size-xs {
|
||||
font-size: 0.75em !important;
|
||||
line-height: 1em !important;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@ export interface Book {
|
||||
format: BookFormat;
|
||||
title: string;
|
||||
author: string;
|
||||
group?: string;
|
||||
group?: string; // deprecated in favor of groupId and groupName
|
||||
groupId?: string;
|
||||
groupName?: string;
|
||||
tags?: string[];
|
||||
coverImageUrl?: string | null;
|
||||
|
||||
@@ -25,6 +27,11 @@ export interface Book {
|
||||
progress?: [number, number]; // Add progress field: [current, total]
|
||||
}
|
||||
|
||||
export interface BookGroupType {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface PageInfo {
|
||||
current: number;
|
||||
next?: number;
|
||||
@@ -73,6 +80,7 @@ export interface BookStyle {
|
||||
invert: boolean;
|
||||
theme: string;
|
||||
overrideFont: boolean;
|
||||
overrideLayout: boolean;
|
||||
userStylesheet: string;
|
||||
}
|
||||
|
||||
@@ -156,6 +164,7 @@ export interface BookDataRecord {
|
||||
}
|
||||
|
||||
export interface BooksGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
books: Book[];
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ export interface DBBook {
|
||||
format: string;
|
||||
title: string;
|
||||
author: string;
|
||||
group?: string;
|
||||
group_id?: string;
|
||||
group_name?: string;
|
||||
tags?: string[];
|
||||
progress?: [number, number];
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface SystemSettings {
|
||||
keepLogin: boolean;
|
||||
autoUpload: boolean;
|
||||
autoCheckUpdates: boolean;
|
||||
screenWakeLock: boolean;
|
||||
|
||||
lastSyncedAtBooks: number;
|
||||
lastSyncedAtConfigs: number;
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { md5 } from 'js-md5';
|
||||
|
||||
export function isMd5(value: string): boolean {
|
||||
return /^[0-9a-f]{32}$/.test(value);
|
||||
}
|
||||
|
||||
export function md5Fingerprint(value: string): string {
|
||||
return md5(value).slice(0, 7);
|
||||
}
|
||||
|
||||
export async function partialMD5(file: File): Promise<string> {
|
||||
const step = 1024;
|
||||
const size = 1024;
|
||||
|
||||
@@ -32,6 +32,8 @@ export const makeSafeFilename = (filename: string, replacement = '_') => {
|
||||
|
||||
export const getUserLang = () => navigator?.language.split('-')[0] || 'en';
|
||||
|
||||
export const isCJKEnv = () => ['zh', 'ja', 'ko'].includes(getUserLang());
|
||||
|
||||
export const getUserLocale = (lang: string): string | undefined => {
|
||||
const languages =
|
||||
navigator.languages && navigator.languages.length > 0
|
||||
@@ -42,6 +44,9 @@ export const getUserLocale = (lang: string): string | undefined => {
|
||||
return filteredLocales.length > 0 ? filteredLocales[0] : undefined;
|
||||
};
|
||||
|
||||
// Note that iPad may have a user agent string like a desktop browser
|
||||
// when possible please use appService.isIOSApp || getOSPlatform() === 'ios'
|
||||
// to check if the app is running on iOS
|
||||
export const getOSPlatform = () => {
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
|
||||
|
||||
@@ -25,8 +25,12 @@ export const navigateToLogin = (router: ReturnType<typeof useRouter>) => {
|
||||
router.push(`/auth?redirect=${encodeURIComponent(currentPath)}`);
|
||||
};
|
||||
|
||||
export const navigateToLibrary = (router: ReturnType<typeof useRouter>) => {
|
||||
router.push('/library');
|
||||
export const navigateToLibrary = (
|
||||
router: ReturnType<typeof useRouter>,
|
||||
queryParams?: string,
|
||||
navOptions?: { scroll?: boolean },
|
||||
) => {
|
||||
router.push(`/library${queryParams ? `?${queryParams}` : ''}`, navOptions);
|
||||
};
|
||||
|
||||
export const redirectToLibrary = () => {
|
||||
|
||||
@@ -94,6 +94,7 @@ const getAdditionalFontFaces = () => `
|
||||
`;
|
||||
|
||||
const getLayoutStyles = (
|
||||
overrideLayout: boolean,
|
||||
paragraphMargin: number,
|
||||
lineSpacing: number,
|
||||
wordSpacing: number,
|
||||
@@ -150,7 +151,6 @@ const getLayoutStyles = (
|
||||
html, body {
|
||||
color: ${fg};
|
||||
${writingMode === 'auto' ? '' : `writing-mode: ${writingMode};`}
|
||||
text-indent: ${textIndent}em;
|
||||
text-align: var(--default-text-align);
|
||||
background-color: var(--theme-bg-color, transparent);
|
||||
background: var(--background-set, none);
|
||||
@@ -167,10 +167,11 @@ const getLayoutStyles = (
|
||||
background-color: transparent !important;
|
||||
}
|
||||
p, li, blockquote, dd {
|
||||
margin: ${paragraphMargin}em 0 !important;
|
||||
line-height: ${lineSpacing} !important;
|
||||
word-spacing: ${wordSpacing}px !important;
|
||||
letter-spacing: ${letterSpacing}px !important;
|
||||
margin: ${paragraphMargin}em 0;
|
||||
line-height: ${lineSpacing} ${overrideLayout ? '!important' : ''};
|
||||
word-spacing: ${wordSpacing}px ${overrideLayout ? '!important' : ''};
|
||||
letter-spacing: ${letterSpacing}px ${overrideLayout ? '!important' : ''};
|
||||
text-indent: ${textIndent}em ${overrideLayout ? '!important' : ''};
|
||||
text-align: inherit;
|
||||
-webkit-hyphens: ${hyphenate ? 'auto' : 'manual'};
|
||||
hyphens: ${hyphenate ? 'auto' : 'manual'};
|
||||
@@ -200,6 +201,26 @@ const getLayoutStyles = (
|
||||
.duokan-footnote-item {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.calibre {
|
||||
color: unset;
|
||||
}
|
||||
`;
|
||||
|
||||
export const getFootnoteStyles = () => `
|
||||
.duokan-footnote-content,
|
||||
.duokan-footnote-item {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
ol {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
p, li, blockquote, dd {
|
||||
text-indent: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export interface ThemeCode {
|
||||
@@ -211,6 +232,7 @@ export interface ThemeCode {
|
||||
|
||||
export const getStyles = (viewSettings: ViewSettings, themeCode: ThemeCode) => {
|
||||
const layoutStyles = getLayoutStyles(
|
||||
viewSettings.overrideLayout!,
|
||||
viewSettings.paragraphMargin!,
|
||||
viewSettings.lineHeight!,
|
||||
viewSettings.wordSpacing!,
|
||||
|
||||
@@ -42,7 +42,8 @@ export const transformBookToDB = (book: unknown, userId: string): DBBook => {
|
||||
format,
|
||||
title,
|
||||
author,
|
||||
group,
|
||||
groupId,
|
||||
groupName,
|
||||
tags,
|
||||
progress,
|
||||
createdAt,
|
||||
@@ -57,7 +58,8 @@ export const transformBookToDB = (book: unknown, userId: string): DBBook => {
|
||||
format,
|
||||
title,
|
||||
author,
|
||||
group,
|
||||
group_id: groupId,
|
||||
group_name: groupName,
|
||||
tags: tags,
|
||||
progress: progress,
|
||||
created_at: new Date(createdAt ?? Date.now()).toISOString(),
|
||||
@@ -73,7 +75,8 @@ export const transformBookFromDB = (dbBook: DBBook): Book => {
|
||||
format,
|
||||
title,
|
||||
author,
|
||||
group,
|
||||
group_id,
|
||||
group_name,
|
||||
tags,
|
||||
progress,
|
||||
created_at,
|
||||
@@ -87,7 +90,8 @@ export const transformBookFromDB = (dbBook: DBBook): Book => {
|
||||
format: format as BookFormat,
|
||||
title,
|
||||
author,
|
||||
group,
|
||||
groupId: group_id,
|
||||
groupName: group_name,
|
||||
tags: tags,
|
||||
progress: progress,
|
||||
createdAt: new Date(created_at!).getTime(),
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
export const tauriGetWindowLogicalPosition = async () => {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
const currentWindow = getCurrentWindow();
|
||||
const factor = await currentWindow.scaleFactor();
|
||||
const physicalPos = await currentWindow.outerPosition();
|
||||
return { x: physicalPos.x / factor, y: physicalPos.y / factor };
|
||||
};
|
||||
|
||||
export const tauriHandleMinimize = async () => {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
getCurrentWindow().minimize();
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"main": ".open-next/worker.js",
|
||||
"name": "readest-web",
|
||||
"compatibility_date": "2025-02-04",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"assets": {
|
||||
"directory": ".open-next/assets",
|
||||
"binding": "ASSETS"
|
||||
},
|
||||
"kv_namespaces": []
|
||||
}
|
||||
+1
-1
Submodule packages/foliate-js updated: 10541e5e40...601d70dbe4
Generated
+123
-28
@@ -1387,6 +1387,10 @@ packages:
|
||||
resolution: {integrity: sha512-hhUZlBWYuh9t6ycAcN90XOyG76C1AzwxZZgaCVPMYpWqqk9uMFo7HGG5Zu2cEhCJn7DdOi5krBmlibWWWPgdsw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/core@3.1.2':
|
||||
resolution: {integrity: sha512-htwQXkbdF13uwwDevz9BEzL5ABK+1sJpVQXywwGSH973AVOvisHNfpcB8A8761G6XgHoS2kHPqc9DqHJ2gp+/Q==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/credential-provider-imds@4.0.1':
|
||||
resolution: {integrity: sha512-l/qdInaDq1Zpznpmev/+52QomsJNZ3JkTl5yrTl02V6NBgJOQ4LY0SFw/8zsMwj3tLe8vqiIuwF6nxaEwgf6mg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -1451,14 +1455,26 @@ packages:
|
||||
resolution: {integrity: sha512-Z9m67CXizGpj8CF/AW/7uHqYNh1VXXOn9Ap54fenWsCa0HnT4cJuE61zqG3cBkTZJDCy0wHJphilI41co/PE5g==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/middleware-endpoint@4.0.3':
|
||||
resolution: {integrity: sha512-YdbmWhQF5kIxZjWqPIgboVfi8i5XgiYMM7GGKFMTvBei4XjNQfNv8sukT50ITvgnWKKKpOtp0C0h7qixLgb77Q==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/middleware-retry@4.0.3':
|
||||
resolution: {integrity: sha512-TiKwwQTwUDeDtwWW8UWURTqu7s6F3wN2pmziLU215u7bqpVT9Mk2oEvURjpRLA+5XeQhM68R5BpAGzVtomsqgA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/middleware-retry@4.0.4':
|
||||
resolution: {integrity: sha512-wmxyUBGHaYUqul0wZiset4M39SMtDBOtUr2KpDuftKNN74Do9Y36Go6Eqzj9tL0mIPpr31ulB5UUtxcsCeGXsQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/middleware-serde@4.0.1':
|
||||
resolution: {integrity: sha512-Fh0E2SOF+S+P1+CsgKyiBInAt3o2b6Qk7YOp2W0Qx2XnfTdfMuSDKUEcnrtpxCzgKJnqXeLUZYqtThaP0VGqtA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/middleware-serde@4.0.2':
|
||||
resolution: {integrity: sha512-Sdr5lOagCn5tt+zKsaW+U2/iwr6bI9p08wOkCp6/eL6iMbgdtc2R5Ety66rf87PeohR0ExI84Txz9GYv5ou3iQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/middleware-stack@4.0.1':
|
||||
resolution: {integrity: sha512-dHwDmrtR/ln8UTHpaIavRSzeIk5+YZTBtLnKwDW3G2t6nAupCiQUvNzNoHBpik63fwUaJPtlnMzXbQrNFWssIA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -1503,6 +1519,10 @@ packages:
|
||||
resolution: {integrity: sha512-0yApeHWBqocelHGK22UivZyShNxFbDNrgREBllGh5Ws0D0rg/yId/CJfeoKKpjbfY2ju8j6WgDUGZHYQmINZ5w==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/smithy-client@4.1.3':
|
||||
resolution: {integrity: sha512-A2Hz85pu8BJJaYFdX8yb1yocqigyqBzn+OVaVgm+Kwi/DkN8vhN2kbDVEfADo6jXf5hPKquMLGA3UINA64UZ7A==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/types@4.1.0':
|
||||
resolution: {integrity: sha512-enhjdwp4D7CXmwLtD6zbcDMbo6/T6WtuuKCY49Xxc6OMOmUWlBEBDREsxxgV2LIdeQPW756+f97GzcgAwp3iLw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -1539,10 +1559,18 @@ packages:
|
||||
resolution: {integrity: sha512-7c5SF1fVK0EOs+2EOf72/qF199zwJflU1d02AevwKbAUPUZyE9RUZiyJxeUmhVxfKDWdUKaaVojNiaDQgnHL9g==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/util-defaults-mode-browser@4.0.4':
|
||||
resolution: {integrity: sha512-Ej1bV5sbrIfH++KnWxjjzFNq9nyP3RIUq2c9Iqq7SmMO/idUR24sqvKH2LUQFTSPy/K7G4sB2m8n7YYlEAfZaw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/util-defaults-mode-node@4.0.3':
|
||||
resolution: {integrity: sha512-CVnD42qYD3JKgDlImZ9+On+MqJHzq9uJgPbMdeBE8c2x8VJ2kf2R3XO/yVFx+30ts5lD/GlL0eFIShY3x9ROgQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/util-defaults-mode-node@4.0.4':
|
||||
resolution: {integrity: sha512-HE1I7gxa6yP7ZgXPCFfZSDmVmMtY7SHqzFF55gM/GPegzZKaQWZZ+nYn9C2Cc3JltCMyWe63VPR3tSFDEvuGjw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/util-endpoints@3.0.1':
|
||||
resolution: {integrity: sha512-zVdUENQpdtn9jbpD9SCFK4+aSiavRb9BxEtw9ZGUR1TYo6bBHbIoi7VkrFQ0/RwZlzx0wRBaRmPclj8iAoJCLA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -4708,26 +4736,26 @@ snapshots:
|
||||
'@aws-sdk/util-user-agent-browser': 3.734.0
|
||||
'@aws-sdk/util-user-agent-node': 3.734.0
|
||||
'@smithy/config-resolver': 4.0.1
|
||||
'@smithy/core': 3.1.1
|
||||
'@smithy/core': 3.1.2
|
||||
'@smithy/fetch-http-handler': 5.0.1
|
||||
'@smithy/hash-node': 4.0.1
|
||||
'@smithy/invalid-dependency': 4.0.1
|
||||
'@smithy/middleware-content-length': 4.0.1
|
||||
'@smithy/middleware-endpoint': 4.0.2
|
||||
'@smithy/middleware-retry': 4.0.3
|
||||
'@smithy/middleware-serde': 4.0.1
|
||||
'@smithy/middleware-endpoint': 4.0.3
|
||||
'@smithy/middleware-retry': 4.0.4
|
||||
'@smithy/middleware-serde': 4.0.2
|
||||
'@smithy/middleware-stack': 4.0.1
|
||||
'@smithy/node-config-provider': 4.0.1
|
||||
'@smithy/node-http-handler': 4.0.2
|
||||
'@smithy/protocol-http': 5.0.1
|
||||
'@smithy/smithy-client': 4.1.2
|
||||
'@smithy/smithy-client': 4.1.3
|
||||
'@smithy/types': 4.1.0
|
||||
'@smithy/url-parser': 4.0.1
|
||||
'@smithy/util-base64': 4.0.0
|
||||
'@smithy/util-body-length-browser': 4.0.0
|
||||
'@smithy/util-body-length-node': 4.0.0
|
||||
'@smithy/util-defaults-mode-browser': 4.0.3
|
||||
'@smithy/util-defaults-mode-node': 4.0.3
|
||||
'@smithy/util-defaults-mode-browser': 4.0.4
|
||||
'@smithy/util-defaults-mode-node': 4.0.4
|
||||
'@smithy/util-endpoints': 3.0.1
|
||||
'@smithy/util-middleware': 4.0.1
|
||||
'@smithy/util-retry': 4.0.1
|
||||
@@ -4739,12 +4767,12 @@ snapshots:
|
||||
'@aws-sdk/core@3.734.0':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.734.0
|
||||
'@smithy/core': 3.1.1
|
||||
'@smithy/core': 3.1.2
|
||||
'@smithy/node-config-provider': 4.0.1
|
||||
'@smithy/property-provider': 4.0.1
|
||||
'@smithy/protocol-http': 5.0.1
|
||||
'@smithy/signature-v4': 5.0.1
|
||||
'@smithy/smithy-client': 4.1.2
|
||||
'@smithy/smithy-client': 4.1.3
|
||||
'@smithy/types': 4.1.0
|
||||
'@smithy/util-middleware': 4.0.1
|
||||
fast-xml-parser: 4.4.1
|
||||
@@ -4766,7 +4794,7 @@ snapshots:
|
||||
'@smithy/node-http-handler': 4.0.2
|
||||
'@smithy/property-provider': 4.0.1
|
||||
'@smithy/protocol-http': 5.0.1
|
||||
'@smithy/smithy-client': 4.1.2
|
||||
'@smithy/smithy-client': 4.1.3
|
||||
'@smithy/types': 4.1.0
|
||||
'@smithy/util-stream': 4.0.2
|
||||
tslib: 2.8.1
|
||||
@@ -4903,11 +4931,11 @@ snapshots:
|
||||
'@aws-sdk/core': 3.734.0
|
||||
'@aws-sdk/types': 3.734.0
|
||||
'@aws-sdk/util-arn-parser': 3.723.0
|
||||
'@smithy/core': 3.1.1
|
||||
'@smithy/core': 3.1.2
|
||||
'@smithy/node-config-provider': 4.0.1
|
||||
'@smithy/protocol-http': 5.0.1
|
||||
'@smithy/signature-v4': 5.0.1
|
||||
'@smithy/smithy-client': 4.1.2
|
||||
'@smithy/smithy-client': 4.1.3
|
||||
'@smithy/types': 4.1.0
|
||||
'@smithy/util-config-provider': 4.0.0
|
||||
'@smithy/util-middleware': 4.0.1
|
||||
@@ -4926,7 +4954,7 @@ snapshots:
|
||||
'@aws-sdk/core': 3.734.0
|
||||
'@aws-sdk/types': 3.734.0
|
||||
'@aws-sdk/util-endpoints': 3.734.0
|
||||
'@smithy/core': 3.1.1
|
||||
'@smithy/core': 3.1.2
|
||||
'@smithy/protocol-http': 5.0.1
|
||||
'@smithy/types': 4.1.0
|
||||
tslib: 2.8.1
|
||||
@@ -4946,26 +4974,26 @@ snapshots:
|
||||
'@aws-sdk/util-user-agent-browser': 3.734.0
|
||||
'@aws-sdk/util-user-agent-node': 3.734.0
|
||||
'@smithy/config-resolver': 4.0.1
|
||||
'@smithy/core': 3.1.1
|
||||
'@smithy/core': 3.1.2
|
||||
'@smithy/fetch-http-handler': 5.0.1
|
||||
'@smithy/hash-node': 4.0.1
|
||||
'@smithy/invalid-dependency': 4.0.1
|
||||
'@smithy/middleware-content-length': 4.0.1
|
||||
'@smithy/middleware-endpoint': 4.0.2
|
||||
'@smithy/middleware-retry': 4.0.3
|
||||
'@smithy/middleware-serde': 4.0.1
|
||||
'@smithy/middleware-endpoint': 4.0.3
|
||||
'@smithy/middleware-retry': 4.0.4
|
||||
'@smithy/middleware-serde': 4.0.2
|
||||
'@smithy/middleware-stack': 4.0.1
|
||||
'@smithy/node-config-provider': 4.0.1
|
||||
'@smithy/node-http-handler': 4.0.2
|
||||
'@smithy/protocol-http': 5.0.1
|
||||
'@smithy/smithy-client': 4.1.2
|
||||
'@smithy/smithy-client': 4.1.3
|
||||
'@smithy/types': 4.1.0
|
||||
'@smithy/url-parser': 4.0.1
|
||||
'@smithy/util-base64': 4.0.0
|
||||
'@smithy/util-body-length-browser': 4.0.0
|
||||
'@smithy/util-body-length-node': 4.0.0
|
||||
'@smithy/util-defaults-mode-browser': 4.0.3
|
||||
'@smithy/util-defaults-mode-node': 4.0.3
|
||||
'@smithy/util-defaults-mode-browser': 4.0.4
|
||||
'@smithy/util-defaults-mode-node': 4.0.4
|
||||
'@smithy/util-endpoints': 3.0.1
|
||||
'@smithy/util-middleware': 4.0.1
|
||||
'@smithy/util-retry': 4.0.1
|
||||
@@ -6103,7 +6131,18 @@ snapshots:
|
||||
|
||||
'@smithy/core@3.1.1':
|
||||
dependencies:
|
||||
'@smithy/middleware-serde': 4.0.1
|
||||
'@smithy/middleware-serde': 4.0.2
|
||||
'@smithy/protocol-http': 5.0.1
|
||||
'@smithy/types': 4.1.0
|
||||
'@smithy/util-body-length-browser': 4.0.0
|
||||
'@smithy/util-middleware': 4.0.1
|
||||
'@smithy/util-stream': 4.0.2
|
||||
'@smithy/util-utf8': 4.0.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/core@3.1.2':
|
||||
dependencies:
|
||||
'@smithy/middleware-serde': 4.0.2
|
||||
'@smithy/protocol-http': 5.0.1
|
||||
'@smithy/types': 4.1.0
|
||||
'@smithy/util-body-length-browser': 4.0.0
|
||||
@@ -6205,8 +6244,19 @@ snapshots:
|
||||
|
||||
'@smithy/middleware-endpoint@4.0.2':
|
||||
dependencies:
|
||||
'@smithy/core': 3.1.1
|
||||
'@smithy/middleware-serde': 4.0.1
|
||||
'@smithy/core': 3.1.2
|
||||
'@smithy/middleware-serde': 4.0.2
|
||||
'@smithy/node-config-provider': 4.0.1
|
||||
'@smithy/shared-ini-file-loader': 4.0.1
|
||||
'@smithy/types': 4.1.0
|
||||
'@smithy/url-parser': 4.0.1
|
||||
'@smithy/util-middleware': 4.0.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/middleware-endpoint@4.0.3':
|
||||
dependencies:
|
||||
'@smithy/core': 3.1.2
|
||||
'@smithy/middleware-serde': 4.0.2
|
||||
'@smithy/node-config-provider': 4.0.1
|
||||
'@smithy/shared-ini-file-loader': 4.0.1
|
||||
'@smithy/types': 4.1.0
|
||||
@@ -6219,7 +6269,19 @@ snapshots:
|
||||
'@smithy/node-config-provider': 4.0.1
|
||||
'@smithy/protocol-http': 5.0.1
|
||||
'@smithy/service-error-classification': 4.0.1
|
||||
'@smithy/smithy-client': 4.1.2
|
||||
'@smithy/smithy-client': 4.1.3
|
||||
'@smithy/types': 4.1.0
|
||||
'@smithy/util-middleware': 4.0.1
|
||||
'@smithy/util-retry': 4.0.1
|
||||
tslib: 2.8.1
|
||||
uuid: 9.0.1
|
||||
|
||||
'@smithy/middleware-retry@4.0.4':
|
||||
dependencies:
|
||||
'@smithy/node-config-provider': 4.0.1
|
||||
'@smithy/protocol-http': 5.0.1
|
||||
'@smithy/service-error-classification': 4.0.1
|
||||
'@smithy/smithy-client': 4.1.3
|
||||
'@smithy/types': 4.1.0
|
||||
'@smithy/util-middleware': 4.0.1
|
||||
'@smithy/util-retry': 4.0.1
|
||||
@@ -6231,6 +6293,11 @@ snapshots:
|
||||
'@smithy/types': 4.1.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/middleware-serde@4.0.2':
|
||||
dependencies:
|
||||
'@smithy/types': 4.1.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/middleware-stack@4.0.1':
|
||||
dependencies:
|
||||
'@smithy/types': 4.1.0
|
||||
@@ -6294,8 +6361,18 @@ snapshots:
|
||||
|
||||
'@smithy/smithy-client@4.1.2':
|
||||
dependencies:
|
||||
'@smithy/core': 3.1.1
|
||||
'@smithy/middleware-endpoint': 4.0.2
|
||||
'@smithy/core': 3.1.2
|
||||
'@smithy/middleware-endpoint': 4.0.3
|
||||
'@smithy/middleware-stack': 4.0.1
|
||||
'@smithy/protocol-http': 5.0.1
|
||||
'@smithy/types': 4.1.0
|
||||
'@smithy/util-stream': 4.0.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/smithy-client@4.1.3':
|
||||
dependencies:
|
||||
'@smithy/core': 3.1.2
|
||||
'@smithy/middleware-endpoint': 4.0.3
|
||||
'@smithy/middleware-stack': 4.0.1
|
||||
'@smithy/protocol-http': 5.0.1
|
||||
'@smithy/types': 4.1.0
|
||||
@@ -6343,7 +6420,15 @@ snapshots:
|
||||
'@smithy/util-defaults-mode-browser@4.0.3':
|
||||
dependencies:
|
||||
'@smithy/property-provider': 4.0.1
|
||||
'@smithy/smithy-client': 4.1.2
|
||||
'@smithy/smithy-client': 4.1.3
|
||||
'@smithy/types': 4.1.0
|
||||
bowser: 2.11.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-defaults-mode-browser@4.0.4':
|
||||
dependencies:
|
||||
'@smithy/property-provider': 4.0.1
|
||||
'@smithy/smithy-client': 4.1.3
|
||||
'@smithy/types': 4.1.0
|
||||
bowser: 2.11.0
|
||||
tslib: 2.8.1
|
||||
@@ -6354,7 +6439,17 @@ snapshots:
|
||||
'@smithy/credential-provider-imds': 4.0.1
|
||||
'@smithy/node-config-provider': 4.0.1
|
||||
'@smithy/property-provider': 4.0.1
|
||||
'@smithy/smithy-client': 4.1.2
|
||||
'@smithy/smithy-client': 4.1.3
|
||||
'@smithy/types': 4.1.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-defaults-mode-node@4.0.4':
|
||||
dependencies:
|
||||
'@smithy/config-resolver': 4.0.1
|
||||
'@smithy/credential-provider-imds': 4.0.1
|
||||
'@smithy/node-config-provider': 4.0.1
|
||||
'@smithy/property-provider': 4.0.1
|
||||
'@smithy/smithy-client': 4.1.3
|
||||
'@smithy/types': 4.1.0
|
||||
tslib: 2.8.1
|
||||
|
||||
|
||||
Reference in New Issue
Block a user